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>
93 lines
2.3 KiB
Python
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
|