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>
166 lines
5.4 KiB
Python
166 lines
5.4 KiB
Python
"""FastAPI inbound adapter — thin HTTP layer over ChatService.
|
||
|
||
This module contains only routing / serialisation logic. All business rules
|
||
live in domain.services.ChatService; all storage / LLM calls live in outbound
|
||
adapters. The router reads ChatService and RuleRepository from app.state so
|
||
that the container (config/container.py) remains the single wiring point and
|
||
tests can substitute fakes without monkey-patching.
|
||
"""
|
||
|
||
import logging
|
||
from typing import Annotated, Optional
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||
from pydantic import BaseModel, Field
|
||
|
||
from domain.ports import RuleRepository
|
||
from domain.services import ChatService
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Request / response Pydantic models
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class ChatRequest(BaseModel):
|
||
"""Payload accepted by POST /chat."""
|
||
|
||
message: str = Field(
|
||
...,
|
||
min_length=1,
|
||
max_length=4000,
|
||
description="The user's question (1–4000 characters).",
|
||
)
|
||
user_id: str = Field(
|
||
...,
|
||
min_length=1,
|
||
max_length=64,
|
||
description="Opaque caller identifier, e.g. Discord snowflake.",
|
||
)
|
||
channel_id: str = Field(
|
||
...,
|
||
min_length=1,
|
||
max_length=64,
|
||
description="Opaque channel identifier, e.g. Discord channel snowflake.",
|
||
)
|
||
conversation_id: Optional[str] = Field(
|
||
default=None,
|
||
description="Continue an existing conversation; omit to start a new one.",
|
||
)
|
||
parent_message_id: Optional[str] = Field(
|
||
default=None,
|
||
description="Thread parent message ID for Discord thread replies.",
|
||
)
|
||
|
||
|
||
class ChatResponse(BaseModel):
|
||
"""Payload returned by POST /chat."""
|
||
|
||
response: str
|
||
conversation_id: str
|
||
message_id: str
|
||
parent_message_id: Optional[str] = None
|
||
cited_rules: list[str]
|
||
confidence: float
|
||
needs_human: bool
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Dependency helpers — read from app.state set by the container
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _get_chat_service(request: Request) -> ChatService:
|
||
"""Extract the ChatService wired by the container from app.state."""
|
||
return request.app.state.chat_service
|
||
|
||
|
||
def _get_rule_repository(request: Request) -> RuleRepository:
|
||
"""Extract the RuleRepository wired by the container from app.state."""
|
||
return request.app.state.rule_repository
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Endpoints
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.post("/chat", response_model=ChatResponse)
|
||
async def chat(
|
||
body: ChatRequest,
|
||
service: Annotated[ChatService, Depends(_get_chat_service)],
|
||
rules: Annotated[RuleRepository, Depends(_get_rule_repository)],
|
||
) -> ChatResponse:
|
||
"""Handle a rules Q&A request.
|
||
|
||
Delegates entirely to ChatService.answer_question — no business logic here.
|
||
Returns HTTP 503 when the LLM adapter cannot be constructed (missing API key)
|
||
rather than producing a fake success response, so callers can distinguish
|
||
genuine answers from configuration errors.
|
||
"""
|
||
# The container raises at startup if the API key is required but absent;
|
||
# however if the service was created without a real LLM (e.g. missing key
|
||
# detected at request time), surface a clear service-unavailable rather than
|
||
# leaking a misleading 200 OK.
|
||
if not hasattr(service, "llm") or service.llm is None:
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail="LLM service is not available — check OPENROUTER_API_KEY configuration.",
|
||
)
|
||
|
||
try:
|
||
result = await service.answer_question(
|
||
message=body.message,
|
||
user_id=body.user_id,
|
||
channel_id=body.channel_id,
|
||
conversation_id=body.conversation_id,
|
||
parent_message_id=body.parent_message_id,
|
||
)
|
||
except Exception as exc:
|
||
logger.exception("Unhandled error in ChatService.answer_question")
|
||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||
|
||
return ChatResponse(
|
||
response=result.response,
|
||
conversation_id=result.conversation_id,
|
||
message_id=result.message_id,
|
||
parent_message_id=result.parent_message_id,
|
||
cited_rules=result.cited_rules,
|
||
confidence=result.confidence,
|
||
needs_human=result.needs_human,
|
||
)
|
||
|
||
|
||
@router.get("/health")
|
||
async def health(
|
||
rules: Annotated[RuleRepository, Depends(_get_rule_repository)],
|
||
) -> dict:
|
||
"""Return service health and a summary of the loaded knowledge base."""
|
||
stats = rules.get_stats()
|
||
return {
|
||
"status": "healthy",
|
||
"rules_count": stats.get("total_rules", 0),
|
||
"sections": stats.get("sections", {}),
|
||
}
|
||
|
||
|
||
@router.get("/stats")
|
||
async def stats(
|
||
rules: Annotated[RuleRepository, Depends(_get_rule_repository)],
|
||
request: Request,
|
||
) -> dict:
|
||
"""Return extended statistics about the knowledge base and configuration."""
|
||
kb_stats = rules.get_stats()
|
||
|
||
# Pull optional config snapshot from app.state (set by container).
|
||
config_snapshot: dict = getattr(request.app.state, "config_snapshot", {})
|
||
|
||
return {
|
||
"knowledge_base": kb_stats,
|
||
"config": config_snapshot,
|
||
}
|