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>
80 lines
2.0 KiB
Python
80 lines
2.0 KiB
Python
"""Port interfaces — abstract contracts the domain needs from the outside world.
|
|
|
|
No framework imports allowed. Adapters implement these ABCs.
|
|
"""
|
|
|
|
from abc import ABC, abstractmethod
|
|
from typing import Optional
|
|
|
|
from .models import RuleDocument, RuleSearchResult, LLMResponse
|
|
|
|
|
|
class RuleRepository(ABC):
|
|
"""Port for storing and searching rules in a vector knowledge base."""
|
|
|
|
@abstractmethod
|
|
def add_documents(self, docs: list[RuleDocument]) -> None: ...
|
|
|
|
@abstractmethod
|
|
def search(
|
|
self, query: str, top_k: int = 10, section_filter: Optional[str] = None
|
|
) -> list[RuleSearchResult]: ...
|
|
|
|
@abstractmethod
|
|
def count(self) -> int: ...
|
|
|
|
@abstractmethod
|
|
def clear_all(self) -> None: ...
|
|
|
|
@abstractmethod
|
|
def get_stats(self) -> dict: ...
|
|
|
|
|
|
class LLMPort(ABC):
|
|
"""Port for generating answers from an LLM given rules context."""
|
|
|
|
@abstractmethod
|
|
async def generate_response(
|
|
self,
|
|
question: str,
|
|
rules: list[RuleSearchResult],
|
|
conversation_history: Optional[list[dict[str, str]]] = None,
|
|
) -> LLMResponse: ...
|
|
|
|
|
|
class ConversationStore(ABC):
|
|
"""Port for persisting conversation state."""
|
|
|
|
@abstractmethod
|
|
async def get_or_create_conversation(
|
|
self, user_id: str, channel_id: str, conversation_id: Optional[str] = None
|
|
) -> str: ...
|
|
|
|
@abstractmethod
|
|
async def add_message(
|
|
self,
|
|
conversation_id: str,
|
|
content: str,
|
|
is_user: bool,
|
|
parent_id: Optional[str] = None,
|
|
) -> str: ...
|
|
|
|
@abstractmethod
|
|
async def get_conversation_history(
|
|
self, conversation_id: str, limit: int = 10
|
|
) -> list[dict[str, str]]: ...
|
|
|
|
|
|
class IssueTracker(ABC):
|
|
"""Port for creating issues when questions can't be answered."""
|
|
|
|
@abstractmethod
|
|
async def create_unanswered_issue(
|
|
self,
|
|
question: str,
|
|
user_id: str,
|
|
channel_id: str,
|
|
attempted_rules: list[str],
|
|
conversation_id: str,
|
|
) -> str: ...
|