Add read-only production runtime audit
This commit is contained in:
@@ -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ể 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.",
|
||||
"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ủ thuốc 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",
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user