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
+28 -13
View File
@@ -119,13 +119,17 @@ async def test_classifier_with_mock_provider(isolated_data_dir):
@pytest.mark.asyncio
async def test_per_user_provider(db_session):
"""When ai_provider='openai' and ai_model='gpt-4o' are passed to the classifier,
it resolves _settings['active_provider'] == 'openai'.
it builds a ProviderConfig with provider_id='openai' and model='gpt-4o'.
DOC-03: AI provider/model comes from the user's DB record (passed through from
_run) not from global config or the retired load_settings() flat file (D-14).
Plan 07-03: get_provider now receives a ProviderConfig object (D-06), not a
raw dict. Assertions updated accordingly.
"""
from unittest.mock import AsyncMock, patch, MagicMock
from ai.base import ClassificationResult
from ai.provider_config import ProviderConfig
from services.classifier import classify_document
import uuid
@@ -136,10 +140,10 @@ async def test_per_user_provider(db_session):
mock_doc = MagicMock()
mock_doc.user_id = user_id
captured_settings = {}
captured_configs = []
def capture_get_provider(settings):
captured_settings.update(settings)
def capture_get_provider(config):
captured_configs.append(config)
mock_provider = MagicMock()
mock_provider.classify = AsyncMock(return_value=ClassificationResult(
topics=[], suggested_new_topics=[], reasoning=""
@@ -156,9 +160,11 @@ async def test_per_user_provider(db_session):
patch("services.classifier.get_provider", side_effect=capture_get_provider):
await classify_document(mock_session, doc_id, ai_provider="openai", ai_model="gpt-4o")
assert captured_settings.get("active_provider") == "openai"
assert "openai" in captured_settings.get("providers", {})
assert captured_settings["providers"]["openai"]["model"] == "gpt-4o"
assert len(captured_configs) == 1
config = captured_configs[0]
assert isinstance(config, ProviderConfig), "get_provider must receive a ProviderConfig (D-06)"
assert config.provider_id == "openai"
assert config.model == "gpt-4o"
@pytest.mark.asyncio
@@ -224,13 +230,18 @@ async def test_celery_task_uses_user_provider(db_session):
@pytest.mark.asyncio
async def test_default_provider_fallback(db_session):
"""When user.ai_provider is None, the classifier receives config.settings.default_ai_provider.
"""When user.ai_provider is None, the classifier uses the system_settings DB row or env fallback.
D-15: fallback chain is user.ai_provider → DEFAULT_AI_PROVIDER env var →
D-15: fallback chain is user.ai_provider → system_settings DB → DEFAULT_AI_PROVIDER env var →
code default 'ollama' (CONTEXT.md D-15).
Plan 07-03: get_provider now receives a ProviderConfig (D-06). When ai_provider=None
and load_provider_config returns None, the classifier falls back to
app_settings.default_ai_provider. Assertions updated from dict to ProviderConfig.
"""
from unittest.mock import AsyncMock, patch, MagicMock
from ai.base import ClassificationResult
from ai.provider_config import ProviderConfig
from services.classifier import classify_document
import uuid
@@ -241,10 +252,10 @@ async def test_default_provider_fallback(db_session):
mock_doc = MagicMock()
mock_doc.user_id = user_id
captured_settings = {}
captured_configs = []
def capture_get_provider(settings):
captured_settings.update(settings)
def capture_get_provider(config):
captured_configs.append(config)
mock_provider = MagicMock()
mock_provider.classify = AsyncMock(return_value=ClassificationResult(
topics=[], suggested_new_topics=[], reasoning=""
@@ -255,6 +266,7 @@ async def test_default_provider_fallback(db_session):
patch("services.classifier.storage.load_topics_for_user", AsyncMock(return_value=[])), \
patch("services.classifier.storage.load_topics", AsyncMock(return_value=[])), \
patch("services.classifier.storage.update_document_topics", AsyncMock(return_value=None)), \
patch("services.classifier.load_provider_config", AsyncMock(return_value=None)), \
patch("services.classifier.get_provider", side_effect=capture_get_provider):
mock_session = AsyncMock()
@@ -262,5 +274,8 @@ async def test_default_provider_fallback(db_session):
# Pass ai_provider=None to trigger the default fallback (D-15)
await classify_document(mock_session, doc_id, ai_provider=None, ai_model=None)
assert len(captured_configs) == 1
config = captured_configs[0]
assert isinstance(config, ProviderConfig), "get_provider must receive a ProviderConfig (D-06)"
# Should fall back to app_settings.default_ai_provider = "ollama"
assert captured_settings.get("active_provider") == "ollama"
assert config.provider_id == "ollama"