Files
duocthu/apps/web/app/api/chat/route.ts
T

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);
}