126 lines
4.8 KiB
TypeScript
126 lines
4.8 KiB
TypeScript
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);
|
|
}
|