Fix ai-service Dockerfile: bake in drug_entities.json, override its path
This commit is contained in:
+279
-29
@@ -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 = " và ".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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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 đó", "nó")
|
||||
|
||||
# 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
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 []
|
||||
|
||||
Reference in New Issue
Block a user