Fix ai-service Dockerfile: bake in drug_entities.json, override its path

This commit is contained in:
2026-08-10 10:35:13 +07:00
parent a4b8e1c4db
commit 60b4397032
51 changed files with 4302 additions and 2087 deletions
+279 -29
View File
@@ -17,17 +17,50 @@ This module owns routing only; it states no medical fact of its own.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from typing import Protocol
from .answer import Citation, GroundedAnswerService
from .budget import RequestBudget
from .models import EvidenceDecision, RetrievalResult
from .policy import looks_non_human
from .service import RetrievalService
from .understanding import QueryFrame, QueryUnderstander
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
# 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_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)
# clarify question forever — reproduced 3 times independently, one case
# never converged after 5 real answered turns. `understanding.py`'s prior-
# frame merge (F-11) fixes most of the underlying cause, but a code-level
# backstop is still needed: nothing bounds a model that just keeps deciding
# needs_clarify=true no matter what. After this many CONSECUTIVE clarify
# turns on the same conversation, force a hard stop instead of asking again.
MAX_CONSECUTIVE_CLARIFY = 4
class ConversationStore(Protocol):
"""Durable, cross-worker alternative to the in-process history dict —
the gap ADR 0008 names as still open. Satisfied by
`adapters.postgres.PostgresConversationStore`; entirely optional — with
none configured, `RagAgent` behaves exactly as before this existed."""
def recent(self, conversation_id: str, limit: int) -> list[str]: ...
def append(self, conversation_id: str, line: str) -> None: ...
class AutocompleteSource(Protocol):
@@ -47,6 +80,7 @@ class AgentReply:
drugs: tuple[str, ...] = ()
turn_type: str = ""
generated: bool = False
quick_replies: tuple[str, ...] = ()
class RagAgent:
@@ -57,13 +91,23 @@ class RagAgent:
answers: GroundedAnswerService,
autocomplete: AutocompleteSource | None = None,
history_turns: int = HISTORY_TURNS,
max_wall_clock_ms: int = MAX_WALL_CLOCK_MS,
max_llm_calls_per_turn: int = MAX_LLM_CALLS_PER_TURN,
store: ConversationStore | None = None,
) -> None:
self._understander = understander
self._retrieval = retrieval
self._answers = answers
self._autocomplete = autocomplete
self._history_turns = history_turns
self._max_wall_clock_ms = max_wall_clock_ms
self._max_llm_calls_per_turn = max_llm_calls_per_turn
self._store = store
self._history: dict[str, list[str]] = {}
# In-process only, same durability caveat as `_history` (ADR 0008's
# named gap) — lost on restart, not shared across workers.
self._last_frame: dict[str, QueryFrame] = {}
self._clarify_streak: dict[str, int] = {}
def complete(self, prefix: str, k: int = 8) -> list[str]:
"""Display names matching a typed prefix, for input autocomplete."""
@@ -72,20 +116,108 @@ class RagAgent:
return [_display_name(drug_id) for drug_id in self._autocomplete.complete(prefix, k)]
def handle(self, turn: str, conversation_id: str | None = None) -> AgentReply:
history = self._history.get(conversation_id, []) if conversation_id else []
frame = self._understander.understand(turn, tuple(history))
reply = self._route(turn, frame)
# F-08: one budget per turn, threaded through every LLM call this
# turn makes (understand, then whatever `_route` reaches).
t0 = time.monotonic()
budget = RequestBudget.start(self._max_wall_clock_ms, self._max_llm_calls_per_turn)
history = self._get_history(conversation_id)
t1 = time.monotonic()
prior_frame = self._last_frame.get(conversation_id) if conversation_id else None
frame = self._understander.understand(
turn, tuple(history), budget=budget, prior_frame=prior_frame
)
t2 = time.monotonic()
reply = self._route(turn, frame, budget)
reply = self._enforce_clarify_circuit_breaker(conversation_id, reply)
t3 = time.monotonic()
if conversation_id is not None:
self._remember(conversation_id, turn, reply)
self._last_frame[conversation_id] = frame
t4 = time.monotonic()
# Temporary instrumentation (2026-08-07): added specifically to
# pinpoint a live, reproduced-in-browser case of the FIRST LLM call
# already reporting the F-08 budget exhausted — i.e. >20s elapsed
# before even one Bedrock call was attempted, with no code between
# `RequestBudget.start()` and that first `budget.require()` that
# should plausibly take anywhere near that long. Logs unconditionally
# (not just on the slow path) so a normal turn's timing is on record
# too, for comparison.
# .warning, not .info: uvicorn's default logging config only wires
# handlers onto its own "uvicorn"/"uvicorn.access" loggers, not the
# root logger, so a plain .info() here would silently go nowhere —
# confirmed by `understanding.py`'s existing warning-level log
# already showing up in the same server output this session.
logger.warning(
"handle() timing: history=%.2fs understand=%.2fs route=%.2fs "
"remember=%.2fs total=%.2fs",
t1 - t0, t2 - t1, t3 - t2, t4 - t3, t4 - t0,
)
return reply
def _route(self, turn: str, frame: QueryFrame) -> AgentReply:
def _enforce_clarify_circuit_breaker(
self, conversation_id: str | None, reply: AgentReply
) -> AgentReply:
"""Hard stop after `MAX_CONSECUTIVE_CLARIFY` clarify turns in a row.
Every other failure mode in this file degrades to a bounded, honest
abstain — this is the one path that previously had no bound at all:
a model that keeps deciding needs_clarify=true has no natural exit,
and the user has no way out except abandoning the conversation. Any
non-clarify decision (answered, or a different abstain reason) resets
the streak — this only fires on genuinely consecutive clarifies.
"""
if conversation_id is None:
return reply
if reply.decision != "clarify":
self._clarify_streak.pop(conversation_id, None)
return reply
streak = self._clarify_streak.get(conversation_id, 0) + 1
if streak >= MAX_CONSECUTIVE_CLARIFY:
self._clarify_streak.pop(conversation_id, None)
return AgentReply(
"abstain", "clarify_loop_exhausted",
answer=(
"Hệ thống chưa xác định đủ thông tin sau nhiều lần hỏi lại. "
"Anh/chị vui lòng gõ lại TOÀN BỘ câu hỏi trong một tin nhắn "
"đầy đủ (tên thuốc, đối tượng, cân nặng/tuổi nếu có, đường "
"dùng), hoặc bấm \"Tạo phiên tra cứu mới\" để bắt đầu lại."
),
turn_type=reply.turn_type,
)
self._clarify_streak[conversation_id] = streak
return reply
def _get_history(self, conversation_id: str | None) -> list[str]:
if conversation_id is None:
return []
if self._store is not None:
try:
return self._store.recent(conversation_id, self._history_turns * 2)
except Exception:
# Fail-open (F-09's precedent): a store outage means this
# turn is understood fresh, with no memory of earlier ones —
# worse UX, not a 500. `routers/rag.py` wraps
# `PostgresTraceRepository.save()` the same bare way for the
# same reason.
return []
return self._history.get(conversation_id, [])
def _route(self, turn: str, frame: QueryFrame, budget: RequestBudget) -> AgentReply:
tt = frame.turn_type
if frame.needs_clarify and frame.clarify_reason:
return AgentReply("clarify", "needs_more_info",
# `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
# reason instead of the generic "needs_more_info" so a technical
# failure is distinguishable from an ordinary question back, both
# in the API response and in `/metrics`/traces (found live
# 2026-08-07: these were indistinguishable, which is why a real
# outage looked identical to normal clarify traffic).
return AgentReply("clarify", frame.system_error or "needs_more_info",
clarification=frame.clarify_reason,
drugs=frame.drugs, turn_type=tt)
drugs=frame.drugs, turn_type=tt,
quick_replies=frame.quick_replies)
if tt == "smalltalk":
return AgentReply(
@@ -111,40 +243,54 @@ class RagAgent:
answer=f"Không tìm thấy \"{names}\" trong Dược thư Quốc gia Việt Nam.",
turn_type=tt)
if tt == "symptom_to_drug":
# Reverse lookup (indication/adverse-effect -> drugs) is a distinct
# retrieval mode, not yet wired. Be honest rather than abstain blank.
if frame.indication:
return self._symptom_to_drug(turn, frame, budget)
return AgentReply(
"clarify", "reverse_lookup_not_ready",
clarification="Tra ngược theo triệu chứng/chỉ định đang được bổ "
"sung. Anh/chị cho biết tên thuốc cụ thể để tôi tra giúp?",
"clarify", "no_indication",
clarification="Anh/chị mô tả triệu chứng hoặc chỉ định cần tra giúp "
"em với?",
turn_type=tt)
return AgentReply(
"clarify", "no_drug",
clarification="Anh/chị muốn tra thuốc nào?", turn_type=tt)
if tt == "interaction" and len(frame.drugs) >= 2:
return self._interaction(turn, frame)
return self._interaction(turn, frame, budget)
# drug_attribute / drug_overview / dosing_calc / fallback: one drug + section
return self._single_drug(turn, frame)
return self._single_drug(turn, frame, budget)
def _single_drug(self, turn: str, frame: QueryFrame) -> AgentReply:
def _single_drug(self, turn: str, frame: QueryFrame, budget: RequestBudget) -> AgentReply:
query = _synthesize_query(turn, frame)
result = self._retrieval.retrieve_framed(
frame.drugs[0], frame.attribute, turn,
frame.drugs[0], frame.attribute, query,
is_overview=frame.turn_type == "drug_overview",
)
return self._grounded(turn, result, frame)
return self._grounded(query, result, frame, budget=budget)
def _interaction(self, turn: str, frame: QueryFrame) -> AgentReply:
def _interaction(self, turn: str, frame: QueryFrame, budget: RequestBudget) -> AgentReply:
"""Gather the interaction section of each named drug and synthesise.
Absence of a match is stated as "not found in each drug's interaction
section", never as "safe" — the answer layer's grounding still applies.
A quarantined table/formula in EITHER drug's interaction section
forces the whole combined answer to VERIFY_PDF via
`RetrievalService.decide` — the same policy the single-drug path
already applies to a section with quarantined content. Previously
this only kept `part.decision == ANSWERABLE` parts, so a quarantined
drug's evidence (and the "table exists, verify PDF" notice it must
produce per the quarantine contract) was silently dropped instead of
surfaced; a confident interaction answer could omit exactly the
unverified contraindication table it should have flagged. Generating
a synthesis claim from one verified and one unverified source is not
safer than generating from either alone, so both now block
generation the same way.
"""
evidences = []
for drug_id in frame.drugs:
part = self._retrieval.retrieve_framed(drug_id, TUONG_TAC, turn)
if part.decision == EvidenceDecision.ANSWERABLE:
if part.decision in (EvidenceDecision.ANSWERABLE, EvidenceDecision.VERIFY_PDF):
evidences.extend(part.evidence)
if not evidences:
listed = "".join(frame.drugs)
@@ -153,16 +299,47 @@ class RagAgent:
answer=f"Không tìm thấy mục tương tác thuốc cho {listed} trong Dược "
"thư. Điều này KHÔNG có nghĩa là an toàn khi phối hợp.",
drugs=frame.drugs, turn_type=frame.turn_type)
combined = RetrievalResult(
EvidenceDecision.ANSWERABLE, "interaction_evidence",
tuple(evidences),
combined = self._retrieval.decide(tuple(evidences))
return self._grounded(turn, combined, frame, budget=budget)
def _symptom_to_drug(
self, turn: str, frame: QueryFrame, budget: RequestBudget
) -> AgentReply:
"""Reverse lookup: a symptom/indication -> which drugs' `chi_dinh`
actually names it. A factual list from the formulary, not a
treatment ranking or recommendation — no drug is preferred over
another here, only cited as indicated ([[feedback_no_recommendation_gate]]:
this audience is doctors/pharmacists, a lookup like this is normal use).
Absence is stated plainly, never as "no such drug exists" — the
formulary may simply not name this indication under any monograph.
"""
result = self._retrieval.retrieve_by_indication(frame.indication)
if result.decision == EvidenceDecision.ABSTAIN:
return AgentReply(
"abstain", result.reason,
answer=f"Không tìm thấy thuốc nào trong Dược thư Quốc gia Việt Nam ghi "
f"nhận chỉ định cho \"{frame.indication}\". Điều này KHÔNG có "
"nghĩa là không có thuốc điều trị — vui lòng tra theo tên thuốc "
"cụ thể nếu đã biết.",
turn_type=frame.turn_type)
# `matched_doc_id` is always `{drug_id}__chi_dinh__{part_index}` — the
# drugs actually found, not `frame.drugs` (empty by construction for
# this turn_type; the router only reaches here with no named drug).
matched_drugs = tuple(dict.fromkeys(
evidence.matched_doc_id.split("__")[0] for evidence in result.evidence
))
return self._grounded(
turn, result, frame, drugs=matched_drugs, list_mode=True, budget=budget
)
return self._grounded(turn, combined, frame)
def _grounded(
self, turn: str, result: RetrievalResult, frame: QueryFrame
self, turn: str, result: RetrievalResult, frame: QueryFrame,
drugs: tuple[str, ...] | None = None, list_mode: bool = False,
budget: RequestBudget | None = None,
) -> AgentReply:
ga = self._answers.answer_from_result(turn, result)
ga = self._answers.answer_from_result(
turn, result, list_mode=list_mode, budget=budget
)
decision = ga.result.decision.value
if ga.clarification is not None:
decision = "clarify"
@@ -172,18 +349,34 @@ class RagAgent:
answer=ga.answer,
clarification=ga.clarification,
citations=ga.citations,
drugs=frame.drugs,
drugs=drugs if drugs is not None else frame.drugs,
turn_type=frame.turn_type,
generated=ga.generated,
quick_replies=ga.quick_replies,
)
def _remember(self, conversation_id: str, turn: str, reply: AgentReply) -> None:
history = self._history.setdefault(conversation_id, [])
history.append(f"Người dùng: {turn}")
lines = [f"Người dùng: {turn}"]
spoken = reply.answer or reply.clarification
if spoken:
history.append(f"Trợ lý: {spoken[:300]}")
# Keep only the recent window; the LLM re-reads it every turn.
lines.append(f"Trợ lý: {spoken[:300]}")
if self._store is not None:
try:
for line in lines:
self._store.append(conversation_id, line)
except Exception:
# Fail-open: this turn's memory is lost, not the response
# already computed and about to be returned to the caller.
pass
return
history = self._history.setdefault(conversation_id, [])
history.extend(lines)
# Keep only the recent window; the LLM re-reads it every turn. Only
# needed for the in-process dict — the store path windows at READ
# time instead (`recent(..., limit)`), so old rows just sit unused
# rather than needing a delete on every turn.
excess = len(history) - self._history_turns * 2
if excess > 0:
del history[:excess]
@@ -192,3 +385,60 @@ class RagAgent:
def _display_name(drug_id: str) -> str:
"""A readable display name from a drug id ('paracetamol_acetaminophen')."""
return drug_id.replace("_", " ").title()
_POPULATION_LABELS = {
"tre_em": "trẻ em",
"tre_so_sinh": "trẻ sơ sinh",
"nguoi_lon": "người lớn",
"nguoi_cao_tuoi": "người cao tuổi",
"phu_nu_co_thai": "phụ nữ có thai",
"phu_nu_cho_con_bu": "phụ nữ cho con bú",
"suy_than": "suy thận",
"suy_gan": "suy gan",
}
_ROUTE_LABELS = {
"uong": "uống",
"tiem_tinh_mach": "tiêm tĩnh mạch",
"tiem_bap": "tiêm bắp",
"tiem_duoi_da": "tiêm dưới da",
"dat_truc_trang": "đặt trực tràng",
"boi_ngoai_da": "bôi ngoài da",
"nho_mat": "nhỏ mắt",
"nho_mui": "nhỏ mũi",
"khac": "khác",
}
def _synthesize_query(turn: str, frame: QueryFrame) -> str:
"""Fold the structured context `understanding.py` resolved — possibly
across several turns — into one self-contained question.
`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
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
nothing else — the two P0s the 2026-08-06 audit named (population/
weight/age/route extracted but discarded downstream) are exactly this
gap. Redundant when the turn is already self-contained (a fresh
single-shot question re-states its own population/route, so this just
repeats it) — harmless, since omission is the failure mode, not
repetition.
"""
parts = [turn]
if frame.population:
parts.append(f"Đối tượng: {_POPULATION_LABELS.get(frame.population, frame.population)}")
if frame.age_text:
parts.append(f"Tuổi: {frame.age_text}")
if frame.weight_kg is not None:
parts.append(f"Cân nặng: {frame.weight_kg:g} kg")
if frame.route:
parts.append(f"Đường dùng: {_ROUTE_LABELS.get(frame.route, frame.route)}")
if frame.indication:
parts.append(f"Chỉ định/triệu chứng: {frame.indication}")
if len(parts) == 1:
return turn
return ". ".join(parts) + "."
+207 -63
View File
@@ -5,12 +5,17 @@ import re
from dataclasses import dataclass, replace
from . import grounding, metrics as metric_names
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 .routing import QueryRoutingService
# See `_verify_entailment`'s docstring for the measured trade-off behind
# widening this from 2 to 3.
_ENTAILMENT_MAX_ATTEMPTS = 3
@dataclass(frozen=True)
class Citation:
@@ -22,6 +27,10 @@ class Citation:
bbox: tuple[float, float, float, float] | None = None
source_crop: str | None = None
attachment: str | None = None
# The exact retrieved text this citation stands for — the same string
# handed to the generator/entailment checks, so the UI can show precisely
# what was retrieved rather than a fabricated summary of it.
evidence_text: str = ""
@dataclass(frozen=True)
@@ -34,12 +43,35 @@ class GroundedAnswer:
# (e.g. a dose question with no age/weight). The answer field carries the
# question; the caller renders it as a clarification, not a final answer.
clarification: str | None = None
# Short suggested replies for `clarification`, e.g. ("Người lớn", "Trẻ
# 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, ...] = ()
@dataclass(frozen=True)
class _GenOutcome:
answer: str | None = None
clarification: str | None = None
# 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
@dataclass(frozen=True)
class _RawAttempt:
"""One raw `_attempt_generation` call, before any metric is charged —
lets `_generate` retry the noisy `insufficient` case without
double-counting a rejection metric across both attempts."""
answer: str | None = None
clarification: str | None = None
insufficient: bool = False
outage: bool = False
budget_exhausted: bool = False
malformed: bool = False
class GroundedAnswerService:
@@ -57,10 +89,13 @@ class GroundedAnswerService:
pass confirming each cited claim's *content* — not just its numbers —
is actually stated by that block). If a configured generation fails
any check, or the provider itself is unreachable, or its output is
malformed, the turn **abstains** (`reason="generation_unavailable"`
or the specific `grounding.verify` reason) rather than silently
degrading to a raw source dump — this product is a real LLM chatbot,
and a citation-stapled paragraph of book text is not an acceptable
malformed, the turn **abstains** with the specific reason that failed
it (`provider_unavailable`, `malformed_output`, `evidence_insufficient`,
a `grounding.verify` reason, or `unsupported_claim`; falls back to
the generic `generation_unavailable` only if none of those was set)
rather than silently degrading to a raw source dump — this product is
a real LLM chatbot, and a citation-stapled paragraph of book text is
not an acceptable
stand-in for an answer the model was supposed to produce.
"""
@@ -94,12 +129,27 @@ class GroundedAnswerService:
return self.answer_from_result(query, result)
def answer_from_result(
self, query: str, result: RetrievalResult
self, query: str, result: RetrievalResult, list_mode: bool = False,
budget: RequestBudget | None = None,
) -> GroundedAnswer:
"""Everything after retrieval — grounding, sufficiency, generation,
citations. Split out so the new understanding-driven orchestrator
(`rag/agent.py`) reuses the safe answer path without going through the
old `QueryRoutingService` text resolution."""
old `QueryRoutingService` text resolution.
`list_mode=True`: the evidence is several DIFFERENT drugs' own
sections (symptom_to_drug), not alternative phrasings of one drug's
answer — the sufficiency clarify ("which kind of headache?") that's
right for a single dose question doesn't fit a reverse lookup, whose
whole point is to show what the formulary has and let the clinician
narrow it themselves; skipped here the same way a bare-name intro
already skips it.
`budget` (F-08): threaded through to every LLM call this method
makes (sufficiency, generate, up to 2 entailment). `None` (the
default) means unbounded, unchanged from before F-08 — only
`RagAgent` constructs a real budget today.
"""
if result.decision == EvidenceDecision.ABSTAIN:
self._metrics.increment(metric_names.ABSTENTION, reason=result.reason)
return GroundedAnswer(result, None)
@@ -135,11 +185,22 @@ class GroundedAnswerService:
# Reasoning step BEFORE answering: if the turn is under-specified (a dose
# with several bands and no age/weight/condition), ask instead of dumping.
# A separate focused call is more reliable than folding it into generation.
clarify_q = self._check_sufficiency(query, evidence_texts, result.is_drug_overview)
if clarify_q is not None:
return GroundedAnswer(result, clarify_q, (), clarification=clarify_q)
sufficiency = (
None if list_mode
else self._check_sufficiency(
query, evidence_texts, result.is_drug_overview, budget=budget
)
)
if sufficiency is not None:
clarify_q, quick_replies = sufficiency
return GroundedAnswer(
result, clarify_q, (), clarification=clarify_q, quick_replies=quick_replies
)
outcome = self._generate(query, evidence_texts, intro=result.is_drug_overview)
outcome = self._generate(
query, evidence_texts, intro=result.is_drug_overview, list_mode=list_mode,
budget=budget,
)
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
@@ -163,14 +224,23 @@ class GroundedAnswerService:
# source dump is not an acceptable stand-in for a failed
# generation, so this abstains instead of silently degrading to
# one.
self._metrics.increment(
metric_names.ABSTENTION, reason="generation_unavailable"
)
# The specific check that failed (provider_unavailable,
# malformed_output, evidence_insufficient, ungrounded_number,
# uncited_claim, unsupported_claim, request_budget_exhausted) —
# found live 2026-08-07: every one of these used to collapse into
# the same generic "generation_unavailable" by the time it
# reached the API response/trace, so a real, diagnosable cause
# (e.g. a genuine provider outage) was indistinguishable from
# ordinary entailment noise without reading server-side metrics
# by hand. `outcome.reject_reason` already carries the granular
# value the metric above uses — just propagate it.
reason = outcome.reject_reason or "generation_unavailable"
self._metrics.increment(metric_names.ABSTENTION, reason=reason)
return GroundedAnswer(
replace(
result,
decision=EvidenceDecision.ABSTAIN,
reason="generation_unavailable",
reason=reason,
),
None,
)
@@ -182,67 +252,107 @@ class GroundedAnswerService:
self._metrics.increment(metric_names.GENERATION_SERVED)
return GroundedAnswer(result, outcome.answer, citations, generated=True)
def _generate(
self, query: str, evidence_texts: tuple[str, ...], intro: bool = False
) -> "_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)
def _attempt_generation(
self, request: "GenerationRequest", budget: RequestBudget | None
) -> "_RawAttempt":
"""One raw generation call, parsed but not yet metric-counted or
verified — the caller decides whether to retry before charging a
metric to any particular reason."""
try:
if budget is not None:
budget.require()
raw = self._generator.generate(request.system, request.user, request.schema)
except RequestBudgetExhausted:
return _RawAttempt(budget_exhausted=True)
except AnswerGenerationUnavailable:
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="provider_unavailable"
)
return _GenOutcome()
return _RawAttempt(outage=True)
try:
payload = json.loads(raw)
answer = payload["answer"]
sufficient = payload["evidence_sufficient"]
except (ValueError, TypeError, KeyError):
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="malformed_output"
)
return _GenOutcome()
return _RawAttempt(malformed=True)
# The model asked for a missing detail (age/weight/renal function/
# indication…) instead of listing every band. A clarify is not a grounded
# claim, so it skips the number check — it states no dose.
clarify = payload.get("clarifying_question") if isinstance(payload, dict) else None
if isinstance(clarify, str) and clarify.strip():
return _GenOutcome(clarification=clarify.strip())
return _RawAttempt(clarification=clarify.strip())
if not isinstance(answer, str) or not isinstance(sufficient, bool):
return _RawAttempt(malformed=True)
if not sufficient:
return _RawAttempt(insufficient=True)
return _RawAttempt(answer=answer)
def _generate(
self, query: str, evidence_texts: tuple[str, ...], intro: bool = False,
list_mode: bool = False, budget: RequestBudget | 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)
attempt = self._attempt_generation(request, budget)
if attempt.insufficient:
# Empirically noisy (found live 2026-08-07, reproduced 3/3 on a
# fresh retry): the model's own evidence_sufficient=false
# self-assessment sometimes flips to a correct, fully grounded
# answer when asked again with the IDENTICAL evidence — the same
# one-retry pattern `_verify_entailment` already uses below for
# its own noisy judge call. Only the terminal "insufficient AND
# no clarifying question" case retries; a legitimate ask-for-
# more-detail clarify is untouched.
attempt = self._attempt_generation(request, budget)
if attempt.budget_exhausted:
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="request_budget_exhausted"
)
return _GenOutcome(reject_reason="request_budget_exhausted")
if attempt.outage:
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="provider_unavailable"
)
return _GenOutcome(reject_reason="provider_unavailable")
if attempt.malformed:
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="malformed_output"
)
return _GenOutcome()
if not sufficient:
# The model says the evidence does not answer the question. Showing
# the retrieved section verbatim lets the clinician judge that.
return _GenOutcome(reject_reason="malformed_output")
if attempt.clarification is not None:
return _GenOutcome(clarification=attempt.clarification)
if attempt.insufficient:
# The model says the evidence does not answer the question, on
# both attempts. Showing the retrieved section verbatim lets the
# clinician judge that.
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="evidence_insufficient"
)
return _GenOutcome()
return _GenOutcome(reject_reason="evidence_insufficient")
answer = attempt.answer
report = grounding.verify(answer, evidence_texts)
if not report.grounded:
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason=report.reason
)
return _GenOutcome()
return _GenOutcome(reject_reason=report.reason)
if not self._verify_entailment(answer, evidence_texts):
if not self._verify_entailment(answer, evidence_texts, budget=budget):
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="unsupported_claim"
)
return _GenOutcome()
return _GenOutcome(reject_reason="unsupported_claim")
return _GenOutcome(answer=answer)
def _verify_entailment(self, answer: str, evidence_texts: tuple[str, ...]) -> bool:
def _verify_entailment(
self, answer: str, evidence_texts: tuple[str, ...],
budget: RequestBudget | None = None,
) -> bool:
"""A second, adversarial LLM pass over an answer that already passed
`grounding.verify`.
@@ -254,14 +364,22 @@ 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. A single rejection is
NOT: live probing (2026-08-06) found the judge call itself is noisy
— the identical claim/evidence pair, called three times, came back
entailed twice and rejected once, discarding a correct, well-cited
interaction answer. So a reject triggers one same-claim retry, and
only a second, agreeing reject discards the generation; a single
provider outage/malformed response still fails closed immediately
(that failure mode is reliable, not noisy — no retry needed there).
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.
"""
@@ -274,18 +392,23 @@ class GroundedAnswerService:
return True
request = build_entailment_request(claims)
first = self._run_entailment_check(request)
if first is None:
return False
if first:
return True
second = self._run_entailment_check(request)
return bool(second)
for _ in range(_ENTAILMENT_MAX_ATTEMPTS):
verdict = self._run_entailment_check(request, budget=budget)
if verdict is None:
return False
if verdict:
return True
return False
def _run_entailment_check(self, request) -> bool | None:
"""One entailment call. `None` = outage/malformed (fails closed by the
caller without a retry); `True`/`False` = the judge's verdict."""
def _run_entailment_check(
self, request, budget: RequestBudget | None = None
) -> bool | None:
"""One entailment call. `None` = outage/malformed/budget-exhausted
(fails closed by the caller without a retry); `True`/`False` = the
judge's verdict."""
try:
if budget is not None:
budget.require()
raw = self._generator.generate(request.system, request.user, request.schema)
except AnswerGenerationUnavailable:
return None
@@ -300,17 +423,31 @@ class GroundedAnswerService:
return entailed and not unsupported
def _check_sufficiency(
self, query: str, evidence_texts: tuple[str, ...], intro: bool = False
) -> str | None:
self, query: str, evidence_texts: tuple[str, ...], intro: bool = False,
budget: RequestBudget | None = None,
) -> tuple[str, tuple[str, ...]] | None:
"""A focused reasoning call: is the turn specific enough to answer, or
must we ask? Returns a clarifying question, or None to proceed.
must we ask? Returns (clarifying_question, quick_replies), or None to
proceed. `quick_replies` is often empty — only populated when the
model judged the missing detail has a few natural discrete answers
(e.g. "Người lớn"/"Trẻ em"), never invented here.
Skipped without a model, for a bare-name intro (not a dose), or for a
single evidence block (nothing to disambiguate)."""
single evidence block (nothing to disambiguate).
Fails OPEN on outage/budget-exhaustion (returns None, proceeds to
generate) — deliberately different from every other call in this
file, which fail closed. This is a reasoning heuristic, not a safety
check; grounding + entailment remain the real gate on whatever gets
generated next, so skipping this one costs UX quality (a dose
question that should have asked for age/weight might not), not
safety."""
if self._generator is None or intro or len(evidence_texts) < 2:
return None
request = build_sufficiency_request(query, evidence_texts)
try:
if budget is not None:
budget.require()
raw = self._generator.generate(request.system, request.user, request.schema)
except AnswerGenerationUnavailable:
return None
@@ -321,7 +458,13 @@ class GroundedAnswerService:
if isinstance(payload, dict) and payload.get("sufficient") is False:
question = payload.get("clarifying_question")
if isinstance(question, str) and question.strip():
return question.strip()
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 ()
return question.strip(), replies
return None
@staticmethod
@@ -362,5 +505,6 @@ class GroundedAnswerService:
# real crop path wins; otherwise the block id plus the
# structured page/bbox fields is enough to render later.
attachment=source.source_crop or source.block_id,
evidence_text=evidence.text,
)))
return citations
+65
View File
@@ -0,0 +1,65 @@
"""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
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.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from .ports import AnswerGenerationUnavailable
class RequestBudgetExhausted(AnswerGenerationUnavailable):
"""The per-request budget ran out before a call could be attempted.
Subclasses `AnswerGenerationUnavailable` deliberately: every existing
`except AnswerGenerationUnavailable:` fail-closed/fail-open handler
already does the right thing for this with no changes — a caller that
wants to log a distinct reason (budget vs. genuine outage) catches this
subclass specifically before the general one.
"""
@dataclass
class RequestBudget:
deadline: float
calls_remaining: int
@classmethod
def start(cls, max_wall_clock_ms: int, max_calls: int) -> "RequestBudget":
return cls(
deadline=time.monotonic() + max_wall_clock_ms / 1000,
calls_remaining=max_calls,
)
def has_budget(self) -> bool:
return self.calls_remaining > 0 and time.monotonic() < self.deadline
def spend(self) -> None:
self.calls_remaining -= 1
def require(self) -> None:
"""Raise if there's no budget for one more call, else spend it.
The single call site every LLM-call wrapper below should make
immediately before its actual provider call."""
if not self.has_budget():
raise RequestBudgetExhausted(
"request budget exhausted (calls or wall-clock deadline)"
)
self.spend()
-371
View File
@@ -1,371 +0,0 @@
"""Conversation state, and the rules for carrying context across turns.
Pure domain. Everything here works without an LLM, which is deliberate: the
part of "understanding a follow-up" that matters clinically — *which drug is
this still about* — must be deterministic and testable, not inferred.
Two structures with different jobs:
`Focus` is structured and drives routing. It is what makes "còn trẻ em thì
sao?" resolvable at all.
`summary` is prose for the generator. It records **what was discussed**, never
clinical content: a dose restated from a summary carries no citation and could
not be grounding-verified, because that check compares against retrieved
evidence and a summary is not evidence.
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
from typing import Literal, Protocol
# A drug named six turns ago is not context, it is a hazard: conversations
# drift, and inheriting a stale drug produces a confident answer about the
# wrong medicine.
FOCUS_TTL_TURNS = 6
# Three exchanges kept verbatim; older turns are folded into the summary.
RECENT_TURNS = 6
Role = Literal["user", "assistant"]
Verbosity = Literal["concise", "detailed"]
@dataclass(frozen=True)
class Turn:
role: Role
text: str
at: str
drug_id: str | None = None
section_key: str | None = None
# Storing what answered a turn is what lets the planner reuse evidence
# instead of retrieving the same section again.
evidence_ids: tuple[str, ...] = ()
@dataclass(frozen=True)
class Focus:
"""The entities a follow-up may inherit, each with the turn that set it."""
drug_id: str | None = None
drug_name: str | None = None
section_key: str | None = None
population: str | None = None
verbosity: Verbosity | None = None
set_at_turn: dict[str, int] = field(default_factory=dict)
def age_of(self, name: str, turn_count: int) -> int | None:
set_at = self.set_at_turn.get(name)
return None if set_at is None else turn_count - set_at
def is_fresh(self, name: str, turn_count: int, ttl: int = FOCUS_TTL_TURNS) -> bool:
age = self.age_of(name, turn_count)
return age is not None and age <= ttl
def with_field(self, name: str, value, turn: int) -> "Focus":
stamps = dict(self.set_at_turn)
stamps[name] = turn
return replace(self, **{name: value}, set_at_turn=stamps)
def expire(self, turn_count: int, ttl: int = FOCUS_TTL_TURNS) -> "Focus":
"""Drops every field older than the TTL, stamps included."""
kept = {
name: getattr(self, name)
for name in ("drug_id", "drug_name", "section_key", "population", "verbosity")
if self.is_fresh(name, turn_count, ttl)
}
stamps = {
name: at for name, at in self.set_at_turn.items() if name in kept
}
return Focus(**kept, set_at_turn=stamps)
@dataclass(frozen=True)
class ConversationState:
conversation_id: str
recent: tuple[Turn, ...] = ()
summary: str = ""
focus: Focus = field(default_factory=Focus)
turn_count: int = 0
# Turns evicted from `recent` since the last time `overflow()` was
# consumed and cleared (by the caller passing `pending_overflow=()` to
# `replace()` after folding them into the summary). NOT derivable from
# `recent` alone — `recent` is already capped at `window`, so comparing
# its length against `window` can never find anything (see the bug note
# on `overflow` below). Plumbing, not conversation content.
pending_overflow: tuple[Turn, ...] = ()
def append(self, turn: Turn, window: int = RECENT_TURNS) -> "ConversationState":
"""Adds a turn and evicts the oldest beyond the window.
Eviction accumulates the dropped turns into `pending_overflow` for
the caller's summariser to fold via `overflow()`, rather than
discarding them here — this type does not decide what a summary
says. Accumulates rather than overwrites because one turn commonly
triggers two `append()` calls in a row (user, then assistant); each
can evict at most one turn, and the second call must not lose the
first's.
"""
combined = (*self.recent, turn)
recent = combined[-window:]
dropped = combined[:-window] if len(combined) > window else ()
return replace(
self,
recent=recent,
turn_count=self.turn_count + 1,
pending_overflow=(*self.pending_overflow, *dropped),
)
def overflow(self) -> tuple[Turn, ...]:
"""Turns evicted from `recent` and not yet folded into the summary.
Bug fixed 2026-08-06 (Codex review, F-06): this used to check
`len(self.recent) > window`, but `recent` is already truncated to
`window` by every `append()` call, so that comparison could never be
true — dropped turns were silently discarded and the summariser
never received them, no matter how long a conversation ran. The
caller must clear `pending_overflow` (pass `pending_overflow=()` to
`replace()`) after folding, or the same turns fold again next time.
"""
return self.pending_overflow
def inherited(self, name: str):
"""A focus value only if it is still fresh; otherwise None."""
return getattr(self.focus, name) if self.focus.is_fresh(name, self.turn_count) else None
# --- follow-up resolution -----------------------------------------------------
# Phrases that mean "same question, different population". Longest-first for the
# same reason `sections.py` sorts that way: "phụ nữ cho con bú" must be tested
# before "phụ nữ", or the more specific reading is never reached.
POPULATION_PHRASES: dict[str, str] = {
"phụ nữ cho con bú": "phu_nu_cho_con_bu",
"người cao tuổi": "nguoi_cao_tuoi",
"phụ nữ có thai": "phu_nu_co_thai",
"người suy thận": "suy_than",
"người suy gan": "suy_gan",
"trẻ sơ sinh": "tre_so_sinh",
"người lớn": "nguoi_lon",
"bà bầu": "phu_nu_co_thai",
"trẻ nhỏ": "tre_em",
"trẻ em": "tre_em",
"người già": "nguoi_cao_tuoi",
}
VERBOSITY_PHRASES: dict[str, Verbosity] = {
"giải thích kỹ hơn": "detailed",
"nói rõ hơn": "detailed",
"chi tiết hơn": "detailed",
"ngắn gọn": "concise",
"tóm tắt": "concise",
}
# A turn that is only a qualifier — no drug, no attribute — is a follow-up by
# construction. These are the openers that mark one.
FOLLOWUP_MARKERS = ("còn", "thế còn", "vậy còn", "so với", "thuốc vừa", "cái đó", "")
# Greetings, thanks, farewells and bare acknowledgements. A turn made up only of
# these is social, not a failed drug lookup: answering "Chưa xác định được
# thuốc" to "chào bạn" reads as broken. Longest-first so "cảm ơn nhiều" is
# stripped before "cảm ơn".
SMALLTALK_PHRASES = (
"xin chào", "chào bạn", "chào ad", "cảm ơn nhiều", "cảm ơn bạn", "cám ơn",
"cảm ơn", "tạm biệt", "hay quá", "tuyệt vời", "hiểu rồi", "được rồi",
"chào", "hello", "hi", "alo", "thanks", "thank", "ok", "oke", "okie",
"", "uh", "haha", "hihi", "bye",
)
def is_smalltalk(text: str) -> bool:
"""True when a turn carries nothing but social phrases.
Deliberately conservative: it strips every known social phrase and returns
True only if what remains is empty. "chào bạn, liều paracetamol?" keeps
"liều paracetamol" after stripping, so it is treated as a real question —
a greeting must never swallow the medical part of a turn.
"""
remainder = _normalise(text).strip(" .,!?;:")
for phrase in sorted(SMALLTALK_PHRASES, key=len, reverse=True):
# Space-pad both sides so a short phrase ("hi", "ok") matches a whole
# word only, never a substring of "chi" or "block".
remainder = f" {remainder} ".replace(f" {phrase} ", " ").strip(" .,!?;:")
return not remainder
def _normalise(text: str) -> str:
return " ".join(text.casefold().split())
def _longest_first(phrases: dict[str, str]) -> list[tuple[str, str]]:
return sorted(phrases.items(), key=lambda item: -len(item[0]))
def detect_population(text: str) -> str | None:
normalised = _normalise(text)
for phrase, tag in _longest_first(POPULATION_PHRASES):
if phrase in normalised:
return tag
return None
def detect_verbosity(text: str) -> Verbosity | None:
normalised = _normalise(text)
for phrase, level in _longest_first(VERBOSITY_PHRASES):
if phrase in normalised:
return level
return None
def looks_like_followup(text: str) -> bool:
normalised = _normalise(text)
return any(normalised.startswith(marker) for marker in FOLLOWUP_MARKERS)
@dataclass(frozen=True)
class ResolvedQuestion:
"""What this turn is asking, after the conversation is taken into account."""
text: str
drug_id: str | None
section_key: str | None
population: str | None
verbosity: Verbosity | None
inherited_drug: bool
inherited_section: bool
@property
def needs_carry_over_notice(self) -> bool:
"""Whether the answer must name what it inherited.
An inherited drug that is wrong is a wrong-drug answer, so the answer
has to say which drug it decided this was about.
"""
return self.inherited_drug
def resolve_against(
state: ConversationState,
text: str,
drug_id: str | None,
section_key: str | None,
) -> ResolvedQuestion:
"""Fills gaps in this turn from conversation focus, freshness permitting.
`drug_id` and `section_key` are what this turn resolved on its own — the
existing resolvers decide those, unchanged. Only what the turn left blank
is inherited, so an explicit mention always wins over context.
"""
inherited_drug = False
inherited_section = False
if drug_id is None:
carried = state.inherited("drug_id")
if carried is not None:
drug_id, inherited_drug = carried, True
if section_key is None:
carried = state.inherited("section_key")
if carried is not None:
section_key, inherited_section = carried, True
population = detect_population(text) or state.inherited("population")
verbosity = detect_verbosity(text) or state.inherited("verbosity")
return ResolvedQuestion(
text=text,
drug_id=drug_id,
section_key=section_key,
population=population,
verbosity=verbosity,
inherited_drug=inherited_drug,
inherited_section=inherited_section,
)
def update_focus(
state: ConversationState,
resolved: ResolvedQuestion,
) -> Focus:
"""Focus after this turn, stamped with the current turn index."""
focus = state.focus.expire(state.turn_count)
turn = state.turn_count
for name, value in (
("drug_id", resolved.drug_id),
("section_key", resolved.section_key),
("population", resolved.population),
("verbosity", resolved.verbosity),
):
if value is not None:
focus = focus.with_field(name, value, turn)
return focus
# --- persistence and summary --------------------------------------------------
#
# Protocol + no-LLM default co-located, matching how `reasoning.py` ships
# `SufficiencyAssessor`/`DeterministicAssessor` and `metrics.py` ships
# `Metrics`/`NullMetrics`. The Postgres-backed store lives in `adapters/`.
class ConversationStore(Protocol):
"""Loads and persists one conversation's state.
`load` returns a fresh empty state for an unknown id rather than raising: a
first turn has no prior state, and that is not an error.
"""
def load(self, conversation_id: str) -> "ConversationState": ...
def save(self, state: "ConversationState") -> None: ...
class InMemoryConversationStore:
"""Reference implementation and the offline/test default."""
def __init__(self) -> None:
self._states: dict[str, ConversationState] = {}
def load(self, conversation_id: str) -> ConversationState:
return self._states.get(conversation_id, ConversationState(conversation_id))
def save(self, state: ConversationState) -> None:
self._states[state.conversation_id] = state
class Summariser(Protocol):
"""Folds turns evicted from the recent window into rolling prose.
Contract, load-bearing for safety: the summary records *what was discussed*,
never a clinical value. A dose copied into a summary carries no citation and
cannot be grounding-verified — the check compares against retrieved
evidence, and a summary is not evidence.
"""
def fold(self, prev_summary: str, dropped: tuple["Turn", ...]) -> str: ...
class DeterministicSummariser:
"""No-LLM default: one topic line per evicted user turn, capped.
Records only the drug and section a turn was *about* — labels, never cell
values — so the no-clinical-content rule holds by construction rather than
by trusting a generator not to leak a dose.
"""
MAX_CHARS = 1600 # ~400 tokens, per ADR 0007 §2
def fold(self, prev_summary: str, dropped: tuple[Turn, ...]) -> str:
lines = [prev_summary] if prev_summary else []
for turn in dropped:
if turn.role != "user":
continue
drug = turn.drug_id or "thuốc chưa xác định"
section = turn.section_key or "thông tin chung"
lines.append(f"- đã hỏi {section} của {drug}")
text = "\n".join(lines)
# Keep the most recent topics when over budget: drop oldest lines, not
# mid-line characters, so the summary never ends on a fragment.
while len(text) > self.MAX_CHARS and len(lines) > 1:
lines.pop(0)
text = "\n".join(lines)
return text
-424
View File
@@ -1,424 +0,0 @@
"""Orchestration: turns a stateless single-turn engine into a conversation.
This is the glue ADR 0007 specified and nothing yet called. It owns no rules of
its own — inheritance lives in `conversation.py`, the bounded loop in
`reasoning.py`, grounding in `grounding.py`. Its whole job is the sequence:
load state
→ resolve this turn, then inherit gaps from focus
→ derive clarify signals from resolver state (never a model score)
→ run the bounded loop (retrieve / generate / verify)
→ update focus, append turns, summarise overflow, save
→ name any inherited drug in the answer
Everything here runs with no LLM and no live service: the collaborators are
protocols, so a turn can be exercised end-to-end with fakes.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Protocol
from . import metrics as metric_names
from .answer import GroundedAnswer, GroundedAnswerService
from .conversation import (
ConversationState,
ConversationStore,
Summariser,
Turn,
is_smalltalk,
resolve_against,
update_focus,
)
from .metrics import Metrics, NullMetrics
from .models import EvidenceDecision, QueryIntent, SubjectScope
from .reasoning import (
BudgetExhausted,
Clarification,
ClarifyReason,
DeterministicAssessor,
Generate,
LoopOutcome,
MAX_RETRIEVAL_ROUNDS,
Retrieve,
SufficiencyAssessor,
TurnBudget,
clarify_for,
run_turn,
)
from .routing import CatalogDrugResolver, DrugResolutionStatus, normalize_name
from .sections import SectionResolver
# Turns that only confirm a prior suggestion. They resolve no drug and must not
# be fuzzy-matched against the catalog (which returns garbage like terbinafin).
# Stored normalised (normalize_name strips diacritics: "đúng" -> "dung"), or the
# lookup below never matches.
_CONFIRMATION_WORDS = (
"đúng", "đúng rồi", "đúng vậy", "phải", "phải rồi", "chuẩn", "chuẩn rồi",
"chính xác", "", "uh", "ok", "oke", "yes", "vâng",
)
_CONFIRMATIONS = frozenset(normalize_name(word) for word in _CONFIRMATION_WORDS)
def _is_confirmation(text: str) -> bool:
return normalize_name(text) in _CONFIRMATIONS
SUMMARY_EVERY = 4 # regenerate the summary at most every S turns, per ADR 0007 §2
@dataclass(frozen=True)
class TurnResolution:
"""What one turn resolved on its own, before conversation is considered.
`drug_status` is the resolver's verdict — resolved / not_found / ambiguous —
kept distinct from `drug_id` so an ambiguous turn (asks which drug) reads
differently from a bare follow-up (inherits the drug).
"""
drug_id: str | None
section_key: str | None
drug_status: str
class TurnResolverPort(Protocol):
def resolve_turn(self, text: str) -> TurnResolution: ...
@dataclass(frozen=True)
class TurnResponse:
answer: str | None
clarification: Clarification | None
evidence_texts: tuple[str, ...]
stopped_because: str
inherited_drug: str | None
generated: bool
class ConversationalRagService:
def __init__(
self,
store: ConversationStore,
summariser: Summariser,
resolver: TurnResolverPort,
retrieve: Retrieve,
generate: Generate,
metrics: Metrics | None = None,
summary_every: int = SUMMARY_EVERY,
) -> None:
self._store = store
self._summariser = summariser
self._resolver = resolver
self._retrieve = retrieve
self._generate = generate
self._metrics = metrics or NullMetrics()
self._summary_every = summary_every
def answer(
self, conversation_id: str, text: str, budget: TurnBudget | None = None
) -> TurnResponse:
state = self._store.load(conversation_id)
turn = self._resolver.resolve_turn(text)
resolved = resolve_against(state, text, turn.drug_id, turn.section_key)
signals = self._clarify_signals(resolved, turn)
if resolved.inherited_drug:
self._metrics.increment(metric_names.FOLLOWUP_INHERITED)
outcome = run_turn(
state,
resolved,
self._retrieve,
self._generate,
clarify_signals=signals,
budget=budget or TurnBudget(),
metrics=self._metrics,
)
self._persist(state, resolved, outcome)
answer = outcome.answer
inherited = resolved.drug_id if resolved.needs_carry_over_notice else None
if answer is not None and inherited is not None:
# An inherited drug that is wrong is a wrong-drug answer, so the
# answer has to say which drug it decided this was about.
answer = f"Về {inherited}: {answer}"
return TurnResponse(
answer=answer,
clarification=outcome.clarification,
evidence_texts=outcome.evidence_texts,
stopped_because=outcome.stopped_because,
inherited_drug=inherited,
generated=outcome.generated,
)
@staticmethod
def _clarify_signals(resolved, turn: TurnResolution) -> tuple[str, ...]:
"""Resolver states that should ask instead of guess.
Only fires when the drug is *still* unknown after inheritance: a
follow-up like "còn trẻ em thì sao?" names no drug but inherits one, and
must not be turned into a clarify.
"""
if resolved.drug_id is None:
return (ClarifyReason.AMBIGUOUS_DRUG,)
return ()
def _persist(
self, state: ConversationState, resolved, outcome: LoopOutcome
) -> None:
focus = update_focus(state, resolved)
state = ConversationState(
conversation_id=state.conversation_id,
recent=state.recent,
summary=state.summary,
focus=focus,
turn_count=state.turn_count,
)
state = state.append(
Turn("user", resolved.text, _now(), resolved.drug_id, resolved.section_key)
)
if outcome.answer is not None:
state = state.append(
Turn(
"assistant",
outcome.answer,
_now(),
resolved.drug_id,
resolved.section_key,
evidence_ids=tuple(str(i) for i in range(len(outcome.evidence_texts))),
)
)
if state.turn_count % self._summary_every == 0 and state.overflow():
summary = self._summariser.fold(state.summary, state.overflow())
state = ConversationState(
conversation_id=state.conversation_id,
recent=state.recent,
summary=summary,
focus=state.focus,
turn_count=state.turn_count,
)
self._store.save(state)
def _now() -> str:
# Timestamps are provenance, not logic; the domain never branches on them,
# so a monotonic placeholder keeps this module free of wall-clock coupling.
return ""
# --- live chat core -----------------------------------------------------------
#
# The deployable multi-turn path. The loop is what *understands and clarifies*
# a turn; retrieval, citation, VERIFY_PDF and grounding stay inside
# GroundedAnswerService, untouched — so clarify + refine are added *around* the
# safe engine, never inside it.
SMALLTALK_REPLY = (
"Mình tra cứu Dược thư Quốc gia Việt Nam. Bạn muốn hỏi về thuốc nào, "
"hoặc thuộc tính nào (liều dùng, chống chỉ định, tương tác…)?"
)
@dataclass(frozen=True)
class ConversationTurnResult:
answer: str | None
clarification: Clarification | None
grounded: GroundedAnswer | None
smalltalk: bool
inherited_drug: str | None
reason: str
class ConversationalLoopService:
def __init__(
self,
answers: GroundedAnswerService,
resolver: CatalogDrugResolver,
section_resolver: SectionResolver,
store: ConversationStore,
assessor: SufficiencyAssessor | None = None,
summariser: Summariser | None = None,
metrics: Metrics | None = None,
) -> None:
self._answers = answers
self._resolver = resolver
self._section_resolver = section_resolver
self._store = store
self._assessor = assessor or DeterministicAssessor()
self._summariser = summariser
self._metrics = metrics or NullMetrics()
def answer(
self,
conversation_id: str,
query: str,
subject_scope: SubjectScope,
intent: QueryIntent,
budget: TurnBudget | None = None,
) -> ConversationTurnResult:
state = self._store.load(conversation_id)
resolution = self._resolver.resolve(query)
# Only an EXACT name is auto-accepted. A fuzzy match (score < 1.0) is a
# guess, and a formulary must not silently answer about a *different*
# drug than the one meant — a typo is asked about ("did you mean…?"),
# never resolved on a similarity threshold. Autocomplete at input is the
# first line; this is the backstop when a wrong name is still submitted.
is_exact = resolution.status == DrugResolutionStatus.RESOLVED and (
resolution.score is None or resolution.score >= 0.999
)
drug_self = resolution.drug_id if is_exact else None
# Social turn that names no drug: answer as a person, not a failed lookup.
if drug_self is None and is_smalltalk(query):
self._append_user(state, query, None, None)
return ConversationTurnResult(
SMALLTALK_REPLY, None, None, True, None, "smalltalk"
)
section = self._section_resolver.resolve(query)
section_self = section.section_key if section else None
resolved = resolve_against(state, query, drug_self, section_self)
# Clarify beats guessing: no drug even after inheritance. If the text is
# a near-miss for real drug names, offer them ("did you mean") rather
# than a bare "which drug?" — a typo should not dead-end.
if resolved.drug_id is None:
# A bare confirmation ("đúng") with no drug in context is not a drug
# lookup — never fuzzy-match it (that returned terbinafin/tretinoin).
if _is_confirmation(query):
reason = "confirm_without_context"
clarification = Clarification(
reason=reason,
question="Bạn muốn xác nhận thuốc nào? Vui lòng gõ tên thuốc để mình tra cứu.",
options=(),
)
self._metrics.increment(metric_names.CLARIFY_ASKED, reason=reason)
self._persist(state, resolved, None)
return ConversationTurnResult(None, clarification, None, False, None, reason)
# Only offer "did you mean" for a SHORT, drug-name-shaped miss (a
# typo). Fuzzy-matching a whole sentence ("EPO điều trị thiếu máu…")
# or a confirmation ("đúng") against 684 aliases returns confident
# garbage — that is the did-you-mean loop the reviewer hit. A long or
# confirming turn that resolves no drug is answered honestly, not
# with a list of unrelated drugs.
looks_like_name = len(normalize_name(query).split()) <= 4
suggestions = (
self._resolver.suggest(query, k=3, min_score=0.72)
if looks_like_name and not _is_confirmation(query)
else []
)
if suggestions:
names = [self._drug_name(drug_id) for drug_id, _ in suggestions]
reason = "did_you_mean"
clarification = Clarification(
reason=reason,
question=f"Ý bạn là: {', '.join(names)}?",
options=tuple(names),
)
else:
reason = "drug_not_supported"
clarification = Clarification(
reason=reason,
question=(
"Không có thuốc này trong Dược thư Quốc gia. Vui lòng kiểm "
"tra lại tên, hoặc gõ vài ký tự để chọn từ gợi ý."
),
options=(),
)
self._metrics.increment(metric_names.CLARIFY_ASKED, reason=reason)
self._persist(state, resolved, None)
return ConversationTurnResult(None, clarification, None, False, None, reason)
if resolved.inherited_drug:
self._metrics.increment(metric_names.FOLLOWUP_INHERITED)
# One call to the safe engine. The drug is passed already-resolved (incl.
# an inherited follow-up drug), so the engine does NOT re-resolve it from
# the turn text — that double-resolution is what abstained follow-ups as
# "ambiguous". The turn's own text drives section routing; when it names
# no attribute the drug-overview + rerank path finds the relevant part.
grounded: GroundedAnswer | None = self._answers.answer(
query, subject_scope, intent, drug_id=resolved.drug_id
)
# The engine asked for a missing detail (age/weight/renal function/
# indication…) rather than dumping every dose band — surface it as a
# clarification, not a final answer.
if grounded is not None and grounded.clarification is not None:
self._metrics.increment(
metric_names.CLARIFY_ASKED, reason="needs_more_info"
)
self._persist(state, resolved, None)
return ConversationTurnResult(
None,
Clarification(
reason="needs_more_info",
question=grounded.clarification,
options=(),
),
None,
False,
None,
"needs_more_info",
)
answer = grounded.answer if grounded else None
inherited = resolved.drug_id if resolved.needs_carry_over_notice else None
if answer is not None and inherited is not None:
answer = f"Về {self._drug_name(inherited)}: {answer}"
if grounded is not None:
grounded = replace(grounded, answer=answer)
self._persist(state, resolved, grounded)
return ConversationTurnResult(
answer,
None,
grounded,
False,
inherited,
grounded.result.reason if grounded else "no_answer",
)
def complete(self, prefix: str, k: int = 8) -> list[str]:
"""Display names matching a typed prefix, for input autocomplete."""
return [self._drug_name(drug_id) for drug_id in self._resolver.complete(prefix, k)]
@staticmethod
def _drug_name(drug_id: str) -> str:
"""A readable display name from a drug id ('paracetamol_acetaminophen')."""
return drug_id.replace("_", " ").title()
def _append_user(self, state, text, drug_id, section_key) -> None:
state = state.append(Turn("user", text, _now(), drug_id, section_key))
self._store.save(state)
def _persist(self, state, resolved, grounded) -> None:
focus = update_focus(state, resolved)
state = replace(state, focus=focus)
state = state.append(
Turn("user", resolved.text, _now(), resolved.drug_id, resolved.section_key)
)
if grounded is not None and grounded.answer is not None:
state = state.append(
Turn(
"assistant",
grounded.answer,
_now(),
resolved.drug_id,
resolved.section_key,
)
)
if (
self._summariser is not None
and state.turn_count % SUMMARY_EVERY == 0
and state.overflow()
):
summary = self._summariser.fold(state.summary, state.overflow())
# Clear what was just folded — `replace()` keeps every field not
# named here, and `pending_overflow` accumulates across calls
# (see `ConversationState.append`), so leaving it would fold the
# same already-summarised turns again next time.
state = replace(state, summary=summary, pending_overflow=())
self._store.save(state)
+50 -7
View File
@@ -33,7 +33,14 @@ Quy tắc bắt buộc:
[n] ở mọi câu — gắn một lần cho một cụm cùng nguồn là đủ. Không bịa số [n].
5. Nếu BẰNG CHỨNG không đủ (thiếu đối tượng được hỏi, thiếu con số, hoặc chỉ nói
chung chung), nói rõ là không đủ và đặt evidence_sufficient=false. Đó là câu
trả lời hợp lệ. Không suy diễn để lấp chỗ trống.
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
điền `clarifying_question` giải thích NGẮN GỌN, CỤ THỂ vì sao — dù đó là vì
người dùng chưa nêu đủ dữ kiện (hỏi lại dữ kiện còn thiếu, như quy tắc 7), hay
đơn giản là chuyên luận KHÔNG đề cập nội dung này cho đối tượng/đường dùng
đang hỏi (nói thẳng điều đó, ví dụ "Dược thư không nêu liều dùng đường nhỏ
mắt của thuốc này"). TUYỆT ĐỐI KHÔNG để `clarifying_question`=null khi
evidence_sufficient=false — một câu giải thích cụ thể luôn hữu ích hơn cho
người đọc so với việc để trống.
6. Giữ nguyên thuật ngữ chuyên môn của sách. Không diễn giải cho người
không chuyên.
7. HỎI LẠI khi thiếu dữ kiện — ĐÂY LÀ QUY TẮC QUAN TRỌNG NHẤT, ưu tiên hơn việc
@@ -70,8 +77,11 @@ ANSWER_SCHEMA = {
"clarifying_question": {
"type": ["string", "null"],
"description": (
"Câu hỏi lại khi người dùng chưa nêu đủ dữ kiện (vd tuổi/cân nặng "
"cho câu hỏi liều có nhiều mức). null nếu đã đủ dữ kiện để trả lời."
"BẮT BUỘC khi evidence_sufficient=false: câu hỏi lại khi người dùng "
"chưa nêu đủ dữ kiện (vd tuổi/cân nặng), HOẶC — nếu vấn đề là "
"chuyên luận không có nội dung này — một câu nói thẳng điều đó (vd "
"'Dược thư không nêu liều dùng đường nhỏ mắt của thuốc này'). "
"null CHỈ khi evidence_sufficient=true."
),
},
},
@@ -94,18 +104,32 @@ Quy tắc:
- Câu hỏi KHÔNG về liều (chống chỉ định, tương tác, tác dụng phụ, giới thiệu
thuốc…) thì thường ĐỦ.
Trả về DUY NHẤT JSON: {"sufficient": bool, "clarifying_question": string|null}.
Trả về DUY NHẤT JSON: {"sufficient": bool, "clarifying_question": string|null,
"quick_replies": string[]}.
Nếu CHƯA đủ: sufficient=false và clarifying_question hỏi NGẮN GỌN tất cả dữ kiện
còn thiếu (vd: "Bé mấy tuổi, cân nặng bao nhiêu kg, dùng đường nào và để hạ sốt
hay giảm đau?"). Nếu đủ: sufficient=true, clarifying_question=null."""
hay giảm đau?"). Nếu đủ: sufficient=true, clarifying_question=null,
quick_replies=[].
quick_replies: 2-4 phương án trả lời NGẮN (dưới ~20 ký tự mỗi phương án) cho
CHÍNH câu clarifying_question vừa đặt ra, để người dùng bấm chọn thay vì gõ —
CHỈ khi câu hỏi thực sự có vài lựa chọn rời rạc, tự nhiên (vd đối tượng: "Người
lớn" / "Trẻ em"; đường dùng: "Uống" / "Tiêm"). Để mảng RỖNG nếu câu hỏi cần một
con số cụ thể không có sẵn lựa chọn ngắn (vd hỏi cân nặng chính xác) — không
được bịa ra các phương án number-ish giả."""
SUFFICIENCY_SCHEMA = {
"type": "object",
"properties": {
"sufficient": {"type": "boolean"},
"clarifying_question": {"type": ["string", "null"]},
"quick_replies": {
"type": "array",
"items": {"type": "string"},
"maxItems": 4,
},
},
"required": ["sufficient", "clarifying_question"],
"required": ["sufficient", "clarifying_question", "quick_replies"],
"additionalProperties": False,
}
@@ -180,7 +204,8 @@ def build_entailment_request(claims: list[tuple[str, str]]) -> "GenerationReques
def build_request(
question: str, evidence_texts: tuple[str, ...], intro: bool = False
question: str, evidence_texts: tuple[str, ...], intro: bool = False,
list_mode: bool = False,
) -> GenerationRequest:
"""The prompt for one question over one ordered evidence list.
@@ -191,6 +216,14 @@ def build_request(
`intro=True` is the "user typed only a drug name" case: instead of restating
a section, write a short introduction — what the drug is, its class and its
main indication — then invite a specific follow-up. Still evidence-only.
`list_mode=True` is the symptom_to_drug reverse-lookup case: each evidence
block is a DIFFERENT drug's own `chi_dinh`, not alternative phrasings of
one drug's section — found live 2026-08-07 that without this the model
picked just one drug out of 8 real matches and answered only about that
one, silently dropping the rest. Not a treatment ranking — a factual list
([[feedback_no_recommendation_gate]] already covers why a "which is best"
framing would be wrong for this audience anyway).
"""
if not evidence_texts:
raise ValueError("cannot build a grounded prompt with no evidence")
@@ -206,6 +239,16 @@ def build_request(
"lượng. Kết thúc bằng một câu mời hỏi tiếp về thuộc tính cụ thể (liều "
"dùng, chống chỉ định, thận trọng, tương tác…)."
)
elif list_mode:
task = (
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 "
"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}"
-305
View File
@@ -1,305 +0,0 @@
"""The bounded reasoning loop.
Understand → plan → retrieve → assess → refine → generate → verify → repair.
Every edge is bounded, and every budget is decremented **before** the call it
pays for, so exhaustion degrades to the best answer so far rather than to an
error.
Two rules hold across every path and are the reason this can be added to a
formulary at all:
- `grounding.verify` still gates every generated answer. Reasoning chooses what
to look up and how to phrase it; it is never a source of facts.
- A clarify signal bypasses the loop entirely. Asking beats guessing, and the
signals are resolver states — ambiguous drug, unresolved attribute — not a
model's confidence score.
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
from typing import Protocol
from . import metrics as metric_names
from .conversation import ConversationState, ResolvedQuestion
from .metrics import Metrics, NullMetrics
MAX_RETRIEVAL_ROUNDS = 2
MAX_REPAIRS = 1
MAX_LLM_CALLS = 4
MAX_WALL_CLOCK_MS = 20_000
class BudgetExhausted(RuntimeError):
"""Raised only inside the loop, never surfaced; the loop catches it."""
@dataclass
class TurnBudget:
"""Mutable on purpose: one budget is threaded through one turn."""
llm_calls: int = MAX_LLM_CALLS
retrieval_rounds: int = MAX_RETRIEVAL_ROUNDS
repairs: int = MAX_REPAIRS
wall_clock_ms: int = MAX_WALL_CLOCK_MS
elapsed_ms: int = 0
def spend_llm(self) -> None:
if self.llm_calls <= 0:
raise BudgetExhausted("llm_calls")
self.llm_calls -= 1
def spend_retrieval(self) -> None:
if self.retrieval_rounds <= 0:
raise BudgetExhausted("retrieval_rounds")
self.retrieval_rounds -= 1
def spend_repair(self) -> None:
if self.repairs <= 0:
raise BudgetExhausted("repairs")
self.repairs -= 1
def out_of_time(self) -> bool:
return self.elapsed_ms >= self.wall_clock_ms
class ClarifyReason:
AMBIGUOUS_DRUG = "ambiguous_drug"
NO_ATTRIBUTE = "no_attribute"
MULTI_ATTRIBUTE = "multi_attribute"
STILL_INSUFFICIENT = "still_insufficient"
@dataclass(frozen=True)
class Clarification:
reason: str
question: str
options: tuple[str, ...] = ()
@dataclass(frozen=True)
class Sufficiency:
"""The assessor's verdict on retrieved evidence.
`missing` must name something specific — a section, a population, a second
drug. "Feels incomplete" does not buy a retrieval round; a round is only
spent when there is a concrete thing to go and fetch.
"""
sufficient: bool
missing: str | None = None
refined_query: str | None = None
class SufficiencyAssessor(Protocol):
def assess(
self, resolved: ResolvedQuestion, evidence_texts: tuple[str, ...]
) -> Sufficiency: ...
class DeterministicAssessor:
"""The no-LLM default, and the reference for what the port must do.
Runs offline and is what the loop uses until a provider is enabled. It only
reports insufficiency it can *demonstrate* — a population was asked for and
no retrieved text mentions it — so it can never spin the loop on a feeling.
"""
POPULATION_TERMS = {
"nguoi_lon": ("người lớn",),
"tre_em": ("trẻ em", "trẻ nhỏ", "trẻ "),
"tre_so_sinh": ("sơ sinh",),
"phu_nu_co_thai": ("thai", "mang thai"),
"phu_nu_cho_con_bu": ("cho con bú", "sữa mẹ"),
"nguoi_cao_tuoi": ("người cao tuổi", "người già"),
"suy_than": ("suy thận", "clcr"),
"suy_gan": ("suy gan",),
}
def assess(
self, resolved: ResolvedQuestion, evidence_texts: tuple[str, ...]
) -> Sufficiency:
if not evidence_texts:
return Sufficiency(False, missing="no_evidence")
if resolved.population is None:
return Sufficiency(True)
terms = self.POPULATION_TERMS.get(resolved.population, ())
haystack = " ".join(evidence_texts).casefold()
if any(term in haystack for term in terms):
return Sufficiency(True)
return Sufficiency(
False,
missing=f"population:{resolved.population}",
refined_query=f"{resolved.text} {terms[0] if terms else ''}".strip(),
)
@dataclass(frozen=True)
class LoopOutcome:
"""What one turn produced, plus what it cost."""
answer: str | None
clarification: Clarification | None
evidence_texts: tuple[str, ...]
retrieval_rounds_used: int
repairs_used: int
stopped_because: str
generated: bool = False
@dataclass
class LoopTrace:
"""Ordered record of stages, for the dashboard and for debugging."""
stages: list[str] = field(default_factory=list)
def enter(self, stage: str) -> None:
self.stages.append(stage)
def clarify_for(
reason: str, options: tuple[str, ...] = ()
) -> Clarification:
questions = {
ClarifyReason.NO_ATTRIBUTE: (
"Anh/chị muốn tra thuộc tính nào của thuốc này?"
),
ClarifyReason.AMBIGUOUS_DRUG: (
"Câu hỏi có thể ứng với nhiều thuốc. Anh/chị muốn tra thuốc nào?"
),
ClarifyReason.MULTI_ATTRIBUTE: (
"Câu hỏi nhắc tới nhiều mục. Anh/chị muốn xem mục nào trước?"
),
ClarifyReason.STILL_INSUFFICIENT: (
"Chưa tìm đủ căn cứ trong Dược thư cho ý này. "
"Anh/chị có thể nêu rõ hơn điều cần tra không?"
),
}
return Clarification(reason, questions[reason], options)
class Retrieve(Protocol):
def __call__(self, resolved: ResolvedQuestion) -> tuple[str, ...]: ...
class Generate(Protocol):
def __call__(
self, resolved: ResolvedQuestion, evidence: tuple[str, ...], state: ConversationState
) -> str | None: ...
def run_turn(
state: ConversationState,
resolved: ResolvedQuestion,
retrieve: Retrieve,
generate: Generate,
clarify_signals: tuple[str, ...] = (),
assessor: SufficiencyAssessor | None = None,
budget: TurnBudget | None = None,
metrics: Metrics | None = None,
trace: LoopTrace | None = None,
) -> LoopOutcome:
"""One conversational turn through the bounded loop.
`clarify_signals` comes from the existing resolvers — ambiguous drug,
unresolved section, multi-attribute. They short-circuit before any spend,
because a question worth asking is cheaper and safer than a guess.
"""
budget = budget or TurnBudget()
assessor = assessor or DeterministicAssessor()
metrics = metrics or NullMetrics()
trace = trace or LoopTrace()
trace.enter("understand")
if clarify_signals:
reason = clarify_signals[0]
metrics.increment(metric_names.CLARIFY_ASKED, reason=reason)
trace.enter("clarify")
return LoopOutcome(
answer=None,
clarification=clarify_for(reason),
evidence_texts=(),
retrieval_rounds_used=0,
repairs_used=0,
stopped_because="clarify_signal",
)
evidence: tuple[str, ...] = ()
rounds_used = 0
stopped = "sufficient"
while True:
try:
budget.spend_retrieval()
except BudgetExhausted:
stopped = "retrieval_budget"
break
trace.enter("retrieve")
evidence = retrieve(resolved)
rounds_used += 1
trace.enter("assess")
verdict = assessor.assess(resolved, evidence)
if verdict.sufficient:
break
if budget.retrieval_rounds <= 0 or budget.out_of_time():
stopped = "retrieval_budget"
break
# A round is spent only on a named gap with a genuinely new query.
if not verdict.missing or not verdict.refined_query:
stopped = "no_actionable_gap"
break
if verdict.refined_query == resolved.text:
stopped = "query_unchanged"
break
trace.enter("refine")
metrics.increment(metric_names.LOOP_REFINED, missing=verdict.missing)
resolved = replace(resolved, text=verdict.refined_query)
metrics.increment(metric_names.LOOP_ROUNDS, rounds=str(rounds_used))
if not evidence:
trace.enter("clarify")
metrics.increment(
metric_names.CLARIFY_ASKED, reason=ClarifyReason.STILL_INSUFFICIENT
)
return LoopOutcome(
answer=None,
clarification=clarify_for(ClarifyReason.STILL_INSUFFICIENT),
evidence_texts=(),
retrieval_rounds_used=rounds_used,
repairs_used=0,
stopped_because="no_evidence",
)
repairs_used = 0
answer: str | None = None
while True:
trace.enter("generate")
try:
budget.spend_llm()
except BudgetExhausted:
stopped = "llm_budget"
break
answer = generate(resolved, evidence, state)
if answer is not None:
break
# `generate` returning None means verification already refused it.
try:
budget.spend_repair()
except BudgetExhausted:
stopped = "repair_budget"
break
repairs_used += 1
trace.enter("repair")
metrics.increment(metric_names.LOOP_REPAIRED)
return LoopOutcome(
answer=answer,
clarification=None,
evidence_texts=evidence,
retrieval_rounds_used=rounds_used,
repairs_used=repairs_used,
stopped_because=stopped,
generated=answer is not None,
)
+18
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import functools
import re
from dataclasses import dataclass, replace
from difflib import SequenceMatcher
@@ -55,6 +56,20 @@ class CatalogDrugResolver:
self._fuzzy_threshold = fuzzy_threshold
self._ambiguity_margin = ambiguity_margin
# Measured live 2026-08-07: a single `resolve()` call over the real
# ~10,164-alias catalog costs ~0.65-0.7s, `suggest()` ~0.94-0.97s — both
# O(aliases) regex/SequenceMatcher work, pure functions of their
# arguments (only `self._aliases` et al, fixed at construction, feed
# them). `understanding.py`'s `_candidate_ids` calls both PER HISTORY
# LINE on every single turn — so the SAME already-seen history lines
# were being re-resolved from scratch every turn a conversation grew,
# ~1.6-1.7s of pure CPU per repeated line. A real user's ordinary
# multi-turn conversation was enough to exceed the 20s F-08 budget
# before the first Bedrock call ever ran, surfacing as a false
# "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)
def resolve(self, query: str) -> DrugResolution:
normalized_query = normalize_name(query)
query_tokens = normalized_query.split()
@@ -140,6 +155,9 @@ class CatalogDrugResolver:
break
return ordered
# 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)
def suggest(
self, query: str, k: int = 3, min_score: float = 0.5
) -> list[tuple[str, float]]:
+65
View File
@@ -32,6 +32,9 @@ class EvidencePolicy:
# A free-form question about a resolved drug otherwise hands the LLM the
# entire monograph; rerank trims it to the sections that actually answer.
rerank_top_k: int = 6
# symptom_to_drug: a common symptom can match far more drugs than is
# useful to show in one answer.
indication_candidate_limit: int = 8
class RetrievalService:
@@ -157,6 +160,47 @@ class RetrievalService:
return RetrievalResult(EvidenceDecision.ABSTAIN, "insufficient_retrieval_score")
return self._decide(self._hydrate(self._rerank(query, hits)))
def retrieve_by_indication(self, indication_text: str) -> RetrievalResult:
"""Reverse lookup: symptom/indication -> candidate drugs.
Keyword match first (deterministic, precise — nothing here can
fabricate a drug that doesn't genuinely mention the indication).
Dense-vector search over `chi_dinh` only is the fallback, tried
only when the keyword pass finds nothing, to catch a paraphrase the
book's own wording doesn't share. This is the one place in the live
path dense search is actually used — see ADR 0008.
"""
if not indication_text.strip():
return RetrievalResult(EvidenceDecision.ABSTAIN, "missing_indication")
find_by_indication = getattr(self._retriever, "find_by_indication", None)
hits = (
find_by_indication(indication_text, self._policy.indication_candidate_limit)
if find_by_indication is not None
else []
)
if not hits:
search_indication = getattr(self._retriever, "search_indication", None)
if search_indication is not None:
try:
hits = search_indication(
indication_text, self._policy.indication_candidate_limit
)
except QueryEmbeddingUnavailable:
hits = []
# Dense search always returns its nearest neighbours, even for
# an indication the corpus has nothing on — verified live: a
# made-up phrase still got 8 unrelated "matches". A weak top
# score means those neighbours aren't really about the
# question, so don't spend a generation call finding that out
# the slow way; abstain here, the same bar `retrieve()`'s own
# dense fallback already applies.
if hits and hits[0].score < self._policy.minimum_score:
hits = []
if not hits:
return RetrievalResult(EvidenceDecision.ABSTAIN, "no_indication_match")
return self._decide(self._hydrate(hits, limit=None))
@staticmethod
def _is_question(query: str) -> bool:
"""A bare drug name (one or two tokens) wants the whole monograph; more
@@ -206,8 +250,29 @@ class RetrievalService:
if match is None:
return None
hits = find_by_section(drug_id, match.section_key)
if match.section_key == "than_trong":
# A "thận trọng" question about a specific condition sometimes has
# its real answer filed under "chống chỉ định" instead — found live
# 2026-08-10: Aspirin's own "thận trọng" text never says "loét dạ
# dày", the fact only exists in its "chống chỉ định" text ("loét
# dạ dày hoặc tá tràng đang hoạt động"). The two are the closest
# pair of "is this safe for my patient" categories the book has,
# and chống chỉ định text is short — pooling it costs nothing on
# a drug where than_trong already answers, and prevents a false
# "not in this source" clarify/abstain on one where it doesn't.
hits = hits + find_by_section(drug_id, "chong_chi_dinh")
return hits or None
def decide(self, evidence: tuple[Evidence, ...]) -> RetrievalResult:
"""Public entry point for a caller that assembles its own evidence
pool across several `retrieve_framed` calls — e.g. `RagAgent`'s
2-drug interaction path — and needs the same quarantine/provenance
policy applied to the combined pool that a single call already gets.
Bypassing this (hand-rolling `RetrievalResult(ANSWERABLE, ...)`) is
exactly how the interaction path silently dropped a quarantined
drug's evidence instead of surfacing VERIFY_PDF for it."""
return self._decide(evidence)
def _decide(
self, evidence: tuple[Evidence, ...], is_drug_overview: bool = False
) -> RetrievalResult:
+192 -7
View File
@@ -35,9 +35,15 @@ stub runs the whole path offline in tests.
from __future__ import annotations
import json
from dataclasses import dataclass, field
import logging
from dataclasses import dataclass, field, replace
from typing import Protocol, Sequence
from .budget import RequestBudget
from .ports import AnswerGenerationUnavailable
logger = logging.getLogger(__name__)
# The 19 monograph section keys, kept here as the closed vocabulary the model may
# use for `attribute`. Adding a new section is one entry, not a code change.
SECTION_KEYS = (
@@ -126,8 +132,22 @@ class QueryFrame:
weight_kg: float | None = None
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"
needs_clarify: bool = False
clarify_reason: str | None = None
# Short suggested replies for `clarify_reason` (e.g. ("Người lớn", "Trẻ
# em")) — only when the model judged the missing detail has a few
# natural discrete answers; often empty (e.g. a question needing a
# specific weight has no clean short options).
quick_replies: tuple[str, ...] = ()
# Set only when `needs_clarify` fired because of a real technical
# failure (provider outage, malformed model output) rather than the
# model genuinely judging the turn under-specified. Found live
# 2026-08-07: both cases produced the exact same `reason="needs_more_info"`
# downstream, making a real, diagnosable outage indistinguishable from an
# ordinary clarifying question in the API response and trace — this lets
# `RagAgent._route` surface the real cause instead.
system_error: str | None = None
raw: dict = field(default_factory=dict, compare=False)
@@ -147,8 +167,24 @@ FRAME_SCHEMA = {
),
"age_text": "the age exactly as stated (e.g. '3 tuổi', '5 tháng'), else null",
"indication": "the symptom or disease if turn_type is symptom_to_drug, else null",
"route": (
"route of administration if stated or implied, normalized to one of: "
"uong | tiem_tinh_mach | tiem_bap | tiem_duoi_da | dat_truc_trang | "
"boi_ngoai_da | nho_mat | nho_mui | khac, else null. A bare reply like "
"'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."
),
"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": (
"2-4 short suggested replies (each under ~20 chars) to your OWN "
"clarify_reason, for the user to tap instead of typing — ONLY when "
"clarify_reason genuinely has a few natural discrete answers (e.g. "
"['Người lớn', 'Trẻ em'] or ['Uống', 'Tiêm']). Empty list [] if the "
"missing detail needs a specific free-form value (e.g. an exact "
"weight) with no clean short options — never invent numeric-ish "
"options."
),
}
_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.
@@ -169,7 +205,39 @@ Quy tắc bắt buộc:
không kèm đơn vị khác) NGHĨA LÀ 30 kg -> điền weight_kg=30, không bỏ trống.
- Lượt nối tiếp ("còn liều thì sao", "nó dùng cho trẻ em?") -> dùng LỊCH SỬ để biết
thuốc đang nói tới và điền vào "drugs".
- Chào hỏi/vu vơ -> "smalltalk". Ngoài phạm vi chuyên luận thuốc -> "out_of_scope"."""
- 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
hợp lý cho đúng câu đó (vd "Uống", "Người lớn", "30kg") — hãy đọc nó là câu trả
lời, điền vào field tương ứng (route/population/weight_kg/age_text), giữ lại các
field đã biết từ các lượt trước đó trong LỊCH SỬ (đừng bỏ trống lại), và CHỈ đặt
needs_clarify=true với PHẦN THÔNG TIN CÒN THIẾU KHÁC (nếu có) — TUYỆT ĐỐI KHÔNG
lặp lại nguyên văn clarify_reason đã được trả lời. Nếu sau khi điền, đã đủ dữ
kiện (đối tượng + đường dùng, và tuổi/cân nặng nếu là trẻ em) thì needs_clarify=false.
Nếu có khối "THÔNG TIN ĐÃ XÁC ĐỊNH TỪ CÁC LƯỢT TRƯỚC" bên dưới, các mục đó ĐÃ
ĐƯỢC XÁC NHẬN — không hỏi lại, không đặt needs_clarify=true vì thiếu đúng mục
đã liệt kê ở đó.
- NGƯỢC LẠI — nếu CÂU HỎI HIỆN TẠI là một câu hỏi y khoa MỚI, KHÔNG phải một câu
trả lời ngắn cho câu hỏi làm rõ gần nhất (không khớp loại thông tin vừa hỏi) và
KHÔNG nhắc lại thuốc/triệu chứng nào đã có trong LỊCH SỬ hay khối "THÔNG TIN ĐÃ
XÁC ĐỊNH": đây là LƯỢT MỚI HOÀN TOÀN — TUYỆT ĐỐI KHÔNG mang "drugs"/
"population"/"weight_kg"/"age_text"/"route"/"indication" của lượt trước sang lượt
này, chỉ điền những gì thực sự có trong CÂU HỎI HIỆN TẠI. Ví dụ: lượt trước đang
hỏi về Omeprazol nhưng câu hiện tại là "tôi bị đau đầu nên uống thuốc gì" (không
nhắc Omeprazol) -> chủ đề mới, "drugs" phải để trống trừ khi có thuốc thực sự
được nhắc trong câu này.
- Nếu CÂU HỎI HIỆN TẠI là một lời PHỦ ĐỊNH/SỬA LẠI câu trả lời vừa rồi (vd "tôi
có hỏi X đâu", "tôi không hỏi vậy", "đâu phải thế", "ý tôi không phải vậy",
"sai rồi") — đây là dấu hiệu bạn vừa hiểu SAI ý người dùng ở lượt trước.
TUYỆT ĐỐI KHÔNG lặp lại đúng route/population/thuộc tính vừa trả lời (đã bị
từ chối): đặt needs_clarify=true và hỏi lại thật ngắn gọn, cụ thể người dùng
thực sự muốn hỏi điều gì (vd "Anh/chị muốn hỏi đường dùng nào ạ?"), không tự
suy đoán lại giá trị cũ.
- Chào hỏi/vu vơ -> "smalltalk". Ngoài phạm vi chuyên luận thuốc -> "out_of_scope".
- Khi needs_clarify=true, kèm "quick_replies": 2-4 phương án NGẮN cho câu hỏi lại
đó, CHỈ khi nó thực sự có vài lựa chọn rời rạc tự nhiên (vd đối tượng: "Người
lớn"/"Trẻ em"). Để mảng rỗng nếu cần một giá trị cụ thể không có lựa chọn ngắn
(vd hỏi cân nặng chính xác) — không bịa phương án dạng số."""
class JsonLlm(Protocol):
@@ -181,7 +249,11 @@ class JsonLlm(Protocol):
class QueryUnderstander(Protocol):
def understand(
self, turn: str, history: Sequence[str] = ()
self,
turn: str,
history: Sequence[str] = (),
budget: RequestBudget | None = None,
prior_frame: QueryFrame | None = None,
) -> QueryFrame: ...
@@ -240,7 +312,13 @@ class LlmQueryUnderstander:
ids.add(drug_id)
return ids
def understand(self, turn: str, history: Sequence[str] = ()) -> QueryFrame:
def understand(
self,
turn: str,
history: Sequence[str] = (),
budget: RequestBudget | None = None,
prior_frame: QueryFrame | None = None,
) -> QueryFrame:
shown = {
drug_id: self._catalog[drug_id]
for drug_id in self._candidate_ids(turn, history)
@@ -255,6 +333,7 @@ class LlmQueryUnderstander:
"LỊCH SỬ HỘI THOẠI (cũ -> mới):\n" + "\n".join(history)
if history else "LỊCH SỬ HỘI THOẠI: (chưa có)"
)
known_block = _known_facts_block(prior_frame)
user = (
f"DANH SÁCH THUỐC ỨNG VIÊN cho lượt này (drug_id\\ttên) — CHỈ được chọn "
f"drug_id từ đây, đây KHÔNG phải toàn bộ Dược thư, chỉ là các thuốc khớp "
@@ -263,11 +342,39 @@ class LlmQueryUnderstander:
"CÁC SECTION KEY hợp lệ cho 'attribute' (key: ý nghĩa):\n"
+ "\n".join(f"{key}: {SECTION_KEY_HINTS[key]}" for key in SECTION_KEYS)
+ "\n\n"
f"{known_block}"
f"{history_block}\n\n"
f"CÂU HỎI HIỆN TẠI: {turn}"
)
raw_text = self._llm.generate(_SYSTEM, user, FRAME_SCHEMA)
return self._parse(raw_text, shown)
# Found live 2026-08-07 (F-10 adversarial pass): unlike every other
# LLM call site in this product (`answer.py`'s sufficiency/generate/
# entailment all catch this), this one call had no error handling at
# all — a provider outage here propagated straight through
# `RagAgent.handle()` and `routers/rag.py` (which only wraps the
# trace-save call, not `agent.handle()`) into an unhandled 500,
# rather than the graceful abstain every other failure mode gets.
try:
if budget is not None:
budget.require()
raw_text = self._llm.generate(_SYSTEM, user, FRAME_SCHEMA)
except AnswerGenerationUnavailable as exc:
# Found live 2026-08-07: this except block silently swallowed
# the real exception entirely — no log line anywhere — so a
# genuine provider outage (throttling, timeout, IAM, whatever)
# left zero trace to diagnose from. Now logged with the actual
# exception, and tagged with a `system_error` code distinct from
# an ordinary clarify (see `QueryFrame.system_error`).
logger.warning(
"understanding call failed (%s): %s", type(exc).__name__, exc
)
return QueryFrame(
turn_type="out_of_scope",
needs_clarify=True,
clarify_reason="Dịch vụ đang gặp sự cố tạm thời, vui lòng thử lại "
"sau ít phút.",
system_error="understanding_provider_unavailable",
)
return _merge_with_prior_frame(self._parse(raw_text, shown), prior_frame)
@staticmethod
def _resolve_id(value: str, shown: dict[str, str]) -> str | None:
@@ -291,11 +398,15 @@ class LlmQueryUnderstander:
try:
data = json.loads(raw_text)
except (json.JSONDecodeError, TypeError):
# Fail closed to a clarify rather than to a wrong reading.
# Fail closed to a clarify rather than to a wrong reading. Not a
# provider outage (the call succeeded) — the model's own output
# didn't parse, a distinct, separately diagnosable cause.
logger.warning("understanding call returned unparseable JSON: %r", raw_text)
return QueryFrame(
turn_type="out_of_scope",
needs_clarify=True,
clarify_reason="Xin lỗi, tôi chưa hiểu rõ câu hỏi. Anh/chị hỏi lại giúp nhé?",
system_error="understanding_malformed_output",
)
resolved = [
(d, self._resolve_id(d, shown)) for d in _as_list(data.get("drugs"))
@@ -322,12 +433,86 @@ class LlmQueryUnderstander:
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"))),
raw=data if isinstance(data, dict) else {},
)
_KNOWN_FACT_LABELS: tuple[tuple[str, str], ...] = (
("population", "Đối tượng"),
("age_text", "Tuổi"),
("route", "Đường dùng"),
("indication", "Chỉ định/triệu chứng"),
("attribute", "Thuộc tính đang tra"),
)
def _known_facts_block(prior_frame: QueryFrame | None) -> str:
"""The structured "already established" summary shown to the model on a
clarify-continuation turn.
Found live 2026-08-07 (50-question hand-typed browser audit): relying on
the model to re-derive the WHOLE frame from raw text history every turn
is fragile — reproduced 3 times independently (Insulin storage, weight-
based Azithromycin dosing, a headache question mislabeled OMEPRAZOL) as
either a non-terminating re-ask of an already-answered clarify question,
or a stale drug bleeding into an unrelated new topic. Stating the known
fields explicitly, as data rather than asking the model to infer them
from a growing text transcript, removes most of the guesswork; `_merge_
with_prior_frame` below is the code-level backstop for whatever the
model still drops.
"""
if prior_frame is None or not prior_frame.needs_clarify:
return ""
parts = []
if prior_frame.drugs:
parts.append(f"Thuốc: {', '.join(prior_frame.drugs)}")
if prior_frame.weight_kg is not None:
parts.append(f"Cân nặng: {prior_frame.weight_kg:g} kg")
for field_name, label in _KNOWN_FACT_LABELS:
value = getattr(prior_frame, field_name)
if value:
parts.append(f"{label}: {value}")
if not parts:
return ""
return (
"THÔNG TIN ĐÃ XÁC ĐỊNH TỪ CÁC LƯỢT TRƯỚC (dữ liệu CÓ THẬT, đã xác nhận "
"— KHÔNG hỏi lại các mục này; nếu câu hỏi hiện tại là một chủ đề mới "
"không liên quan, hãy bỏ qua khối này thay vì gán nhầm vào lượt mới):\n"
+ "\n".join(parts) + "\n\n"
)
def _merge_with_prior_frame(frame: QueryFrame, prior_frame: QueryFrame | None) -> QueryFrame:
"""Code-level backstop for the model dropping an already-known field.
Only fires when the prior turn was itself a clarify (there is something
to continue) and this turn's own `drugs` extraction agrees with it (empty,
meaning a short reply like "20kg"/"Uống" that names no drug of its own, or
an exact match) — a turn that resolves a DIFFERENT drug is a genuine topic
change and must not inherit stale population/weight/route/indication from
the old one (the headache/OMEPRAZOL bleed this guards against runs the
other way: don't let old fields survive into an unrelated new drug either).
"""
if prior_frame is None or not prior_frame.needs_clarify:
return frame
if frame.drugs and frame.drugs != prior_frame.drugs:
return frame
return replace(
frame,
drugs=frame.drugs or prior_frame.drugs,
population=frame.population or prior_frame.population,
age_text=frame.age_text or prior_frame.age_text,
weight_kg=frame.weight_kg if frame.weight_kg is not None else prior_frame.weight_kg,
route=frame.route or prior_frame.route,
indication=frame.indication or prior_frame.indication,
attribute=frame.attribute or prior_frame.attribute,
)
def _as_list(value) -> list[str]:
if isinstance(value, str):
return [value] if value.strip() else []