Remove corpus counts from chat chrome

This commit is contained in:
2026-08-10 17:26:58 +07:00
parent 46469468bb
commit 97cb6d16f4
31 changed files with 2192 additions and 424 deletions
+160 -21
View File
@@ -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", "", "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: