Fix live multi-turn: pass the resolved drug, stop did-you-mean garbage
This commit is contained in:
@@ -21,10 +21,20 @@ export interface ChatPanelProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ERROR_MESSAGE =
|
||||
"Hệ thống tạm thời không phản hồi. Vui lòng thử lại — nếu vẫn lỗi, có thể dịch vụ tra cứu đang tạm ngưng.";
|
||||
|
||||
export function ChatPanel({ onCitationClick, className }: ChatPanelProps) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
// One id per chat session, so follow-ups ("còn trẻ em thì sao?") resolve
|
||||
// against the same conversation on the backend.
|
||||
const [conversationId] = useState(() =>
|
||||
typeof crypto !== "undefined" && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `conv-${Date.now()}`
|
||||
);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
@@ -41,8 +51,20 @@ export function ChatPanel({ onCitationClick, className }: ChatPanelProps) {
|
||||
setInput("");
|
||||
setIsSending(true);
|
||||
try {
|
||||
const response = await sendChatMessage(content);
|
||||
const response = await sendChatMessage(content, conversationId);
|
||||
setMessages((prev) => [...prev, response.message]);
|
||||
} catch {
|
||||
// Never leave the user staring at their own message with no reply: an
|
||||
// error is surfaced as a labelled bubble, not swallowed silently.
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `error-${messages.length}`,
|
||||
role: "assistant",
|
||||
content: ERROR_MESSAGE,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { Citation, SendMessageResponse } from "@duoc-thu/shared-types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const AI_SERVICE_URL = process.env.AI_SERVICE_URL ?? "http://localhost:8079";
|
||||
|
||||
const DISCLAIMER =
|
||||
"Nội dung trích từ Dược thư Quốc gia Việt Nam, chỉ mang tính tra cứu chuyên môn và không thay thế chỉ định của bác sĩ hoặc dược sĩ.";
|
||||
|
||||
interface RagCitation {
|
||||
chunk_id: string;
|
||||
printed_page_start: number;
|
||||
printed_page_end: number;
|
||||
physical_page: number;
|
||||
attachment?: string | null;
|
||||
}
|
||||
|
||||
interface RagResponse {
|
||||
trace_id: string;
|
||||
decision: string;
|
||||
reason: string;
|
||||
answer: string | null;
|
||||
resolved_drug_id: string | null;
|
||||
citations: RagCitation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the user reads when the system declines.
|
||||
*
|
||||
* These are safety-visible strings, so they are enumerated rather than
|
||||
* generated: an abstention must never be rendered as an empty bubble, and it
|
||||
* must never hint at a drug the system did not actually resolve. Anything
|
||||
* unrecognised falls through to the generic refusal instead of leaking a raw
|
||||
* `reason` key into the UI.
|
||||
*/
|
||||
const REFUSALS: Record<string, string> = {
|
||||
drug_not_resolved:
|
||||
"Chưa xác định được thuốc trong câu hỏi này, nên hệ thống không đưa ra nội dung chuyên môn. Vui lòng nêu rõ tên hoạt chất cần tra cứu.",
|
||||
drug_resolution_ambiguous:
|
||||
"Câu hỏi có thể ứng với nhiều thuốc khác nhau. Vui lòng nêu rõ tên hoạt chất cần tra cứu.",
|
||||
recommendation_out_of_scope:
|
||||
"Đây là câu hỏi xin tư vấn hoặc quyết định điều trị. Hệ thống chỉ tra cứu Dược thư và không đưa ra khuyến cáo điều trị — vui lòng hỏi bác sĩ hoặc dược sĩ.",
|
||||
out_of_scope_non_human:
|
||||
"Dược thư Quốc gia Việt Nam áp dụng cho người. Hệ thống không tra cứu cho đối tượng khác.",
|
||||
subject_scope_unknown:
|
||||
"Chưa rõ câu hỏi áp dụng cho đối tượng nào, nên hệ thống không trả lời.",
|
||||
query_embedding_unavailable:
|
||||
"Chưa tra được mục tương ứng cho câu hỏi này. Vui lòng nêu rõ thuộc tính cần tra (liều dùng, chống chỉ định, tương tác thuốc…).",
|
||||
insufficient_retrieval_score:
|
||||
"Không tìm thấy nội dung đủ liên quan trong Dược thư cho câu hỏi này.",
|
||||
};
|
||||
|
||||
const GENERIC_REFUSAL =
|
||||
"Hệ thống không tìm thấy căn cứ trong Dược thư để trả lời câu hỏi này.";
|
||||
|
||||
function toCitations(raw: RagCitation[], resolvedDrugId: string | null): Citation[] {
|
||||
return raw.map((item) => {
|
||||
// Chunk ids are `<drug>__<section>__<index>`. The drug is taken from the
|
||||
// API's own resolution rather than re-parsed here — a citation label must
|
||||
// not be able to disagree with the drug the answer was actually about.
|
||||
const parts = item.chunk_id.split("__");
|
||||
return {
|
||||
drugName: resolvedDrugId ?? parts[0] ?? item.chunk_id,
|
||||
sectionType: parts.length > 1 ? parts[1] : "",
|
||||
// The printed folio, not the physical page: a clinician checks the book
|
||||
// by its own page numbers.
|
||||
sourcePageRange: [item.printed_page_start, item.printed_page_end],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let content: string;
|
||||
let conversationId: string | null = null;
|
||||
try {
|
||||
const body = await request.json();
|
||||
content = typeof body?.content === "string" ? body.content.trim() : "";
|
||||
conversationId =
|
||||
typeof body?.conversationId === "string" ? body.conversationId : null;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid_body" }, { status: 400 });
|
||||
}
|
||||
if (!content) {
|
||||
return NextResponse.json({ error: "empty_query" }, { status: 400 });
|
||||
}
|
||||
|
||||
let rag: RagResponse;
|
||||
try {
|
||||
const upstream = await fetch(`${AI_SERVICE_URL}/v1/rag/query`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query: content,
|
||||
subject_scope: "human",
|
||||
intent: "fact_lookup",
|
||||
conversation_id: conversationId,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "upstream_error", status: upstream.status },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
rag = (await upstream.json()) as RagResponse;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "upstream_unreachable" }, { status: 502 });
|
||||
}
|
||||
|
||||
const refused = rag.decision === "abstain" || rag.answer === null;
|
||||
const message: SendMessageResponse["message"] = {
|
||||
id: rag.trace_id,
|
||||
role: "assistant",
|
||||
content: refused
|
||||
? REFUSALS[rag.reason] ?? GENERIC_REFUSAL
|
||||
: (rag.answer as string),
|
||||
citations: refused ? [] : toCitations(rag.citations, rag.resolved_drug_id),
|
||||
disclaimer: DISCLAIMER,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json({ message } satisfies SendMessageResponse);
|
||||
}
|
||||
Reference in New Issue
Block a user