strat-chatbot/domain/models.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

93 lines
2.3 KiB
Python

"""Pure domain models — no framework imports (no FastAPI, SQLAlchemy, httpx, etc.)."""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional
@dataclass
class RuleDocument:
"""A rule from the knowledge base with metadata."""
rule_id: str
title: str
section: str
content: str
source_file: str
parent_rule: Optional[str] = None
page_ref: Optional[str] = None
def to_metadata(self) -> dict[str, str]:
"""Flat dict suitable for vector store metadata (no None values)."""
return {
"rule_id": self.rule_id,
"title": self.title,
"section": self.section,
"parent_rule": self.parent_rule or "",
"page_ref": self.page_ref or "",
"source_file": self.source_file,
}
@dataclass
class RuleSearchResult:
"""A rule returned from semantic search with a similarity score."""
rule_id: str
title: str
content: str
section: str
similarity: float
def __post_init__(self):
if not (0.0 <= self.similarity <= 1.0):
raise ValueError(
f"similarity must be between 0.0 and 1.0, got {self.similarity}"
)
@dataclass
class Conversation:
"""A chat session between a user and the bot."""
id: str
user_id: str
channel_id: str
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
last_activity: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass
class ChatMessage:
"""A single message in a conversation."""
id: str
conversation_id: str
content: str
is_user: bool
parent_id: Optional[str] = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass
class LLMResponse:
"""Structured response from the LLM."""
answer: str
cited_rules: list[str] = field(default_factory=list)
confidence: float = 0.5
needs_human: bool = False
@dataclass
class ChatResult:
"""Final result returned by ChatService to inbound adapters."""
response: str
conversation_id: str
message_id: str
cited_rules: list[str]
confidence: float
needs_human: bool
parent_message_id: Optional[str] = None