Files
duocthu/apps/ai-service/rag/agent.py
T

445 lines
21 KiB
Python

"""The new RAG orchestrator — LLM understanding in, grounded answer out.
Replaces the old front-end wholesale:
- `CatalogDrugResolver` (fuzzy) + `SectionResolver` (keyword) -> `understanding.py`
- `ConversationalLoopService` + `conversation.py` (Focus / Summariser / manual
follow-up inheritance) -> the LLM reads a plain turn history and resolves
"thuốc đó" / "còn liều thì sao" itself.
What is deliberately KEPT because it is the safety spine, not the brittle part:
- `RetrievalService.retrieve_framed` (Qdrant section/overview retrieval, whole
section, provenance, quarantine `VERIFY_PDF`),
- `GroundedAnswerService.answer_from_result` (`grounding.verify` + entailment
on every generated claim; a configured generator that fails abstains rather
than degrading to a raw source dump).
This module owns routing only; it states no medical fact of its own.
"""
from __future__ import annotations
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):
"""As-you-type suggestion, kept deterministic and independent of the LLM
understander — a prefix match needs no model call."""
def complete(self, prefix: str, k: int) -> list[str]: ...
@dataclass(frozen=True)
class AgentReply:
decision: str # answerable | abstain | clarify | verify_pdf
reason: str
answer: str | None = None
clarification: str | None = None
citations: tuple[Citation, ...] = ()
drugs: tuple[str, ...] = ()
turn_type: str = ""
generated: bool = False
quick_replies: tuple[str, ...] = ()
class RagAgent:
def __init__(
self,
understander: QueryUnderstander,
retrieval: RetrievalService,
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."""
if self._autocomplete is None:
return []
return [_display_name(drug_id) for drug_id in self._autocomplete.complete(prefix, k)]
def handle(self, turn: str, conversation_id: str | None = None) -> AgentReply:
# 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 _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:
# `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,
quick_replies=frame.quick_replies)
if tt == "smalltalk":
return AgentReply(
"answerable", "smalltalk",
answer="Chào anh/chị! Em là trợ lý tra cứu Dược thư Quốc gia Việt Nam "
"2018, sẵn sàng hỗ trợ tra liều dùng, chống chỉ định, tương tác "
"thuốc... Anh/chị đang cần tra thuốc nào ạ?",
turn_type=tt)
if tt in ("out_of_scope",) or looks_non_human(turn):
return AgentReply(
"abstain", "out_of_scope",
answer="Nội dung này nằm ngoài phần chuyên luận thuốc của Dược thư "
"(có thể thuộc phần hướng dẫn chung/phụ lục chưa được đưa vào). "
"Tôi chưa có dữ liệu để trả lời chính xác.",
turn_type=tt)
if not frame.drugs:
if frame.unknown_drugs:
names = ", ".join(frame.unknown_drugs)
return AgentReply(
"abstain", "drug_not_in_formulary",
answer=f"Không tìm thấy \"{names}\" trong Dược thư Quốc gia Việt Nam.",
turn_type=tt)
if tt == "symptom_to_drug":
if frame.indication:
return self._symptom_to_drug(turn, frame, budget)
return AgentReply(
"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, budget)
# drug_attribute / drug_overview / dosing_calc / fallback: one drug + section
return self._single_drug(turn, frame, budget)
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, query,
is_overview=frame.turn_type == "drug_overview",
)
return self._grounded(query, result, frame, budget=budget)
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 in (EvidenceDecision.ANSWERABLE, EvidenceDecision.VERIFY_PDF):
evidences.extend(part.evidence)
if not evidences:
listed = "".join(frame.drugs)
return AgentReply(
"abstain", "no_interaction_evidence",
answer=f"Không tìm thấy mục tương tác thuốc cho {listed} trong Dược "
"thư. Điều này KHÔNG có nghĩa là an toàn khi phối hợp.",
drugs=frame.drugs, turn_type=frame.turn_type)
combined = 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
)
def _grounded(
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, list_mode=list_mode, budget=budget
)
decision = ga.result.decision.value
if ga.clarification is not None:
decision = "clarify"
return AgentReply(
decision=decision,
reason=ga.result.reason,
answer=ga.answer,
clarification=ga.clarification,
citations=ga.citations,
drugs=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:
lines = [f"Người dùng: {turn}"]
spoken = reply.answer or reply.clarification
if spoken:
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]
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) + "."