"""Anthropic AI provider — singleton client, output_config structured output, smart truncation. D-03: Uses output_config={"format": {"type": "json_schema", "schema": ...}} with constrained decoding available in anthropic SDK >=0.95.0 (GA, no beta headers needed). D-07: self._client = AsyncAnthropic(...) created once in __init__ and reused — never recreated per API call to preserve the httpx connection pool. D-12/D-13: Global char constant removed; uses self._context_chars with 60/40 smart truncation. Security: api_key is accepted from the caller (loaded from system_settings by ai_config.py and decrypted before being passed here). The key is never stored beyond this instance's lifetime. T-07-06 mitigated: this class never reads the api_key from env vars directly. """ from __future__ import annotations import anthropic from ai.base import AIProvider, ClassificationResult from ai.utils import parse_classification, parse_suggestions # ── Output schemas for constrained decoding (D-03 / RESEARCH.md) ──────────────────────────── # additionalProperties=False required by Anthropic output_config grammar. # "reasoning" is intentionally absent from "required" so legacy prompts that don't include it # still produce valid JSON (Anthropic will emit it because it is declared in properties, but # we do not enforce it in the schema to avoid refusal on minimal responses). _CLASSIFICATION_SCHEMA: dict = { "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: dict = { "type": "object", "properties": { "suggested_topics": {"type": "array", "items": {"type": "string"}}, }, "required": ["suggested_topics"], "additionalProperties": False, } class AnthropicProvider(AIProvider): """Anthropic Claude provider with singleton client and output_config structured output. Constructor signature matches the uniform factory contract in ai/__init__.py: api_key, model, context_chars, base_url (accepted but unused — Anthropic SDK manages the endpoint; widened so get_provider() can call all providers uniformly). """ def __init__( self, api_key: str, model: str = "claude-sonnet-4-6", context_chars: int = 180_000, base_url: str | None = None, # accepted for uniform factory signature; unused ): self._api_key = api_key self._model = model self._context_chars = context_chars # Singleton: created once in __init__, reused for all calls on this instance. # Do NOT recreate per API call — AsyncAnthropic wraps an httpx.AsyncClient # that maintains a connection pool; recreating per call destroys pool reuse # and forces a new TLS handshake per request (D-07 / RESEARCH.md). self._client = anthropic.AsyncAnthropic(api_key=self._api_key) def _truncate(self, text: str) -> str: """D-13 smart truncation: first 60% + last 40% of context window. Captures both document introduction and conclusion, which carry the most topic signal for long documents. """ if len(text) <= self._context_chars: return text head_len = int(self._context_chars * 0.6) tail_len = self._context_chars - head_len return text[:head_len] + "\n[...truncated...]\n" + text[-tail_len:] async def classify( self, document_text: str, existing_topics: list[str], system_prompt: str, ) -> ClassificationResult: topics_str = ", ".join(existing_topics) if existing_topics else "(none yet)" user_msg = ( f"Existing topics: [{topics_str}]\n\n" f"Document text:\n{self._truncate(document_text)}" ) response = 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}}, ) # Graceful degradation (T-07-08): when stop_reason is "refusal" or "max_tokens" # the constrained decoding did not complete — fall back to parse_classification("") # which returns an empty ClassificationResult rather than raising an exception. stop_reason = getattr(response, "stop_reason", "end_turn") if response.content and stop_reason == "end_turn": raw = response.content[0].text else: raw = "" return parse_classification(raw) async def suggest_topics( self, document_text: str, system_prompt: str, ) -> list[str]: user_msg = ( "Suggest 3-5 topic names for this document. " "Return ONLY valid JSON: {\"suggested_topics\": [\"topic1\", \"topic2\"]}\n\n" f"Document text:\n{self._truncate(document_text)}" ) response = await self._client.messages.create( model=self._model, max_tokens=256, system=system_prompt, messages=[{"role": "user", "content": user_msg}], output_config={"format": {"type": "json_schema", "schema": _SUGGESTIONS_SCHEMA}}, ) stop_reason = getattr(response, "stop_reason", "end_turn") if response.content and stop_reason == "end_turn": raw = response.content[0].text else: raw = "" return parse_suggestions(raw) async def health_check(self) -> bool: """Verify API key validity and connectivity by sending a minimal message. Does NOT pass output_config — the response shape does not matter here; this only confirms the api_key and network path are working. """ try: await self._client.messages.create( model=self._model, max_tokens=8, messages=[{"role": "user", "content": "ping"}], ) return True except Exception: return False