"""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 from dataclasses import dataclass, field from typing import Protocol from .answer import Citation, GroundedAnswerService from .models import EvidenceDecision, RetrievalResult from .policy import looks_non_human from .service import RetrievalService from .understanding import QueryFrame, QueryUnderstander TUONG_TAC = "tuong_tac_thuoc" HISTORY_TURNS = 6 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 class RagAgent: def __init__( self, understander: QueryUnderstander, retrieval: RetrievalService, answers: GroundedAnswerService, autocomplete: AutocompleteSource | None = None, history_turns: int = HISTORY_TURNS, ) -> None: self._understander = understander self._retrieval = retrieval self._answers = answers self._autocomplete = autocomplete self._history_turns = history_turns self._history: dict[str, list[str]] = {} 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) -> AgentReply: history = self._history.get(conversation_id, []) if conversation_id else [] frame = self._understander.understand(turn, tuple(history)) reply = self._route(turn, frame) if conversation_id is not None: self._remember(conversation_id, turn, reply) return reply def _route(self, turn: str, frame: QueryFrame) -> AgentReply: tt = frame.turn_type if frame.needs_clarify and frame.clarify_reason: return AgentReply("clarify", "needs_more_info", clarification=frame.clarify_reason, 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 in ("out_of_scope",) or looks_non_human(turn): 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.", 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) if tt == "symptom_to_drug": # Reverse lookup (indication/adverse-effect -> drugs) is a distinct # retrieval mode, not yet wired. Be honest rather than abstain blank. return AgentReply( "clarify", "reverse_lookup_not_ready", clarification="Tra ngược theo triệu chứng/chỉ định đang được bổ " "sung. Anh/chị cho biết tên thuốc cụ thể để tôi tra giúp?", 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) # drug_attribute / drug_overview / dosing_calc / fallback: one drug + section return self._single_drug(turn, frame) def _single_drug(self, turn: str, frame: QueryFrame) -> AgentReply: result = self._retrieval.retrieve_framed( frame.drugs[0], frame.attribute, turn, is_overview=frame.turn_type == "drug_overview", ) return self._grounded(turn, result, frame) def _interaction(self, turn: str, frame: QueryFrame) -> 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. """ evidences = [] for drug_id in frame.drugs: part = self._retrieval.retrieve_framed(drug_id, TUONG_TAC, turn) if part.decision == EvidenceDecision.ANSWERABLE: 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 = RetrievalResult( EvidenceDecision.ANSWERABLE, "interaction_evidence", tuple(evidences), ) return self._grounded(turn, combined, frame) def _grounded( self, turn: str, result: RetrievalResult, frame: QueryFrame ) -> AgentReply: ga = self._answers.answer_from_result(turn, result) decision = ga.result.decision.value if ga.clarification is not None: decision = "clarify" return AgentReply( decision=decision, reason=ga.result.reason, answer=ga.answer, clarification=ga.clarification, citations=ga.citations, drugs=frame.drugs, turn_type=frame.turn_type, generated=ga.generated, ) def _remember(self, conversation_id: str, turn: str, reply: AgentReply) -> None: history = self._history.setdefault(conversation_id, []) history.append(f"Người dùng: {turn}") spoken = reply.answer or reply.clarification if spoken: history.append(f"Trợ lý: {spoken[:300]}") # Keep only the recent window; the LLM re-reads it 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()