Wire up query history: localStorage session persistence + sidebar UI
This commit is contained in:
@@ -28,6 +28,7 @@ from .clinical import ConditionRelation, MedicationCandidateAssessment
|
||||
from .models import EvidenceDecision, RetrievalResult
|
||||
from .policy import looks_non_human
|
||||
from .service import RetrievalService
|
||||
from .text import normalize_name
|
||||
from .understanding import QueryFrame, QueryUnderstander
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -53,6 +54,27 @@ MAX_LLM_CALLS_PER_TURN = 8
|
||||
# turns on the same conversation, force a hard stop instead of asking again.
|
||||
MAX_CONSECUTIVE_CLARIFY = 4
|
||||
|
||||
# Feature-List #18/#19: "out_of_scope" used to cover two different things
|
||||
# with one identical message that named neither — a question about data
|
||||
# the formulary genuinely never contains for ANY drug (price, vendor,
|
||||
# brand availability), and a question with nothing to do with drugs at
|
||||
# all. Found live 2026-08-14 asking both "Paracetamol giá bao nhiêu?" and
|
||||
# "Thời tiết Hà Nội hôm nay?" and getting the same vague "nằm ngoài phần
|
||||
# chuyên luận... có thể thuộc phụ lục chưa được đưa vào" — which reads as
|
||||
# "maybe added later" for data that will never be in a drug formulary.
|
||||
# Deliberately a keyword heuristic, same trade-off as policy.py's
|
||||
# looks_non_human: cheap, auditable, no model call on the abstain path.
|
||||
_OUT_OF_SCOPE_DATA_PHRASES = (
|
||||
"gia bao nhieu", "gia ca", "gia tien", "bao nhieu tien",
|
||||
"mua o dau", "ban o dau", "nha thuoc nao ban",
|
||||
"thuong hieu nao", "hang san xuat", "nha san xuat",
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_missing_data_question(query: str) -> bool:
|
||||
normalized = normalize_name(query)
|
||||
return any(phrase in normalized for phrase in _OUT_OF_SCOPE_DATA_PHRASES)
|
||||
|
||||
|
||||
class ConversationStore(Protocol):
|
||||
"""Durable, cross-worker alternative to the in-process history dict —
|
||||
@@ -404,11 +426,23 @@ class RagAgent:
|
||||
turn_type=tt)
|
||||
|
||||
if tt == "out_of_scope":
|
||||
if _looks_like_missing_data_question(turn):
|
||||
return AgentReply(
|
||||
"abstain", "out_of_scope",
|
||||
answer="Dược thư Quốc gia Việt Nam KHÔNG chứa giá bán, nơi bán "
|
||||
"hay thương hiệu/nhà sản xuất cụ thể của thuốc — đây "
|
||||
"không phải nội dung sách này tra cứu (sách chỉ có chỉ "
|
||||
"định, liều dùng, chống chỉ định, tương tác thuốc và các "
|
||||
"mục chuyên môn khác). Vui lòng tra các nguồn khác cho "
|
||||
"thông tin này.",
|
||||
turn_type=tt)
|
||||
return AgentReply(
|
||||
"abstain", "out_of_scope",
|
||||
answer="Nội dung này nằm ngoài phần chuyên luận thuốc của Dược thư "
|
||||
"(có thể thuộc phần hướng dẫn chung/phụ lục chưa được đưa vào). "
|
||||
"Tôi chưa có dữ liệu để trả lời chính xác.",
|
||||
answer="Câu hỏi này nằm ngoài phạm vi hỗ trợ của hệ thống. Hệ "
|
||||
"thống chỉ tra cứu thông tin thuốc theo Dược thư Quốc gia "
|
||||
"Việt Nam 2018 — chỉ định, liều dùng, chống chỉ định, tác "
|
||||
"dụng phụ, tương tác thuốc và các mục chuyên môn khác của "
|
||||
"một thuốc cụ thể.",
|
||||
turn_type=tt)
|
||||
|
||||
if not frame.drugs:
|
||||
|
||||
@@ -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 = 18 # one per monograph section (see rag/sections.py SECTION_ORDER)
|
||||
_QUICK_REPLY_MAX_ITEMS = 19 # one per monograph section (see rag/sections.py SECTION_ORDER)
|
||||
_QUICK_REPLY_MAX_CHARS = 40
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -368,6 +368,39 @@ def _presentation_for_sources(source_ids: tuple[str, ...]) -> tuple[str, str]:
|
||||
return "Trả lời", "fact_list"
|
||||
|
||||
|
||||
# Feature-List #14: a condition/symptom -> drug reverse lookup
|
||||
# (`list_mode=True`, `RagAgent._condition_to_drug`) states which drugs the
|
||||
# formulary's `chi_dinh` sections name for this indication — a factual
|
||||
# lookup, never a treatment ranking (see
|
||||
# `[[feedback_no_recommendation_gate]]`) — but rendered as a bare drug list
|
||||
# it reads exactly like a recommendation. Fixed, non-generated block, same
|
||||
# guarantee as `DISCLAIMER` above: the model never sees or writes this text,
|
||||
# so it cannot reword, shorten, or drop it. Always the first block; `kind`
|
||||
# matches `_plan_answer`'s forced `needs_warning=True` for `list_mode`, so
|
||||
# the UI renders it with the same visual separation as any other warning
|
||||
# block, not a plain bullet.
|
||||
_LIST_MODE_NOTICE_BLOCK = AnswerBlock(
|
||||
title="Đọc cho đúng",
|
||||
kind="warning",
|
||||
claims=(
|
||||
AnswerClaim(
|
||||
text=(
|
||||
"Đây là kết quả TRA CỨU các thuốc mà Dược thư Quốc gia Việt "
|
||||
"Nam ghi nhận chỉ định cho triệu chứng/bệnh này, KHÔNG PHẢI "
|
||||
"khuyến nghị điều trị hay chỉ định lâm sàng cho một người "
|
||||
"bệnh cụ thể."
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _with_list_mode_notice(
|
||||
blocks: tuple[AnswerBlock, ...], list_mode: bool
|
||||
) -> tuple[AnswerBlock, ...]:
|
||||
return (_LIST_MODE_NOTICE_BLOCK, *blocks) if list_mode else blocks
|
||||
|
||||
|
||||
def _build_blocks(
|
||||
claims: tuple[tuple[str, tuple[int, ...]], ...],
|
||||
indexed: list[tuple[str, Citation]],
|
||||
@@ -437,7 +470,10 @@ def _plan_answer(query: str, result: RetrievalResult, list_mode: bool) -> Answer
|
||||
layout=layout,
|
||||
reasoning_mode="synthesis" if multi_source else "direct_lookup",
|
||||
show_heading=multi_source or verbosity == "detailed",
|
||||
needs_warning=any(section in _WARNING_SECTIONS for section in sections),
|
||||
# `list_mode` always carries `_LIST_MODE_NOTICE_BLOCK` (see below) —
|
||||
# this is what gives that block its actual warning presentation in
|
||||
# the UI, not just a plain untitled bullet.
|
||||
needs_warning=list_mode or any(section in _WARNING_SECTIONS for section in sections),
|
||||
)
|
||||
|
||||
|
||||
@@ -677,7 +713,7 @@ class GroundedAnswerService:
|
||||
(text, (index,))
|
||||
for index, text in enumerate(evidence_texts, start=1)
|
||||
)
|
||||
blocks = _build_blocks(claims, indexed)
|
||||
blocks = _with_list_mode_notice(_build_blocks(claims, indexed), list_mode)
|
||||
return GroundedAnswer(
|
||||
result,
|
||||
"\n".join(text for text, _ in claims),
|
||||
@@ -724,7 +760,7 @@ class GroundedAnswerService:
|
||||
if 1 <= index <= len(indexed)
|
||||
))
|
||||
citations = tuple(indexed[index - 1][1] for index in cited_indices)
|
||||
blocks = _build_blocks(outcome.claims, indexed)
|
||||
blocks = _with_list_mode_notice(_build_blocks(outcome.claims, indexed), list_mode)
|
||||
clean_answer = "\n".join(text for text, _ in outcome.claims)
|
||||
self._metrics.increment(metric_names.GENERATION_SERVED)
|
||||
return GroundedAnswer(
|
||||
|
||||
@@ -76,3 +76,12 @@ class SectionRetriever(Protocol):
|
||||
|
||||
class ParentStore(Protocol):
|
||||
def get(self, parent_id: str) -> ParentDocument | None: ...
|
||||
|
||||
|
||||
class SectionListRetriever(Protocol):
|
||||
"""The per-drug section checklist Feature-List #4 needs, cheap because
|
||||
`drug_id`/`section_key` are already-indexed Qdrant payload fields — no
|
||||
new indexing. Separate from `SectionRetriever` (interface segregation):
|
||||
this returns labels only, never chunk text."""
|
||||
|
||||
def list_sections(self, drug_id: str) -> list[tuple[str, str]]: ...
|
||||
|
||||
@@ -147,12 +147,27 @@ SECTION_PHRASES: dict[str, tuple[str, ...]] = {
|
||||
}
|
||||
|
||||
|
||||
# Book order of monograph sections (Hướng dẫn sử dụng, printed page 39). Used to
|
||||
# present a whole-drug overview when the query names the drug but no attribute —
|
||||
# typing "PARACETAMOL" should return the monograph, never a "specify an
|
||||
# attribute" dead-end.
|
||||
# Book order of monograph sections, verified 2026-08-14 against the actual
|
||||
# PDF (physical page 38 = printed page 39, "HƯỚNG DẪN SỬ DỤNG DƯỢC THƯ QUỐC
|
||||
# GIA VIỆT NAM"), not assumed from an earlier reading of this constant. The
|
||||
# guide numbers 19 items; item 1, "Tên chuyên luận thuốc", is the monograph's
|
||||
# own title/heading, not a content section with a `section_key` — items 2-19
|
||||
# are exactly these 18 keys, in exactly this order. Confirms this tuple was
|
||||
# already complete and correctly ordered for the book's own stated template.
|
||||
#
|
||||
# `ten_thuong_mai` (trade name) is real, present in the corpus (492/684
|
||||
# drugs) but is NOT one of the guide's 19 numbered items — the book's own
|
||||
# template never promises it, so there is no book-verified position to place
|
||||
# it at. Inserted right after `ten_chung_quoc_te` (generic/INN name) as the
|
||||
# most natural adjacency (same convention `understanding.py`'s `SECTION_KEYS`
|
||||
# already uses) — a judgment call, not a sourced fact, unlike the 18 above.
|
||||
#
|
||||
# Used to present a whole-drug overview when the query names the drug but no
|
||||
# attribute — typing "PARACETAMOL" should return the monograph, never a
|
||||
# "specify an attribute" dead-end.
|
||||
SECTION_ORDER: tuple[str, ...] = (
|
||||
"ten_chung_quoc_te",
|
||||
"ten_thuong_mai",
|
||||
"ma_atc",
|
||||
"loai_thuoc",
|
||||
"dang_thuoc_va_ham_luong",
|
||||
@@ -208,3 +223,37 @@ class SectionResolver:
|
||||
if f" {normalized_phrase} " in padded:
|
||||
return SectionMatch(section_key, phrase)
|
||||
return None
|
||||
|
||||
def resolve_all(self, query: str) -> tuple[SectionMatch, ...]:
|
||||
"""Every distinct section a question genuinely names, not just the
|
||||
first. Same longest-first order as `resolve()`, and the same
|
||||
span-claiming rule: once a phrase's occurrence is accepted, any
|
||||
shorter phrase whose only occurrence falls inside that already-
|
||||
claimed span is a substring of it, not a second section — e.g.
|
||||
"chỉ định" inside "chống chỉ định của X" must NOT count as a second,
|
||||
separate mention of `chi_dinh`. A phrase counts only when it has an
|
||||
occurrence that does not overlap any span already claimed by a
|
||||
longer, earlier-accepted phrase. Returns `()` for no match and
|
||||
exactly one item when the question names only one section — this is
|
||||
a superset of `resolve()`, not a replacement for it.
|
||||
"""
|
||||
normalized_query = normalize_name(query)
|
||||
if not normalized_query:
|
||||
return ()
|
||||
padded = f" {normalized_query} "
|
||||
claimed: list[tuple[int, int]] = []
|
||||
seen_sections: set[str] = set()
|
||||
matches: list[SectionMatch] = []
|
||||
for normalized_phrase, section_key, phrase in self._index:
|
||||
needle = f" {normalized_phrase} "
|
||||
idx = padded.find(needle)
|
||||
if idx == -1:
|
||||
continue
|
||||
span = (idx, idx + len(needle))
|
||||
if any(span[0] < c_end and c_start < span[1] for c_start, c_end in claimed):
|
||||
continue
|
||||
claimed.append(span)
|
||||
if section_key not in seen_sections:
|
||||
seen_sections.add(section_key)
|
||||
matches.append(SectionMatch(section_key, phrase))
|
||||
return tuple(matches)
|
||||
|
||||
@@ -118,6 +118,31 @@ SECTION_KEY_HINTS: dict[str, str] = {
|
||||
"thong_tin_quy_che": "thông tin quy chế/pháp lý",
|
||||
}
|
||||
|
||||
# Short, chip-sized Vietnamese section names — SECTION_KEY_HINTS above is
|
||||
# full-sentence disambiguation text for the model, well over
|
||||
# _QUICK_REPLY_MAX_CHARS, so it cannot double as a quick_reply label.
|
||||
_SECTION_SHORT_LABEL: dict[str, str] = {
|
||||
"ten_chung_quoc_te": "Tên chung quốc tế",
|
||||
"ten_thuong_mai": "Tên thương mại",
|
||||
"ma_atc": "Mã ATC",
|
||||
"loai_thuoc": "Loại thuốc",
|
||||
"dang_thuoc_va_ham_luong": "Dạng thuốc và hàm lượng",
|
||||
"duoc_ly_va_co_che_tac_dung": "Dược lý, cơ chế tác dụng",
|
||||
"chi_dinh": "Chỉ định",
|
||||
"chong_chi_dinh": "Chống chỉ định",
|
||||
"than_trong": "Thận trọng",
|
||||
"thoi_ky_mang_thai": "Thời kỳ mang thai",
|
||||
"thoi_ky_cho_con_bu": "Thời kỳ cho con bú",
|
||||
"tac_dung_khong_mong_muon": "Tác dụng không mong muốn",
|
||||
"huong_dan_xu_tri_adr": "Xử trí ADR",
|
||||
"lieu_luong_va_cach_dung": "Liều lượng và cách dùng",
|
||||
"tuong_tac_thuoc": "Tương tác thuốc",
|
||||
"qua_lieu_va_xu_tri": "Quá liều và xử trí",
|
||||
"do_on_dinh_va_bao_quan": "Độ ổn định, bảo quản",
|
||||
"tuong_ky": "Tương kỵ",
|
||||
"thong_tin_quy_che": "Thông tin quy chế",
|
||||
}
|
||||
|
||||
# What kind of turn this is — the router branches on it. Deliberately explicit so a
|
||||
# symptom lookup is never silently treated as a failed drug lookup, and a two-drug
|
||||
# interaction never collapses to an "ambiguous drug" abstain.
|
||||
@@ -294,7 +319,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 = 18 # one per monograph section (see rag/sections.py SECTION_ORDER)
|
||||
_QUICK_REPLY_MAX_ITEMS = 19 # 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.
|
||||
@@ -577,6 +602,7 @@ class LlmQueryUnderstander:
|
||||
resolved_section_key=(section_match.section_key if section_match else None),
|
||||
resolved_section_phrase=(section_match.phrase if section_match else None),
|
||||
)
|
||||
frame = _apply_multi_section_clarify(frame, turn)
|
||||
return _merge_with_prior_frame(frame, prior_frame)
|
||||
|
||||
@staticmethod
|
||||
@@ -856,6 +882,51 @@ def _apply_named_drug_cues(
|
||||
return frame
|
||||
|
||||
|
||||
def _apply_multi_section_clarify(frame: QueryFrame, turn: str) -> QueryFrame:
|
||||
"""A turn naming two-or-more distinct monograph sections for one drug
|
||||
("chỉ định và chống chỉ định của X") cannot be served by narrowing
|
||||
`attribute` to a single value: both the model's own JSON output and
|
||||
`_apply_named_drug_cues` above only ever produce one `attribute`, so
|
||||
retrieval silently fetches evidence for whichever section wins while the
|
||||
question handed to generation still promises both. The completeness check
|
||||
then correctly reports a real, quote-backed gap against the one section
|
||||
actually retrieved, generation retries with the same mismatched scope,
|
||||
and the turn fails closed as a confusing `incomplete_answer` abstain —
|
||||
found live 2026-08-14, reproduced 2/2 on "Chỉ định và chống chỉ định của
|
||||
Aspirin là gì?". Answering every named section at once is a larger,
|
||||
separate change (multi-attribute retrieval); asking which one first is
|
||||
the safe interim behavior, using the generic `needs_clarify`+
|
||||
`clarify_reason` clarify path `RagAgent` already has (checked ahead of
|
||||
the narrower `attribute is None` -> `missing_attribute` branch, and the
|
||||
only one of the two that forwards `quick_replies`).
|
||||
|
||||
Scoped to turns already read as a single named drug's own attribute(s)
|
||||
— `_apply_named_drug_cues` runs first, so by this point `turn_type` is
|
||||
already "drug_attribute"/"drug_overview" for exactly the cases this
|
||||
bug affects; interaction/condition/dosing turns are untouched.
|
||||
"""
|
||||
if frame.turn_type not in ("drug_attribute", "drug_overview") or not frame.drugs:
|
||||
return frame
|
||||
matches = _SECTION_RESOLVER.resolve_all(turn)
|
||||
distinct_sections = tuple(dict.fromkeys(match.section_key for match in matches))
|
||||
if len(distinct_sections) < 2:
|
||||
return frame
|
||||
options = tuple(
|
||||
_SECTION_SHORT_LABEL.get(key, key) for key in distinct_sections
|
||||
)[:_QUICK_REPLY_MAX_ITEMS]
|
||||
return replace(
|
||||
frame,
|
||||
turn_type="drug_attribute",
|
||||
attribute=None,
|
||||
needs_clarify=True,
|
||||
clarify_reason=(
|
||||
"Câu hỏi nêu nhiều mục cùng lúc — anh/chị muốn xem mục nào trước?"
|
||||
),
|
||||
quick_replies=options,
|
||||
system_error=None,
|
||||
)
|
||||
|
||||
|
||||
_KNOWN_FACT_LABELS: tuple[tuple[str, str], ...] = (
|
||||
("population", "Đối tượng"),
|
||||
("age_text", "Tuổi"),
|
||||
|
||||
Reference in New Issue
Block a user