strat-chatbot/config/settings.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

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"
)