306 lines
9.5 KiB
Python
306 lines
9.5 KiB
Python
"""The bounded reasoning loop.
|
|
|
|
Understand → plan → retrieve → assess → refine → generate → verify → repair.
|
|
Every edge is bounded, and every budget is decremented **before** the call it
|
|
pays for, so exhaustion degrades to the best answer so far rather than to an
|
|
error.
|
|
|
|
Two rules hold across every path and are the reason this can be added to a
|
|
formulary at all:
|
|
|
|
- `grounding.verify` still gates every generated answer. Reasoning chooses what
|
|
to look up and how to phrase it; it is never a source of facts.
|
|
- A clarify signal bypasses the loop entirely. Asking beats guessing, and the
|
|
signals are resolver states — ambiguous drug, unresolved attribute — not a
|
|
model's confidence score.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field, replace
|
|
from typing import Protocol
|
|
|
|
from . import metrics as metric_names
|
|
from .conversation import ConversationState, ResolvedQuestion
|
|
from .metrics import Metrics, NullMetrics
|
|
|
|
MAX_RETRIEVAL_ROUNDS = 2
|
|
MAX_REPAIRS = 1
|
|
MAX_LLM_CALLS = 4
|
|
MAX_WALL_CLOCK_MS = 20_000
|
|
|
|
|
|
class BudgetExhausted(RuntimeError):
|
|
"""Raised only inside the loop, never surfaced; the loop catches it."""
|
|
|
|
|
|
@dataclass
|
|
class TurnBudget:
|
|
"""Mutable on purpose: one budget is threaded through one turn."""
|
|
|
|
llm_calls: int = MAX_LLM_CALLS
|
|
retrieval_rounds: int = MAX_RETRIEVAL_ROUNDS
|
|
repairs: int = MAX_REPAIRS
|
|
wall_clock_ms: int = MAX_WALL_CLOCK_MS
|
|
elapsed_ms: int = 0
|
|
|
|
def spend_llm(self) -> None:
|
|
if self.llm_calls <= 0:
|
|
raise BudgetExhausted("llm_calls")
|
|
self.llm_calls -= 1
|
|
|
|
def spend_retrieval(self) -> None:
|
|
if self.retrieval_rounds <= 0:
|
|
raise BudgetExhausted("retrieval_rounds")
|
|
self.retrieval_rounds -= 1
|
|
|
|
def spend_repair(self) -> None:
|
|
if self.repairs <= 0:
|
|
raise BudgetExhausted("repairs")
|
|
self.repairs -= 1
|
|
|
|
def out_of_time(self) -> bool:
|
|
return self.elapsed_ms >= self.wall_clock_ms
|
|
|
|
|
|
class ClarifyReason:
|
|
AMBIGUOUS_DRUG = "ambiguous_drug"
|
|
NO_ATTRIBUTE = "no_attribute"
|
|
MULTI_ATTRIBUTE = "multi_attribute"
|
|
STILL_INSUFFICIENT = "still_insufficient"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Clarification:
|
|
reason: str
|
|
question: str
|
|
options: tuple[str, ...] = ()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Sufficiency:
|
|
"""The assessor's verdict on retrieved evidence.
|
|
|
|
`missing` must name something specific — a section, a population, a second
|
|
drug. "Feels incomplete" does not buy a retrieval round; a round is only
|
|
spent when there is a concrete thing to go and fetch.
|
|
"""
|
|
|
|
sufficient: bool
|
|
missing: str | None = None
|
|
refined_query: str | None = None
|
|
|
|
|
|
class SufficiencyAssessor(Protocol):
|
|
def assess(
|
|
self, resolved: ResolvedQuestion, evidence_texts: tuple[str, ...]
|
|
) -> Sufficiency: ...
|
|
|
|
|
|
class DeterministicAssessor:
|
|
"""The no-LLM default, and the reference for what the port must do.
|
|
|
|
Runs offline and is what the loop uses until a provider is enabled. It only
|
|
reports insufficiency it can *demonstrate* — a population was asked for and
|
|
no retrieved text mentions it — so it can never spin the loop on a feeling.
|
|
"""
|
|
|
|
POPULATION_TERMS = {
|
|
"nguoi_lon": ("người lớn",),
|
|
"tre_em": ("trẻ em", "trẻ nhỏ", "trẻ "),
|
|
"tre_so_sinh": ("sơ sinh",),
|
|
"phu_nu_co_thai": ("thai", "mang thai"),
|
|
"phu_nu_cho_con_bu": ("cho con bú", "sữa mẹ"),
|
|
"nguoi_cao_tuoi": ("người cao tuổi", "người già"),
|
|
"suy_than": ("suy thận", "clcr"),
|
|
"suy_gan": ("suy gan",),
|
|
}
|
|
|
|
def assess(
|
|
self, resolved: ResolvedQuestion, evidence_texts: tuple[str, ...]
|
|
) -> Sufficiency:
|
|
if not evidence_texts:
|
|
return Sufficiency(False, missing="no_evidence")
|
|
if resolved.population is None:
|
|
return Sufficiency(True)
|
|
|
|
terms = self.POPULATION_TERMS.get(resolved.population, ())
|
|
haystack = " ".join(evidence_texts).casefold()
|
|
if any(term in haystack for term in terms):
|
|
return Sufficiency(True)
|
|
return Sufficiency(
|
|
False,
|
|
missing=f"population:{resolved.population}",
|
|
refined_query=f"{resolved.text} {terms[0] if terms else ''}".strip(),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LoopOutcome:
|
|
"""What one turn produced, plus what it cost."""
|
|
|
|
answer: str | None
|
|
clarification: Clarification | None
|
|
evidence_texts: tuple[str, ...]
|
|
retrieval_rounds_used: int
|
|
repairs_used: int
|
|
stopped_because: str
|
|
generated: bool = False
|
|
|
|
|
|
@dataclass
|
|
class LoopTrace:
|
|
"""Ordered record of stages, for the dashboard and for debugging."""
|
|
|
|
stages: list[str] = field(default_factory=list)
|
|
|
|
def enter(self, stage: str) -> None:
|
|
self.stages.append(stage)
|
|
|
|
|
|
def clarify_for(
|
|
reason: str, options: tuple[str, ...] = ()
|
|
) -> Clarification:
|
|
questions = {
|
|
ClarifyReason.NO_ATTRIBUTE: (
|
|
"Anh/chị muốn tra thuộc tính nào của thuốc này?"
|
|
),
|
|
ClarifyReason.AMBIGUOUS_DRUG: (
|
|
"Câu hỏi có thể ứng với nhiều thuốc. Anh/chị muốn tra thuốc nào?"
|
|
),
|
|
ClarifyReason.MULTI_ATTRIBUTE: (
|
|
"Câu hỏi nhắc tới nhiều mục. Anh/chị muốn xem mục nào trước?"
|
|
),
|
|
ClarifyReason.STILL_INSUFFICIENT: (
|
|
"Chưa tìm đủ căn cứ trong Dược thư cho ý này. "
|
|
"Anh/chị có thể nêu rõ hơn điều cần tra không?"
|
|
),
|
|
}
|
|
return Clarification(reason, questions[reason], options)
|
|
|
|
|
|
class Retrieve(Protocol):
|
|
def __call__(self, resolved: ResolvedQuestion) -> tuple[str, ...]: ...
|
|
|
|
|
|
class Generate(Protocol):
|
|
def __call__(
|
|
self, resolved: ResolvedQuestion, evidence: tuple[str, ...], state: ConversationState
|
|
) -> str | None: ...
|
|
|
|
|
|
def run_turn(
|
|
state: ConversationState,
|
|
resolved: ResolvedQuestion,
|
|
retrieve: Retrieve,
|
|
generate: Generate,
|
|
clarify_signals: tuple[str, ...] = (),
|
|
assessor: SufficiencyAssessor | None = None,
|
|
budget: TurnBudget | None = None,
|
|
metrics: Metrics | None = None,
|
|
trace: LoopTrace | None = None,
|
|
) -> LoopOutcome:
|
|
"""One conversational turn through the bounded loop.
|
|
|
|
`clarify_signals` comes from the existing resolvers — ambiguous drug,
|
|
unresolved section, multi-attribute. They short-circuit before any spend,
|
|
because a question worth asking is cheaper and safer than a guess.
|
|
"""
|
|
budget = budget or TurnBudget()
|
|
assessor = assessor or DeterministicAssessor()
|
|
metrics = metrics or NullMetrics()
|
|
trace = trace or LoopTrace()
|
|
|
|
trace.enter("understand")
|
|
if clarify_signals:
|
|
reason = clarify_signals[0]
|
|
metrics.increment(metric_names.CLARIFY_ASKED, reason=reason)
|
|
trace.enter("clarify")
|
|
return LoopOutcome(
|
|
answer=None,
|
|
clarification=clarify_for(reason),
|
|
evidence_texts=(),
|
|
retrieval_rounds_used=0,
|
|
repairs_used=0,
|
|
stopped_because="clarify_signal",
|
|
)
|
|
|
|
evidence: tuple[str, ...] = ()
|
|
rounds_used = 0
|
|
stopped = "sufficient"
|
|
|
|
while True:
|
|
try:
|
|
budget.spend_retrieval()
|
|
except BudgetExhausted:
|
|
stopped = "retrieval_budget"
|
|
break
|
|
trace.enter("retrieve")
|
|
evidence = retrieve(resolved)
|
|
rounds_used += 1
|
|
|
|
trace.enter("assess")
|
|
verdict = assessor.assess(resolved, evidence)
|
|
if verdict.sufficient:
|
|
break
|
|
if budget.retrieval_rounds <= 0 or budget.out_of_time():
|
|
stopped = "retrieval_budget"
|
|
break
|
|
# A round is spent only on a named gap with a genuinely new query.
|
|
if not verdict.missing or not verdict.refined_query:
|
|
stopped = "no_actionable_gap"
|
|
break
|
|
if verdict.refined_query == resolved.text:
|
|
stopped = "query_unchanged"
|
|
break
|
|
trace.enter("refine")
|
|
metrics.increment(metric_names.LOOP_REFINED, missing=verdict.missing)
|
|
resolved = replace(resolved, text=verdict.refined_query)
|
|
|
|
metrics.increment(metric_names.LOOP_ROUNDS, rounds=str(rounds_used))
|
|
|
|
if not evidence:
|
|
trace.enter("clarify")
|
|
metrics.increment(
|
|
metric_names.CLARIFY_ASKED, reason=ClarifyReason.STILL_INSUFFICIENT
|
|
)
|
|
return LoopOutcome(
|
|
answer=None,
|
|
clarification=clarify_for(ClarifyReason.STILL_INSUFFICIENT),
|
|
evidence_texts=(),
|
|
retrieval_rounds_used=rounds_used,
|
|
repairs_used=0,
|
|
stopped_because="no_evidence",
|
|
)
|
|
|
|
repairs_used = 0
|
|
answer: str | None = None
|
|
while True:
|
|
trace.enter("generate")
|
|
try:
|
|
budget.spend_llm()
|
|
except BudgetExhausted:
|
|
stopped = "llm_budget"
|
|
break
|
|
answer = generate(resolved, evidence, state)
|
|
if answer is not None:
|
|
break
|
|
# `generate` returning None means verification already refused it.
|
|
try:
|
|
budget.spend_repair()
|
|
except BudgetExhausted:
|
|
stopped = "repair_budget"
|
|
break
|
|
repairs_used += 1
|
|
trace.enter("repair")
|
|
metrics.increment(metric_names.LOOP_REPAIRED)
|
|
|
|
return LoopOutcome(
|
|
answer=answer,
|
|
clarification=None,
|
|
evidence_texts=evidence,
|
|
retrieval_rounds_used=rounds_used,
|
|
repairs_used=repairs_used,
|
|
stopped_because=stopped,
|
|
generated=answer is not None,
|
|
)
|