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>
75 lines
3.1 KiB
Python
75 lines
3.1 KiB
Python
"""Application settings — Pydantic v2 style, no module-level singleton.
|
|
|
|
The container (config/container.py) instantiates Settings once at startup
|
|
and passes it down to adapters. This keeps tests free of singleton state.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from pydantic import Field
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
"""All runtime configuration, sourced from environment variables or a .env file.
|
|
|
|
Fields use explicit ``env=`` aliases so the variable names are immediately
|
|
visible and grep-able without needing to know Pydantic's casing rules.
|
|
"""
|
|
|
|
model_config = SettingsConfigDict(
|
|
env_file=".env",
|
|
env_file_encoding="utf-8",
|
|
# Allow unknown env vars — avoids breakage when the .env file has
|
|
# variables that belong to other services (Discord bot, scripts, etc.).
|
|
extra="ignore",
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# OpenRouter / LLM
|
|
# ------------------------------------------------------------------
|
|
openrouter_api_key: str = Field(default="", alias="OPENROUTER_API_KEY")
|
|
openrouter_model: str = Field(
|
|
default="stepfun/step-3.5-flash:free", alias="OPENROUTER_MODEL"
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Discord
|
|
# ------------------------------------------------------------------
|
|
discord_bot_token: str = Field(default="", alias="DISCORD_BOT_TOKEN")
|
|
discord_guild_id: Optional[str] = Field(default=None, alias="DISCORD_GUILD_ID")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Gitea issue tracker
|
|
# ------------------------------------------------------------------
|
|
gitea_token: str = Field(default="", alias="GITEA_TOKEN")
|
|
gitea_owner: str = Field(default="cal", alias="GITEA_OWNER")
|
|
gitea_repo: str = Field(default="strat-chatbot", alias="GITEA_REPO")
|
|
gitea_base_url: str = Field(
|
|
default="https://git.manticorum.com/api/v1", alias="GITEA_BASE_URL"
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# File-system paths
|
|
# ------------------------------------------------------------------
|
|
data_dir: Path = Field(default=Path("./data"), alias="DATA_DIR")
|
|
rules_dir: Path = Field(default=Path("./data/rules"), alias="RULES_DIR")
|
|
chroma_dir: Path = Field(default=Path("./data/chroma"), alias="CHROMA_DIR")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Database
|
|
# ------------------------------------------------------------------
|
|
db_url: str = Field(
|
|
default="sqlite+aiosqlite:///./data/conversations.db", alias="DB_URL"
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Conversation / retrieval tuning
|
|
# ------------------------------------------------------------------
|
|
conversation_ttl: int = Field(default=1800, alias="CONVERSATION_TTL")
|
|
top_k_rules: int = Field(default=10, alias="TOP_K_RULES")
|
|
embedding_model: str = Field(
|
|
default="sentence-transformers/all-MiniLM-L6-v2", alias="EMBEDDING_MODEL"
|
|
)
|