Remove chat rate limit

This commit is contained in:
2026-08-14 11:57:58 +07:00
parent d5ea65cd6e
commit 9be5819710
8 changed files with 260 additions and 40 deletions
+14 -1
View File
@@ -335,7 +335,20 @@ class RagAgent:
),
turn_type=tt,
)
if frame.condition_relation != ConditionRelation.INDICATION:
# UNKNOWN is treated as INDICATION here, not as a third rejection
# state: turn_type is already condition_to_drug/symptom_to_drug at
# this point, which only exists because the turn was read as
# asking which drug treats the condition -- that already settles
# the direction. Only an explicit reverse-relation reading
# (ADVERSE_EFFECT/CONTRAINDICATION) should abstain here; UNKNOWN
# is the model hedging on an ordinary question, not a genuine
# reverse-relation query (found live 2026-08-13: bare "X thì dùng
# thuốc gì" turns were reliably classified with the right
# turn_type but condition_relation="unknown", incorrectly
# aborting a plain treatment-lookup question).
if frame.condition_relation in (
ConditionRelation.ADVERSE_EFFECT, ConditionRelation.CONTRAINDICATION,
):
return AgentReply(
"abstain",
"unsupported_reverse_relation",
+26 -3
View File
@@ -30,7 +30,7 @@ from .routing import QueryRoutingService
# be different prompts (or a different judge) to be independent evidence —
# see this function's own reasoning above.
_QUICK_REPLY_MAX_ITEMS = 4
_QUICK_REPLY_MAX_ITEMS = 18 # one per monograph section (see rag/sections.py SECTION_ORDER)
_QUICK_REPLY_MAX_CHARS = 40
logger = logging.getLogger(__name__)
@@ -207,6 +207,29 @@ class _VerificationOutcome:
missing: tuple[str, ...] = ()
# Display-only label override, 2026-08-13: these 3 of 684 catalog drug_ids are
# missing the letter for "Đ"/"đ" entirely (ingestion's slug generator drops it
# instead of mapping it to "d" like every other Vietnamese diacritic), so
# `drug_id.replace("_", " ").upper()` can never reconstruct the accented name
# and the fold-based dedup below always mismatches for them. Does not touch
# drug_id or any stored data — only the label text shown in generated answers.
_DRUG_LABEL_OVERRIDES: dict[str, str] = {
"giai_oc_to_uon_van_hap_phu_vac_xin_uon_van_hap_phu":
"GIẢI ĐỘC TỐ UỐN VÁN HẤP PHỤ (VẮC XIN UỐN VÁN HẤP PHỤ)",
"khang_oc_to_bach_hau": "KHÁNG ĐỘC TỐ BẠCH HẦU",
"thuoc_uong_bu_nuoc_va_ien_giai": "THUỐC UỐNG BÙ NƯỚC VÀ ĐIỆN GIẢI",
}
def _fold_diacritics(text: str) -> str:
"""Accent-insensitive fold. Vietnamese "Đ"/"đ" is not a combining-mark
decomposition under NFKD (unlike every other Vietnamese diacritic), so it
survives the strip below unless mapped explicitly first."""
text = text.replace("Đ", "D").replace("đ", "d")
stripped = unicodedata.normalize("NFKD", text)
return "".join(ch for ch in stripped if not unicodedata.combining(ch)).casefold()
def _parse_claims(
raw_claims: list, *, include_drug_label: bool = False
) -> tuple[tuple[str, tuple[int, ...]], ...] | None:
@@ -230,8 +253,8 @@ def _parse_claims(
drug_id = item.get("drug_id")
if not isinstance(drug_id, str) or not drug_id.strip():
return None
label = drug_id.replace("_", " ").upper()
if label.casefold() not in cleaned.casefold():
label = _DRUG_LABEL_OVERRIDES.get(drug_id) or drug_id.replace("_", " ").upper()
if _fold_diacritics(label) not in _fold_diacritics(cleaned):
cleaned = f"{label}: {cleaned}"
claims.append((cleaned, tuple(citations)))
return tuple(claims)
+104 -18
View File
@@ -50,9 +50,11 @@ from .clinical import (
RenalContext,
)
from .ports import AnswerGenerationUnavailable
from .sections import SectionResolver
from .text import normalize_name
logger = logging.getLogger(__name__)
_SECTION_RESOLVER = SectionResolver()
# The 19 monograph section keys, kept here as the closed vocabulary the model may
# use for `attribute`. Adding a new section is one entry, not a code change.
@@ -208,8 +210,15 @@ FRAME_SCHEMA = {
},
"condition_relation": (
"indication | adverse_effect | contraindication | unknown. "
"'thuốc nào gây X' is adverse_effect; 'thuốc nào chống chỉ định ở X' "
"is contraindication, never indication"
"DEFAULT is 'indication' — use it for the ordinary, most common case: "
"any question asking which drug to use/take/treat a condition or "
"symptom with (e.g. 'X thì dùng thuốc gì', 'bị X uống thuốc gì', "
"'thuốc trị X', 'thuốc chữa X'). Only deviate from 'indication' when "
"the question ITSELF contains an explicit reverse-direction phrase: "
"'thuốc nào gây X' -> adverse_effect; 'thuốc nào chống chỉ định ở X' -> "
"contraindication. Do not pick 'unknown' for an ordinary treatment "
"question just to hedge — 'unknown' is only for text that is not "
"readable as any of the other three at all."
),
"patient_context": {
"age_text": "age exactly as stated, else null",
@@ -285,7 +294,7 @@ _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_ITEMS = 18 # one per monograph section (see rag/sections.py SECTION_ORDER)
_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.
@@ -299,17 +308,24 @@ Quy tắc bắt buộc:
- Sai chính tả một thuốc CÓ trong danh sách thì sửa về đúng drug_id của nó
(ví dụ "amoxicillin" -> "amoxicilin", "metfomin" -> "metformin").
- Nếu câu nhắc 2 thuốc trở lên và hỏi về dùng chung/tương tác -> turn_type="interaction".
- Nếu là BỆNH/CONDITION đã nêu và hỏi thuốc nào có chỉ định điều trị ->
"condition_to_drug", điền `condition`, `condition_relation="indication"`.
Có thể dùng "symptom_to_drug" cho triệu chứng chưa phải chẩn đoán; không đánh
đồng triệu chứng với bệnh đã chẩn đoán.
- MẶC ĐỊNH cho một BỆNH/TRIỆU CHỨNG đã nêu (không nhắc tên thuốc): câu hỏi
đang hỏi THUỐC NÀO DÙNG ĐỂ ĐIỀU TRỊ nó -> "condition_to_drug" (bệnh đã chẩn
đoán) hoặc "symptom_to_drug" (triệu chứng chưa phải chẩn đoán), điền
`condition`, `condition_relation="indication"`. Đây là cách đọc MẶC ĐỊNH —
mọi cách diễn đạt kiểu "bị/mắc [bệnh] thì/nên dùng/uống thuốc gì", "thuốc gì
trị/chữa [bệnh]", "[bệnh] uống thuốc gì" đều thuộc nhánh này, kể cả khi
không có từ "chỉ định". Không đánh đồng triệu chứng với bệnh đã chẩn đoán.
- Nếu hỏi một THUỐC đã nêu được chỉ định cho bệnh gì -> "drug_to_condition",
attribute="chi_dinh". Đây là chiều ngược với condition_to_drug.
- Phân biệt QUAN HỆ: "thuốc nào GÂY tăng huyết áp" ->
turn_type="condition_relation", condition_relation="adverse_effect"; "thuốc
nào CHỐNG CHỈ ĐỊNH ở bệnh nhân gout" -> "condition_relation",
condition_relation="contraindication". TUYỆT ĐỐI không gán hai câu này thành
condition_to_drug/indication.
- NGOẠI LỆ DUY NHẤT khỏi mặc định ở trên — turn_type="condition_relation"
là câu hỏi tra NGƯỢC từ một bệnh/biến cố sang danh sách thuốc: "thuốc nào
GÂY tăng huyết áp", "thuốc nào CHỐNG CHỈ ĐỊNH ở bệnh nhân gout". Cụm
"chống chỉ định" tự nó KHÔNG đủ để chọn nhánh này: khi đã nêu một thuốc làm
đối tượng tra cứu, ví dụ "Chống chỉ định của Paracetamol là gì?" hoặc
"Probenecid có chống chỉ định gì?", phải là turn_type="drug_attribute",
attribute="chong_chi_dinh". Tương tự, "tác dụng không mong muốn của X"
thuộc tính của thuốc X, không phải tra ngược. Chỉ gán condition_relation khi
chiều hỏi thực sự là bệnh/biến cố -> thuốc.
- Chuẩn hoá condition bảo thủ: "cao huyết áp"/"THA" -> "tăng huyết áp" khi
chắc chắn; giữ nguyên viết tắt mơ hồ. "Viêm gan", "ung thư", "nhiễm trùng"
không có subtype/vị trí là mơ hồ đáng kể -> ambiguous=true và hỏi làm rõ.
@@ -374,7 +390,18 @@ Quy tắc bắt buộc:
- 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
(vd hỏi cân nặng chính xác) — không bịa phương án dạng số."""
(vd hỏi cân nặng chính xác) — không bịa phương án dạng số.
- NGOẠI LỆ về số lượng: khi CÂU HỎI đã nêu đúng MỘT thuốc cụ thể (drugs có đúng
một phần tử) nhưng KHÔNG nêu mục/thuộc tính nào (attribute=null, không hỏi
liều/chỉ định/tương tác/... cụ thể) — tức người dùng chỉ gõ tên thuốc — thì
"quick_replies" liệt kê TOÀN BỘ các mục chuyên luận của Dược thư, không giới
hạn ở 2-4: "Tên chung quốc tế", "Mã ATC", "Loại thuốc", "Dạng thuốc và hàm
lượng", "Dược lý và cơ chế tác dụng", "Chỉ định", "Chống chỉ định", "Thận
trọng", "Thời kỳ mang thai", "Thời kỳ cho con bú", "Tác dụng không mong
muốn", "Hướng dẫn xử trí ADR", "Liều lượng và cách dùng", "Tương tác thuốc",
"Độ ổn định và bảo quản", "Tương kỵ", "Quá liều và xử trí", "Thông tin quy
chế" — nguyên văn đúng 18 nhãn này, đúng thứ tự, không rút gọn, không tự
đổi chữ."""
class JsonLlm(Protocol):
@@ -471,6 +498,14 @@ class LlmQueryUnderstander:
budget: RequestBudget | None = None,
prior_frame: QueryFrame | None = None,
) -> QueryFrame:
turn_resolution = self._resolver.resolve(turn) if turn.strip() else None
resolved_turn_drug = (
turn_resolution.drug_id
if turn_resolution is not None
and turn_resolution.status == "resolved"
and turn_resolution.drug_id in self._catalog
else None
)
shown = {
drug_id: self._catalog[drug_id]
for drug_id in self._candidate_ids(turn, history)
@@ -534,7 +569,14 @@ class LlmQueryUnderstander:
frame, turn, self._condition_normalizer
)
frame = _apply_reverse_relation_cues(frame, turn)
frame = _apply_named_drug_cues(frame, turn)
section_match = _SECTION_RESOLVER.resolve(turn)
frame = _apply_named_drug_cues(
frame,
turn,
resolved_turn_drug=resolved_turn_drug,
resolved_section_key=(section_match.section_key if section_match else None),
resolved_section_phrase=(section_match.phrase if section_match else None),
)
return _merge_with_prior_frame(frame, prior_frame)
@staticmethod
@@ -735,23 +777,66 @@ def _apply_broad_condition_cue(
)
def _apply_named_drug_cues(frame: QueryFrame, turn: str) -> QueryFrame:
def _apply_named_drug_cues(
frame: QueryFrame,
turn: str,
resolved_turn_drug: str | None = None,
resolved_section_key: str | None = None,
resolved_section_phrase: str | None = None,
) -> QueryFrame:
"""A drug explicitly named as subject outranks reverse-condition wording."""
if not frame.drugs:
subject_drugs = frame.drugs or (
(resolved_turn_drug,) if resolved_turn_drug else ()
)
if not subject_drugs:
return frame
text = f" {normalize_name(turn)} "
explicit_reverse = any(
cue in text
for cue in (
" thuoc nao chong chi dinh ",
" nhung thuoc nao chong chi dinh ",
" thuoc nao can tranh o ",
" thuoc nao can tranh cho ",
" thuoc nao gay ",
" thuoc nao co the gay ",
" thuoc nao lam tang ",
" thuoc nao co adr ",
)
)
owned_section = bool(
resolved_turn_drug
and resolved_section_key
and resolved_section_phrase
and f" {normalize_name(resolved_section_phrase)} cua " in text
)
if owned_section and not explicit_reverse:
return replace(
frame,
turn_type="drug_attribute",
drugs=(resolved_turn_drug,),
attribute=resolved_section_key,
needs_clarify=False,
clarify_reason=None,
quick_replies=(),
)
purpose = (
" co tac dung gi " in text
and " tac dung khong mong muon " not in text
) or " dung de lam gi " in text
contraindication = (
" co chong chi dinh " in text
contraindication = not explicit_reverse and (
frame.attribute == "chong_chi_dinh"
or " chong chi dinh cua " in text
or " co chong chi dinh " in text
or " chong chi dinh gi " in text
or " chong chi dinh nao " in text
or " co dung duoc khong " in text
)
if purpose:
return replace(
frame,
turn_type="drug_to_condition",
drugs=subject_drugs,
attribute="chi_dinh",
condition_relation=ConditionRelation.INDICATION,
needs_clarify=False,
@@ -762,6 +847,7 @@ def _apply_named_drug_cues(frame: QueryFrame, turn: str) -> QueryFrame:
return replace(
frame,
turn_type="drug_attribute",
drugs=subject_drugs,
attribute="chong_chi_dinh",
needs_clarify=False,
clarify_reason=None,
@@ -15,6 +15,7 @@ from rag.clinical import (
RenalContext,
)
from rag.models import Evidence, SourceRef
from rag.sections import SECTION_PHRASES
from rag.understanding import (
LlmQueryUnderstander,
QueryFrame,
@@ -173,12 +174,57 @@ def test_named_drug_safety_and_purpose_cues_override_noisy_relation_frames():
replace(noisy, drugs=("paracetamol_acetaminophen",)),
"Paracetamol có tác dụng gì?",
)
golden_contraindication = _apply_named_drug_cues(
replace(noisy, drugs=("paracetamol_acetaminophen",)),
"Chống chỉ định của Paracetamol là gì?",
)
golden_contraindication_with_omitted_llm_drug = _apply_named_drug_cues(
replace(noisy, drugs=()),
"Chống chỉ định của Paracetamol là gì?",
resolved_turn_drug="paracetamol_acetaminophen",
)
reverse_contraindication = _apply_named_drug_cues(
replace(noisy, drugs=("warfarin",)),
"Thuốc nào chống chỉ định ở bệnh nhân đang dùng warfarin?",
)
assert safety.turn_type == "drug_attribute"
assert safety.attribute == "chong_chi_dinh"
assert safety.needs_clarify is False
assert purpose.turn_type == "drug_to_condition"
assert purpose.attribute == "chi_dinh"
assert golden_contraindication.turn_type == "drug_attribute"
assert golden_contraindication.attribute == "chong_chi_dinh"
assert golden_contraindication.condition_relation == ConditionRelation.CONTRAINDICATION
assert golden_contraindication_with_omitted_llm_drug.turn_type == "drug_attribute"
assert golden_contraindication_with_omitted_llm_drug.drugs == (
"paracetamol_acetaminophen",
)
assert reverse_contraindication.turn_type == "condition_relation"
def test_owned_section_phrase_routes_every_monograph_section_without_llm_help():
noisy = QueryFrame(
turn_type="condition_relation",
condition_relation=ConditionRelation.CONTRAINDICATION,
needs_clarify=True,
clarify_reason="Cần làm rõ",
)
for section_key, phrases in SECTION_PHRASES.items():
phrase = phrases[0]
corrected = _apply_named_drug_cues(
noisy,
f"{phrase} của Paracetamol là gì?",
resolved_turn_drug="paracetamol_acetaminophen",
resolved_section_key=section_key,
resolved_section_phrase=phrase,
)
assert corrected.turn_type == "drug_attribute", section_key
assert corrected.drugs == ("paracetamol_acetaminophen",), section_key
assert corrected.attribute == section_key
assert corrected.needs_clarify is False
def test_ambiguous_condition_clarifies_before_retrieval():
+34 -2
View File
@@ -86,6 +86,30 @@ def test_drug_id_in_exact_underscore_form_resolves():
assert frame.unknown_drugs == ()
def test_golden_named_drug_section_overrides_a_misclassified_relation_frame():
"""Regression for the production failure observed through the real UI."""
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "condition_relation",
"drugs": [],
"unknown_drugs": [],
"attribute": None,
"population": None,
"weight_kg": None,
"age_text": None,
"indication": None,
"condition_relation": "contraindication",
"needs_clarify": False,
"clarify_reason": None,
}), CATALOG, RESOLVER)
frame = understander.understand("Chống chỉ định của Paracetamol là gì?")
assert frame.turn_type == "drug_attribute"
assert frame.drugs == ("paracetamol_acetaminophen",)
assert frame.attribute == "chong_chi_dinh"
assert frame.needs_clarify is False
def test_exact_candidate_does_not_repeat_the_catalog_wide_fuzzy_scan():
resolver = _FakeResolver({"metformin": "metformin"})
understander = LlmQueryUnderstander(_FixedLlm({
@@ -220,6 +244,11 @@ def test_quick_replies_are_parsed_when_the_model_offers_them():
def test_quick_replies_are_dynamic_but_bounded_before_becoming_ui_chips():
# 19 distinct valid entries (after " Người lớn " / "người lớn" dedup
# and the non-string 12 are dropped) so the 18-item cap — one per
# monograph section, see rag/sections.py SECTION_ORDER — still trims
# the last one, not just the old 4-item cap.
extra = [f"Lựa chọn {i}" for i in range(6, 20)]
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "dosing_calc", "drugs": ["paracetamol_acetaminophen"],
"unknown_drugs": [], "attribute": None, "population": None,
@@ -228,14 +257,17 @@ def test_quick_replies_are_dynamic_but_bounded_before_becoming_ui_chips():
"quick_replies": [
" Người lớn ", "người lớn", "Trẻ em", 12,
"Phụ nữ có thai", "Người cao tuổi", "Lựa chọn thứ năm",
*extra,
],
}), CATALOG, RESOLVER)
frame = understander.understand("liều paracetamol")
assert frame.quick_replies == (
"Người lớn", "Trẻ em", "Phụ nữ có thai", "Người cao tuổi"
assert len(frame.quick_replies) == 18
assert frame.quick_replies[:5] == (
"Người lớn", "Trẻ em", "Phụ nữ có thai", "Người cao tuổi", "Lựa chọn thứ năm"
)
assert "Lựa chọn 19" not in frame.quick_replies
def test_string_false_does_not_turn_into_a_clarification():