Add read-only production runtime audit

This commit is contained in:
2026-08-17 11:17:40 +07:00
parent 057d4ed9dc
commit a1de4715a4
106 changed files with 6869 additions and 1782 deletions
+60 -7
View File
@@ -28,10 +28,12 @@ from .clinical import ConditionRelation, MedicationCandidateAssessment
from .models import EvidenceDecision, RetrievalResult
from .policy import looks_non_human
from .service import RetrievalService
from .sections import SectionResolver
from .text import normalize_name
from .understanding import QueryFrame, QueryUnderstander
logger = logging.getLogger(__name__)
_SECTION_RESOLVER = SectionResolver()
TUONG_TAC = "tuong_tac_thuoc"
HISTORY_TURNS = 6
@@ -142,7 +144,12 @@ class RagAgent:
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:
def handle(
self,
turn: str,
conversation_id: str | None = None,
response_mode: str = "ai",
) -> AgentReply:
# F-08: one budget per turn, threaded through every LLM call this
# turn makes (understand, then whatever `_route` reaches).
t0 = time.monotonic()
@@ -154,7 +161,7 @@ class RagAgent:
turn, tuple(history), budget=budget, prior_frame=prior_frame
)
t2 = time.monotonic()
reply = self._route(turn, frame, budget)
reply = self._route(turn, frame, budget, response_mode=response_mode)
reply = self._enforce_clarify_circuit_breaker(conversation_id, reply)
t3 = time.monotonic()
if conversation_id is not None:
@@ -242,7 +249,13 @@ class RagAgent:
return []
return self._history.get(conversation_id, [])
def _route(self, turn: str, frame: QueryFrame, budget: RequestBudget) -> AgentReply:
def _route(
self,
turn: str,
frame: QueryFrame,
budget: RequestBudget,
response_mode: str = "ai",
) -> AgentReply:
tt = frame.turn_type
section_overview = _is_section_overview(turn, frame)
if section_overview and not frame.section_overview:
@@ -253,10 +266,10 @@ class RagAgent:
# an out-of-scope request look recoverable.
if looks_non_human(turn):
return AgentReply(
"abstain", "out_of_scope",
answer="Nội dung này nằm ngoài phần chuyên luận thuốc của Dược thư "
"(có thể thuc 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.",
"abstain", "out_of_scope_non_human",
answer="Dược thư Quốc gia Việt Nam trong hệ thống này chỉ bao "
"phủ thuc dùng cho người. Hệ thống không tra cứu liều "
"dùng hoặc hướng dẫn điều trị cho động vật.",
turn_type=tt)
# Dosing is a small state machine, not an unconstrained model opinion.
@@ -407,6 +420,24 @@ class RagAgent:
turn_type=tt,
)
if response_mode == "monograph" and (
tt == "drug_overview"
or (
tt == "drug_attribute"
and frame.attribute is None
and not frame.needs_clarify
)
or _is_bare_monograph_request(turn)
) and frame.drugs:
return AgentReply(
"clarify", "select_drug_sections",
clarification=(
"Đã nhận diện chuyên luận thuốc. Anh/chị chọn các mục cần "
"xem; nếu không chọn mục nào, hệ thống sẽ hiển thị toàn bộ."
),
drugs=frame.drugs, turn_type=tt,
)
if tt == "drug_attribute" and frame.drugs and frame.attribute is None:
return AgentReply(
"clarify", "missing_attribute",
@@ -713,6 +744,28 @@ def _is_section_overview(turn: str, frame: QueryFrame) -> bool:
return frame.section_overview or any(cue in text for cue in overview_cues)
def _is_bare_monograph_request(turn: str) -> bool:
"""True for a plain drug name in explicit monograph-browse mode.
A persisted conversation can contribute a stale attribute to a new bare
drug turn (for example the prior question was about contraindications).
The UI mode is an explicit current-turn instruction, so a plain name must
open the picker rather than inherit that old section. Any actual section
phrase or clinical-question cue keeps the normal AI route.
"""
text = normalize_name(turn)
if not text or len(text) > 100 or _SECTION_RESOLVER.resolve_all(turn):
return False
clinical_cues = (
" dung ", " dieu tri ", " tuong tac ", " tac dung ", " lieu ",
" benh ", " thai ", " cho con bu ", " tre em ", " nguoi lon ",
" suy than ", " suy gan ", " di ung ", " bao nhieu ", " la gi ",
" co the ", " duoc khong ",
)
padded = f" {text} "
return not any(cue in padded for cue in clinical_cues)
_POPULATION_LABELS = {
"tre_em": "trẻ em",
"tre_so_sinh": "trẻ sơ sinh",
+21
View File
@@ -839,6 +839,7 @@ class GroundedAnswerService:
evidence_drug_ids: tuple[str | None, ...] = (),
budget: RequestBudget | None = None,
plan: AnswerPlan | None = None,
retry_unsupported_patient_list: bool = True,
) -> "_GenOutcome":
"""A verified generation, a clarifying question, or empty to fall back."""
if self._generator is None or not evidence_texts:
@@ -935,6 +936,26 @@ class GroundedAnswerService:
)
return _GenOutcome(reject_reason=verification.reason)
if not verification.supported:
# Patient-specific candidate comparisons occasionally receive a
# noisy negative entailment verdict even though the same evidence
# and a fresh answer clear both fail-closed checks immediately
# afterwards (observed in the C03 contextual renal-safety turn).
# Retry only this known conversational lane, once. Ordinary AI
# answers and monograph browsing are intentionally unchanged.
if patient_specific and list_mode and retry_unsupported_patient_list:
return self._generate(
query,
evidence_texts,
prompt_evidence_texts,
intro=intro,
list_mode=list_mode,
patient_specific=patient_specific,
candidate_drug_ids=candidate_drug_ids,
evidence_drug_ids=evidence_drug_ids,
budget=budget,
plan=plan,
retry_unsupported_patient_list=False,
)
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="unsupported_claim"
)
+2
View File
@@ -314,6 +314,8 @@ class ConditionNormalizer:
"benh gout": "gút",
"benh gut": "gút",
"gut": "gút",
"viem phoi": "viêm phổi",
"benh viem phoi": "viêm phổi",
}
_BROAD = frozenset({"viem gan", "ung thu", "nhiem trung", "nhiem khuan"})
_BROAD_QUESTIONS = {
+91 -3
View File
@@ -593,6 +593,7 @@ class LlmQueryUnderstander:
frame = _apply_broad_condition_cue(
frame, turn, self._condition_normalizer
)
frame = _apply_general_condition_scope(frame, turn)
frame = _apply_reverse_relation_cues(frame, turn)
section_match = _SECTION_RESOLVER.resolve(turn)
frame = _apply_named_drug_cues(
@@ -603,7 +604,8 @@ class LlmQueryUnderstander:
resolved_section_phrase=(section_match.phrase if section_match else None),
)
frame = _apply_multi_section_clarify(frame, turn)
return _merge_with_prior_frame(frame, prior_frame)
frame = _merge_with_prior_frame(frame, prior_frame)
return _apply_contextual_candidate_safety(frame, turn, prior_frame)
@staticmethod
def _resolve_id(value: str, shown: dict[str, str]) -> str | None:
@@ -754,7 +756,7 @@ def _apply_condition_candidate_cue(
"""Keep current medicines subordinate in an explicit condition lookup."""
if frame.turn_type == "condition_to_drug" and frame.condition is not None:
return frame
condition = normalizer.detect_known_alias(turn)
condition = frame.condition or normalizer.detect_known_alias(turn)
if condition is None:
return frame
text = f" {normalize_name(turn)} "
@@ -767,6 +769,8 @@ def _apply_condition_candidate_cue(
" option dieu tri ",
" ung vien nao ",
" cac ung vien nao ",
" co chi dinh lien quan ",
" co chi dinh cho ",
)
if not any(cue in text for cue in candidate_cues):
return frame
@@ -782,6 +786,80 @@ def _apply_condition_candidate_cue(
)
def _apply_general_condition_scope(frame: QueryFrame, turn: str) -> QueryFrame:
"""Do not turn a disease name into an unstated patient impairment.
A general reverse lookup such as ``Viêm gan B mạn dùng thuốc gì?`` names
the condition being treated; it does not say that a particular patient has
hepatic impairment. The understanding model can otherwise duplicate the
same phrase into ``patient_context.hepatic`` and trigger a stage-2 safety
review, mixing contraindication/precaution citations into a general
indication list. Explicit patient cues keep the full context untouched.
"""
if frame.turn_type not in {"condition_to_drug", "symptom_to_drug"}:
return frame
text = f" {normalize_name(turn)} "
patient_cues = (
" bn ", " benh nhan ", " nguoi benh ", " kem ", " di ung ",
" dang dung ", " mang thai ", " cho con bu ", " tuoi ", " kg ",
" ckd ", " suy than ", " suy gan ", " child pugh ", " egfr ",
" creatinin ", " ast ", " alt ",
)
if any(cue in text for cue in patient_cues):
return frame
primary = (
frame.condition.normalized_condition
if frame.condition is not None
else frame.indication
)
return replace(frame, patient_context=PatientContext(primary_condition=primary))
def _apply_contextual_candidate_safety(
frame: QueryFrame,
turn: str,
prior_frame: QueryFrame | None,
) -> QueryFrame:
"""Keep ``các thuốc trên`` on the prior condition-to-drug candidate lane.
This follow-up asks to compare the already retrieved candidates against a
new patient constraint. It is not a reverse disease->contraindication
lookup, even if the current turn contains words such as ``bệnh thận``.
"""
if prior_frame is None or prior_frame.turn_type not in {
"condition_to_drug", "symptom_to_drug"
}:
return frame
text = f" {normalize_name(turn)} "
refers_to_candidates = any(
cue in text for cue in (" cac thuoc tren ", " trong cac thuoc tren ")
)
safety_cue = any(
cue in text
for cue in (
" luu y ", " than trong ", " benh than ", " suy than ",
" benh gan ", " suy gan ", " di ung ", " tuong tac ",
)
)
if not (refers_to_candidates and safety_cue):
return frame
condition = frame.condition or prior_frame.condition
return replace(
frame,
turn_type="condition_to_drug",
indication=(
condition.normalized_condition
if condition is not None
else frame.indication or prior_frame.indication
),
condition=condition,
condition_relation=ConditionRelation.INDICATION,
needs_clarify=False,
clarify_reason=None,
quick_replies=(),
)
def _apply_broad_condition_cue(
frame: QueryFrame, turn: str, normalizer: ConditionNormalizer
) -> QueryFrame:
@@ -1037,7 +1115,17 @@ def _merge_with_prior_frame(frame: QueryFrame, prior_frame: QueryFrame | None) -
indication=indication or prior_frame.indication,
condition=condition,
patient_context=patient_context,
attribute=frame.attribute or prior_frame.attribute,
# A current drug-attribute clarify with no attribute is an explicit
# ambiguity signal (for example, the user named both "chỉ định" and
# "chống chỉ định"). Re-inheriting the previous turn's attribute here
# silently picks one of those sections and poisons the frame remembered
# for the next quick reply. Other continuation shapes still inherit the
# prior slot as before (notably pediatric dosing clarifications).
attribute=(
frame.attribute
if frame.turn_type == "drug_attribute" and frame.needs_clarify
else frame.attribute or prior_frame.attribute
),
)