Files
duocthu/apps/ai-service/adapters/bedrock_claude.py
T

146 lines
5.5 KiB
Python

"""Claude on Amazon Bedrock as the answer generator.
The only module that names the `anthropic` SDK, imported lazily — the same
arrangement that confines `boto3` to `embedding.py` and `qdrant_client` to
`qdrant.py`, so `rag/` imports and the whole suite runs with no SDK and no
cloud account.
Two provider facts, taken from the Anthropic API reference rather than from
memory: Bedrock model ids carry an `anthropic.` prefix (`anthropic.claude-opus-5`),
and the Messages-API path on Bedrock is the Mantle client — not the legacy
`bedrock-runtime` InvokeModel route the embedding adapter uses.
"""
from __future__ import annotations
import json
import re
from typing import Any
from rag.ports import AnswerGenerationUnavailable
BEDROCK_CLAUDE_OPUS_5 = "anthropic.claude-opus-5"
# Generation must not outrun the evidence it is rewriting. The section route
# can return a long section, so this is sized for the rewrite, not the source.
MAX_OUTPUT_TOKENS = 4096
def _provider_error_types() -> tuple[type[BaseException], ...]:
"""SDK and transport error classes, or none when the SDK is absent."""
collected: list[type[BaseException]] = []
try:
import anthropic
collected.append(anthropic.APIError)
except ImportError:
pass
try:
from botocore.exceptions import BotoCoreError, ClientError
collected.extend((BotoCoreError, ClientError))
except ImportError:
pass
return tuple(collected)
class BedrockClaudeAnswerGenerator:
"""Rewrites evidence into prose under a schema the API enforces.
The output shape is constrained by `output_config.format` rather than by
asking for JSON in the prompt, so a malformed envelope is the provider's
error rather than this code's parsing problem. What the schema cannot
constrain is whether the *content* is faithful — that is
`rag.grounding.verify`'s job, and it runs on every response this returns.
"""
def __init__(
self,
region: str = "us-east-1",
client: Any | None = None,
model_id: str = BEDROCK_CLAUDE_OPUS_5,
max_tokens: int = MAX_OUTPUT_TOKENS,
) -> None:
self._region = region
self._client = client
self._model_id = model_id
self._max_tokens = max_tokens
@property
def model_id(self) -> str:
return self._model_id
def _runtime(self) -> Any:
if self._client is None:
from anthropic import AnthropicBedrockMantle
# max_retries: the SDK's own default (2) is lower than the retry
# budget given the other Bedrock adapters (bedrock_converse.py,
# embedding.py) after a real throttling burst measured live
# 2026-08-07 — matched here for consistency, in case this
# provider is ever selected instead of bedrock-converse.
self._client = AnthropicBedrockMantle(aws_region=self._region, max_retries=4)
return self._client
def generate(self, system: str, user: str, schema: dict) -> str:
try:
response = self._runtime().messages.create(
model=self._model_id,
max_tokens=self._max_tokens,
system=system,
messages=[{"role": "user", "content": user}],
output_config={"format": {"type": "json_schema", "schema": schema}},
)
except _provider_error_types() as error:
raise AnswerGenerationUnavailable(
f"{self._model_id} could not be invoked: {type(error).__name__}"
) from error
# A refusal is a successful HTTP response with no usable content, not
# an exception. Treating it as an outage routes it to the extractive
# fallback instead of letting `content[0]` raise.
if getattr(response, "stop_reason", None) == "refusal":
raise AnswerGenerationUnavailable(
f"{self._model_id} declined the request"
)
text = "".join(
block.text
for block in response.content
if getattr(block, "type", None) == "text"
)
if not text.strip():
raise AnswerGenerationUnavailable(
f"{self._model_id} returned no text content"
)
return text
class StubAnswerGenerator:
"""Returns a fixed payload; lets the whole answer path run with no cloud.
Not a fake for tests only — it is what `EMBEDDING_PROVIDER`-style local
demos use to exercise prompt building, schema parsing, grounding
verification and the fallback branch without spending anything.
"""
def __init__(self, answer: str, evidence_sufficient: bool = True) -> None:
# `answer` keeps its old free-text-with-[n]-markers shape for this
# constructor's own callers (bootstrap.py's demo string) — wrapped
# into a single structured claim here since 2026-08-10's schema
# change (see rag/prompt.py's ANSWER_SCHEMA).
citations = [int(n) for n in re.findall(r"\[(\d+)\]", answer)]
text = re.sub(r"\s*\[\d+\]", "", answer).strip()
self._payload = json.dumps(
{
"claims": [{"text": text, "citations": citations}] if text else [],
"evidence_sufficient": evidence_sufficient,
},
ensure_ascii=False,
)
self.calls: list[tuple[str, str]] = []
def generate(self, system: str, user: str, schema: dict) -> str: # noqa: ARG002
# `schema` is unused here; the stub returns an already-valid payload.
self.calls.append((system, user))
return self._payload