strat-chatbot/adapters/inbound/api.py
Cal Corum c3218f70c4 refactor: hexagonal architecture with ports & adapters, DI, and test-first development
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>
2026-03-08 15:51:16 -05:00

166 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 (14000 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,
}