Checkpoint frontend UI/UX overhaul and ingestion embed benchmark work

This commit is contained in:
2026-08-06 17:21:21 +07:00
parent 1e8cbdb586
commit a4b8e1c4db
78 changed files with 6761 additions and 654 deletions
+194
View File
@@ -0,0 +1,194 @@
"""The new RAG orchestrator — LLM understanding in, grounded answer out.
Replaces the old front-end wholesale:
- `CatalogDrugResolver` (fuzzy) + `SectionResolver` (keyword) -> `understanding.py`
- `ConversationalLoopService` + `conversation.py` (Focus / Summariser / manual
follow-up inheritance) -> the LLM reads a plain turn history and resolves
"thuốc đó" / "còn liều thì sao" itself.
What is deliberately KEPT because it is the safety spine, not the brittle part:
- `RetrievalService.retrieve_framed` (Qdrant section/overview retrieval, whole
section, provenance, quarantine `VERIFY_PDF`),
- `GroundedAnswerService.answer_from_result` (`grounding.verify` + entailment
on every generated claim; a configured generator that fails abstains rather
than degrading to a raw source dump).
This module owns routing only; it states no medical fact of its own.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol
from .answer import Citation, GroundedAnswerService
from .models import EvidenceDecision, RetrievalResult
from .policy import looks_non_human
from .service import RetrievalService
from .understanding import QueryFrame, QueryUnderstander
TUONG_TAC = "tuong_tac_thuoc"
HISTORY_TURNS = 6
class AutocompleteSource(Protocol):
"""As-you-type suggestion, kept deterministic and independent of the LLM
understander — a prefix match needs no model call."""
def complete(self, prefix: str, k: int) -> list[str]: ...
@dataclass(frozen=True)
class AgentReply:
decision: str # answerable | abstain | clarify | verify_pdf
reason: str
answer: str | None = None
clarification: str | None = None
citations: tuple[Citation, ...] = ()
drugs: tuple[str, ...] = ()
turn_type: str = ""
generated: bool = False
class RagAgent:
def __init__(
self,
understander: QueryUnderstander,
retrieval: RetrievalService,
answers: GroundedAnswerService,
autocomplete: AutocompleteSource | None = None,
history_turns: int = HISTORY_TURNS,
) -> None:
self._understander = understander
self._retrieval = retrieval
self._answers = answers
self._autocomplete = autocomplete
self._history_turns = history_turns
self._history: dict[str, list[str]] = {}
def complete(self, prefix: str, k: int = 8) -> list[str]:
"""Display names matching a typed prefix, for input autocomplete."""
if self._autocomplete is None:
return []
return [_display_name(drug_id) for drug_id in self._autocomplete.complete(prefix, k)]
def handle(self, turn: str, conversation_id: str | None = None) -> AgentReply:
history = self._history.get(conversation_id, []) if conversation_id else []
frame = self._understander.understand(turn, tuple(history))
reply = self._route(turn, frame)
if conversation_id is not None:
self._remember(conversation_id, turn, reply)
return reply
def _route(self, turn: str, frame: QueryFrame) -> AgentReply:
tt = frame.turn_type
if frame.needs_clarify and frame.clarify_reason:
return AgentReply("clarify", "needs_more_info",
clarification=frame.clarify_reason,
drugs=frame.drugs, turn_type=tt)
if tt == "smalltalk":
return AgentReply(
"answerable", "smalltalk",
answer="Chào anh/chị! Em là trợ lý tra cứu Dược thư Quốc gia Việt Nam "
"2018, sẵn sàng hỗ trợ tra liều dùng, chống chỉ định, tương tác "
"thuốc... Anh/chị đang cần tra thuốc nào ạ?",
turn_type=tt)
if tt in ("out_of_scope",) or looks_non_human(turn):
return AgentReply(
"abstain", "out_of_scope",
answer="Nội dung này nằm ngoài phần chuyên luận thuốc của Dược thư "
"(có thể thuộc phần hướng dẫn chung/phụ lục chưa được đưa vào). "
"Tôi chưa có dữ liệu để trả lời chính xác.",
turn_type=tt)
if not frame.drugs:
if frame.unknown_drugs:
names = ", ".join(frame.unknown_drugs)
return AgentReply(
"abstain", "drug_not_in_formulary",
answer=f"Không tìm thấy \"{names}\" trong Dược thư Quốc gia Việt Nam.",
turn_type=tt)
if tt == "symptom_to_drug":
# Reverse lookup (indication/adverse-effect -> drugs) is a distinct
# retrieval mode, not yet wired. Be honest rather than abstain blank.
return AgentReply(
"clarify", "reverse_lookup_not_ready",
clarification="Tra ngược theo triệu chứng/chỉ định đang được bổ "
"sung. Anh/chị cho biết tên thuốc cụ thể để tôi tra giúp?",
turn_type=tt)
return AgentReply(
"clarify", "no_drug",
clarification="Anh/chị muốn tra thuốc nào?", turn_type=tt)
if tt == "interaction" and len(frame.drugs) >= 2:
return self._interaction(turn, frame)
# drug_attribute / drug_overview / dosing_calc / fallback: one drug + section
return self._single_drug(turn, frame)
def _single_drug(self, turn: str, frame: QueryFrame) -> AgentReply:
result = self._retrieval.retrieve_framed(
frame.drugs[0], frame.attribute, turn,
is_overview=frame.turn_type == "drug_overview",
)
return self._grounded(turn, result, frame)
def _interaction(self, turn: str, frame: QueryFrame) -> AgentReply:
"""Gather the interaction section of each named drug and synthesise.
Absence of a match is stated as "not found in each drug's interaction
section", never as "safe" — the answer layer's grounding still applies.
"""
evidences = []
for drug_id in frame.drugs:
part = self._retrieval.retrieve_framed(drug_id, TUONG_TAC, turn)
if part.decision == EvidenceDecision.ANSWERABLE:
evidences.extend(part.evidence)
if not evidences:
listed = "".join(frame.drugs)
return AgentReply(
"abstain", "no_interaction_evidence",
answer=f"Không tìm thấy mục tương tác thuốc cho {listed} trong Dược "
"thư. Điều này KHÔNG có nghĩa là an toàn khi phối hợp.",
drugs=frame.drugs, turn_type=frame.turn_type)
combined = RetrievalResult(
EvidenceDecision.ANSWERABLE, "interaction_evidence",
tuple(evidences),
)
return self._grounded(turn, combined, frame)
def _grounded(
self, turn: str, result: RetrievalResult, frame: QueryFrame
) -> AgentReply:
ga = self._answers.answer_from_result(turn, result)
decision = ga.result.decision.value
if ga.clarification is not None:
decision = "clarify"
return AgentReply(
decision=decision,
reason=ga.result.reason,
answer=ga.answer,
clarification=ga.clarification,
citations=ga.citations,
drugs=frame.drugs,
turn_type=frame.turn_type,
generated=ga.generated,
)
def _remember(self, conversation_id: str, turn: str, reply: AgentReply) -> None:
history = self._history.setdefault(conversation_id, [])
history.append(f"Người dùng: {turn}")
spoken = reply.answer or reply.clarification
if spoken:
history.append(f"Trợ lý: {spoken[:300]}")
# Keep only the recent window; the LLM re-reads it every turn.
excess = len(history) - self._history_turns * 2
if excess > 0:
del history[:excess]
def _display_name(drug_id: str) -> str:
"""A readable display name from a drug id ('paracetamol_acetaminophen')."""
return drug_id.replace("_", " ").title()
+185 -23
View File
@@ -8,7 +8,7 @@ from . import grounding, metrics as metric_names
from .metrics import Metrics, NullMetrics
from .models import EvidenceDecision, QueryIntent, RetrievalResult, SubjectScope
from .ports import AnswerGenerationUnavailable, AnswerGenerator
from .prompt import build_request
from .prompt import build_entailment_request, build_request, build_sufficiency_request
from .routing import QueryRoutingService
@@ -30,17 +30,38 @@ class GroundedAnswer:
answer: str | None
citations: tuple[Citation, ...] = ()
generated: bool = False
# Set when the model decided the turn is under-specified and asked back
# (e.g. a dose question with no age/weight). The answer field carries the
# question; the caller renders it as a clarification, not a final answer.
clarification: str | None = None
@dataclass(frozen=True)
class _GenOutcome:
answer: str | None = None
clarification: str | None = None
class GroundedAnswerService:
"""Retrieval decides what is true; generation only decides how it reads.
When a generator is configured, its output replaces the extractive text
**only** if `grounding.verify` confirms every figure and citation in it
traces back to the retrieved evidence. Anything else — an unsupported
number, a citation to nothing, a provider outage, malformed output — falls
back to quoting the source verbatim, which is always available because it
was computed first.
Two operating modes, not to be confused with each other:
- **No generator configured** (`generator=None`, e.g. `ANSWER_PROVIDER=
disabled`) is retrieval-only mode, a deliberate and fully supported
way to run this service. It quotes the retrieved source verbatim.
- **A generator IS configured.** Its output replaces the extractive text
only if it clears two independent checks: `grounding.verify` (every
figure and citation traces to the specific evidence block it cites,
and every claim carries one) and `_verify_entailment` (a second LLM
pass confirming each cited claim's *content* — not just its numbers —
is actually stated by that block). If a configured generation fails
any check, or the provider itself is unreachable, or its output is
malformed, the turn **abstains** (`reason="generation_unavailable"`
or the specific `grounding.verify` reason) rather than silently
degrading to a raw source dump — this product is a real LLM chatbot,
and a citation-stapled paragraph of book text is not an acceptable
stand-in for an answer the model was supposed to produce.
"""
def __init__(
@@ -70,6 +91,15 @@ class GroundedAnswerService:
)
else:
result = self._routing.retrieve(query, subject_scope, intent)
return self.answer_from_result(query, result)
def answer_from_result(
self, query: str, result: RetrievalResult
) -> GroundedAnswer:
"""Everything after retrieval — grounding, sufficiency, generation,
citations. Split out so the new understanding-driven orchestrator
(`rag/agent.py`) reuses the safe answer path without going through the
old `QueryRoutingService` text resolution."""
if result.decision == EvidenceDecision.ABSTAIN:
self._metrics.increment(metric_names.ABSTENTION, reason=result.reason)
return GroundedAnswer(result, None)
@@ -102,25 +132,62 @@ class GroundedAnswerService:
f"{text} [{index}]" for index, text in enumerate(evidence_texts, start=1)
)
generated = self._generate(query, evidence_texts, intro=result.is_drug_overview)
answer_text = extractive if generated is None else generated
# Reasoning step BEFORE answering: if the turn is under-specified (a dose
# with several bands and no age/weight/condition), ask instead of dumping.
# A separate focused call is more reliable than folding it into generation.
clarify_q = self._check_sufficiency(query, evidence_texts, result.is_drug_overview)
if clarify_q is not None:
return GroundedAnswer(result, clarify_q, (), clarification=clarify_q)
outcome = self._generate(query, evidence_texts, intro=result.is_drug_overview)
if outcome.clarification is not None:
# The model judged the turn under-specified (a dose with no
# age/weight/renal-function/indication…) and asked back instead of
# listing every band. Return the question, not the whole section.
return GroundedAnswer(
result, outcome.clarification, (), clarification=outcome.clarification
)
if outcome.answer is None:
if self._generator is None:
# No generator configured at all — retrieval-only mode. A
# deliberate operating mode (e.g. ANSWER_PROVIDER=disabled),
# not a failure, so the source is quoted verbatim.
self._metrics.increment(metric_names.ANSWER_EXTRACTIVE)
citations = self._cited_only(indexed, extractive) or all_citations
return GroundedAnswer(result, extractive, citations)
# A generator WAS configured and this specific generation did not
# clear the safety checks (provider outage, malformed output, an
# ungrounded/uncited/unsupported claim). This product is a real
# LLM chatbot, not the retired offline-extractive build — a raw
# source dump is not an acceptable stand-in for a failed
# generation, so this abstains instead of silently degrading to
# one.
self._metrics.increment(
metric_names.ABSTENTION, reason="generation_unavailable"
)
return GroundedAnswer(
replace(
result,
decision=EvidenceDecision.ABSTAIN,
reason="generation_unavailable",
),
None,
)
# 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)
citations = self._cited_only(indexed, outcome.answer) or all_citations
self._metrics.increment(metric_names.GENERATION_SERVED)
return GroundedAnswer(result, generated, citations, generated=True)
return GroundedAnswer(result, outcome.answer, citations, generated=True)
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."""
) -> "_GenOutcome":
"""A verified generation, a clarifying question, or empty to fall back."""
if self._generator is None or not evidence_texts:
return None
return _GenOutcome()
request = build_request(query, evidence_texts, intro=intro)
try:
@@ -129,7 +196,7 @@ class GroundedAnswerService:
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="provider_unavailable"
)
return None
return _GenOutcome()
try:
payload = json.loads(raw)
@@ -139,28 +206,123 @@ class GroundedAnswerService:
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="malformed_output"
)
return None
return _GenOutcome()
# The model asked for a missing detail (age/weight/renal function/
# indication…) instead of listing every band. A clarify is not a grounded
# claim, so it skips the number check — it states no dose.
clarify = payload.get("clarifying_question") if isinstance(payload, dict) else None
if isinstance(clarify, str) and clarify.strip():
return _GenOutcome(clarification=clarify.strip())
if not isinstance(answer, str) or not isinstance(sufficient, bool):
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="malformed_output"
)
return None
return _GenOutcome()
if not sufficient:
# The model says the evidence does not answer the question. Showing
# the retrieved section verbatim lets the clinician judge that.
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="evidence_insufficient"
)
return None
return _GenOutcome()
report = grounding.verify(answer, evidence_texts)
if not report.grounded:
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason=report.reason
)
return _GenOutcome()
if not self._verify_entailment(answer, evidence_texts):
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="unsupported_claim"
)
return _GenOutcome()
return _GenOutcome(answer=answer)
def _verify_entailment(self, answer: str, evidence_texts: tuple[str, ...]) -> bool:
"""A second, adversarial LLM pass over an answer that already passed
`grounding.verify`.
The regex check above only binds numbers and citation indices — it
has no notion of meaning, so "Metformin chữa ung thư [1]" citing an
evidence block about "điều trị đái tháo đường" sails through it
untouched: right drug, right citation shape, fabricated indication.
This call is what catches that: each substantive, validly-cited claim
is checked against only the evidence block(s) it names, by a model
told to compare wording, not to reason about medicine.
Fails closed on an outage or malformed output. A single rejection is
NOT: live probing (2026-08-06) found the judge call itself is noisy
— the identical claim/evidence pair, called three times, came back
entailed twice and rejected once, discarding a correct, well-cited
interaction answer. So a reject triggers one same-claim retry, and
only a second, agreeing reject discards the generation; a single
provider outage/malformed response still fails closed immediately
(that failure mode is reliable, not noisy — no retry needed there).
An answer with no claim text at all (nothing between or after its
citation markers) is vacuously fine — nothing to verify, no call.
"""
claims = [
(claim.text, "\n".join(evidence_texts[i - 1] for i in claim.indices))
for claim in grounding.split_claims(answer, len(evidence_texts))
if claim.indices and grounding.has_content(claim.text)
]
if not claims:
return True
request = build_entailment_request(claims)
first = self._run_entailment_check(request)
if first is None:
return False
if first:
return True
second = self._run_entailment_check(request)
return bool(second)
def _run_entailment_check(self, request) -> bool | None:
"""One entailment call. `None` = outage/malformed (fails closed by the
caller without a retry); `True`/`False` = the judge's verdict."""
try:
raw = self._generator.generate(request.system, request.user, request.schema)
except AnswerGenerationUnavailable:
return None
return answer
try:
payload = json.loads(raw)
entailed = payload["entailed"]
unsupported = payload["unsupported"]
except (ValueError, TypeError, KeyError):
return None
if not isinstance(entailed, bool) or not isinstance(unsupported, list):
return None
return entailed and not unsupported
def _check_sufficiency(
self, query: str, evidence_texts: tuple[str, ...], intro: bool = False
) -> str | None:
"""A focused reasoning call: is the turn specific enough to answer, or
must we ask? Returns a clarifying question, or None to proceed.
Skipped without a model, for a bare-name intro (not a dose), or for a
single evidence block (nothing to disambiguate)."""
if self._generator is None or intro or len(evidence_texts) < 2:
return None
request = build_sufficiency_request(query, evidence_texts)
try:
raw = self._generator.generate(request.system, request.user, request.schema)
except AnswerGenerationUnavailable:
return None
try:
payload = json.loads(raw)
except (ValueError, TypeError):
return None
if isinstance(payload, dict) and payload.get("sufficient") is False:
question = payload.get("clarifying_question")
if isinstance(question, str) and question.strip():
return question.strip()
return None
@staticmethod
def _cited_only(
+30 -6
View File
@@ -87,23 +87,47 @@ class ConversationState:
summary: str = ""
focus: Focus = field(default_factory=Focus)
turn_count: int = 0
# Turns evicted from `recent` since the last time `overflow()` was
# consumed and cleared (by the caller passing `pending_overflow=()` to
# `replace()` after folding them into the summary). NOT derivable from
# `recent` alone — `recent` is already capped at `window`, so comparing
# its length against `window` can never find anything (see the bug note
# on `overflow` below). Plumbing, not conversation content.
pending_overflow: tuple[Turn, ...] = ()
def append(self, turn: Turn, window: int = RECENT_TURNS) -> "ConversationState":
"""Adds a turn and evicts the oldest beyond the window.
Eviction returns the dropped turns to the caller's summariser via
`overflow`, rather than discarding them here — this type does not
decide what a summary says.
Eviction accumulates the dropped turns into `pending_overflow` for
the caller's summariser to fold via `overflow()`, rather than
discarding them here — this type does not decide what a summary
says. Accumulates rather than overwrites because one turn commonly
triggers two `append()` calls in a row (user, then assistant); each
can evict at most one turn, and the second call must not lose the
first's.
"""
recent = (*self.recent, turn)[-window:]
combined = (*self.recent, turn)
recent = combined[-window:]
dropped = combined[:-window] if len(combined) > window else ()
return replace(
self,
recent=recent,
turn_count=self.turn_count + 1,
pending_overflow=(*self.pending_overflow, *dropped),
)
def overflow(self, window: int = RECENT_TURNS) -> tuple[Turn, ...]:
return self.recent[:-window] if len(self.recent) > window else ()
def overflow(self) -> tuple[Turn, ...]:
"""Turns evicted from `recent` and not yet folded into the summary.
Bug fixed 2026-08-06 (Codex review, F-06): this used to check
`len(self.recent) > window`, but `recent` is already truncated to
`window` by every `append()` call, so that comparison could never be
true — dropped turns were silently discarded and the summariser
never received them, no matter how long a conversation ran. The
caller must clear `pending_overflow` (pass `pending_overflow=()` to
`replace()`) after folding, or the same turns fold again next time.
"""
return self.pending_overflow
def inherited(self, name: str):
"""A focus value only if it is still fresh; otherwise None."""
+27 -2
View File
@@ -343,10 +343,31 @@ class ConversationalLoopService:
query, subject_scope, intent, drug_id=resolved.drug_id
)
# The engine asked for a missing detail (age/weight/renal function/
# indication…) rather than dumping every dose band — surface it as a
# clarification, not a final answer.
if grounded is not None and grounded.clarification is not None:
self._metrics.increment(
metric_names.CLARIFY_ASKED, reason="needs_more_info"
)
self._persist(state, resolved, None)
return ConversationTurnResult(
None,
Clarification(
reason="needs_more_info",
question=grounded.clarification,
options=(),
),
None,
False,
None,
"needs_more_info",
)
answer = grounded.answer if grounded else None
inherited = resolved.drug_id if resolved.needs_carry_over_notice else None
if answer is not None and inherited is not None:
answer = f"Về {inherited}: {answer}"
answer = f"Về {self._drug_name(inherited)}: {answer}"
if grounded is not None:
grounded = replace(grounded, answer=answer)
@@ -395,5 +416,9 @@ class ConversationalLoopService:
and state.overflow()
):
summary = self._summariser.fold(state.summary, state.overflow())
state = replace(state, summary=summary)
# Clear what was just folded — `replace()` keeps every field not
# named here, and `pending_overflow` accumulates across calls
# (see `ConversationState.append`), so leaving it would fold the
# same already-summarised turns again next time.
state = replace(state, summary=summary, pending_overflow=())
self._store.save(state)
+118 -24
View File
@@ -2,10 +2,25 @@
The answer layer may only rephrase retrieved text. This module is what makes
that a checkable property rather than a promise in a prompt: it recomputes,
from the evidence alone, whether every number and every citation in a
generated answer can be traced back to the source. A generation that fails is
from the evidence alone, whether every claim in a generated answer traces
back to the specific source block it cites. A generation that fails is
discarded, never shown.
Binding is per-citation, not global. The answer is split at each citation
marker group (one or more consecutive `[n]`); the text immediately before a
group is that group's claim, and only the evidence block(s) named in that
group may support it. A number that is true of evidence block 2 does not
make a claim citing `[1]` grounded — the old implementation pooled every
number from every evidence block into one set, which let a number attributed
to the wrong source pass silently. `evidence_texts` is positional: `[n]`
refers to `evidence_texts[n - 1]`.
A claim with no valid citation group is rejected outright — a citation
nobody can follow is not a citation, and an uncited clinical statement is not
verifiable, numeric or not. This catches a missing-citation defect that the
old check never looked for at all (it only ever checked numbers already
carrying a marker).
Numbers are compared **character for character**, deliberately. "7,5" and
"7.5" are not treated as equal, and no attempt is made to parse either into a
quantity. Parsing invites the one error that matters most here: `1.500` is
@@ -13,6 +28,13 @@ quantity. Parsing invites the one error that matters most here: `1.500` is
separators maps "7,5" and "75" to the same key — a tenfold dose error scored
as a match. The model is told to copy figures verbatim, so an exact match is
achievable, and every deviation from it is refused rather than interpreted.
What this module still cannot do: confirm that a citation-bearing nonnumeric
claim is actually *entailed* by the block it cites (e.g. "chữa ung thư [1]"
where evidence 1 is only about "điều trị đái tháo đường" — same drug name,
unrelated indication). Regex-level number/citation checking has no notion of
semantic content. That gap is closed separately by an LLM entailment pass
(`rag/answer.py`'s post-generation verifier call), not by this module.
"""
from __future__ import annotations
@@ -27,12 +49,23 @@ _NUMBER = re.compile(r"\d+(?:[.,]\d+)*")
# never mistaken for the quantity 2.
_CITATION = re.compile(r"\[(\d+)\]")
# One or more consecutive markers ("[1]", "[1][2]") count as a single group:
# the prompt allows citing more than one source for one claim, and each is
# checked against the union of just those sources, not all evidence.
_CITATION_GROUP = re.compile(r"(?:\[\d+\])+")
# Any word character — letter (Vietnamese diacritics included) or digit —
# used to tell "claim with actual content" apart from bare punctuation or
# whitespace trailing a citation, which needs no citation of its own.
_LETTER = re.compile(r"\w", re.UNICODE)
@dataclass(frozen=True)
class GroundingReport:
grounded: bool
unsupported_numbers: tuple[str, ...]
invalid_citations: tuple[int, ...]
uncited_claim: bool
cited_indices: tuple[int, ...]
@property
@@ -41,6 +74,8 @@ class GroundingReport:
return "ungrounded_number"
if self.invalid_citations:
return "invalid_citation"
if self.uncited_claim:
return "uncited_claim"
return "grounded"
@@ -53,31 +88,90 @@ def citations_in(text: str) -> tuple[int, ...]:
return tuple(int(marker) for marker in _CITATION.findall(text))
def verify(answer: str, evidence_texts: tuple[str, ...]) -> GroundingReport:
"""Whether `answer` states only figures and sources present in evidence.
def has_content(text: str) -> bool:
"""True once `text` carries any letter or digit — i.e. more than
punctuation or whitespace left over between/after citation markers."""
return _LETTER.search(text) is not None
`evidence_texts` is positional: citation `[n]` refers to
`evidence_texts[n - 1]`, so an out-of-range marker is a defect even when
the prose around it is faithful — a citation nobody can follow is not a
citation.
@dataclass(frozen=True)
class Claim:
"""One citation-bounded segment of an answer: the text before a citation
group, and the (in-range) evidence indices that group names.
`indices` is empty for the trailing segment after the last citation
group, or for a claim whose only marker(s) were out of range — in both
cases there is no evidence block left to check the claim against.
"""
source_numbers = set()
for text in evidence_texts:
source_numbers.update(numbers_in(text))
unsupported = tuple(
token for token in numbers_in(answer) if token not in source_numbers
)
invalid = tuple(
index
for index in citations_in(answer)
if not 1 <= index <= len(evidence_texts)
)
cited = tuple(sorted({index for index in citations_in(answer)} - set(invalid)))
text: str
indices: tuple[int, ...]
def split_claims(answer: str, evidence_count: int) -> tuple[Claim, ...]:
"""The claim segmentation `verify` checks numbers against, exposed so a
semantic entailment pass can run the same per-claim binding — each claim
checked only against the evidence block(s) it actually cites, never the
whole evidence set.
"""
claims: list[Claim] = []
cursor = 0
for group in _CITATION_GROUP.finditer(answer):
text = answer[cursor:group.start()]
cursor = group.end()
indices = tuple(
i for i in citations_in(group.group(0)) if 1 <= i <= evidence_count
)
claims.append(Claim(text, indices))
claims.append(Claim(answer[cursor:], ()))
return tuple(claims)
def verify(answer: str, evidence_texts: tuple[str, ...]) -> GroundingReport:
"""Whether `answer` states only figures and sources traceable to the
specific evidence block(s) cited immediately after each claim.
See module docstring for the binding rule and its known limit (no
semantic entailment check).
"""
unsupported: list[str] = []
invalid: list[int] = []
uncited = False
cited_all: set[int] = set()
cursor = 0
for group in _CITATION_GROUP.finditer(answer):
claim = answer[cursor:group.start()]
cursor = group.end()
indices = citations_in(group.group(0))
bad = [i for i in indices if not 1 <= i <= len(evidence_texts)]
good = [i for i in indices if i not in bad]
invalid.extend(bad)
cited_all.update(good)
claim_numbers = numbers_in(claim)
if good:
source_numbers: set[str] = set()
for index in good:
source_numbers.update(numbers_in(evidence_texts[index - 1]))
unsupported.extend(n for n in claim_numbers if n not in source_numbers)
else:
# Every marker in this group was out of range: nothing to bind
# the claim to, numeric or not.
unsupported.extend(claim_numbers)
if has_content(claim):
uncited = True
tail = answer[cursor:]
unsupported.extend(numbers_in(tail))
if has_content(tail):
uncited = True
return GroundingReport(
grounded=not unsupported and not invalid,
unsupported_numbers=unsupported,
invalid_citations=invalid,
cited_indices=cited,
grounded=not unsupported and not invalid and not uncited,
unsupported_numbers=tuple(unsupported),
invalid_citations=tuple(invalid),
uncited_claim=uncited,
cited_indices=tuple(sorted(cited_all)),
)
+62
View File
@@ -0,0 +1,62 @@
"""F-05: refuse to become ready on a corpus/model manifest mismatch.
Two different embedding models can produce vectors of the same
dimensionality; Qdrant returns plausible-looking but meaningless nearest
neighbours with no error at query time — a stale or wrong collection is
otherwise invisible until a clinician notices the answers are subtly off.
The ingestion loader already writes a sidecar manifest recording what a
collection was built from (`ingestion/ingestion/load/manifest.py`); this is
the query-time half — compare it against the configured query embedder
*before* serving anything, not after a bad answer is reported.
"""
from __future__ import annotations
MANIFEST_POINT_ID = "00000000-0000-5000-8000-000000000001"
class ManifestMismatch(RuntimeError):
"""The configured query embedder does not match what the collection was
built from. Raised at startup so the service refuses to become ready
rather than search with mismatched vectors."""
def manifest_collection(name: str) -> str:
return f"{name}__manifest"
def check_manifest(
payload: dict | None,
collection: str,
expected_model_id: str,
expected_dimensions: int,
) -> None:
"""Raises `ManifestMismatch` unless `payload` (the manifest sidecar
point's payload, or `None` if the sidecar/point is missing entirely)
matches the configured query embedder.
A collection with no manifest at all is refused for the same reason a
mismatched one is: nothing can be said about what it was built from, and
"probably fine" is not a load-bearing claim for a medical formulary.
"""
if payload is None:
raise ManifestMismatch(
f"{collection!r} has no corpus manifest "
f"({manifest_collection(collection)!r}) — refusing to query an "
"unattested corpus."
)
mismatches = []
if payload.get("model_id") != expected_model_id:
mismatches.append(
f"model_id: corpus={payload.get('model_id')!r} "
f"query_embedder={expected_model_id!r}"
)
if payload.get("dimensions") != expected_dimensions:
mismatches.append(
f"dimensions: corpus={payload.get('dimensions')!r} "
f"query_embedder={expected_dimensions!r}"
)
if mismatches:
raise ManifestMismatch(
f"{collection!r}'s corpus manifest does not match the configured "
"query embedder — " + "; ".join(mismatches)
)
+6
View File
@@ -54,3 +54,9 @@ LOOP_ROUNDS = "duocthu_loop_retrieval_rounds_total"
LOOP_REFINED = "duocthu_loop_refined_total"
LOOP_REPAIRED = "duocthu_loop_repaired_total"
FOLLOWUP_INHERITED = "duocthu_followup_inherited_total"
# Trace persistence is fail-open (F-09): a Postgres outage must not turn an
# already-computed, safe answer into a 500. This counts how often that
# degradation actually happens, since a silent fail-open with no counter is
# indistinguishable from tracing quietly working.
TRACE_WRITE_FAILED = "duocthu_trace_write_failed_total"
+71
View File
@@ -0,0 +1,71 @@
"""Server-derived subject scope.
`routing.py`'s `_scope_gate` abstains outright on `NON_HUMAN` scope. Before
this module, that value came straight from the request body — a caller could
send `{"subject_scope":"human",...}` regardless of the query text, and the
shipped web BFF did exactly that, hard-coded on every request without reading
the message at all (Codex's 2026-08-06 review, F-02). A scope decision must
not be something the caller gets to assert.
`resolve_subject_scope` derives it from the query text and combines that with
whatever the caller claimed by taking the more conservative of the two: a
caller can *narrow* scope (claim `non_human` and have it stick) but can never
*widen* it — a claim of `human` cannot override a server-detected veterinary
turn. This is a corpus-coverage check, not a restriction on what a doctor or
pharmacist is allowed to ask: the book behind this product covers human
drug monographs only, so a query about dosing a dog is out of scope
regardless of who is asking. It has nothing to do with, and must never be
extended into, gatekeeping what kind of *clinical* question a professional
user is allowed to ask (see `[[feedback_no_recommendation_gate]]`/progress
log 2026-08-06 for the removed `QueryIntent.RECOMMENDATION` keyword
detector — this product is for doctors and pharmacists, not lay users, and a
"nên dùng thuốc gì" question from a clinician is exactly what a formulary
reference is for, not something to abstain on).
Deliberately not an LLM call: this is the gate every request passes through,
so it must be cheap, available during a provider outage, and auditable as a
fixed rule instead of a model judgment. It is a keyword heuristic, which
means it has real blind spots (an unusual phrasing can still slip past) — the
same trade-off `rag/agent.py`'s `_looks_non_human` backstop already accepts.
`rag/agent.py` should consolidate onto this module once the new orchestrator
is wired (F-03), instead of keeping a second, narrower keyword list.
"""
from __future__ import annotations
from .models import SubjectScope
from .text import normalize_name
# Phrases that name a non-human recipient. Matched on normalized text (casefold,
# diacritics stripped) so "chó", "CHÓ", "cho chó" all match one entry.
_NON_HUMAN_PHRASES = (
"cho cho", # "cho chó" — normalize_name collapses "chó" -> "cho"
"cho meo",
"cho ga",
"cho vit",
"cho lon",
"cho heo",
"cho bo",
"cho ngua",
"cho de",
"cho cuu",
"thu y",
"vat nuoi",
"gia suc",
"gia cam",
"dong vat",
)
def looks_non_human(query: str) -> bool:
normalized = normalize_name(query)
return any(phrase in normalized for phrase in _NON_HUMAN_PHRASES)
def resolve_subject_scope(query: str, claimed: SubjectScope) -> SubjectScope:
"""The scope that actually gates retrieval: the more conservative of what
the caller claimed and what the query text itself indicates."""
if claimed == SubjectScope.NON_HUMAN or looks_non_human(query):
return SubjectScope.NON_HUMAN
if claimed == SubjectScope.UNKNOWN:
return SubjectScope.UNKNOWN
return SubjectScope.HUMAN
+112
View File
@@ -36,6 +36,19 @@ Quy tắc bắt buộc:
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.
7. HỎI LẠI khi thiếu dữ kiện — ĐÂY LÀ QUY TẮC QUAN TRỌNG NHẤT, ưu tiên hơn việc
trả lời. TUYỆT ĐỐI KHÔNG liệt kê nhiều mức liều rồi để người đọc tự chọn.
Nếu là câu hỏi LIỀU/CÁCH DÙNG và bằng chứng phân mức theo điều kiện (tuổi,
cân nặng, chức năng thận/gan, chỉ định/bệnh, đường dùng, mức độ nặng…) mà
người dùng CHƯA nêu đủ (các) điều kiện để chọn ĐÚNG MỘT mức, thì BẮT BUỘC:
để `answer`="", `evidence_sufficient`=false, và đặt `clarifying_question`
hỏi NGẮN GỌN tất cả dữ kiện còn thiếu.
- "trẻ em" hay "cho trẻ" nói chung là CHƯA đủ (liều trẻ em thay đổi theo
tuổi/cân nặng) → phải hỏi lại, KHÔNG được liệt kê các nhóm tuổi.
- "người lớn" thường là đủ cho liều người lớn tiêu chuẩn → trả lời được.
Ví dụ clarifying_question: "Bé mấy tuổi, cân nặng bao nhiêu kg, dùng đường
nào (uống/đặt hậu môn/tiêm) và để hạ sốt hay giảm đau?".
Nếu đã đủ dữ kiện thì trả lời bình thường, `clarifying_question`=null.
Viết gọn. Trả lời đúng điều được hỏi, không mở rộng phạm vi."""
@@ -54,12 +67,59 @@ ANSWER_SCHEMA = {
"type": "boolean",
"description": "false nếu bằng chứng không đủ để trả lời câu hỏi.",
},
"clarifying_question": {
"type": ["string", "null"],
"description": (
"Câu hỏi lại khi người dùng chưa nêu đủ dữ kiện (vd tuổi/cân nặng "
"cho câu hỏi liều có nhiều mức). null nếu đã đủ dữ kiện để trả lời."
),
},
},
"required": ["answer", "evidence_sufficient"],
"additionalProperties": False,
}
SUFFICIENCY_SYSTEM = """\
Bạn là bộ KIỂM TRA ĐỦ DỮ KIỆN cho tra cứu Dược thư, chạy TRƯỚC khi trả lời.
Cho CÂU HỎI của người dùng và BẰNG CHỨNG, xác định câu hỏi đã đủ dữ kiện để đưa
ra ĐÚNG MỘT câu trả lời cụ thể hay chưa.
Quy tắc:
- Nếu là câu hỏi về LIỀU/CÁCH DÙNG và BẰNG CHỨNG có NHIỀU mức khác nhau theo điều
kiện (tuổi, cân nặng, chức năng thận/gan, chỉ định/bệnh, đường dùng, mức độ
nặng) mà CÂU HỎI chưa nêu đủ (các) điều kiện để chọn đúng MỘT mức → CHƯA đủ.
- "trẻ em" / "cho trẻ" / "cho bé" nói chung là CHƯA đủ (liều trẻ thay đổi theo
tuổi và cân nặng). "người lớn" thường ĐỦ cho liều người lớn tiêu chuẩn.
- Câu hỏi KHÔNG về liều (chống chỉ định, tương tác, tác dụng phụ, giới thiệu
thuốc…) thì thường ĐỦ.
Trả về DUY NHẤT JSON: {"sufficient": bool, "clarifying_question": string|null}.
Nếu CHƯA đủ: sufficient=false và clarifying_question hỏi NGẮN GỌN tất cả dữ kiện
còn thiếu (vd: "Bé mấy tuổi, cân nặng bao nhiêu kg, dùng đường nào và để hạ sốt
hay giảm đau?"). Nếu đủ: sufficient=true, clarifying_question=null."""
SUFFICIENCY_SCHEMA = {
"type": "object",
"properties": {
"sufficient": {"type": "boolean"},
"clarifying_question": {"type": ["string", "null"]},
},
"required": ["sufficient", "clarifying_question"],
"additionalProperties": False,
}
def build_sufficiency_request(
question: str, evidence_texts: tuple[str, ...]
) -> "GenerationRequest":
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}"
return GenerationRequest(system=SUFFICIENCY_SYSTEM, user=user, schema=SUFFICIENCY_SCHEMA)
@dataclass(frozen=True)
class GenerationRequest:
system: str
@@ -67,6 +127,58 @@ class GenerationRequest:
schema: dict
ENTAILMENT_SYSTEM = """\
Bạn là bộ KIỂM TRA ĐỘ CHÍNH XÁC, chạy SAU khi một câu trả lời đã được sinh ra.
Với mỗi CÂU dưới đây, so sánh nó với ĐÚNG đoạn BẰNG CHỨNG ĐƯỢC TRÍCH đi kèm câu
đó (câu đã được gắn số nguồn [n] trỏ tới đúng đoạn này). Việc DUY NHẤT cần làm:
nội dung của CÂU có được đoạn BẰNG CHỨNG ĐƯỢC TRÍCH đó — và CHỈ đoạn đó — nói
tới hay không. KHÔNG dùng kiến thức y khoa của bạn, KHÔNG suy luận thêm, KHÔNG
tự hỏi liệu câu đó có hợp lý về mặt y khoa hay không.
Một CÂU là KHÔNG được chứng thực nếu nó nêu chỉ định, chống chỉ định, cơ chế,
tương tác, đối tượng áp dụng, hoặc bất kỳ quan hệ nào mà đoạn BẰNG CHỨNG ĐƯỢC
TRÍCH của nó KHÔNG nói tới — kể cả khi câu đó đúng về mặt y khoa, và kể cả khi
đúng tên thuốc nhưng sai ý (ví dụ bằng chứng nói "điều trị đái tháo đường"
câu nói "chữa ung thư").
LƯU Ý QUAN TRỌNG: bằng chứng trong lĩnh vực dược thường liệt kê nhiều tên
thuốc trong một câu/danh sách dài, phân cách bởi dấu phẩy (ví dụ: "Tác dụng
của warfarin có thể tăng lên khi dùng với: acetaminophen, allopurinol, ...,
aspirin, kháng sinh, ..."). Hãy ĐỌC KỸ TOÀN BỘ danh sách trước khi kết luận —
nếu tên thuốc trong CÂU xuất hiện ở bất kỳ đâu trong danh sách đó với đúng
quan hệ đang nói (vd "làm tăng tác dụng của X"), đó LÀ được chứng thực, dù
tên thuốc chỉ là một mục nhỏ giữa danh sách dài.
Trả về DUY NHẤT JSON: {"entailed": bool, "unsupported": [danh sách số thứ tự
1-based của các CÂU KHÔNG được chứng thực; rỗng nếu tất cả đều được chứng
thực]}. entailed=true chỉ khi unsupported rỗng."""
ENTAILMENT_SCHEMA = {
"type": "object",
"properties": {
"entailed": {"type": "boolean"},
"unsupported": {"type": "array", "items": {"type": "integer"}},
},
"required": ["entailed", "unsupported"],
"additionalProperties": False,
}
def build_entailment_request(claims: list[tuple[str, str]]) -> "GenerationRequest":
"""`claims` is a list of (claim_text, cited_evidence_text) pairs — already
filtered by the caller to the claims worth checking (substantive content,
a validly-cited evidence block to check it against)."""
if not claims:
raise ValueError("cannot build an entailment request with no claims")
blocks = "\n\n".join(
f"CÂU {index}: {claim}\nBẰNG CHỨNG ĐƯỢC TRÍCH: {evidence}"
for index, (claim, evidence) in enumerate(claims, start=1)
)
user = f"{blocks}\n\nKiểm tra từng CÂU theo đúng BẰNG CHỨNG ĐƯỢC TRÍCH của nó."
return GenerationRequest(system=ENTAILMENT_SYSTEM, user=user, schema=ENTAILMENT_SCHEMA)
def build_request(
question: str, evidence_texts: tuple[str, ...], intro: bool = False
) -> GenerationRequest:
+53
View File
@@ -58,6 +58,59 @@ class RetrievalService:
self._section_resolver = section_resolver
self._reranker = reranker
def retrieve_framed(
self,
drug_id: str,
section_key: str | None,
query: str,
is_overview: bool = False,
) -> RetrievalResult:
"""Retrieve driven by an already-understood frame, not by parsing text.
The LLM understanding layer has resolved the drug (against the real
catalog) and, when the turn named one, the section. So this skips the
fuzzy `CatalogDrugResolver` and the keyword `SectionResolver` entirely:
a named `section_key` filters that section whole; without one, this
mirrors `retrieve()`'s two remaining cases — `is_overview` (the frame's
`turn_type == "drug_overview"`, a bare name) answers from the identity
sections only, and a free-form question reranks the full monograph
down to `rerank_top_k`.
Found live 2026-08-06: without the `is_overview` split, a bare drug
name sent the ENTIRE ~29-section monograph as evidence for every
generation call (retrieval had no notion of "just the intro"), which
is both wrong retrieval and, downstream, an answer so long it
intermittently failed generation/entailment outright. `query` is only
the rerank signal here, never a resolution input.
"""
if not drug_id.strip():
return RetrievalResult(EvidenceDecision.ABSTAIN, "missing_query_or_drug")
if section_key:
find_by_section = getattr(self._retriever, "find_by_section", None)
if find_by_section is not None:
hits = find_by_section(drug_id, section_key)
if hits:
return self._decide(self._hydrate(hits, limit=None))
overview_hits = self._drug_overview(drug_id)
if overview_hits is None:
return RetrievalResult(EvidenceDecision.ABSTAIN, "insufficient_retrieval_score")
if is_overview:
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
)
overview_hits = self._rerank(query, overview_hits)
# Capped even when rerank is disabled/unavailable and fails open to
# the unfiltered list — an ordering aid must never remove the size
# bound too, or the same 29-section explosion returns through here.
return self._decide(
self._hydrate(overview_hits, limit=self._policy.evidence_limit)
)
def retrieve(self, query: str, drug_id: str) -> RetrievalResult:
if not query.strip() or not drug_id.strip():
return RetrievalResult(EvidenceDecision.ABSTAIN, "missing_query_or_drug")
+353
View File
@@ -0,0 +1,353 @@
"""LLM-driven query understanding — the front-end of the RAG chatbot.
The old drug-first path resolved a drug with a fuzzy `SequenceMatcher` and routed
sections with a Vietnamese phrase table. Both are brittle string heuristics: they
false-matched a made-up name (``aspirinol`` -> aspirin), tied on a correctly
spelled English INN (``amoxicillin``), and mistook a common word (``uống``) for a
drug. This module replaces that with the model the system already has.
Division of labour, and why it is safe for a formulary:
- The **LLM** does the messy human-language part — which token is a drug, is this
an interaction / a symptom lookup / a weight-based dose, what section is asked,
what population/weight. It is good at exactly the fuzziness the heuristics were
bad at.
- The **catalog** stays the authority on drug *identity*. The model may only pick
``drug_id`` values from a list *bounded before the model ever runs* — a
deterministic alias/fuzzy pass over the turn and history (`CandidateSource`)
decides which real drugs are even plausible candidates, and only those are
shown. This closes a gap the catalog-whitelist alone did not (F-04, Codex
2026-08-06 review): validating that an output id is *some* real drug_id does
not prove it is the *one the user's text actually named* — an LLM could
satisfy that whitelist while mapping an unrelated or invented name to any of
the other 683 real drugs. Bounding candidates first removes that degree of
freedom: the model can still read ``amoxicillin`` as ``amoxicilin`` (a fuzzy
match puts it in the candidate set) but cannot map ``aspirinol`` to aspirin,
because nothing about ``aspirinol`` fuzzy-matches anything and the candidate
set the model is shown is empty or contains unrelated drugs, not aspirin.
- Nothing here answers the medical question. It only produces a structured frame;
retrieval + ``grounding.verify`` remain the load-bearing safety layer downstream.
`rag/` imports no SDK: the LLM is injected as a ``JsonLlm`` protocol (satisfied by
`adapters.bedrock_converse.BedrockConverseAnswerGenerator`), and a deterministic
stub runs the whole path offline in tests.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Protocol, Sequence
# The 19 monograph section keys, kept here as the closed vocabulary the model may
# use for `attribute`. Adding a new section is one entry, not a code change.
SECTION_KEYS = (
"ten_chung_quoc_te",
"ten_thuong_mai",
"ma_atc",
"loai_thuoc",
"dang_thuoc_va_ham_luong",
"duoc_ly_va_co_che_tac_dung",
"chi_dinh",
"chong_chi_dinh",
"than_trong",
"thoi_ky_mang_thai",
"thoi_ky_cho_con_bu",
"tac_dung_khong_mong_muon",
"huong_dan_xu_tri_adr",
"lieu_luong_va_cach_dung",
"tuong_tac_thuoc",
"qua_lieu_va_xu_tri",
"do_on_dinh_va_bao_quan",
"tuong_ky",
"thong_tin_quy_che",
)
# Short glosses shown to the model alongside each key. Found live 2026-08-06
# (golden e2e set): a bare key list gives the model nothing to disambiguate
# "thận trọng" from "chống chỉ định" — 9/9 live calls for "X cần thận trọng
# gì?" picked chong_chi_dinh, silently answering from the wrong section
# (and, downstream, dropping the specific safety content the precautions
# section actually has, e.g. metformin's lactic acidosis warning). The two
# are genuinely adjacent concepts in Vietnamese medical text; a bare slug
# name is not enough to tell a model which one a question means.
SECTION_KEY_HINTS: dict[str, str] = {
"ten_chung_quoc_te": "tên chung quốc tế/INN",
"ten_thuong_mai": "tên thương mại/biệt dược",
"ma_atc": "mã ATC",
"loai_thuoc": "phân loại thuốc",
"dang_thuoc_va_ham_luong": "dạng bào chế và hàm lượng",
"duoc_ly_va_co_che_tac_dung": "dược lý, cơ chế tác dụng",
"chi_dinh": "chỉ định — bệnh/triệu chứng thuốc dùng để điều trị",
"chong_chi_dinh": (
"CHỐNG CHỈ ĐỊNH — trường hợp TUYỆT ĐỐI KHÔNG được dùng thuốc này"
),
"than_trong": (
"THẬN TRỌNG — KHÁC chống chỉ định: vẫn dùng được nhưng cần cảnh "
"giác/theo dõi/chỉnh liều (ví dụ nguy cơ nhiễm toan lactic của "
"metformin, độc tính thận/tai của gentamicin). Câu hỏi có chữ "
"\"thận trọng\", \"cẩn thận\", \"lưu ý gì\", \"cần chú ý\" → key này, "
"KHÔNG PHẢI chong_chi_dinh."
),
"thoi_ky_mang_thai": "dùng khi mang thai",
"thoi_ky_cho_con_bu": "dùng khi cho con bú",
"tac_dung_khong_mong_muon": "tác dụng phụ/ADR",
"huong_dan_xu_tri_adr": "cách xử trí khi gặp ADR",
"lieu_luong_va_cach_dung": "liều dùng và cách dùng",
"tuong_tac_thuoc": "tương tác với thuốc khác",
"qua_lieu_va_xu_tri": "quá liều và cách xử trí",
"do_on_dinh_va_bao_quan": "độ ổn định, bảo quản",
"tuong_ky": "tương kỵ (không pha/trộn được với gì)",
"thong_tin_quy_che": "thông tin quy chế/pháp lý",
}
# What kind of turn this is — the router branches on it. Deliberately explicit so a
# symptom lookup is never silently treated as a failed drug lookup, and a two-drug
# interaction never collapses to an "ambiguous drug" abstain.
TURN_TYPES = (
"drug_attribute", # one drug, one/more sections ("liều paracetamol")
"drug_overview", # a bare drug name, wants the monograph
"interaction", # 2+ drugs, asks about combining them
"symptom_to_drug", # a symptom/indication, wants candidate drugs
"dosing_calc", # a dose that needs weight/age arithmetic
"smalltalk", # greeting / meta, not a medical query
"out_of_scope", # not answerable from the Part-2 monographs
)
@dataclass(frozen=True)
class QueryFrame:
"""The structured reading of one user turn. No medical content, only intent."""
turn_type: str
drugs: tuple[str, ...] = () # canonical drug_ids, from the catalog only
unknown_drugs: tuple[str, ...] = () # mentioned, not in the catalog
attribute: str | None = None # a SECTION_KEYS value, or None
population: str | None = None # e.g. "tre_em", "nguoi_lon", "suy_than"
weight_kg: float | None = None
age_text: str | None = None
indication: str | None = None # symptom/disease, for symptom_to_drug
needs_clarify: bool = False
clarify_reason: str | None = None
raw: dict = field(default_factory=dict, compare=False)
# The JSON contract the model must fill. Stated in the prompt (Converse has no
# server-side schema) and validated on the way back.
FRAME_SCHEMA = {
"turn_type": "one of: " + " | ".join(TURN_TYPES),
"drugs": ["drug_id exactly as it appears in the provided catalog list"],
"unknown_drugs": ["a drug name the user mentioned that is NOT in the catalog"],
"attribute": "one of the section keys provided, or null",
"population": "tre_em | tre_so_sinh | nguoi_lon | nguoi_cao_tuoi | phu_nu_co_thai | phu_nu_cho_con_bu | suy_than | suy_gan | null",
"weight_kg": (
"number if a body weight is given, else null. Vietnamese casual speech "
"states weight as a bare number of 'cân' or '' with no unit word "
"('bé 30 cân', 'nặng 30 ký') — both mean kilograms; read the number as "
"weight_kg the same as if 'kg' had been written."
),
"age_text": "the age exactly as stated (e.g. '3 tuổi', '5 tháng'), else null",
"indication": "the symptom or disease if turn_type is symptom_to_drug, else null",
"needs_clarify": "true only if the turn cannot be acted on without more info",
"clarify_reason": "short Vietnamese question to ask, or null",
}
_SYSTEM = """Bạn là bộ HIỂU CÂU HỎI cho một chatbot tra cứu Dược thư Quốc gia Việt Nam.
Nhiệm vụ: đọc câu hỏi (tiếng Việt, có thể sai chính tả, viết tắt, nhiều lượt) và
XUẤT RA một JSON mô tả ý định. TUYỆT ĐỐI KHÔNG trả lời câu hỏi y khoa, không nêu liều.
Quy tắc bắt buộc:
- Trường "drugs" CHỈ được chứa các drug_id có trong DANH SÁCH THUỐC được cung cấp.
Nếu người dùng nhắc một thuốc KHÔNG có trong danh sách (kể cả tên bịa như
"aspirinol"), đưa tên đó vào "unknown_drugs", KHÔNG được gán sang thuốc gần giống.
- Sai chính tả một thuốc CÓ trong danh sách thì sửa về đúng drug_id của nó
(ví dụ "amoxicillin" -> "amoxicilin", "metfomin" -> "metformin").
- Nếu câu nhắc 2 thuốc trở lên và hỏi về dùng chung/tương tác -> turn_type="interaction".
- Nếu là triệu chứng/bệnh cần gợi ý thuốc (không nêu tên thuốc) -> "symptom_to_drug",
điền "indication".
- Nếu hỏi liều cần cân nặng/tuổi -> "dosing_calc", điền weight_kg/age_text nếu có.
Nói cân nặng kiểu thường ngày ("bé 30 cân", "nặng 30 ký", chỉ 1 số + "cân"/""
không kèm đơn vị khác) NGHĨA LÀ 30 kg -> điền weight_kg=30, không bỏ trống.
- Lượt nối tiếp ("còn liều thì sao", "nó dùng cho trẻ em?") -> dùng LỊCH SỬ để biết
thuốc đang nói tới và điền vào "drugs".
- Chào hỏi/vu vơ -> "smalltalk". Ngoài phạm vi chuyên luận thuốc -> "out_of_scope"."""
class JsonLlm(Protocol):
"""A model that returns a single JSON object as text. Satisfied by the
existing Bedrock Converse generator, so this adds no SDK to `rag/`."""
def generate(self, system: str, user: str, schema: dict) -> str: ...
class QueryUnderstander(Protocol):
def understand(
self, turn: str, history: Sequence[str] = ()
) -> QueryFrame: ...
class CandidateSource(Protocol):
"""Deterministic, no-LLM drug-name matching — what bounds the model's
choice before it ever runs (F-04). Satisfied by `routing.CatalogDrugResolver`;
kept as a protocol (not an import of it) so this module stays decoupled
from the fuzzy-matching implementation, only its shape.
"""
def resolve(self, query: str): ...
def suggest(
self, query: str, k: int = 3, min_score: float = 0.5
) -> list[tuple[str, float]]: ...
class LlmQueryUnderstander:
"""Turns a raw user turn into a `QueryFrame` with one LLM call.
`catalog` maps drug_id -> a human name (used only to label whichever
candidates get shown). `resolver` is what actually decides which real
drugs are plausible for this turn, deterministically, before the model
runs at all: every drug_id an exact-alias or fuzzy match finds anywhere
in the turn or the raw history text. The model then picks only among
those — never the full ~684-drug catalog — so it structurally cannot
map an invented or unrelated name to some other real drug_id it merely
happens to also list correctly (F-04). This also directly answers a
separate 2026-08-06 review finding: sending the full catalog on every
turn is unbounded token cost; a per-turn candidate shortlist is both
safer and cheaper.
"""
def __init__(self, llm: JsonLlm, catalog: dict[str, str], resolver: CandidateSource) -> None:
self._llm = llm
self._catalog = catalog
self._resolver = resolver
def _candidate_ids(self, turn: str, history: Sequence[str]) -> set[str]:
"""Every drug_id a deterministic pass finds plausible in the turn or
the raw history text. Deliberately generous — an exact alias match
plus a fuzzy `suggest` well below the resolver's own auto-answer
threshold — because the job here is only to rule out drugs nothing
in the conversation plausibly refers to, not to pick the right one;
that disambiguation is still the model's job, within this bound.
"""
ids: set[str] = set()
for line in (turn, *history):
if not line.strip():
continue
resolution = self._resolver.resolve(line)
if resolution.status == "resolved" and resolution.drug_id:
ids.add(resolution.drug_id)
elif resolution.status == "ambiguous":
ids.update(resolution.candidate_drug_ids)
for drug_id, _score in self._resolver.suggest(line, k=5, min_score=0.55):
ids.add(drug_id)
return ids
def understand(self, turn: str, history: Sequence[str] = ()) -> QueryFrame:
shown = {
drug_id: self._catalog[drug_id]
for drug_id in self._candidate_ids(turn, history)
if drug_id in self._catalog
}
catalog_block = (
"\n".join(f"{drug_id}\t{name}" for drug_id, name in sorted(shown.items()))
if shown
else "(không có thuốc nào trong Dược thư khớp với lượt này hoặc lịch sử gần đây)"
)
history_block = (
"LỊCH SỬ HỘI THOẠI (cũ -> mới):\n" + "\n".join(history)
if history else "LỊCH SỬ HỘI THOẠI: (chưa có)"
)
user = (
f"DANH SÁCH THUỐC ỨNG VIÊN cho lượt này (drug_id\\ttên) — CHỈ được chọn "
f"drug_id từ đây, đây KHÔNG phải toàn bộ Dược thư, chỉ là các thuốc khớp "
f"với chữ trong lượt/lịch sử:\n"
f"{catalog_block}\n\n"
"CÁC SECTION KEY hợp lệ cho 'attribute' (key: ý nghĩa):\n"
+ "\n".join(f"{key}: {SECTION_KEY_HINTS[key]}" for key in SECTION_KEYS)
+ "\n\n"
f"{history_block}\n\n"
f"CÂU HỎI HIỆN TẠI: {turn}"
)
raw_text = self._llm.generate(_SYSTEM, user, FRAME_SCHEMA)
return self._parse(raw_text, shown)
@staticmethod
def _resolve_id(value: str, shown: dict[str, str]) -> str | None:
if value in shown:
return value
# `drug_id` is shown with underscores ("paracetamol_acetaminophen")
# but its own canonical display name (bootstrap's `_catalog_names`)
# is the same string with spaces — found live 2026-08-06: the two
# look near-identical in the "drug_id\tname" table, and the model
# sometimes echoes the spaced display form instead of the id. This
# is a deterministic, lossless formatting difference (not a fuzzy
# match — one specific known substitution), so it's tolerated here
# rather than dropping a correctly-identified drug to unknown.
spaced = value.strip().casefold()
for drug_id in shown:
if drug_id.replace("_", " ").casefold() == spaced:
return drug_id
return None
def _parse(self, raw_text: str, shown: dict[str, str]) -> QueryFrame:
try:
data = json.loads(raw_text)
except (json.JSONDecodeError, TypeError):
# Fail closed to a clarify rather than to a wrong reading.
return QueryFrame(
turn_type="out_of_scope",
needs_clarify=True,
clarify_reason="Xin lỗi, tôi chưa hiểu rõ câu hỏi. Anh/chị hỏi lại giúp nhé?",
)
resolved = [
(d, self._resolve_id(d, shown)) for d in _as_list(data.get("drugs"))
]
drugs = tuple(dict.fromkeys(rid for _, rid in resolved if rid is not None))
# A drug the model named but that resolves to no id among the shown
# candidates (exact or underscore/space form) is unknown, not a
# silent drop and not a fuzzy substitution to an unrelated drug.
unknown = tuple(
d for d, rid in resolved if rid is None
) + tuple(_as_list(data.get("unknown_drugs")))
attribute = data.get("attribute")
if attribute not in SECTION_KEYS:
attribute = None
turn_type = data.get("turn_type")
if turn_type not in TURN_TYPES:
turn_type = "drug_attribute" if drugs else "out_of_scope"
return QueryFrame(
turn_type=turn_type,
drugs=drugs,
unknown_drugs=tuple(dict.fromkeys(unknown)),
attribute=attribute,
population=_clean_str(data.get("population")),
weight_kg=_clean_float(data.get("weight_kg")),
age_text=_clean_str(data.get("age_text")),
indication=_clean_str(data.get("indication")),
needs_clarify=bool(data.get("needs_clarify")),
clarify_reason=_clean_str(data.get("clarify_reason")),
raw=data if isinstance(data, dict) else {},
)
def _as_list(value) -> list[str]:
if isinstance(value, str):
return [value] if value.strip() else []
if isinstance(value, list):
return [str(v).strip() for v in value if str(v).strip()]
return []
def _clean_str(value) -> str | None:
if isinstance(value, str) and value.strip() and value.strip().lower() != "null":
return value.strip()
return None
def _clean_float(value) -> float | None:
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value.replace(",", ".").split()[0])
except (ValueError, IndexError):
return None
return None