strat-chatbot/config/container.py
Cal Corum 2fe7163c89 fix: resolve MEDIUM-severity issues from code review
Prompt injection mitigation:
- Wrap user question in <user_question> XML tags in LLM prompt
- Add system prompt instruction to treat tagged content as untrusted

Docker security:
- Bind ChromaDB and API ports to localhost only (127.0.0.1)
- Remove redundant DB init command from api service (lifespan handles it)
- Remove deprecated version field and unused volume definitions
- Add API_SECRET env var to api and discord-bot services

Gitea labels fix:
- Remove string labels from API payload (Gitea expects integer IDs)
- Include label names as text in issue body instead

Conversation cleanup:
- Add periodic background task in lifespan (every 5 minutes)
- Cleans up conversations older than CONVERSATION_TTL (default 30 min)
- Graceful cancellation on shutdown

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

185 lines
6.5 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 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