"""The new RAG orchestrator — LLM understanding in, grounded answer out. Replaces the old front-end wholesale: - `CatalogDrugResolver` (fuzzy) + `SectionResolver` (keyword) -> `understanding.py` - `ConversationalLoopService` + `conversation.py` (Focus / Summariser / manual follow-up inheritance) -> the LLM reads a plain turn history and resolves "thuốc đó" / "còn liều thì sao" itself. What is deliberately KEPT because it is the safety spine, not the brittle part: - `RetrievalService.retrieve_framed` (Qdrant section/overview retrieval, whole section, provenance, quarantine `VERIFY_PDF`), - `GroundedAnswerService.answer_from_result` (`grounding.verify` + entailment on every generated claim; a configured generator that fails abstains rather than degrading to a raw source dump). This module owns routing only; it states no medical fact of its own. """ from __future__ import annotations import logging import time from dataclasses import dataclass, replace from typing import Protocol from .answer import AnswerBlock, AnswerPlan, Citation, GroundedAnswerService from .budget import RequestBudget from .clinical import ConditionRelation, MedicationCandidateAssessment from .models import EvidenceDecision, RetrievalResult from .policy import looks_non_human from .service import RetrievalService from .sections import SectionResolver from .text import normalize_name from .understanding import QueryFrame, QueryUnderstander logger = logging.getLogger(__name__) _SECTION_RESOLVER = SectionResolver() TUONG_TAC = "tuong_tac_thuoc" HISTORY_TURNS = 6 # F-08: a normal answerable agent turn makes 3 sequential Bedrock calls # (understand, generate, entailment). Input-field sufficiency is enforced by # the structured state machine; generation still fails closed when retrieved # evidence is insufficient. Defaults are sized with headroom, so ordinary # traffic never trips the budget — it exists for the pathological case # (a stuck/slow call, or an unexpectedly long retry chain), not to shave # time off the common path. MAX_WALL_CLOCK_MS = 40_000 MAX_LLM_CALLS_PER_TURN = 8 # Found live 2026-08-07 (50-question hand-typed browser audit): the # understanding LLM can get stuck re-asking the same (or a near-identical) # clarify question forever — reproduced 3 times independently, one case # never converged after 5 real answered turns. `understanding.py`'s prior- # frame merge (F-11) fixes most of the underlying cause, but a code-level # backstop is still needed: nothing bounds a model that just keeps deciding # needs_clarify=true no matter what. After this many CONSECUTIVE clarify # 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 — the gap ADR 0008 names as still open. Satisfied by `adapters.postgres.PostgresConversationStore`; entirely optional — with none configured, `RagAgent` behaves exactly as before this existed.""" def recent(self, conversation_id: str, limit: int) -> list[str]: ... def append(self, conversation_id: str, line: str) -> None: ... class AutocompleteSource(Protocol): """As-you-type suggestion, kept deterministic and independent of the LLM understander — a prefix match needs no model call.""" def complete(self, prefix: str, k: int) -> list[str]: ... @dataclass(frozen=True) class AgentReply: decision: str # answerable | abstain | clarify | verify_pdf reason: str answer: str | None = None clarification: str | None = None citations: tuple[Citation, ...] = () drugs: tuple[str, ...] = () turn_type: str = "" generated: bool = False quick_replies: tuple[str, ...] = () blocks: tuple[AnswerBlock, ...] = () answer_mode: str = "concise" plan: AnswerPlan | None = None candidate_assessments: tuple[MedicationCandidateAssessment, ...] = () class RagAgent: def __init__( self, understander: QueryUnderstander, retrieval: RetrievalService, answers: GroundedAnswerService, autocomplete: AutocompleteSource | None = None, history_turns: int = HISTORY_TURNS, max_wall_clock_ms: int = MAX_WALL_CLOCK_MS, max_llm_calls_per_turn: int = MAX_LLM_CALLS_PER_TURN, store: ConversationStore | None = None, ) -> None: self._understander = understander self._retrieval = retrieval self._answers = answers self._autocomplete = autocomplete self._history_turns = history_turns self._max_wall_clock_ms = max_wall_clock_ms self._max_llm_calls_per_turn = max_llm_calls_per_turn self._store = store self._history: dict[str, list[str]] = {} # In-process only, same durability caveat as `_history` (ADR 0008's # named gap) — lost on restart, not shared across workers. self._last_frame: dict[str, QueryFrame] = {} self._clarify_streak: dict[str, int] = {} def complete(self, prefix: str, k: int = 8) -> list[str]: """Display names matching a typed prefix, for input autocomplete.""" if self._autocomplete is None: return [] return [_display_name(drug_id) for drug_id in self._autocomplete.complete(prefix, k)] def handle( self, turn: str, conversation_id: str | None = None, response_mode: str = "ai", ) -> AgentReply: # F-08: one budget per turn, threaded through every LLM call this # turn makes (understand, then whatever `_route` reaches). t0 = time.monotonic() budget = RequestBudget.start(self._max_wall_clock_ms, self._max_llm_calls_per_turn) history = self._get_history(conversation_id) t1 = time.monotonic() prior_frame = self._last_frame.get(conversation_id) if conversation_id else None frame = self._understander.understand( turn, tuple(history), budget=budget, prior_frame=prior_frame ) t2 = time.monotonic() reply = self._route(turn, frame, budget, response_mode=response_mode) reply = self._enforce_clarify_circuit_breaker(conversation_id, reply) t3 = time.monotonic() if conversation_id is not None: self._remember(conversation_id, turn, reply) # A clarify can originate downstream of understanding (the dose # route invariant or evidence sufficiency). Persist that as an # open frame too; otherwise the next short reply sees a prior # frame marked complete and the structured merge cannot inherit # the drug/population the user already supplied. remembered_frame = frame if reply.decision == "clarify" and reply.clarification: remembered_frame = replace( frame, needs_clarify=True, clarify_reason=reply.clarification, quick_replies=reply.quick_replies, ) self._last_frame[conversation_id] = remembered_frame t4 = time.monotonic() # Temporary instrumentation (2026-08-07): added specifically to # pinpoint a live, reproduced-in-browser case of the FIRST LLM call # already reporting the F-08 budget exhausted — i.e. >20s elapsed # before even one Bedrock call was attempted, with no code between # `RequestBudget.start()` and that first `budget.require()` that # should plausibly take anywhere near that long. Logs unconditionally # (not just on the slow path) so a normal turn's timing is on record # too, for comparison. # .warning, not .info: uvicorn's default logging config only wires # handlers onto its own "uvicorn"/"uvicorn.access" loggers, not the # root logger, so a plain .info() here would silently go nowhere — # confirmed by `understanding.py`'s existing warning-level log # already showing up in the same server output this session. logger.warning( "handle() timing: history=%.2fs understand=%.2fs route=%.2fs " "remember=%.2fs total=%.2fs", t1 - t0, t2 - t1, t3 - t2, t4 - t3, t4 - t0, ) return reply def _enforce_clarify_circuit_breaker( self, conversation_id: str | None, reply: AgentReply ) -> AgentReply: """Hard stop after `MAX_CONSECUTIVE_CLARIFY` clarify turns in a row. Every other failure mode in this file degrades to a bounded, honest abstain — this is the one path that previously had no bound at all: a model that keeps deciding needs_clarify=true has no natural exit, and the user has no way out except abandoning the conversation. Any non-clarify decision (answered, or a different abstain reason) resets the streak — this only fires on genuinely consecutive clarifies. """ if conversation_id is None: return reply if reply.decision != "clarify": self._clarify_streak.pop(conversation_id, None) return reply streak = self._clarify_streak.get(conversation_id, 0) + 1 if streak >= MAX_CONSECUTIVE_CLARIFY: self._clarify_streak.pop(conversation_id, None) return AgentReply( "abstain", "clarify_loop_exhausted", answer=( "Hệ thống chưa xác định đủ thông tin sau nhiều lần hỏi lại. " "Anh/chị vui lòng gõ lại TOÀN BỘ câu hỏi trong một tin nhắn " "đầy đủ (tên thuốc, đối tượng, cân nặng/tuổi nếu có, đường " "dùng), hoặc bấm \"Tạo phiên tra cứu mới\" để bắt đầu lại." ), turn_type=reply.turn_type, ) self._clarify_streak[conversation_id] = streak return reply def _get_history(self, conversation_id: str | None) -> list[str]: if conversation_id is None: return [] if self._store is not None: try: return self._store.recent(conversation_id, self._history_turns * 2) except Exception: # Fail-open (F-09's precedent): a store outage means this # turn is understood fresh, with no memory of earlier ones — # worse UX, not a 500. `routers/rag.py` wraps # `PostgresTraceRepository.save()` the same bare way for the # same reason. return [] return self._history.get(conversation_id, []) def _route( self, turn: str, frame: QueryFrame, budget: RequestBudget, response_mode: str = "ai", ) -> AgentReply: tt = frame.turn_type section_overview = _is_section_overview(turn, frame) if section_overview and not frame.section_overview: frame = replace(frame, section_overview=True) # Deterministic scope guard precedes every conversational clarify. A # non-human dose must abstain, never ask which attribute/route and make # an out-of-scope request look recoverable. if looks_non_human(turn): return AgentReply( "abstain", "out_of_scope_non_human", answer="Dược thư Quốc gia Việt Nam trong hệ thống này chỉ bao " "phủ thuốc dùng cho người. Hệ thống không tra cứu liều " "dùng hoặc hướng dẫn điều trị cho động vật.", turn_type=tt) # Dosing is a small state machine, not an unconstrained model opinion. # The LLM extracts the fields and can phrase/populate a useful initial # clarify; code decides which core fields are actually required. Live # testing caught the model asking an adult's weight repeatedly even # after the user supplied a route, while previously skipping route and # dumping oral + rectal regimens together. if tt == "dosing_calc" and frame.drugs and not section_overview: if frame.population is None: return AgentReply( "clarify", "missing_population", clarification=( frame.clarify_reason if frame.needs_clarify and frame.clarify_reason else "Anh/chị muốn tra liều cho người lớn hay trẻ em?" ), drugs=frame.drugs, turn_type=tt, quick_replies=( frame.quick_replies if frame.needs_clarify else () ), ) if frame.population in {"tre_em", "tre_so_sinh"} and ( frame.age_text is None or frame.weight_kg is None ): # Both fields stay required. The formulary branches pediatric # dosing on BOTH — paracetamol prints an age band ("Trẻ em # 4-6 tuổi: 240 mg") *and* a weight rule ("10-50 kg: 15 # mg/kg") — so answering with only one of them would mean # picking a regimen the source does not let us pick. # # What changed on 2026-08-11 is the *question*, not the gate. # The fallback used to ask for age and weight every time, # including for whichever field the user had already given # (reproduced 5/5 live: "Bé 18 ký ...", "Bé nặng 18 kg ...", # "Trẻ 5 tuổi ..." all received the same sentence). The # effect was most visible when understanding.py did well: a # frame that parsed the weight and set needs_clarify=false # reaches this fallback, so a clearer question from the user # produced a more redundant question back. return AgentReply( "clarify", "missing_pediatric_age_or_weight", clarification=( frame.clarify_reason if frame.needs_clarify and frame.clarify_reason else _pediatric_clarify_question(frame) ), drugs=frame.drugs, turn_type=tt, quick_replies=( frame.quick_replies if frame.needs_clarify else () ), ) # Route is intentionally not a universal required slot. Retrieval # and the answer contract decide from the actual evidence whether # omitting it is harmless (one applicable route -> answer now) or # materially ambiguous (several routes -> model clarification and # model-proposed quick replies). This prevents chip funnels for a # question that was already precise enough to answer. if ( frame.needs_clarify and frame.clarify_reason and tt != "dosing_calc" and tt != "condition_to_drug" and not section_overview ): # `system_error` set means this isn't a real clarify at all — the # understanding call itself failed (provider outage, malformed # output) and failed closed to this same shape. Surface the real # reason instead of the generic "needs_more_info" so a technical # failure is distinguishable from an ordinary question back, both # in the API response and in `/metrics`/traces (found live # 2026-08-07: these were indistinguishable, which is why a real # outage looked identical to normal clarify traffic). if frame.system_error: return AgentReply( "abstain", frame.system_error, answer=frame.clarify_reason, drugs=frame.drugs, turn_type=tt, ) return AgentReply( "clarify", "needs_more_info", clarification=frame.clarify_reason, drugs=frame.drugs, turn_type=tt, quick_replies=frame.quick_replies, ) if tt in {"condition_to_drug", "symptom_to_drug"}: if frame.condition and frame.condition.ambiguous: return AgentReply( "clarify", "ambiguous_condition", clarification=( frame.condition.clarify_question or "Anh/chị muốn hỏi loại bệnh cụ thể nào?" ), turn_type=tt, ) # 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", answer=( "Câu hỏi này đang hỏi quan hệ khác với chỉ định điều trị " "(ví dụ thuốc gây bệnh hoặc chống chỉ định theo bệnh). Hệ " "thống chưa tra ngược quan hệ đó và sẽ không biến nó thành " "danh sách thuốc điều trị." ), turn_type=tt, ) if frame.condition or frame.indication: return self._condition_to_drug(turn, frame, budget) return AgentReply( "clarify", "no_indication" if tt == "symptom_to_drug" else "no_condition", clarification=( "Anh/chị mô tả triệu chứng hoặc chỉ định cần tra giúp em với?" if tt == "symptom_to_drug" else "Anh/chị muốn tra thuốc có chỉ định cho bệnh/condition nào?" ), turn_type=tt, ) if tt == "condition_relation": return AgentReply( "abstain", "unsupported_reverse_relation", answer=( "Hệ thống nhận ra đây không phải câu hỏi thuốc điều trị bệnh, " "nên không dùng mục Chỉ định để trả lời. Tra ngược thuốc gây " "bệnh/chống chỉ định theo bệnh chưa được hỗ trợ trong phiên bản này." ), turn_type=tt, ) if response_mode == "monograph" and ( tt == "drug_overview" or ( tt == "drug_attribute" and frame.attribute is None and not frame.needs_clarify ) or _is_bare_monograph_request(turn) ) and frame.drugs: return AgentReply( "clarify", "select_drug_sections", clarification=( "Đã nhận diện chuyên luận thuốc. Anh/chị chọn các mục cần " "xem; nếu không chọn mục nào, hệ thống sẽ hiển thị toàn bộ." ), drugs=frame.drugs, turn_type=tt, ) if tt == "drug_attribute" and frame.drugs and frame.attribute is None: return AgentReply( "clarify", "missing_attribute", clarification=( "Anh/chị muốn tra nội dung nào của thuốc này " "(chỉ định, chống chỉ định, thận trọng, tác dụng phụ…)?" ), drugs=frame.drugs, turn_type=tt, ) if tt == "smalltalk": return AgentReply( "answerable", "smalltalk", answer="Chào anh/chị! Em là trợ lý tra cứu Dược thư Quốc gia Việt Nam " "2018, sẵn sàng hỗ trợ tra liều dùng, chống chỉ định, tương tác " "thuốc... Anh/chị đang cần tra thuốc nào ạ?", 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="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: if frame.unknown_drugs: names = ", ".join(frame.unknown_drugs) return AgentReply( "abstain", "drug_not_in_formulary", answer=f"Không tìm thấy \"{names}\" trong Dược thư Quốc gia Việt Nam.", turn_type=tt) return AgentReply( "clarify", "no_drug", clarification="Anh/chị muốn tra thuốc nào?", turn_type=tt) if tt == "interaction" and len(frame.drugs) >= 2: return self._interaction(turn, frame, budget) # drug_attribute / drug_overview / dosing_calc / fallback: one drug + section return self._single_drug(turn, frame, budget) def _single_drug(self, turn: str, frame: QueryFrame, budget: RequestBudget) -> AgentReply: query = _synthesize_query(frame.standalone_query or turn, frame) # `dosing_calc` semantically names the dosage section even when the # understanding model leaves the separate `attribute` field null. # Passing null here falls into an overview retrieval and was observed # pulling interactions/precautions into a plain adult-dose answer. section_key = ( "lieu_luong_va_cach_dung" if frame.turn_type == "dosing_calc" else ( "chi_dinh" if frame.turn_type == "drug_to_condition" else frame.attribute ) ) result = self._retrieval.retrieve_framed( frame.drugs[0], section_key, query, is_overview=frame.turn_type == "drug_overview", ) if frame.patient_context.requires_safety_review: result = self._retrieval.retrieve_patient_drug_context( frame.drugs[0], result, frame.patient_context ) return self._grounded( query, result, frame, patient_specific=frame.patient_context.requires_safety_review, budget=budget, ) def _interaction(self, turn: str, frame: QueryFrame, budget: RequestBudget) -> AgentReply: """Gather the interaction section of each named drug and synthesise. Absence of a match is stated as "not found in each drug's interaction section", never as "safe" — the answer layer's grounding still applies. A quarantined table/formula in EITHER drug's interaction section forces the whole combined answer to VERIFY_PDF via `RetrievalService.decide` — the same policy the single-drug path already applies to a section with quarantined content. Previously this only kept `part.decision == ANSWERABLE` parts, so a quarantined drug's evidence (and the "table exists, verify PDF" notice it must produce per the quarantine contract) was silently dropped instead of surfaced; a confident interaction answer could omit exactly the unverified contraindication table it should have flagged. Generating a synthesis claim from one verified and one unverified source is not safer than generating from either alone, so both now block generation the same way. """ evidences = [] query = _synthesize_query(frame.standalone_query or turn, frame) for drug_id in frame.drugs: part = self._retrieval.retrieve_framed(drug_id, TUONG_TAC, query) if part.decision in (EvidenceDecision.ANSWERABLE, EvidenceDecision.VERIFY_PDF): evidences.extend(part.evidence) if not evidences: listed = " và ".join(frame.drugs) return AgentReply( "abstain", "no_interaction_evidence", answer=f"Không tìm thấy mục tương tác thuốc cho {listed} trong Dược " "thư. Điều này KHÔNG có nghĩa là an toàn khi phối hợp.", drugs=frame.drugs, turn_type=frame.turn_type) combined = self._retrieval.decide(tuple(evidences)) return self._grounded(query, combined, frame, budget=budget) def _condition_to_drug( self, turn: str, frame: QueryFrame, budget: RequestBudget ) -> AgentReply: """Reverse lookup: a symptom/indication -> which drugs' `chi_dinh` actually names it. A factual list from the formulary, not a treatment ranking or recommendation — no drug is preferred over another here, only cited as indicated ([[feedback_no_recommendation_gate]]: this audience is doctors/pharmacists, a lookup like this is normal use). Absence is stated plainly, never as "no such drug exists" — the formulary may simply not name this indication under any monograph. """ condition_text = ( frame.condition.retrieval_text if frame.condition else frame.indication ) result = self._retrieval.retrieve_by_indication(condition_text) if result.decision == EvidenceDecision.ABSTAIN: return AgentReply( "abstain", result.reason, answer=f"Không tìm thấy thuốc nào trong Dược thư Quốc gia Việt Nam ghi " f"nhận chỉ định cho \"{condition_text}\". Điều này KHÔNG có " "nghĩa là không có thuốc điều trị — vui lòng tra theo tên thuốc " "cụ thể nếu đã biết.", turn_type=frame.turn_type) # `matched_doc_id` is always `{drug_id}__chi_dinh__{part_index}` — the # drugs actually found, not `frame.drugs` (empty by construction for # this turn_type; the router only reaches here with no named drug). matched_drugs = tuple(dict.fromkeys( evidence.drug_id or evidence.matched_doc_id.split("__")[0] for evidence in result.evidence )) assessments: tuple[MedicationCandidateAssessment, ...] = () patient_specific = frame.patient_context.requires_safety_review if patient_specific: result, assessments = self._retrieval.assess_patient_candidates( result, frame.patient_context ) if assessments: matched_drugs = tuple(item.drug_id for item in assessments) if result.decision == EvidenceDecision.ABSTAIN: return AgentReply( "abstain", result.reason, answer=( "Có bằng chứng chỉ định cho bệnh chính nhưng chưa tìm thấy đủ " "bằng chứng an toàn liên quan đến dữ kiện người bệnh trong các " "mục Dược thư được tra. Không suy ra thuốc là phù hợp/an toàn." ), drugs=matched_drugs, turn_type=frame.turn_type, candidate_assessments=assessments, ) generation_query = ( _patient_generation_query(frame) if patient_specific else _synthesize_query(turn, frame) ) return self._grounded( generation_query, result, frame, drugs=matched_drugs, list_mode=True, patient_specific=patient_specific, assessments=assessments, budget=budget, ) # Compatibility name for older tests/callers while the public taxonomy # moves from symptom-only wording to condition-centric wording. _symptom_to_drug = _condition_to_drug def _grounded( self, turn: str, result: RetrievalResult, frame: QueryFrame, drugs: tuple[str, ...] | None = None, list_mode: bool = False, patient_specific: bool = False, assessments: tuple[MedicationCandidateAssessment, ...] = (), budget: RequestBudget | None = None, ) -> AgentReply: ga = self._answers.answer_from_result( turn, result, list_mode=list_mode, patient_specific=patient_specific, candidate_drug_ids=drugs or (), budget=budget, prechecked=True, ) decision = ga.result.decision.value if ga.clarification is not None: decision = "clarify" reason = "needs_more_info" if ga.clarification is not None else ga.result.reason return AgentReply( decision=decision, reason=reason, answer=ga.answer, clarification=ga.clarification, citations=ga.citations, drugs=drugs if drugs is not None else frame.drugs, turn_type=frame.turn_type, generated=ga.generated, quick_replies=ga.quick_replies, blocks=ga.blocks, answer_mode=ga.answer_mode, plan=ga.plan, candidate_assessments=assessments, ) def _remember(self, conversation_id: str, turn: str, reply: AgentReply) -> None: lines = [f"Người dùng: {turn}"] spoken = reply.answer or reply.clarification if spoken: lines.append(f"Trợ lý: {spoken[:300]}") if self._store is not None: try: for line in lines: self._store.append(conversation_id, line) except Exception: # Fail-open: this turn's memory is lost, not the response # already computed and about to be returned to the caller. pass return history = self._history.setdefault(conversation_id, []) history.extend(lines) # Keep only the recent window; the LLM re-reads it every turn. Only # needed for the in-process dict — the store path windows at READ # time instead (`recent(..., limit)`), so old rows just sit unused # rather than needing a delete on every turn. excess = len(history) - self._history_turns * 2 if excess > 0: del history[:excess] def _display_name(drug_id: str) -> str: """A readable display name from a drug id ('paracetamol_acetaminophen').""" return drug_id.replace("_", " ").title() def _pediatric_clarify_question(frame: QueryFrame) -> str: """Ask for the pediatric field that is actually missing. The caller still requires both age and weight; this only stops the question from asking for something the user already supplied in the very same sentence, which reads as not having been listened to and invites them to repeat themselves into the clarify-loop breaker. When a field IS known it is echoed back, so the user can see the value was received and correct it if the parse was wrong (e.g. a colloquial "18 ký" read as 18 kg). """ has_age = frame.age_text is not None has_weight = frame.weight_kg is not None if has_age and not has_weight: return f"Bé {frame.age_text} nặng bao nhiêu kg?" if has_weight and not has_age: return f"Bé nặng {_format_kg(frame.weight_kg)} kg, vậy bé bao nhiêu tuổi?" return "Bé bao nhiêu tuổi và cân nặng bao nhiêu kg?" def _format_kg(weight_kg: float) -> str: """Render a weight without a trailing '.0' on whole kilograms.""" return f"{weight_kg:g}" def _is_section_overview(turn: str, frame: QueryFrame) -> bool: """Separate a handbook survey from a patient-specific decision. The model supplies the first-class flag, while the narrow lexical backstop makes a clear section lookup deterministic. A personal target always wins: broad words inside a patient-specific dose question must not suppress a necessary clarification. """ text = turn.casefold() personal_cues = ( "cho tôi", "tôi đang", "bệnh nhân này", "ca này", "bé ", "trẻ ", "tuổi", "cân nặng", " kg", "suy thận", "suy gan", "nên dùng liều nào", ) if any(cue in text for cue in personal_cues): return False overview_cues = ( "dược thư hướng dẫn", "những ", "các ", "toàn bộ", "tất cả", "trình bày", "theo nhóm", "theo tần suất", "nếu có", "tổng quan", ) return frame.section_overview or any(cue in text for cue in overview_cues) def _is_bare_monograph_request(turn: str) -> bool: """True for a plain drug name in explicit monograph-browse mode. A persisted conversation can contribute a stale attribute to a new bare drug turn (for example the prior question was about contraindications). The UI mode is an explicit current-turn instruction, so a plain name must open the picker rather than inherit that old section. Any actual section phrase or clinical-question cue keeps the normal AI route. """ text = normalize_name(turn) if not text or len(text) > 100 or _SECTION_RESOLVER.resolve_all(turn): return False clinical_cues = ( " dung ", " dieu tri ", " tuong tac ", " tac dung ", " lieu ", " benh ", " thai ", " cho con bu ", " tre em ", " nguoi lon ", " suy than ", " suy gan ", " di ung ", " bao nhieu ", " la gi ", " co the ", " duoc khong ", ) padded = f" {text} " return not any(cue in padded for cue in clinical_cues) _POPULATION_LABELS = { "tre_em": "trẻ em", "tre_so_sinh": "trẻ sơ sinh", "nguoi_lon": "người lớn", "nguoi_cao_tuoi": "người cao tuổi", "phu_nu_co_thai": "phụ nữ có thai", "phu_nu_cho_con_bu": "phụ nữ cho con bú", "suy_than": "suy thận", "suy_gan": "suy gan", } _ROUTE_LABELS = { "uong": "uống", "tiem_tinh_mach": "tiêm tĩnh mạch", "tiem_bap": "tiêm bắp", "tiem_duoi_da": "tiêm dưới da", "dat_truc_trang": "đặt trực tràng", "boi_ngoai_da": "bôi ngoài da", "nho_mat": "nhỏ mắt", "nho_mui": "nhỏ mũi", "khac": "khác", } def _synthesize_query(turn: str, frame: QueryFrame) -> str: """Fold the structured context `understanding.py` resolved — possibly across several turns — into one self-contained question. `GroundedAnswerService.answer_from_result` has no conversation history of its own; the `query` string it receives IS the entire context its generation LLM call sees (and the legacy sufficiency call, if enabled). Passing the bare current turn loses everything resolved earlier: a reply like "Uống" answering a route question three turns into a dose conversation would reach generation as just "Uống", indistinguishable from a user who typed nothing else — the two P0s the 2026-08-06 audit named (population/ weight/age/route extracted but discarded downstream) are exactly this gap. Redundant when the turn is already self-contained (a fresh single-shot question re-states its own population/route, so this just repeats it) — harmless, since omission is the failure mode, not repetition. """ parts = [turn] if frame.section_overview: parts.append( "Phạm vi yêu cầu: tra cứu tổng quan toàn mục; trình bày các nhánh " "trong Dược thư với nhãn điều kiện, không chọn một phác đồ cho một người bệnh" ) if frame.population: parts.append(f"Đối tượng: {_POPULATION_LABELS.get(frame.population, frame.population)}") if frame.age_text: parts.append(f"Tuổi: {frame.age_text}") if frame.weight_kg is not None: parts.append(f"Cân nặng: {frame.weight_kg:g} kg") if frame.route: parts.append(f"Đường dùng: {_ROUTE_LABELS.get(frame.route, frame.route)}") if frame.indication: parts.append(f"Chỉ định/triệu chứng: {frame.indication}") patient = frame.patient_context if patient.primary_condition: parts.append(f"Bệnh chính: {patient.primary_condition}") if patient.comorbidities: parts.append(f"Bệnh nền: {', '.join(patient.comorbidities)}") if patient.allergies: parts.append(f"Dị ứng: {', '.join(patient.allergies)}") if patient.previous_adverse_reactions: parts.append(f"ADR trước đây: {', '.join(patient.previous_adverse_reactions)}") if patient.current_medications: parts.append(f"Thuốc đang dùng: {', '.join(patient.current_medications)}") if patient.renal.present: parts.append(f"Dữ kiện thận: {patient.renal}") if patient.hepatic.present: parts.append(f"Dữ kiện gan: {patient.hepatic}") if patient.pregnancy_status: parts.append(f"Thai kỳ: {patient.pregnancy_status}") if patient.breastfeeding is not None: parts.append(f"Cho con bú: {'có' if patient.breastfeeding else 'không'}") if patient.relevant_labs: parts.append(f"Xét nghiệm: {', '.join(patient.relevant_labs)}") if len(parts) == 1: return turn return ". ".join(parts) + "." def _patient_generation_query(frame: QueryFrame) -> str: """Give generation the clinical task without restating user-only values. Patient values have already done their job before generation: they select the targeted safety sections. They are not Dược thư evidence themselves. Passing the raw turn here encouraged the model to repeat age, eGFR or CKD grade inside a cited medical claim, which the numeric grounding guard then correctly rejected. The composer therefore receives only the evidenced condition and an instruction to describe the supplied positive evidence; exact patient values remain in structured state and retrieval traces. """ condition = ( frame.condition.normalized_condition if frame.condition and frame.condition.normalized_condition else frame.patient_context.primary_condition or frame.indication or "bệnh chính đã nêu" ) return ( f"Tra cứu các thuốc có bằng chứng chỉ định cho {condition}. " "Đây là ca cụ thể: với từng ứng viên, chỉ trình bày bằng chứng an toàn " "dương tính đã truy xuất liên quan đến dữ kiện người bệnh; không lặp " "lại dữ kiện người bệnh nếu dữ kiện đó không nằm trong đoạn bằng chứng." )