Remove chat rate limit
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" là
|
||||
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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -14,6 +14,11 @@ RUN pnpm install --frozen-lockfile
|
||||
FROM deps AS build
|
||||
COPY packages/ packages/
|
||||
COPY apps/web/ apps/web/
|
||||
# app/api/pdf/route.ts resolves the source PDF from a full monorepo checkout
|
||||
# (process.cwd()/../../ingestion/data/raw/...) — this image only has apps/web
|
||||
# and packages/ copied in, so the file is baked in here at the same relative
|
||||
# path the route already expects.
|
||||
COPY ingestion/data/raw/duoc-thu-quoc-gia-viet-nam-2018.pdf ingestion/data/raw/duoc-thu-quoc-gia-viet-nam-2018.pdf
|
||||
RUN pnpm --filter @duoc-thu/web build
|
||||
|
||||
FROM base AS runtime
|
||||
|
||||
+4
-15
@@ -4,11 +4,9 @@ import type { NextRequest } from "next/server";
|
||||
/**
|
||||
* Rate limiting for the public API surface.
|
||||
*
|
||||
* `/api/chat` is reachable by anyone on the internet, takes no credentials,
|
||||
* and spends AWS Bedrock credit on every call (understanding + generation +
|
||||
* entailment, several model calls per turn) against a small personal budget.
|
||||
* The architecture assigns rate limiting to `api-gateway`, which is not built
|
||||
* yet, so until it exists this is the only place the limit can live.
|
||||
* yet, so until it exists this is where limits for the remaining bounded API
|
||||
* routes live. `/api/chat` is intentionally unlimited.
|
||||
*
|
||||
* It runs here rather than inside the route handlers because the edge
|
||||
* middleware rejects an abusive request before any handler work — and because
|
||||
@@ -37,18 +35,9 @@ interface Rule {
|
||||
max: number;
|
||||
}
|
||||
|
||||
// Chat is the expensive path: several Bedrock calls per request, and a single
|
||||
// turn was measured taking up to ~45s of model time. Autocomplete is a local
|
||||
// catalog lookup with no model call, so it can be far more generous without
|
||||
// costing anything.
|
||||
// `/api/chat` is intentionally absent, so chat requests pass through without
|
||||
// rate limiting. Autocomplete is a local catalog lookup with no model call.
|
||||
const RULES: Array<{ prefix: string; rules: Rule[] }> = [
|
||||
{
|
||||
prefix: "/api/chat",
|
||||
rules: [
|
||||
{ windowMs: 60_000, max: 12 },
|
||||
{ windowMs: 3_600_000, max: 120 },
|
||||
],
|
||||
},
|
||||
{
|
||||
prefix: "/api/suggest",
|
||||
rules: [{ windowMs: 60_000, max: 120 }],
|
||||
|
||||
@@ -19,6 +19,32 @@ import {
|
||||
import { cn } from "./lib/utils";
|
||||
import { citationSectionLabel } from "./CitationCard";
|
||||
|
||||
// Display-only patch, 2026-08-13: exactly 3 of 684 catalog entries have "Đ"/
|
||||
// "đ" in their canonical name (ingestion's slug generator maps every other
|
||||
// Vietnamese diacritic to its base Latin letter, but drops "Đ"/"đ" entirely
|
||||
// instead of mapping it to "D"/"d" — it is not a combining-mark decomposition
|
||||
// under Unicode NFKD, so a generic accent-strip silently loses it). Fixing
|
||||
// the underlying drug_id would mean re-keying every citation chunk_id built
|
||||
// from it in the already-loaded Qdrant corpus, so this only corrects the
|
||||
// header label shown to the user, not the data. Verified against the full
|
||||
// catalog (`ingestion/data/verified/drug_entities.json`): these are the only
|
||||
// 3 affected ids.
|
||||
const DRUG_ID_DISPLAY_OVERRIDES: Record<string, string> = {
|
||||
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",
|
||||
};
|
||||
|
||||
function formatResolvedDrugId(resolvedDrugId: string): string {
|
||||
return resolvedDrugId
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean)
|
||||
.map((id) => DRUG_ID_DISPLAY_OVERRIDES[id] ?? id.replace(/_/g, " ").toUpperCase())
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
interface ChatBubbleProps {
|
||||
message: ChatMessage;
|
||||
// `allCitations` is THIS message's own citation list (`message.citations`)
|
||||
@@ -256,7 +282,7 @@ export function ChatBubble({
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-txt-secondary">
|
||||
{message.resolvedDrugId ? message.resolvedDrugId.replace(/_/g, " ").toUpperCase() : "Trợ lý Dược thư"}
|
||||
{message.resolvedDrugId ? formatResolvedDrugId(message.resolvedDrugId) : "Trợ lý Dược thư"}
|
||||
</span>
|
||||
{message.decision === "answerable" && message.grounded !== false && (
|
||||
<span className="inline-flex items-center gap-1 text-[0.68rem] font-medium text-status-success">
|
||||
|
||||
Reference in New Issue
Block a user