Wire the guarded conversational RAG answer layer end-to-end
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
"""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
|
||||
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
|
||||
|
||||
self._client = AnthropicBedrockMantle(aws_region=self._region)
|
||||
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:
|
||||
self._payload = json.dumps(
|
||||
{"answer": answer, "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
|
||||
Reference in New Issue
Block a user