Log the 2026-08-24 session: F3 fix live, audit filled, corpus re-ingest scoped

This commit is contained in:
2026-08-24 15:07:30 +07:00
parent f3eaab0948
commit 33b16c885b
7 changed files with 575 additions and 11 deletions
+77 -7
View File
@@ -203,6 +203,18 @@ class QueryFrame:
# ordinary clarifying question in the API response and trace — this lets
# `RagAgent._route` surface the real cause instead.
system_error: str | None = None
# The part of the turn the Dược thư cannot answer AT ALL, as the model
# named it — a property the book does not record (giá, nơi bán, bảo hiểm)
# or a comparative judgement it never makes ("hãng nào tốt nhất"). Set
# means refuse, and `_parse` forces `turn_type` to "out_of_scope" on it.
#
# Why a field and not another phrase list: `attribute` is validated against
# SECTION_KEYS and anything unrecognised collapses to None, which made
# "user asked for a section but did not say which" and "user asked for
# something the book has no section for" indistinguishable — both became
# `attribute=None` and both clarified. Found live 2026-08-24: "Paracetamol
# giá bao nhiêu?" answered "Bạn muốn hỏi liều cho người lớn hay trẻ em?".
unsupported_request: str | None = None
raw: dict = field(default_factory=dict, compare=False)
@@ -213,6 +225,19 @@ FRAME_SCHEMA = {
"drugs": ["drug_id exactly as it appears in the provided catalog list"],
"unknown_drugs": ["a drug name the user mentioned that is NOT in the catalog"],
"attribute": "one of the section keys provided, or null",
"unsupported_request": (
"Null in the ordinary case. Set it ONLY when the turn asks for "
"something the Dược thư does not contain at all, and name that thing "
"briefly in Vietnamese. Two kinds qualify: (a) a commercial or "
"administrative property the book never records — giá/giá tiền, nơi "
"bán/mua ở đâu, bảo hiểm chi trả, hạn dùng của một hộp cụ thể; (b) a "
"comparative or evaluative judgement the book never makes — 'hãng nào "
"tốt nhất', 'thuốc nào hay hơn', 'nên chọn loại nào'. "
"IMPORTANT — do NOT set it for trade names: the monograph HAS a "
"'Tên thương mại' section, so 'Paracetamol của hãng nào', 'biệt dược "
"của X' are ordinary in-scope lookups (attribute=ten_thuong_mai). "
"Only RANKING trade names is unsupported, not listing them."
),
"population": "tre_em | tre_so_sinh | nguoi_lon | nguoi_cao_tuoi | phu_nu_co_thai | phu_nu_cho_con_bu | suy_than | suy_gan | null",
"weight_kg": (
"number if a body weight is given, else null. Vietnamese casual speech "
@@ -412,6 +437,10 @@ Quy tắc bắt buộc:
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".
- Nếu câu hỏi nhắm vào thứ Dược thư không ghi (giá tiền, nơi mua, bảo hiểm) hoặc
đòi xếp hạng hơn kém ("hãng nào tốt nhất", "thuốc nào hay hơn"), đặt
"unsupported_request" nêu ngắn gọn thứ đó. Tên biệt dược CÓ trong sách (mục
"Tên thương mại"), nên hỏi biệt dược là hợp lệ — không đặt unsupported_request.
- 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
@@ -658,6 +687,24 @@ class LlmQueryUnderstander:
if turn_type not in TURN_TYPES:
turn_type = "drug_attribute" if drugs else "out_of_scope"
needs_clarify = data.get("needs_clarify") is True
# The scope gate, applied deterministically rather than trusted to the
# model's own `turn_type`. When the turn asks for something the book
# does not contain, refusing is the only correct outcome — Feature-List
# F3 requires 100% of out-of-scope turns to be refused, and the failure
# this fixes was precisely the model saying `drug_attribute` while
# leaving `attribute` null, which downstream read as "which section did
# you mean?" and asked the user a question the book cannot answer.
#
# Deliberately unconditional: it fires even when a valid `attribute`
# was also parsed. A turn mixing an answerable section with an
# unanswerable property ("giá bao nhiêu và liều người lớn?") is refused
# whole rather than half-answered. Over-refusing is the safe direction
# for a safety threshold; the 90-case suite is the guard against
# over-refusing in practice.
unsupported_request = _clean_str(data.get("unsupported_request"))
if unsupported_request:
turn_type = "out_of_scope"
needs_clarify = False
clarify_reason = _clean_str(data.get("clarify_reason"))
quick_replies = (
_clean_quick_replies(data.get("quick_replies"))
@@ -700,6 +747,7 @@ class LlmQueryUnderstander:
needs_clarify=needs_clarify,
clarify_reason=clarify_reason,
quick_replies=quick_replies,
unsupported_request=unsupported_request,
raw=data if isinstance(data, dict) else {},
)
@@ -750,6 +798,17 @@ def _apply_reverse_relation_cues(frame: QueryFrame, turn: str) -> QueryFrame:
)
# Shared with `_apply_condition_candidate_cue` below: a turn naming these
# signals is describing one patient's own combined profile ("BN X kèm Y"),
# not asking the model to pick between unrelated conditions.
_PATIENT_CONTEXT_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 ",
)
def _apply_condition_candidate_cue(
frame: QueryFrame, turn: str, normalizer: ConditionNormalizer
) -> QueryFrame:
@@ -774,6 +833,23 @@ def _apply_condition_candidate_cue(
)
if not any(cue in text for cue in candidate_cues):
return frame
if condition.ambiguous and any(cue in text for cue in _PATIENT_CONTEXT_CUES):
# Found live 2026-08-20 (eval case P08): "BN tăng huyết áp kèm xơ gan
# Child-Pugh B dùng thuốc nào cần lưu ý?" reliably clarified instead
# of answering, 4/4 reproductions. The raw understanding call reads
# a comorbidity ("kèm xơ gan...") as a FORK in what the question
# means ("thuốc nào cần lưu ý" vs "thuốc nào gây tăng huyết áp") and
# marks the condition ambiguous with its own clarify_question — but
# this turn already told us which drug lane it wants (a candidate
# cue matched, e.g. "thuốc nào cần"), so the fork the model raised
# is not genuine: `_apply_general_condition_scope` already treats
# this same cue set as "this is one patient's profile, not a choice
# between diseases", and `frame.patient_context` (hepatic/renal/etc,
# parsed separately and left untouched here) is exactly what lets
# `_condition_to_drug`'s `assess_patient_candidates` answer safely
# instead — clearing the stale ambiguity is what lets a turn reach
# that path instead of dead-ending in a clarify loop.
condition = replace(condition, ambiguous=False, clarify_question=None)
return replace(
frame,
turn_type="condition_to_drug",
@@ -799,13 +875,7 @@ def _apply_general_condition_scope(frame: QueryFrame, turn: str) -> QueryFrame:
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):
if any(cue in text for cue in _PATIENT_CONTEXT_CUES):
return frame
primary = (
frame.condition.normalized_condition