From 9be58197101784ccaca9a28d56dc190746dfed95 Mon Sep 17 00:00:00 2001 From: BaoVu2k4 Date: Fri, 14 Aug 2026 11:57:58 +0700 Subject: [PATCH] Remove chat rate limit --- apps/ai-service/rag/agent.py | 15 ++- apps/ai-service/rag/answer.py | 29 ++++- apps/ai-service/rag/understanding.py | 122 +++++++++++++++--- .../tests/test_clinical_condition_flow.py | 46 +++++++ apps/ai-service/tests/test_understanding.py | 36 +++++- apps/web/Dockerfile | 5 + apps/web/middleware.ts | 19 +-- packages/ui/src/ChatBubble.tsx | 28 +++- 8 files changed, 260 insertions(+), 40 deletions(-) diff --git a/apps/ai-service/rag/agent.py b/apps/ai-service/rag/agent.py index 404ea65..0dbd885 100644 --- a/apps/ai-service/rag/agent.py +++ b/apps/ai-service/rag/agent.py @@ -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", diff --git a/apps/ai-service/rag/answer.py b/apps/ai-service/rag/answer.py index 920815f..cb42dc1 100644 --- a/apps/ai-service/rag/answer.py +++ b/apps/ai-service/rag/answer.py @@ -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) diff --git a/apps/ai-service/rag/understanding.py b/apps/ai-service/rag/understanding.py index 53bee55..a7c15a5 100644 --- a/apps/ai-service/rag/understanding.py +++ b/apps/ai-service/rag/understanding.py @@ -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, diff --git a/apps/ai-service/tests/test_clinical_condition_flow.py b/apps/ai-service/tests/test_clinical_condition_flow.py index c5bfdce..e9cd020 100644 --- a/apps/ai-service/tests/test_clinical_condition_flow.py +++ b/apps/ai-service/tests/test_clinical_condition_flow.py @@ -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(): diff --git a/apps/ai-service/tests/test_understanding.py b/apps/ai-service/tests/test_understanding.py index a6d107d..12bfa92 100644 --- a/apps/ai-service/tests/test_understanding.py +++ b/apps/ai-service/tests/test_understanding.py @@ -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(): diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 060e286..ae20679 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -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 diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 7352860..d5f5362 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -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 }], diff --git a/packages/ui/src/ChatBubble.tsx b/packages/ui/src/ChatBubble.tsx index a2a628e..27d1e03 100644 --- a/packages/ui/src/ChatBubble.tsx +++ b/packages/ui/src/ChatBubble.tsx @@ -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 = { + 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({ - {message.resolvedDrugId ? message.resolvedDrugId.replace(/_/g, " ").toUpperCase() : "Trợ lý Dược thư"} + {message.resolvedDrugId ? formatResolvedDrugId(message.resolvedDrugId) : "Trợ lý Dược thư"} {message.decision === "answerable" && message.grounded !== false && (