"""Dependency wiring — the single place that constructs all adapters. create_app() is the composition root for the production runtime. Tests use the make_test_app() factory in tests/adapters/test_api.py instead (which wires fakes directly into app.state, bypassing this module entirely). Why a factory function instead of module-level globals ------------------------------------------------------- - Makes the lifespan scope explicit: adapters are created inside the lifespan context manager and torn down on exit. - Avoids the singleton-state problems that plague import-time construction: tests can call create_app() in isolation without shared state. - Follows the hexagonal architecture principle that wiring is infrastructure, not domain logic. """ import asyncio import logging from contextlib import asynccontextmanager from typing import AsyncIterator from fastapi import FastAPI from adapters.inbound.api import router from adapters.outbound.chroma_rules import ChromaRuleRepository from adapters.outbound.gitea_issues import GiteaIssueTracker from adapters.outbound.openrouter import OpenRouterLLM from adapters.outbound.sqlite_convos import SQLiteConversationStore from config.settings import Settings from domain.services import ChatService logger = logging.getLogger(__name__) async def _cleanup_loop(store, ttl: int, interval: int = 300) -> None: """Run conversation cleanup every *interval* seconds. Sleeps first so the initial burst of startup activity completes before the first deletion pass. Cancelled cleanly on application shutdown. """ while True: await asyncio.sleep(interval) try: await store.cleanup_old_conversations(ttl) except Exception: logger.exception("Conversation cleanup failed") def _make_lifespan(settings: Settings): """Return an async context manager that owns the adapter lifecycle. Accepts Settings so the lifespan closure captures a specific configuration instance rather than reading from module-level state. """ @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: # ------------------------------------------------------------------ # Startup # ------------------------------------------------------------------ logger.info("Initialising Strat-Chatbot...") print("Initialising Strat-Chatbot...") # Ensure required directories exist settings.data_dir.mkdir(parents=True, exist_ok=True) settings.chroma_dir.mkdir(parents=True, exist_ok=True) # Vector store (synchronous adapter — no async init needed) chroma_repo = ChromaRuleRepository( persist_dir=settings.chroma_dir, embedding_model=settings.embedding_model, ) rule_count = chroma_repo.count() print(f"ChromaDB ready at {settings.chroma_dir} ({rule_count} rules loaded)") # SQLite conversation store conv_store = SQLiteConversationStore(db_url=settings.db_url) await conv_store.init_db() print("SQLite conversation store initialised") # LLM adapter — only constructed when an API key is present llm: OpenRouterLLM | None = None if settings.openrouter_api_key: llm = OpenRouterLLM( api_key=settings.openrouter_api_key, model=settings.openrouter_model, ) print(f"OpenRouter LLM ready (model: {settings.openrouter_model})") else: logger.warning( "OPENROUTER_API_KEY not set — LLM adapter disabled. " "POST /chat will return HTTP 503." ) print( "WARNING: OPENROUTER_API_KEY not set — " "POST /chat will return HTTP 503." ) # Gitea issue tracker — optional gitea: GiteaIssueTracker | None = None if settings.gitea_token: gitea = GiteaIssueTracker( token=settings.gitea_token, owner=settings.gitea_owner, repo=settings.gitea_repo, base_url=settings.gitea_base_url, ) print( f"Gitea issue tracker ready " f"({settings.gitea_owner}/{settings.gitea_repo})" ) # Compose the service from its ports service = ChatService( rules=chroma_repo, llm=llm, # type: ignore[arg-type] # None is handled at the router level conversations=conv_store, issues=gitea, top_k_rules=settings.top_k_rules, ) # Expose via app.state for the router's Depends helpers app.state.chat_service = service app.state.rule_repository = chroma_repo app.state.api_secret = settings.api_secret app.state.config_snapshot = { "openrouter_model": settings.openrouter_model, "top_k_rules": settings.top_k_rules, "embedding_model": settings.embedding_model, } print("Strat-Chatbot ready!") logger.info("Strat-Chatbot ready") cleanup_task = asyncio.create_task( _cleanup_loop(conv_store, settings.conversation_ttl) ) yield # ------------------------------------------------------------------ # Shutdown — cancel background tasks, release HTTP connection pools # ------------------------------------------------------------------ cleanup_task.cancel() logger.info("Shutting down Strat-Chatbot...") print("Shutting down...") if llm is not None: await llm.close() logger.debug("OpenRouterLLM HTTP client closed") if gitea is not None: await gitea.close() logger.debug("GiteaIssueTracker HTTP client closed") logger.info("Shutdown complete") return lifespan def create_app(settings: Settings | None = None) -> FastAPI: """Construct and return the production FastAPI application. Args: settings: Optional pre-built Settings instance. When *None* (the common case), a new Settings() is constructed which reads from environment variables and the .env file automatically. Returns: A fully-wired FastAPI application ready to be served by uvicorn. """ if settings is None: settings = Settings() app = FastAPI( title="Strat-Chatbot", description="Strat-O-Matic rules Q&A API", version="0.1.0", lifespan=_make_lifespan(settings), ) app.include_router(router) return app