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

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: ...