feat(07-03): classifier wired to load_provider_config + ai_config stub removed — D-04/D-06

- Remove _ProviderConfigStub from services/ai_config.py (replaced by real ProviderConfig)
- Add module-level import: from ai.provider_config import ProviderConfig, PROVIDER_DEFAULTS
- load_provider_config() now returns ProviderConfig (no lazy import inside function body)
- classifier.py: replace inline _settings dict with load_provider_config(session) call (D-06)
- Per-user override path builds ProviderConfig from PROVIDER_DEFAULTS (no api_key — T-07-06)
- Fallback to app_settings defaults when load_provider_config returns None (D-15)
- Truncation delegated to provider._truncate() — no more text slices in classifier (D-12/D-13)
- Promote test_api_key_encrypt_decrypt to passing (round-trip + domain salt isolation)
- Update test_classifier.py: test_per_user_provider + test_default_provider_fallback use ProviderConfig assertions
This commit is contained in:
curo1305
2026-06-04 19:14:48 +02:00
parent efc177a155
commit 95c386f764
4 changed files with 197 additions and 77 deletions
+11 -37
View File
@@ -22,38 +22,21 @@ from __future__ import annotations
import base64
import logging
from typing import Optional, TYPE_CHECKING
from typing import Optional
import structlog
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ai.provider_config import ProviderConfig, PROVIDER_DEFAULTS
from config import settings
# ProviderConfig redefined in ai/provider_config.py during Plan 02 —
# load_provider_config will re-import and return that class once Plan 02 lands.
# The stub below is removed in Plan 03 once the classifier consumes the real class.
logger = structlog.get_logger(__name__)
# ── Stub ProviderConfig for Plan 01 (replaced by ai/provider_config.py in Plan 02) ──
class _ProviderConfigStub(BaseModel):
"""Minimal provider config placeholder until Plan 02 creates ai/provider_config.py."""
provider_id: str
api_key: str = ""
base_url: Optional[str] = None
model: str = ""
context_chars: int = 8000
# ── HKDF key derivation ───────────────────────────────────────────────────────
def _derive_ai_settings_key(master_key: bytes, provider_id: str) -> Fernet:
@@ -125,31 +108,19 @@ def decrypt_api_key(master_key: bytes, provider_id: str, api_key_enc: str) -> st
# ── Provider config loader ────────────────────────────────────────────────────
async def load_provider_config(session: AsyncSession) -> Optional[_ProviderConfigStub]:
async def load_provider_config(session: AsyncSession) -> Optional[ProviderConfig]:
"""Load the active AI provider config from the system_settings table.
Returns a _ProviderConfigStub built from the row where is_active=True,
Returns a ProviderConfig built from the row where is_active=True,
decrypting api_key_enc when present. Returns None when no active row exists.
Note: In Plan 02, this function will try to import and return ProviderConfig
from ai/provider_config.py instead of the stub. The import is done lazily
inside the function body so that Plan 01 does not depend on files that don't
exist yet.
Args:
session: An open AsyncSession.
Returns:
A _ProviderConfigStub (or the real ProviderConfig from Plan 02+) if an
active row exists; None if the table is empty or no row is marked active.
A ProviderConfig if an active row exists; None if the table is empty or
no row is marked active.
"""
# Lazy import: try to use the real ProviderConfig once Plan 02 lands
try:
from ai.provider_config import ProviderConfig as _RealProviderConfig # type: ignore[import]
config_cls = _RealProviderConfig
except ImportError:
config_cls = _ProviderConfigStub # type: ignore[assignment]
from db.models import SystemSettings # local import to avoid circular deps
stmt = select(SystemSettings).where(SystemSettings.is_active.is_(True))
@@ -171,7 +142,7 @@ async def load_provider_config(session: AsyncSession) -> Optional[_ProviderConfi
provider_id=row.provider_id,
)
return config_cls(
return ProviderConfig(
provider_id=row.provider_id,
api_key=api_key,
base_url=row.base_url,
@@ -208,11 +179,14 @@ async def seed_system_settings_from_env(session: AsyncSession) -> None:
# Row already exists — never overwrite (idempotent)
return
# Use PROVIDER_DEFAULTS context_chars for the provider, fallback to 8000
context_chars = PROVIDER_DEFAULTS.get(provider_id, {}).get("context_chars", 8000)
# Insert default row with no API key (local providers like Ollama don't need one)
row = SystemSettings(
provider_id=provider_id,
model_name=model_name,
context_chars=8000,
context_chars=context_chars,
is_active=True,
api_key_enc=None,
base_url=None,
+70 -14
View File
@@ -13,6 +13,13 @@ the user's namespace via create_topic(user_id=doc.user_id) (D-11).
Updated in Plan 03-04: classify_document and suggest_topics_for_document now accept
ai_provider and ai_model kwargs. No longer calls storage.load_settings(). Provider
resolved via get_provider() using per-user settings from DB (D-14, D-15).
Updated in Plan 07-03: Provider resolved via load_provider_config(session) reading
from the system_settings DB table (D-04/D-06). Per-user override still honoured:
when ai_provider is non-None, a ProviderConfig is constructed from PROVIDER_DEFAULTS
for that provider (note: per-user override does not carry an api_key — admin must
configure each provider's key in system_settings). Truncation delegated to provider
_truncate() — no more inline text slicing in the classifier (D-12/D-13).
"""
from __future__ import annotations
@@ -23,7 +30,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from config import settings as app_settings
from db.models import Document
from services import storage
from services.ai_config import load_provider_config
from ai import get_provider
from ai.provider_config import ProviderConfig, PROVIDER_DEFAULTS
_DEFAULT_SYSTEM_PROMPT = """You are a document classification assistant. When given a document's text content and a list of existing topics, you must:
1. Assign the document to one or more relevant topics from the list.
@@ -47,19 +56,45 @@ async def classify_document(
ai_provider and ai_model come from the document owner's User record (D-14).
Falls back to app_settings.default_ai_provider / default_ai_model when None (D-15).
Provider config loaded from system_settings DB table via load_provider_config() (D-04).
"""
meta = await storage.get_metadata(session, doc_id)
if meta is None:
raise ValueError(f"Document {doc_id} not found")
_ai_provider = ai_provider or app_settings.default_ai_provider
_ai_model = ai_model or app_settings.default_ai_model
system_prompt = app_settings.system_prompt or _DEFAULT_SYSTEM_PROMPT
_settings = {
"active_provider": _ai_provider,
"providers": {_ai_provider: {"model": _ai_model}},
}
provider = get_provider(_settings)
# ── Provider resolution (D-04/D-06) ─────────────────────────────────────
if ai_provider is not None:
# Per-user override path: build a ProviderConfig from PROVIDER_DEFAULTS.
# per-user override does not carry an api_key — admin must configure each
# provider's key in system_settings. When the per-user override selects a
# different provider than the active system provider, api_key stays empty
# and get_provider() normalises it to "not-needed".
config = ProviderConfig(
provider_id=ai_provider,
model=ai_model or PROVIDER_DEFAULTS.get(ai_provider, {}).get("model", ""),
api_key="",
base_url=None,
context_chars=PROVIDER_DEFAULTS.get(ai_provider, {}).get("context_chars", 8000),
)
else:
# System provider path: load from DB (D-04)
config = await load_provider_config(session)
if config is None:
# No active row in system_settings — fall back to env-var defaults (D-15)
fallback_provider = app_settings.default_ai_provider
config = ProviderConfig(
provider_id=fallback_provider,
model=app_settings.default_ai_model,
api_key="",
base_url=None,
context_chars=PROVIDER_DEFAULTS.get(fallback_provider, {}).get(
"context_chars", 8000
),
)
provider = get_provider(config)
# Load the Document ORM object to get the owner's user_id (D-11, D-17)
try:
@@ -80,6 +115,7 @@ async def classify_document(
topic_names = [t["name"] for t in all_topics]
text = meta.get("extracted_text", "")
# Truncation is performed inside provider.classify() via provider._truncate(). (D-12/D-13)
result = await provider.classify(text, topic_names, system_prompt)
# Collect all topic names to persist (assigned + suggested)
@@ -108,18 +144,38 @@ async def suggest_topics_for_document(
ai_provider and ai_model come from the document owner's User record (D-14).
Falls back to app_settings.default_ai_provider / default_ai_model when None (D-15).
Provider config loaded from system_settings DB table via load_provider_config() (D-04).
"""
meta = await storage.get_metadata(session, doc_id)
if meta is None:
raise ValueError(f"Document {doc_id} not found")
_ai_provider = ai_provider or app_settings.default_ai_provider
_ai_model = ai_model or app_settings.default_ai_model
system_prompt = app_settings.system_prompt or _DEFAULT_SYSTEM_PROMPT
_settings = {
"active_provider": _ai_provider,
"providers": {_ai_provider: {"model": _ai_model}},
}
provider = get_provider(_settings)
# ── Provider resolution (D-04/D-06) ─────────────────────────────────────
if ai_provider is not None:
config = ProviderConfig(
provider_id=ai_provider,
model=ai_model or PROVIDER_DEFAULTS.get(ai_provider, {}).get("model", ""),
api_key="",
base_url=None,
context_chars=PROVIDER_DEFAULTS.get(ai_provider, {}).get("context_chars", 8000),
)
else:
config = await load_provider_config(session)
if config is None:
fallback_provider = app_settings.default_ai_provider
config = ProviderConfig(
provider_id=fallback_provider,
model=app_settings.default_ai_model,
api_key="",
base_url=None,
context_chars=PROVIDER_DEFAULTS.get(fallback_provider, {}).get(
"context_chars", 8000
),
)
provider = get_provider(config)
text = meta.get("extracted_text", "")
# Truncation is performed inside provider.suggest_topics() via provider._truncate(). (D-12/D-13)
return await provider.suggest_topics(text, system_prompt)