"""Orchestration: turns a stateless single-turn engine into a conversation. This is the glue ADR 0007 specified and nothing yet called. It owns no rules of its own — inheritance lives in `conversation.py`, the bounded loop in `reasoning.py`, grounding in `grounding.py`. Its whole job is the sequence: load state → resolve this turn, then inherit gaps from focus → derive clarify signals from resolver state (never a model score) → run the bounded loop (retrieve / generate / verify) → update focus, append turns, summarise overflow, save → name any inherited drug in the answer Everything here runs with no LLM and no live service: the collaborators are protocols, so a turn can be exercised end-to-end with fakes. """ from __future__ import annotations from dataclasses import dataclass, replace from typing import Protocol from . import metrics as metric_names from .answer import GroundedAnswer, GroundedAnswerService from .conversation import ( ConversationState, ConversationStore, Summariser, Turn, is_smalltalk, resolve_against, update_focus, ) from .metrics import Metrics, NullMetrics from .models import EvidenceDecision, QueryIntent, SubjectScope from .reasoning import ( BudgetExhausted, Clarification, ClarifyReason, DeterministicAssessor, Generate, LoopOutcome, MAX_RETRIEVAL_ROUNDS, Retrieve, SufficiencyAssessor, TurnBudget, clarify_for, run_turn, ) from .routing import CatalogDrugResolver, DrugResolutionStatus, normalize_name from .sections import SectionResolver # Turns that only confirm a prior suggestion. They resolve no drug and must not # be fuzzy-matched against the catalog (which returns garbage like terbinafin). # Stored normalised (normalize_name strips diacritics: "đúng" -> "dung"), or the # lookup below never matches. _CONFIRMATION_WORDS = ( "đúng", "đúng rồi", "đúng vậy", "phải", "phải rồi", "chuẩn", "chuẩn rồi", "chính xác", "ừ", "uh", "ok", "oke", "yes", "vâng", ) _CONFIRMATIONS = frozenset(normalize_name(word) for word in _CONFIRMATION_WORDS) def _is_confirmation(text: str) -> bool: return normalize_name(text) in _CONFIRMATIONS SUMMARY_EVERY = 4 # regenerate the summary at most every S turns, per ADR 0007 §2 @dataclass(frozen=True) class TurnResolution: """What one turn resolved on its own, before conversation is considered. `drug_status` is the resolver's verdict — resolved / not_found / ambiguous — kept distinct from `drug_id` so an ambiguous turn (asks which drug) reads differently from a bare follow-up (inherits the drug). """ drug_id: str | None section_key: str | None drug_status: str class TurnResolverPort(Protocol): def resolve_turn(self, text: str) -> TurnResolution: ... @dataclass(frozen=True) class TurnResponse: answer: str | None clarification: Clarification | None evidence_texts: tuple[str, ...] stopped_because: str inherited_drug: str | None generated: bool class ConversationalRagService: def __init__( self, store: ConversationStore, summariser: Summariser, resolver: TurnResolverPort, retrieve: Retrieve, generate: Generate, metrics: Metrics | None = None, summary_every: int = SUMMARY_EVERY, ) -> None: self._store = store self._summariser = summariser self._resolver = resolver self._retrieve = retrieve self._generate = generate self._metrics = metrics or NullMetrics() self._summary_every = summary_every def answer( self, conversation_id: str, text: str, budget: TurnBudget | None = None ) -> TurnResponse: state = self._store.load(conversation_id) turn = self._resolver.resolve_turn(text) resolved = resolve_against(state, text, turn.drug_id, turn.section_key) signals = self._clarify_signals(resolved, turn) if resolved.inherited_drug: self._metrics.increment(metric_names.FOLLOWUP_INHERITED) outcome = run_turn( state, resolved, self._retrieve, self._generate, clarify_signals=signals, budget=budget or TurnBudget(), metrics=self._metrics, ) self._persist(state, resolved, outcome) answer = outcome.answer inherited = resolved.drug_id if resolved.needs_carry_over_notice else None if answer is not None and inherited is not None: # An inherited drug that is wrong is a wrong-drug answer, so the # answer has to say which drug it decided this was about. answer = f"Về {inherited}: {answer}" return TurnResponse( answer=answer, clarification=outcome.clarification, evidence_texts=outcome.evidence_texts, stopped_because=outcome.stopped_because, inherited_drug=inherited, generated=outcome.generated, ) @staticmethod def _clarify_signals(resolved, turn: TurnResolution) -> tuple[str, ...]: """Resolver states that should ask instead of guess. Only fires when the drug is *still* unknown after inheritance: a follow-up like "còn trẻ em thì sao?" names no drug but inherits one, and must not be turned into a clarify. """ if resolved.drug_id is None: return (ClarifyReason.AMBIGUOUS_DRUG,) return () def _persist( self, state: ConversationState, resolved, outcome: LoopOutcome ) -> None: focus = update_focus(state, resolved) state = ConversationState( conversation_id=state.conversation_id, recent=state.recent, summary=state.summary, focus=focus, turn_count=state.turn_count, ) state = state.append( Turn("user", resolved.text, _now(), resolved.drug_id, resolved.section_key) ) if outcome.answer is not None: state = state.append( Turn( "assistant", outcome.answer, _now(), resolved.drug_id, resolved.section_key, evidence_ids=tuple(str(i) for i in range(len(outcome.evidence_texts))), ) ) if state.turn_count % self._summary_every == 0 and state.overflow(): summary = self._summariser.fold(state.summary, state.overflow()) state = ConversationState( conversation_id=state.conversation_id, recent=state.recent, summary=summary, focus=state.focus, turn_count=state.turn_count, ) self._store.save(state) def _now() -> str: # Timestamps are provenance, not logic; the domain never branches on them, # so a monotonic placeholder keeps this module free of wall-clock coupling. return "" # --- live chat core ----------------------------------------------------------- # # The deployable multi-turn path. The loop is what *understands and clarifies* # a turn; retrieval, citation, VERIFY_PDF and grounding stay inside # GroundedAnswerService, untouched — so clarify + refine are added *around* the # safe engine, never inside it. SMALLTALK_REPLY = ( "Mình tra cứu Dược thư Quốc gia Việt Nam. Bạn muốn hỏi về thuốc nào, " "hoặc thuộc tính nào (liều dùng, chống chỉ định, tương tác…)?" ) @dataclass(frozen=True) class ConversationTurnResult: answer: str | None clarification: Clarification | None grounded: GroundedAnswer | None smalltalk: bool inherited_drug: str | None reason: str class ConversationalLoopService: def __init__( self, answers: GroundedAnswerService, resolver: CatalogDrugResolver, section_resolver: SectionResolver, store: ConversationStore, assessor: SufficiencyAssessor | None = None, summariser: Summariser | None = None, metrics: Metrics | None = None, ) -> None: self._answers = answers self._resolver = resolver self._section_resolver = section_resolver self._store = store self._assessor = assessor or DeterministicAssessor() self._summariser = summariser self._metrics = metrics or NullMetrics() def answer( self, conversation_id: str, query: str, subject_scope: SubjectScope, intent: QueryIntent, budget: TurnBudget | None = None, ) -> ConversationTurnResult: state = self._store.load(conversation_id) resolution = self._resolver.resolve(query) # Only an EXACT name is auto-accepted. A fuzzy match (score < 1.0) is a # guess, and a formulary must not silently answer about a *different* # drug than the one meant — a typo is asked about ("did you mean…?"), # never resolved on a similarity threshold. Autocomplete at input is the # first line; this is the backstop when a wrong name is still submitted. is_exact = resolution.status == DrugResolutionStatus.RESOLVED and ( resolution.score is None or resolution.score >= 0.999 ) drug_self = resolution.drug_id if is_exact else None # Social turn that names no drug: answer as a person, not a failed lookup. if drug_self is None and is_smalltalk(query): self._append_user(state, query, None, None) return ConversationTurnResult( SMALLTALK_REPLY, None, None, True, None, "smalltalk" ) section = self._section_resolver.resolve(query) section_self = section.section_key if section else None resolved = resolve_against(state, query, drug_self, section_self) # Clarify beats guessing: no drug even after inheritance. If the text is # a near-miss for real drug names, offer them ("did you mean") rather # than a bare "which drug?" — a typo should not dead-end. if resolved.drug_id is None: # A bare confirmation ("đúng") with no drug in context is not a drug # lookup — never fuzzy-match it (that returned terbinafin/tretinoin). if _is_confirmation(query): reason = "confirm_without_context" clarification = Clarification( reason=reason, question="Bạn muốn xác nhận thuốc nào? Vui lòng gõ tên thuốc để mình tra cứu.", options=(), ) self._metrics.increment(metric_names.CLARIFY_ASKED, reason=reason) self._persist(state, resolved, None) return ConversationTurnResult(None, clarification, None, False, None, reason) # Only offer "did you mean" for a SHORT, drug-name-shaped miss (a # typo). Fuzzy-matching a whole sentence ("EPO điều trị thiếu máu…") # or a confirmation ("đúng") against 684 aliases returns confident # garbage — that is the did-you-mean loop the reviewer hit. A long or # confirming turn that resolves no drug is answered honestly, not # with a list of unrelated drugs. looks_like_name = len(normalize_name(query).split()) <= 4 suggestions = ( self._resolver.suggest(query, k=3, min_score=0.72) if looks_like_name and not _is_confirmation(query) else [] ) if suggestions: names = [self._drug_name(drug_id) for drug_id, _ in suggestions] reason = "did_you_mean" clarification = Clarification( reason=reason, question=f"Ý bạn là: {', '.join(names)}?", options=tuple(names), ) else: reason = "drug_not_supported" clarification = Clarification( reason=reason, question=( "Không có thuốc này trong Dược thư Quốc gia. Vui lòng kiểm " "tra lại tên, hoặc gõ vài ký tự để chọn từ gợi ý." ), options=(), ) self._metrics.increment(metric_names.CLARIFY_ASKED, reason=reason) self._persist(state, resolved, None) return ConversationTurnResult(None, clarification, None, False, None, reason) if resolved.inherited_drug: self._metrics.increment(metric_names.FOLLOWUP_INHERITED) # One call to the safe engine. The drug is passed already-resolved (incl. # an inherited follow-up drug), so the engine does NOT re-resolve it from # the turn text — that double-resolution is what abstained follow-ups as # "ambiguous". The turn's own text drives section routing; when it names # no attribute the drug-overview + rerank path finds the relevant part. grounded: GroundedAnswer | None = self._answers.answer( query, subject_scope, intent, drug_id=resolved.drug_id ) answer = grounded.answer if grounded else None inherited = resolved.drug_id if resolved.needs_carry_over_notice else None if answer is not None and inherited is not None: answer = f"Về {inherited}: {answer}" if grounded is not None: grounded = replace(grounded, answer=answer) self._persist(state, resolved, grounded) return ConversationTurnResult( answer, None, grounded, False, inherited, grounded.result.reason if grounded else "no_answer", ) def complete(self, prefix: str, k: int = 8) -> list[str]: """Display names matching a typed prefix, for input autocomplete.""" return [self._drug_name(drug_id) for drug_id in self._resolver.complete(prefix, k)] @staticmethod def _drug_name(drug_id: str) -> str: """A readable display name from a drug id ('paracetamol_acetaminophen').""" return drug_id.replace("_", " ").title() def _append_user(self, state, text, drug_id, section_key) -> None: state = state.append(Turn("user", text, _now(), drug_id, section_key)) self._store.save(state) def _persist(self, state, resolved, grounded) -> None: focus = update_focus(state, resolved) state = replace(state, focus=focus) state = state.append( Turn("user", resolved.text, _now(), resolved.drug_id, resolved.section_key) ) if grounded is not None and grounded.answer is not None: state = state.append( Turn( "assistant", grounded.answer, _now(), resolved.drug_id, resolved.section_key, ) ) if ( self._summariser is not None and state.turn_count % SUMMARY_EVERY == 0 and state.overflow() ): summary = self._summariser.fold(state.summary, state.overflow()) state = replace(state, summary=summary) self._store.save(state)