Add read-only production runtime audit
This commit is contained in:
@@ -89,6 +89,10 @@ _ALLOWED: dict[str, frozenset[str]] = {
|
||||
"/metrics",
|
||||
"/v1/rag/query",
|
||||
"/v1/rag/suggest",
|
||||
"/v1/rag/feedback",
|
||||
"/v1/rag/history",
|
||||
"/v1/rag/sections",
|
||||
"/v1/rag/section-text",
|
||||
"section",
|
||||
"overview",
|
||||
"similarity",
|
||||
|
||||
@@ -129,7 +129,8 @@ def create_app(
|
||||
def _route_label(path: str) -> str:
|
||||
known = {
|
||||
"/health", "/ready", "/metrics", "/v1/rag/query", "/v1/rag/suggest",
|
||||
"/v1/rag/feedback",
|
||||
"/v1/rag/feedback", "/v1/rag/history", "/v1/rag/sections",
|
||||
"/v1/rag/section-text",
|
||||
}
|
||||
return path if path in known else "other"
|
||||
|
||||
|
||||
@@ -28,10 +28,12 @@ from .clinical import ConditionRelation, MedicationCandidateAssessment
|
||||
from .models import EvidenceDecision, RetrievalResult
|
||||
from .policy import looks_non_human
|
||||
from .service import RetrievalService
|
||||
from .sections import SectionResolver
|
||||
from .text import normalize_name
|
||||
from .understanding import QueryFrame, QueryUnderstander
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_SECTION_RESOLVER = SectionResolver()
|
||||
|
||||
TUONG_TAC = "tuong_tac_thuoc"
|
||||
HISTORY_TURNS = 6
|
||||
@@ -142,7 +144,12 @@ class RagAgent:
|
||||
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:
|
||||
def handle(
|
||||
self,
|
||||
turn: str,
|
||||
conversation_id: str | None = None,
|
||||
response_mode: str = "ai",
|
||||
) -> AgentReply:
|
||||
# F-08: one budget per turn, threaded through every LLM call this
|
||||
# turn makes (understand, then whatever `_route` reaches).
|
||||
t0 = time.monotonic()
|
||||
@@ -154,7 +161,7 @@ class RagAgent:
|
||||
turn, tuple(history), budget=budget, prior_frame=prior_frame
|
||||
)
|
||||
t2 = time.monotonic()
|
||||
reply = self._route(turn, frame, budget)
|
||||
reply = self._route(turn, frame, budget, response_mode=response_mode)
|
||||
reply = self._enforce_clarify_circuit_breaker(conversation_id, reply)
|
||||
t3 = time.monotonic()
|
||||
if conversation_id is not None:
|
||||
@@ -242,7 +249,13 @@ class RagAgent:
|
||||
return []
|
||||
return self._history.get(conversation_id, [])
|
||||
|
||||
def _route(self, turn: str, frame: QueryFrame, budget: RequestBudget) -> AgentReply:
|
||||
def _route(
|
||||
self,
|
||||
turn: str,
|
||||
frame: QueryFrame,
|
||||
budget: RequestBudget,
|
||||
response_mode: str = "ai",
|
||||
) -> AgentReply:
|
||||
tt = frame.turn_type
|
||||
section_overview = _is_section_overview(turn, frame)
|
||||
if section_overview and not frame.section_overview:
|
||||
@@ -253,10 +266,10 @@ class RagAgent:
|
||||
# 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.",
|
||||
"abstain", "out_of_scope_non_human",
|
||||
answer="Dược thư Quốc gia Việt Nam trong hệ thống này chỉ bao "
|
||||
"phủ thuốc dùng cho người. Hệ thống không tra cứu liều "
|
||||
"dùng hoặc hướng dẫn điều trị cho động vật.",
|
||||
turn_type=tt)
|
||||
|
||||
# Dosing is a small state machine, not an unconstrained model opinion.
|
||||
@@ -407,6 +420,24 @@ class RagAgent:
|
||||
turn_type=tt,
|
||||
)
|
||||
|
||||
if response_mode == "monograph" and (
|
||||
tt == "drug_overview"
|
||||
or (
|
||||
tt == "drug_attribute"
|
||||
and frame.attribute is None
|
||||
and not frame.needs_clarify
|
||||
)
|
||||
or _is_bare_monograph_request(turn)
|
||||
) and frame.drugs:
|
||||
return AgentReply(
|
||||
"clarify", "select_drug_sections",
|
||||
clarification=(
|
||||
"Đã nhận diện chuyên luận thuốc. Anh/chị chọn các mục cần "
|
||||
"xem; nếu không chọn mục nào, hệ thống sẽ hiển thị toàn bộ."
|
||||
),
|
||||
drugs=frame.drugs, turn_type=tt,
|
||||
)
|
||||
|
||||
if tt == "drug_attribute" and frame.drugs and frame.attribute is None:
|
||||
return AgentReply(
|
||||
"clarify", "missing_attribute",
|
||||
@@ -713,6 +744,28 @@ def _is_section_overview(turn: str, frame: QueryFrame) -> bool:
|
||||
return frame.section_overview or any(cue in text for cue in overview_cues)
|
||||
|
||||
|
||||
def _is_bare_monograph_request(turn: str) -> bool:
|
||||
"""True for a plain drug name in explicit monograph-browse mode.
|
||||
|
||||
A persisted conversation can contribute a stale attribute to a new bare
|
||||
drug turn (for example the prior question was about contraindications).
|
||||
The UI mode is an explicit current-turn instruction, so a plain name must
|
||||
open the picker rather than inherit that old section. Any actual section
|
||||
phrase or clinical-question cue keeps the normal AI route.
|
||||
"""
|
||||
text = normalize_name(turn)
|
||||
if not text or len(text) > 100 or _SECTION_RESOLVER.resolve_all(turn):
|
||||
return False
|
||||
clinical_cues = (
|
||||
" dung ", " dieu tri ", " tuong tac ", " tac dung ", " lieu ",
|
||||
" benh ", " thai ", " cho con bu ", " tre em ", " nguoi lon ",
|
||||
" suy than ", " suy gan ", " di ung ", " bao nhieu ", " la gi ",
|
||||
" co the ", " duoc khong ",
|
||||
)
|
||||
padded = f" {text} "
|
||||
return not any(cue in padded for cue in clinical_cues)
|
||||
|
||||
|
||||
_POPULATION_LABELS = {
|
||||
"tre_em": "trẻ em",
|
||||
"tre_so_sinh": "trẻ sơ sinh",
|
||||
|
||||
@@ -839,6 +839,7 @@ class GroundedAnswerService:
|
||||
evidence_drug_ids: tuple[str | None, ...] = (),
|
||||
budget: RequestBudget | None = None,
|
||||
plan: AnswerPlan | None = None,
|
||||
retry_unsupported_patient_list: bool = True,
|
||||
) -> "_GenOutcome":
|
||||
"""A verified generation, a clarifying question, or empty to fall back."""
|
||||
if self._generator is None or not evidence_texts:
|
||||
@@ -935,6 +936,26 @@ class GroundedAnswerService:
|
||||
)
|
||||
return _GenOutcome(reject_reason=verification.reason)
|
||||
if not verification.supported:
|
||||
# Patient-specific candidate comparisons occasionally receive a
|
||||
# noisy negative entailment verdict even though the same evidence
|
||||
# and a fresh answer clear both fail-closed checks immediately
|
||||
# afterwards (observed in the C03 contextual renal-safety turn).
|
||||
# Retry only this known conversational lane, once. Ordinary AI
|
||||
# answers and monograph browsing are intentionally unchanged.
|
||||
if patient_specific and list_mode and retry_unsupported_patient_list:
|
||||
return self._generate(
|
||||
query,
|
||||
evidence_texts,
|
||||
prompt_evidence_texts,
|
||||
intro=intro,
|
||||
list_mode=list_mode,
|
||||
patient_specific=patient_specific,
|
||||
candidate_drug_ids=candidate_drug_ids,
|
||||
evidence_drug_ids=evidence_drug_ids,
|
||||
budget=budget,
|
||||
plan=plan,
|
||||
retry_unsupported_patient_list=False,
|
||||
)
|
||||
self._metrics.increment(
|
||||
metric_names.GENERATION_REJECTED, reason="unsupported_claim"
|
||||
)
|
||||
|
||||
@@ -314,6 +314,8 @@ class ConditionNormalizer:
|
||||
"benh gout": "gút",
|
||||
"benh gut": "gút",
|
||||
"gut": "gút",
|
||||
"viem phoi": "viêm phổi",
|
||||
"benh viem phoi": "viêm phổi",
|
||||
}
|
||||
_BROAD = frozenset({"viem gan", "ung thu", "nhiem trung", "nhiem khuan"})
|
||||
_BROAD_QUESTIONS = {
|
||||
|
||||
@@ -593,6 +593,7 @@ class LlmQueryUnderstander:
|
||||
frame = _apply_broad_condition_cue(
|
||||
frame, turn, self._condition_normalizer
|
||||
)
|
||||
frame = _apply_general_condition_scope(frame, turn)
|
||||
frame = _apply_reverse_relation_cues(frame, turn)
|
||||
section_match = _SECTION_RESOLVER.resolve(turn)
|
||||
frame = _apply_named_drug_cues(
|
||||
@@ -603,7 +604,8 @@ class LlmQueryUnderstander:
|
||||
resolved_section_phrase=(section_match.phrase if section_match else None),
|
||||
)
|
||||
frame = _apply_multi_section_clarify(frame, turn)
|
||||
return _merge_with_prior_frame(frame, prior_frame)
|
||||
frame = _merge_with_prior_frame(frame, prior_frame)
|
||||
return _apply_contextual_candidate_safety(frame, turn, prior_frame)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_id(value: str, shown: dict[str, str]) -> str | None:
|
||||
@@ -754,7 +756,7 @@ def _apply_condition_candidate_cue(
|
||||
"""Keep current medicines subordinate in an explicit condition lookup."""
|
||||
if frame.turn_type == "condition_to_drug" and frame.condition is not None:
|
||||
return frame
|
||||
condition = normalizer.detect_known_alias(turn)
|
||||
condition = frame.condition or normalizer.detect_known_alias(turn)
|
||||
if condition is None:
|
||||
return frame
|
||||
text = f" {normalize_name(turn)} "
|
||||
@@ -767,6 +769,8 @@ def _apply_condition_candidate_cue(
|
||||
" option dieu tri ",
|
||||
" ung vien nao ",
|
||||
" cac ung vien nao ",
|
||||
" co chi dinh lien quan ",
|
||||
" co chi dinh cho ",
|
||||
)
|
||||
if not any(cue in text for cue in candidate_cues):
|
||||
return frame
|
||||
@@ -782,6 +786,80 @@ def _apply_condition_candidate_cue(
|
||||
)
|
||||
|
||||
|
||||
def _apply_general_condition_scope(frame: QueryFrame, turn: str) -> QueryFrame:
|
||||
"""Do not turn a disease name into an unstated patient impairment.
|
||||
|
||||
A general reverse lookup such as ``Viêm gan B mạn dùng thuốc gì?`` names
|
||||
the condition being treated; it does not say that a particular patient has
|
||||
hepatic impairment. The understanding model can otherwise duplicate the
|
||||
same phrase into ``patient_context.hepatic`` and trigger a stage-2 safety
|
||||
review, mixing contraindication/precaution citations into a general
|
||||
indication list. Explicit patient cues keep the full context untouched.
|
||||
"""
|
||||
if frame.turn_type not in {"condition_to_drug", "symptom_to_drug"}:
|
||||
return frame
|
||||
text = f" {normalize_name(turn)} "
|
||||
patient_cues = (
|
||||
" bn ", " benh nhan ", " nguoi benh ", " kem ", " di ung ",
|
||||
" dang dung ", " mang thai ", " cho con bu ", " tuoi ", " kg ",
|
||||
" ckd ", " suy than ", " suy gan ", " child pugh ", " egfr ",
|
||||
" creatinin ", " ast ", " alt ",
|
||||
)
|
||||
if any(cue in text for cue in patient_cues):
|
||||
return frame
|
||||
primary = (
|
||||
frame.condition.normalized_condition
|
||||
if frame.condition is not None
|
||||
else frame.indication
|
||||
)
|
||||
return replace(frame, patient_context=PatientContext(primary_condition=primary))
|
||||
|
||||
|
||||
def _apply_contextual_candidate_safety(
|
||||
frame: QueryFrame,
|
||||
turn: str,
|
||||
prior_frame: QueryFrame | None,
|
||||
) -> QueryFrame:
|
||||
"""Keep ``các thuốc trên`` on the prior condition-to-drug candidate lane.
|
||||
|
||||
This follow-up asks to compare the already retrieved candidates against a
|
||||
new patient constraint. It is not a reverse disease->contraindication
|
||||
lookup, even if the current turn contains words such as ``bệnh thận``.
|
||||
"""
|
||||
if prior_frame is None or prior_frame.turn_type not in {
|
||||
"condition_to_drug", "symptom_to_drug"
|
||||
}:
|
||||
return frame
|
||||
text = f" {normalize_name(turn)} "
|
||||
refers_to_candidates = any(
|
||||
cue in text for cue in (" cac thuoc tren ", " trong cac thuoc tren ")
|
||||
)
|
||||
safety_cue = any(
|
||||
cue in text
|
||||
for cue in (
|
||||
" luu y ", " than trong ", " benh than ", " suy than ",
|
||||
" benh gan ", " suy gan ", " di ung ", " tuong tac ",
|
||||
)
|
||||
)
|
||||
if not (refers_to_candidates and safety_cue):
|
||||
return frame
|
||||
condition = frame.condition or prior_frame.condition
|
||||
return replace(
|
||||
frame,
|
||||
turn_type="condition_to_drug",
|
||||
indication=(
|
||||
condition.normalized_condition
|
||||
if condition is not None
|
||||
else frame.indication or prior_frame.indication
|
||||
),
|
||||
condition=condition,
|
||||
condition_relation=ConditionRelation.INDICATION,
|
||||
needs_clarify=False,
|
||||
clarify_reason=None,
|
||||
quick_replies=(),
|
||||
)
|
||||
|
||||
|
||||
def _apply_broad_condition_cue(
|
||||
frame: QueryFrame, turn: str, normalizer: ConditionNormalizer
|
||||
) -> QueryFrame:
|
||||
@@ -1037,7 +1115,17 @@ def _merge_with_prior_frame(frame: QueryFrame, prior_frame: QueryFrame | None) -
|
||||
indication=indication or prior_frame.indication,
|
||||
condition=condition,
|
||||
patient_context=patient_context,
|
||||
attribute=frame.attribute or prior_frame.attribute,
|
||||
# A current drug-attribute clarify with no attribute is an explicit
|
||||
# ambiguity signal (for example, the user named both "chỉ định" and
|
||||
# "chống chỉ định"). Re-inheriting the previous turn's attribute here
|
||||
# silently picks one of those sections and poisons the frame remembered
|
||||
# for the next quick reply. Other continuation shapes still inherit the
|
||||
# prior slot as before (notably pediatric dosing clarifications).
|
||||
attribute=(
|
||||
frame.attribute
|
||||
if frame.turn_type == "drug_attribute" and frame.needs_clarify
|
||||
else frame.attribute or prior_frame.attribute
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from typing import Annotated, Any, Literal, Protocol
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from rag.answer import DISCLAIMER, GroundedAnswerService
|
||||
@@ -36,6 +36,7 @@ class RagQueryRequest(BaseModel):
|
||||
# (follow-up inheritance, clarify, smalltalk). Absent → single-turn, exactly
|
||||
# as before, so existing callers are unchanged.
|
||||
conversation_id: str | None = Field(default=None, max_length=128)
|
||||
response_mode: Literal["ai", "monograph"] = "ai"
|
||||
|
||||
|
||||
class CitationResponse(BaseModel):
|
||||
@@ -198,7 +199,7 @@ _HISTORY_LIMIT = 50
|
||||
|
||||
@router.get("/history", response_model=HistoryResponse)
|
||||
def list_history(
|
||||
conversation_id: str,
|
||||
conversation_id: Annotated[str, Query(max_length=128)],
|
||||
traces: Annotated[TraceWriter, Depends(_trace_writer)],
|
||||
) -> HistoryResponse:
|
||||
"""Feature-List #25: past queries for one session, most recent first, so
|
||||
@@ -430,7 +431,11 @@ def query_rag(
|
||||
# then routes to the safety-verified retrieval + grounded-answer
|
||||
# engine. Replaces the old fuzzy resolver + keyword section router +
|
||||
# manual follow-up inheritance for both single- and multi-turn.
|
||||
reply = agent.handle(payload.query, payload.conversation_id)
|
||||
reply = agent.handle(
|
||||
payload.query,
|
||||
payload.conversation_id,
|
||||
response_mode=payload.response_mode,
|
||||
)
|
||||
decision = reply.decision
|
||||
reason = reply.reason
|
||||
answer = reply.clarification if reply.clarification is not None else reply.answer
|
||||
|
||||
@@ -127,7 +127,8 @@ def test_veterinary_phrase_abstains_even_if_the_model_missed_it():
|
||||
agent = _agent(QueryFrame(turn_type="drug_attribute", drugs=("metformin",)))
|
||||
reply = agent.handle("liều metformin cho chó bao nhiêu")
|
||||
assert reply.decision == "abstain"
|
||||
assert reply.reason == "out_of_scope"
|
||||
assert reply.reason == "out_of_scope_non_human"
|
||||
assert "chỉ bao phủ thuốc dùng cho người" in reply.answer
|
||||
|
||||
|
||||
def test_unknown_drug_name_is_reported_not_substituted():
|
||||
@@ -145,7 +146,7 @@ def test_no_drug_named_asks_which_one():
|
||||
assert reply.reason == "no_drug"
|
||||
|
||||
|
||||
def test_drug_attribute_without_an_attribute_does_not_fall_into_overview_retrieval():
|
||||
def test_drug_attribute_without_an_attribute_keeps_ai_clarification_without_retrieval():
|
||||
retrieval = _FixedRetrieval({})
|
||||
answers = GroundedAnswerService(routing=None)
|
||||
agent = RagAgent(
|
||||
@@ -163,6 +164,65 @@ def test_drug_attribute_without_an_attribute_does_not_fall_into_overview_retriev
|
||||
assert retrieval.calls == []
|
||||
|
||||
|
||||
def test_bare_drug_overview_opens_section_picker_without_retrieval():
|
||||
retrieval = _FixedRetrieval({})
|
||||
agent = RagAgent(
|
||||
_FixedUnderstander(QueryFrame(
|
||||
turn_type="drug_overview", drugs=("metformin",)
|
||||
)),
|
||||
retrieval,
|
||||
GroundedAnswerService(routing=None),
|
||||
)
|
||||
|
||||
reply = agent.handle("Metformin", response_mode="monograph")
|
||||
|
||||
assert reply.decision == "clarify"
|
||||
assert reply.reason == "select_drug_sections"
|
||||
assert reply.drugs == ("metformin",)
|
||||
assert retrieval.calls == []
|
||||
|
||||
|
||||
def test_monograph_bare_drug_ignores_stale_inherited_attribute():
|
||||
retrieval = _FixedRetrieval({})
|
||||
agent = RagAgent(
|
||||
_FixedUnderstander(QueryFrame(
|
||||
turn_type="drug_attribute",
|
||||
drugs=("metformin",),
|
||||
attribute="chong_chi_dinh",
|
||||
)),
|
||||
retrieval,
|
||||
GroundedAnswerService(routing=None),
|
||||
)
|
||||
|
||||
reply = agent.handle("Metformin", response_mode="monograph")
|
||||
|
||||
assert reply.reason == "select_drug_sections"
|
||||
assert retrieval.calls == []
|
||||
|
||||
|
||||
def test_monograph_mode_keeps_explicit_attribute_on_ai_route():
|
||||
result = RetrievalResult(
|
||||
EvidenceDecision.ABSTAIN, "not_configured", resolved_drug_id="metformin"
|
||||
)
|
||||
retrieval = _FixedRetrieval({"metformin": result})
|
||||
agent = RagAgent(
|
||||
_FixedUnderstander(QueryFrame(
|
||||
turn_type="drug_attribute",
|
||||
drugs=("metformin",),
|
||||
attribute="chong_chi_dinh",
|
||||
)),
|
||||
retrieval,
|
||||
GroundedAnswerService(routing=None),
|
||||
)
|
||||
|
||||
reply = agent.handle(
|
||||
"Chống chỉ định của Metformin là gì?", response_mode="monograph"
|
||||
)
|
||||
|
||||
assert reply.reason != "select_drug_sections"
|
||||
assert retrieval.calls[0][1] == "chong_chi_dinh"
|
||||
|
||||
|
||||
# --- the pediatric dosing gate. This code path gained its first test
|
||||
# coverage on 2026-08-11, after driving production reproduced the same
|
||||
# behaviour 5/5: the clarify question asked for both age and weight every
|
||||
|
||||
@@ -5,7 +5,7 @@ from fastapi.testclient import TestClient
|
||||
from adapters.prometheus import PrometheusMetrics
|
||||
from adapters.postgres import FeedbackTraceNotFound, RetrievalTrace
|
||||
from config import Settings
|
||||
from main import create_app
|
||||
from main import _route_label, create_app
|
||||
from rag.agent import AgentReply
|
||||
from rag.answer import DISCLAIMER, Citation, GroundedAnswerService
|
||||
from rag.metrics import TRACE_WRITE_FAILED, InMemoryMetrics
|
||||
@@ -150,6 +150,18 @@ def test_history_for_unknown_conversation_is_empty_not_an_error():
|
||||
assert response.json() == {"items": []}
|
||||
|
||||
|
||||
def test_history_rejects_an_oversized_conversation_id_before_querying_storage():
|
||||
traces = FakeHistoryTraceWriter({})
|
||||
app = create_app(settings=Settings(), trace_writer=traces)
|
||||
|
||||
response = TestClient(app).get(
|
||||
"/v1/rag/history", params={"conversation_id": "x" * 129}
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert traces.calls == []
|
||||
|
||||
|
||||
def test_health_and_fail_closed_rag_response_are_traced():
|
||||
traces = MemoryTraceWriter()
|
||||
app = create_app(
|
||||
@@ -204,6 +216,19 @@ def _metrics_app(**settings_kwargs):
|
||||
)
|
||||
|
||||
|
||||
def test_all_public_rag_endpoints_have_bounded_request_metric_labels():
|
||||
paths = (
|
||||
"/v1/rag/query",
|
||||
"/v1/rag/suggest",
|
||||
"/v1/rag/feedback",
|
||||
"/v1/rag/history",
|
||||
"/v1/rag/sections",
|
||||
"/v1/rag/section-text",
|
||||
)
|
||||
|
||||
assert {_route_label(path) for path in paths} == set(paths)
|
||||
|
||||
|
||||
def test_metrics_stays_open_when_no_token_is_configured():
|
||||
"""The default must not break the existing Compose scrape or local runs —
|
||||
the endpoint is not internet-reachable in that topology."""
|
||||
@@ -252,10 +277,15 @@ class FakeAgent:
|
||||
|
||||
def __init__(self, reply: AgentReply) -> None:
|
||||
self._reply = reply
|
||||
self.calls: list[tuple[str, str | None]] = []
|
||||
self.calls: list[tuple[str, str | None, str]] = []
|
||||
|
||||
def handle(self, turn: str, conversation_id: str | None = None) -> AgentReply:
|
||||
self.calls.append((turn, conversation_id))
|
||||
def handle(
|
||||
self,
|
||||
turn: str,
|
||||
conversation_id: str | None = None,
|
||||
response_mode: str = "ai",
|
||||
) -> AgentReply:
|
||||
self.calls.append((turn, conversation_id, response_mode))
|
||||
return self._reply
|
||||
|
||||
def complete(self, prefix: str, k: int = 8) -> list[str]:
|
||||
@@ -291,7 +321,34 @@ def test_query_routes_through_the_agent_when_one_is_configured():
|
||||
assert body["answer"] == "Liều 500 mg [1]."
|
||||
assert body["resolved_drug_id"] == "metformin"
|
||||
assert len(body["citations"]) == 1
|
||||
assert agent.calls == [("Liều metformin?", "c1")]
|
||||
assert agent.calls == [("Liều metformin?", "c1", "ai")]
|
||||
|
||||
|
||||
def test_query_forwards_monograph_response_mode_to_agent():
|
||||
agent = FakeAgent(AgentReply(
|
||||
decision="clarify",
|
||||
reason="select_drug_sections",
|
||||
clarification="Chọn mục cần xem.",
|
||||
drugs=("metformin",),
|
||||
turn_type="drug_overview",
|
||||
))
|
||||
app = create_app(
|
||||
settings=Settings(),
|
||||
answer_service=GroundedAnswerService(FixedRouting()),
|
||||
conversational=agent,
|
||||
trace_writer=MemoryTraceWriter(),
|
||||
)
|
||||
|
||||
response = TestClient(app).post("/v1/rag/query", json={
|
||||
"query": "Metformin",
|
||||
"subject_scope": "human",
|
||||
"intent": "fact_lookup",
|
||||
"response_mode": "monograph",
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["reason"] == "select_drug_sections"
|
||||
assert agent.calls == [("Metformin", None, "monograph")]
|
||||
|
||||
|
||||
def test_query_agent_clarification_is_surfaced_as_the_answer():
|
||||
|
||||
@@ -11,6 +11,7 @@ from rag.clinical import (
|
||||
ConditionNormalizer,
|
||||
ConditionQuery,
|
||||
ConditionRelation,
|
||||
HepaticContext,
|
||||
PatientContext,
|
||||
RenalContext,
|
||||
)
|
||||
@@ -20,6 +21,8 @@ from rag.understanding import (
|
||||
LlmQueryUnderstander,
|
||||
QueryFrame,
|
||||
_apply_condition_candidate_cue,
|
||||
_apply_contextual_candidate_safety,
|
||||
_apply_general_condition_scope,
|
||||
_apply_named_drug_cues,
|
||||
_apply_reverse_relation_cues,
|
||||
_merge_with_prior_frame,
|
||||
@@ -72,9 +75,104 @@ def test_condition_normalizer_handles_professional_aliases_without_drug_mapping(
|
||||
assert normalizer.normalize("THA dùng gì", "THA").normalized_condition == "tăng huyết áp"
|
||||
assert normalizer.normalize("cao huyết áp", "cao huyết áp").normalized_condition == "tăng huyết áp"
|
||||
assert normalizer.normalize("Gout", "gout").normalized_condition == "gút"
|
||||
assert normalizer.detect_known_alias("BN viêm phổi dùng thuốc gì?").normalized_condition == "viêm phổi"
|
||||
assert normalizer.normalize("bệnh lạ", "bệnh lạ").normalized_condition == "bệnh lạ"
|
||||
|
||||
|
||||
def test_explicit_indication_relation_is_a_condition_candidate_lookup():
|
||||
noisy = QueryFrame(
|
||||
turn_type="condition_relation",
|
||||
condition=ConditionNormalizer().normalize("bệnh gút", "gút"),
|
||||
needs_clarify=True,
|
||||
clarify_reason="Hỏi lại sai hướng",
|
||||
)
|
||||
|
||||
frame = _apply_condition_candidate_cue(
|
||||
noisy,
|
||||
"Thuốc nào có chỉ định liên quan bệnh gút?",
|
||||
ConditionNormalizer(),
|
||||
)
|
||||
|
||||
assert frame.turn_type == "condition_to_drug"
|
||||
assert frame.condition_relation == ConditionRelation.INDICATION
|
||||
assert frame.needs_clarify is False
|
||||
|
||||
|
||||
def test_general_condition_does_not_invent_patient_hepatic_context():
|
||||
frame = QueryFrame(
|
||||
turn_type="condition_to_drug",
|
||||
indication="viêm gan B mạn",
|
||||
condition=ConditionQuery(
|
||||
original_query="Viêm gan B mạn dùng thuốc gì?",
|
||||
normalized_condition="viêm gan B mạn",
|
||||
),
|
||||
patient_context=PatientContext(
|
||||
primary_condition="viêm gan B mạn",
|
||||
hepatic=HepaticContext(description="viêm gan B mạn"),
|
||||
),
|
||||
)
|
||||
|
||||
cleaned = _apply_general_condition_scope(
|
||||
frame, "Viêm gan B mạn dùng thuốc gì?"
|
||||
)
|
||||
|
||||
assert cleaned.patient_context.primary_condition == "viêm gan B mạn"
|
||||
assert cleaned.patient_context.requires_safety_review is False
|
||||
|
||||
|
||||
def test_patient_allergy_condition_lookup_keeps_safety_context():
|
||||
patient = PatientContext(
|
||||
primary_condition="viêm phổi", allergies=("penicillin",)
|
||||
)
|
||||
frame = QueryFrame(
|
||||
turn_type="condition_to_drug",
|
||||
condition=ConditionQuery(
|
||||
original_query="BN dị ứng penicillin, viêm phổi dùng thuốc gì?",
|
||||
normalized_condition="viêm phổi",
|
||||
),
|
||||
patient_context=patient,
|
||||
)
|
||||
|
||||
kept = _apply_general_condition_scope(
|
||||
frame, "BN dị ứng penicillin, viêm phổi dùng thuốc gì?"
|
||||
)
|
||||
|
||||
assert kept.patient_context == patient
|
||||
assert kept.patient_context.requires_safety_review is True
|
||||
|
||||
|
||||
def test_candidate_safety_followup_stays_on_prior_condition_lookup():
|
||||
prior = QueryFrame(
|
||||
turn_type="condition_to_drug",
|
||||
indication="tăng huyết áp",
|
||||
condition=ConditionQuery(
|
||||
original_query="BN bị tăng huyết áp",
|
||||
normalized_condition="tăng huyết áp",
|
||||
),
|
||||
patient_context=PatientContext(
|
||||
age_text="68 tuổi",
|
||||
renal=RenalContext(description="CKD", ckd_stage="G4"),
|
||||
),
|
||||
)
|
||||
noisy = QueryFrame(
|
||||
turn_type="condition_relation",
|
||||
condition_relation=ConditionRelation.CONTRAINDICATION,
|
||||
depends_on_previous_turn=True,
|
||||
patient_context=prior.patient_context,
|
||||
needs_clarify=False,
|
||||
)
|
||||
|
||||
corrected = _apply_contextual_candidate_safety(
|
||||
noisy,
|
||||
"Trong các thuốc trên cái nào cần lưu ý hơn với bệnh thận?",
|
||||
prior,
|
||||
)
|
||||
|
||||
assert corrected.turn_type == "condition_to_drug"
|
||||
assert corrected.condition == prior.condition
|
||||
assert corrected.condition_relation == ConditionRelation.INDICATION
|
||||
|
||||
|
||||
def test_broad_condition_is_clarified_but_specific_subtype_is_not():
|
||||
normalizer = ConditionNormalizer()
|
||||
broad = normalizer.normalize("Viêm gan dùng thuốc gì?", "viêm gan")
|
||||
|
||||
@@ -7,6 +7,7 @@ from the real METFORMIN and PARACETAMOL sections in `duocthu_v1`.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -516,6 +517,43 @@ def test_a_real_negative_verdict_is_still_an_unsupported_claim():
|
||||
assert metrics.total(GENERATION_REJECTED, reason="request_budget_exhausted") == 0
|
||||
|
||||
|
||||
def test_patient_candidate_list_retries_one_noisy_entailment_rejection():
|
||||
metrics = InMemoryMetrics()
|
||||
result = _result()
|
||||
result = replace(
|
||||
result,
|
||||
evidence=(replace(result.evidence[0], drug_id="metformin"),),
|
||||
)
|
||||
generator = _Generator(
|
||||
[
|
||||
{"claims": [{"drug_id": "metformin", "text": "Người lớn uống 500 mg", "citations": [1]}],
|
||||
"evidence_sufficient": True},
|
||||
{"claims": [{"drug_id": "metformin", "text": "Người lớn uống 500 mg", "citations": [1]}],
|
||||
"evidence_sufficient": True},
|
||||
],
|
||||
entailment_payload=[
|
||||
{"entailed": False, "unsupported": [1]},
|
||||
{"entailed": True, "unsupported": [], "complete": True},
|
||||
],
|
||||
)
|
||||
service = GroundedAnswerService(_FixedRouting(result), generator, metrics)
|
||||
|
||||
grounded = service.answer_from_result(
|
||||
"Trong các thuốc trên thuốc nào cần lưu ý hơn với bệnh thận?",
|
||||
result,
|
||||
list_mode=True,
|
||||
patient_specific=True,
|
||||
candidate_drug_ids=("metformin",),
|
||||
prechecked=True,
|
||||
)
|
||||
|
||||
assert grounded.generated is True
|
||||
assert grounded.result.decision == EvidenceDecision.ANSWERABLE
|
||||
assert generator._call == 2
|
||||
assert generator._entailment_call == 2
|
||||
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 0
|
||||
|
||||
|
||||
def test_entailment_check_is_skipped_when_there_are_no_claims():
|
||||
"""No claims at all (2026-08-10: the structured-claims schema makes a
|
||||
claim's `text` a required, non-empty field, so the old "answer is
|
||||
|
||||
@@ -16,6 +16,7 @@ from rag.understanding import (
|
||||
SECTION_KEYS,
|
||||
LlmQueryUnderstander,
|
||||
QueryFrame,
|
||||
_merge_with_prior_frame,
|
||||
)
|
||||
|
||||
CATALOG = {
|
||||
@@ -162,6 +163,29 @@ def test_single_section_named_is_unaffected_by_the_multi_section_clarify():
|
||||
assert frame.needs_clarify is False
|
||||
|
||||
|
||||
def test_multi_section_clarify_does_not_inherit_a_stale_prior_attribute():
|
||||
prior = QueryFrame(
|
||||
turn_type="drug_attribute",
|
||||
drugs=("paracetamol_acetaminophen",),
|
||||
attribute="lieu_luong_va_cach_dung",
|
||||
needs_clarify=True,
|
||||
clarify_reason="Anh/chị muốn tra gì?",
|
||||
)
|
||||
current = QueryFrame(
|
||||
turn_type="drug_attribute",
|
||||
drugs=("paracetamol_acetaminophen",),
|
||||
attribute=None,
|
||||
needs_clarify=True,
|
||||
clarify_reason="Anh/chị muốn xem mục nào trước?",
|
||||
quick_replies=("Chỉ định", "Chống chỉ định"),
|
||||
)
|
||||
|
||||
merged = _merge_with_prior_frame(current, prior)
|
||||
|
||||
assert merged.attribute is None
|
||||
assert merged.quick_replies == ("Chỉ định", "Chống chỉ định")
|
||||
|
||||
|
||||
def test_exact_candidate_does_not_repeat_the_catalog_wide_fuzzy_scan():
|
||||
resolver = _FakeResolver({"metformin": "metformin"})
|
||||
understander = LlmQueryUnderstander(_FixedLlm({
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import type { ChatMessage, Citation, SendMessageResponse } from "@duoc-thu/shared-types";
|
||||
import type {
|
||||
AnswerBlock,
|
||||
ChatMessage,
|
||||
Citation,
|
||||
DrugSectionOption,
|
||||
MonographPickerState,
|
||||
SendMessageResponse,
|
||||
} from "@duoc-thu/shared-types";
|
||||
import { ChatBubble, CitationBeamOverlay, useTheme } from "@duoc-thu/ui";
|
||||
import { Composer } from "./Composer";
|
||||
import { AnswerFeedback } from "./AnswerFeedback";
|
||||
@@ -27,9 +34,36 @@ interface ChatPanelProps {
|
||||
onCitationClick?: (citation: Citation, index: number, allCitations: Citation[]) => void;
|
||||
onCitationsLoaded?: (citations: Citation[]) => void;
|
||||
activeCitationIndex?: number | null;
|
||||
monographPicker?: MonographPickerState | null;
|
||||
onMonographChange?: (picker: MonographPickerState | null) => void;
|
||||
onToggleSection?: (sectionKey: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface SectionTextResponse {
|
||||
drug_id: string;
|
||||
section_key: string;
|
||||
section_title: string | null;
|
||||
parts: Array<{
|
||||
part_index: number | null;
|
||||
text: string;
|
||||
is_quarantined: boolean;
|
||||
printed_page_start: number | null;
|
||||
printed_page_end: number | null;
|
||||
physical_page: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
const MONOGRAPH_DISCLAIMER =
|
||||
"Nội dung nguyên văn được lấy từ Dược thư Quốc gia Việt Nam 2018, phục vụ tra cứu chuyên môn và không thay thế chỉ định của bác sĩ hoặc dược sĩ lâm sàng.";
|
||||
|
||||
const QUICK_SECTION_KEYS = [
|
||||
"chi_dinh",
|
||||
"lieu_luong_va_cach_dung",
|
||||
"chong_chi_dinh",
|
||||
"than_trong",
|
||||
];
|
||||
|
||||
// The client must never be the thing that gives up first.
|
||||
//
|
||||
// The backend's own per-request budget is 40s (`max_wall_clock_ms` in
|
||||
@@ -84,12 +118,16 @@ export function ChatPanel({
|
||||
onCitationClick,
|
||||
onCitationsLoaded,
|
||||
activeCitationIndex = null,
|
||||
monographPicker,
|
||||
onMonographChange,
|
||||
onToggleSection,
|
||||
className,
|
||||
}: ChatPanelProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [responseMode, setResponseMode] = useState<"ai" | "monograph">("ai");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const initialQuerySentRef = useRef<number | undefined>(undefined);
|
||||
@@ -136,6 +174,7 @@ export function ChatPanel({
|
||||
body: JSON.stringify({
|
||||
content: userText,
|
||||
conversationId: sessionId,
|
||||
responseMode,
|
||||
}),
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
@@ -145,7 +184,56 @@ export function ChatPanel({
|
||||
}
|
||||
|
||||
const data: SendMessageResponse = await res.json();
|
||||
const assistantMsg = data.message;
|
||||
let assistantMsg = data.message;
|
||||
|
||||
if (
|
||||
assistantMsg.reason === "select_drug_sections" &&
|
||||
assistantMsg.resolvedDrugId &&
|
||||
!assistantMsg.resolvedDrugId.includes(",")
|
||||
) {
|
||||
const drugId = assistantMsg.resolvedDrugId;
|
||||
const [sectionsResponse, suggestionResponse] = await Promise.all([
|
||||
fetch(`/api/sections?drug_id=${encodeURIComponent(drugId)}`, {
|
||||
cache: "no-store",
|
||||
signal: abortControllerRef.current.signal,
|
||||
}),
|
||||
fetch(`/api/suggest?q=${encodeURIComponent(userText)}`, {
|
||||
cache: "no-store",
|
||||
signal: abortControllerRef.current.signal,
|
||||
}),
|
||||
]);
|
||||
if (!sectionsResponse.ok) {
|
||||
throw new Error("section_list_unavailable");
|
||||
}
|
||||
const rawSections = (await sectionsResponse.json()) as {
|
||||
sections?: Array<{ section_key: string; section_title: string }>;
|
||||
};
|
||||
const suggestionData = suggestionResponse.ok
|
||||
? ((await suggestionResponse.json()) as { suggestions?: string[] })
|
||||
: {};
|
||||
const sections: DrugSectionOption[] = (rawSections.sections ?? []).map(
|
||||
(section) => ({
|
||||
sectionKey: section.section_key,
|
||||
sectionTitle: section.section_title,
|
||||
})
|
||||
);
|
||||
const drugName = suggestionData.suggestions?.[0] ?? userText.trim();
|
||||
const picker: MonographPickerState = {
|
||||
drugId,
|
||||
drugName,
|
||||
sections,
|
||||
selectedSectionKeys: [],
|
||||
};
|
||||
assistantMsg = {
|
||||
...assistantMsg,
|
||||
content:
|
||||
`Chuyên luận ${drugName} có ${sections.length} mục. ` +
|
||||
"Chọn các mục cần xem ở cột bên phải rồi nhấn Gửi tra cứu — " +
|
||||
"nếu không chọn mục nào, hệ thống sẽ hiển thị toàn bộ.",
|
||||
sectionOptions: sections,
|
||||
};
|
||||
onMonographChange?.(picker);
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
|
||||
@@ -175,6 +263,129 @@ export function ChatPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitMonograph = async () => {
|
||||
if (!monographPicker || isLoading) return;
|
||||
const selected = monographPicker.selectedSectionKeys.length
|
||||
? monographPicker.sections.filter((section) =>
|
||||
monographPicker.selectedSectionKeys.includes(section.sectionKey)
|
||||
)
|
||||
: monographPicker.sections;
|
||||
if (selected.length === 0) {
|
||||
setError("Chuyên luận này chưa có mục văn bản để hiển thị.");
|
||||
return;
|
||||
}
|
||||
|
||||
const label = monographPicker.selectedSectionKeys.length
|
||||
? selected.map((section) => section.sectionTitle).join(", ")
|
||||
: "Toàn bộ chuyên luận";
|
||||
const userMsg: ChatMessage = {
|
||||
id: `user-monograph-${Date.now()}`,
|
||||
role: "user",
|
||||
content: `${monographPicker.drugName} — ${label}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
stopRequestedRef.current = false;
|
||||
abortControllerRef.current = new AbortController();
|
||||
const timeoutId = window.setTimeout(
|
||||
() => abortControllerRef.current?.abort(),
|
||||
45_000
|
||||
);
|
||||
|
||||
try {
|
||||
const responses = await Promise.all(
|
||||
selected.map(async (section) => {
|
||||
const response = await fetch(
|
||||
`/api/section-text?drug_id=${encodeURIComponent(
|
||||
monographPicker.drugId
|
||||
)}§ion_key=${encodeURIComponent(section.sectionKey)}`,
|
||||
{ cache: "no-store", signal: abortControllerRef.current!.signal }
|
||||
);
|
||||
if (!response.ok) throw new Error("section_text_unavailable");
|
||||
return (await response.json()) as SectionTextResponse;
|
||||
})
|
||||
);
|
||||
|
||||
const citations: Citation[] = [];
|
||||
const blocks: AnswerBlock[] = [];
|
||||
for (const response of responses) {
|
||||
const claims: AnswerBlock["claims"] = [];
|
||||
for (const [index, part] of response.parts.entries()) {
|
||||
if (
|
||||
part.printed_page_start == null ||
|
||||
part.printed_page_end == null ||
|
||||
part.physical_page == null
|
||||
) {
|
||||
throw new Error("section_provenance_missing");
|
||||
}
|
||||
const chunkId = `${response.drug_id}__${response.section_key}__${
|
||||
part.part_index ?? index
|
||||
}`;
|
||||
citations.push({
|
||||
chunkId,
|
||||
drugName: monographPicker.drugName.toUpperCase(),
|
||||
sectionType: response.section_key,
|
||||
sourceDocument: "Dược thư Quốc gia Việt Nam 2018",
|
||||
sourcePageRange: [part.printed_page_start, part.printed_page_end],
|
||||
physicalPage: part.physical_page,
|
||||
snippet: part.text,
|
||||
isQuarantined: part.is_quarantined,
|
||||
quarantineNotice: part.is_quarantined
|
||||
? "Mục này có bảng hoặc công thức cần đối chiếu trực tiếp trang PDF gốc."
|
||||
: undefined,
|
||||
});
|
||||
claims.push({ text: part.text, sourceIds: [chunkId] });
|
||||
}
|
||||
blocks.push({
|
||||
title:
|
||||
response.section_title ??
|
||||
selected.find((item) => item.sectionKey === response.section_key)
|
||||
?.sectionTitle ??
|
||||
response.section_key,
|
||||
kind: "fact_list",
|
||||
claims,
|
||||
});
|
||||
}
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: `monograph-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: `Nguyên văn ${selected.length} mục của ${monographPicker.drugName}.`,
|
||||
citations,
|
||||
disclaimer: MONOGRAPH_DISCLAIMER,
|
||||
decision: "answerable",
|
||||
reason: "verbatim_sections",
|
||||
grounded: true,
|
||||
generated: false,
|
||||
resolvedDrugId: monographPicker.drugId,
|
||||
blocks,
|
||||
answerMode: "detailed",
|
||||
answerPlan: {
|
||||
verbosity: "detailed",
|
||||
layout: "bullet_list",
|
||||
reasoningMode: "direct_lookup",
|
||||
showHeading: true,
|
||||
needsWarning: false,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
onCitationsLoaded?.(citations);
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err?.name === "AbortError"
|
||||
? "Đã dừng tải chuyên luận."
|
||||
: "Không thể tải đầy đủ nguyên văn các mục đã chọn. Vui lòng thử lại."
|
||||
);
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
setIsLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (abortControllerRef.current) {
|
||||
stopRequestedRef.current = true;
|
||||
@@ -182,6 +393,13 @@ export function ChatPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handleResponseModeChange = (mode: "ai" | "monograph") => {
|
||||
setResponseMode(mode);
|
||||
if (mode === "ai") {
|
||||
onMonographChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => abortControllerRef.current?.abort();
|
||||
}, []);
|
||||
@@ -387,8 +605,38 @@ export function ChatPanel({
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{msg.role === "assistant" &&
|
||||
msg.sectionOptions &&
|
||||
msg.sectionOptions.length > 0 && (
|
||||
<div className="ml-10 flex flex-wrap gap-2 px-4 pb-2">
|
||||
{QUICK_SECTION_KEYS.flatMap((key) => {
|
||||
const section = msg.sectionOptions?.find(
|
||||
(item) => item.sectionKey === key
|
||||
);
|
||||
if (!section) return [];
|
||||
const selected =
|
||||
monographPicker?.selectedSectionKeys.includes(key) ?? false;
|
||||
return [
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onToggleSection?.(key)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1.5 text-xs font-semibold transition-colors",
|
||||
selected
|
||||
? "border-accent-primary bg-accent-primary text-txt-inverse"
|
||||
: "border-border-accent bg-accent-soft text-accent-primary hover:bg-accent-primary hover:text-txt-inverse"
|
||||
)}
|
||||
>
|
||||
{section.sectionTitle}
|
||||
</button>,
|
||||
];
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{msg.role === "assistant" &&
|
||||
msg.traceId &&
|
||||
msg.reason !== "select_drug_sections" &&
|
||||
!msg.traceId.startsWith("fallback-") && (
|
||||
<AnswerFeedback traceId={msg.traceId} conversationId={sessionId} />
|
||||
)}
|
||||
@@ -438,7 +686,17 @@ export function ChatPanel({
|
||||
|
||||
{/* Fixed Composer Bottom Bar */}
|
||||
<div className="p-3 sm:p-4 border-t border-border-subtle bg-surface-elevated/60 backdrop-blur-md">
|
||||
<Composer onSubmit={handleSendMessage} isLoading={isLoading} onStop={handleStop} />
|
||||
<Composer
|
||||
onSubmit={handleSendMessage}
|
||||
isLoading={isLoading}
|
||||
onStop={handleStop}
|
||||
monographPicker={monographPicker}
|
||||
onToggleSection={onToggleSection}
|
||||
onClearMonograph={() => onMonographChange?.(null)}
|
||||
onSubmitMonograph={handleSubmitMonograph}
|
||||
responseMode={responseMode}
|
||||
onResponseModeChange={handleResponseModeChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Send, Square, Sparkles, Pill, Search, Command } from "lucide-react";
|
||||
import type { MonographPickerState } from "@duoc-thu/shared-types";
|
||||
import { Send, Square, Sparkles, Pill, Search, Command, X, BookOpen } from "lucide-react";
|
||||
import { cn } from "@duoc-thu/ui";
|
||||
|
||||
interface ComposerProps {
|
||||
@@ -9,6 +10,12 @@ interface ComposerProps {
|
||||
isLoading?: boolean;
|
||||
onStop?: () => void;
|
||||
initialValue?: string;
|
||||
monographPicker?: MonographPickerState | null;
|
||||
onToggleSection?: (sectionKey: string) => void;
|
||||
onClearMonograph?: () => void;
|
||||
onSubmitMonograph?: () => void;
|
||||
responseMode?: "ai" | "monograph";
|
||||
onResponseModeChange?: (mode: "ai" | "monograph") => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -17,6 +24,12 @@ export function Composer({
|
||||
isLoading = false,
|
||||
onStop,
|
||||
initialValue = "",
|
||||
monographPicker,
|
||||
onToggleSection,
|
||||
onClearMonograph,
|
||||
onSubmitMonograph,
|
||||
responseMode = "ai",
|
||||
onResponseModeChange,
|
||||
className,
|
||||
}: ComposerProps) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
@@ -103,7 +116,12 @@ export function Composer({
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || isLoading) return;
|
||||
if (isLoading) return;
|
||||
if (!trimmed && monographPicker) {
|
||||
onSubmitMonograph?.();
|
||||
return;
|
||||
}
|
||||
if (!trimmed) return;
|
||||
onSubmit(trimmed);
|
||||
setValue("");
|
||||
setSuggestions([]);
|
||||
@@ -181,6 +199,36 @@ export function Composer({
|
||||
|
||||
{/* Main Composer Box */}
|
||||
<div className="relative flex flex-col rounded-3xl border border-border-subtle bg-surface p-2 shadow-surface transition-all focus-within:border-border-accent focus-within:shadow-elevated glass-panel">
|
||||
{monographPicker && (
|
||||
<div className="flex flex-wrap items-center gap-2 px-2 pt-1 pb-2 border-b border-border-subtle/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearMonograph}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-accent-primary px-3 py-1.5 text-xs font-bold text-txt-inverse"
|
||||
>
|
||||
<Pill className="h-3.5 w-3.5" />
|
||||
{monographPicker.drugName}
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{monographPicker.selectedSectionKeys.map((sectionKey) => {
|
||||
const section = monographPicker.sections.find(
|
||||
(item) => item.sectionKey === sectionKey
|
||||
);
|
||||
if (!section) return null;
|
||||
return (
|
||||
<button
|
||||
key={sectionKey}
|
||||
type="button"
|
||||
onClick={() => onToggleSection?.(sectionKey)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-border-accent bg-accent-soft px-3 py-1.5 text-xs font-semibold text-accent-primary"
|
||||
>
|
||||
{section.sectionTitle}
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
@@ -192,9 +240,33 @@ export function Composer({
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 pt-2 px-2 border-t border-border-subtle/50">
|
||||
<div className="flex items-center gap-1.5 text-[0.7rem] text-txt-muted">
|
||||
<Command className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">Nhấn Enter để gửi • Shift+Enter để xuống dòng</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onResponseModeChange?.(
|
||||
responseMode === "ai" ? "monograph" : "ai"
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1.5 text-[0.7rem] font-bold transition-colors",
|
||||
responseMode === "monograph"
|
||||
? "border-border-accent bg-accent-soft text-accent-primary"
|
||||
: "border-border-subtle bg-surface-elevated text-txt-secondary hover:text-accent-primary"
|
||||
)}
|
||||
title="Chuyển giữa AI tổng hợp và tra nguyên văn chuyên luận"
|
||||
>
|
||||
{responseMode === "ai" ? (
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{responseMode === "ai" ? "AI tổng hợp" : "Chuyên luận"}
|
||||
</button>
|
||||
<div className="hidden items-center gap-1.5 text-[0.7rem] text-txt-muted sm:flex">
|
||||
<Command className="w-3 h-3" />
|
||||
<span>Enter để gửi • Shift+Enter để xuống dòng</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -210,11 +282,11 @@ export function Composer({
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!value.trim()}
|
||||
disabled={!value.trim() && !monographPicker}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-4 py-1.5 rounded-xl text-xs font-bold transition-all shadow-sm",
|
||||
value.trim()
|
||||
value.trim() || monographPicker
|
||||
? "bg-accent-primary text-txt-inverse hover:bg-accent-hover"
|
||||
: "bg-surface-elevated text-txt-muted cursor-not-allowed"
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { Citation } from "@duoc-thu/shared-types";
|
||||
import type { Citation, MonographPickerState } from "@duoc-thu/shared-types";
|
||||
import { CitationCard } from "@duoc-thu/ui";
|
||||
import { BookOpen, X, ShieldCheck, Layers, FileSearch } from "lucide-react";
|
||||
import { cn } from "@duoc-thu/ui";
|
||||
@@ -10,6 +10,8 @@ interface EvidencePanelProps {
|
||||
citations: Citation[];
|
||||
activeCitationIndex: number | null;
|
||||
onSelectCitation: (citation: Citation, index: number) => void;
|
||||
monographPicker?: MonographPickerState | null;
|
||||
onToggleSection?: (sectionKey: string) => void;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
@@ -18,6 +20,8 @@ export function EvidencePanel({
|
||||
citations,
|
||||
activeCitationIndex,
|
||||
onSelectCitation,
|
||||
monographPicker,
|
||||
onToggleSection,
|
||||
onClose,
|
||||
className,
|
||||
}: EvidencePanelProps) {
|
||||
@@ -36,13 +40,15 @@ export function EvidencePanel({
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="m-0 text-xs font-bold text-txt-primary flex items-center gap-1.5">
|
||||
<span>Bằng Chứng Dược Thư</span>
|
||||
<span>{monographPicker ? "Thuộc tính thuốc" : "Bằng Chứng Dược Thư"}</span>
|
||||
<span className="rounded-full bg-accent-primary px-2 py-0.5 text-[0.65rem] font-extrabold text-txt-inverse">
|
||||
{citations.length}
|
||||
{monographPicker ? monographPicker.sections.length : citations.length}
|
||||
</span>
|
||||
</h3>
|
||||
<p className="m-0 text-[0.68rem] text-txt-muted">
|
||||
Căn cứ chính thức Dược thư QGVN 2018
|
||||
{monographPicker
|
||||
? "Chọn mục cần xem trong chuyên luận"
|
||||
: "Căn cứ chính thức Dược thư QGVN 2018"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,7 +66,47 @@ export function EvidencePanel({
|
||||
|
||||
{/* Citations List */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{citations.length === 0 ? (
|
||||
{monographPicker ? (
|
||||
<>
|
||||
<div className="rounded-2xl border border-border-accent bg-accent-soft/40 p-4">
|
||||
<p className="m-0 text-sm font-extrabold text-accent-primary">
|
||||
{monographPicker.drugName}
|
||||
</p>
|
||||
<p className="m-0 mt-1 text-[0.7rem] text-txt-muted">
|
||||
Chuyên luận · Dược thư Quốc gia Việt Nam 2018
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{monographPicker.sections.map((section) => {
|
||||
const checked = monographPicker.selectedSectionKeys.includes(
|
||||
section.sectionKey
|
||||
);
|
||||
return (
|
||||
<label
|
||||
key={section.sectionKey}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-2.5 text-xs transition-colors",
|
||||
checked
|
||||
? "border-border-accent bg-accent-soft text-accent-primary font-bold"
|
||||
: "border-transparent text-txt-secondary hover:border-border-subtle hover:bg-surface-elevated"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggleSection?.(section.sectionKey)}
|
||||
className="h-4 w-4 rounded border-border-active accent-[var(--accent-primary)]"
|
||||
/>
|
||||
<span>{section.sectionTitle}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="m-0 rounded-xl bg-surface-elevated p-3 text-[0.7rem] leading-relaxed text-txt-muted">
|
||||
Không chọn mục nào rồi nhấn Gửi tra cứu để hiển thị toàn bộ chuyên luận.
|
||||
</p>
|
||||
</>
|
||||
) : citations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center p-6 text-txt-muted space-y-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-surface-elevated border border-border-subtle text-txt-muted">
|
||||
<BookOpen className="h-6 w-6" />
|
||||
|
||||
@@ -29,6 +29,7 @@ interface SidebarProps {
|
||||
onNewChat: () => void;
|
||||
onDeleteSession?: (id: string) => void;
|
||||
onQuickQuery: (query: string) => void;
|
||||
historyRefreshKey?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -73,6 +74,7 @@ export function Sidebar({
|
||||
onNewChat,
|
||||
onDeleteSession,
|
||||
onQuickQuery,
|
||||
historyRefreshKey = 0,
|
||||
className,
|
||||
}: SidebarProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -102,7 +104,7 @@ export function Sidebar({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentSessionId]);
|
||||
}, [currentSessionId, historyRefreshKey]);
|
||||
|
||||
const filteredSessions = sessions.filter((s) =>
|
||||
s.title.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
|
||||
@@ -227,11 +227,13 @@ function toCitations(raw: RagCitation[]): Citation[] {
|
||||
export async function POST(request: Request) {
|
||||
let content: string;
|
||||
let conversationId: string | null = null;
|
||||
let responseMode: "ai" | "monograph" = "ai";
|
||||
try {
|
||||
const body = await request.json();
|
||||
content = typeof body?.content === "string" ? body.content.trim() : "";
|
||||
conversationId =
|
||||
typeof body?.conversationId === "string" ? body.conversationId : null;
|
||||
responseMode = body?.responseMode === "monograph" ? "monograph" : "ai";
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid_body" }, { status: 400 });
|
||||
}
|
||||
@@ -277,6 +279,7 @@ export async function POST(request: Request) {
|
||||
subject_scope: "human",
|
||||
intent: "fact_lookup",
|
||||
conversation_id: conversationId,
|
||||
response_mode: responseMode,
|
||||
}),
|
||||
cache: "no-store",
|
||||
// Propagate a browser disconnect/Stop action to the upstream fetch.
|
||||
|
||||
@@ -14,9 +14,8 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const targetUrl = API_GATEWAY_URL.includes("/v1/rag")
|
||||
? `${API_GATEWAY_URL.replace(/\/query$/, "/history")}?conversation_id=${encodeURIComponent(conversationId)}`
|
||||
: `${API_GATEWAY_URL}/v1/rag/history?conversation_id=${encodeURIComponent(conversationId)}`;
|
||||
const base = API_GATEWAY_URL.replace(/\/v1\/rag(?:\/query)?\/?$/, "");
|
||||
const targetUrl = `${base}/v1/rag/history?conversation_id=${encodeURIComponent(conversationId)}`;
|
||||
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method: "GET",
|
||||
@@ -24,6 +23,7 @@ export async function GET(request: Request) {
|
||||
"X-Client-Version": "1.0.0",
|
||||
},
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
|
||||
if (!upstream.ok) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const API_GATEWAY_URL =
|
||||
process.env.API_GATEWAY_URL ?? process.env.AI_SERVICE_URL ?? "http://localhost:8000";
|
||||
|
||||
function ragBaseUrl() {
|
||||
return API_GATEWAY_URL.replace(/\/v1\/rag(?:\/query)?\/?$/, "");
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const params = new URL(request.url).searchParams;
|
||||
const drugId = params.get("drug_id")?.trim() ?? "";
|
||||
const sectionKey = params.get("section_key")?.trim() ?? "";
|
||||
if (
|
||||
!/^[a-z0-9_]{1,160}$/i.test(drugId) ||
|
||||
!/^[a-z0-9_]{1,80}$/i.test(sectionKey)
|
||||
) {
|
||||
return NextResponse.json({ error: "invalid_section_request" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(
|
||||
`${ragBaseUrl()}/v1/rag/section-text?drug_id=${encodeURIComponent(
|
||||
drugId
|
||||
)}§ion_key=${encodeURIComponent(sectionKey)}`,
|
||||
{
|
||||
headers: { "X-Client-Version": "1.0.0" },
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
}
|
||||
);
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json({ error: "section_text_unavailable" }, { status: upstream.status });
|
||||
}
|
||||
return NextResponse.json(await upstream.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "section_text_unavailable" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const API_GATEWAY_URL =
|
||||
process.env.API_GATEWAY_URL ?? process.env.AI_SERVICE_URL ?? "http://localhost:8000";
|
||||
|
||||
function ragBaseUrl() {
|
||||
return API_GATEWAY_URL.replace(/\/v1\/rag(?:\/query)?\/?$/, "");
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const drugId = new URL(request.url).searchParams.get("drug_id")?.trim() ?? "";
|
||||
if (!/^[a-z0-9_]{1,160}$/i.test(drugId)) {
|
||||
return NextResponse.json({ error: "invalid_drug_id" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(
|
||||
`${ragBaseUrl()}/v1/rag/sections?drug_id=${encodeURIComponent(drugId)}`,
|
||||
{
|
||||
headers: { "X-Client-Version": "1.0.0" },
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
}
|
||||
);
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json({ error: "sections_unavailable" }, { status: upstream.status });
|
||||
}
|
||||
return NextResponse.json(await upstream.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "sections_unavailable" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ChatMessage, Citation } from "@duoc-thu/shared-types";
|
||||
import type { ChatMessage, Citation, MonographPickerState } from "@duoc-thu/shared-types";
|
||||
import { ChatPanel } from "./_components/ChatPanel";
|
||||
import { Sidebar, ChatSession } from "./_components/Sidebar";
|
||||
import { EvidencePanel } from "./_components/EvidencePanel";
|
||||
@@ -39,6 +39,7 @@ export default function ChatPage() {
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string>("");
|
||||
const [messagesBySession, setMessagesBySession] = useState<Record<string, ChatMessage[]>>({});
|
||||
const [queryOverride, setQueryOverride] = useState<{ text: string; token: number } | null>(null);
|
||||
const [monographPicker, setMonographPicker] = useState<MonographPickerState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = loadStoredSessions();
|
||||
@@ -106,6 +107,7 @@ export default function ChatPage() {
|
||||
setQueryOverride(null);
|
||||
setCitations([]);
|
||||
setActiveCitationIndex(null);
|
||||
setMonographPicker(null);
|
||||
setShowMobileSidebar(false);
|
||||
};
|
||||
|
||||
@@ -114,6 +116,7 @@ export default function ChatPage() {
|
||||
setQueryOverride(null);
|
||||
setCitations([]);
|
||||
setActiveCitationIndex(null);
|
||||
setMonographPicker(null);
|
||||
setShowMobileSidebar(false);
|
||||
};
|
||||
|
||||
@@ -162,6 +165,7 @@ export default function ChatPage() {
|
||||
// must replace the panel's state, not just the index into a stale one.
|
||||
setCitations(allCitations);
|
||||
setActiveCitationIndex(index);
|
||||
setMonographPicker(null);
|
||||
setShowMobileEvidence(true);
|
||||
};
|
||||
|
||||
@@ -179,6 +183,16 @@ export default function ChatPage() {
|
||||
// coordinates are computed fresh.
|
||||
};
|
||||
|
||||
const handleToggleSection = (sectionKey: string) => {
|
||||
setMonographPicker((current) => {
|
||||
if (!current) return current;
|
||||
const selected = current.selectedSectionKeys.includes(sectionKey)
|
||||
? current.selectedSectionKeys.filter((key) => key !== sectionKey)
|
||||
: [...current.selectedSectionKeys, sectionKey];
|
||||
return { ...current, selectedSectionKeys: selected };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 w-full h-[calc(100vh-6.5rem)] overflow-hidden bg-app relative">
|
||||
{/* Mobile Header Bar Controls */}
|
||||
@@ -210,6 +224,7 @@ export default function ChatPage() {
|
||||
onNewChat={handleNewChat}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onQuickQuery={handleQuickQuery}
|
||||
historyRefreshKey={currentMessages.length}
|
||||
className="hidden lg:flex"
|
||||
/>
|
||||
|
||||
@@ -228,6 +243,7 @@ export default function ChatPage() {
|
||||
onNewChat={handleNewChat}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onQuickQuery={handleQuickQuery}
|
||||
historyRefreshKey={currentMessages.length}
|
||||
className="w-full h-full border-r-0"
|
||||
/>
|
||||
</div>
|
||||
@@ -246,6 +262,9 @@ export default function ChatPage() {
|
||||
onCitationClick={handleCitationClick}
|
||||
onCitationsLoaded={handleCitationsLoaded}
|
||||
activeCitationIndex={activeCitationIndex}
|
||||
monographPicker={monographPicker}
|
||||
onMonographChange={setMonographPicker}
|
||||
onToggleSection={handleToggleSection}
|
||||
className="w-full max-w-4xl h-full"
|
||||
/>
|
||||
</main>
|
||||
@@ -256,6 +275,8 @@ export default function ChatPage() {
|
||||
citations={citations}
|
||||
activeCitationIndex={activeCitationIndex}
|
||||
onSelectCitation={(citation, index) => setActiveCitationIndex(index)}
|
||||
monographPicker={monographPicker}
|
||||
onToggleSection={handleToggleSection}
|
||||
onClose={() => setShowEvidenceDesktop(false)}
|
||||
className="hidden lg:flex"
|
||||
/>
|
||||
@@ -275,6 +296,8 @@ export default function ChatPage() {
|
||||
onSelectCitation={(citation, index) => {
|
||||
setActiveCitationIndex(index);
|
||||
}}
|
||||
monographPicker={monographPicker}
|
||||
onToggleSection={handleToggleSection}
|
||||
onClose={() => setShowMobileEvidence(false)}
|
||||
className="w-full h-full border-l-0"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user