Add production condition retrieval smoke test

This commit is contained in:
2026-08-11 14:58:28 +07:00
parent 59e6ad2d0d
commit 7ebbe1f309
38 changed files with 3752 additions and 121 deletions
+169 -17
View File
@@ -24,6 +24,7 @@ from typing import Protocol
from .answer import AnswerBlock, AnswerPlan, Citation, GroundedAnswerService
from .budget import RequestBudget
from .clinical import ConditionRelation, MedicationCandidateAssessment
from .models import EvidenceDecision, RetrievalResult
from .policy import looks_non_human
from .service import RetrievalService
@@ -84,6 +85,7 @@ class AgentReply:
blocks: tuple[AnswerBlock, ...] = ()
answer_mode: str = "concise"
plan: AnswerPlan | None = None
candidate_assessments: tuple[MedicationCandidateAssessment, ...] = ()
class RagAgent:
@@ -298,6 +300,7 @@ class RagAgent:
frame.needs_clarify
and frame.clarify_reason
and tt != "dosing_calc"
and tt != "condition_to_drug"
and not section_overview
):
# `system_error` set means this isn't a real clarify at all — the
@@ -321,6 +324,54 @@ class RagAgent:
quick_replies=frame.quick_replies,
)
if tt in {"condition_to_drug", "symptom_to_drug"}:
if frame.condition and frame.condition.ambiguous:
return AgentReply(
"clarify",
"ambiguous_condition",
clarification=(
frame.condition.clarify_question
or "Anh/chị muốn hỏi loại bệnh cụ thể nào?"
),
turn_type=tt,
)
if frame.condition_relation != ConditionRelation.INDICATION:
return AgentReply(
"abstain",
"unsupported_reverse_relation",
answer=(
"Câu hỏi này đang hỏi quan hệ khác với chỉ định điều trị "
"(ví dụ thuốc gây bệnh hoặc chống chỉ định theo bệnh). Hệ "
"thống chưa tra ngược quan hệ đó và sẽ không biến nó thành "
"danh sách thuốc điều trị."
),
turn_type=tt,
)
if frame.condition or frame.indication:
return self._condition_to_drug(turn, frame, budget)
return AgentReply(
"clarify",
"no_indication" if tt == "symptom_to_drug" else "no_condition",
clarification=(
"Anh/chị mô tả triệu chứng hoặc chỉ định cần tra giúp em với?"
if tt == "symptom_to_drug"
else "Anh/chị muốn tra thuốc có chỉ định cho bệnh/condition nào?"
),
turn_type=tt,
)
if tt == "condition_relation":
return AgentReply(
"abstain",
"unsupported_reverse_relation",
answer=(
"Hệ thống nhận ra đây không phải câu hỏi thuốc điều trị bệnh, "
"nên không dùng mục Chỉ định để trả lời. Tra ngược thuốc gây "
"bệnh/chống chỉ định theo bệnh chưa được hỗ trợ trong phiên bản này."
),
turn_type=tt,
)
if tt == "drug_attribute" and frame.drugs and frame.attribute is None:
return AgentReply(
"clarify", "missing_attribute",
@@ -354,14 +405,6 @@ class RagAgent:
"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)
@@ -381,13 +424,25 @@ class RagAgent:
section_key = (
"lieu_luong_va_cach_dung"
if frame.turn_type == "dosing_calc"
else frame.attribute
else (
"chi_dinh" if frame.turn_type == "drug_to_condition" else frame.attribute
)
)
result = self._retrieval.retrieve_framed(
frame.drugs[0], section_key, query,
is_overview=frame.turn_type == "drug_overview",
)
return self._grounded(query, result, frame, budget=budget)
if frame.patient_context.requires_safety_review:
result = self._retrieval.retrieve_patient_drug_context(
frame.drugs[0], result, frame.patient_context
)
return self._grounded(
query,
result,
frame,
patient_specific=frame.patient_context.requires_safety_review,
budget=budget,
)
def _interaction(self, turn: str, frame: QueryFrame, budget: RequestBudget) -> AgentReply:
"""Gather the interaction section of each named drug and synthesise.
@@ -424,7 +479,7 @@ class RagAgent:
combined = self._retrieval.decide(tuple(evidences))
return self._grounded(query, combined, frame, budget=budget)
def _symptom_to_drug(
def _condition_to_drug(
self, turn: str, frame: QueryFrame, budget: RequestBudget
) -> AgentReply:
"""Reverse lookup: a symptom/indication -> which drugs' `chi_dinh`
@@ -435,12 +490,15 @@ class RagAgent:
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)
condition_text = (
frame.condition.retrieval_text if frame.condition else frame.indication
)
result = self._retrieval.retrieve_by_indication(condition_text)
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ó "
f"nhận chỉ định cho \"{condition_text}\". Đ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)
@@ -448,19 +506,65 @@ class RagAgent:
# 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
evidence.drug_id or 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
assessments: tuple[MedicationCandidateAssessment, ...] = ()
patient_specific = frame.patient_context.requires_safety_review
if patient_specific:
result, assessments = self._retrieval.assess_patient_candidates(
result, frame.patient_context
)
if assessments:
matched_drugs = tuple(item.drug_id for item in assessments)
if result.decision == EvidenceDecision.ABSTAIN:
return AgentReply(
"abstain",
result.reason,
answer=(
"Có bằng chứng chỉ định cho bệnh chính nhưng chưa tìm thấy đủ "
"bằng chứng an toàn liên quan đến dữ kiện người bệnh trong các "
"mục Dược thư được tra. Không suy ra thuốc là phù hợp/an toàn."
),
drugs=matched_drugs,
turn_type=frame.turn_type,
candidate_assessments=assessments,
)
generation_query = (
_patient_generation_query(frame)
if patient_specific
else _synthesize_query(turn, frame)
)
return self._grounded(
generation_query,
result,
frame,
drugs=matched_drugs,
list_mode=True,
patient_specific=patient_specific,
assessments=assessments,
budget=budget,
)
# Compatibility name for older tests/callers while the public taxonomy
# moves from symptom-only wording to condition-centric wording.
_symptom_to_drug = _condition_to_drug
def _grounded(
self, turn: str, result: RetrievalResult, frame: QueryFrame,
drugs: tuple[str, ...] | None = None, list_mode: bool = False,
patient_specific: bool = False,
assessments: tuple[MedicationCandidateAssessment, ...] = (),
budget: RequestBudget | None = None,
) -> AgentReply:
ga = self._answers.answer_from_result(
turn, result, list_mode=list_mode, budget=budget, prechecked=True
turn,
result,
list_mode=list_mode,
patient_specific=patient_specific,
candidate_drug_ids=drugs or (),
budget=budget,
prechecked=True,
)
decision = ga.result.decision.value
if ga.clarification is not None:
@@ -479,6 +583,7 @@ class RagAgent:
blocks=ga.blocks,
answer_mode=ga.answer_mode,
plan=ga.plan,
candidate_assessments=assessments,
)
def _remember(self, conversation_id: str, turn: str, reply: AgentReply) -> None:
@@ -619,6 +724,53 @@ def _synthesize_query(turn: str, frame: QueryFrame) -> str:
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}")
patient = frame.patient_context
if patient.primary_condition:
parts.append(f"Bệnh chính: {patient.primary_condition}")
if patient.comorbidities:
parts.append(f"Bệnh nền: {', '.join(patient.comorbidities)}")
if patient.allergies:
parts.append(f"Dị ứng: {', '.join(patient.allergies)}")
if patient.previous_adverse_reactions:
parts.append(f"ADR trước đây: {', '.join(patient.previous_adverse_reactions)}")
if patient.current_medications:
parts.append(f"Thuốc đang dùng: {', '.join(patient.current_medications)}")
if patient.renal.present:
parts.append(f"Dữ kiện thận: {patient.renal}")
if patient.hepatic.present:
parts.append(f"Dữ kiện gan: {patient.hepatic}")
if patient.pregnancy_status:
parts.append(f"Thai kỳ: {patient.pregnancy_status}")
if patient.breastfeeding is not None:
parts.append(f"Cho con bú: {'' if patient.breastfeeding else 'không'}")
if patient.relevant_labs:
parts.append(f"Xét nghiệm: {', '.join(patient.relevant_labs)}")
if len(parts) == 1:
return turn
return ". ".join(parts) + "."
def _patient_generation_query(frame: QueryFrame) -> str:
"""Give generation the clinical task without restating user-only values.
Patient values have already done their job before generation: they select
the targeted safety sections. They are not Dược thư evidence themselves.
Passing the raw turn here encouraged the model to repeat age, eGFR or CKD
grade inside a cited medical claim, which the numeric grounding guard then
correctly rejected. The composer therefore receives only the evidenced
condition and an instruction to describe the supplied positive evidence;
exact patient values remain in structured state and retrieval traces.
"""
condition = (
frame.condition.normalized_condition
if frame.condition and frame.condition.normalized_condition
else frame.patient_context.primary_condition
or frame.indication
or "bệnh chính đã nêu"
)
return (
f"Tra cứu các thuốc có bằng chứng chỉ định cho {condition}. "
"Đây là ca cụ thể: với từng ứng viên, chỉ trình bày bằng chứng an toàn "
"dương tính đã truy xuất liên quan đến dữ kiện người bệnh; không lặp "
"lại dữ kiện người bệnh nếu dữ kiện đó không nằm trong đoạn bằng chứng."
)