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