Add Langfuse as a self-hosted eval and trace viewer

This commit is contained in:
2026-08-21 10:35:25 +07:00
parent 4490a1abf0
commit 53582b6030
12 changed files with 1129 additions and 280 deletions
+13 -258
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { randomUUID } from "node:crypto";
import type { AnswerBlock, AnswerPlan, Citation, SendMessageResponse } from "@duoc-thu/shared-types";
import type { SendMessageResponse } from "@duoc-thu/shared-types";
import { buildAssistantMessage, type RagAnswerRaw } from "../_lib/ragResponse";
export const runtime = "nodejs";
@@ -12,222 +13,10 @@ export const runtime = "nodejs";
// directly and let AI_SERVICE_URL mean what its name says: auth only.
const AI_SERVICE_URL = process.env.AI_SERVICE_URL ?? "http://localhost:8000";
interface RagCitation {
chunk_id: string;
printed_page_start: number;
printed_page_end: number;
physical_page: number;
block_id?: string | null;
bbox?: [number, number, number, number] | null;
source_crop?: string | null;
attachment?: string | null;
evidence_text?: string | null;
drug_id?: string | null;
drug_name?: string | null;
section_key?: string | null;
section_title?: string | null;
source_document?: string | null;
}
interface RagResponse {
interface RagResponse extends RagAnswerRaw {
trace_id: string;
decision: string;
reason: string;
answer: string | null;
resolved_drug_id: string | null;
citations: RagCitation[];
generated?: boolean;
quick_replies?: string[];
blocks?: Array<{
title: string;
kind: string;
claims: Array<{ text: string; source_ids: string[] }>;
}>;
answer_mode?: "concise" | "normal" | "detailed";
/**
* Fixed notice set by `rag/answer.py`, never written by the model. Optional
* here only so an older ai-service build still parses; the fallback below
* keeps the guarantee that a message always carries one.
*/
disclaimer?: string;
answer_plan?: {
verbosity: "concise" | "normal" | "detailed";
layout: string;
reasoning_mode: string;
show_heading: boolean;
needs_warning: boolean;
} | null;
candidate_assessments?: Array<{
drug_id: string;
drug_name: string;
indication_supported: boolean;
status: string;
indication_source_ids: string[];
safety_source_ids: string[];
}>;
}
function toAnswerBlocks(raw: NonNullable<RagResponse["blocks"]>): AnswerBlock[] {
return raw.map((block) => ({
title: block.title,
kind: block.kind,
claims: block.claims.map((claim) => ({
text: claim.text,
sourceIds: claim.source_ids,
})),
}));
}
function toAnswerPlan(raw: NonNullable<RagResponse["answer_plan"]>): AnswerPlan {
return {
verbosity: raw.verbosity,
layout: raw.layout,
reasoningMode: raw.reasoning_mode,
showHeading: raw.show_heading,
needsWarning: raw.needs_warning,
};
}
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.",
drug_resolution_invalid_state:
"Có lỗi nội bộ khi xác định thuốc trong câu hỏi này. Vui lòng thử lại.",
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_intent_unknown:
"Chưa rõ mục đích câu hỏi (tra cứu thông tin hay xin tư vấn điều trị). Vui lòng đặt lại câu hỏi cụ thể hơn.",
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.",
missing_query_or_drug:
"Câu hỏi hoặc tên thuốc chưa đủ rõ để tra cứu. Vui lòng nêu rõ tên thuốc và nội dung cần tra.",
missing_indication:
"Vui lòng nêu rõ triệu chứng hoặc chỉ định cần tra thuốc (ví dụ: sốt, đau đầu).",
no_indication_match:
"Không tìm thấy thuốc nào trong Dược thư ghi nhận chỉ định phù hợp với triệu chứng này.",
parent_hydration_failed:
"Có lỗi khi tổng hợp dữ liệu nhiều thuốc trong câu hỏi này. Vui lòng thử lại.",
missing_provenance:
"Không xác định được nguồn trang cho nội dung này nên hệ thống không thể trích dẫn. Vui lòng thử lại.",
missing_printed_page_provenance:
"Không xác định được nguồn trang cho nội dung này nên hệ thống không thể trích dẫn. Vui lòng thử lại.",
// The backend DID retrieve real evidence for every code below — none of
// these are missing-data cases. `rag/answer.py` now propagates the
// SPECIFIC safety check that rejected a generation instead of collapsing
// them all into "generation_unavailable" (found live 2026-08-07: the
// collapsed version made a real provider outage indistinguishable from
// ordinary entailment noise, both from here and from server metrics).
// Every one of these needs its own entry for the exact reason the
// now-fixed `generation_unavailable` case did: an unmapped reason here
// silently reads as "no data in the formulary", which is false.
request_budget_exhausted:
"Hệ thống mất quá nhiều thời gian xử lý câu hỏi này. Vui lòng thử lại.",
provider_unavailable:
"Không thể kết nối dịch vụ AI để tạo câu trả lời lúc này. Vui lòng thử lại sau ít phút.",
malformed_output:
"Hệ thống nhận được phản hồi không hợp lệ khi tạo câu trả lời. Vui lòng thử lại.",
evidence_insufficient:
"Dược thư có nội dung liên quan đến câu hỏi này, nhưng hệ thống chưa xác định đủ cơ sở để trả lời chắc chắn. Vui lòng thử lại hoặc nêu rõ hơn câu hỏi.",
ungrounded_number:
"Hệ thống phát hiện số liệu trong câu trả lời không khớp với nguồn nên đã huỷ để tránh sai sót. Vui lòng thử lại.",
invalid_citation:
"Hệ thống phát hiện trích dẫn không hợp lệ trong câu trả lời nên đã huỷ để tránh sai sót. Vui lòng thử lại.",
uncited_claim:
"Hệ thống phát hiện một phần câu trả lời không có trích dẫn nguồn rõ ràng nên đã huỷ để tránh sai sót. Vui lòng thử lại.",
unsupported_claim:
"Dược thư có nội dung liên quan đến câu hỏi này, nhưng bước đối chiếu lại chưa xác nhận được câu trả lời khớp hoàn toàn với nguồn. Vui lòng thử lại.",
unsupported_drug:
"Câu trả lời vừa tạo có tên thuốc ngoài tập ứng viên được Dược thư hỗ trợ nên đã bị huỷ để tránh gợi ý không có bằng chứng.",
incomplete_answer:
"Câu trả lời vừa tạo đã bị huỷ vì bước đối chiếu phát hiện còn bỏ sót dữ kiện liên quan trong nguồn. Vui lòng thử lại để hệ thống tạo câu trả lời đầy đủ hơn.",
// Kept as the fallback `answer.py` itself falls back to when, for some
// reason, none of the specific codes above was set.
generation_unavailable:
"Dược thư có nội dung liên quan đến câu hỏi này, nhưng hệ thống chưa tạo được câu trả lời đã kiểm chứng đầy đủ (có thể do lỗi tạm thời). Vui lòng bấm Thử lại.",
// `agent.py`'s clarify-loop circuit breaker (found live 2026-08-07: the
// understanding LLM could re-ask the same clarifying question forever,
// reproduced 3 times independently, one case never converged after 5 real
// turns). The backend always supplies its own `answer` text for this
// reason, so this entry is a fallback only.
clarify_loop_exhausted:
"Hệ thống chưa xác định đủ thông tin sau nhiều lần hỏi lại. Vui lòng gõ lại toàn bộ câu hỏi trong một tin nhắn đầy đủ, hoặc bấm \"Tạo phiên tra cứu mới\".",
};
// Only a truly unclassified reason code reaches this — every abstain path
// the backend actually produces (see rag/routing.py, rag/service.py,
// rag/answer.py) has a specific entry above. This must stay narrow: an
// unmapped reason silently reading as "no data in the formulary" is exactly
// the bug fixed 2026-08-07 (generation_unavailable was falling through here).
const GENERIC_REFUSAL =
"Hệ thống không thể xử lý câu hỏi này lúc này. Vui lòng thử lại.";
// Mirrors `DISCLAIMER` in `apps/ai-service/rag/answer.py`. ai-service is the
// source of truth and normally supplies it; this exists so a version skew
// between the two services cannot produce a medical message with no notice
// attached. If the wording changes, change it there first.
const FALLBACK_DISCLAIMER =
"Nội dung được trích từ Dược thư Quốc gia Việt Nam 2018, phục vụ tra cứu " +
"chuyên môn và không thay thế chỉ định của bác sĩ hoặc dược sĩ lâm sàng.";
// Chunk ids are always `{drug_id}__{section_key}__{part_index}` — drug_id and
// section_key use single underscores internally, so splitting on the double
// underscore reliably recovers both per citation. This must be derived per
// citation, not from the turn's single `resolved_drug_id`: a 2-drug
// interaction answer cites both drugs, and stamping every citation with one
// drug name would misattribute half of them.
//
// The backend emits one raw citation per `source_ref` of an evidence block —
// a quarantined chunk has both a plain-text ref (where the prose sits) and
// an attachment ref (where the table/formula actually sits, which can be a
// different physical page than the prose that mentions it — confirmed on
// real data, not assumed). Both refs share the same `chunk_id` and the same
// `evidence_text`, so they're grouped into ONE card here instead of showing
// two near-identical ones — the attachment ref's own page is kept as
// `quarantinePhysicalPage` rather than discarded.
function toCitations(raw: RagCitation[]): Citation[] {
const byChunk = new Map<string, RagCitation[]>();
for (const item of raw) {
const group = byChunk.get(item.chunk_id);
if (group) group.push(item);
else byChunk.set(item.chunk_id, [item]);
}
return Array.from(byChunk.entries()).map(([chunkId, group]) => {
const primary = group.find((g) => !g.attachment) ?? group[0];
const attachmentRef = group.find((g) => g.attachment);
const [drugSlug, chunkSectionKey] = chunkId.split("__");
const sectionKey = primary.section_key ?? chunkSectionKey;
const isQuarantined = Boolean(attachmentRef);
return {
chunkId,
drugName:
primary.drug_name ??
(drugSlug ? drugSlug.replace(/_/g, " ").toUpperCase() : chunkId),
sectionType: sectionKey ?? "",
sourceDocument: primary.source_document ?? "Dược thư Quốc gia Việt Nam 2018",
sourcePageRange: [primary.printed_page_start, primary.printed_page_end],
physicalPage: primary.physical_page,
snippet: primary.evidence_text ?? "",
isQuarantined,
quarantineNotice: attachmentRef
? `Có bảng hoặc công thức tại trang in ${attachmentRef.printed_page_start}${
attachmentRef.printed_page_end !== attachmentRef.printed_page_start
? `${attachmentRef.printed_page_end}`
: ""
} chưa được số hóa tự động — không suy ra số liệu từ đây, cần đối chiếu trực tiếp ảnh PDF gốc.`
: undefined,
quarantinePhysicalPage: attachmentRef?.physical_page,
sourceCropUrl: attachmentRef?.source_crop ?? undefined,
};
});
correlation_id?: string;
otel_trace_id?: string | null;
}
export async function POST(request: Request) {
@@ -323,48 +112,14 @@ export async function POST(request: Request) {
// 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 isGroundedAnswer =
rag.decision === "answerable" && !noAnswer && rag.citations.length > 0;
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),
traceId: rag.trace_id,
decision: rag.decision,
reason: rag.reason,
grounded: isGroundedAnswer,
generated: isGroundedAnswer ? Boolean(rag.generated) : false,
resolvedDrugId: rag.resolved_drug_id ?? undefined,
createdAt: new Date().toISOString(),
quickReplies:
rag.decision === "clarify" && rag.quick_replies && rag.quick_replies.length > 0
? rag.quick_replies
: undefined,
blocks:
isGroundedAnswer && rag.blocks && rag.blocks.length > 0
? toAnswerBlocks(rag.blocks)
: undefined,
answerMode: rag.answer_mode,
answerPlan: rag.answer_plan ? toAnswerPlan(rag.answer_plan) : undefined,
candidateAssessments: rag.candidate_assessments?.map((item) => ({
drugId: item.drug_id,
drugName: item.drug_name,
indicationSupported: item.indication_supported,
status: item.status,
indicationSourceIds: item.indication_source_ids,
safetySourceIds: item.safety_source_ids,
})),
// Carried on every message, including abstains and clarifications: those
// are clinical responses too. The local fallback covers an ai-service
// that predates the field, so the guarantee does not depend on both
// sides being deployed together.
disclaimer: rag.disclaimer?.trim() || FALLBACK_DISCLAIMER,
};
// REFUSALS/GENERIC_REFUSAL (in `_lib/ragResponse`) are now purely the
// fallback for the genuinely answer-less case (retrieval abstained with
// no message to show).
const message: SendMessageResponse["message"] = buildAssistantMessage(
rag,
rag.trace_id || `msg-${Date.now()}`,
new Date().toISOString()
);
const responseHeaders = new Headers({
"X-Correlation-ID": responseCorrelationId,