158 lines
6.1 KiB
TypeScript
158 lines
6.1 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import type { Citation, SendMessageResponse } from "@duoc-thu/shared-types";
|
|
|
|
export const runtime = "nodejs";
|
|
|
|
const API_GATEWAY_URL = process.env.API_GATEWAY_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;
|
|
text_snippet?: string | null;
|
|
citation_reason?: string | null;
|
|
}
|
|
|
|
interface RagResponse {
|
|
trace_id: string;
|
|
decision: string;
|
|
reason: string;
|
|
answer: string | null;
|
|
resolved_drug_id: string | null;
|
|
citations: RagCitation[];
|
|
}
|
|
|
|
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 (ví dụ: Paracetamol, Amoxicillin...).",
|
|
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) => {
|
|
const parts = item.chunk_id.split("__");
|
|
const sectionName = parts.length > 1 ? parts[1] : "";
|
|
return {
|
|
drugName: resolvedDrugId ?? parts[0] ?? item.chunk_id,
|
|
sectionType: sectionName,
|
|
sourcePageRange: [item.printed_page_start, item.printed_page_end],
|
|
snippet: item.text_snippet ?? undefined,
|
|
reason: item.citation_reason ?? `Trích xuất từ mục ${sectionName || "nội dung chuyên luận"} làm căn cứ đối chiếu câu trả lời LLM.`,
|
|
};
|
|
});
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
const correlationId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
|
|
let rag: RagResponse;
|
|
try {
|
|
const targetUrl = API_GATEWAY_URL.includes("/v1/rag")
|
|
? API_GATEWAY_URL
|
|
: `${API_GATEWAY_URL}/v1/rag/query`;
|
|
|
|
const upstream = await fetch(targetUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-Correlation-ID": correlationId,
|
|
"X-Client-Version": "1.0.0",
|
|
},
|
|
body: JSON.stringify({
|
|
query: content,
|
|
subject_scope: "human",
|
|
intent: "fact_lookup",
|
|
conversation_id: conversationId,
|
|
}),
|
|
cache: "no-store",
|
|
});
|
|
if (!upstream.ok) {
|
|
rag = {
|
|
trace_id: `fallback-${Date.now()}`,
|
|
decision: "abstain",
|
|
reason: "upstream_error",
|
|
answer: "Dịch vụ AI Service đang khởi động hoặc gặp sự cố tạm thời. Vui lòng thử lại trong giây lát.",
|
|
resolved_drug_id: null,
|
|
citations: [],
|
|
};
|
|
} else {
|
|
rag = (await upstream.json()) as RagResponse;
|
|
}
|
|
} catch {
|
|
rag = {
|
|
trace_id: `fallback-${Date.now()}`,
|
|
decision: "abstain",
|
|
reason: "upstream_unreachable",
|
|
answer: "Không thể kết nối đến AI Service (http://localhost:8079). Vui lòng đảm bảo AI Service đã được bật.",
|
|
resolved_drug_id: null,
|
|
citations: [],
|
|
};
|
|
}
|
|
|
|
// The RagAgent orchestrator (F-03) puts a specific, already-Vietnamese
|
|
// message into `answer` for most abstain cases too (e.g. "Không tìm thấy
|
|
// X trong Dược thư Quốc gia Việt Nam") — prefer it over the static
|
|
// REFUSALS lookup, which only covers the retired resolver's reason codes
|
|
// and would otherwise discard a good message in favor of a generic one.
|
|
// REFUSALS/GENERIC_REFUSAL are now purely the fallback for the genuinely
|
|
// answer-less case (retrieval abstained with no message to show).
|
|
const noAnswer = rag.answer === null;
|
|
const isAbstain = rag.decision === "abstain";
|
|
const message: SendMessageResponse["message"] = {
|
|
id: rag.trace_id || `msg-${Date.now()}`,
|
|
role: "assistant",
|
|
content: noAnswer ? (REFUSALS[rag.reason] ?? GENERIC_REFUSAL) : (rag.answer ?? GENERIC_REFUSAL),
|
|
citations: isAbstain || noAnswer ? [] : toCitations(rag.citations, rag.resolved_drug_id),
|
|
disclaimer: DISCLAIMER,
|
|
traceId: rag.trace_id,
|
|
decision: rag.decision,
|
|
reason: rag.reason,
|
|
grounded: !isAbstain && !noAnswer,
|
|
resolvedDrugId: rag.resolved_drug_id ?? undefined,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
|
|
return NextResponse.json(
|
|
{ message, sessionId: conversationId ?? undefined } satisfies SendMessageResponse,
|
|
{
|
|
headers: {
|
|
"X-Correlation-ID": correlationId,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|