docs(07): create phase plan — 5 plans, verification passed

5 waves: system_settings DB + HKDF encryption (01), ProviderConfig +
GenericOpenAIProvider + singleton client fix (02), Anthropic output_config +
classifier wiring (03), Celery retry 30/90/270s + re-queue endpoint (04),
admin AI panel + DocumentCard badge + human checkpoint (05).

All 18 decisions D-01..D-18 covered. Plan checker passed after 1 revision
round (4 blockers fixed: D-02/D-14 coverage, D-11 Vitest tests, Plan 05
files_modified).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-06-03 18:25:00 +02:00
co-authored by Claude Sonnet 4.6
parent 6c8e0d8bde
commit 3df62506c9
11 changed files with 2328 additions and 26 deletions
@@ -0,0 +1,253 @@
---
phase: 07-redo-and-optimize-llm-integration
plan: 03
type: execute
wave: 3
depends_on:
- 07-02
files_modified:
- backend/ai/anthropic_provider.py
- backend/ai/__init__.py
- backend/services/classifier.py
- backend/services/ai_config.py
- backend/tests/test_ai_providers.py
- backend/tests/test_ai_config.py
autonomous: true
requirements:
- D-03
- D-04
- D-06
- D-07
- D-12
- D-13
must_haves:
truths:
- "AnthropicProvider stores self._client = AsyncAnthropic(...) in __init__ and never recreates it"
- "AnthropicProvider.classify() passes output_config={\"format\": {\"type\": \"json_schema\", \"schema\": _CLASSIFICATION_SCHEMA}} to messages.create()"
- "AnthropicProvider.suggest_topics() passes output_config={\"format\": {\"type\": \"json_schema\", \"schema\": _SUGGESTIONS_SCHEMA}} to messages.create()"
- "AnthropicProvider has no MAX_AI_CHARS constant and uses self._truncate(document_text)"
- "classifier.classify_document calls load_provider_config(session) and passes a ProviderConfig to get_provider"
- "load_provider_config returns the real ProviderConfig from ai/provider_config.py (stub removed)"
- "Registry get_provider now passes base_url+context_chars to AnthropicProvider as well"
artifacts:
- path: "backend/ai/anthropic_provider.py"
provides: "Singleton AsyncAnthropic + output_config structured output + smart truncation"
contains: "output_config"
- path: "backend/services/classifier.py"
provides: "ProviderConfig-driven classifier (no inline dict construction)"
contains: "load_provider_config"
- path: "backend/services/ai_config.py"
provides: "ProviderConfig stub replaced by import from ai.provider_config"
contains: "from ai.provider_config import ProviderConfig"
key_links:
- from: "backend/services/classifier.py::classify_document"
to: "backend/services/ai_config.py::load_provider_config"
via: "async session.execute path"
pattern: "await load_provider_config"
- from: "backend/ai/anthropic_provider.py::classify"
to: "anthropic SDK messages.create"
via: "output_config kwarg"
pattern: "output_config"
- from: "backend/ai/__init__.py::get_provider anthropic branch"
to: "AnthropicProvider.__init__"
via: "widened ctor signature (context_chars)"
pattern: "context_chars"
---
<objective>
Complete the provider refactor by giving AnthropicProvider the same singleton+context_chars+output_config treatment as the OpenAI side, and replace the classifier's inline `_settings` dict with a real ProviderConfig sourced from the database.
Purpose: Resolve D-03 (Anthropic structured output via `output_config.format.type="json_schema"`) using the constrained-decoding API that GA'd in anthropic SDK 0.95+, finish the D-07 singleton client cleanup, and close out D-06 by routing the classifier through `load_provider_config()` instead of the legacy env-var dict shim.
Output: Refactored anthropic_provider.py (output_config + singleton + truncation, MAX_AI_CHARS removed), classifier.py talking to ai_config.load_provider_config, ai_config.py stub removed in favour of the real ProviderConfig, registry updated to pass base_url/context_chars to AnthropicProvider, and the matching Anthropic + classifier integration tests promoted from xfail.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/07-redo-and-optimize-llm-integration/07-CONTEXT.md
@.planning/phases/07-redo-and-optimize-llm-integration/07-RESEARCH.md
@.planning/phases/07-redo-and-optimize-llm-integration/07-PATTERNS.md
@.planning/phases/07-redo-and-optimize-llm-integration/07-01-SUMMARY.md
@.planning/phases/07-redo-and-optimize-llm-integration/07-02-SUMMARY.md
@backend/ai/anthropic_provider.py
@backend/ai/openai_provider.py
@backend/ai/provider_config.py
@backend/ai/__init__.py
@backend/services/classifier.py
@backend/services/ai_config.py
<interfaces>
<!-- Contracts the executor needs -->
From backend/ai/anthropic_provider.py (current state before this plan):
- `MAX_AI_CHARS = 8_000` module constant
- `class AnthropicProvider(AIProvider)`
- `def __init__(self, api_key: str, model: str = "claude-sonnet-4-6")` — narrow ctor
- `def _client(self)` — to be removed
- `async def classify(...)` / `async def suggest_topics(...)` / `async def health_check(...)` calling `await client.messages.create(...)`
Anthropic SDK output_config schema (07-RESEARCH.md D-03):
- `messages.create(..., output_config={"format": {"type": "json_schema", "schema": <dict>}})`
- Response read from `response.content[0].text` only when `response.stop_reason == "end_turn"`; otherwise call parse_classification("") for graceful degradation
- Required schema fields all listed in `required` array; `additionalProperties: False`
From backend/ai/provider_config.py (introduced in Plan 02):
- `ProviderConfig(BaseModel)` with provider_id/api_key/base_url/model/context_chars
- `PROVIDER_DEFAULTS["anthropic"] = {"base_url": None, "model": "claude-sonnet-4-6", "context_chars": 180000}`
From backend/services/ai_config.py (introduced in Plan 01):
- `async def load_provider_config(session) -> ProviderConfig | None` — currently imports the _ProviderConfigStub; this plan switches it to import from ai.provider_config
From backend/services/classifier.py (current — lines 57-64):
- Inline `_settings = {"active_provider": _ai_provider, "providers": {_ai_provider: {"model": _ai_model}}}` then `provider = get_provider(_settings)`
- Old signature `async def classify_document(document_id, session, ai_provider: str | None = None, ai_model: str | None = None)`
Module-level _DEFAULT_SYSTEM_PROMPT remains in classifier.py.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: AnthropicProvider — singleton, output_config, truncation</name>
<read_first>
backend/ai/anthropic_provider.py
backend/ai/openai_provider.py
backend/ai/utils.py
backend/ai/provider_config.py
backend/ai/__init__.py
</read_first>
<behavior>
- AnthropicProvider.__init__(api_key, model, context_chars, base_url=None) stores self._client = AsyncAnthropic(api_key=api_key) once; base_url is accepted but ignored (anthropic SDK uses its default; signature widened so the factory can pass uniformly)
- AnthropicProvider has no MAX_AI_CHARS constant, no `_client(self)` method
- AnthropicProvider has _truncate(text) implementing the 60/40 split
- Module-level _CLASSIFICATION_SCHEMA and _SUGGESTIONS_SCHEMA dicts match the structures defined in 07-RESEARCH.md (assigned_topics + new_topic_suggestions + reasoning required for classification; suggested_topics required for suggestions; additionalProperties False)
- classify() and suggest_topics() pass output_config={"format": {"type": "json_schema", "schema": _CLASSIFICATION_SCHEMA / _SUGGESTIONS_SCHEMA}} to messages.create
- When response.stop_reason != "end_turn" the raw text is replaced by "" so parse_classification/parse_suggestions degrade gracefully
- get_provider in ai/__init__.py now invokes AnthropicProvider with api_key+model+context_chars+base_url (uniform call signature)
- test_ai_providers.py::test_anthropic_structured_output promoted to passing (mocks anthropic.AsyncAnthropic.messages.create and asserts output_config kwarg)
</behavior>
<action>
Refactor backend/ai/anthropic_provider.py: delete `MAX_AI_CHARS = 8_000`. Change __init__ signature to `def __init__(self, api_key: str, model: str, context_chars: int, base_url: str | None = None)`. Set self._api_key, self._model, self._context_chars, then self._client = anthropic.AsyncAnthropic(api_key=self._api_key). Delete the `def _client(self)` method. Add `def _truncate(self, text: str) -> str` body identical to OpenAIProvider._truncate (60/40 split using self._context_chars).
Add module-level constants:
`_CLASSIFICATION_SCHEMA = {"type": "object", "properties": {"assigned_topics": {"type": "array", "items": {"type": "string"}}, "new_topic_suggestions": {"type": "array", "items": {"type": "string"}}, "reasoning": {"type": "string"}}, "required": ["assigned_topics", "new_topic_suggestions"], "additionalProperties": False}`
`_SUGGESTIONS_SCHEMA = {"type": "object", "properties": {"suggested_topics": {"type": "array", "items": {"type": "string"}}}, "required": ["suggested_topics"], "additionalProperties": False}`
Rewrite classify(): truncated = self._truncate(document_text); topics_str composed as before; user_msg as before; await self._client.messages.create(model=self._model, max_tokens=1024, system=system_prompt, messages=[{"role":"user","content":user_msg}], output_config={"format": {"type": "json_schema", "schema": _CLASSIFICATION_SCHEMA}}); raw = response.content[0].text if response.content and getattr(response, "stop_reason", "end_turn") == "end_turn" else ""; return parse_classification(raw).
Rewrite suggest_topics(): same shape but output_config uses _SUGGESTIONS_SCHEMA and returns parse_suggestions(raw).
Rewrite health_check(): await self._client.messages.create(model=self._model, max_tokens=8, messages=[{"role":"user","content":"ping"}]); return True on success, False on Exception. Do not pass output_config in health_check (the response shape doesn't matter; this just verifies credentials/connectivity).
Update backend/ai/__init__.py get_provider(): in the registry branch for "anthropic", call `cls(api_key=effective_api_key, model=effective_model, context_chars=effective_context_chars, base_url=effective_base_url)`. Remove the special-case branch from Plan 02 (the comment "AnthropicProvider does not take base_url" is no longer true after this task).
Promote test_ai_providers.py::test_anthropic_structured_output: build an AnthropicProvider(api_key="k", model="claude-sonnet-4-6", context_chars=100); patch ai.anthropic_provider.anthropic.AsyncAnthropic with a MagicMock whose messages.create is an AsyncMock returning a stub response (object with .content=[MagicMock(text='{"assigned_topics":[],"new_topic_suggestions":[]}')] and .stop_reason="end_turn"); call await provider.classify("doc", [], "sys"); assert mock_create.await_args.kwargs["output_config"] == {"format": {"type": "json_schema", "schema": _CLASSIFICATION_SCHEMA}}. Remove the @pytest.mark.xfail decorator.
</action>
<verify>
<automated>cd backend &amp;&amp; pytest tests/test_ai_providers.py::test_anthropic_structured_output -x -v &amp;&amp; grep -c 'MAX_AI_CHARS' backend/ai/anthropic_provider.py</automated>
</verify>
<acceptance_criteria>
- Source assertion: grep -v '^#' backend/ai/anthropic_provider.py | grep -c 'MAX_AI_CHARS' returns 0
- Source assertion: backend/ai/anthropic_provider.py contains `self._client = anthropic.AsyncAnthropic(`, `output_config=`, `_CLASSIFICATION_SCHEMA`, `_SUGGESTIONS_SCHEMA`
- Source assertion: backend/ai/anthropic_provider.py does NOT contain `def _client(self)`
- Source assertion: backend/ai/__init__.py passes `context_chars=` and `base_url=` to AnthropicProvider
- Behavior: pytest backend/tests/test_ai_providers.py::test_anthropic_structured_output exits 0
- Behavior: response.stop_reason "max_tokens" or "refusal" → raw == "" → parse_classification("") returns an empty ClassificationResult (no crash)
</acceptance_criteria>
<done>AnthropicProvider mirrors the OpenAI-side refactor (singleton, truncation, no MAX_AI_CHARS); classify/suggest call output_config with the two schemas; registry passes uniform kwargs; test_anthropic_structured_output is green.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Classifier wired to load_provider_config + ai_config stub removal</name>
<read_first>
backend/services/classifier.py
backend/services/ai_config.py
backend/ai/provider_config.py
backend/ai/__init__.py
backend/tasks/document_tasks.py
backend/tests/test_classifier.py
backend/tests/test_ai_config.py
</read_first>
<behavior>
- backend/services/ai_config.py imports `from ai.provider_config import ProviderConfig` at module top (lazy import inside load_provider_config removed)
- The _ProviderConfigStub class defined in ai_config.py is deleted
- backend/services/classifier.py calls `config = await load_provider_config(session)` and `provider = get_provider(config)` — no inline `_settings = {...}` dict construction remains
- classify_document keeps optional ai_provider/ai_model kwargs (per-user override from ADMIN-05 still honored): when either kwarg is non-None, build a ProviderConfig override (start from PROVIDER_DEFAULTS[user provider], merge user-provided model) and skip the DB load
- When load_provider_config returns None (no active row), fall back to a ProviderConfig built from settings.default_ai_provider/default_ai_model + PROVIDER_DEFAULTS — preserves existing default-AI behaviour from STATE.md ("Default AI provider is ollama/llama3.2")
- The classifier no longer slices document_text — truncation happens inside provider._truncate()
- tests/test_classifier.py still passes (existing behavior preserved)
- tests/test_ai_config.py::test_load_provider_config and tests/test_ai_config.py::test_api_key_encrypt_decrypt promoted to passing
</behavior>
<action>
Edit backend/services/ai_config.py: remove the `_ProviderConfigStub` class entirely; replace the lazy import inside load_provider_config with a module-top `from ai.provider_config import ProviderConfig, PROVIDER_DEFAULTS`. load_provider_config(session) reads the row WHERE is_active = True via select(SystemSettings).where(SystemSettings.is_active.is_(True)) (use `.is_(True)` for SQLAlchemy comparison, not `==`); if no row found return None; otherwise decrypt api_key_enc using settings.cloud_creds_key when not None (else api_key=""); build and return ProviderConfig(provider_id=row.provider_id, api_key=decrypted_api_key, base_url=row.base_url, model=row.model_name, context_chars=row.context_chars). Import AsyncSession from sqlalchemy.ext.asyncio. Import select from sqlalchemy. Use base64 + bytes(settings.cloud_creds_key, "utf-8") if cloud_creds_key is a str — match the existing pattern in cloud_utils.
Edit backend/services/classifier.py: at the top of `async def classify_document`, replace the inline dict construction (lines ~57-64 per 07-PATTERNS.md) with:
1. `config = await load_provider_config(session)` (import `from services.ai_config import load_provider_config` at module top)
2. If ai_provider is not None (per-user override path): build `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))` (per-user overrides do not carry an API key in this codebase — the system_settings api_key is still authoritative; when the per-user override changes the provider away from the active system provider, api_key remains empty and get_provider falls back to "not-needed". Document this in a comment: "per-user override does not carry an api_key — admin must configure each provider's key in system_settings".)
3. If config is None: fallback ProviderConfig(provider_id=app_settings.default_ai_provider, model=app_settings.default_ai_model, base_url=None, context_chars=PROVIDER_DEFAULTS.get(app_settings.default_ai_provider, {}).get("context_chars", 8000), api_key="").
4. `provider = get_provider(config)` (unchanged call).
Apply the same load/override/fallback logic in `suggest_topics_for_document`. Remove any remaining `document_text[:MAX_AI_CHARS]` slices; pass the full document_text to provider.classify/provider.suggest_topics (the provider truncates internally).
Promote backend/tests/test_ai_config.py::test_load_provider_config and ::test_api_key_encrypt_decrypt. For test_api_key_encrypt_decrypt: a simple unit test using a 32-byte master key, encrypt_api_key + decrypt_api_key round-trip with provider_id="openai", assert plaintext recovered; assert encrypting with provider_id="anthropic" yields different ciphertext (domain salt isolation). For test_load_provider_config: requires DB — gate with `pytest.importorskip("psycopg")` and `@pytest.mark.skipif(not os.getenv("INTEGRATION"), reason="needs PostgreSQL")`; insert a SystemSettings row with is_active=True via the async session fixture; encrypt a test api_key; await load_provider_config(session); assert returned ProviderConfig has the expected fields and decrypted api_key. Remove xfail decorators from both.
</action>
<verify>
<automated>cd backend &amp;&amp; pytest tests/test_ai_config.py::test_api_key_encrypt_decrypt tests/test_classifier.py -x -v</automated>
</verify>
<acceptance_criteria>
- Source assertion: backend/services/ai_config.py contains `from ai.provider_config import ProviderConfig` at module level (grep on the top 30 lines)
- Source assertion: backend/services/ai_config.py does NOT contain `_ProviderConfigStub`
- Source assertion: backend/services/classifier.py contains `await load_provider_config(` and `get_provider(config)`
- Source assertion: backend/services/classifier.py does NOT contain `"active_provider"` or `"providers": {`
- Source assertion: grep -v '^#' backend/services/classifier.py | grep -c 'MAX_AI_CHARS' returns 0
- Behavior: pytest backend/tests/test_classifier.py exits 0
- Behavior: pytest backend/tests/test_ai_config.py::test_api_key_encrypt_decrypt exits 0
- Behavior: INTEGRATION=1 pytest backend/tests/test_ai_config.py::test_load_provider_config exits 0 when DB is available (test is skipped otherwise)
</acceptance_criteria>
<done>ai_config.py imports ProviderConfig directly; stub class gone; classifier uses load_provider_config + per-user/system fallback; truncation lives only in providers; two test_ai_config.py tests promoted; existing test_classifier.py still green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| services/ai_config.load_provider_config → providers | decrypted api_key crosses here in-memory only; never serialized |
| AnthropicProvider → anthropic.com API | api_key transits over TLS; SDK manages outbound auth |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-06 | Information Disclosure | classifier per-user override path | mitigate | per-user override does not carry api_key; system_settings is the only key store; api_key never leaves backend services tier |
| T-07-07 | Tampering | Anthropic output_config grammar limit | accept | Pitfall 5 RESEARCH.md — schemas are simple (≤4 required fields, no unions); will not exceed Anthropic's 24-optional / 16-union limits |
| T-07-08 | Denial of Service | Anthropic stop_reason "refusal"/"max_tokens" | mitigate | classify() falls back to parse_classification("") which returns empty ClassificationResult; document status set by caller (Plan 04 retry logic) |
| T-07-SC | Tampering | anthropic SDK pin raised to >=0.95.0 | accept | RESEARCH.md confirms package legitimacy (official Anthropic SDK, ~3 yrs old); pip audit re-runs at phase gate |
</threat_model>
<verification>
- grep -v '^#' backend/ai/anthropic_provider.py | grep -c 'MAX_AI_CHARS' returns 0.
- grep -F 'output_config' backend/ai/anthropic_provider.py returns 2+ matches (one per classify/suggest call).
- grep -F '_ProviderConfigStub' backend/services/ai_config.py returns 0.
- pytest backend/tests/test_ai_providers.py::test_anthropic_structured_output backend/tests/test_classifier.py backend/tests/test_ai_config.py::test_api_key_encrypt_decrypt exits 0.
- pytest backend/tests/ -v shows no new failures.
</verification>
<success_criteria>
- AnthropicProvider singleton + output_config + truncation + no MAX_AI_CHARS.
- classifier.classify_document driven by load_provider_config with per-user override + env fallback.
- ai_config.py stub removed; real ProviderConfig used everywhere.
- 3 previously-xfailed tests now pass (test_anthropic_structured_output, test_api_key_encrypt_decrypt, test_load_provider_config under INTEGRATION=1).
- Existing test_classifier.py still green.
</success_criteria>
<output>
Create `.planning/phases/07-redo-and-optimize-llm-integration/07-03-SUMMARY.md` when done.
</output>