Checkpoint frontend UI/UX overhaul and ingestion embed benchmark work

This commit is contained in:
2026-08-06 17:21:21 +07:00
parent 1e8cbdb586
commit a4b8e1c4db
78 changed files with 6761 additions and 654 deletions
+64 -32
View File
@@ -3,7 +3,7 @@ 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 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ĩ.";
@@ -14,6 +14,8 @@ interface RagCitation {
printed_page_end: number;
physical_page: number;
attachment?: string | null;
text_snippet?: string | null;
citation_reason?: string | null;
}
interface RagResponse {
@@ -25,18 +27,9 @@ interface RagResponse {
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.",
"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:
@@ -56,16 +49,14 @@ const GENERIC_REFUSAL =
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("__");
const sectionName = parts.length > 1 ? parts[1] : "";
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.
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.`,
};
});
}
@@ -85,11 +76,21 @@ export async function POST(request: Request) {
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 upstream = await fetch(`${AI_SERVICE_URL}/v1/rag/query`, {
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" },
headers: {
"Content-Type": "application/json",
"X-Correlation-ID": correlationId,
"X-Client-Version": "1.0.0",
},
body: JSON.stringify({
query: content,
subject_scope: "human",
@@ -99,27 +100,58 @@ export async function POST(request: Request) {
cache: "no-store",
});
if (!upstream.ok) {
return NextResponse.json(
{ error: "upstream_error", status: upstream.status },
{ status: 502 }
);
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;
}
rag = (await upstream.json()) as RagResponse;
} catch {
return NextResponse.json({ error: "upstream_unreachable" }, { status: 502 });
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: [],
};
}
const refused = rag.decision === "abstain" || rag.answer === null;
// 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,
id: rag.trace_id || `msg-${Date.now()}`,
role: "assistant",
content: refused
? REFUSALS[rag.reason] ?? GENERIC_REFUSAL
: (rag.answer as string),
citations: refused ? [] : toCitations(rag.citations, rag.resolved_drug_id),
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 } satisfies SendMessageResponse);
return NextResponse.json(
{ message, sessionId: conversationId ?? undefined } satisfies SendMessageResponse,
{
headers: {
"X-Correlation-ID": correlationId,
},
}
);
}
+38
View File
@@ -0,0 +1,38 @@
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:8079";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const q = searchParams.get("q")?.trim() || "";
if (!q) {
return NextResponse.json({ suggestions: [] });
}
try {
const targetUrl = API_GATEWAY_URL.includes("/v1/rag")
? `${API_GATEWAY_URL.replace(/\/query$/, "/suggest")}?q=${encodeURIComponent(q)}`
: `${API_GATEWAY_URL}/v1/rag/suggest?q=${encodeURIComponent(q)}`;
const upstream = await fetch(targetUrl, {
method: "GET",
headers: {
"X-Client-Version": "1.0.0",
},
cache: "no-store",
});
if (!upstream.ok) {
return NextResponse.json({ suggestions: [] });
}
const data = await upstream.json();
return NextResponse.json(data);
} catch {
return NextResponse.json({ suggestions: [] });
}
}