API authentication: - Add X-API-Secret shared-secret header validation on /chat and /stats - /health remains public for monitoring - Auth is a no-op when API_SECRET is empty (dev mode) Rate limiting: - Add per-user sliding-window rate limiter on /chat (10 req/60s default) - Returns 429 with clear message when exceeded - Self-cleaning memory (prunes expired entries on each check) Exception sanitization: - Discord bot no longer exposes raw exception text to users - Error embeds show generic "Something went wrong" message - Full exception details logged server-side with context - query_chat_api RuntimeError no longer includes response body Async correctness: - Wrap synchronous RuleRepository.search() in run_in_executor() to prevent blocking the event loop during SentenceTransformer inference - Port contract stays synchronous; service owns the async boundary Test coverage: 101 passed, 1 skipped (11 new tests for auth + rate limiting) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
165 lines
5.9 KiB
Python
165 lines
5.9 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.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")
|
|
|
|
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
|