Fix live multi-turn: pass the resolved drug, stop did-you-mean garbage
This commit is contained in:
@@ -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]
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user