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>
107 lines
3.4 KiB
Python
107 lines
3.4 KiB
Python
"""Domain services — core business logic with no framework dependencies.
|
|
|
|
ChatService orchestrates the Q&A flow using only domain ports.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from .models import ChatResult
|
|
from .ports import RuleRepository, LLMPort, ConversationStore, IssueTracker
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CONFIDENCE_THRESHOLD = 0.4
|
|
|
|
|
|
class ChatService:
|
|
"""Orchestrates the rules Q&A use case.
|
|
|
|
All external dependencies are injected via ports — this class has zero
|
|
knowledge of ChromaDB, OpenRouter, SQLite, or Gitea.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
rules: RuleRepository,
|
|
llm: LLMPort,
|
|
conversations: ConversationStore,
|
|
issues: Optional[IssueTracker] = None,
|
|
top_k_rules: int = 10,
|
|
):
|
|
self.rules = rules
|
|
self.llm = llm
|
|
self.conversations = conversations
|
|
self.issues = issues
|
|
self.top_k_rules = top_k_rules
|
|
|
|
async def answer_question(
|
|
self,
|
|
message: str,
|
|
user_id: str,
|
|
channel_id: str,
|
|
conversation_id: Optional[str] = None,
|
|
parent_message_id: Optional[str] = None,
|
|
) -> ChatResult:
|
|
"""Full Q&A flow: search rules → get history → call LLM → persist → maybe create issue."""
|
|
# Get or create conversation
|
|
conv_id = await self.conversations.get_or_create_conversation(
|
|
user_id=user_id,
|
|
channel_id=channel_id,
|
|
conversation_id=conversation_id,
|
|
)
|
|
|
|
# Save user message
|
|
user_msg_id = await self.conversations.add_message(
|
|
conversation_id=conv_id,
|
|
content=message,
|
|
is_user=True,
|
|
parent_id=parent_message_id,
|
|
)
|
|
|
|
# Search for relevant rules
|
|
search_results = self.rules.search(query=message, top_k=self.top_k_rules)
|
|
|
|
# Get conversation history for context
|
|
history = await self.conversations.get_conversation_history(conv_id, limit=10)
|
|
|
|
# Generate response from LLM
|
|
llm_response = await self.llm.generate_response(
|
|
question=message,
|
|
rules=search_results,
|
|
conversation_history=history,
|
|
)
|
|
|
|
# Save assistant message
|
|
assistant_msg_id = await self.conversations.add_message(
|
|
conversation_id=conv_id,
|
|
content=llm_response.answer,
|
|
is_user=False,
|
|
parent_id=user_msg_id,
|
|
)
|
|
|
|
# Create issue if confidence is low or human review needed
|
|
if self.issues and (
|
|
llm_response.needs_human or llm_response.confidence < CONFIDENCE_THRESHOLD
|
|
):
|
|
try:
|
|
await self.issues.create_unanswered_issue(
|
|
question=message,
|
|
user_id=user_id,
|
|
channel_id=channel_id,
|
|
attempted_rules=[r.rule_id for r in search_results],
|
|
conversation_id=conv_id,
|
|
)
|
|
except Exception:
|
|
logger.exception("Failed to create issue for unanswered question")
|
|
|
|
return ChatResult(
|
|
response=llm_response.answer,
|
|
conversation_id=conv_id,
|
|
message_id=assistant_msg_id,
|
|
parent_message_id=user_msg_id,
|
|
cited_rules=llm_response.cited_rules,
|
|
confidence=llm_response.confidence,
|
|
needs_human=llm_response.needs_human,
|
|
)
|