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
+11 -1
View File
@@ -13,6 +13,7 @@ and the Messages-API path on Bedrock is the Mantle client — not the legacy
from __future__ import annotations
import json
import re
from typing import Any
from rag.ports import AnswerGenerationUnavailable
@@ -123,8 +124,17 @@ class StubAnswerGenerator:
"""
def __init__(self, answer: str, evidence_sufficient: bool = True) -> None:
# `answer` keeps its old free-text-with-[n]-markers shape for this
# constructor's own callers (bootstrap.py's demo string) — wrapped
# into a single structured claim here since 2026-08-10's schema
# change (see rag/prompt.py's ANSWER_SCHEMA).
citations = [int(n) for n in re.findall(r"\[(\d+)\]", answer)]
text = re.sub(r"\s*\[\d+\]", "", answer).strip()
self._payload = json.dumps(
{"answer": answer, "evidence_sufficient": evidence_sufficient},
{
"claims": [{"text": text, "citations": citations}] if text else [],
"evidence_sufficient": evidence_sufficient,
},
ensure_ascii=False,
)
self.calls: list[tuple[str, str]] = []
+11 -13
View File
@@ -9,8 +9,8 @@ One structural difference from the Anthropic path drives the shape of this file:
Converse has **no** server-side response schema (no `output_config.format`), so
the JSON envelope `answer.py` parses cannot be enforced by the API. It is asked
for in the prompt and then isolated here (`_extract_json`) before returning. If
the model still emits something unparseable, `answer.py` falls back to the
verbatim source text — losing the rewrite, never the answer.
the model still emits something unparseable, `answer.py` fails closed with a
diagnosable abstention instead of presenting a raw dump as a generated answer.
"""
from __future__ import annotations
@@ -92,17 +92,15 @@ class BedrockConverseAnswerGenerator:
BEDROCK_RUNTIME_SERVICE,
region_name=self._region,
config=Config(
connect_timeout=10,
read_timeout=60,
# "adaptive" was tried 2026-08-07 and reverted same day: its
# client-side rate limiter remembers "throttled" across
# requests and paces even unrelated, otherwise-healthy calls
# down after a burst — turned a single answerable turn's
# baseline ~9s into 1-5 MINUTES following this session's own
# heavy adversarial test traffic. "standard" retries each
# call independently, no shared state to get stuck in a bad
# regime. max_attempts alone (3->4) is kept.
retries={"max_attempts": 4, "mode": "standard"},
connect_timeout=5,
# The request budget is checked between calls and cannot
# interrupt boto3 while a call is in flight. A 60s read
# timeout with four SDK attempts allowed one turn to run
# for minutes. Allow one bounded retry: production smoke
# tests showed an isolated Qwen read timeout immediately
# followed by a healthy 3s response for the same request.
read_timeout=20,
retries={"total_max_attempts": 2, "mode": "standard"},
),
)
return self._client
+4 -3
View File
@@ -52,11 +52,12 @@ class Settings(BaseSettings):
metrics_enabled: bool = True
entities_path: Path = _default_entities_path()
# F-08: a per-turn budget across RagAgent's sequential Bedrock calls
# (understand, sufficiency, generate, up to 2 entailment retries).
# (understand, generate, one entailment check on the live agent path).
# Defaults sized with headroom above what a normal turn measures live
# (~8-9s, 4-5 calls) — see rag/agent.py's MAX_WALL_CLOCK_MS/
# (~8-9s before the bounded-provider change) — see rag/agent.py's
# MAX_WALL_CLOCK_MS/
# MAX_LLM_CALLS_PER_TURN for the full rationale.
max_wall_clock_ms: int = 20_000
max_wall_clock_ms: int = 40_000
max_llm_calls_per_turn: int = 8
+158 -19
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",
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)
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": ["entailed", "unsupported"],
"required": ["description", "evidence_quote"],
"additionalProperties": False,
},
},
},
"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 []
+105 -9
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,7 +359,15 @@ 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):
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
@@ -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
+60
View File
@@ -40,6 +40,25 @@ class CitationResponse(BaseModel):
evidence_text: str = ""
class AnswerClaimResponse(BaseModel):
text: str
source_ids: list[str]
class AnswerBlockResponse(BaseModel):
title: str
kind: str
claims: list[AnswerClaimResponse]
class AnswerPlanResponse(BaseModel):
verbosity: str
layout: str
reasoning_mode: str
show_heading: bool
needs_warning: bool
class RagQueryResponse(BaseModel):
trace_id: str
decision: str
@@ -57,6 +76,9 @@ class RagQueryResponse(BaseModel):
# clarify path today; other clarify sources (no_drug, dosing_calc's
# needs_clarify) leave this empty rather than fabricate options.
quick_replies: list[str] = []
blocks: list[AnswerBlockResponse] = []
answer_mode: str = "concise"
answer_plan: AnswerPlanResponse | None = None
def _answer_service(request: Request) -> GroundedAnswerService:
@@ -110,6 +132,32 @@ def _map_citations(items) -> list[CitationResponse]:
]
def _map_blocks(items) -> list[AnswerBlockResponse]:
return [
AnswerBlockResponse(
title=item.title,
kind=item.kind,
claims=[
AnswerClaimResponse(text=claim.text, source_ids=list(claim.source_ids))
for claim in item.claims
],
)
for item in items
]
def _map_plan(item) -> AnswerPlanResponse | None:
if item is None:
return None
return AnswerPlanResponse(
verbosity=item.verbosity,
layout=item.layout,
reasoning_mode=item.reasoning_mode,
show_heading=item.show_heading,
needs_warning=item.needs_warning,
)
@router.post("/query", response_model=RagQueryResponse)
def query_rag(
payload: RagQueryRequest,
@@ -144,6 +192,9 @@ def query_rag(
citations = _map_citations(reply.citations)
generated = reply.generated
quick_replies = list(reply.quick_replies)
blocks = _map_blocks(reply.blocks)
answer_mode = reply.answer_mode
answer_plan = _map_plan(reply.plan)
else:
# No generator configured (ANSWER_PROVIDER=disabled): there is no LLM
# to understand a turn with, so this is retrieval-only, single-turn,
@@ -156,6 +207,9 @@ def query_rag(
citations = []
generated = False
quick_replies = list(grounded.quick_replies)
blocks = []
answer_mode = "concise"
answer_plan = None
else:
decision = grounded.result.decision.value
reason = grounded.result.reason
@@ -164,6 +218,9 @@ def query_rag(
citations = _map_citations(grounded.citations)
generated = grounded.generated
quick_replies = []
blocks = _map_blocks(grounded.blocks)
answer_mode = grounded.answer_mode
answer_plan = _map_plan(grounded.plan)
# Trace persistence is fail-open (F-09): an already-computed, safe answer
# must reach the caller even if Postgres is unreachable. `save()` opens a
@@ -197,4 +254,7 @@ def query_rag(
citations=citations,
generated=generated,
quick_replies=quick_replies,
blocks=blocks,
answer_mode=answer_mode,
answer_plan=answer_plan,
)
+201 -2
View File
@@ -124,6 +124,24 @@ def test_no_drug_named_asks_which_one():
assert reply.reason == "no_drug"
def test_drug_attribute_without_an_attribute_does_not_fall_into_overview_retrieval():
retrieval = _FixedRetrieval({})
answers = GroundedAnswerService(routing=None)
agent = RagAgent(
_FixedUnderstander(QueryFrame(
turn_type="drug_attribute", drugs=("paracetamol_acetaminophen",)
)),
retrieval,
answers,
)
reply = agent.handle("paracetamol thì sao?")
assert reply.decision == "clarify"
assert reply.reason == "missing_attribute"
assert retrieval.calls == []
def test_needs_clarify_frame_is_surfaced_directly():
agent = _agent(QueryFrame(
turn_type="dosing_calc", drugs=("paracetamol",),
@@ -137,6 +155,22 @@ def test_needs_clarify_frame_is_surfaced_directly():
assert reply.quick_replies == ()
def test_understanding_provider_failure_is_an_abstain_not_a_fake_clarification():
agent = _agent(QueryFrame(
turn_type="out_of_scope",
needs_clarify=True,
clarify_reason="Dịch vụ đang gặp sự cố tạm thời.",
system_error="understanding_provider_unavailable",
))
reply = agent.handle("liều paracetamol")
assert reply.decision == "abstain"
assert reply.reason == "understanding_provider_unavailable"
assert reply.answer == "Dịch vụ đang gặp sự cố tạm thời."
assert reply.clarification is None
def test_needs_clarify_frame_carries_quick_replies_through():
"""This is the path real traffic actually hits (checked live): the
understanding LLM call itself sets needs_clarify/clarify_reason before
@@ -153,6 +187,167 @@ def test_needs_clarify_frame_carries_quick_replies_through():
assert reply.quick_replies == ("Người lớn", "Trẻ em")
def test_dosing_without_route_asks_only_when_evidence_is_ambiguous():
result = RetrievalResult(
EvidenceDecision.ANSWERABLE,
"grounded_evidence_available",
(
_evidence("Đường uống, người lớn: 500 mg mỗi lần."),
_evidence("Đặt trực tràng, người lớn: 500 mg mỗi lần."),
),
resolved_drug_id="paracetamol_acetaminophen",
)
class _Generator:
def generate(self, system, user, schema):
return (
'{"claims": [], "evidence_sufficient": false, '
'"clarifying_question": "Anh/chị muốn dùng đường nào?", '
'"quick_replies": ["Uống", "Đặt trực tràng"]}'
)
retrieval = _FixedRetrieval({"paracetamol_acetaminophen": result})
answers = GroundedAnswerService(routing=None, generator=_Generator())
agent = RagAgent(
_FixedUnderstander(QueryFrame(
turn_type="dosing_calc",
drugs=("paracetamol_acetaminophen",),
population="nguoi_lon",
)),
retrieval,
answers,
)
reply = agent.handle("Người lớn", conversation_id="dose-route")
assert reply.decision == "clarify"
assert reply.reason == "needs_more_info"
assert reply.quick_replies == ("Uống", "Đặt trực tràng")
assert len(retrieval.calls) == 1
remembered = agent._last_frame["dose-route"]
assert remembered.needs_clarify is True
assert remembered.population == "nguoi_lon"
assert remembered.clarify_reason == reply.clarification
def test_dosing_without_route_answers_directly_when_evidence_has_one_route():
result = RetrievalResult(
EvidenceDecision.ANSWERABLE,
"grounded_evidence_available",
(_evidence("Đường uống, người lớn: 500 mg mỗi lần."),),
resolved_drug_id="paracetamol_acetaminophen",
)
class _Generator:
def generate(self, system, user, schema):
if "entailed" in schema.get("properties", {}):
return '{"entailed": true, "unsupported": []}'
return (
'{"claims": [{"text": "Đường uống, người lớn: 500 mg mỗi lần.", '
'"citations": [1]}], "evidence_sufficient": true, '
'"clarifying_question": null, "quick_replies": []}'
)
retrieval = _FixedRetrieval({"paracetamol_acetaminophen": result})
answers = GroundedAnswerService(routing=None, generator=_Generator())
agent = RagAgent(
_FixedUnderstander(QueryFrame(
turn_type="dosing_calc",
drugs=("paracetamol_acetaminophen",),
population="nguoi_lon",
)),
retrieval,
answers,
)
reply = agent.handle("Liều Paracetamol cho người lớn")
assert reply.decision == "answerable"
assert reply.quick_replies == ()
assert "500 mg" in reply.answer
assert len(retrieval.calls) == 1
def test_general_dosage_section_survey_does_not_force_population_chip():
result = RetrievalResult(
EvidenceDecision.ANSWERABLE,
"grounded_evidence_available",
(_evidence("Người lớn: uống 10 mg. Trẻ em: liều theo cân nặng."),),
resolved_drug_id="example",
)
retrieval = _FixedRetrieval({"example": result})
agent = RagAgent(
_FixedUnderstander(QueryFrame(
turn_type="dosing_calc",
drugs=("example",),
needs_clarify=True,
clarify_reason="Người lớn hay trẻ em?",
)),
retrieval,
GroundedAnswerService(routing=None),
)
reply = agent.handle(
"Dược thư hướng dẫn dùng Example thế nào: đường dùng và các liều nếu có?"
)
assert reply.decision == "answerable"
assert reply.quick_replies == ()
assert "tra cứu tổng quan toàn mục" in retrieval.calls[0][2]
def test_clear_precaution_section_survey_overrides_model_overclarification():
result = RetrievalResult(
EvidenceDecision.ANSWERABLE,
"grounded_evidence_available",
(_evidence("Theo dõi chức năng thận và điện giải."),),
resolved_drug_id="example",
)
agent = _agent(
QueryFrame(
turn_type="drug_attribute",
drugs=("example",),
attribute="than_trong",
needs_clarify=True,
clarify_reason="Muốn hỏi thận trọng hay chống chỉ định?",
),
{"example": result},
)
reply = agent.handle("Những tình huống nào cần thận trọng khi dùng Example?")
assert reply.decision == "answerable"
assert reply.quick_replies == ()
def test_complete_adult_dosing_core_ignores_an_irrelevant_weight_reask():
result = RetrievalResult(
EvidenceDecision.ANSWERABLE, "grounded_evidence_available",
(_evidence("Đường uống, người lớn: 500 mg mỗi lần."),),
resolved_drug_id="paracetamol_acetaminophen",
)
retrieval = _FixedRetrieval({"paracetamol_acetaminophen": result})
answers = GroundedAnswerService(routing=None)
agent = RagAgent(
_FixedUnderstander(QueryFrame(
turn_type="dosing_calc",
drugs=("paracetamol_acetaminophen",),
population="nguoi_lon",
route="uong",
needs_clarify=True,
clarify_reason="Cân nặng của người lớn là bao nhiêu kg?",
)),
retrieval,
answers,
)
reply = agent.handle("Uống")
assert reply.decision == "answerable"
assert len(retrieval.calls) == 1
assert retrieval.calls[0][1] == "lieu_luong_va_cach_dung"
def test_single_drug_attribute_retrieves_and_answers():
result = RetrievalResult(
EvidenceDecision.ANSWERABLE, "grounded_evidence_available",
@@ -490,7 +685,10 @@ def test_a_generous_budget_does_not_change_normal_behaviour():
return '{"sufficient": true, "clarifying_question": null, "quick_replies": []}'
if "entailed" in schema.get("properties", {}):
return '{"entailed": true, "unsupported": []}'
return '{"answer": "Liều 500 mg [1].", "evidence_sufficient": true, "clarifying_question": null}'
return (
'{"claims": [{"text": "Liều 500 mg", "citations": [1]}], '
'"evidence_sufficient": true, "clarifying_question": null}'
)
answers = GroundedAnswerService(routing=None, generator=_Generator())
agent = RagAgent(
@@ -500,7 +698,8 @@ def test_a_generous_budget_does_not_change_normal_behaviour():
reply = agent.handle("liều metformin")
assert reply.decision == "answerable"
assert reply.answer == "Liều 500 mg [1]."
assert reply.answer == "Liều 500 mg"
assert reply.blocks[0].claims[0].source_ids
# --- durable conversation history (ADR 0008's named gap): an optional
@@ -36,7 +36,8 @@ def test_answer_uses_only_printed_page_citations():
(evidence(source),), "abacavir", "resolved",
)))
answer = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
assert answer.answer == "Liều được ghi trong nguồn. [1]"
assert answer.answer == "Liều được ghi trong nguồn."
assert answer.blocks[0].claims[0].source_ids
assert answer.citations[0].printed_page_start == 101
assert answer.citations[0].printed_page_end == 103
@@ -84,7 +84,8 @@ def test_only_cited_sources_are_returned():
result = _answerable(_evidence(0, 100), _evidence(1, 200), _evidence(2, 300))
service = GroundedAnswerService(
_Routing(result),
_Generator({"answer": "Chỉ dùng đoạn hai [2].", "evidence_sufficient": True}),
_Generator({"claims": [{"text": "Chỉ dùng đoạn hai", "citations": [2]}],
"evidence_sufficient": True}),
)
grounded = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
@@ -105,7 +106,8 @@ def test_answer_citing_nothing_is_rejected_not_dressed_up_with_borrowed_citation
# and it must not silently degrade to a raw extractive quote either
# (owner correction, 2026-08-06: no fallback to the retired
# offline-extractive shape when a real generator is configured).
_Generator({"answer": "Không có trích dẫn.", "evidence_sufficient": True}),
_Generator({"claims": [{"text": "Không có trích dẫn.", "citations": []}],
"evidence_sufficient": True}),
)
grounded = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
@@ -157,8 +159,8 @@ def test_sufficiency_check_outage_fails_open_to_generation_not_abstain():
the real safety net on whatever gets generated next."""
result = _answerable(_evidence(0, 100), _evidence(1, 200))
gen = _Generator(
{"answer": "Đoạn bằng chứng 0 [1].", "evidence_sufficient": True,
"clarifying_question": None},
{"claims": [{"text": "Đoạn bằng chứng 0", "citations": [1]}],
"evidence_sufficient": True, "clarifying_question": None},
sufficiency_payload=AnswerGenerationUnavailable("Bedrock unreachable"),
)
service = GroundedAnswerService(_Routing(result), gen)
@@ -177,8 +179,8 @@ def test_sufficient_query_is_not_turned_into_a_clarification():
g = service.answer("liều người lớn", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
# sufficiency passes; generation then runs (its payload lacks answer keys, so
# it falls back to the source text) — the point is no clarification fired.
# Sufficiency passes; generation then runs (its payload lacks answer keys,
# so it fails closed) — the point is no clarification fired.
assert g.clarification is None
@@ -208,8 +210,10 @@ def test_list_mode_skips_the_sufficiency_clarify():
must never call it at all, so only the real answer payload is ever read."""
result = _answerable(_evidence(0, 100), _evidence(1, 200))
gen = _Generator(
{"answer": "Đoạn bằng chứng 0 [1]. Đoạn bằng chứng 1 [2].",
"evidence_sufficient": True, "clarifying_question": None},
{"claims": [
{"text": "Đoạn bằng chứng 0", "citations": [1]},
{"text": "Đoạn bằng chứng 1", "citations": [2]},
], "evidence_sufficient": True, "clarifying_question": None},
sufficiency_payload={
"sufficient": False, "clarifying_question": "Loại nào?", "quick_replies": [],
},
+130 -75
View File
@@ -67,18 +67,14 @@ class _FixedRouting:
class _Generator:
"""Returns whatever payload the test wants the model to have produced.
`_generate` now makes up to five calls through this port: the main
answer (a lone `evidence_sufficient: false` retries once — the same
noisy-judge finding as entailment, live-confirmed 2026-08-07), a
sufficiency check (skipped here — one evidence block), and up to three
entailment calls (widened from two 2026-08-07: live probing found the
judge noisy on an identical claim/evidence pair, and a real adversarial
sample showed a single retry still discarding correct answers on the
unlucky reject-reject draw). They're told apart by schema, so a test
`_generate` can make a main answer call (a lone
`evidence_sufficient: false` retries once) and one fail-closed entailment
call. The legacy direct-answer path may also make a sufficiency call when
several evidence blocks need disambiguation; the structured agent path
skips that duplicate judgment. They're told apart by schema, so a test
that only cares about one call doesn't have to fake the others; `payload`
and `entailment_payload` each take either a fixed value or a list for a
different answer on each successive call to that schema (e.g.
`[reject, reject, accept]` for the third-attempt-recovers case).
different answer on each successive call to that schema.
"""
def __init__(self, payload, entailment_payload=None) -> None:
@@ -124,7 +120,7 @@ def _answer(payload, result: RetrievalResult | None = None, entailment_payload=N
def test_invented_dose_is_refused_and_never_reaches_the_answer():
grounded, metrics = _answer(
{"answer": "Người lớn uống 850 mg, 2 lần mỗi ngày [1].",
{"claims": [{"text": "Người lớn uống 850 mg, 2 lần mỗi ngày", "citations": [1]}],
"evidence_sufficient": True}
)
@@ -147,7 +143,8 @@ def test_a_rounded_figure_counts_as_invented():
"""`2 g` is in the source; `2000 mg` is a conversion, and conversions are
where unit errors live. The prompt forbids it and the check enforces it."""
grounded, metrics = _answer(
{"answer": "Liều tối đa 2000 mg mỗi ngày [1].", "evidence_sufficient": True}
{"claims": [{"text": "Liều tối đa 2000 mg mỗi ngày", "citations": [1]}],
"evidence_sufficient": True}
)
assert grounded.generated is False
@@ -156,7 +153,8 @@ def test_a_rounded_figure_counts_as_invented():
def test_citation_pointing_at_nothing_is_refused():
grounded, metrics = _answer(
{"answer": "Người lớn uống 500 mg [3].", "evidence_sufficient": True}
{"claims": [{"text": "Người lớn uống 500 mg", "citations": [3]}],
"evidence_sufficient": True}
)
assert grounded.generated is False
@@ -170,12 +168,13 @@ def test_citation_pointing_at_nothing_is_refused():
def test_faithful_rewrite_is_served():
grounded, metrics = _answer(
{"answer": "Người lớn: 500 mg, 2 lần/ngày; tối đa 2 g/ngày [1].",
{"claims": [{"text": "Người lớn: 500 mg, 2 lần/ngày; tối đa 2 g/ngày", "citations": [1]}],
"evidence_sufficient": True}
)
assert grounded.generated is True
assert grounded.answer == "Người lớn: 500 mg, 2 lần/ngày; tối đa 2 g/ngày [1]."
assert grounded.answer == "Người lớn: 500 mg, 2 lần/ngày; tối đa 2 g/ngày"
assert grounded.blocks[0].claims[0].source_ids == ("metformin::lieu::0",)
assert metrics.total(GENERATION_SERVED) == 1
assert metrics.total(GENERATION_REJECTED) == 0
@@ -190,7 +189,7 @@ def test_fabricated_nonnumeric_claim_with_a_valid_citation_is_rejected():
the entailment pass, told the model judged evidence 1 does not support
it, is what rejects the generation."""
grounded, metrics = _answer(
{"answer": "Metformin chữa ung thư [1].", "evidence_sufficient": True},
{"claims": [{"text": "Metformin chữa ung thư", "citations": [1]}], "evidence_sufficient": True},
entailment_payload={"entailed": False, "unsupported": [1]},
)
@@ -202,7 +201,7 @@ def test_fabricated_nonnumeric_claim_with_a_valid_citation_is_rejected():
def test_entailment_check_running_and_passing_still_serves_the_answer():
grounded, metrics = _answer(
{"answer": "Metformin dùng điều trị đái tháo đường [1].",
{"claims": [{"text": "Metformin dùng điều trị đái tháo đường", "citations": [1]}],
"evidence_sufficient": True},
entailment_payload={"entailed": True, "unsupported": []},
)
@@ -211,13 +210,15 @@ def test_entailment_check_running_and_passing_still_serves_the_answer():
assert metrics.total(GENERATION_SERVED) == 1
def test_entailment_retries_once_after_a_reject_and_a_later_accept_serves():
"""Reproduces the 2026-08-06 live finding: the same claim/evidence pair,
called three times through the real judge, came back entailed twice and
rejected once — a single noisy reject must not discard a correct,
well-cited answer."""
def test_entailment_rejects_after_one_fail_closed_semantic_pass():
"""The verifier is one semantic pass after deterministic grounding.
Repeating an identical temperature-0 prompt against the same model is a
correlated retry, not an independent vote, and doubled the hot-path model
latency for every valid answer.
"""
grounded, metrics = _answer(
{"answer": "Metformin dùng điều trị đái tháo đường [1].",
{"claims": [{"text": "Metformin chữa ung thư", "citations": [1]}],
"evidence_sufficient": True},
entailment_payload=[
{"entailed": False, "unsupported": [1]},
@@ -225,52 +226,102 @@ def test_entailment_retries_once_after_a_reject_and_a_later_accept_serves():
],
)
assert grounded.generated is True
assert metrics.total(GENERATION_SERVED) == 1
def test_entailment_recovers_on_third_attempt_after_two_rejects():
"""The improvement 2026-08-07 widened the retry from 2 to 3 attempts
after a live 50-question adversarial sample found the 2-attempt policy's
own math (~11% false-discard rate on a genuinely valid claim, from the
noise probed in the docstring above) matched the observed real
abstention rate almost exactly. Two rejects followed by a real accept
must now be served, not discarded."""
grounded, metrics = _answer(
{"answer": "Metformin dùng điều trị đái tháo đường [1].",
"evidence_sufficient": True},
entailment_payload=[
{"entailed": False, "unsupported": [1]},
{"entailed": False, "unsupported": [1]},
{"entailed": True, "unsupported": []},
],
)
assert grounded.generated is True
assert metrics.total(GENERATION_SERVED) == 1
assert metrics.total(GENERATION_REJECTED) == 0
def test_entailment_three_agreeing_rejects_still_discard():
grounded, metrics = _answer(
{"answer": "Metformin chữa ung thư [1].", "evidence_sufficient": True},
entailment_payload=[
{"entailed": False, "unsupported": [1]},
{"entailed": False, "unsupported": [1]},
{"entailed": False, "unsupported": [1]},
],
)
assert grounded.generated is False
assert grounded.answer is None
# All 3 attempts are noisy-judge calls against the SAME claim/evidence —
# a real, reliable rejection must still discard exactly once, not 3x.
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 1
def test_supported_but_incomplete_answer_is_rejected_against_full_raw_evidence():
grounded, metrics = _answer(
{
"claims": [{"text": "Người lớn uống 500 mg", "citations": [1]}],
"evidence_sufficient": True,
},
entailment_payload={
"entailed": True,
"unsupported": [],
"complete": False,
"missing_evidence": [{
"description": "2 lần mỗi ngày và liều tối đa 2 g mỗi ngày",
"evidence_quote": EVIDENCE_TEXT,
}],
},
)
assert grounded.answer is None
assert grounded.result.reason == "incomplete_answer"
assert metrics.total(GENERATION_REJECTED, reason="incomplete_answer") == 1
def test_completeness_judge_cannot_claim_its_own_quoted_fact_is_missing():
grounded, _ = _answer(
{
"claims": [{
"text": "Chảy máu giữa vòng kinh (rất hay gặp trong 3 tháng đầu dùng thuốc theo đường tiêm).",
"citations": [1],
}],
"evidence_sufficient": True,
},
result=_result(
"Chảy máu giữa vòng kinh (rất hay gặp trong 3 tháng đầu dùng thuốc theo đường tiêm)."
),
entailment_payload={
"entailed": True,
"unsupported": [],
"complete": False,
"missing_evidence": [{
"description": "Không ghi nhận 'rất hay gặp trong 3 tháng đầu dùng thuốc theo đường tiêm'",
"evidence_quote": "rất hay gặp trong 3 tháng đầu dùng thuốc theo đường tiêm",
}],
},
)
assert grounded.generated is True
assert grounded.answer is not None
def test_completeness_objection_without_a_real_source_quote_is_ignored():
grounded, _ = _answer(
{
"claims": [{
"text": "Người lớn: uống 500 mg metformin hydroclorid, 2 lần mỗi ngày. Liều tối đa 2 g mỗi ngày, chia làm nhiều lần.",
"citations": [1],
}],
"evidence_sufficient": True,
},
entailment_payload={
"entailed": True,
"unsupported": [],
"complete": False,
"missing_evidence": [{
"description": "Không nêu điều kiện độ ẩm",
"evidence_quote": "độ ẩm",
}],
},
)
assert grounded.generated is True
assert grounded.answer is not None
def test_entailment_accepts_after_one_semantic_pass():
grounded, metrics = _answer(
{"claims": [{"text": "Metformin dùng điều trị đái tháo đường", "citations": [1]}],
"evidence_sufficient": True},
entailment_payload=[
{"entailed": True, "unsupported": []},
{"entailed": False, "unsupported": [1]}, # never consulted
],
)
assert grounded.generated is True
assert metrics.total(GENERATION_SERVED) == 1
def test_entailment_provider_outage_fails_closed_to_abstain():
grounded, metrics = _answer(
{"answer": "Người lớn: 500 mg, 2 lần/ngày [1].", "evidence_sufficient": True},
{"claims": [{"text": "Người lớn: 500 mg, 2 lần/ngày", "citations": [1]}],
"evidence_sufficient": True},
entailment_payload=AnswerGenerationUnavailable(),
)
@@ -280,13 +331,16 @@ def test_entailment_provider_outage_fails_closed_to_abstain():
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 1
def test_entailment_check_is_skipped_when_the_answer_has_no_claim_text():
"""An answer that is nothing but a citation marker has no claim text for
an entailment pass to check against — `_verify_entailment` must not call
def test_entailment_check_is_skipped_when_there_are_no_claims():
"""No claims at all (2026-08-10: the structured-claims schema makes a
claim's `text` a required, non-empty field, so the old "answer is
nothing but a bare citation marker" scenario can no longer occur — the
analogous edge case is an empty `claims` list) has nothing for an
entailment pass to check against — `_verify_entailment` must not call
the provider at all. Proven by making that call raise: if the skip
didn't fire, this would reject rather than serve the answer."""
grounded, metrics = _answer(
{"answer": "[1]", "evidence_sufficient": True},
{"claims": [], "evidence_sufficient": True},
entailment_payload=AnswerGenerationUnavailable(),
)
@@ -297,7 +351,8 @@ def test_entailment_check_is_skipped_when_the_answer_has_no_claim_text():
def test_citations_survive_generation():
"""Provenance is the point; a prettier answer must not cost the folio."""
grounded, _ = _answer(
{"answer": "Người lớn: 500 mg [1].", "evidence_sufficient": True}
{"claims": [{"text": "Người lớn: 500 mg", "citations": [1]}],
"evidence_sufficient": True}
)
assert grounded.generated is True
@@ -313,9 +368,9 @@ def test_citations_survive_generation():
[
(AnswerGenerationUnavailable("revoked"), "provider_unavailable"),
("not json at all", "malformed_output"),
({"answer": "500 mg [1]"}, "malformed_output"),
({"answer": 500, "evidence_sufficient": True}, "malformed_output"),
({"answer": "...", "evidence_sufficient": False}, "evidence_insufficient"),
({"claims": [{"text": "500 mg", "citations": [1]}]}, "malformed_output"),
({"claims": 500, "evidence_sufficient": True}, "malformed_output"),
({"claims": [], "evidence_sufficient": False}, "evidence_insufficient"),
],
)
def test_every_generation_failure_abstains_instead_of_a_raw_source_dump(payload, reason):
@@ -340,21 +395,21 @@ def test_evidence_insufficient_retries_once_and_recovers():
pattern already known for entailment, just on a different field of the
same call. A lone insufficient verdict must not be final."""
grounded, metrics = _answer([
{"answer": "...", "evidence_sufficient": False},
{"answer": "Metformin dùng điều trị đái tháo đường [1].",
{"claims": [], "evidence_sufficient": False},
{"claims": [{"text": "Metformin dùng điều trị đái tháo đường", "citations": [1]}],
"evidence_sufficient": True},
])
assert grounded.generated is True
assert grounded.answer == "Metformin dùng điều trị đái tháo đường [1]."
assert grounded.answer == "Metformin dùng điều trị đái tháo đường"
assert metrics.total(GENERATION_SERVED) == 1
assert metrics.total(GENERATION_REJECTED) == 0
def test_evidence_insufficient_twice_still_abstains():
grounded, metrics = _answer([
{"answer": "...", "evidence_sufficient": False},
{"answer": "...", "evidence_sufficient": False},
{"claims": [], "evidence_sufficient": False},
{"claims": [], "evidence_sufficient": False},
])
assert grounded.generated is False
@@ -259,7 +259,8 @@ def test_real_rag_agent_end_to_end_through_the_http_api():
"needs_clarify": False, "clarify_reason": None,
},
answer_payload={
"answer": f"{record['text']} [1].", "evidence_sufficient": True,
"claims": [{"text": record["text"], "citations": [1]}],
"evidence_sufficient": True,
},
)
understander = LlmQueryUnderstander(
@@ -197,6 +197,34 @@ def test_retrieve_framed_pools_lexically_strong_neighbour_section():
]
def test_explicit_dosage_section_does_not_pool_a_lexical_interaction_match():
documents = [
RetrievalDocument(
doc_id=f"paracetamol::{section}::0", drug_id="paracetamol",
kind="prose", section_key=section,
text=f"Nội dung mục {section}.", source_refs=(SOURCE,),
)
for section in ("lieu_luong_va_cach_dung", "tuong_tac_thuoc")
]
interaction = next(d for d in documents if d.section_key == "tuong_tac_thuoc")
retriever = _OverviewRetriever(
documents, lexical_hits=[SearchHit(interaction, score=10.0)]
)
service = RetrievalService(retriever, InMemoryParentStore([]), EvidencePolicy())
result = service.retrieve_framed(
"paracetamol", "lieu_luong_va_cach_dung",
"Liều uống paracetamol cho người lớn",
)
assert result.decision == EvidenceDecision.ANSWERABLE
returned_sections = {
evidence.matched_doc_id.split("::")[1] for evidence in result.evidence
}
assert returned_sections == {"lieu_luong_va_cach_dung"}
assert retriever.lexical_calls == []
def test_row_hit_hydrates_complete_parent_and_keeps_citation():
result = table_service().retrieve("acetylcystein 45 kg bao nhiêu ml", "acetylcystein")
assert result.decision == EvidenceDecision.ANSWERABLE
@@ -318,6 +346,18 @@ def test_verified_aliases_reach_common_parenthesized_drug_names():
)
def test_autocomplete_prioritizes_canonical_name_over_an_unrelated_trade_alias():
resolver = CatalogDrugResolver({
"paracetamol_acetaminophen": {"paracetamol", "acetaminophen"},
"galantamin": {"paragal"},
"metformin": {"metformin"},
"alpha_tocopherol_vitamin_e": {"met-alpha"},
})
assert resolver.complete("para", k=2)[0] == "paracetamol_acetaminophen"
assert resolver.complete("met", k=2)[0] == "metformin"
def test_verified_catalog_protects_canonical_substring_traps():
resolver = CatalogDrugResolver(load_aliases(VERIFIED_ENTITIES))
traps = {
@@ -51,6 +51,7 @@ class _FakeResolver:
def __init__(self, known: dict[str, str], suggestions: dict[str, str] | None = None) -> None:
self._known = known
self._suggestions = suggestions or {}
self.suggest_calls = 0
def resolve(self, query: str) -> _Resolution:
low = query.lower()
@@ -60,6 +61,7 @@ class _FakeResolver:
return _Resolution()
def suggest(self, query: str, k: int = 3, min_score: float = 0.5):
self.suggest_calls += 1
low = query.lower()
return [
(drug_id, 0.9) for needle, drug_id in self._suggestions.items() if needle in low
@@ -84,6 +86,21 @@ def test_drug_id_in_exact_underscore_form_resolves():
assert frame.unknown_drugs == ()
def test_exact_candidate_does_not_repeat_the_catalog_wide_fuzzy_scan():
resolver = _FakeResolver({"metformin": "metformin"})
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "drug_attribute", "drugs": ["metformin"],
"unknown_drugs": [], "attribute": "chong_chi_dinh",
"population": None, "weight_kg": None, "age_text": None,
"indication": None, "needs_clarify": False, "clarify_reason": None,
}), CATALOG, resolver)
frame = understander.understand("chống chỉ định metformin")
assert frame.drugs == ("metformin",)
assert resolver.suggest_calls == 0
def test_drug_id_echoed_with_spaces_instead_of_underscores_still_resolves():
"""Reproduces the live 2026-08-06 bug on a genuine multi-turn shape: the
drug is named in an earlier turn (in history), the current turn is just
@@ -202,6 +219,40 @@ def test_quick_replies_are_parsed_when_the_model_offers_them():
assert frame.quick_replies == ("Người lớn", "Trẻ em")
def test_quick_replies_are_dynamic_but_bounded_before_becoming_ui_chips():
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "dosing_calc", "drugs": ["paracetamol_acetaminophen"],
"unknown_drugs": [], "attribute": None, "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": True, "clarify_reason": "Chọn nhóm phù hợp?",
"quick_replies": [
" Người lớn ", "người lớn", "Trẻ em", 12,
"Phụ nữ có thai", "Người cao tuổi", "Lựa chọn thứ năm",
],
}), CATALOG, RESOLVER)
frame = understander.understand("liều paracetamol")
assert frame.quick_replies == (
"Người lớn", "Trẻ em", "Phụ nữ có thai", "Người cao tuổi"
)
def test_string_false_does_not_turn_into_a_clarification():
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "drug_attribute", "drugs": ["paracetamol_acetaminophen"],
"unknown_drugs": [], "attribute": "chong_chi_dinh", "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": "false", "clarify_reason": "Không được hiển thị",
"quick_replies": ["", "Không"],
}), CATALOG, RESOLVER)
frame = understander.understand("chống chỉ định paracetamol")
assert frame.needs_clarify is False
assert frame.quick_replies == ()
def test_missing_quick_replies_key_defaults_to_empty_not_a_crash():
"""The model is asked for `quick_replies` but structured-output providers
aren't guaranteed to include every optional key — a clarify without it
@@ -240,6 +291,26 @@ def test_route_is_parsed_when_the_model_resolves_it():
assert frame.needs_clarify is False
def test_follow_up_exposes_a_first_class_standalone_query():
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "drug_attribute", "drugs": ["metformin"],
"unknown_drugs": [], "attribute": "chong_chi_dinh",
"population": None, "weight_kg": None, "age_text": None,
"indication": None, "route": None,
"standalone_query": "Chống chỉ định của metformin",
"depends_on_previous_turn": True,
"needs_clarify": False, "clarify_reason": None,
}), CATALOG, RESOLVER)
frame = understander.understand(
"thế còn chống chỉ định?",
history=("Người dùng: Metformin dùng để làm gì?",),
)
assert frame.standalone_query == "Chống chỉ định của metformin"
assert frame.depends_on_previous_turn is True
def test_missing_route_key_defaults_to_none_not_a_crash():
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "dosing_calc", "drugs": [],
+45 -16
View File
@@ -19,7 +19,10 @@ import { cn } from "@duoc-thu/ui";
interface ChatPanelProps {
sessionId: string;
messages: ChatMessage[];
onMessagesChange: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
initialQuery?: string;
initialQueryToken?: number;
onCitationClick?: (citation: Citation, index: number, allCitations: Citation[]) => void;
onCitationsLoaded?: (citations: Citation[]) => void;
activeCitationIndex?: number | null;
@@ -28,42 +31,45 @@ interface ChatPanelProps {
const STARTER_QUESTIONS = [
{
category: "Liều Dùng Lâm Sàng",
query: "Liều dùng Paracetamol người lớn và trẻ em theo cân nặng là bao nhiêu?",
category: "Chỉ Định",
query: "Levetiracetam được chỉ định trong những trường hợp nào?",
icon: Pill,
},
{
category: "Chống Chỉ Định",
query: "Chống chỉ định tuyệt đối và tương đối của Amoxicillin là gì?",
query: "Chống chỉ định của Metformin là gì?",
icon: Stethoscope,
},
{
category: "Tương Tác Thuốc",
query: "Tương tác giữa Metformin và thuốc cản quang chứa iốt xử trí thế nào?",
category: "ADR Theo Tần Suất",
query: "Tác dụng không mong muốn của Zolpidem là gì?",
icon: Activity,
},
{
category: "Thận Trọng & ADR",
query: "Thận trọng khi dùng Aspirin cho bệnh nhân có tiền sử loét dạ dày?",
category: "Thời Kỳ Mang Thai",
query: "Có thể dùng Fluoxetin trong thời kỳ mang thai không?",
icon: Zap,
},
];
export function ChatPanel({
sessionId,
messages,
onMessagesChange: setMessages,
initialQuery,
initialQueryToken,
onCitationClick,
onCitationsLoaded,
activeCitationIndex = null,
className,
}: ChatPanelProps) {
const { resolvedTheme } = useTheme();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const initialQuerySentRef = useRef<string | undefined>(undefined);
const initialQuerySentRef = useRef<number | undefined>(undefined);
const stopRequestedRef = useRef(false);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -87,8 +93,12 @@ export function ChatPanel({
setMessages((prev) => [...prev, userMsg]);
setIsLoading(true);
stopRequestedRef.current = false;
abortControllerRef.current = new AbortController();
const timeoutId = window.setTimeout(() => {
abortControllerRef.current?.abort();
}, 25_000);
try {
const res = await fetch("/api/chat", {
@@ -115,10 +125,16 @@ export function ChatPanel({
}
} catch (err: any) {
if (err.name === "AbortError") {
setError(
stopRequestedRef.current
? "Đã dừng chờ trên giao diện. Tác vụ đang chạy có thể cần vài giây để kết thúc an toàn."
: "Yêu cầu vượt quá 25 giây và đã được dừng. Vui lòng thử lại với câu hỏi cụ thể hơn."
);
return;
}
setError("Không thể kết nối đến máy chủ AI Service. Vui lòng kiểm tra lại dịch vụ backend.");
} finally {
window.clearTimeout(timeoutId);
setIsLoading(false);
abortControllerRef.current = null;
}
@@ -126,12 +142,15 @@ export function ChatPanel({
const handleStop = () => {
if (abortControllerRef.current) {
stopRequestedRef.current = true;
abortControllerRef.current.abort();
setIsLoading(false);
abortControllerRef.current = null;
}
};
useEffect(() => {
return () => abortControllerRef.current?.abort();
}, []);
useEffect(() => {
// Guard against firing twice for the same query: React 18 Strict Mode
// (dev only) runs this effect setup twice on mount, and with no guard
@@ -140,12 +159,16 @@ export function ChatPanel({
// muốn của Aspirin" turns in the trace). The ref persists across the
// Strict Mode replay, so the second invocation for the same
// `initialQuery` is a no-op; a genuinely new query still sends once.
if (initialQuery && initialQuerySentRef.current !== initialQuery) {
initialQuerySentRef.current = initialQuery;
if (
initialQuery &&
initialQueryToken !== undefined &&
initialQuerySentRef.current !== initialQueryToken
) {
initialQuerySentRef.current = initialQueryToken;
handleSendMessage(initialQuery);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialQuery]);
}, [initialQuery, initialQueryToken]);
// Empty state renderer per theme
const renderEmptyState = () => {
@@ -165,7 +188,7 @@ export function ChatPanel({
Tra Cứu Dược Thư Quốc Gia Việt Nam
</h2>
<p className="text-xs sm:text-sm text-txt-secondary mt-1.5 leading-relaxed max-w-xl mx-auto">
Hệ thống AI y tế tra cứu chính xác theo 684 chuyên luận chính thức. Mọi thông tin đu đưc xác thực suy luận (Entailment Verification) kèm trích dẫn trang in PDF.
Tra cứu 684 chuyên luận Dược thư Quốc gia Việt Nam 2018 với căn cứ theo trang in. Khi cần, bác thể tiếp tục trao đi đ làm dữ kiện đi chiếu với bối cảnh lâm sàng.
</p>
</div>
@@ -321,7 +344,13 @@ export function ChatPanel({
}
activeCitationIndex={activeCitationIndex}
onRetry={retryQuery ? () => handleSendMessage(retryQuery) : undefined}
onQuickReply={(text) => handleSendMessage(text)}
onQuickReply={
msgIdx === messages.length - 1 &&
msg.decision === "clarify" &&
!isLoading
? (text) => handleSendMessage(text)
: undefined
}
/>
);
})
+13 -19
View File
@@ -12,14 +12,6 @@ interface ComposerProps {
className?: string;
}
const SAMPLE_SUGGESTIONS = [
"Liều dùng Paracetamol người lớn và trẻ em theo cân nặng",
"Chống chỉ định và tác dụng không mong muốn của Amoxicillin",
"Tương tác thuốc giữa Metformin và thuốc cản quang",
"Thận trọng khi dùng Aspirin cho bệnh nhân loét dạ dày",
"Hướng dẫn liều dùng Ibuprofen và giới hạn tối đa ngày",
];
export function Composer({
onSubmit,
isLoading = false,
@@ -76,13 +68,9 @@ export function Composer({
return;
}
}
// Fallback filter local suggestions
const filtered = SAMPLE_SUGGESTIONS.filter((s) =>
s.toLowerCase().includes(term.toLowerCase())
);
if (cancelled) return;
setSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
setSuggestions([]);
setShowSuggestions(false);
} catch {
if (!cancelled) {
setSuggestions([]);
@@ -122,6 +110,15 @@ export function Composer({
setShowSuggestions(false);
};
const applySuggestion = (suggestion: string) => {
// Keep the clinical intent already typed and replace only the unfinished
// final token: "liều para" -> "liều Paracetamol", not "Paracetamol".
const prefix = value.match(/^([\s\S]*\s)[^\s]*$/)?.[1] ?? "";
setValue(`${prefix}${suggestion}`);
setShowSuggestions(false);
setSelectedIndex(-1);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showSuggestions && suggestions.length > 0) {
if (e.key === "ArrowDown") {
@@ -136,9 +133,7 @@ export function Composer({
}
if (e.key === "Enter" && selectedIndex >= 0) {
e.preventDefault();
setValue(suggestions[selectedIndex]);
setShowSuggestions(false);
setSelectedIndex(-1);
applySuggestion(suggestions[selectedIndex]);
return;
}
}
@@ -166,8 +161,7 @@ export function Composer({
<button
key={idx}
onClick={() => {
setValue(item);
setShowSuggestions(false);
applySuggestion(item);
inputRef.current?.focus();
}}
className={cn(
+27 -8
View File
@@ -32,11 +32,31 @@ interface SidebarProps {
}
const QUICK_PROMPTS = [
{ drug: "Paracetamol", label: "Liều dùng Paracetamol người lớn & trẻ em" },
{ drug: "Amoxicillin", label: "Chống chỉ định & Thận trọng khi dùng Amoxicillin" },
{ drug: "Metformin", label: "Liều lượng & Tương tác thuốc Metformin" },
{ drug: "Aspirin", label: "Chỉ định & Tác dụng không mong muốn của Aspirin" },
{ drug: "Ibuprofen", label: "Liều dùng Ibuprofen theo trọng lượng cơ thể" },
{
drug: "Levetiracetam",
label: "Chỉ định của Levetiracetam",
query: "Levetiracetam được chỉ định trong những trường hợp nào?",
},
{
drug: "Metformin",
label: "Chống chỉ định của Metformin",
query: "Chống chỉ định của Metformin là gì?",
},
{
drug: "Zolpidem",
label: "ADR Zolpidem theo tần suất",
query: "Tác dụng không mong muốn của Zolpidem là gì?",
},
{
drug: "Fluoxetin",
label: "Fluoxetin trong thời kỳ mang thai",
query: "Có thể dùng Fluoxetin trong thời kỳ mang thai không?",
},
{
drug: "Danazol",
label: "Tương tác thuốc của Danazol",
query: "Danazol có những tương tác thuốc nào?",
},
];
export function Sidebar({
@@ -194,7 +214,7 @@ export function Sidebar({
{QUICK_PROMPTS.map((prompt, idx) => (
<button
key={idx}
onClick={() => onQuickQuery(prompt.label)}
onClick={() => onQuickQuery(prompt.query)}
className="w-full text-left p-2 rounded-xl bg-surface-elevated hover:bg-surface-hover border border-border-subtle text-txt-secondary hover:text-txt-primary text-[0.72rem] leading-snug transition-all flex items-center justify-between group"
>
<span className="truncate pr-1">{prompt.label}</span>
@@ -206,12 +226,11 @@ export function Sidebar({
</div>
{/* System Stats Footer */}
<div className="p-3 border-t border-border-subtle bg-surface-elevated/40 text-[0.68rem] text-txt-muted flex items-center justify-between">
<div className="p-3 border-t border-border-subtle bg-surface-elevated/40 text-[0.68rem] text-txt-muted flex items-center">
<div className="flex items-center gap-1.5">
<BookOpen className="w-3.5 h-3.5 text-accent-primary" />
<span>Dược thư QGVN 2018</span>
</div>
<span className="font-semibold text-accent-primary">684 Chuyên luận</span>
</div>
</aside>
);
+59 -9
View File
@@ -1,12 +1,9 @@
import { NextResponse } from "next/server";
import type { Citation, SendMessageResponse } from "@duoc-thu/shared-types";
import type { AnswerBlock, AnswerPlan, Citation, SendMessageResponse } from "@duoc-thu/shared-types";
export const runtime = "nodejs";
const API_GATEWAY_URL = process.env.API_GATEWAY_URL ?? process.env.AI_SERVICE_URL ?? "http://localhost:8079";
const DISCLAIMER =
"Nội dung trích từ Dược thư Quốc gia Việt Nam, chỉ mang tính tra cứu chuyên môn và không thay thế chỉ định của bác sĩ hoặc dược sĩ.";
const API_GATEWAY_URL = process.env.API_GATEWAY_URL ?? process.env.AI_SERVICE_URL ?? "http://localhost:8000";
interface RagCitation {
chunk_id: string;
@@ -29,6 +26,40 @@ interface RagResponse {
citations: RagCitation[];
generated?: boolean;
quick_replies?: string[];
blocks?: Array<{
title: string;
kind: string;
claims: Array<{ text: string; source_ids: string[] }>;
}>;
answer_mode?: "concise" | "normal" | "detailed";
answer_plan?: {
verbosity: "concise" | "normal" | "detailed";
layout: string;
reasoning_mode: string;
show_heading: boolean;
needs_warning: boolean;
} | null;
}
function toAnswerBlocks(raw: NonNullable<RagResponse["blocks"]>): AnswerBlock[] {
return raw.map((block) => ({
title: block.title,
kind: block.kind,
claims: block.claims.map((claim) => ({
text: claim.text,
sourceIds: claim.source_ids,
})),
}));
}
function toAnswerPlan(raw: NonNullable<RagResponse["answer_plan"]>): AnswerPlan {
return {
verbosity: raw.verbosity,
layout: raw.layout,
reasoningMode: raw.reasoning_mode,
showHeading: raw.show_heading,
needsWarning: raw.needs_warning,
};
}
const REFUSALS: Record<string, string> = {
@@ -87,6 +118,8 @@ const REFUSALS: Record<string, string> = {
"Hệ thống phát hiện một phần câu trả lời không có trích dẫn nguồn rõ ràng nên đã huỷ để tránh sai sót. Vui lòng thử lại.",
unsupported_claim:
"Dược thư có nội dung liên quan đến câu hỏi này, nhưng bước đối chiếu lại chưa xác nhận được câu trả lời khớp hoàn toàn với nguồn. Vui lòng thử lại.",
incomplete_answer:
"Câu trả lời vừa tạo đã bị huỷ vì bước đối chiếu phát hiện còn bỏ sót dữ kiện liên quan trong nguồn. Vui lòng thử lại để hệ thống tạo câu trả lời đầy đủ hơn.",
// Kept as the fallback `answer.py` itself falls back to when, for some
// reason, none of the specific codes above was set.
generation_unavailable:
@@ -171,6 +204,12 @@ export async function POST(request: Request) {
if (!content) {
return NextResponse.json({ error: "empty_query" }, { status: 400 });
}
if (content.length > 4000) {
return NextResponse.json({ error: "query_too_long" }, { status: 400 });
}
if (conversationId && conversationId.length > 128) {
return NextResponse.json({ error: "conversation_id_too_long" }, { status: 400 });
}
const correlationId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
@@ -194,6 +233,10 @@ export async function POST(request: Request) {
conversation_id: conversationId,
}),
cache: "no-store",
// Propagate a browser disconnect/Stop action to the upstream fetch.
// The synchronous Bedrock call already in flight may finish, but this
// prevents the BFF itself from keeping an orphaned HTTP request open.
signal: request.signal,
});
if (!upstream.ok) {
rag = {
@@ -212,7 +255,7 @@ export async function POST(request: Request) {
trace_id: `fallback-${Date.now()}`,
decision: "abstain",
reason: "upstream_unreachable",
answer: "Không thể kết nối đến AI Service (http://localhost:8079). Vui lòng đảm bảo AI Service đã được bật.",
answer: `Không thể kết nối đến AI Service (${API_GATEWAY_URL}). Vui lòng đảm bảo AI Service đã được bật.`,
resolved_drug_id: null,
citations: [],
};
@@ -227,23 +270,30 @@ export async function POST(request: Request) {
// answer-less case (retrieval abstained with no message to show).
const noAnswer = rag.answer === null;
const isAbstain = rag.decision === "abstain";
const isGroundedAnswer =
rag.decision === "answerable" && !noAnswer && rag.citations.length > 0;
const message: SendMessageResponse["message"] = {
id: rag.trace_id || `msg-${Date.now()}`,
role: "assistant",
content: noAnswer ? (REFUSALS[rag.reason] ?? GENERIC_REFUSAL) : (rag.answer ?? GENERIC_REFUSAL),
citations: isAbstain || noAnswer ? [] : toCitations(rag.citations),
disclaimer: DISCLAIMER,
traceId: rag.trace_id,
decision: rag.decision,
reason: rag.reason,
grounded: !isAbstain && !noAnswer,
generated: !isAbstain && !noAnswer ? Boolean(rag.generated) : false,
grounded: isGroundedAnswer,
generated: isGroundedAnswer ? Boolean(rag.generated) : false,
resolvedDrugId: rag.resolved_drug_id ?? undefined,
createdAt: new Date().toISOString(),
quickReplies:
rag.decision === "clarify" && rag.quick_replies && rag.quick_replies.length > 0
? rag.quick_replies
: undefined,
blocks:
isGroundedAnswer && rag.blocks && rag.blocks.length > 0
? toAnswerBlocks(rag.blocks)
: undefined,
answerMode: rag.answer_mode,
answerPlan: rag.answer_plan ? toAnswerPlan(rag.answer_plan) : undefined,
};
return NextResponse.json(
+1 -1
View File
@@ -3,7 +3,7 @@ import { NextResponse } from "next/server";
export const runtime = "nodejs";
const API_GATEWAY_URL =
process.env.API_GATEWAY_URL ?? process.env.AI_SERVICE_URL ?? "http://localhost:8079";
process.env.API_GATEWAY_URL ?? process.env.AI_SERVICE_URL ?? "http://localhost:8000";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
+1 -4
View File
@@ -6,7 +6,7 @@ import "./globals.css";
export const metadata: Metadata = {
title: "Dược Thư RAG — Medical Chatbot Platform (DTQGVN 2018)",
description: "Hệ thống AI y tế tra cứu Dược thư Quốc gia Việt Nam 2018 với căn cứ trích dẫn chính xác và xác thực Entailment Verification.",
description: "Tra cứu 684 chuyên luận Dược thư Quốc gia Việt Nam 2018 với căn cứ theo trang in, hỗ trợ bác sĩ làm rõ dữ kiện và thảo luận theo bối cảnh lâm sàng.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
@@ -36,9 +36,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
ENTAILEMENT VERIFIED
</span>
</div>
<p className="m-0 text-[0.68rem] font-medium text-txt-muted">
Dược thư Quốc gia Việt Nam 2018 (684 chuyên luận 15.100 chunks)
</p>
</div>
</div>
+47 -23
View File
@@ -1,30 +1,29 @@
"use client";
import { useState } from "react";
import type { Citation } from "@duoc-thu/shared-types";
import { useEffect, useState } from "react";
import type { ChatMessage, Citation } from "@duoc-thu/shared-types";
import { ChatPanel } from "./_components/ChatPanel";
import { Sidebar, ChatSession } from "./_components/Sidebar";
import { EvidencePanel } from "./_components/EvidencePanel";
import { MessageSquare, FileSearch, Menu, X, Layers } from "lucide-react";
import { cn } from "@duoc-thu/ui";
const INITIAL_SESSIONS: ChatSession[] = [
{
id: "session-1",
title: "Tra cứu liều dùng Paracetamol",
updatedAt: new Date().toISOString(),
},
{
id: "session-2",
title: "Chống chỉ định Amoxicillin",
updatedAt: new Date().toISOString(),
},
];
function createSessionId() {
return `session-${crypto.randomUUID()}`;
}
export default function ChatPage() {
const [sessions, setSessions] = useState<ChatSession[]>(INITIAL_SESSIONS);
const [currentSessionId, setCurrentSessionId] = useState<string>("session-1");
const [queryOverride, setQueryOverride] = useState<string | undefined>();
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string>("");
const [messagesBySession, setMessagesBySession] = useState<Record<string, ChatMessage[]>>({});
const [queryOverride, setQueryOverride] = useState<{ text: string; token: number } | null>(null);
useEffect(() => {
const id = createSessionId();
setSessions([{ id, title: "Phiên tra cứu mới", updatedAt: new Date().toISOString() }]);
setMessagesBySession({ [id]: [] });
setCurrentSessionId(id);
}, []);
// Citation & Evidence Panel State
const [citations, setCitations] = useState<Citation[]>([]);
@@ -36,15 +35,16 @@ export default function ChatPage() {
const [showEvidenceDesktop, setShowEvidenceDesktop] = useState(true);
const handleNewChat = () => {
const newId = `session-${Date.now()}`;
const newId = createSessionId();
const newSession: ChatSession = {
id: newId,
title: "Phiên tra cứu mới",
updatedAt: new Date().toISOString(),
};
setSessions((prev) => [newSession, ...prev]);
setMessagesBySession((prev) => ({ ...prev, [newId]: [] }));
setCurrentSessionId(newId);
setQueryOverride(undefined);
setQueryOverride(null);
setCitations([]);
setActiveCitationIndex(null);
setShowMobileSidebar(false);
@@ -52,12 +52,19 @@ export default function ChatPage() {
const handleSelectSession = (id: string) => {
setCurrentSessionId(id);
setQueryOverride(undefined);
setQueryOverride(null);
setCitations([]);
setActiveCitationIndex(null);
setShowMobileSidebar(false);
};
const handleDeleteSession = (id: string) => {
setSessions((prev) => prev.filter((s) => s.id !== id));
setMessagesBySession((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
if (currentSessionId === id) {
const remaining = sessions.filter((s) => s.id !== id);
if (remaining.length > 0) {
@@ -69,10 +76,24 @@ export default function ChatPage() {
};
const handleQuickQuery = (query: string) => {
setQueryOverride(query);
setQueryOverride({ text: query, token: Date.now() });
setShowMobileSidebar(false);
};
const currentMessages = messagesBySession[currentSessionId] ?? [];
const setCurrentMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>> = (update) => {
const sessionId = currentSessionId;
setMessagesBySession((prev) => {
const existing = prev[sessionId] ?? [];
const next = typeof update === "function" ? update(existing) : update;
return { ...prev, [sessionId]: next };
});
};
if (!currentSessionId) {
return <div className="flex flex-1 items-center justify-center text-sm text-txt-muted">Đang tạo phiên tra cứu an toàn...</div>;
}
const handleCitationClick = (citation: Citation, index: number, allCitations: Citation[]) => {
// Found live 2026-08-07: this used to only set the index into whatever
// `citations` array was last loaded (i.e. the MOST RECENT answer's), so
@@ -157,9 +178,12 @@ export default function ChatPage() {
{/* Desktop Region 2: Primary Answer Workspace */}
<main className="flex-1 flex justify-center overflow-hidden relative">
<ChatPanel
key={`${currentSessionId}-${queryOverride}`}
key={currentSessionId}
sessionId={currentSessionId}
initialQuery={queryOverride}
messages={currentMessages}
onMessagesChange={setCurrentMessages}
initialQuery={queryOverride?.text}
initialQueryToken={queryOverride?.token}
onCitationClick={handleCitationClick}
onCitationsLoaded={handleCitationsLoaded}
activeCitationIndex={activeCitationIndex}
+13 -3
View File
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { BookOpen, Bookmark, FileText, Sparkles } from "lucide-react";
import type { Citation } from "@duoc-thu/shared-types";
import type { ChatMessage, Citation } from "@duoc-thu/shared-types";
import { ChatPanel } from "../_components/ChatPanel";
import { cn } from "@duoc-thu/ui";
@@ -10,6 +10,12 @@ export default function TraCuuPage() {
const [activePage, setActivePage] = useState<number | null>(null);
const [activeDrug, setActiveDrug] = useState<string | null>(null);
const [pdfSrc, setPdfSrc] = useState("/api/pdf");
const [sessionId, setSessionId] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>([]);
useEffect(() => {
setSessionId(`lookup-${crypto.randomUUID()}`);
}, []);
function handleCitationClick(citation: Citation) {
if (citation.sourcePageRange && citation.sourcePageRange[0]) {
@@ -69,11 +75,15 @@ export default function TraCuuPage() {
<Sparkles className="h-3.5 w-3.5 text-accent-primary" />
<span>Bấm vào Trích Nguồn bên dưới đ nhảy trực tiếp tới trang PDF tương ng</span>
</div>
{sessionId && (
<ChatPanel
sessionId="tra-cuu-session"
sessionId={sessionId}
messages={messages}
onMessagesChange={setMessages}
className="flex-1 h-full"
onCitationClick={handleCitationClick}
/>
)}
</div>
</div>
);
+128
View File
@@ -0,0 +1,128 @@
# Claude handoff — 2026-08-10, in case of context/token cutoff
Read this before touching `apps/ai-service/rag/answer.py`, `rag/prompt.py`,
`adapters/bedrock_claude.py`, or any test file under `apps/ai-service/tests/`
that references the answer-generation schema. A structured-claims refactor
is **IN PROGRESS AND NOT YET FULLY GREEN**.
## What's done and committed (pushed, deployed, live-verified)
- Production live at `https://realvuxbaro.me` (EC2 + Docker Compose + Caddy
SSL + GitHub Actions CI/CD). See `project_production_deployment_live`
memory (Claude's own memory dir, not readable by Codex — this file is the
Codex-readable version of the relevant parts).
- Section-neighbour lexical pooling (`rag/service.py::_pooled_neighbour_hits`,
`adapters/qdrant.py::search_lexical`) — fixes 2 of 3 persistent audit
abstains (Aspirin+loét dạ dày, Vancomycin rapid-infusion). Committed,
deployed, live-verified.
- Token-budget packing wired into the overview/rerank fallback
(`rag/context.py::pack_evidence`, was dead code, now used in
`rag/service.py`). Committed, deployed.
- Entailment verification changed from "accept on any single True out of 3"
to MAJORITY VOTE (2-of-3) — `rag/answer.py::_verify_entailment`. Committed,
deployed, live-verified no regression. Full reasoning (measured math,
adversarial spot-check results) is in the commit message and the
function's own docstring — read that before changing it again.
- `Composer.tsx` autocomplete: fixed matching the whole sentence instead of
the last word being typed, added a race guard on the debounced fetch.
Committed, deployed.
## What's IN PROGRESS, NOT committed, NOT deployed (as of this handoff)
**Structured-claims output** (`rag/prompt.py` ANSWER_SCHEMA changed from
`{answer: string, evidence_sufficient, clarifying_question}` to
`{claims: [{text, citations}], evidence_sufficient, clarifying_question}`;
`rag/answer.py` parses `claims` and deterministically assembles the display
string via `_assemble_answer` — same `text [n]` format the frontend already
renders, so `grounding.verify` and the frontend need NO changes).
**Modified, uncommitted**: `apps/ai-service/adapters/bedrock_claude.py`,
`apps/ai-service/rag/answer.py`, `apps/ai-service/rag/prompt.py`,
`apps/ai-service/tests/test_grounded_generation.py` (this one IS finished —
25/25 pass).
**Still broken as of this handoff** (`python -m pytest -q` in
`apps/ai-service`, 4 failures, 208 passed):
- `tests/test_agent.py::test_a_generous_budget_does_not_change_normal_behaviour`
— 1 fake generator payload at ~line 493 still uses the old
`{"answer": "...", ...}` shape, needs converting to
`{"claims": [{"text": "...", "citations": [...]}], ...}` (see
`test_grounded_generation.py`'s already-converted tests for the pattern).
- `tests/test_citation_and_intro.py` — 3 failures, same root cause (old-shape
fake payloads not yet converted): `test_only_cited_sources_are_returned`,
`test_sufficiency_check_outage_fails_open_to_generation_not_abstain`,
`test_list_mode_skips_the_sufficiency_clarify`.
- Have NOT yet checked `tests/test_live_datastores.py` or
`tests/test_bedrock_converse.py` for old-shape payloads — grep for
`"answer":` across `apps/ai-service` to find any remaining.
**Conversion pattern** (mechanical, already applied ~15 times in
`test_grounded_generation.py`):
```python
# OLD:
{"answer": "Người lớn uống 500 mg [1].", "evidence_sufficient": True}
# NEW:
{"claims": [{"text": "Người lớn uống 500 mg", "citations": [1]}], "evidence_sufficient": True}
```
For `evidence_sufficient: False` payloads, old `{"answer": "...", ...}`
becomes `{"claims": [], "evidence_sufficient": False}``_attempt_generation`
now requires `claims` to be a present list even when insufficient, or it's
misclassified as `malformed_output` instead of `evidence_insufficient`.
**After all tests are green**: run full local live-verify (restart
ai-service, hit `/v1/rag/query` for a few real drugs, confirm answers still
read normally and citations still work) before committing. Then commit,
push, let CI/CD deploy, live-verify on `https://realvuxbaro.me` too.
## Task #4 — real BM25 via Qdrant native sparse vectors (NOT STARTED)
Owner gave a detailed 12-point spec, paraphrased:
1. Keep deterministic section routing as the fast path, unchanged.
2. Only for free-form / no-section-matched / low-confidence queries: run
dense + Qdrant native sparse (BM25) in parallel, both filtered to the
resolved `drug_id`, fuse via RRF, feed the existing reranker, then
token-budget pack.
3. Never run hybrid for a query the deterministic route already answered.
4/5. Dense failure falls back to sparse-only; sparse failure falls back to
dense-only.
6. No hardcoding to specific drugs/sections/questions.
7. Proper Vietnamese tokenization — do not blindly reuse English
stemming/stopword defaults.
8. Trace must record dense hits, sparse hits, RRF score, reranker score,
route taken, and per-step latency.
9. Run an ablation on the golden set: dense-only / sparse-only /
dense+sparse RRF / dense+sparse+reranker.
10. Report Recall@K, MRR/nDCG, citation correctness, latency per variant.
11. **Verify Qdrant server AND client library versions support native sparse
vectors/Query API BEFORE designing anything further.**
12. Never touch/lose the existing `duocthu_v1` collection — new
collection/version with a rollback path. Do not deploy before testing
and reporting results.
**Version check already done** (2026-08-10): Qdrant SERVER is 1.18.3 (full
native sparse-vector + Query API/RRF support). Installed `qdrant-client`
PYTHON package is 1.7.0 (has basic `SparseVector` model but NOT the newer
Query API/`FusionQuery` — that needs a client upgrade to roughly 1.10+).
`pyproject.toml`'s `qdrant-client>=1.7,<2` already permits upgrading within
range, no constraint change needed. **Nothing sparse-related has been built
yet** — no sparse index, no corpus indexing, no real sparse query has run.
Do not report "BM25 exists" until all of that is actually done and verified
— explicit owner instruction, PostgreSQL ts_rank/tsvector does NOT count as
BM25 (different formula, no term-frequency saturation / doc-length norm).
## Hard constraint, repeat for emphasis
**No commit message, code comment, memory file, or project doc may
reference the competitor pipeline material the owner showed via
screenshots earlier this session, or say anything is "based on"/"dựa
theo" it.** Justify every design choice from this codebase's own live
findings or public, generically-cited RAG research only. Already checked
clean through commit `df55af4`; keep checking every future commit before
pushing.
## Coordination note
Codex's session was explicitly stopped by the owner this same day; Claude
took over `rag/**` scope at the owner's direction (see
`coordination/README.md`'s "Active ownership" section, already updated).
If Codex resumes, read this file and `coordination/README.md` first.
+203
View File
@@ -0,0 +1,203 @@
# Audit pipeline RAG hội thoại hiện tại
> Phạm vi: worktree `D:\VSF-DUOCTHU` ngày 2026-08-10. Báo cáo phản ánh
> implementation thật đang có trong worktree, bao gồm các thay đổi chưa commit.
> `EXISTS` không có nghĩa là đã đạt chất lượng production; nó chỉ nghĩa là đã
> tìm thấy implementation live tương đương.
## 1. Request path đã xác minh
```text
ChatPanel.handleSendMessage
-> POST /api/chat (Next.js BFF)
-> POST /v1/rag/query (FastAPI)
-> RagAgent.handle
-> history + prior QueryFrame
-> LlmQueryUnderstander.understand
-> RagAgent._route
-> RetrievalService.retrieve_framed / retrieve_by_indication
-> Qdrant metadata route hoặc bounded fallback
-> parent hydration + dedupe + evidence policy
-> GroundedAnswerService.answer_from_result
-> structured claim generation
-> deterministic number/citation grounding
-> semantic support + completeness verifier
-> RagQueryResponse (answer blocks + claims + source ids)
-> /api/chat mapping sang ChatMessage
-> ChatBubble + CitationCard/Evidence panel
```
Đường live không dùng `QueryRoutingService` để fuzzy-resolve thuốc; class này còn
được giữ cho retrieval-only fallback. Live agent dùng candidate-bounded
`LlmQueryUnderstander` rồi truyền `drug_id``section_key` đã resolve vào
`RetrievalService.retrieve_framed`.
### Trace live đã chạy
| Query | Kết quả | Latency quan sát | Đối chiếu raw |
|---|---|---:|---|
| `Levetiracetam cần tránh những điều kiện môi trường nào khi cất giữ?` qua browser `localhost:3000` | answerable; block `Bảo quản`; 1 source, tr. 888 | 10,9 s end-to-end | Đủ 2025 °C, tránh ánh sáng, dung dịch uống giữ trong bao bì ban đầu |
| `Nêu đầy đủ tác dụng không mong muốn của Medroxyprogesteron acetat...` qua API | answerable; 14 nhóm ADR | 14,6 s | Tên ADR và điều kiện chính đủ; hierarchy tần suất kế thừa vẫn cần regression test chặt hơn |
| `Bảo quản Levetiracetam thế nào?` | abstain `provider_unavailable` | 17,9 s | Không có answer để chấm; không tính pass |
| Medroxy completeness repair | abstain `incomplete_answer` | 23,526,8 s | Cho thấy repair path có thể chạm budget/provider và làm latency xấu |
Số mẫu trên chưa đủ để gọi là p50/p95. TTFB bằng gần toàn bộ latency vì response
hiện là JSON nguyên khối, không có streaming.
## 2. Capability matrix
| Capability | Status | Evidence implementation | Quyết định |
|---|---|---|---|
| Conversation state | PARTIAL | `rag/agent.py:RagAgent._get_history/_remember`; `adapters/postgres.py:PostgresConversationStore` | EXTEND: raw lines có window 6 turns; `_last_frame` chỉ in-process, không bền qua restart/multi-worker |
| Context resolution | PARTIAL | `LlmQueryUnderstander.understand`, `_known_facts_block`, `_merge_with_prior_frame` | EXTEND: merge có code backstop chủ yếu cho clarify continuation; topic switching vẫn phụ thuộc model |
| Standalone query rewrite | PARTIAL | `rag/agent.py:_synthesize_query` | EXTEND: đã fold population/age/weight/route/indication nhưng không lưu `standalone_query` first-class trong frame/trace |
| Active entity tracking | PARTIAL | `QueryFrame.drugs`; `RagAgent._last_frame` | EXTEND persistence/isolation; active frame hiện mất khi process restart |
| Intent/facet detection | EXISTS | `QueryFrame.turn_type`, `attribute`, `population`, `route`, `indication`; closed vocab trong `understanding.py` | REUSE; mở rộng multi-facet/reasoning mode, không thêm classifier call riêng |
| Metadata routing | EXISTS | `RetrievalService.retrieve_framed`; `QdrantRetriever.find_by_section/find_by_drug` | KEEP: known entity + facet đi thẳng đúng section |
| Dense retrieval | EXISTS | `QdrantRetriever.search/search_indication` | KEEP bounded fallback; không dùng cho mọi query |
| Sparse/lexical retrieval | PARTIAL | `QdrantRetriever.search_lexical` | EXTEND nếu cần: term-overlap/BM25-style, không phải một sparse vector/BM25 index đầy đủ |
| Hybrid/RRF | PARTIAL | `rag/fusion.py:reciprocal_rank_fusion` có testable primitive nhưng live `RetrievalService` chưa gọi | Không quảng cáo là live hybrid; chỉ wire sau eval chứng minh lợi ích |
| Reranker | PARTIAL | `RetrievalService._rerank`; `BedrockCohereReranker` trong bootstrap | KEEP: chỉ overview/similarity fallback; explicit section route cố ý không rerank |
| Parent/sibling expansion | PARTIAL | `RetrievalService._hydrate` parent hydration; `_pooled_neighbour_hits` bounded cross-section | KEEP bounded; không có generic sibling expansion cho mọi query |
| Evidence selector | PARTIAL | `_hydrate` dedupe, provenance/quarantine policy, `pack_evidence` token budget | EXTEND: chưa có explicit selected/rejected reason trace theo population/route relevance |
| Evidence sufficiency | PARTIAL | generation `evidence_sufficient`; `_check_sufficiency`; completeness verifier | EXTEND thành supported/partial/insufficient/conflicting; hiện boolean và fail toàn answer |
| Multi-section retrieval | PARTIAL | interaction gom evidence nhiều thuốc; `than_trong` opt-in lexical neighbor | EXTEND cho multi-facet có kế hoạch; không mở cross-section pooling toàn cục |
| Reasoning/multi-step logic | MISSING | Không có premise/conclusion representation hoặc bounded decomposition path | ADD sau P0P2; không dùng agent loop cho simple lookup |
| Structured claims | EXISTS | `prompt.py:ANSWER_SCHEMA`; `answer.py:_parse_claims` | KEEP |
| Claim-to-evidence mapping | EXISTS | claim citation indices được map sang stable `source_ids`; response blocks giữ mapping | KEEP; bổ sung claim id/support status khi cần inference/partial |
| Grounding validation | EXISTS | `grounding.verify`; `_verify_entailment` | KEEP; completeness judge cần eval để giảm false positive/negative |
| Abstention | EXISTS | `EvidenceDecision`; granular reject reasons; provider/malformed/grounding guards | KEEP |
| Answer planning | PARTIAL | generation instruction + `_answer_mode` theo claim count + `_build_blocks` theo section | REPLACE heuristic bằng compact plan trong cùng generation call; không thêm LLM call |
| Adaptive verbosity | PARTIAL | `_answer_mode` chỉ dựa claim count; prompt phân biệt broad/specific | EXTEND theo query complexity/answer mode, không chỉ số claim |
| Response composition | PARTIAL | `AnswerBlock/AnswerClaim` và BFF DTO | EXTEND: hiện block granularity còn section-centric; chưa có lead/limitation/group hierarchy |
| SSE/streaming | MISSING | `ChatPanel` dùng `await res.json()`; FastAPI trả `RagQueryResponse`, không `StreamingResponse` | ADD sau correctness; hiện không được nói là streaming |
| Source rendering | EXISTS | `CitationCard`, evidence pane, printed/physical page, raw snippet | KEEP provenance; giảm chip lặp dưới từng claim |
| Semantic response components | PARTIAL | `ChatBubble` render `AnswerBlock.kind`; citation panel | EXTEND nhỏ; không biến mỗi paragraph/section thành card |
| Follow-up handling | PARTIAL | history, prior frame merge, latest-clarify quick replies, clarify circuit breaker | EXTEND và eval 50100 turns; history window hiện 6 turns nên long chat chưa được chứng minh |
| Prometheus/Grafana | PARTIAL | `/metrics`, `PrometheusMetrics`, provisioned Grafana dashboard | KEEP aggregate counters; stack chưa được xác minh running trong audit này |
| Full request trace | PARTIAL | Postgres `rag_retrieval_trace` chỉ lưu query/decision/reason/resolved drug/citations | EXTEND stage timing/frame/route/evidence/guard verdict; không đưa lên user UI |
## 3. Actual pipeline so với target
Phần nên giữ:
- candidate-bounded entity understanding;
- structured `QueryFrame` và deterministic metadata route;
- whole-section retrieval cho explicit facet;
- parent hydration, dedupe, provenance và quarantine;
- structured claims, deterministic numeric grounding và semantic verifier;
- Postgres conversation/trace, Prometheus counter và evidence panel.
Khoảng trống có tác động lớn nhất:
1. active frame không durable và standalone meaning không phải first-class output;
2. một `attribute` duy nhất không biểu diễn multi-facet query;
3. evidence selection/sufficiency chưa biểu diễn partial/conflicting;
4. chưa có direct/synthesis/inference mode và premise mapping;
5. answer plan chỉ là heuristic, renderer hiện quá card-heavy/source-heavy;
6. không streaming; latency 927 s và provider availability là lỗi backend thực;
7. trace chưa đủ stage timing để drill-down từ Grafana.
## 4. Failure taxonomy theo layer
| Layer | Failure đã thấy hoặc có code path | Không được ngụy trang thành |
|---|---|---|
| Understanding/provider | timeout/throttle/malformed frame | user clarification |
| Context | stale entity, mất constraint, clarify loop | retrieval miss |
| Routing | sai facet, single-facet collapse | generator hallucination |
| Retrieval | wrong section, dense weak neighbor, parent missing | answer-style problem |
| Evidence | duplicate, mất heading/condition, token truncation | citation success |
| Generation | unsupported/partial/incomplete claim | “đã grounded” |
| Composition | hierarchy bị làm phẳng, source chip lặp | RAG correctness |
| Availability | provider unavailable, request budget exhausted | “không có trong Dược thư” |
| Observability | thiếu stage timing/selected-rejected evidence | user-facing technical trace |
## 5. Smallest coherent change-set
Không dựng pipeline thứ hai. Mở rộng các abstraction đang có theo thứ tự:
1. **P0 evidence/response contract:** giữ structured claims, thêm answer plan nhỏ
trong cùng generation call; hỗ trợ `lead`, semantic group và limitation;
verifier trả support/completeness rõ, partial không bị trình bày như full.
2. **P1 context:** đưa `standalone_query``depends_on_previous_turn` vào
`QueryFrame`; persist active frame cùng conversation store thay vì dict local.
3. **P2 retrieval planning:** cho frame mang nhiều facets; gọi
`retrieve_framed` theo từng facet có giới hạn rồi dùng cùng `decide`/provenance
policy. Không bật generic RRF/cross-section pooling nếu eval chưa chứng minh.
4. **Composition/UI:** prose/list là mặc định; warning/dosage/table chỉ khi plan
yêu cầu; một affordance `Xem căn cứ` theo group/message, không chip dưới mọi dòng;
bỏ dashboard chrome trong mỗi answer.
5. **Trace/latency:** stage timing và call counts vào internal trace/metrics; sau
khi correctness ổn mới thiết kế safe streaming commit-by-verified-claim.
## 6. Những gì chưa được gọi là pass
- Batch 30 thuốc đã chạy xong nhưng **không pass**: chỉ 11/30 trả lời, 11/30
abstain và 8/30 hỏi lại. Đây là baseline trước bản sửa `section_overview`
evidence-quoted completeness bên dưới, không được dùng làm số sau-fix.
- Hội thoại dài đã chạy qua BFF; xem kết quả và giới hạn encoding ở mục 7.
- Prometheus/Grafana chưa được mở và xác minh trong phiên audit này.
- Không có p50/p95/p99 đủ mẫu.
- Grounded inference chưa được implement.
- Medroxy đã tốt hơn nhưng hierarchy tần suất cần test machine-checkable và
browser review sau khi answer-plan contract hoàn thiện.
## 7. Kết quả triển khai và kiểm chứng ngày 2026-08-10
Thay đổi nhỏ trên đúng pipeline hiện hữu, không tạo pipeline thứ hai:
- `QueryFrame``standalone_query`, `depends_on_previous_turn`
`section_overview`. Tra toàn mục được tách khỏi yêu cầu chọn một liều cho ca
bệnh; drug + facet rõ không còn bị classifier tự ý biến thành chip thu hẹp.
- Answer plan compact (`verbosity`, `layout`, `reasoning_mode`, heading/warning)
được lập trước generation bằng code, không thêm model call.
- Completeness objection phải kèm `evidence_quote`; code kiểm tra quote tồn tại
trong raw và thật sự hỗ trợ mô tả “bị thiếu”. Judge không còn có thể loại câu
bảo quản chỉ vì câu hỏi nhắc “độ ẩm” trong khi raw không nêu độ ẩm.
- Renderer dùng prose/list mặc định, một `Xem căn cứ` cho group, không `[1] [2]`
trong câu trả lời và không card cho từng claim.
Baseline random 30 trước-fix theo facet:
| Facet | Answer | Abstain | Clarify | Nhận xét |
|---|---:|---:|---:|---|
| Bảo quản | 4 | 2 | 0 | completeness false-positive |
| Tương tác | 6 | 0 | 0 | tốt nhất trong mẫu |
| ADR | 1 | 5 | 0 | incomplete/provider/grounding gây fail |
| Liều/cách dùng | 0 | 2 | 4 | ép population cho cả truy vấn toàn mục |
| Thận trọng | 0 | 2 | 4 | classifier hỏi lại dù facet đã rõ |
Retest có đối chiếu raw:
- Levetiracetam sau-fix: answerable 9,3 giây; đủ `2025 °C`, tránh ánh sáng,
dung dịch uống giữ bao bì ban đầu. Browser localhost sau hot path: 6,1 giây.
- Ergotamin tartrat: answerable 6,8 giây; giữ đúng nhiệt độ riêng theo dạng dùng.
- Isosorbid dinitrat toàn mục liều: answerable 19,9 giây thay vì chip; giữ nhãn
chỉ định/đường dùng/liều, nhưng latency chưa đạt.
- Sildenafil ADR vẫn fail `ungrounded_number`; đây là fail đúng của safety gate,
không được đổi nhãn thành pass.
- Ganciclovir, Glipizid, Vancomycin, Isradipin từng gặp
`provider_unavailable`; availability/provider vẫn là blocker thực.
Validation code hiện tại:
- Ruff: pass.
- Pytest: `226 passed, 5 skipped`.
- TypeScript `--noEmit`: pass.
- Next.js production build: pass.
- UI browser: pass về request/render; ảnh review xác nhận hết bullet kép và câu
trả lời không còn citation marker nội tuyến.
Long conversation:
- Một conversation ID chạy 50 request liên tiếp qua `localhost:3000/api/chat`,
không có HTTP error. Runner đầu làm mất dấu tiếng Việt trong user lines khi
đi qua PowerShell nên không dùng 6 lượt cuối của lần này làm kết luận context.
- Giữ nguyên conversation đó và chạy sạch lượt 5156 bằng chuỗi không lỗi
encoding: Levetiracetam → follow-up chống chỉ định → đổi sang Isradipin →
follow-up bảo quản → đổi sang Zolpidem → follow-up ADR. Cả ba follow-up đều
bám đúng thuốc gần nhất; không rò Levetiracetam sang Isradipin/Zolpidem.
- Isradipin thận trọng ở lượt 53 bị `incomplete_answer`, nhưng lượt 54 vẫn resolve
“thuốc này” đúng Isradipin và trả bảo quản dưới 30 °C, lọ kín, tránh sáng/ẩm.
- Latency lượt sạch 5156: 6,918,8 giây; correctness context đạt trong kịch bản
này nhưng tốc độ và provider/completeness availability chưa đạt.
+24
View File
@@ -23,6 +23,26 @@ export interface Citation {
sourceCropUrl?: string;
}
export interface AnswerClaim {
text: string;
/** Stable backend chunk ids; resolved against `message.citations` by the UI. */
sourceIds: string[];
}
export interface AnswerBlock {
title: string;
kind: "fact_list" | "warning" | "dosage" | string;
claims: AnswerClaim[];
}
export interface AnswerPlan {
verbosity: "concise" | "normal" | "detailed";
layout: "prose" | "bullet_list" | "dosage" | string;
reasoningMode: "direct_lookup" | "synthesis" | "grounded_inference" | string;
showHeading: boolean;
needsWarning: boolean;
}
export interface ChatMessage {
id: string;
role: "user" | "assistant";
@@ -44,6 +64,10 @@ export interface ChatMessage {
* and the UI must fall back to free text either way.
*/
quickReplies?: string[];
/** Verified semantic structure from the answer service; never inferred from prose by the UI. */
blocks?: AnswerBlock[];
answerMode?: "concise" | "normal" | "detailed";
answerPlan?: AnswerPlan;
}
export interface SendMessageRequest {
+85 -92
View File
@@ -17,6 +17,7 @@ import {
CornerDownRight,
} from "lucide-react";
import { cn } from "./lib/utils";
import { citationSectionLabel } from "./CitationCard";
interface ChatBubbleProps {
message: ChatMessage;
@@ -167,98 +168,110 @@ export function ChatBubble({
});
};
const renderAnswerBlocks = () => {
if (!message.blocks?.length) return null;
return (
<div className="space-y-5">
{message.blocks.map((block, blockIndex) => {
const warning = block.kind === "warning" && message.answerPlan?.needsWarning;
const showHeading = message.blocks!.length > 1 || message.answerPlan?.showHeading;
const sourceIds = Array.from(new Set(block.claims.flatMap((claim) => claim.sourceIds)));
const sources = sourceIds.flatMap((sourceId) => {
const index = message.citations?.findIndex((item) => item.chunkId === sourceId) ?? -1;
const citation = index >= 0 ? message.citations?.[index] : undefined;
return citation ? [{ citation, index }] : [];
});
return (
<section
key={`${block.title}-${blockIndex}`}
className={cn(
warning && "rounded-lg border-l-2 border-status-warning bg-status-warning-bg/25 px-3 py-2"
)}
>
{showHeading && (
<h4 className="mb-2 flex items-center gap-2 text-sm font-bold text-txt-primary">
{warning && <AlertTriangle className="h-4 w-4 shrink-0 text-status-warning" />}
{block.title}
</h4>
)}
<ul className="list-none space-y-2 p-0" style={{ listStyle: "none" }}>
{block.claims.map((claim, claimIndex) => (
<li
key={`${blockIndex}-${claimIndex}`}
className="text-[0.96rem] leading-relaxed text-txt-primary"
style={{ listStyle: "none" }}
>
<div className="flex items-start gap-2.5">
{message.answerPlan?.layout !== "prose" && (
<span className="mt-[0.6rem] h-1.5 w-1.5 shrink-0 rounded-full bg-accent-primary" />
)}
<div>{formatBoldText(claim.text)}</div>
</div>
</li>
))}
</ul>
{sources.length > 0 && (
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1">
{sources.map(({ citation, index }) => (
<button
key={citation.chunkId}
onClick={() => onCitationClick?.(citation, index + 1, message.citations ?? [])}
className="inline-flex items-center gap-1 text-[0.7rem] font-medium text-txt-muted transition-colors hover:text-accent-primary"
>
<BookOpen className="h-3 w-3" />
Xem căn cứ · {citation.drugName} · {citationSectionLabel(citation.sectionType)} · tr. {citation.sourcePageRange[0]}
</button>
))}
</div>
)}
</section>
);
})}
</div>
);
};
return (
<article
className={cn(
"my-4 w-full rounded-2xl border transition-all shadow-sm glass-content-card",
message.grounded !== false
? "border-border-subtle bg-surface"
: "border-status-warning/30 bg-status-warning-bg/20",
"my-5 w-full",
className
)}
>
{/* Intelligence Document Header */}
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-border-subtle bg-surface-elevated px-4 py-2.5 rounded-t-2xl">
<header className="mb-2.5 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-xl bg-accent-soft text-accent-primary">
<Pill className="h-4 w-4" />
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-accent-soft text-accent-primary">
<BookOpen className="h-3.5 w-3.5" />
</div>
<div>
<h3 className="m-0 text-xs font-bold tracking-tight text-txt-primary flex items-center gap-1.5">
<span>Báo Cáo Tra Cứu Chuyên Luận Dược Thư</span>
{message.resolvedDrugId && (
<span className="rounded-md bg-accent-soft px-1.5 py-0.5 text-[0.68rem] font-bold text-accent-primary uppercase">
{message.resolvedDrugId}
<span className="text-xs font-semibold text-txt-secondary">
{message.resolvedDrugId ? message.resolvedDrugId.replace(/_/g, " ").toUpperCase() : "Trợ lý Dược thư"}
</span>
)}
</h3>
</div>
</div>
<div className="flex items-center gap-2">
{message.decision === "answerable" && message.grounded !== false ? (
<span className="inline-flex items-center gap-1 rounded-full border border-status-success/30 bg-status-success-bg px-2.5 py-0.5 text-[0.65rem] font-extrabold text-status-success">
{message.decision === "answerable" && message.grounded !== false && (
<span className="inline-flex items-center gap-1 text-[0.68rem] font-medium text-status-success">
<ShieldCheck className="h-3 w-3" />
ENTAILED & GROUNDED
</span>
) : message.decision === "verify_pdf" ? (
<span className="inline-flex items-center gap-1 rounded-full border border-status-warning/30 bg-status-warning-bg px-2.5 py-0.5 text-[0.65rem] font-extrabold text-status-warning">
<AlertTriangle className="h-3 w-3" />
CẦN ĐI CHIẾU PDF GỐC
</span>
) : message.decision === "clarify" ? (
<span className="inline-flex items-center gap-1 rounded-full border border-border-accent/40 bg-accent-soft px-2.5 py-0.5 text-[0.65rem] font-extrabold text-accent-primary">
<Info className="h-3 w-3" />
CẦN LÀM CÂU HỎI
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full border border-status-warning/30 bg-status-warning-bg px-2.5 py-0.5 text-[0.65rem] font-extrabold text-status-warning">
<Info className="h-3 w-3" />
THÔNG TIN TRA CỨU MỞ RỘNG
căn cứ Dược thư
</span>
)}
{/* How the answer was produced from the retrieved evidence only
meaningful for a real answerable turn (rag/answer.py's two
operating modes: LLM paraphrase vs. verbatim quote). A clarify
or verify_pdf turn is neither, so it gets no source-mode pill. */}
{message.decision === "answerable" && message.grounded !== false && message.generated !== undefined && (
<span
title={
message.generated
? "Câu trả lời do LLM diễn giải lại từ chuyên luận gốc, đã qua kiểm tra grounding + entailment."
: "Trích dẫn nguyên văn từ chuyên luận gốc, không qua diễn giải của LLM."
}
className="inline-flex items-center gap-1 rounded-full border border-border-subtle bg-surface-elevated px-2.5 py-0.5 text-[0.65rem] font-bold text-txt-secondary"
>
{message.generated ? (
<>
<Sparkles className="h-3 w-3 text-accent-primary" />
AI diễn giải, đã kiểm chứng
</>
) : (
<>
<BookOpen className="h-3 w-3 text-accent-primary" />
Trích dẫn nguyên văn
</>
)}
</span>
)}
<time className="text-[0.68rem] text-txt-muted hidden sm:inline">
</div>
<time className="hidden text-[0.68rem] text-txt-muted sm:inline">
{new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</time>
</div>
</header>
{/* Document Body */}
<div className="p-4 sm:p-5 medical-document-body">
{renderStructuredContent(message.content, message.citations)}
<div className={cn(
"medical-document-body rounded-2xl px-4 py-3.5 sm:px-5",
message.grounded === false ? "bg-status-warning-bg/25" : "bg-surface"
)}>
{message.blocks?.length
? renderAnswerBlocks()
: renderStructuredContent(message.content, message.citations)}
</div>
{/* Quick-reply chips only for a clarify turn the model gave a few
natural discrete answers to; free text always still works. */}
{message.quickReplies && message.quickReplies.length > 0 && (
{onQuickReply && message.quickReplies && message.quickReplies.length > 0 && (
<div className="px-4 pb-3 flex flex-wrap gap-2">
{message.quickReplies.map((reply, idx) => (
<button
@@ -273,27 +286,7 @@ export function ChatBubble({
</div>
)}
{/* Disclaimer Section inside document */}
{message.disclaimer && (
<div className="mx-4 mb-3 rounded-xl border border-border-subtle bg-surface-elevated/50 p-2.5 text-[0.72rem] text-txt-muted flex items-start gap-2">
<Info className="h-3.5 w-3.5 text-accent-primary shrink-0 mt-0.5" />
<span>{message.disclaimer}</span>
</div>
)}
{/* Document Footer & Actions */}
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-border-subtle bg-surface-elevated/40 px-4 py-2.5 rounded-b-2xl text-xs text-txt-muted">
<div className="flex items-center gap-2">
{message.citations && message.citations.length > 0 && (
<div className="flex items-center gap-1.5">
<BookOpen className="h-3.5 w-3.5 text-accent-primary" />
<span className="font-semibold text-txt-secondary text-[0.72rem]">
{message.citations.length} Nguồn trích dẫn chính thức
</span>
</div>
)}
</div>
<footer className="mt-1 flex justify-end gap-1 text-xs text-txt-muted">
<div className="flex items-center gap-2">
{onRetry && (
<button
+5 -1
View File
@@ -40,6 +40,10 @@ const SECTION_LABELS: Record<string, string> = {
thong_tin_quy_che: "Thông tin quy chế",
};
export function citationSectionLabel(sectionType: string): string {
return SECTION_LABELS[sectionType] ?? (sectionType.replace(/_/g, " ") || "Chuyên luận");
}
export function CitationCard({
citation,
index,
@@ -47,7 +51,7 @@ export function CitationCard({
onSelect,
className,
}: CitationCardProps) {
const sectionLabel = SECTION_LABELS[citation.sectionType] ?? citation.sectionType ?? "Chuyên luận";
const sectionLabel = citationSectionLabel(citation.sectionType);
const pageRangeText = citation.sourcePageRange
? `Trang ${citation.sourcePageRange[0]}${
citation.sourcePageRange[1] && citation.sourcePageRange[1] !== citation.sourcePageRange[0]