Checkpoint frontend UI/UX overhaul and ingestion embed benchmark work
This commit is contained in:
@@ -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 = " và ".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
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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" mà
|
||||
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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 'ký' 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"/"ký"
|
||||
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
|
||||
Reference in New Issue
Block a user