Fix live multi-turn: pass the resolved drug, stop did-you-mean garbage

This commit is contained in:
2026-08-05 16:54:35 +07:00
parent ef08b4929e
commit 1e8cbdb586
29 changed files with 2013 additions and 83 deletions
@@ -0,0 +1,199 @@
"""A Bedrock Converse-API answer generator (DeepSeek / Qwen / Nova / GLM …).
The counterpart to `bedrock_claude.py`, for every non-Anthropic model on
Bedrock. Those models are reached through the unified `converse` operation on
`bedrock-runtime` — the same client `embedding.py` already uses — so this adapter
adds no new SDK: `boto3` is imported lazily, and `rag/` still imports nothing.
One structural difference from the Anthropic path drives the shape of this file:
Converse has **no** server-side response schema (no `output_config.format`), so
the JSON envelope `answer.py` parses cannot be enforced by the API. It is asked
for in the prompt and then isolated here (`_extract_json`) before returning. If
the model still emits something unparseable, `answer.py` falls back to the
verbatim source text — losing the rewrite, never the answer.
"""
from __future__ import annotations
import json
from typing import Any
from rag.ports import AnswerGenerationUnavailable, RerankUnavailable
BEDROCK_RUNTIME_SERVICE = "bedrock-runtime"
DEEPSEEK_V3_2 = "deepseek.v3.2"
# Sized for a rewrite of the retrieved evidence, not for open-ended generation:
# the section route can hand over a long section, and the answer restates it.
MAX_OUTPUT_TOKENS = 4096
# stopReasons that mean "a successful HTTP response carrying no usable answer".
# Treated as an outage so the caller degrades to the extractive text instead of
# reading content that was filtered away.
_EMPTY_STOP_REASONS = frozenset({"content_filtered", "guardrail_intervened"})
def _provider_error_types() -> tuple[type[BaseException], ...]:
"""botocore's error classes, or none when botocore is absent."""
try:
from botocore.exceptions import BotoCoreError, ClientError
except ImportError:
return ()
return (BotoCoreError, ClientError)
def _extract_json(text: str) -> str:
"""Isolate the JSON object from a Converse text block.
Converse cannot pin the output shape, so a model may wrap the object in a
```json fence or add a sentence around it. This returns the outermost
`{...}` span so `answer.py`'s `json.loads` sees the same clean envelope the
Anthropic adapter's schema-constrained path produces. If no object is found
the original text is returned, and the caller's parse fails closed.
"""
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
return text[start : end + 1]
return text
class BedrockConverseAnswerGenerator:
"""Rewrites retrieved evidence into prose via the Bedrock Converse API.
Model-agnostic: the model id is injected, so switching from DeepSeek to Qwen
or GLM is one config value (and one IAM resource ARN), no code change. What
the model returns is not trusted — `rag.grounding.verify` runs on every
answer this produces, so a fabricated figure yields a discarded generation,
not a wrong answer.
"""
def __init__(
self,
region: str = "us-east-1",
client: Any | None = None,
model_id: str = DEEPSEEK_V3_2,
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:
import boto3
from botocore.config import Config
self._client = boto3.client(
BEDROCK_RUNTIME_SERVICE,
region_name=self._region,
config=Config(
connect_timeout=10,
read_timeout=60,
retries={"max_attempts": 3, "mode": "standard"},
),
)
return self._client
def generate(self, system: str, user: str, schema: dict) -> str:
# The schema cannot be enforced by Converse, so it is stated in the
# message. Temperature 0: a formulary restatement is not a place for
# sampling variety.
directive = (
"Trả về DUY NHẤT một đối tượng JSON đúng schema sau, không kèm văn "
"bản nào khác, không dùng khối markdown ```:\n"
f"{json.dumps(schema, ensure_ascii=False)}"
)
try:
response = self._runtime().converse(
modelId=self._model_id,
system=[{"text": system}],
messages=[{"role": "user", "content": [{"text": f"{user}\n\n{directive}"}]}],
inferenceConfig={"maxTokens": self._max_tokens, "temperature": 0},
)
except _provider_error_types() as error:
raise AnswerGenerationUnavailable(
f"{self._model_id} could not be invoked: {type(error).__name__}"
) from error
if response.get("stopReason") in _EMPTY_STOP_REASONS:
raise AnswerGenerationUnavailable(
f"{self._model_id} produced no usable content "
f"(stopReason={response.get('stopReason')})"
)
blocks = response.get("output", {}).get("message", {}).get("content", [])
text = "".join(block.get("text", "") for block in blocks if isinstance(block, dict))
if not text.strip():
raise AnswerGenerationUnavailable(f"{self._model_id} returned no text content")
return _extract_json(text)
class BedrockCohereReranker:
"""Reorders candidate chunks by relevance with Cohere Rerank on Bedrock.
A cross-encoder rerank is the standard fix for the weak spot of pure vector
similarity: hit@1 0.544 was measured letting the embedding alone pick the
section, because the large pharmacology section sits close to every question.
Rerank scores each (query, chunk) pair jointly, so it recovers precision the
bi-encoder cannot. It is an optional improvement on the similarity fallback,
never on the deterministic section route — losing it reorders nothing, it
does not lose an answer.
"""
COHERE_RERANK_V3_5 = "cohere.rerank-v3-5:0"
def __init__(
self,
region: str = "us-east-1",
client: Any | None = None,
model_id: str = COHERE_RERANK_V3_5,
) -> None:
self._region = region
self._client = client
self._model_id = model_id
def _runtime(self) -> Any:
if self._client is None:
import boto3
from botocore.config import Config
self._client = boto3.client(
BEDROCK_RUNTIME_SERVICE,
region_name=self._region,
config=Config(
connect_timeout=10,
read_timeout=30,
retries={"max_attempts": 3, "mode": "standard"},
),
)
return self._client
def rerank(self, query: str, documents: list[str], top_n: int | None = None) -> list[int]:
"""Return document indices, most relevant first. Never drops silently:
on any provider error it raises, and the caller keeps the input order."""
if not documents:
return []
n = top_n or len(documents)
try:
response = self._runtime().invoke_model(
modelId=self._model_id,
body=json.dumps(
{"query": query, "documents": documents, "top_n": n, "api_version": 2}
),
accept="*/*",
contentType="application/json",
)
except _provider_error_types() as error:
raise RerankUnavailable(
f"{self._model_id} could not be invoked: {type(error).__name__}"
) from error
body = json.loads(response["body"].read())
results = body.get("results")
if not results:
raise RerankUnavailable(f"{self._model_id} returned no results")
return [item["index"] for item in results]
+18 -1
View File
@@ -51,12 +51,28 @@ def _build_generator(settings: Settings):
return BedrockClaudeAnswerGenerator(
region=settings.aws_region, model_id=settings.answer_model_id
)
if settings.answer_provider == "bedrock-converse":
from adapters.bedrock_converse import BedrockConverseAnswerGenerator
return BedrockConverseAnswerGenerator(
region=settings.aws_region, model_id=settings.answer_model_id
)
raise ValueError(
"Unknown ANSWER_PROVIDER. Supported values: disabled (default), "
"stub (local, no cloud), bedrock-claude"
"stub (local, no cloud), bedrock-claude, bedrock-converse"
)
def _build_reranker(settings: Settings):
"""A Cohere reranker, or None when disabled. Only used on the similarity /
overview fallback; the section route never reranks."""
if not settings.rerank_enabled:
return None
from adapters.bedrock_converse import BedrockCohereReranker
return BedrockCohereReranker(region=settings.aws_region)
def build_runtime(settings: Settings):
metrics = _build_metrics(settings)
if settings.embedding_provider == "disabled":
@@ -91,6 +107,7 @@ def build_runtime(settings: Settings):
QdrantParentStore(client, settings.qdrant_collection),
EvidencePolicy(minimum_score=settings.evidence_minimum_score),
section_resolver=section_resolver,
reranker=_build_reranker(settings),
)
routing = QueryRoutingService(retrieval, resolver)
answers = GroundedAnswerService(
+7 -1
View File
@@ -26,8 +26,14 @@ class Settings(BaseSettings):
aws_region: str = "us-east-1"
# Generation is off unless asked for. `stub` runs the whole answer path —
# prompt, schema parsing, grounding check, fallback — with no cloud call.
# Live options: `bedrock-converse` (DeepSeek/Qwen/GLM/Nova via the Converse
# API) or `bedrock-claude` (Anthropic via the Messages path).
answer_provider: str = "disabled"
answer_model_id: str = "anthropic.claude-opus-5"
answer_model_id: str = "deepseek.v3.2"
# Optional cross-encoder rerank on the similarity fallback (needs live
# Bedrock invoke on the rerank model). Off by default; the section route
# never uses it.
rerank_enabled: bool = False
metrics_enabled: bool = True
entities_path: Path = (
Path(__file__).resolve().parents[2]
+44 -13
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass, replace
from . import grounding, metrics as metric_names
@@ -57,14 +58,24 @@ class GroundedAnswerService:
query: str,
subject_scope: SubjectScope,
intent: QueryIntent,
drug_id: str | None = None,
) -> GroundedAnswer:
result = self._routing.retrieve(query, subject_scope, intent)
# When the caller already resolved the drug (e.g. the conversational
# layer, incl. an inherited follow-up), retrieve for it directly instead
# of re-resolving from the turn text — re-resolution from a rewritten
# turn is what abstained good follow-ups as "ambiguous".
if drug_id is not None:
result = self._routing.retrieve_for_drug(
query, drug_id, subject_scope, intent
)
else:
result = self._routing.retrieve(query, subject_scope, intent)
if result.decision == EvidenceDecision.ABSTAIN:
self._metrics.increment(metric_names.ABSTENTION, reason=result.reason)
return GroundedAnswer(result, None)
citations = self._citations(result)
if citations is None:
indexed = self._indexed_citations(result)
if indexed is None:
return GroundedAnswer(
replace(
result,
@@ -74,6 +85,7 @@ class GroundedAnswerService:
),
None,
)
all_citations = tuple(citation for _, citation in indexed)
if result.decision == EvidenceDecision.VERIFY_PDF:
# Never generated over. A quarantined table or formula is exactly
# the evidence whose numbers were not reliably reconstructed, so
@@ -82,7 +94,7 @@ class GroundedAnswerService:
result,
"Nguồn có bảng hoặc công thức cần đối chiếu trực tiếp với ảnh PDF; "
"không tự động trích số liệu.",
citations,
all_citations,
)
evidence_texts = tuple(item.text for item in result.evidence)
@@ -90,7 +102,12 @@ class GroundedAnswerService:
f"{text} [{index}]" for index, text in enumerate(evidence_texts, start=1)
)
generated = self._generate(query, evidence_texts)
generated = self._generate(query, evidence_texts, intro=result.is_drug_overview)
answer_text = extractive if generated is None else generated
# Show only the sources the answer actually cited, not every chunk that
# was retrieved — a paragraph that cites [4] must not drag 13 citation
# chips onto the screen. Falls back to all when the text cites nothing.
citations = self._cited_only(indexed, answer_text) or all_citations
if generated is None:
self._metrics.increment(metric_names.ANSWER_EXTRACTIVE)
return GroundedAnswer(result, extractive, citations)
@@ -98,12 +115,14 @@ class GroundedAnswerService:
self._metrics.increment(metric_names.GENERATION_SERVED)
return GroundedAnswer(result, generated, citations, generated=True)
def _generate(self, query: str, evidence_texts: tuple[str, ...]) -> str | None:
def _generate(
self, query: str, evidence_texts: tuple[str, ...], intro: bool = False
) -> str | None:
"""A verified generation, or None to fall back to the source text."""
if self._generator is None or not evidence_texts:
return None
request = build_request(query, evidence_texts)
request = build_request(query, evidence_texts, intro=intro)
try:
raw = self._generator.generate(request.system, request.user, request.schema)
except AnswerGenerationUnavailable:
@@ -144,9 +163,21 @@ class GroundedAnswerService:
return answer
@staticmethod
def _citations(result: RetrievalResult) -> tuple[Citation, ...] | None:
citations = []
for evidence in result.evidence:
def _cited_only(
indexed: list[tuple[int, Citation]], answer_text: str
) -> tuple[Citation, ...]:
"""Keep citations whose 1-based evidence marker [n] appears in the text."""
used = {int(m) for m in re.findall(r"\[(\d+)\]", answer_text)}
return tuple(citation for index, citation in indexed if index in used)
@staticmethod
def _indexed_citations(
result: RetrievalResult,
) -> list[tuple[int, Citation]] | None:
"""Citations tagged with the 1-based evidence index the prompt gives them,
so the response can show only the ones the answer cited."""
citations: list[tuple[int, Citation]] = []
for index, evidence in enumerate(result.evidence, start=1):
if not evidence.source_refs:
return None
for source in evidence.source_refs:
@@ -157,7 +188,7 @@ class GroundedAnswerService:
start = end = source.printed_page
else:
return None
citations.append(Citation(
citations.append((index, Citation(
chunk_id=evidence.matched_doc_id,
printed_page_start=int(start),
printed_page_end=int(end),
@@ -169,5 +200,5 @@ class GroundedAnswerService:
# real crop path wins; otherwise the block id plus the
# structured page/bbox fields is enough to render later.
attachment=source.source_crop or source.block_id,
))
return tuple(citations)
)))
return citations
+46 -28
View File
@@ -46,8 +46,22 @@ from .reasoning import (
clarify_for,
run_turn,
)
from .routing import CatalogDrugResolver, DrugResolutionStatus
from .sections import SECTION_PHRASES, SectionResolver
from .routing import CatalogDrugResolver, DrugResolutionStatus, normalize_name
from .sections import SectionResolver
# Turns that only confirm a prior suggestion. They resolve no drug and must not
# be fuzzy-matched against the catalog (which returns garbage like terbinafin).
# Stored normalised (normalize_name strips diacritics: "đúng" -> "dung"), or the
# lookup below never matches.
_CONFIRMATION_WORDS = (
"đúng", "đúng rồi", "đúng vậy", "phải", "phải rồi", "chuẩn", "chuẩn rồi",
"chính xác", "", "uh", "ok", "oke", "yes", "vâng",
)
_CONFIRMATIONS = frozenset(normalize_name(word) for word in _CONFIRMATION_WORDS)
def _is_confirmation(text: str) -> bool:
return normalize_name(text) in _CONFIRMATIONS
SUMMARY_EVERY = 4 # regenerate the summary at most every S turns, per ADR 0007 §2
@@ -272,11 +286,30 @@ class ConversationalLoopService:
# a near-miss for real drug names, offer them ("did you mean") rather
# than a bare "which drug?" — a typo should not dead-end.
if resolved.drug_id is None:
# Only genuinely-close names are offered. A far match (Arginin for
# "metfomin") is noise, not a suggestion — so the bar is high, and
# when nothing clears it the honest answer is "not in the formulary",
# never a padded list of unrelated drugs.
suggestions = self._resolver.suggest(query, k=3, min_score=0.72)
# A bare confirmation ("đúng") with no drug in context is not a drug
# lookup — never fuzzy-match it (that returned terbinafin/tretinoin).
if _is_confirmation(query):
reason = "confirm_without_context"
clarification = Clarification(
reason=reason,
question="Bạn muốn xác nhận thuốc nào? Vui lòng gõ tên thuốc để mình tra cứu.",
options=(),
)
self._metrics.increment(metric_names.CLARIFY_ASKED, reason=reason)
self._persist(state, resolved, None)
return ConversationTurnResult(None, clarification, None, False, None, reason)
# Only offer "did you mean" for a SHORT, drug-name-shaped miss (a
# typo). Fuzzy-matching a whole sentence ("EPO điều trị thiếu máu…")
# or a confirmation ("đúng") against 684 aliases returns confident
# garbage — that is the did-you-mean loop the reviewer hit. A long or
# confirming turn that resolves no drug is answered honestly, not
# with a list of unrelated drugs.
looks_like_name = len(normalize_name(query).split()) <= 4
suggestions = (
self._resolver.suggest(query, k=3, min_score=0.72)
if looks_like_name and not _is_confirmation(query)
else []
)
if suggestions:
names = [self._drug_name(drug_id) for drug_id, _ in suggestions]
reason = "did_you_mean"
@@ -301,16 +334,13 @@ class ConversationalLoopService:
if resolved.inherited_drug:
self._metrics.increment(metric_names.FOLLOWUP_INHERITED)
# One call to the safe engine with the self-contained (rewritten) query.
# A multi-round retrieval-refine loop was tried and removed: refining an
# already-answerable whole-section result cannot fetch more (the section
# is complete) and, worse, the refined query drops the inherited drug and
# abstains — discarding a good answer. Refinement belongs to the
# similarity path, not here. Clarify + inheritance are the loop's value,
# and both happen above this line.
effective = self._rewrite(query, resolved)
# One call to the safe engine. The drug is passed already-resolved (incl.
# an inherited follow-up drug), so the engine does NOT re-resolve it from
# the turn text — that double-resolution is what abstained follow-ups as
# "ambiguous". The turn's own text drives section routing; when it names
# no attribute the drug-overview + rerank path finds the relevant part.
grounded: GroundedAnswer | None = self._answers.answer(
effective, subject_scope, intent
query, subject_scope, intent, drug_id=resolved.drug_id
)
answer = grounded.answer if grounded else None
@@ -339,18 +369,6 @@ class ConversationalLoopService:
"""A readable display name from a drug id ('paracetamol_acetaminophen')."""
return drug_id.replace("_", " ").title()
@staticmethod
def _rewrite(query: str, resolved) -> str:
parts: list[str] = []
if resolved.inherited_drug and resolved.drug_id:
parts.append(resolved.drug_id)
if resolved.inherited_section and resolved.section_key:
phrases = SECTION_PHRASES.get(resolved.section_key)
if phrases:
parts.append(phrases[0])
parts.append(query)
return " ".join(parts)
def _append_user(self, state, text, drug_id, section_key) -> None:
state = state.append(Turn("user", text, _now(), drug_id, section_key))
self._store.save(state)
+4
View File
@@ -81,3 +81,7 @@ class RetrievalResult:
evidence: tuple[Evidence, ...] = field(default_factory=tuple)
resolved_drug_id: str | None = None
drug_resolution_status: str = "not_attempted"
# True when the user typed only a drug name (no attribute): the answer layer
# should introduce the drug (what it is + what it treats), not restate a
# section verbatim.
is_drug_overview: bool = False
+21
View File
@@ -26,6 +26,27 @@ class AnswerGenerationUnavailable(RuntimeError):
"""
class RerankUnavailable(RuntimeError):
"""The reranker could not be reached.
Same fail-open contract as the other provider errors, but softer: losing the
reranker only means the candidates keep their original order, so the caller
catches this and proceeds rather than abstaining. Rerank is an ordering
improvement on the fallback, never a precondition for an answer.
"""
class Reranker(Protocol):
"""Reorders candidate texts by joint relevance to the query.
Returns indices into `documents`, most relevant first. A cross-encoder pass
that recovers precision the bi-encoder embedding cannot; applied only to the
similarity/overview fallback, never to the deterministic section route.
"""
def rerank(self, query: str, documents: list[str], top_n: int | None = None) -> list[int]: ...
class AnswerGenerator(Protocol):
"""Rewrites retrieved evidence into prose. Never a source of facts.
+29 -6
View File
@@ -24,10 +24,17 @@ Quy tắc bắt buộc:
2. Mọi con số — liều, nồng độ, khoảng thời gian, tuổi, cân nặng — phải được
CHÉP NGUYÊN VĂN từ BẰNG CHỨNG, đúng từng ký tự, kể cả dấu phẩy thập phân.
Không làm tròn, không đổi đơn vị, không quy đổi.
3. Mỗi ý phải gắn số nguồn dạng [n], với n là số thứ tự đoạn bằng chứng.
4. Nếu BẰNG CHỨNG không đủ để trả lời, nói rõ là không đủ. Đó là câu trả lời
hợp lệ, không phải thất bại.
5. Giữ nguyên thuật ngữ chuyên môn của sách. Không diễn giải cho người
3. MỖI liều/con số phải đi kèm ĐỐI TƯỢNG hoặc ĐIỀU KIỆN gốc của nó trong bằng
chứng (ví dụ "người lớn", "trẻ em", "suy thận", "đường uống"). TUYỆT ĐỐI
không gán liều của đối tượng này cho đối tượng khác, và không gộp các liều
khác đối tượng thành một.
4. Gắn số nguồn [n] cho từng ý, với n là đoạn bằng chứng THỰC SỰ chứa ý đó.
Chỉ trích [n] nếu đọc đoạn n thấy đúng ý đang nói. Không lặp lại cùng một
[n] ở mọi câu — gắn một lần cho một cụm cùng nguồn là đủ. Không bịa số [n].
5. Nếu BẰNG CHỨNG không đủ (thiếu đối tượng được hỏi, thiếu con số, hoặc chỉ nói
chung chung), nói rõ là không đủ và đặt evidence_sufficient=false. Đó là câu
trả lời hợp lệ. Không suy diễn để lấp chỗ trống.
6. Giữ nguyên thuật ngữ chuyên môn của sách. Không diễn giải cho người
không chuyên.
Viết gọn. Trả lời đúng điều được hỏi, không mở rộng phạm vi."""
@@ -60,12 +67,18 @@ class GenerationRequest:
schema: dict
def build_request(question: str, evidence_texts: tuple[str, ...]) -> GenerationRequest:
def build_request(
question: str, evidence_texts: tuple[str, ...], intro: bool = False
) -> GenerationRequest:
"""The prompt for one question over one ordered evidence list.
Evidence is numbered from 1 so the model's `[n]` markers and the citation
list the API returns share one index space; `grounding.verify` rejects any
marker outside it.
`intro=True` is the "user typed only a drug name" case: instead of restating
a section, write a short introduction — what the drug is, its class and its
main indication — then invite a specific follow-up. Still evidence-only.
"""
if not evidence_texts:
raise ValueError("cannot build a grounded prompt with no evidence")
@@ -73,5 +86,15 @@ def build_request(question: str, evidence_texts: tuple[str, ...]) -> GenerationR
blocks = "\n\n".join(
f"[{index}] {text}" for index, text in enumerate(evidence_texts, start=1)
)
user = f"BẰNG CHỨNG:\n\n{blocks}\n\nCÂU HỎI: {question}"
if intro:
task = (
f"Người dùng mới gõ tên thuốc: {question}. Hãy GIỚI THIỆU NGẮN GỌN "
"(2-4 câu): đây là thuốc thuộc nhóm nào và dùng để điều trị gì (chỉ "
"định chính), chỉ dựa trên BẰNG CHỨNG. KHÔNG liệt kê dạng bào chế/hàm "
"lượng. Kết thúc bằng một câu mời hỏi tiếp về thuộc tính cụ thể (liều "
"dùng, chống chỉ định, thận trọng, tương tác…)."
)
else:
task = f"CÂU HỎI: {question}"
user = f"BẰNG CHỨNG:\n\n{blocks}\n\n{task}"
return GenerationRequest(system=SYSTEM_PROMPT, user=user, schema=ANSWER_SCHEMA)
+39 -6
View File
@@ -181,12 +181,10 @@ class QueryRoutingService:
self._retrieval = retrieval
self._resolver = resolver
def retrieve(
self,
query: str,
subject_scope: SubjectScope = SubjectScope.UNKNOWN,
intent: QueryIntent = QueryIntent.UNKNOWN,
) -> RetrievalResult:
@staticmethod
def _scope_gate(
subject_scope: SubjectScope, intent: QueryIntent
) -> RetrievalResult | None:
# Scope comes from the API/policy layer. Unknown is deliberately
# fail-closed; retrieval must not infer clinical scope from keywords.
if subject_scope == SubjectScope.NON_HUMAN:
@@ -197,6 +195,41 @@ class QueryRoutingService:
return RetrievalResult(EvidenceDecision.ABSTAIN, "recommendation_out_of_scope")
if intent == QueryIntent.UNKNOWN:
return RetrievalResult(EvidenceDecision.ABSTAIN, "query_intent_unknown")
return None
def retrieve_for_drug(
self,
query: str,
drug_id: str,
subject_scope: SubjectScope = SubjectScope.UNKNOWN,
intent: QueryIntent = QueryIntent.UNKNOWN,
) -> RetrievalResult:
"""Retrieve for an ALREADY-resolved drug, skipping name resolution.
The conversational layer has already resolved (and possibly inherited)
the drug; re-resolving from the rewritten turn text is what produced the
`drug_resolution_ambiguous` empty answers on follow-ups. The query text
still drives section routing and the intro/overview decision.
"""
gate = self._scope_gate(subject_scope, intent)
if gate is not None:
return gate
result = self._retrieval.retrieve(query, drug_id)
return replace(
result,
resolved_drug_id=drug_id,
drug_resolution_status=DrugResolutionStatus.RESOLVED,
)
def retrieve(
self,
query: str,
subject_scope: SubjectScope = SubjectScope.UNKNOWN,
intent: QueryIntent = QueryIntent.UNKNOWN,
) -> RetrievalResult:
gate = self._scope_gate(subject_scope, intent)
if gate is not None:
return gate
resolution = self._resolver.resolve(query)
if resolution.status == DrugResolutionStatus.NOT_FOUND:
return RetrievalResult(
+74 -8
View File
@@ -3,15 +3,35 @@ from __future__ import annotations
from dataclasses import dataclass
from .models import Evidence, EvidenceDecision, RetrievalResult, SearchHit
from .ports import ParentStore, QueryEmbeddingUnavailable, Retriever
from .ports import (
ParentStore,
QueryEmbeddingUnavailable,
Reranker,
RerankUnavailable,
Retriever,
)
from .sections import SectionResolver
# The sections that introduce a drug: what it is, its class, its main use, its
# mechanism — in book order. A bare drug name is answered from these, not from
# the dosage-forms table that happens to sit near the top of the monograph.
INTRO_SECTIONS = (
"ten_chung_quoc_te",
"loai_thuoc",
"chi_dinh",
"duoc_ly_va_co_che_tac_dung",
)
@dataclass(frozen=True)
class EvidencePolicy:
minimum_score: float = 0.12
candidate_limit: int = 5
evidence_limit: int = 3
# A free-form question about a resolved drug otherwise hands the LLM the
# entire monograph; rerank trims it to the sections that actually answer.
rerank_top_k: int = 6
class RetrievalService:
@@ -30,11 +50,13 @@ class RetrievalService:
parent_store: ParentStore,
policy: EvidencePolicy | None = None,
section_resolver: SectionResolver | None = None,
reranker: Reranker | None = None,
) -> None:
self._retriever = retriever
self._parent_store = parent_store
self._policy = policy or EvidencePolicy()
self._section_resolver = section_resolver
self._reranker = reranker
def retrieve(self, query: str, drug_id: str) -> RetrievalResult:
if not query.strip() or not drug_id.strip():
@@ -46,12 +68,23 @@ class RetrievalService:
# truncated list of contraindications reads as a complete one.
return self._decide(self._hydrate(section_hits, limit=None))
# Drug resolved but no attribute named ("PARACETAMOL"): show the whole
# monograph, in book order, rather than dead-ending on "specify an
# attribute". A drug reference answers a drug name with the drug.
# Drug resolved but no attribute named. A *bare* drug name ("PARACETAMOL")
# shows the whole monograph in book order. A *free-form question* about
# the drug ("sốt cao uống được không?") would otherwise dump all ~29
# sections at the model; rerank keeps only the sections that answer it.
overview_hits = self._drug_overview(drug_id)
if overview_hits is not None:
return self._decide(self._hydrate(overview_hits, limit=None))
if self._is_question(query):
overview_hits = self._rerank(query, overview_hits)
return self._decide(self._hydrate(overview_hits, limit=None))
# A bare drug name is not a question — introduce the drug from its
# identity sections (what it is, its class, its main indication),
# not the whole monograph starting with the dosage-forms table.
intro = [h for h in overview_hits if h.document.section_key in INTRO_SECTIONS]
return self._decide(
self._hydrate(intro or overview_hits, limit=None),
is_drug_overview=True,
)
try:
hits = self._retriever.search(
@@ -69,7 +102,33 @@ class RetrievalService:
)
if not hits or hits[0].score < self._policy.minimum_score:
return RetrievalResult(EvidenceDecision.ABSTAIN, "insufficient_retrieval_score")
return self._decide(self._hydrate(hits))
return self._decide(self._hydrate(self._rerank(query, hits)))
@staticmethod
def _is_question(query: str) -> bool:
"""A bare drug name (one or two tokens) wants the whole monograph; more
than that is a question whose overview should be reranked to the point."""
return len(query.split()) > 2
def _rerank(self, query: str, hits: list[SearchHit]) -> list[SearchHit]:
"""Reorder hits by cross-encoder relevance, keep the top-k.
Fail-open: no reranker configured, or the provider is unreachable, and
the original order is returned unchanged — an ordering aid must never be
able to lose an answer. The section route never reaches this.
"""
if self._reranker is None or len(hits) <= 1:
return hits
try:
order = self._reranker.rerank(
query,
[hit.document.text for hit in hits],
top_n=self._policy.rerank_top_k,
)
except RerankUnavailable:
return hits
reranked = [hits[index] for index in order if 0 <= index < len(hits)]
return reranked[: self._policy.rerank_top_k] or hits
def _drug_overview(self, drug_id: str) -> list[SearchHit] | None:
"""Every prose section of the drug, or None if the store cannot scroll."""
@@ -96,14 +155,21 @@ class RetrievalService:
hits = find_by_section(drug_id, match.section_key)
return hits or None
def _decide(self, evidence: tuple[Evidence, ...]) -> RetrievalResult:
def _decide(
self, evidence: tuple[Evidence, ...], is_drug_overview: bool = False
) -> RetrievalResult:
if not evidence:
return RetrievalResult(EvidenceDecision.ABSTAIN, "parent_hydration_failed")
if any(not item.source_refs for item in evidence):
return RetrievalResult(EvidenceDecision.ABSTAIN, "missing_provenance")
if any(item.requires_visual_check for item in evidence):
return RetrievalResult(EvidenceDecision.VERIFY_PDF, "visual_verification_required", evidence)
return RetrievalResult(EvidenceDecision.ANSWERABLE, "grounded_evidence_available", evidence)
return RetrievalResult(
EvidenceDecision.ANSWERABLE,
"grounded_evidence_available",
evidence,
is_drug_overview=is_drug_overview,
)
def _hydrate(
self, hits: list[SearchHit], limit: int | None = -1
@@ -0,0 +1,147 @@
"""The Converse adapter must translate provider failure into the domain error,
isolate the JSON envelope the API cannot enforce, and never leak an SDK type.
No network: a stub client stands in for `bedrock-runtime`. The grounding check
that actually guards correctness lives in `rag.grounding` and is tested there;
here we prove only the adapter's envelope and failure contract.
"""
from __future__ import annotations
import json
import pytest
from adapters.bedrock_converse import (
BedrockConverseAnswerGenerator,
BedrockCohereReranker,
RerankUnavailable,
_extract_json,
)
from rag.ports import AnswerGenerationUnavailable
def _converse_reply(text: str, stop_reason: str = "end_turn") -> dict:
return {
"stopReason": stop_reason,
"output": {"message": {"content": [{"text": text}]}},
"usage": {"inputTokens": 10, "outputTokens": 5},
}
class _Client:
def __init__(self, reply: dict) -> None:
self._reply = reply
self.calls: list[dict] = []
def converse(self, **kwargs):
self.calls.append(kwargs)
return self._reply
def _payload(answer: str = "Liều 500 mg [1]", sufficient: bool = True) -> str:
return json.dumps({"answer": answer, "evidence_sufficient": sufficient}, ensure_ascii=False)
def test_returns_the_models_json_on_success():
client = _Client(_converse_reply(_payload()))
gen = BedrockConverseAnswerGenerator(client=client, model_id="deepseek.v3.2")
raw = gen.generate("system", "user", {"type": "object"})
parsed = json.loads(raw)
assert parsed["answer"] == "Liều 500 mg [1]"
assert parsed["evidence_sufficient"] is True
# The prompt carries the schema and a JSON-only directive to the model.
sent = client.calls[0]["messages"][0]["content"][0]["text"]
assert "JSON" in sent
def test_strips_a_markdown_fence_the_api_cannot_forbid():
fenced = f"```json\n{_payload()}\n```"
gen = BedrockConverseAnswerGenerator(client=_Client(_converse_reply(fenced)))
parsed = json.loads(gen.generate("s", "u", {}))
assert parsed["answer"] == "Liều 500 mg [1]"
def test_extracts_json_when_the_model_adds_prose_around_it():
noisy = f"Đây là câu trả lời: {_payload()} Hết."
assert json.loads(_extract_json(noisy))["evidence_sufficient"] is True
def test_a_filtered_stop_reason_is_an_outage_not_an_answer():
client = _Client(_converse_reply("", stop_reason="content_filtered"))
gen = BedrockConverseAnswerGenerator(client=client)
with pytest.raises(AnswerGenerationUnavailable):
gen.generate("s", "u", {})
def test_empty_text_raises_rather_than_returning_blank():
gen = BedrockConverseAnswerGenerator(client=_Client(_converse_reply(" ")))
with pytest.raises(AnswerGenerationUnavailable):
gen.generate("s", "u", {})
def test_real_botocore_client_error_becomes_the_domain_error():
botocore_exceptions = pytest.importorskip("botocore.exceptions")
class _Refusing:
def converse(self, **kwargs):
raise botocore_exceptions.ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
"Converse",
)
gen = BedrockConverseAnswerGenerator(client=_Refusing())
with pytest.raises(AnswerGenerationUnavailable):
gen.generate("s", "u", {})
# --- reranker -----------------------------------------------------------------
class _RerankBody:
def __init__(self, payload: dict) -> None:
self._data = json.dumps(payload).encode("utf-8")
def read(self) -> bytes:
return self._data
class _RerankClient:
def __init__(self, payload: dict) -> None:
self._payload = payload
def invoke_model(self, **kwargs):
return {"body": _RerankBody(self._payload)}
def test_rerank_returns_indices_most_relevant_first():
client = _RerankClient({"results": [{"index": 2}, {"index": 0}, {"index": 1}]})
reranker = BedrockCohereReranker(client=client)
order = reranker.rerank("chống chỉ định", ["a", "b", "c"])
assert order == [2, 0, 1]
def test_rerank_raises_so_the_caller_keeps_original_order():
botocore_exceptions = pytest.importorskip("botocore.exceptions")
class _Refusing:
def invoke_model(self, **kwargs):
raise botocore_exceptions.ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
"InvokeModel",
)
with pytest.raises(RerankUnavailable):
BedrockCohereReranker(client=_Refusing()).rerank("q", ["a", "b"])
def test_rerank_of_nothing_is_empty():
assert BedrockCohereReranker(client=_RerankClient({})).rerank("q", []) == []
@@ -0,0 +1,95 @@
"""Two answer-UX fixes, pinned:
- citations shown = only the sources the answer cited, not every retrieved chunk;
- a bare drug name is introduced, not restated section-by-section.
"""
from __future__ import annotations
import json
from rag.answer import GroundedAnswerService
from rag.models import (
Evidence,
EvidenceDecision,
QueryIntent,
RetrievalResult,
SourceRef,
SubjectScope,
)
from rag.prompt import build_request
def _evidence(i: int, page: int) -> Evidence:
return Evidence(
evidence_id=f"drug::sec::{i}",
matched_doc_id=f"drug::sec::{i}",
kind="prose",
text=f"đoạn bằng chứng {i}",
score=1.0,
source_refs=(SourceRef(physical_page=page, precision="exact", printed_page=page),),
hydrated_from_parent=False,
requires_visual_check=False,
)
class _Routing:
def __init__(self, result: RetrievalResult) -> None:
self._result = result
def retrieve(self, query, subject_scope, intent): # noqa: ARG002
return self._result
class _Generator:
def __init__(self, payload: dict) -> None:
self._payload = payload
def generate(self, system: str, user: str, schema: dict) -> str: # noqa: ARG002
return json.dumps(self._payload, ensure_ascii=False)
def _answerable(*evidence: Evidence, is_overview: bool = False) -> RetrievalResult:
return RetrievalResult(
EvidenceDecision.ANSWERABLE,
"grounded_evidence_available",
tuple(evidence),
resolved_drug_id="drug",
is_drug_overview=is_overview,
)
def test_only_cited_sources_are_returned():
result = _answerable(_evidence(0, 100), _evidence(1, 200), _evidence(2, 300))
service = GroundedAnswerService(
_Routing(result),
_Generator({"answer": "Chỉ dùng đoạn hai [2].", "evidence_sufficient": True}),
)
grounded = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
assert grounded.generated is True
assert len(grounded.citations) == 1
assert grounded.citations[0].printed_page_start == 200
def test_answer_citing_nothing_falls_back_to_all_citations():
result = _answerable(_evidence(0, 100), _evidence(1, 200))
service = GroundedAnswerService(
_Routing(result),
# no [n] marker at all: rather than show zero provenance, show all.
_Generator({"answer": "Không có trích dẫn.", "evidence_sufficient": True}),
)
grounded = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
assert len(grounded.citations) == 2
def test_bare_name_builds_an_intro_prompt():
intro = build_request("PARACETAMOL", ("đoạn A", "đoạn B"), intro=True)
assert "GIỚI THIỆU" in intro.user
assert "CÂU HỎI:" not in intro.user
normal = build_request("Liều?", ("đoạn A",), intro=False)
assert "CÂU HỎI:" in normal.user
assert "GIỚI THIỆU" not in normal.user
@@ -31,8 +31,8 @@ class FakeAnswers:
self._e = evidence_text
self.calls = []
def answer(self, query, subject_scope, intent):
self.calls.append(query)
def answer(self, query, subject_scope, intent, drug_id=None):
self.calls.append((query, drug_id))
return _grounded(self._a, self._e)
@@ -66,7 +66,7 @@ def test_medical_turn_returns_grounded_answer():
assert out.grounded is not None
def test_followup_inherits_drug_and_names_it_and_rewrites_query():
def test_followup_inherits_drug_and_passes_it_resolved():
answers = FakeAnswers(
"Ở trẻ em điều chỉnh theo cân nặng.",
"Ở trẻ em, liều metformin điều chỉnh theo cân nặng.",
@@ -76,12 +76,40 @@ def test_followup_inherits_drug_and_names_it_and_rewrites_query():
out = svc.answer("c3", "còn trẻ em thì sao?", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
assert out.inherited_drug == "metformin"
assert out.answer.startswith("Về metformin:")
# The follow-up was rewritten self-contained before hitting the engine.
assert "metformin" in answers.calls[-1]
# The inherited drug is passed already-resolved (not re-resolved from the
# rewritten turn text), and the raw follow-up drives section routing.
last_query, last_drug_id = answers.calls[-1]
assert last_drug_id == "metformin"
assert last_query == "còn trẻ em thì sao?"
# State carried the drug forward.
assert svc._store.load("c3").focus.drug_id == "metformin"
def test_confirmation_is_not_fuzzy_matched_to_a_drug():
"""'đúng' must not be fuzzy-matched to terbinafin/tretinoin (the did-you-mean
loop the reviewer hit); it asks which drug instead."""
answers = FakeAnswers("x", "x")
svc = _service(answers)
out = svc.answer("cc", "đúng", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
assert out.clarification is not None
assert out.clarification.reason == "confirm_without_context"
assert answers.calls == []
def test_a_long_sentence_that_names_no_drug_is_not_offered_did_you_mean():
"""A full question ('EPO điều trị thiếu máu...') that resolves no drug is
answered honestly, not with garbage suggestions from fuzzing the sentence."""
answers = FakeAnswers("x", "x")
svc = _service(answers) # catalog holds only metformin
out = svc.answer(
"cl", "EPO điều trị thiếu máu do hóa trị ung thư liều khởi đầu bao nhiêu",
SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP,
)
assert out.clarification is not None
assert out.clarification.reason == "drug_not_supported"
assert answers.calls == []
def test_no_close_drug_reports_not_supported():
answers = FakeAnswers("x", "x")
svc = _service(answers) # catalog holds only metformin
@@ -0,0 +1,92 @@
"""A free-form question about a resolved drug must not dump the whole monograph
at the model. When a reranker is configured, the overview is reordered by
relevance and trimmed to top-k; a bare drug name still returns everything.
"""
from __future__ import annotations
from rag.models import EvidenceDecision, RetrievalDocument, SearchHit, SourceRef
from rag.ports import RerankUnavailable
from rag.service import EvidencePolicy, RetrievalService
def _hit(i: int) -> SearchHit:
doc = RetrievalDocument(
doc_id=f"d{i}",
drug_id="paracetamol",
kind="prose",
text=f"section {i} text",
section_key=f"sec_{i}",
source_refs=(
SourceRef(physical_page=100 + i, precision="exact", printed_page=i),
),
)
return SearchHit(document=doc, score=1.0)
class _Retriever:
def __init__(self, n: int) -> None:
self._hits = [_hit(i) for i in range(n)]
def find_by_drug(self, drug_id: str) -> list[SearchHit]:
return list(self._hits)
def search(self, query: str, drug_id: str, limit: int) -> list[SearchHit]: # noqa: ARG002
return []
class _ParentStore:
def get(self, parent_id: str): # noqa: ARG002
return None
class _Reranker:
def __init__(self, order: list[int], fail: bool = False) -> None:
self._order = order
self._fail = fail
self.calls: list[str] = []
def rerank(self, query: str, documents: list[str], top_n=None): # noqa: ARG002
self.calls.append(query)
if self._fail:
raise RerankUnavailable("provider down")
return self._order
def _service(reranker=None, top_k=3) -> RetrievalService:
return RetrievalService(
_Retriever(8),
_ParentStore(),
EvidencePolicy(rerank_top_k=top_k),
section_resolver=None,
reranker=reranker,
)
def test_a_question_reranks_the_overview_and_keeps_top_k():
reranker = _Reranker(order=[7, 6, 5, 4, 3, 2, 1, 0])
result = _service(reranker, top_k=3).retrieve(
"sốt cao uống được không", "paracetamol"
)
assert result.decision == EvidenceDecision.ANSWERABLE
assert reranker.calls, "reranker should run on a free-form question"
assert len(result.evidence) == 3
assert [e.matched_doc_id for e in result.evidence] == ["d7", "d6", "d5"]
def test_a_bare_drug_name_returns_the_whole_monograph_unreranked():
reranker = _Reranker(order=[0])
result = _service(reranker).retrieve("PARACETAMOL", "paracetamol")
assert reranker.calls == [], "a bare name must not be reranked/trimmed"
assert len(result.evidence) == 8
def test_rerank_outage_keeps_the_original_order():
reranker = _Reranker(order=[], fail=True)
result = _service(reranker).retrieve("sốt cao uống được không", "paracetamol")
# Fail-open: the answer survives, in book order, when rerank is unreachable.
assert result.decision == EvidenceDecision.ANSWERABLE
assert len(result.evidence) == 8
assert [e.matched_doc_id for e in result.evidence][:2] == ["d0", "d1"]
+23 -1
View File
@@ -21,10 +21,20 @@ export interface ChatPanelProps {
className?: string;
}
const ERROR_MESSAGE =
"Hệ thống tạm thời không phản hồi. Vui lòng thử lại — nếu vẫn lỗi, có thể dịch vụ tra cứu đang tạm ngưng.";
export function ChatPanel({ onCitationClick, className }: ChatPanelProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [isSending, setIsSending] = useState(false);
// One id per chat session, so follow-ups ("còn trẻ em thì sao?") resolve
// against the same conversation on the backend.
const [conversationId] = useState(() =>
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: `conv-${Date.now()}`
);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
@@ -41,8 +51,20 @@ export function ChatPanel({ onCitationClick, className }: ChatPanelProps) {
setInput("");
setIsSending(true);
try {
const response = await sendChatMessage(content);
const response = await sendChatMessage(content, conversationId);
setMessages((prev) => [...prev, response.message]);
} catch {
// Never leave the user staring at their own message with no reply: an
// error is surfaced as a labelled bubble, not swallowed silently.
setMessages((prev) => [
...prev,
{
id: `error-${messages.length}`,
role: "assistant",
content: ERROR_MESSAGE,
createdAt: new Date().toISOString(),
},
]);
} finally {
setIsSending(false);
}
+125
View File
@@ -0,0 +1,125 @@
import { NextResponse } from "next/server";
import type { Citation, SendMessageResponse } from "@duoc-thu/shared-types";
export const runtime = "nodejs";
const AI_SERVICE_URL = process.env.AI_SERVICE_URL ?? "http://localhost:8079";
const DISCLAIMER =
"Nội dung trích từ Dược thư Quốc gia Việt Nam, chỉ mang tính tra cứu chuyên môn và không thay thế chỉ định của bác sĩ hoặc dược sĩ.";
interface RagCitation {
chunk_id: string;
printed_page_start: number;
printed_page_end: number;
physical_page: number;
attachment?: string | null;
}
interface RagResponse {
trace_id: string;
decision: string;
reason: string;
answer: string | null;
resolved_drug_id: string | null;
citations: RagCitation[];
}
/**
* What the user reads when the system declines.
*
* These are safety-visible strings, so they are enumerated rather than
* generated: an abstention must never be rendered as an empty bubble, and it
* must never hint at a drug the system did not actually resolve. Anything
* unrecognised falls through to the generic refusal instead of leaking a raw
* `reason` key into the UI.
*/
const REFUSALS: Record<string, string> = {
drug_not_resolved:
"Chưa xác định được thuốc trong câu hỏi này, nên hệ thống không đưa ra nội dung chuyên môn. Vui lòng nêu rõ tên hoạt chất cần tra cứu.",
drug_resolution_ambiguous:
"Câu hỏi có thể ứng với nhiều thuốc khác nhau. Vui lòng nêu rõ tên hoạt chất cần tra cứu.",
recommendation_out_of_scope:
"Đây là câu hỏi xin tư vấn hoặc quyết định điều trị. Hệ thống chỉ tra cứu Dược thư và không đưa ra khuyến cáo điều trị — vui lòng hỏi bác sĩ hoặc dược sĩ.",
out_of_scope_non_human:
"Dược thư Quốc gia Việt Nam áp dụng cho người. Hệ thống không tra cứu cho đối tượng khác.",
subject_scope_unknown:
"Chưa rõ câu hỏi áp dụng cho đối tượng nào, nên hệ thống không trả lời.",
query_embedding_unavailable:
"Chưa tra được mục tương ứng cho câu hỏi này. Vui lòng nêu rõ thuộc tính cần tra (liều dùng, chống chỉ định, tương tác thuốc…).",
insufficient_retrieval_score:
"Không tìm thấy nội dung đủ liên quan trong Dược thư cho câu hỏi này.",
};
const GENERIC_REFUSAL =
"Hệ thống không tìm thấy căn cứ trong Dược thư để trả lời câu hỏi này.";
function toCitations(raw: RagCitation[], resolvedDrugId: string | null): Citation[] {
return raw.map((item) => {
// Chunk ids are `<drug>__<section>__<index>`. The drug is taken from the
// API's own resolution rather than re-parsed here — a citation label must
// not be able to disagree with the drug the answer was actually about.
const parts = item.chunk_id.split("__");
return {
drugName: resolvedDrugId ?? parts[0] ?? item.chunk_id,
sectionType: parts.length > 1 ? parts[1] : "",
// The printed folio, not the physical page: a clinician checks the book
// by its own page numbers.
sourcePageRange: [item.printed_page_start, item.printed_page_end],
};
});
}
export async function POST(request: Request) {
let content: string;
let conversationId: string | null = null;
try {
const body = await request.json();
content = typeof body?.content === "string" ? body.content.trim() : "";
conversationId =
typeof body?.conversationId === "string" ? body.conversationId : null;
} catch {
return NextResponse.json({ error: "invalid_body" }, { status: 400 });
}
if (!content) {
return NextResponse.json({ error: "empty_query" }, { status: 400 });
}
let rag: RagResponse;
try {
const upstream = await fetch(`${AI_SERVICE_URL}/v1/rag/query`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: content,
subject_scope: "human",
intent: "fact_lookup",
conversation_id: conversationId,
}),
cache: "no-store",
});
if (!upstream.ok) {
return NextResponse.json(
{ error: "upstream_error", status: upstream.status },
{ status: 502 }
);
}
rag = (await upstream.json()) as RagResponse;
} catch {
return NextResponse.json({ error: "upstream_unreachable" }, { status: 502 });
}
const refused = rag.decision === "abstain" || rag.answer === null;
const message: SendMessageResponse["message"] = {
id: rag.trace_id,
role: "assistant",
content: refused
? REFUSALS[rag.reason] ?? GENERIC_REFUSAL
: (rag.answer as string),
citations: refused ? [] : toCitations(rag.citations, rag.resolved_drug_id),
disclaimer: DISCLAIMER,
createdAt: new Date().toISOString(),
};
return NextResponse.json({ message } satisfies SendMessageResponse);
}