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>
20 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07-redo-and-optimize-llm-integration | 03 | execute | 3 |
|
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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.pyFrom backend/ai/anthropic_provider.py (current state before this plan):
MAX_AI_CHARS = 8_000module constantclass AnthropicProvider(AIProvider)def __init__(self, api_key: str, model: str = "claude-sonnet-4-6")— narrow ctordef _client(self)— to be removedasync def classify(...)/async def suggest_topics(...)/async def health_check(...)callingawait 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].textonly whenresponse.stop_reason == "end_turn"; otherwise call parse_classification("") for graceful degradation - Required schema fields all listed in
requiredarray;additionalProperties: False
From backend/ai/provider_config.py (introduced in Plan 02):
ProviderConfig(BaseModel)with provider_id/api_key/base_url/model/context_charsPROVIDER_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}}}thenprovider = 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.
Task 1: AnthropicProvider — singleton, output_config, truncation backend/ai/anthropic_provider.py backend/ai/openai_provider.py backend/ai/utils.py backend/ai/provider_config.py backend/ai/__init__.py - 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) 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.
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.
<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> |
<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>