Remove corpus counts from chat chrome
This commit is contained in:
+160
-21
@@ -19,10 +19,10 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Protocol
|
||||
|
||||
from .answer import Citation, GroundedAnswerService
|
||||
from .answer import AnswerBlock, AnswerPlan, Citation, GroundedAnswerService
|
||||
from .budget import RequestBudget
|
||||
from .models import EvidenceDecision, RetrievalResult
|
||||
from .policy import looks_non_human
|
||||
@@ -33,14 +33,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
TUONG_TAC = "tuong_tac_thuoc"
|
||||
HISTORY_TURNS = 6
|
||||
# F-08: measured live 2026-08-07, a normal answerable turn makes 4 sequential
|
||||
# Bedrock calls (understand, sufficiency, generate, entailment) and costs
|
||||
# ~8-9s; a noisy entailment retry adds a 5th. Defaults sized with headroom
|
||||
# above that measured normal case, not at its exact edge, so ordinary
|
||||
# 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 = 20_000
|
||||
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)
|
||||
@@ -81,6 +81,9 @@ class AgentReply:
|
||||
turn_type: str = ""
|
||||
generated: bool = False
|
||||
quick_replies: tuple[str, ...] = ()
|
||||
blocks: tuple[AnswerBlock, ...] = ()
|
||||
answer_mode: str = "concise"
|
||||
plan: AnswerPlan | None = None
|
||||
|
||||
|
||||
class RagAgent:
|
||||
@@ -132,7 +135,20 @@ class RagAgent:
|
||||
t3 = time.monotonic()
|
||||
if conversation_id is not None:
|
||||
self._remember(conversation_id, turn, reply)
|
||||
self._last_frame[conversation_id] = frame
|
||||
# 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
|
||||
@@ -204,8 +220,71 @@ class RagAgent:
|
||||
|
||||
def _route(self, turn: str, frame: QueryFrame, budget: RequestBudget) -> 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)
|
||||
|
||||
if frame.needs_clarify and frame.clarify_reason:
|
||||
# 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",
|
||||
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)
|
||||
|
||||
# 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
|
||||
):
|
||||
return AgentReply(
|
||||
"clarify", "missing_pediatric_age_or_weight",
|
||||
clarification=(
|
||||
frame.clarify_reason
|
||||
if frame.needs_clarify and frame.clarify_reason
|
||||
else "Bé bao nhiêu tuổi và cân nặng bao nhiêu kg?"
|
||||
),
|
||||
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 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
|
||||
@@ -214,10 +293,28 @@ class RagAgent:
|
||||
# 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).
|
||||
return AgentReply("clarify", frame.system_error or "needs_more_info",
|
||||
clarification=frame.clarify_reason,
|
||||
drugs=frame.drugs, turn_type=tt,
|
||||
quick_replies=frame.quick_replies)
|
||||
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 == "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(
|
||||
@@ -227,7 +324,7 @@ class RagAgent:
|
||||
"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):
|
||||
if tt == "out_of_scope":
|
||||
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ư "
|
||||
@@ -261,9 +358,18 @@ class RagAgent:
|
||||
return self._single_drug(turn, frame, budget)
|
||||
|
||||
def _single_drug(self, turn: str, frame: QueryFrame, budget: RequestBudget) -> AgentReply:
|
||||
query = _synthesize_query(turn, frame)
|
||||
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 frame.attribute
|
||||
)
|
||||
result = self._retrieval.retrieve_framed(
|
||||
frame.drugs[0], frame.attribute, query,
|
||||
frame.drugs[0], section_key, query,
|
||||
is_overview=frame.turn_type == "drug_overview",
|
||||
)
|
||||
return self._grounded(query, result, frame, budget=budget)
|
||||
@@ -288,8 +394,9 @@ class RagAgent:
|
||||
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, turn)
|
||||
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:
|
||||
@@ -300,7 +407,7 @@ class RagAgent:
|
||||
"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(turn, combined, frame, budget=budget)
|
||||
return self._grounded(query, combined, frame, budget=budget)
|
||||
|
||||
def _symptom_to_drug(
|
||||
self, turn: str, frame: QueryFrame, budget: RequestBudget
|
||||
@@ -338,14 +445,15 @@ class RagAgent:
|
||||
budget: RequestBudget | None = None,
|
||||
) -> AgentReply:
|
||||
ga = self._answers.answer_from_result(
|
||||
turn, result, list_mode=list_mode, budget=budget
|
||||
turn, result, list_mode=list_mode, 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=ga.result.reason,
|
||||
reason=reason,
|
||||
answer=ga.answer,
|
||||
clarification=ga.clarification,
|
||||
citations=ga.citations,
|
||||
@@ -353,6 +461,9 @@ class RagAgent:
|
||||
turn_type=frame.turn_type,
|
||||
generated=ga.generated,
|
||||
quick_replies=ga.quick_replies,
|
||||
blocks=ga.blocks,
|
||||
answer_mode=ga.answer_mode,
|
||||
plan=ga.plan,
|
||||
)
|
||||
|
||||
def _remember(self, conversation_id: str, turn: str, reply: AgentReply) -> None:
|
||||
@@ -387,6 +498,28 @@ def _display_name(drug_id: str) -> str:
|
||||
return drug_id.replace("_", " ").title()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
_POPULATION_LABELS = {
|
||||
"tre_em": "trẻ em",
|
||||
"tre_so_sinh": "trẻ sơ sinh",
|
||||
@@ -417,7 +550,8 @@ def _synthesize_query(turn: str, frame: QueryFrame) -> str:
|
||||
|
||||
`GroundedAnswerService.answer_from_result` has no conversation history of
|
||||
its own; the `query` string it receives IS the entire context its
|
||||
sufficiency-check and generation LLM calls see. Passing the bare current
|
||||
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
|
||||
@@ -429,6 +563,11 @@ def _synthesize_query(turn: str, frame: QueryFrame) -> str:
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user