Files

10 KiB

phase, plan, subsystem, tags, dependency_graph, tech_stack, key_files, decisions, metrics
phase plan subsystem tags dependency_graph tech_stack key_files decisions metrics
07-redo-and-optimize-llm-integration 03 backend/ai-providers
ai
anthropic
output_config
structured-output
singleton-client
classifier-refactor
db-driven-config
wave-3
requires provides affects
07-02 (ProviderConfig + GenericOpenAIProvider + registry — get_provider accepts ProviderConfig)
07-01 (system_settings table + HKDF helpers + load_provider_config stub)
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)
07-04 (Celery retry — classifier is now provider-agnostic; truncation inside providers)
07-05 (Admin AI panel — ProviderConfig is the single config representation)
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)
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
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
duration completed tasks_completed tasks_total files_created files_modified
~25 minutes 2026-06-04 2 2 0 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:

  • backend/ai/anthropic_provider.py — FOUND (singleton _client, output_config, _truncate, no MAX_AI_CHARS)
  • backend/ai/__init__.py — FOUND (context_chars + base_url passed to AnthropicProvider)
  • backend/services/ai_config.py — FOUND (ProviderConfig import at module level, no _ProviderConfigStub)
  • backend/services/classifier.py — FOUND (load_provider_config + ProviderConfig construction)
  • backend/tests/test_ai_providers.py — FOUND (test_anthropic_structured_output promoted)
  • backend/tests/test_ai_config.py — FOUND (test_api_key_encrypt_decrypt promoted)
  • backend/tests/test_classifier.py — FOUND (per_user_provider + default_fallback tests updated)

Commits:

  • efc177a — feat(07-03): AnthropicProvider singleton + output_config + truncation — D-03/D-07/D-12/D-13
  • 95c386f — feat(07-03): classifier wired to load_provider_config + ai_config stub removed — D-04/D-06