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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user