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