strat-chatbot/config/container.py
Cal Corum c3218f70c4 refactor: hexagonal architecture with ports & adapters, DI, and test-first development
Domain layer (zero framework imports):
- domain/models.py: pure dataclasses (RuleDocument, RuleSearchResult,
  Conversation, ChatMessage, LLMResponse, ChatResult)
- domain/ports.py: ABC interfaces (RuleRepository, LLMPort,
  ConversationStore, IssueTracker)
- domain/services.py: ChatService orchestrates Q&A flow using only ports

Outbound adapters (implement domain ports):
- adapters/outbound/openrouter.py: OpenRouterLLM with persistent httpx
  client, robust JSON parsing, regex citation fallback
- adapters/outbound/sqlite_convos.py: SQLiteConversationStore with
  async_sessionmaker, timezone-aware datetimes, cleanup support
- adapters/outbound/gitea_issues.py: GiteaIssueTracker with markdown
  injection protection (fenced code blocks)
- adapters/outbound/chroma_rules.py: ChromaRuleRepository with clamped
  similarity scores

Inbound adapter:
- adapters/inbound/api.py: thin FastAPI router with input validation
  (max_length constraints), proper HTTP status codes (503 for missing LLM)

Configuration & wiring:
- config/settings.py: Pydantic v2 SettingsConfigDict (no module-level singleton)
- config/container.py: create_app() factory with lifespan-managed DI
- main.py: minimal entry point

Test infrastructure (90 tests, all passing):
- tests/fakes/: in-memory implementations of all 4 ports
- tests/domain/: 26 tests for models and ChatService
- tests/adapters/: 64 tests for all adapters using fakes/mocks
- No real API calls, no model downloads, no disk I/O in fast tests

Also fixes: aiosqlite version constraint (>=0.19.0), adds hatch build
targets for new package layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:51:16 -05:00

164 lines
5.8 KiB
Python

"""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 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__)
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.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")
yield
# ------------------------------------------------------------------
# Shutdown — release HTTP connection pools
# ------------------------------------------------------------------
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