Remove corpus counts from chat chrome

This commit is contained in:
2026-08-10 17:26:58 +07:00
parent 46469468bb
commit 97cb6d16f4
31 changed files with 2192 additions and 424 deletions
+160 -21
View File
@@ -19,10 +19,10 @@ from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from dataclasses import dataclass, replace
from typing import Protocol
from .answer import Citation, GroundedAnswerService
from .answer import AnswerBlock, AnswerPlan, Citation, GroundedAnswerService
from .budget import RequestBudget
from .models import EvidenceDecision, RetrievalResult
from .policy import looks_non_human
@@ -33,14 +33,14 @@ logger = logging.getLogger(__name__)
TUONG_TAC = "tuong_tac_thuoc"
HISTORY_TURNS = 6
# F-08: measured live 2026-08-07, a normal answerable turn makes 4 sequential
# Bedrock calls (understand, sufficiency, generate, entailment) and costs
# ~8-9s; a noisy entailment retry adds a 5th. Defaults sized with headroom
# above that measured normal case, not at its exact edge, so ordinary
# F-08: a normal answerable agent turn makes 3 sequential Bedrock calls
# (understand, generate, entailment). Input-field sufficiency is enforced by
# the structured state machine; generation still fails closed when retrieved
# evidence is insufficient. Defaults are sized with headroom, so ordinary
# traffic never trips the budget — it exists for the pathological case
# (a stuck/slow call, or an unexpectedly long retry chain), not to shave
# time off the common path.
MAX_WALL_CLOCK_MS = 20_000
MAX_WALL_CLOCK_MS = 40_000
MAX_LLM_CALLS_PER_TURN = 8
# Found live 2026-08-07 (50-question hand-typed browser audit): the
# understanding LLM can get stuck re-asking the same (or a near-identical)
@@ -81,6 +81,9 @@ class AgentReply:
turn_type: str = ""
generated: bool = False
quick_replies: tuple[str, ...] = ()
blocks: tuple[AnswerBlock, ...] = ()
answer_mode: str = "concise"
plan: AnswerPlan | None = None
class RagAgent:
@@ -132,7 +135,20 @@ class RagAgent:
t3 = time.monotonic()
if conversation_id is not None:
self._remember(conversation_id, turn, reply)
self._last_frame[conversation_id] = frame
# A clarify can originate downstream of understanding (the dose
# route invariant or evidence sufficiency). Persist that as an
# open frame too; otherwise the next short reply sees a prior
# frame marked complete and the structured merge cannot inherit
# the drug/population the user already supplied.
remembered_frame = frame
if reply.decision == "clarify" and reply.clarification:
remembered_frame = replace(
frame,
needs_clarify=True,
clarify_reason=reply.clarification,
quick_replies=reply.quick_replies,
)
self._last_frame[conversation_id] = remembered_frame
t4 = time.monotonic()
# Temporary instrumentation (2026-08-07): added specifically to
# pinpoint a live, reproduced-in-browser case of the FIRST LLM call
@@ -204,8 +220,71 @@ class RagAgent:
def _route(self, turn: str, frame: QueryFrame, budget: RequestBudget) -> AgentReply:
tt = frame.turn_type
section_overview = _is_section_overview(turn, frame)
if section_overview and not frame.section_overview:
frame = replace(frame, section_overview=True)
if frame.needs_clarify and frame.clarify_reason:
# Deterministic scope guard precedes every conversational clarify. A
# non-human dose must abstain, never ask which attribute/route and make
# an out-of-scope request look recoverable.
if 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)
# Dosing is a small state machine, not an unconstrained model opinion.
# The LLM extracts the fields and can phrase/populate a useful initial
# clarify; code decides which core fields are actually required. Live
# testing caught the model asking an adult's weight repeatedly even
# after the user supplied a route, while previously skipping route and
# dumping oral + rectal regimens together.
if tt == "dosing_calc" and frame.drugs and not section_overview:
if frame.population is None:
return AgentReply(
"clarify", "missing_population",
clarification=(
frame.clarify_reason
if frame.needs_clarify and frame.clarify_reason
else "Anh/chị muốn tra liều cho người lớn hay trẻ em?"
),
drugs=frame.drugs, turn_type=tt,
quick_replies=(
frame.quick_replies
if frame.needs_clarify else ()
),
)
if frame.population in {"tre_em", "tre_so_sinh"} and (
frame.age_text is None or frame.weight_kg is None
):
return AgentReply(
"clarify", "missing_pediatric_age_or_weight",
clarification=(
frame.clarify_reason
if frame.needs_clarify and frame.clarify_reason
else "Bé bao nhiêu tuổi và cân nặng bao nhiêu kg?"
),
drugs=frame.drugs, turn_type=tt,
quick_replies=(
frame.quick_replies
if frame.needs_clarify else ()
),
)
# Route is intentionally not a universal required slot. Retrieval
# and the answer contract decide from the actual evidence whether
# omitting it is harmless (one applicable route -> answer now) or
# materially ambiguous (several routes -> model clarification and
# model-proposed quick replies). This prevents chip funnels for a
# question that was already precise enough to answer.
if (
frame.needs_clarify
and frame.clarify_reason
and tt != "dosing_calc"
and not section_overview
):
# `system_error` set means this isn't a real clarify at all — the
# understanding call itself failed (provider outage, malformed
# output) and failed closed to this same shape. Surface the real
@@ -214,10 +293,28 @@ class RagAgent:
# in the API response and in `/metrics`/traces (found live
# 2026-08-07: these were indistinguishable, which is why a real
# outage looked identical to normal clarify traffic).
return AgentReply("clarify", frame.system_error or "needs_more_info",
clarification=frame.clarify_reason,
drugs=frame.drugs, turn_type=tt,
quick_replies=frame.quick_replies)
if frame.system_error:
return AgentReply(
"abstain", frame.system_error,
answer=frame.clarify_reason,
drugs=frame.drugs, turn_type=tt,
)
return AgentReply(
"clarify", "needs_more_info",
clarification=frame.clarify_reason,
drugs=frame.drugs, turn_type=tt,
quick_replies=frame.quick_replies,
)
if tt == "drug_attribute" and frame.drugs and frame.attribute is None:
return AgentReply(
"clarify", "missing_attribute",
clarification=(
"Anh/chị muốn tra nội dung nào của thuốc này "
"(chỉ định, chống chỉ định, thận trọng, tác dụng phụ…)?"
),
drugs=frame.drugs, turn_type=tt,
)
if tt == "smalltalk":
return AgentReply(
@@ -227,7 +324,7 @@ class RagAgent:
"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):
if tt == "out_of_scope":
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ư "
@@ -261,9 +358,18 @@ class RagAgent:
return self._single_drug(turn, frame, budget)
def _single_drug(self, turn: str, frame: QueryFrame, budget: RequestBudget) -> AgentReply:
query = _synthesize_query(turn, frame)
query = _synthesize_query(frame.standalone_query or turn, frame)
# `dosing_calc` semantically names the dosage section even when the
# understanding model leaves the separate `attribute` field null.
# Passing null here falls into an overview retrieval and was observed
# pulling interactions/precautions into a plain adult-dose answer.
section_key = (
"lieu_luong_va_cach_dung"
if frame.turn_type == "dosing_calc"
else frame.attribute
)
result = self._retrieval.retrieve_framed(
frame.drugs[0], frame.attribute, query,
frame.drugs[0], section_key, query,
is_overview=frame.turn_type == "drug_overview",
)
return self._grounded(query, result, frame, budget=budget)
@@ -288,8 +394,9 @@ class RagAgent:
generation the same way.
"""
evidences = []
query = _synthesize_query(frame.standalone_query or turn, frame)
for drug_id in frame.drugs:
part = self._retrieval.retrieve_framed(drug_id, TUONG_TAC, turn)
part = self._retrieval.retrieve_framed(drug_id, TUONG_TAC, query)
if part.decision in (EvidenceDecision.ANSWERABLE, EvidenceDecision.VERIFY_PDF):
evidences.extend(part.evidence)
if not evidences:
@@ -300,7 +407,7 @@ class RagAgent:
"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 = self._retrieval.decide(tuple(evidences))
return self._grounded(turn, combined, frame, budget=budget)
return self._grounded(query, combined, frame, budget=budget)
def _symptom_to_drug(
self, turn: str, frame: QueryFrame, budget: RequestBudget
@@ -338,14 +445,15 @@ class RagAgent:
budget: RequestBudget | None = None,
) -> AgentReply:
ga = self._answers.answer_from_result(
turn, result, list_mode=list_mode, budget=budget
turn, result, list_mode=list_mode, budget=budget, prechecked=True
)
decision = ga.result.decision.value
if ga.clarification is not None:
decision = "clarify"
reason = "needs_more_info" if ga.clarification is not None else ga.result.reason
return AgentReply(
decision=decision,
reason=ga.result.reason,
reason=reason,
answer=ga.answer,
clarification=ga.clarification,
citations=ga.citations,
@@ -353,6 +461,9 @@ class RagAgent:
turn_type=frame.turn_type,
generated=ga.generated,
quick_replies=ga.quick_replies,
blocks=ga.blocks,
answer_mode=ga.answer_mode,
plan=ga.plan,
)
def _remember(self, conversation_id: str, turn: str, reply: AgentReply) -> None:
@@ -387,6 +498,28 @@ def _display_name(drug_id: str) -> str:
return drug_id.replace("_", " ").title()
def _is_section_overview(turn: str, frame: QueryFrame) -> bool:
"""Separate a handbook survey from a patient-specific decision.
The model supplies the first-class flag, while the narrow lexical
backstop makes a clear section lookup deterministic. A personal target
always wins: broad words inside a patient-specific dose question must not
suppress a necessary clarification.
"""
text = turn.casefold()
personal_cues = (
"cho tôi", "tôi đang", "bệnh nhân này", "ca này", "", "trẻ ",
"tuổi", "cân nặng", " kg", "suy thận", "suy gan", "nên dùng liều nào",
)
if any(cue in text for cue in personal_cues):
return False
overview_cues = (
"dược thư hướng dẫn", "những ", "các ", "toàn bộ", "tất cả",
"trình bày", "theo nhóm", "theo tần suất", "nếu có", "tổng quan",
)
return frame.section_overview or any(cue in text for cue in overview_cues)
_POPULATION_LABELS = {
"tre_em": "trẻ em",
"tre_so_sinh": "trẻ sơ sinh",
@@ -417,7 +550,8 @@ def _synthesize_query(turn: str, frame: QueryFrame) -> str:
`GroundedAnswerService.answer_from_result` has no conversation history of
its own; the `query` string it receives IS the entire context its
sufficiency-check and generation LLM calls see. Passing the bare current
generation LLM call sees (and the legacy sufficiency call, if enabled).
Passing the bare current
turn loses everything resolved earlier: a reply like "Uống" answering a
route question three turns into a dose conversation would reach
generation as just "Uống", indistinguishable from a user who typed
@@ -429,6 +563,11 @@ def _synthesize_query(turn: str, frame: QueryFrame) -> str:
repetition.
"""
parts = [turn]
if frame.section_overview:
parts.append(
"Phạm vi yêu cầu: tra cứu tổng quan toàn mục; trình bày các nhánh "
"trong Dược thư với nhãn điều kiện, không chọn một phác đồ cho một người bệnh"
)
if frame.population:
parts.append(f"Đối tượng: {_POPULATION_LABELS.get(frame.population, frame.population)}")
if frame.age_text:
+525 -66
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
import json
import logging
import re
import unicodedata
from dataclasses import dataclass, replace
from . import grounding, metrics as metric_names
@@ -9,12 +11,42 @@ from .budget import RequestBudget, RequestBudgetExhausted
from .metrics import Metrics, NullMetrics
from .models import EvidenceDecision, QueryIntent, RetrievalResult, SubjectScope
from .ports import AnswerGenerationUnavailable, AnswerGenerator
from .prompt import build_entailment_request, build_request, build_sufficiency_request
from .prompt import (
GenerationRequest,
build_entailment_request,
build_request,
build_sufficiency_request,
)
from .routing import QueryRoutingService
# See `_verify_entailment`'s docstring for the measured trade-off behind
# widening this from 2 to 3.
_ENTAILMENT_MAX_ATTEMPTS = 3
# Repeating the same temperature-0 prompt against the same model is a
# correlated retry, not an independent vote. One fail-closed semantic pass is
# kept after structured-claim parsing and deterministic grounding.
_ENTAILMENT_MAX_ATTEMPTS = 1
_QUICK_REPLY_MAX_ITEMS = 4
_QUICK_REPLY_MAX_CHARS = 40
logger = logging.getLogger(__name__)
def _sanitize_quick_replies(value) -> tuple[str, ...]:
"""Validate LLM-proposed chips without hard-coding their content."""
if not isinstance(value, list):
return ()
replies: list[str] = []
seen: set[str] = set()
for item in value:
if not isinstance(item, str):
continue
reply = " ".join(item.split())
key = reply.casefold()
if not reply or len(reply) > _QUICK_REPLY_MAX_CHARS or key in seen:
continue
seen.add(key)
replies.append(reply)
if len(replies) >= _QUICK_REPLY_MAX_ITEMS:
break
return tuple(replies)
@dataclass(frozen=True)
@@ -33,6 +65,34 @@ class Citation:
evidence_text: str = ""
@dataclass(frozen=True)
class AnswerClaim:
"""One user-visible fact and the exact retrieved chunks supporting it."""
text: str
source_ids: tuple[str, ...] = ()
@dataclass(frozen=True)
class AnswerBlock:
"""Semantic presentation unit produced by the answer service, not the UI."""
title: str
kind: str
claims: tuple[AnswerClaim, ...]
@dataclass(frozen=True)
class AnswerPlan:
"""Compact, non-reasoning presentation plan decided before generation."""
verbosity: str
layout: str
reasoning_mode: str
show_heading: bool = False
needs_warning: bool = False
@dataclass(frozen=True)
class GroundedAnswer:
result: RetrievalResult
@@ -47,18 +107,23 @@ class GroundedAnswer:
# em") — only populated when the sufficiency check judged the question
# to have a few natural discrete answers, never invented client-side.
quick_replies: tuple[str, ...] = ()
blocks: tuple[AnswerBlock, ...] = ()
answer_mode: str = "concise"
plan: AnswerPlan | None = None
@dataclass(frozen=True)
class _GenOutcome:
answer: str | None = None
clarification: str | None = None
quick_replies: tuple[str, ...] = ()
# The specific reason a rejection happened — the exact string already
# used for the GENERATION_REJECTED metric, propagated here so
# `answer_from_result` can put it in the API response's `reason` field
# instead of a generic catch-all. `None` when `answer`/`clarification`
# is set (nothing was rejected).
reject_reason: str | None = None
claims: tuple[tuple[str, tuple[int, ...]], ...] = ()
@dataclass(frozen=True)
@@ -67,13 +132,251 @@ class _RawAttempt:
lets `_generate` retry the noisy `insufficient` case without
double-counting a rejection metric across both attempts."""
answer: str | None = None
# The model's own claim/citation boundaries (2026-08-10 structured-claims
# change) — `_verify_entailment` checks these directly instead of
# re-deriving claim boundaries from `answer` via regex. `answer` is still
# populated (assembled from these by `_assemble_answer`) because it's
# the exact string `grounding.verify` and the API response need — one
# format, not two representations that could drift apart.
claims: tuple[tuple[str, tuple[int, ...]], ...] = ()
clarification: str | None = None
quick_replies: tuple[str, ...] = ()
insufficient: bool = False
outage: bool = False
budget_exhausted: bool = False
malformed: bool = False
@dataclass(frozen=True)
class _VerificationOutcome:
supported: bool
complete: bool
missing: tuple[str, ...] = ()
def _parse_claims(raw_claims: list) -> tuple[tuple[str, tuple[int, ...]], ...] | None:
"""Validate the model's `claims` array into `(text, citation_indices)`
pairs, or `None` on any malformed entry — same fail-closed contract as
every other shape check in `_attempt_generation`."""
claims: list[tuple[str, tuple[int, ...]]] = []
for item in raw_claims:
if not isinstance(item, dict):
return None
text = item.get("text")
citations = item.get("citations")
if not isinstance(text, str) or not text.strip():
return None
if not isinstance(citations, list) or not all(
isinstance(c, int) and not isinstance(c, bool) for c in citations
):
return None
claims.append((text.strip(), tuple(citations)))
return tuple(claims)
def _prompt_evidence_texts(evidence: tuple) -> tuple[str, ...]:
"""Evidence text as shown to the generator/entailment judge — labeled with
its source drug ONLY when the evidence set spans more than one drug.
Found live 2026-08-10: a drug interaction section routinely refers to the
drug it belongs to by pharmacological class rather than by name (e.g.
warfarin's own "tương tác thuốc" section says "thuốc kháng vitamin K",
never "warfarin" — normal in a monograph, since the reader already knows
which drug's chapter they're in). Read as an isolated chunk with no
section header, that self-reference is lost, and the entailment judge
was measured flip-flopping ~50/50 on a claim naming the drug directly
against evidence that never does (10 identical calls: 5 entailed, 5 not
— see the commit this function was added in for the full trace). Labeling
each block with its own `chunk_id` drug prefix (`ingestion/.../chunker.py`
always writes `{drug_id}__{section}__{n}`, so this is not a per-drug
special case) restores that anchor without asking the judge to reason
about pharmacology — it only has to match a name already handed to it.
Single-drug evidence sets are left unlabeled: nothing there was
ambiguous, and every token here is spent on every call this product
makes, so it is not added where the measured bug does not apply.
"""
drug_ids = [item.matched_doc_id.split("__", 1)[0] for item in evidence]
if len(set(drug_ids)) < 2:
return tuple(item.text for item in evidence)
return tuple(
f"(Nguồn: chuyên luận {drug_id.replace('_', ' ').upper()}) {item.text}"
for drug_id, item in zip(drug_ids, evidence, strict=True)
)
def _assemble_answer(claims: tuple[tuple[str, tuple[int, ...]], ...]) -> str:
"""The exact display string `grounding.verify` and the API response use
— built from the model's own claim/citation structure, not written by
the model as free text. Format matches what the product already shows
(`text [n]` / `text [n][m]`) so the frontend needs no changes and
`grounding.verify`'s existing `[n]`-marker parsing applies unmodified."""
parts = []
for text, citations in claims:
marker = "".join(f"[{c}]" for c in citations)
parts.append(f"{text} {marker}".rstrip() if marker else text)
return " ".join(parts)
_SECTION_PRESENTATION = {
"adr": ("Tác dụng không mong muốn", "fact_list"),
"tac_dung_khong_mong_muon": ("Tác dụng không mong muốn", "fact_list"),
"tuong_tac_thuoc": ("Tương tác thuốc", "warning"),
"tuong_tac": ("Tương tác thuốc", "warning"),
"chong_chi_dinh": ("Chống chỉ định", "warning"),
"than_trong": ("Thận trọng", "warning"),
"canh_bao": ("Cảnh báo", "warning"),
"lieu_luong_va_cach_dung": ("Liều lượng và cách dùng", "dosage"),
"lieu_dung": ("Liều lượng và cách dùng", "dosage"),
"cach_dung": ("Cách dùng", "dosage"),
"bao_quan": ("Bảo quản", "fact_list"),
"do_on_dinh_va_bao_quan": ("Bảo quản", "fact_list"),
"chi_dinh": ("Chỉ định", "fact_list"),
"duoc_luc_hoc": ("Dược lực học", "fact_list"),
"duoc_dong_hoc": ("Dược động học", "fact_list"),
"qua_lieu_va_xu_tri": ("Quá liều và xử trí", "warning"),
}
def _section_key(chunk_id: str) -> str:
parts = chunk_id.split("__")
return parts[1].casefold() if len(parts) > 1 else ""
def _presentation_for_sources(source_ids: tuple[str, ...]) -> tuple[str, str]:
section = _section_key(source_ids[0]) if source_ids else ""
if section in _SECTION_PRESENTATION:
return _SECTION_PRESENTATION[section]
if section:
return section.replace("_", " ").strip().capitalize(), "fact_list"
return "Trả lời", "fact_list"
def _build_blocks(
claims: tuple[tuple[str, tuple[int, ...]], ...],
indexed: list[tuple[str, Citation]],
) -> tuple[AnswerBlock, ...]:
"""Map verified claims to semantic blocks and stable source ids."""
groups: list[tuple[str, str, list[AnswerClaim]]] = []
for text, citation_indices in claims:
source_ids = tuple(
indexed[index - 1][1].chunk_id
for index in citation_indices
if 1 <= index <= len(indexed)
)
claim = AnswerClaim(text=text, source_ids=tuple(dict.fromkeys(source_ids)))
title, kind = _presentation_for_sources(claim.source_ids)
if groups and groups[-1][0] == title and groups[-1][1] == kind:
groups[-1][2].append(claim)
else:
groups.append((title, kind, [claim]))
return tuple(
AnswerBlock(title=title, kind=kind, claims=tuple(items))
for title, kind, items in groups
)
def _answer_mode(claim_count: int) -> str:
if claim_count <= 2:
return "concise"
if claim_count <= 6:
return "normal"
return "detailed"
_BROAD_QUERY_CUES = (
"đầy đủ", "tất cả", "toàn bộ", "tổng hợp", "so sánh", "đối chiếu",
)
_WARNING_SECTIONS = frozenset({
"chong_chi_dinh", "than_trong", "qua_lieu_va_xu_tri",
})
_DOSAGE_SECTIONS = frozenset({"lieu_luong_va_cach_dung", "lieu_dung", "cach_dung"})
_LIST_SECTIONS = frozenset({
"chi_dinh", "chong_chi_dinh", "than_trong", "tac_dung_khong_mong_muon",
"tuong_tac_thuoc", "do_on_dinh_va_bao_quan", "qua_lieu_va_xu_tri",
})
def _plan_answer(query: str, result: RetrievalResult, list_mode: bool) -> AnswerPlan:
sections = tuple(dict.fromkeys(
_section_key(item.matched_doc_id) for item in result.evidence
if _section_key(item.matched_doc_id)
))
drugs = tuple(dict.fromkeys(
item.matched_doc_id.split("__", 1)[0] for item in result.evidence
))
multi_source = len(sections) > 1 or len(drugs) > 1 or list_mode
broad = any(cue in query.casefold() for cue in _BROAD_QUERY_CUES)
verbosity = "detailed" if broad or multi_source else (
"concise" if len(result.evidence) == 1 else "normal"
)
if any(section in _DOSAGE_SECTIONS for section in sections):
layout = "dosage"
elif multi_source or any(section in _LIST_SECTIONS for section in sections):
layout = "bullet_list"
else:
layout = "prose"
return AnswerPlan(
verbosity=verbosity,
layout=layout,
reasoning_mode="synthesis" if multi_source else "direct_lookup",
show_heading=multi_source or verbosity == "detailed",
needs_warning=any(section in _WARNING_SECTIONS for section in sections),
)
def _normalise_for_coverage(text: str) -> str:
folded = unicodedata.normalize("NFKC", text).casefold()
return " ".join(re.sub(r"[^\w]+", " ", folded, flags=re.UNICODE).split())
_MISSING_META_WORDS = {
"không", "chưa", "thiếu", "nêu", "đề", "cập", "đến", "yêu", "cầu",
"ghi", "nhận", "thông", "tin", "trong", "câu", "trả", "lời", "về",
"của", "cho", "", "hoặc", "các", "một", "những",
}
def _quote_supports_missing_description(description: str, quote: str) -> bool:
"""The cited quote must actually contain the fact described as missing."""
description_tokens = {
token
for token in _normalise_for_coverage(description).split()
if len(token) > 1 and token not in _MISSING_META_WORDS
}
quote_tokens = set(_normalise_for_coverage(quote).split())
if not description_tokens:
return False
description_numbers = {token for token in description_tokens if any(c.isdigit() for c in token)}
if not description_numbers.issubset(quote_tokens):
return False
overlap = len(description_tokens & quote_tokens) / len(description_tokens)
return overlap >= 0.5
def _missing_is_already_explicit(
missing: tuple[str, ...],
claims: tuple[tuple[str, tuple[int, ...]], ...],
) -> bool:
"""Reject a judge contradiction when every quoted 'missing' fact is present.
The semantic judge occasionally reports that an exact condition is absent
while quoting that condition verbatim from a claim that already contains it.
This narrow check only resolves that self-contradiction; unquoted or
paraphrased missing facts still fail closed and enter the repair path.
"""
answer = _normalise_for_coverage("\n".join(text for text, _ in claims))
if not missing:
return False
for item in missing:
quoted = [
value for value in re.findall(r"['\"]([^'\"]{8,})['\"]", item)
if _normalise_for_coverage(value)
]
if not quoted or not all(_normalise_for_coverage(value) in answer for value in quoted):
return False
return True
class GroundedAnswerService:
"""Retrieval decides what is true; generation only decides how it reads.
@@ -131,6 +434,7 @@ class GroundedAnswerService:
def answer_from_result(
self, query: str, result: RetrievalResult, list_mode: bool = False,
budget: RequestBudget | None = None,
prechecked: bool = False,
) -> GroundedAnswer:
"""Everything after retrieval — grounding, sufficiency, generation,
citations. Split out so the new understanding-driven orchestrator
@@ -146,9 +450,15 @@ class GroundedAnswerService:
already skips it.
`budget` (F-08): threaded through to every LLM call this method
makes (sufficiency, generate, up to 2 entailment). `None` (the
makes (sufficiency, generate, one entailment verification). `None` (the
default) means unbounded, unchanged from before F-08 — only
`RagAgent` constructs a real budget today.
`prechecked=True` means the structured agent has already enforced its
required input fields. The generation contract still has its own
`evidence_sufficient`/clarifying-question gate for ambiguity visible
only after retrieval, so the separate same-model sufficiency call is
redundant on that path. The legacy direct-answer path keeps it.
"""
if result.decision == EvidenceDecision.ABSTAIN:
self._metrics.increment(metric_names.ABSTENTION, reason=result.reason)
@@ -178,15 +488,13 @@ class GroundedAnswerService:
)
evidence_texts = tuple(item.text for item in result.evidence)
extractive = "\n\n".join(
f"{text} [{index}]" for index, text in enumerate(evidence_texts, start=1)
)
prompt_evidence_texts = _prompt_evidence_texts(result.evidence)
plan = _plan_answer(query, result, list_mode)
# 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.
sufficiency = (
None if list_mode
None if list_mode or prechecked
else self._check_sufficiency(
query, evidence_texts, result.is_drug_overview, budget=budget
)
@@ -198,15 +506,20 @@ class GroundedAnswerService:
)
outcome = self._generate(
query, evidence_texts, intro=result.is_drug_overview, list_mode=list_mode,
budget=budget,
query, evidence_texts, prompt_evidence_texts,
intro=result.is_drug_overview, list_mode=list_mode, budget=budget,
plan=plan,
)
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
result,
outcome.clarification,
(),
clarification=outcome.clarification,
quick_replies=outcome.quick_replies,
)
if outcome.answer is None:
@@ -215,8 +528,19 @@ class GroundedAnswerService:
# 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)
claims = tuple(
(text, (index,))
for index, text in enumerate(evidence_texts, start=1)
)
blocks = _build_blocks(claims, indexed)
return GroundedAnswer(
result,
"\n".join(text for text, _ in claims),
all_citations,
blocks=blocks,
answer_mode=plan.verbosity,
plan=plan,
)
# 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
@@ -248,9 +572,25 @@ class GroundedAnswerService:
# 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, outcome.answer) or all_citations
cited_indices = tuple(dict.fromkeys(
index
for _, claim_indices in outcome.claims
for index in claim_indices
if 1 <= index <= len(indexed)
))
citations = tuple(indexed[index - 1][1] for index in cited_indices)
blocks = _build_blocks(outcome.claims, indexed)
clean_answer = "\n".join(text for text, _ in outcome.claims)
self._metrics.increment(metric_names.GENERATION_SERVED)
return GroundedAnswer(result, outcome.answer, citations, generated=True)
return GroundedAnswer(
result,
clean_answer,
citations,
generated=True,
blocks=blocks,
answer_mode=plan.verbosity,
plan=plan,
)
def _attempt_generation(
self, request: "GenerationRequest", budget: RequestBudget | None
@@ -269,7 +609,7 @@ class GroundedAnswerService:
try:
payload = json.loads(raw)
answer = payload["answer"]
raw_claims = payload["claims"]
sufficient = payload["evidence_sufficient"]
except (ValueError, TypeError, KeyError):
return _RawAttempt(malformed=True)
@@ -279,23 +619,49 @@ class GroundedAnswerService:
# 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 _RawAttempt(clarification=clarify.strip())
return _RawAttempt(
clarification=clarify.strip(),
quick_replies=_sanitize_quick_replies(payload.get("quick_replies")),
)
if not isinstance(answer, str) or not isinstance(sufficient, bool):
if not isinstance(raw_claims, list) or not isinstance(sufficient, bool):
return _RawAttempt(malformed=True)
if not sufficient:
return _RawAttempt(insufficient=True)
return _RawAttempt(answer=answer)
claims = _parse_claims(raw_claims)
if claims is None:
return _RawAttempt(malformed=True)
return _RawAttempt(answer=_assemble_answer(claims), claims=claims)
def _generate(
self, query: str, evidence_texts: tuple[str, ...], intro: bool = False,
list_mode: bool = False, budget: RequestBudget | None = None,
self,
query: str,
evidence_texts: tuple[str, ...],
prompt_evidence_texts: tuple[str, ...] | None = None,
*,
intro: bool = False,
list_mode: bool = False,
budget: RequestBudget | None = None,
plan: AnswerPlan | None = None,
) -> "_GenOutcome":
"""A verified generation, a clarifying question, or empty to fall back."""
if self._generator is None or not evidence_texts:
return _GenOutcome()
request = build_request(query, evidence_texts, intro=intro, list_mode=list_mode)
shown_evidence = prompt_evidence_texts or evidence_texts
plan = plan or AnswerPlan("normal", "prose", "direct_lookup")
request = build_request(
query,
shown_evidence,
intro=intro,
list_mode=list_mode,
answer_mode=plan.verbosity,
layout=plan.layout,
reasoning_mode=plan.reasoning_mode,
show_heading=plan.show_heading,
needs_warning=plan.needs_warning,
)
attempt = self._attempt_generation(request, budget)
if attempt.insufficient:
# Empirically noisy (found live 2026-08-07, reproduced 3/3 on a
@@ -324,7 +690,10 @@ class GroundedAnswerService:
)
return _GenOutcome(reject_reason="malformed_output")
if attempt.clarification is not None:
return _GenOutcome(clarification=attempt.clarification)
return _GenOutcome(
clarification=attempt.clarification,
quick_replies=attempt.quick_replies,
)
if attempt.insufficient:
# The model says the evidence does not answer the question, on
# both attempts. Showing the retrieved section verbatim lets the
@@ -342,17 +711,62 @@ class GroundedAnswerService:
)
return _GenOutcome(reject_reason=report.reason)
if not self._verify_entailment(answer, evidence_texts, budget=budget):
verification = self._verify_entailment(
query, attempt.claims, shown_evidence, budget=budget
)
if verification is None or not verification.supported:
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="unsupported_claim"
)
return _GenOutcome(reject_reason="unsupported_claim")
return _GenOutcome(answer=answer)
if not verification.complete:
missing = "; ".join(verification.missing) or "dữ kiện liên quan trong nguồn"
logger.warning(
"answer completeness repair: query=%r missing=%r claims=%r",
query,
verification.missing,
attempt.claims,
)
repair_request = GenerationRequest(
system=request.system,
user=(
f"{request.user}\n\nBẢN TRƯỚC ĐÃ BỊ LOẠI VÌ THIẾU: {missing}. "
"Hãy tạo lại từ đầu, giữ đúng phạm vi câu hỏi nhưng bổ sung các "
"nhãn, điều kiện, con số và mục liên quan bị thiếu."
),
schema=request.schema,
)
repaired = self._attempt_generation(repair_request, budget)
if repaired.answer is not None:
repaired_report = grounding.verify(repaired.answer, evidence_texts)
repaired_verification = self._verify_entailment(
query, repaired.claims, shown_evidence, budget=budget
)
logger.warning(
"answer completeness repair result: claims=%r verdict=%r",
repaired.claims,
repaired_verification,
)
if (
repaired_report.grounded
and repaired_verification is not None
and repaired_verification.supported
and repaired_verification.complete
):
return _GenOutcome(answer=repaired.answer, claims=repaired.claims)
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="incomplete_answer"
)
return _GenOutcome(reject_reason="incomplete_answer")
return _GenOutcome(answer=answer, claims=attempt.claims)
def _verify_entailment(
self, answer: str, evidence_texts: tuple[str, ...],
self,
query: str,
structured_claims: tuple[tuple[str, tuple[int, ...]], ...],
evidence_texts: tuple[str, ...],
budget: RequestBudget | None = None,
) -> bool:
) -> _VerificationOutcome | None:
"""A second, adversarial LLM pass over an answer that already passed
`grounding.verify`.
@@ -364,45 +778,57 @@ class GroundedAnswerService:
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 — that failure mode is
reliable, not noisy, so it stops immediately rather than spending
retries on it. A single rejection is NOT reliable: 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. Up to `_ENTAILMENT_MAX_ATTEMPTS` same-claim calls run;
accept on the first `True`, discard only if every attempt agrees
reject. Widened from 2 to 3 attempts 2026-08-07 after a live
adversarial sample (50 real questions) measured this specific check
as roughly half of all false abstentions on genuinely answerable
questions. Trade-off, stated plainly: this raises the bar a
genuinely fabricated claim must now clear too (it survives if ANY
one of 3 noisy calls wrongly accepts it, not just 1 of 2) — accepted
because the probed noise is symmetric and the entailment prompt
itself is unchanged, not because the risk is zero.
An answer with no claim text at all (nothing between or after its
citation markers) is vacuously fine — nothing to verify, no call.
Fails closed on an outage, malformed output, budget exhaustion, or a
negative verdict. The same deterministic model and temperature are
used for every judge call, so repeating the identical prompt is not
independent evidence: it adds correlated latency and can amplify a
false acceptance. One bounded adversarial pass is the safer default;
judge quality is measured with an eval set instead of manufactured
by retrying the same request.
Reads the model's OWN claim/citation boundaries (2026-08-10
structured-claims change) instead of re-deriving them from the
assembled text via regex — `structured_claims` is exactly what the
model returned, already validated by `_parse_claims`. A claim with
no in-range citation, or no real content, has nothing to check
against and is skipped, same as before.
"""
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)
(
text,
"\n".join(
evidence_texts[i - 1] for i in citations
if 1 <= i <= len(evidence_texts)
),
)
for text, citations in structured_claims
if any(1 <= i <= len(evidence_texts) for i in citations)
and grounding.has_content(text)
]
if not claims:
return True
return _VerificationOutcome(supported=True, complete=True)
request = build_entailment_request(claims)
request = build_entailment_request(query, claims, evidence_texts)
for _ in range(_ENTAILMENT_MAX_ATTEMPTS):
verdict = self._run_entailment_check(request, budget=budget)
verdict = self._run_entailment_check(
request, evidence_texts=evidence_texts, budget=budget
)
if verdict is None:
return False
if verdict:
return True
return None
if (
verdict.supported
and not verdict.complete
and _missing_is_already_explicit(verdict.missing, structured_claims)
):
return _VerificationOutcome(supported=True, complete=True)
return verdict
return False
def _run_entailment_check(
self, request, budget: RequestBudget | None = None
) -> bool | None:
self,
request,
evidence_texts: tuple[str, ...],
budget: RequestBudget | None = None,
) -> _VerificationOutcome | None:
"""One entailment call. `None` = outage/malformed/budget-exhausted
(fails closed by the caller without a retry); `True`/`False` = the
judge's verdict."""
@@ -416,11 +842,49 @@ class GroundedAnswerService:
payload = json.loads(raw)
entailed = payload["entailed"]
unsupported = payload["unsupported"]
missing_evidence = payload.get("missing_evidence", [])
except (ValueError, TypeError, KeyError):
return None
if not isinstance(entailed, bool) or not isinstance(unsupported, list):
if (
not isinstance(entailed, bool)
or not isinstance(unsupported, list)
or not isinstance(missing_evidence, list)
):
return None
return entailed and not unsupported
if not entailed or unsupported:
return _VerificationOutcome(supported=False, complete=False)
# A completeness objection is itself a factual claim about the raw
# evidence. Require the judge to point to an exact source quote and
# validate it locally. This prevents false objections such as
# "missing humidity" when the storage section never mentions humidity,
# which previously discarded a fully grounded answer after two extra
# model calls. Entailment still fails closed; only ungrounded
# *completeness objections* are ignored.
normalised_evidence = tuple(
_normalise_for_coverage(text) for text in evidence_texts
)
grounded_missing: list[str] = []
for item in missing_evidence:
if not isinstance(item, dict):
return None
description = item.get("description")
evidence_quote = item.get("evidence_quote")
if not isinstance(description, str) or not isinstance(evidence_quote, str):
return None
description = description.strip()
quote = _normalise_for_coverage(evidence_quote)
if (
description
and len(quote) >= 4
and any(quote in evidence for evidence in normalised_evidence)
and _quote_supports_missing_description(description, evidence_quote)
):
grounded_missing.append(description)
return _VerificationOutcome(
supported=True,
complete=not grounded_missing,
missing=tuple(grounded_missing),
)
def _check_sufficiency(
self, query: str, evidence_texts: tuple[str, ...], intro: bool = False,
@@ -458,12 +922,7 @@ class GroundedAnswerService:
if isinstance(payload, dict) and payload.get("sufficient") is False:
question = payload.get("clarifying_question")
if isinstance(question, str) and question.strip():
raw_replies = payload.get("quick_replies")
replies = tuple(
reply.strip()
for reply in raw_replies
if isinstance(reply, str) and reply.strip()
) if isinstance(raw_replies, list) else ()
replies = _sanitize_quick_replies(payload.get("quick_replies"))
return question.strip(), replies
return None
+8 -9
View File
@@ -1,21 +1,20 @@
"""A per-request LLM-call budget — F-08.
`RagAgent.handle()` makes up to ~5 sequential Bedrock calls per turn
(understand, sufficiency, generate, up to 2 entailment retries) with no
aggregate deadline before this: each call is bounded only by its own fixed
provider timeout (`read_timeout=60` in `adapters/bedrock_converse.py`, times
up to 3 retries at "standard" backoff — worst case several minutes for one
stuck call, let alone five). Measured live 2026-08-07: a normal answerable
(understand, sufficiency, generate, entailment) with no aggregate deadline
before this. Each call now has at most two bounded provider attempts
(`read_timeout=20` in `adapters/bedrock_converse.py`) because this budget is
checked between calls and cannot cancel boto3 while it is already in flight.
Measured live 2026-08-07: a normal answerable
turn costs ~8-9s total; nothing bounds the pathological case.
Checked before each call, not wrapped around an already-running one — this
bounds how many MORE calls get a chance to start once time/calls run out. It
does not cancel a call already in flight past its own provider timeout; a
hard per-call cancellation would need cooperative cancellation support from
`adapters/bedrock_converse.py`'s boto3 client, a larger change than this
budget object alone. Still a real improvement: five calls each capable of
running to their own 60s+ limit, one after another, is the actual gap this
closes.
`adapters/bedrock_converse.py`'s boto3 client. The adapter-level timeout bounds
that residual gap; this object prevents any later call from starting after
the aggregate deadline.
"""
from __future__ import annotations
+149 -20
View File
@@ -28,9 +28,11 @@ Quy tắc bắt buộc:
chứng (ví dụ "người lớn", "trẻ em", "suy thận", "đường uống"). TUYỆT ĐỐI
không gán liều của đối tượng này cho đối tượng khác, và không gộp các liều
khác đối tượng thành một.
4. Gắn số nguồn [n] cho từng ý, với n là đoạn bằng chứng THỰC SỰ chứa ý đó.
Chỉ trích [n] nếu đọc đoạn n thấy đúng ý đang nói. Không lặp lại cùng một
[n] ở mọi câu — gắn một lần cho một cụm cùng nguồn là đ. Không bịa số [n].
4. KHÔNG viết câu trả lời thành 1 đoạn văn tự do. Thay vào đó, TÁCH câu trả lời
thành từng "claims" — mỗi claim là MỘT ý độc lập, kèm "citations" là danh
sách số đoạn bằng chứng (1-based) THỰC SỰ chứa ý đó. Một claim chỉ được
trích citations mà đọc đúng đoạn đó thấy đúng ý đang nói — không bịa số.
Nhiều claim có thể trích cùng citations nếu chúng thực sự cùng nguồn.
5. Nếu BẰNG CHỨNG không đủ (thiếu đối tượng được hỏi, thiếu con số, hoặc chỉ nói
chung chung), nói rõ là không đủ và đặt evidence_sufficient=false. Đó là câu
trả lời hợp lệ. Không suy diễn để lấp chỗ trống. TRƯỜNG HỢP NÀY BẮT BUỘC LUÔN
@@ -45,30 +47,78 @@ Quy tắc bắt buộc:
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.
NGOẠI LỆ BẮT BUỘC: nếu CÂU HỎI ghi rõ đây là "tra cứu tổng quan toàn mục"
hoặc yêu cầu Dược thư trình bày các đường dùng/liều/đối tượng "nếu có", đây
KHÔNG phải yêu cầu chọn liều cho một người bệnh. Khi đó phải liệt kê các nhánh
có trong BẰNG CHỨNG, giữ nguyên nhãn đối tượng/đường dùng/chỉ định; không hỏi
người lớn/trẻ em chỉ để thu hẹp và không tạo quick-reply chip.
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`
để `claims`=[] (rỗng), `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.
- "người lớn" chỉ đủ khi BẰNG CHỨNG có đúng một đường dùng phù hợp, hoặc câu
hỏi đã nêu đường dùng. Nếu bằng chứng có nhiều đường dùng (uống/đặt/tiêm…)
mà người dùng chưa chọn → phải hỏi đường dùng, không liệt kê tất 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.
8. `quick_replies` chỉ phục vụ một `clarifying_question` thực sự cần thiết:
- Khi evidence_sufficient=true: BẮT BUỘC quick_replies=[]; trả lời thẳng, không
tạo chip cho có.
- Khi evidence_sufficient=false vì còn mơ hồ và câu hỏi lại có 2-4 lựa chọn
rời rạc tự nhiên: tự sinh 2-4 quick_replies ngắn dựa trên chính các nhánh
thấy trong BẰNG CHỨNG (ví dụ các đường dùng xuất hiện trong nguồn).
- Nếu cần người dùng nhập một giá trị cụ thể như tuổi/cân nặng, hoặc nguồn
hoàn toàn không đủ để hình thành lựa chọn an toàn: quick_replies=[].
Viết gọn. Trả lời đúng điều được hỏi, không mở rộng phạm vi."""
9. LẬP KẾ HOẠCH ĐỘ CHI TIẾT THEO CÂU HỎI:
- Hỏi một dữ kiện cụ thể: trả lời trực tiếp, ngắn, không kéo cả chuyên luận vào.
- Hỏi "đầy đủ", "tất cả", hoặc hỏi cả một mục rộng như toàn bộ ADR/tương tác:
phải bao phủ TẤT CẢ dữ kiện trực tiếp thuộc mục đó trong BẰNG CHỨNG.
- Với danh sách có cấu trúc, KHÔNG tách mỗi thuật ngữ thành một claim rời. Mỗi
claim phải giữ một nhóm có nghĩa và ghi rõ nhãn của sách, ví dụ
"Thường gặp — Tiêu hóa: buồn nôn, đau bụng, biếng ăn".
- Nhãn tần suất/mức độ, nhóm cơ quan và điều kiện bao trùm một đoạn (ví dụ
"khi dùng liều cao để điều trị ung thư phụ thuộc hormon") là DỮ KIỆN LÂM SÀNG,
không phải trang trí. Bắt buộc giữ chúng bên cạnh đúng các mục chúng chi phối.
Nếu một nhãn đầu mục áp dụng cho nhiều dòng phía sau cho tới đầu mục kế tiếp,
phải LẶP LẠI nhãn đó trong từng claim liên quan; không bắt người đọc suy ra từ
claim đứng trước.
Viết gọn trong phạm vi độ chi tiết người dùng yêu cầu. Không mở rộng phạm vi."""
ANSWER_SCHEMA = {
"type": "object",
"properties": {
"answer": {
"type": "string",
"claims": {
"type": "array",
"description": (
"Câu trả lời cho bác sĩ/dược sĩ, mỗi ý gắn [n] chỉ nguồn. "
"Mọi con số chép nguyên văn từ bằng chứng."
"Câu trả lời TÁCH thành từng ý độc lập cho bác sĩ/dược sĩ — "
"KHÔNG phải một đoạn văn tự do. Rỗng nếu evidence_sufficient=false."
),
"items": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Một ý, không chứa số nguồn — số nguồn đi riêng ở citations.",
},
"citations": {
"type": "array",
"items": {"type": "integer"},
"description": (
"Số đoạn bằng chứng (1-based) THỰC SỰ chứa ý này. "
"Không được rỗng trừ khi claim không cần trích dẫn."
),
},
},
"required": ["text", "citations"],
"additionalProperties": False,
},
},
"evidence_sufficient": {
"type": "boolean",
@@ -84,8 +134,18 @@ ANSWER_SCHEMA = {
"null CHỈ khi evidence_sufficient=true."
),
},
"quick_replies": {
"type": "array",
"items": {"type": "string"},
"maxItems": 4,
"description": (
"2-4 câu trả lời ngắn do model đề xuất CHỈ khi cần hỏi lại và "
"có vài lựa chọn tự nhiên trong bằng chứng; rỗng khi trả lời "
"được ngay hoặc cần nhập giá trị tự do."
),
},
},
"required": ["answer", "evidence_sufficient"],
"required": ["claims", "evidence_sufficient", "quick_replies"],
"additionalProperties": False,
}
@@ -100,7 +160,9 @@ Quy tắc:
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.
tuổi và cân nặng). "người lớn" chỉ ĐỦ khi bằng chứng có một đường dùng phù
hợp hoặc câu hỏi đã nêu đường dùng; nếu có nhiều đường dùng mà chưa chọn thì
phải hỏi tiếp đường dùng, không đổ tất cả các mức ra cho người đọc tự chọ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 ĐỦ.
- Câu hỏi "thận trọng"/"an toàn khi dùng cho bệnh nhân [tình trạng cụ thể]"
@@ -181,22 +243,58 @@ nếu tên thuốc trong CÂU xuất hiện ở bất kỳ đâu trong danh sác
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."""
Sau kiểm tra từng claim, kiểm tra ĐỘ ĐẦY ĐỦ: đối chiếu CÂU HỎI GỐC với TOÀN BỘ
BẰNG CHỨNG ĐÃ CHỌN. Nếu câu trả lời bỏ sót dữ kiện trực tiếp được hỏi (điều kiện,
đối tượng, con số, mức độ/tần suất hoặc mục trong danh sách), đặt complete=false.
Không coi nội dung ngoài phạm vi câu hỏi là thiếu.
Chỉ được ghi thiếu một dữ kiện khi dữ kiện đó THỰC SỰ XUẤT HIỆN trong TOÀN BỘ
BẰNG CHỨNG ĐÃ CHỌN nhưng không có trong câu trả lời. Nếu câu hỏi nói "độ ẩm hoặc
bao bì", "điều kiện đặc biệt nếu có" mà bằng chứng không nêu độ ẩm/bao bì/điều
kiện đặc biệt, sự im lặng đó KHÔNG phải lỗi thiếu. Không yêu cầu câu trả lời bịa
ra một mục chỉ vì từ đó xuất hiện trong câu hỏi.
Đặc biệt với câu hỏi "đầy đủ/tất cả" về ADR, tương tác hoặc một mục rộng: mất nhãn
"thường gặp/ít gặp", mất nhóm cơ quan, hoặc kéo các dữ kiện ra khỏi điều kiện bao
trùm như "khi dùng liều cao để điều trị ung thư phụ thuộc hormon" đều là KHÔNG
ĐẦY ĐỦ, dù tên từng dữ kiện vẫn xuất hiện. Khi đó complete=false và
missing_evidence phải nêu đúng nhãn/điều kiện bị mất.
Trả về DUY NHẤT JSON: {"entailed": bool, "unsupported": [số thứ tự claim],
"complete": bool, "missing_evidence": [{"description": mô tả ngắn dữ kiện bị
bỏ sót, "evidence_quote": trích nguyên văn ngắn từ bằng chứng chứa dữ kiện đó}]}.
Mỗi mục thiếu BẮT BUỘC có evidence_quote chép nguyên văn từ bằng chứng. Không tìm
được câu trích thì không được ghi mục đó là thiếu.
entailed=true chỉ khi unsupported rỗng; complete=true chỉ khi missing_evidence rỗng."""
ENTAILMENT_SCHEMA = {
"type": "object",
"properties": {
"entailed": {"type": "boolean"},
"unsupported": {"type": "array", "items": {"type": "integer"}},
"complete": {"type": "boolean"},
"missing_evidence": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {"type": "string"},
"evidence_quote": {"type": "string"},
},
"required": ["description", "evidence_quote"],
"additionalProperties": False,
},
},
},
"required": ["entailed", "unsupported"],
"required": ["entailed", "unsupported", "complete", "missing_evidence"],
"additionalProperties": False,
}
def build_entailment_request(claims: list[tuple[str, str]]) -> "GenerationRequest":
def build_entailment_request(
question: str,
claims: list[tuple[str, str]],
all_evidence: tuple[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)."""
@@ -206,13 +304,33 @@ def build_entailment_request(claims: list[tuple[str, str]]) -> "GenerationReques
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ó."
evidence = "\n\n".join(
f"NGUỒN {index}: {text}"
for index, text in enumerate(all_evidence, start=1)
)
user = (
f"CÂU HỎI GỐC: {question}\n\n{blocks}\n\n"
f"TOÀN BỘ BẰNG CHỨNG ĐÃ CHỌN:\n{evidence}\n\n"
"Kiểm tra hai chiều. (1) Từng CÂU phải được đúng bằng chứng trích dẫn "
"chứng thực. (2) So với CÂU HỎI GỐC và TOÀN BỘ BẰNG CHỨNG, câu trả lời "
"phải đủ các dữ kiện trực tiếp liên quan: không làm rơi điều kiện áp dụng, "
"đối tượng, con số, mức độ/tần suất hoặc các mục trong danh sách mà người "
"dùng yêu cầu. complete=false và liệt kê ngắn trong missing_evidence nếu "
"còn thiếu; mỗi mục phải kèm evidence_quote NGUYÊN VĂN từ nguồn chứa dữ "
"kiện đó. Không có quote trong nguồn thì không được báo thiếu. Không bắt "
"câu trả lời mở rộng sang nội dung ngoài phạm vi hỏi."
)
return GenerationRequest(system=ENTAILMENT_SYSTEM, user=user, schema=ENTAILMENT_SCHEMA)
def build_request(
question: str, evidence_texts: tuple[str, ...], intro: bool = False,
list_mode: bool = False,
answer_mode: str = "normal",
layout: str = "prose",
reasoning_mode: str = "direct_lookup",
show_heading: bool = False,
needs_warning: bool = False,
) -> GenerationRequest:
"""The prompt for one question over one ordered evidence list.
@@ -251,12 +369,23 @@ def build_request(
f"CÂU HỎI: {question}\n\nMỖI đoạn BẰNG CHỨNG trên là chỉ định của MỘT "
"thuốc KHÁC NHAU. Hãy LIỆT KÊ TẤT CẢ các thuốc mà bằng chứng cho thấy "
"có chỉ định phù hợp với câu hỏi — không chỉ chọn một thuốc. Mỗi thuốc "
"một câu ngắn, gắn đúng số nguồn [n] của thuốc đó. Đây là liệt kê tra "
"một claim ngắn riêng, citations đúng số đoạn của thuốc đó. Đây là liệt kê tra "
"cứu, KHÔNG phải khuyến cáo thuốc nào tốt hơn — không xếp hạng, không "
"chọn thuốc \"phù hợp nhất\". Nếu KHÔNG thuốc nào trong bằng chứng thực "
"sự phù hợp với câu hỏi, nói rõ điều đó thay vì liệt kê thuốc không liên quan."
)
else:
task = f"CÂU HỎI: {question}"
user = f"BẰNG CHỨNG:\n\n{blocks}\n\n{task}"
plan = (
"KẾ HOẠCH TRÌNH BÀY (không phải dữ kiện y khoa; không được nhắc lại trong "
"câu trả lời):\n"
f"- độ dài: {answer_mode}\n"
f"- bố cục: {layout}\n"
f"- chế độ: {reasoning_mode}\n"
f"- cần heading: {'' if show_heading else 'không'}\n"
f"- cần nhấn mạnh cảnh báo: {'' if needs_warning else 'không'}\n"
"Dù bố cục nào, claims vẫn là các ý có evidence riêng. Không tạo heading "
"hoặc boilerplate thành claim."
)
user = f"BẰNG CHỨNG:\n\n{blocks}\n\n{plan}\n\n{task}"
return GenerationRequest(system=SYSTEM_PROMPT, user=user, schema=ANSWER_SCHEMA)
+34 -12
View File
@@ -53,6 +53,12 @@ class CatalogDrugResolver:
for alias in aliases
if (normalized := normalize_name(alias))
]
self._alias_to_drug_ids: dict[str, set[str]] = {}
for drug_id, alias in self._aliases:
self._alias_to_drug_ids.setdefault(alias, set()).add(drug_id)
self._max_alias_tokens = max(
(len(alias.split()) for alias in self._alias_to_drug_ids), default=0
)
self._fuzzy_threshold = fuzzy_threshold
self._ambiguity_margin = ambiguity_margin
@@ -69,18 +75,24 @@ class CatalogDrugResolver:
# "Dịch vụ đang gặp sự cố" — not a provider outage at all. Caching by
# exact input turns all but the newest turn's own text into a dict
# lookup on every subsequent call.
@functools.lru_cache(maxsize=4096)
@functools.lru_cache(maxsize=4096) # noqa: B019 - bounded process singleton
def resolve(self, query: str) -> DrugResolution:
normalized_query = normalize_name(query)
query_tokens = normalized_query.split()
exact = [
(drug_id, alias, match.start(1), match.end(1))
for drug_id, alias in self._aliases
for match in [
re.search(rf"(?:^| )({re.escape(alias)})(?:$| )", normalized_query)
]
if match
]
# Exact matching used to compile and run one regex for every alias
# (~10k) on every new line. Enumerating the query's contiguous token
# spans and looking them up in an immutable alias index is equivalent
# at word boundaries and turns the common exact-name path into O(q²)
# in the short query rather than O(catalog).
exact: list[tuple[str, str, int, int]] = []
for start in range(len(query_tokens)):
last = min(len(query_tokens), start + self._max_alias_tokens)
for end in range(start + 1, last + 1):
alias = " ".join(query_tokens[start:end])
exact.extend(
(drug_id, alias, start, end)
for drug_id in self._alias_to_drug_ids.get(alias, ())
)
if exact:
maximal = [
row for row in exact
@@ -137,12 +149,22 @@ class CatalogDrugResolver:
needle = normalize_name(prefix)
if not needle:
return []
matches: list[tuple[tuple[int, int], str]] = []
matches: list[tuple[tuple[int, int, int], str]] = []
for drug_id, alias in self._aliases:
position = alias.find(needle)
if position < 0:
continue
matches.append(((0 if position == 0 else 1, len(alias)), drug_id))
canonical = normalize_name(drug_id.replace("_", " "))
canonical_words = canonical.split()
if canonical.startswith(needle):
source_rank = 0
elif any(word.startswith(needle) for word in canonical_words):
source_rank = 1
elif position == 0:
source_rank = 2
else:
source_rank = 3
matches.append(((source_rank, len(canonical), len(alias)), drug_id))
matches.sort()
ordered: list[str] = []
seen: set[str] = set()
@@ -157,7 +179,7 @@ class CatalogDrugResolver:
# See the comment on `resolve` above — same cost, same fix, same
# single-caller read-only usage (safe to hand back a cached list).
@functools.lru_cache(maxsize=4096)
@functools.lru_cache(maxsize=4096) # noqa: B019 - bounded process singleton
def suggest(
self, query: str, k: int = 3, min_score: float = 0.5
) -> list[tuple[str, float]]:
+9
View File
@@ -270,6 +270,13 @@ class RetrievalService:
# Excluded from pooling outright rather than trusting a score margin
# that isn't reliably wide enough on its own.
_LEXICAL_POOL_EXCLUDED_SECTIONS = frozenset({"duoc_ly_va_co_che_tac_dung"})
# Cross-section pooling solves one measured semantic mismatch: a
# "thận trọng" question whose decisive condition is filed under
# contraindications. Applying it to every explicit section leaked a
# lexically-overlapping interaction section into a dosage answer. Keep
# this recall expansion opt-in for the one section with evidence for it;
# exact dosage/interaction/contraindication routes remain exact.
_LEXICAL_POOL_ENABLED_SECTIONS = frozenset({"than_trong"})
def _section_hits(self, query: str, drug_id: str) -> list[SearchHit] | None:
"""Hits for an explicitly named section, or None to fall back.
@@ -302,6 +309,8 @@ class RetrievalService:
as into a query-driven check across every section, still bounded and
still whole-section (never a partial, out-of-context fragment).
"""
if resolved_section not in self._LEXICAL_POOL_ENABLED_SECTIONS:
return []
search_lexical = getattr(self._retriever, "search_lexical", None)
if search_lexical is None:
return []
+106 -10
View File
@@ -133,6 +133,17 @@ class QueryFrame:
age_text: str | None = None
indication: str | None = None # symptom/disease, for symptom_to_drug
route: str | None = None # e.g. "uong", "tiem_tinh_mach", "dat_truc_trang"
# True when the user is asking to survey/summarise a whole named section
# (for example all ADRs, precautions, or dosage regimens), rather than
# asking for one regimen to apply to a particular patient. This is a
# materially different sufficiency contract: a section survey must label
# every branch, while a patient-specific dose may need clarification.
section_overview: bool = False
# Self-contained meaning used downstream. It rewrites references/omitted
# subjects only; retrieval still trusts the separately validated drug ids,
# facet and constraints below rather than parsing authority back from text.
standalone_query: str | None = None
depends_on_previous_turn: bool = False
needs_clarify: bool = False
clarify_reason: str | None = None
# Short suggested replies for `clarify_reason` (e.g. ("Người lớn", "Trẻ
@@ -174,6 +185,19 @@ FRAME_SCHEMA = {
"'uống' or 'tiêm' to your own prior clarify question about route IS "
"this field — read it here, do not leave it null and re-ask."
),
"section_overview": (
"true when the user asks for a general survey of the requested section "
"(all ADRs/interactions/precautions/dose regimens, 'theo Dược thư', "
"'nếu có'), not one dose/decision for a specific patient"
),
"standalone_query": (
"self-contained Vietnamese question after resolving pronouns/omitted subject "
"from history; do not answer it. For a self-contained current turn, copy its "
"meaning without adding facts"
),
"depends_on_previous_turn": (
"true only when standalone_query needed entity/facet/constraints from history"
),
"needs_clarify": "true only if the turn cannot be acted on without more info",
"clarify_reason": "short Vietnamese question to ask, or null",
"quick_replies": (
@@ -187,6 +211,17 @@ FRAME_SCHEMA = {
),
}
_ALLOWED_POPULATIONS = {
"tre_em", "tre_so_sinh", "nguoi_lon", "nguoi_cao_tuoi",
"phu_nu_co_thai", "phu_nu_cho_con_bu", "suy_than", "suy_gan",
}
_ALLOWED_ROUTES = {
"uong", "tiem_tinh_mach", "tiem_bap", "tiem_duoi_da",
"dat_truc_trang", "boi_ngoai_da", "nho_mat", "nho_mui", "khac",
}
_QUICK_REPLY_MAX_ITEMS = 4
_QUICK_REPLY_MAX_CHARS = 40
_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.
@@ -203,8 +238,24 @@ Quy tắc bắt buộc:
- 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.
- Với "dosing_calc", bộ hiểu câu hỏi chỉ bắt buộc làm rõ population và tuổi/cân
nặng nếu là trẻ em/trẻ sơ sinh. KHÔNG tự kết luận rằng route luôn bắt buộc: nếu
người dùng chưa nêu đường dùng thì giữ route=null; tầng trả lời sẽ nhìn chính
BẰNG CHỨNG truy xuất được để quyết định câu hỏi đã đủ rõ hay chưa. Nhờ vậy nếu
nguồn chỉ có một đường dùng phù hợp thì trả lời thẳng, còn nếu có nhiều nhánh
khác nhau mới hỏi lại và sinh quick_replies.
- Phân biệt tra cứu TOÀN MỤC với áp dụng cho MỘT NGƯỜI BỆNH. Nếu người dùng hỏi
tổng quan kiểu "Dược thư hướng dẫn dùng thế nào", "các ADR/tương tác/thận trọng
nào", "theo nhóm/tần suất", "nếu có" thì đặt section_overview=true và KHÔNG
hỏi người lớn/trẻ em/đường dùng chỉ để thu hẹp. Hãy trả về các nhánh trong sách
với nhãn rõ ràng. Chỉ đặt section_overview=false và hỏi thêm khi người dùng muốn
chọn một liều/quyết định áp dụng cho ca bệnh cụ thể.
- 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".
thuốc đang nói tới và điền vào "drugs". Đồng thời viết `standalone_query` thành
câu có nghĩa độc lập (ví dụ "Còn chống chỉ định của levetiracetam?" ->
"Chống chỉ định của levetiracetam") và đặt `depends_on_previous_turn=true`.
- Nếu lượt hiện tại đã tự đủ nghĩa, `standalone_query` chỉ chuẩn hóa chính câu đó,
không chèn thuốc/đối tượng cũ và đặt `depends_on_previous_turn=false`.
- QUAN TRỌNG — lượt hiện tại trả lời câu hỏi bạn VỪA hỏi: nếu dòng "Trợ lý:" cuối
cùng trong LỊCH SỬ là một câu hỏi làm rõ (vd "Người lớn hay trẻ em?", "Uống hay
tiêm?", "Cân nặng bao nhiêu kg?"), và CÂU HỎI HIỆN TẠI là một câu trả lời ngắn
@@ -308,8 +359,16 @@ class LlmQueryUnderstander:
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)
else:
# Exact/ambiguous resolution has already exhaustively scanned
# the aliases present in this line. Running the O(catalog)
# fuzzy pass as well only adds unrelated candidates and cost
# (~1s on the real catalog); fuzzy suggestions are useful only
# when no exact candidate was found (the typo path).
for drug_id, _score in self._resolver.suggest(
line, k=5, min_score=0.55
):
ids.add(drug_id)
return ids
def understand(
@@ -424,19 +483,29 @@ class LlmQueryUnderstander:
turn_type = data.get("turn_type")
if turn_type not in TURN_TYPES:
turn_type = "drug_attribute" if drugs else "out_of_scope"
needs_clarify = data.get("needs_clarify") is True
clarify_reason = _clean_str(data.get("clarify_reason"))
quick_replies = (
_clean_quick_replies(data.get("quick_replies"))
if needs_clarify and clarify_reason
else ()
)
return QueryFrame(
turn_type=turn_type,
drugs=drugs,
unknown_drugs=tuple(dict.fromkeys(unknown)),
attribute=attribute,
population=_clean_str(data.get("population")),
population=_clean_enum(data.get("population"), _ALLOWED_POPULATIONS),
weight_kg=_clean_float(data.get("weight_kg")),
age_text=_clean_str(data.get("age_text")),
indication=_clean_str(data.get("indication")),
route=_clean_str(data.get("route")),
needs_clarify=bool(data.get("needs_clarify")),
clarify_reason=_clean_str(data.get("clarify_reason")),
quick_replies=tuple(_as_list(data.get("quick_replies"))),
route=_clean_enum(data.get("route"), _ALLOWED_ROUTES),
section_overview=data.get("section_overview") is True,
standalone_query=_clean_str(data.get("standalone_query")),
depends_on_previous_turn=data.get("depends_on_previous_turn") is True,
needs_clarify=needs_clarify,
clarify_reason=clarify_reason,
quick_replies=quick_replies,
raw=data if isinstance(data, dict) else {},
)
@@ -527,12 +596,39 @@ def _clean_str(value) -> str | None:
return None
def _clean_enum(value, allowed: set[str]) -> str | None:
cleaned = _clean_str(value)
return cleaned if cleaned in allowed else None
def _clean_quick_replies(value) -> tuple[str, ...]:
"""Keep the LLM's dynamic suggestions, but enforce the UI contract."""
if not isinstance(value, list):
return ()
replies: list[str] = []
seen: set[str] = set()
for item in value:
if not isinstance(item, str):
continue
reply = " ".join(item.split())
key = reply.casefold()
if not reply or len(reply) > _QUICK_REPLY_MAX_CHARS or key in seen:
continue
seen.add(key)
replies.append(reply)
if len(replies) >= _QUICK_REPLY_MAX_ITEMS:
break
return tuple(replies)
def _clean_float(value) -> float | None:
if isinstance(value, (int, float)):
return float(value)
parsed = float(value)
return parsed if 0 < parsed <= 500 else None
if isinstance(value, str):
try:
return float(value.replace(",", ".").split()[0])
parsed = float(value.replace(",", ".").split()[0])
return parsed if 0 < parsed <= 500 else None
except (ValueError, IndexError):
return None
return None