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>
201 lines
5.9 KiB
Python
201 lines
5.9 KiB
Python
"""Tests for domain models — pure data structures with no framework dependencies."""
|
|
|
|
from datetime import datetime, timezone
|
|
from domain.models import (
|
|
RuleDocument,
|
|
RuleSearchResult,
|
|
Conversation,
|
|
ChatMessage,
|
|
LLMResponse,
|
|
ChatResult,
|
|
)
|
|
|
|
|
|
class TestRuleDocument:
|
|
"""RuleDocument holds rule content with metadata for the knowledge base."""
|
|
|
|
def test_create_with_required_fields(self):
|
|
doc = RuleDocument(
|
|
rule_id="5.2.1(b)",
|
|
title="Stolen Base Attempts",
|
|
section="Baserunning",
|
|
content="When a runner attempts to steal...",
|
|
source_file="data/rules/baserunning.md",
|
|
)
|
|
assert doc.rule_id == "5.2.1(b)"
|
|
assert doc.title == "Stolen Base Attempts"
|
|
assert doc.section == "Baserunning"
|
|
assert doc.parent_rule is None
|
|
assert doc.page_ref is None
|
|
|
|
def test_optional_fields(self):
|
|
doc = RuleDocument(
|
|
rule_id="5.2",
|
|
title="Baserunning Overview",
|
|
section="Baserunning",
|
|
content="Overview content",
|
|
source_file="rules.md",
|
|
parent_rule="5",
|
|
page_ref="32",
|
|
)
|
|
assert doc.parent_rule == "5"
|
|
assert doc.page_ref == "32"
|
|
|
|
def test_metadata_dict_for_vector_store(self):
|
|
"""to_metadata() returns a flat dict suitable for ChromaDB/vector store metadata."""
|
|
doc = RuleDocument(
|
|
rule_id="5.2.1(b)",
|
|
title="Stolen Base Attempts",
|
|
section="Baserunning",
|
|
content="content",
|
|
source_file="rules.md",
|
|
parent_rule="5.2",
|
|
page_ref="32",
|
|
)
|
|
meta = doc.to_metadata()
|
|
assert meta == {
|
|
"rule_id": "5.2.1(b)",
|
|
"title": "Stolen Base Attempts",
|
|
"section": "Baserunning",
|
|
"parent_rule": "5.2",
|
|
"page_ref": "32",
|
|
"source_file": "rules.md",
|
|
}
|
|
|
|
def test_metadata_dict_empty_optionals(self):
|
|
"""Optional fields should be empty strings in metadata (not None) for vector stores."""
|
|
doc = RuleDocument(
|
|
rule_id="1.0",
|
|
title="General",
|
|
section="General",
|
|
content="c",
|
|
source_file="f.md",
|
|
)
|
|
meta = doc.to_metadata()
|
|
assert meta["parent_rule"] == ""
|
|
assert meta["page_ref"] == ""
|
|
|
|
|
|
class TestRuleSearchResult:
|
|
"""RuleSearchResult is what comes back from a semantic search."""
|
|
|
|
def test_create(self):
|
|
result = RuleSearchResult(
|
|
rule_id="5.2.1(b)",
|
|
title="Stolen Base Attempts",
|
|
content="When a runner attempts...",
|
|
section="Baserunning",
|
|
similarity=0.85,
|
|
)
|
|
assert result.similarity == 0.85
|
|
|
|
def test_similarity_bounds(self):
|
|
"""Similarity must be between 0.0 and 1.0."""
|
|
import pytest
|
|
|
|
with pytest.raises(ValueError):
|
|
RuleSearchResult(
|
|
rule_id="x", title="t", content="c", section="s", similarity=-0.1
|
|
)
|
|
with pytest.raises(ValueError):
|
|
RuleSearchResult(
|
|
rule_id="x", title="t", content="c", section="s", similarity=1.1
|
|
)
|
|
|
|
|
|
class TestConversation:
|
|
"""Conversation tracks a chat session between a user and the bot."""
|
|
|
|
def test_create_with_defaults(self):
|
|
conv = Conversation(
|
|
id="conv-123",
|
|
user_id="user-456",
|
|
channel_id="chan-789",
|
|
)
|
|
assert conv.id == "conv-123"
|
|
assert isinstance(conv.created_at, datetime)
|
|
assert isinstance(conv.last_activity, datetime)
|
|
|
|
def test_explicit_timestamps(self):
|
|
ts = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
conv = Conversation(
|
|
id="c",
|
|
user_id="u",
|
|
channel_id="ch",
|
|
created_at=ts,
|
|
last_activity=ts,
|
|
)
|
|
assert conv.created_at == ts
|
|
|
|
|
|
class TestChatMessage:
|
|
"""ChatMessage is a single message in a conversation."""
|
|
|
|
def test_user_message(self):
|
|
msg = ChatMessage(
|
|
id="msg-1",
|
|
conversation_id="conv-1",
|
|
content="What is the steal rule?",
|
|
is_user=True,
|
|
)
|
|
assert msg.is_user is True
|
|
assert msg.parent_id is None
|
|
|
|
def test_assistant_message_with_parent(self):
|
|
msg = ChatMessage(
|
|
id="msg-2",
|
|
conversation_id="conv-1",
|
|
content="According to Rule 5.2.1(b)...",
|
|
is_user=False,
|
|
parent_id="msg-1",
|
|
)
|
|
assert msg.parent_id == "msg-1"
|
|
|
|
|
|
class TestLLMResponse:
|
|
"""LLMResponse is the structured output from the LLM port."""
|
|
|
|
def test_create(self):
|
|
resp = LLMResponse(
|
|
answer="Based on Rule 5.2.1(b), runners can steal...",
|
|
cited_rules=["5.2.1(b)"],
|
|
confidence=0.9,
|
|
needs_human=False,
|
|
)
|
|
assert resp.answer.startswith("Based on")
|
|
assert resp.confidence == 0.9
|
|
|
|
def test_defaults(self):
|
|
resp = LLMResponse(answer="text")
|
|
assert resp.cited_rules == []
|
|
assert resp.confidence == 0.5
|
|
assert resp.needs_human is False
|
|
|
|
|
|
class TestChatResult:
|
|
"""ChatResult is the final result returned by ChatService to inbound adapters."""
|
|
|
|
def test_create(self):
|
|
result = ChatResult(
|
|
response="answer text",
|
|
conversation_id="conv-1",
|
|
message_id="msg-2",
|
|
parent_message_id="msg-1",
|
|
cited_rules=["5.2.1(b)"],
|
|
confidence=0.85,
|
|
needs_human=False,
|
|
)
|
|
assert result.response == "answer text"
|
|
assert result.parent_message_id == "msg-1"
|
|
|
|
def test_optional_parent(self):
|
|
result = ChatResult(
|
|
response="r",
|
|
conversation_id="c",
|
|
message_id="m",
|
|
cited_rules=[],
|
|
confidence=0.5,
|
|
needs_human=False,
|
|
)
|
|
assert result.parent_message_id is None
|