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>
164 lines
5.8 KiB
Python
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
|