Remove corpus counts from chat chrome

This commit is contained in:
2026-08-10 17:26:58 +07:00
parent 46469468bb
commit 97cb6d16f4
31 changed files with 2192 additions and 424 deletions
+106 -10
View File
@@ -133,6 +133,17 @@ class QueryFrame:
age_text: str | None = None
indication: str | None = None # symptom/disease, for symptom_to_drug
route: str | None = None # e.g. "uong", "tiem_tinh_mach", "dat_truc_trang"
# True when the user is asking to survey/summarise a whole named section
# (for example all ADRs, precautions, or dosage regimens), rather than
# asking for one regimen to apply to a particular patient. This is a
# materially different sufficiency contract: a section survey must label
# every branch, while a patient-specific dose may need clarification.
section_overview: bool = False
# Self-contained meaning used downstream. It rewrites references/omitted
# subjects only; retrieval still trusts the separately validated drug ids,
# facet and constraints below rather than parsing authority back from text.
standalone_query: str | None = None
depends_on_previous_turn: bool = False
needs_clarify: bool = False
clarify_reason: str | None = None
# Short suggested replies for `clarify_reason` (e.g. ("Người lớn", "Trẻ
@@ -174,6 +185,19 @@ FRAME_SCHEMA = {
"'uống' or 'tiêm' to your own prior clarify question about route IS "
"this field — read it here, do not leave it null and re-ask."
),
"section_overview": (
"true when the user asks for a general survey of the requested section "
"(all ADRs/interactions/precautions/dose regimens, 'theo Dược thư', "
"'nếu có'), not one dose/decision for a specific patient"
),
"standalone_query": (
"self-contained Vietnamese question after resolving pronouns/omitted subject "
"from history; do not answer it. For a self-contained current turn, copy its "
"meaning without adding facts"
),
"depends_on_previous_turn": (
"true only when standalone_query needed entity/facet/constraints from history"
),
"needs_clarify": "true only if the turn cannot be acted on without more info",
"clarify_reason": "short Vietnamese question to ask, or null",
"quick_replies": (
@@ -187,6 +211,17 @@ FRAME_SCHEMA = {
),
}
_ALLOWED_POPULATIONS = {
"tre_em", "tre_so_sinh", "nguoi_lon", "nguoi_cao_tuoi",
"phu_nu_co_thai", "phu_nu_cho_con_bu", "suy_than", "suy_gan",
}
_ALLOWED_ROUTES = {
"uong", "tiem_tinh_mach", "tiem_bap", "tiem_duoi_da",
"dat_truc_trang", "boi_ngoai_da", "nho_mat", "nho_mui", "khac",
}
_QUICK_REPLY_MAX_ITEMS = 4
_QUICK_REPLY_MAX_CHARS = 40
_SYSTEM = """Bạn là bộ HIỂU CÂU HỎI cho một chatbot tra cứu Dược thư Quốc gia Việt Nam.
Nhiệm vụ: đọc câu hỏi (tiếng Việt, có thể sai chính tả, viết tắt, nhiều lượt) và
XUẤT RA một JSON mô tả ý định. TUYỆT ĐỐI KHÔNG trả lời câu hỏi y khoa, không nêu liều.
@@ -203,8 +238,24 @@ Quy tắc bắt buộc:
- Nếu hỏi liều cần cân nặng/tuổi -> "dosing_calc", điền weight_kg/age_text nếu có.
Nói cân nặng kiểu thường ngày ("bé 30 cân", "nặng 30 ký", chỉ 1 số + "cân"/""
không kèm đơn vị khác) NGHĨA LÀ 30 kg -> điền weight_kg=30, không bỏ trống.
- Với "dosing_calc", bộ hiểu câu hỏi chỉ bắt buộc làm rõ population và tuổi/cân
nặng nếu là trẻ em/trẻ sơ sinh. KHÔNG tự kết luận rằng route luôn bắt buộc: nếu
người dùng chưa nêu đường dùng thì giữ route=null; tầng trả lời sẽ nhìn chính
BẰNG CHỨNG truy xuất được để quyết định câu hỏi đã đủ rõ hay chưa. Nhờ vậy nếu
nguồn chỉ có một đường dùng phù hợp thì trả lời thẳng, còn nếu có nhiều nhánh
khác nhau mới hỏi lại và sinh quick_replies.
- Phân biệt tra cứu TOÀN MỤC với áp dụng cho MỘT NGƯỜI BỆNH. Nếu người dùng hỏi
tổng quan kiểu "Dược thư hướng dẫn dùng thế nào", "các ADR/tương tác/thận trọng
nào", "theo nhóm/tần suất", "nếu có" thì đặt section_overview=true và KHÔNG
hỏi người lớn/trẻ em/đường dùng chỉ để thu hẹp. Hãy trả về các nhánh trong sách
với nhãn rõ ràng. Chỉ đặt section_overview=false và hỏi thêm khi người dùng muốn
chọn một liều/quyết định áp dụng cho ca bệnh cụ thể.
- Lượt nối tiếp ("còn liều thì sao", "nó dùng cho trẻ em?") -> dùng LỊCH SỬ để biết
thuốc đang nói tới và điền vào "drugs".
thuốc đang nói tới và điền vào "drugs". Đồng thời viết `standalone_query` thành
câu có nghĩa độc lập (ví dụ "Còn chống chỉ định của levetiracetam?" ->
"Chống chỉ định của levetiracetam") và đặt `depends_on_previous_turn=true`.
- Nếu lượt hiện tại đã tự đủ nghĩa, `standalone_query` chỉ chuẩn hóa chính câu đó,
không chèn thuốc/đối tượng cũ và đặt `depends_on_previous_turn=false`.
- QUAN TRỌNG — lượt hiện tại trả lời câu hỏi bạn VỪA hỏi: nếu dòng "Trợ lý:" cuối
cùng trong LỊCH SỬ là một câu hỏi làm rõ (vd "Người lớn hay trẻ em?", "Uống hay
tiêm?", "Cân nặng bao nhiêu kg?"), và CÂU HỎI HIỆN TẠI là một câu trả lời ngắn
@@ -308,8 +359,16 @@ class LlmQueryUnderstander:
ids.add(resolution.drug_id)
elif resolution.status == "ambiguous":
ids.update(resolution.candidate_drug_ids)
for drug_id, _score in self._resolver.suggest(line, k=5, min_score=0.55):
ids.add(drug_id)
else:
# Exact/ambiguous resolution has already exhaustively scanned
# the aliases present in this line. Running the O(catalog)
# fuzzy pass as well only adds unrelated candidates and cost
# (~1s on the real catalog); fuzzy suggestions are useful only
# when no exact candidate was found (the typo path).
for drug_id, _score in self._resolver.suggest(
line, k=5, min_score=0.55
):
ids.add(drug_id)
return ids
def understand(
@@ -424,19 +483,29 @@ class LlmQueryUnderstander:
turn_type = data.get("turn_type")
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
clarify_reason = _clean_str(data.get("clarify_reason"))
quick_replies = (
_clean_quick_replies(data.get("quick_replies"))
if needs_clarify and clarify_reason
else ()
)
return QueryFrame(
turn_type=turn_type,
drugs=drugs,
unknown_drugs=tuple(dict.fromkeys(unknown)),
attribute=attribute,
population=_clean_str(data.get("population")),
population=_clean_enum(data.get("population"), _ALLOWED_POPULATIONS),
weight_kg=_clean_float(data.get("weight_kg")),
age_text=_clean_str(data.get("age_text")),
indication=_clean_str(data.get("indication")),
route=_clean_str(data.get("route")),
needs_clarify=bool(data.get("needs_clarify")),
clarify_reason=_clean_str(data.get("clarify_reason")),
quick_replies=tuple(_as_list(data.get("quick_replies"))),
route=_clean_enum(data.get("route"), _ALLOWED_ROUTES),
section_overview=data.get("section_overview") is True,
standalone_query=_clean_str(data.get("standalone_query")),
depends_on_previous_turn=data.get("depends_on_previous_turn") is True,
needs_clarify=needs_clarify,
clarify_reason=clarify_reason,
quick_replies=quick_replies,
raw=data if isinstance(data, dict) else {},
)
@@ -527,12 +596,39 @@ def _clean_str(value) -> str | None:
return None
def _clean_enum(value, allowed: set[str]) -> str | None:
cleaned = _clean_str(value)
return cleaned if cleaned in allowed else None
def _clean_quick_replies(value) -> tuple[str, ...]:
"""Keep the LLM's dynamic suggestions, but enforce the UI contract."""
if not isinstance(value, list):
return ()
replies: list[str] = []
seen: set[str] = set()
for item in value:
if not isinstance(item, str):
continue
reply = " ".join(item.split())
key = reply.casefold()
if not reply or len(reply) > _QUICK_REPLY_MAX_CHARS or key in seen:
continue
seen.add(key)
replies.append(reply)
if len(replies) >= _QUICK_REPLY_MAX_ITEMS:
break
return tuple(replies)
def _clean_float(value) -> float | None:
if isinstance(value, (int, float)):
return float(value)
parsed = float(value)
return parsed if 0 < parsed <= 500 else None
if isinstance(value, str):
try:
return float(value.replace(",", ".").split()[0])
parsed = float(value.replace(",", ".").split()[0])
return parsed if 0 < parsed <= 500 else None
except (ValueError, IndexError):
return None
return None