--- phase: 07-redo-and-optimize-llm-integration plan: "03" subsystem: backend/ai-providers tags: - ai - anthropic - output_config - structured-output - singleton-client - classifier-refactor - db-driven-config - wave-3 dependency_graph: requires: - "07-02 (ProviderConfig + GenericOpenAIProvider + registry — get_provider accepts ProviderConfig)" - "07-01 (system_settings table + HKDF helpers + load_provider_config stub)" provides: - "AnthropicProvider singleton _client + output_config + _truncate (D-03/D-07/D-12/D-13)" - "classifier.classify_document driven by load_provider_config with per-user/env fallback (D-04/D-06)" - "ai_config.py stub removed — real ProviderConfig used everywhere" - "3 previously-xfailed tests promoted to passing (test_anthropic_structured_output, test_api_key_encrypt_decrypt, test_anthropic_stop_reason_fallback)" affects: - "07-04 (Celery retry — classifier is now provider-agnostic; truncation inside providers)" - "07-05 (Admin AI panel — ProviderConfig is the single config representation)" tech_stack: added: [] patterns: - "output_config={\"format\": {\"type\": \"json_schema\", \"schema\": _SCHEMA}} for Anthropic constrained decoding (D-03)" - "stop_reason guard: raw='' when stop_reason != 'end_turn' → graceful degradation (T-07-08)" - "Uniform ctor signature: __init__(api_key, model, context_chars, base_url) across all providers" - "load_provider_config(session) → ProviderConfig | None — DB authoritative, env fallback (D-04/D-15)" - "Per-user override path: ProviderConfig from PROVIDER_DEFAULTS, empty api_key (T-07-06)" key_files: created: [] modified: - backend/ai/anthropic_provider.py - backend/ai/__init__.py - backend/services/ai_config.py - backend/services/classifier.py - backend/tests/test_ai_providers.py - backend/tests/test_ai_config.py - backend/tests/test_classifier.py decisions: - "output_config={'format': {'type': 'json_schema', 'schema': ...}} chosen over tool_use — semantically correct for classification, no extra parsing layer needed" - "base_url accepted in AnthropicProvider.__init__ for uniform factory signature but unused — SDK manages endpoint" - "Per-user override path uses empty api_key — admin configures per-provider keys in system_settings; T-07-06 mitigated" - "test_classifier.py tests updated to assert ProviderConfig properties instead of dict fields — D-06 contract enforced in tests" - "test_anthropic_stop_reason_fallback added as extra regression guard for T-07-08" metrics: duration: "~25 minutes" completed: "2026-06-04" tasks_completed: 2 tasks_total: 2 files_created: 0 files_modified: 7 --- # Phase 7 Plan 03: Anthropic output_config + Classifier ProviderConfig Refactor Summary AnthropicProvider refactored with singleton _client, output_config constrained-decoding structured output, and _truncate; classifier.py wired to load_provider_config(session) replacing inline dict construction; ai_config.py stub removed; 3 xfailed tests promoted. ## Tasks Completed | Task | Description | Commit | Files | |------|-------------|--------|-------| | 1 | AnthropicProvider singleton + output_config + truncation + no MAX_AI_CHARS | efc177a | anthropic_provider.py, ai/__init__.py, tests/test_ai_providers.py | | 2 | Classifier wired to load_provider_config + ai_config stub removed | 95c386f | services/ai_config.py, services/classifier.py, tests/test_ai_config.py, tests/test_classifier.py | ## What Was Built ### Task 1: AnthropicProvider Refactor **backend/ai/anthropic_provider.py** rewritten: - `MAX_AI_CHARS = 8_000` module constant deleted - `def _client(self)` property method deleted - `__init__` signature widened to `(api_key, model, context_chars, base_url=None)` — uniform factory contract - `self._client = anthropic.AsyncAnthropic(api_key=self._api_key)` stored as singleton in `__init__` (D-07) - `def _truncate(self, text)` added — identical 60/40 split pattern to OpenAIProvider (D-13) - `_CLASSIFICATION_SCHEMA` and `_SUGGESTIONS_SCHEMA` module-level dicts added (required + additionalProperties=False) - `classify()` passes `output_config={"format": {"type": "json_schema", "schema": _CLASSIFICATION_SCHEMA}}` (D-03) - `suggest_topics()` passes `output_config={"format": {"type": "json_schema", "schema": _SUGGESTIONS_SCHEMA}}` (D-03) - Graceful degradation: `stop_reason != "end_turn"` → `raw = ""` → `parse_classification("")` returns empty ClassificationResult (T-07-08) - `health_check()` does NOT pass `output_config` — only verifies connectivity/auth **backend/ai/__init__.py** updated: - Anthropic branch now passes `context_chars=effective_context_chars, base_url=effective_base_url` to AnthropicProvider - Comment updated to reflect widened ctor (Plan 03) **backend/tests/test_ai_providers.py** updated: - `test_anthropic_structured_output` promoted from xfail — patches `ai.anthropic_provider.anthropic.AsyncAnthropic`, asserts `output_config` kwarg present and matches `_CLASSIFICATION_SCHEMA`, asserts AsyncAnthropic constructed once (singleton) - `test_anthropic_stop_reason_fallback` added — simulates `stop_reason="max_tokens"`, asserts empty ClassificationResult returned without crash (T-07-08 regression guard) ### Task 2: Classifier ProviderConfig Refactor + ai_config Stub Removal **backend/services/ai_config.py** rewritten: - `_ProviderConfigStub` class deleted entirely - Module-level import: `from ai.provider_config import ProviderConfig, PROVIDER_DEFAULTS` (no more lazy import inside function body) - `load_provider_config(session)` return type changed to `Optional[ProviderConfig]` - `seed_system_settings_from_env` now uses `PROVIDER_DEFAULTS.get(provider_id, {}).get("context_chars", 8000)` for accurate default context_chars per provider **backend/services/classifier.py** refactored: - Imports: `from services.ai_config import load_provider_config` and `from ai.provider_config import ProviderConfig, PROVIDER_DEFAULTS` - `classify_document`: inline `_settings = {...}` dict construction replaced with: 1. Per-user override path: `ProviderConfig(provider_id=ai_provider, ...)` from PROVIDER_DEFAULTS (api_key="" — T-07-06 mitigation documented) 2. System path: `await load_provider_config(session)` → None fallback to env-var defaults 3. `provider = get_provider(config)` — unchanged call signature - `suggest_topics_for_document`: same load/override/fallback pattern applied - No `text[:N]` slices remain — truncation fully delegated to provider `_truncate()` **backend/tests/test_ai_config.py** promoted: - `test_api_key_encrypt_decrypt`: round-trip smoke test (encrypt → decrypt → assert equality), domain salt isolation (different provider_id → different ciphertext), cross-domain decrypt raises `InvalidToken` - `test_load_provider_config`: DB integration test skipped without `INTEGRATION=1` (psycopg guard) **backend/tests/test_classifier.py** updated: - `test_per_user_provider`: captures `ProviderConfig` instead of dict; asserts `config.provider_id == "openai"` and `config.model == "gpt-4o"` (D-06 contract enforced in tests) - `test_default_provider_fallback`: patches `load_provider_config` to return None; captures ProviderConfig; asserts `config.provider_id == "ollama"` (env fallback path) ## Verification Results - `grep -v '^#' backend/ai/anthropic_provider.py | grep -c 'MAX_AI_CHARS'` → 0 - `grep -c 'output_config=' backend/ai/anthropic_provider.py` → 3 (classify, suggest_topics, and schema comment) - `grep -c '_ProviderConfigStub' backend/services/ai_config.py` → 0 - `grep -c 'await load_provider_config(' backend/services/classifier.py` → 2 (one per function) - Full test suite: **1 failed** (pre-existing test_extract_docx ModuleNotFoundError), **366 passed**, **12 xfailed**, **6 skipped** — no new failures; xfailed count down by 3 from wave 2 ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] Classifier tests used dict-based interface** - **Found during:** Task 2 (test_per_user_provider and test_default_provider_fallback asserted dict fields) - **Issue:** After the refactor, `get_provider` receives a `ProviderConfig` object. The existing tests captured the dict argument and asserted `settings.get("active_provider")` etc. These would fail with `AttributeError` or wrong assertions on a ProviderConfig object. - **Fix:** Updated `test_per_user_provider` to assert `config.provider_id == "openai"` and `config.model == "gpt-4o"`. Updated `test_default_provider_fallback` to patch `load_provider_config` returning None and assert `config.provider_id == "ollama"`. - **Files modified:** backend/tests/test_classifier.py - **Commit:** 95c386f **2. [Rule 2 - Missing functionality] `seed_system_settings_from_env` used hardcoded context_chars=8000** - **Found during:** Task 2 code review - **Issue:** The original seed function always inserted `context_chars=8000` regardless of provider, which would insert the wrong default for providers like Anthropic (180,000) or Groq (128,000). - **Fix:** Updated seed to use `PROVIDER_DEFAULTS.get(provider_id, {}).get("context_chars", 8000)` so the seeded row reflects the correct default for each provider. - **Files modified:** backend/services/ai_config.py - **Commit:** 95c386f ## Known Stubs None — all plan goals implemented; no placeholders. ## Threat Flags No new threat surface introduced. Changes are internal service-layer refactoring: - T-07-06 (per-user api_key isolation) mitigated: classifier per-user override path uses `api_key=""` — documented in comment - T-07-08 (Anthropic stop_reason degradation) mitigated: classify() falls back to `parse_classification("")` when `stop_reason != "end_turn"` ## Self-Check: PASSED Files modified: - [x] backend/ai/anthropic_provider.py — FOUND (singleton _client, output_config, _truncate, no MAX_AI_CHARS) - [x] backend/ai/__init__.py — FOUND (context_chars + base_url passed to AnthropicProvider) - [x] backend/services/ai_config.py — FOUND (ProviderConfig import at module level, no _ProviderConfigStub) - [x] backend/services/classifier.py — FOUND (load_provider_config + ProviderConfig construction) - [x] backend/tests/test_ai_providers.py — FOUND (test_anthropic_structured_output promoted) - [x] backend/tests/test_ai_config.py — FOUND (test_api_key_encrypt_decrypt promoted) - [x] backend/tests/test_classifier.py — FOUND (per_user_provider + default_fallback tests updated) Commits: - [x] efc177a — feat(07-03): AnthropicProvider singleton + output_config + truncation — D-03/D-07/D-12/D-13 - [x] 95c386f — feat(07-03): classifier wired to load_provider_config + ai_config stub removed — D-04/D-06