146 lines
5.5 KiB
Python
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
|