Add Langfuse as a self-hosted eval and trace viewer
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
import type { AnswerBlock, AnswerPlan, Citation, ChatMessage } from "@duoc-thu/shared-types";
|
||||
|
||||
// Shared between `/api/chat` (a live turn) and `/api/transcript` (a
|
||||
// persisted turn read back later) — both start from the same ai-service
|
||||
// response shape (`RagQueryResponse` / `response_payload`) and must render
|
||||
// identically, so the mapping lives in one place rather than two that can
|
||||
// drift.
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export interface RagAnswerRaw {
|
||||
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";
|
||||
disclaimer?: string | null;
|
||||
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[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export function toAnswerBlocks(raw: NonNullable<RagAnswerRaw["blocks"]>): AnswerBlock[] {
|
||||
return raw.map((block) => ({
|
||||
title: block.title,
|
||||
kind: block.kind,
|
||||
claims: block.claims.map((claim) => ({
|
||||
text: claim.text,
|
||||
sourceIds: claim.source_ids,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function toAnswerPlan(raw: NonNullable<RagAnswerRaw["answer_plan"]>): AnswerPlan {
|
||||
return {
|
||||
verbosity: raw.verbosity,
|
||||
layout: raw.layout,
|
||||
reasoningMode: raw.reasoning_mode,
|
||||
showHeading: raw.show_heading,
|
||||
needsWarning: raw.needs_warning,
|
||||
};
|
||||
}
|
||||
|
||||
export 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.",
|
||||
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.",
|
||||
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.",
|
||||
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 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).
|
||||
export 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.
|
||||
export 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.
|
||||
export 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Builds the assistant `ChatMessage` for one turn, live or replayed — same
|
||||
* rules either way: an abstain/no-answer turn never shows citations, and a
|
||||
* missing `answer` text falls back to the reason-specific refusal copy. */
|
||||
export function buildAssistantMessage(
|
||||
rag: RagAnswerRaw,
|
||||
id: string,
|
||||
createdAt: string
|
||||
): ChatMessage {
|
||||
const noAnswer = rag.answer === null;
|
||||
const isAbstain = rag.decision === "abstain";
|
||||
const isGroundedAnswer =
|
||||
rag.decision === "answerable" && !noAnswer && rag.citations.length > 0;
|
||||
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
content: noAnswer ? (REFUSALS[rag.reason] ?? GENERIC_REFUSAL) : (rag.answer ?? GENERIC_REFUSAL),
|
||||
citations: isAbstain || noAnswer ? [] : toCitations(rag.citations),
|
||||
traceId: id,
|
||||
decision: rag.decision,
|
||||
reason: rag.reason,
|
||||
grounded: isGroundedAnswer,
|
||||
generated: isGroundedAnswer ? Boolean(rag.generated) : false,
|
||||
resolvedDrugId: rag.resolved_drug_id ?? undefined,
|
||||
createdAt,
|
||||
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,
|
||||
})),
|
||||
disclaimer: rag.disclaimer?.trim() || FALLBACK_DISCLAIMER,
|
||||
};
|
||||
}
|
||||
+13
-258
@@ -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,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { ChatMessage } from "@duoc-thu/shared-types";
|
||||
import { buildAssistantMessage, type RagAnswerRaw } from "../_lib/ragResponse";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// Same resolution rule as `/api/chat` and `/api/history` — RAG never goes
|
||||
// through api-gateway (it only proxies `/auth/*`), so this always talks to
|
||||
// ai-service directly.
|
||||
const AI_SERVICE_URL = process.env.AI_SERVICE_URL ?? "http://localhost:8000";
|
||||
|
||||
interface TranscriptMessageRaw {
|
||||
role: "user" | "assistant";
|
||||
trace_id: string;
|
||||
content: string | null;
|
||||
decision?: string | null;
|
||||
reason?: string | null;
|
||||
resolved_drug_id?: string | null;
|
||||
citations?: RagAnswerRaw["citations"];
|
||||
generated?: boolean;
|
||||
quick_replies?: string[];
|
||||
blocks?: RagAnswerRaw["blocks"];
|
||||
answer_mode?: RagAnswerRaw["answer_mode"];
|
||||
answer_plan?: RagAnswerRaw["answer_plan"];
|
||||
candidate_assessments?: RagAnswerRaw["candidate_assessments"];
|
||||
disclaimer?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface TranscriptResponseRaw {
|
||||
messages: TranscriptMessageRaw[];
|
||||
}
|
||||
|
||||
function toChatMessage(row: TranscriptMessageRaw): ChatMessage {
|
||||
if (row.role === "user") {
|
||||
return {
|
||||
id: `user-${row.trace_id}`,
|
||||
role: "user",
|
||||
content: row.content ?? "",
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
// Reuses the exact live-turn mapping (`/api/chat`'s `buildAssistantMessage`)
|
||||
// so a replayed answer renders identically to the one the user originally
|
||||
// saw — same abstain/refusal fallback, same citation grouping.
|
||||
const raw: RagAnswerRaw = {
|
||||
decision: row.decision ?? "answerable",
|
||||
reason: row.reason ?? "",
|
||||
answer: row.content,
|
||||
resolved_drug_id: row.resolved_drug_id ?? null,
|
||||
citations: row.citations ?? [],
|
||||
generated: row.generated,
|
||||
quick_replies: row.quick_replies,
|
||||
blocks: row.blocks,
|
||||
answer_mode: row.answer_mode,
|
||||
answer_plan: row.answer_plan,
|
||||
candidate_assessments: row.candidate_assessments,
|
||||
disclaimer: row.disclaimer,
|
||||
};
|
||||
return buildAssistantMessage(raw, row.trace_id, row.created_at);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const conversationId = searchParams.get("conversation_id")?.trim() || "";
|
||||
|
||||
if (!conversationId) {
|
||||
return NextResponse.json({ messages: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
const base = AI_SERVICE_URL.replace(/\/v1\/rag(?:\/query)?\/?$/, "");
|
||||
const targetUrl = `${base}/v1/rag/transcript?conversation_id=${encodeURIComponent(conversationId)}`;
|
||||
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method: "GET",
|
||||
headers: { "X-Client-Version": "1.0.0" },
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json({ messages: [] });
|
||||
}
|
||||
|
||||
const data = (await upstream.json()) as TranscriptResponseRaw;
|
||||
const messages = (data.messages ?? []).map(toChatMessage);
|
||||
return NextResponse.json({ messages });
|
||||
} catch {
|
||||
return NextResponse.json({ messages: [] });
|
||||
}
|
||||
}
|
||||
+75
-12
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ChatMessage, Citation, MonographPickerState } from "@duoc-thu/shared-types";
|
||||
import { ChatPanel } from "./_components/ChatPanel";
|
||||
import { Sidebar, ChatSession } from "./_components/Sidebar";
|
||||
@@ -12,14 +12,13 @@ function createSessionId() {
|
||||
return `session-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
// Feature-List #25: the session id is the key `/v1/rag/history` scopes its
|
||||
// listing by, so persisting it is a prerequisite for history surviving a
|
||||
// refresh at all — not just a UX nicety. Chat MESSAGE CONTENT is
|
||||
// deliberately NOT persisted here (server never stores generated answer
|
||||
// text/blocks either, only decision/reason metadata — see
|
||||
// `PostgresTraceRepository.list_by_conversation`'s docstring): a resumed
|
||||
// session's transcript starts empty, matching the spec's own "re-run the
|
||||
// query" wording rather than "replay the old answer".
|
||||
// The session id is the key both `/api/history` (quick-rerun list) and
|
||||
// `/api/transcript` (full replay) scope their reads by, so persisting it is
|
||||
// a prerequisite for either surviving a refresh at all. The session LIST
|
||||
// itself still lives only in this browser's localStorage — there is no
|
||||
// auth yet to scope a server-side list by, so a different browser/device
|
||||
// (or cleared storage) loses the sidebar entries even though the
|
||||
// transcripts themselves are still in Postgres, keyed by the same id.
|
||||
const SESSIONS_STORAGE_KEY = "dt_sessions";
|
||||
const CURRENT_SESSION_STORAGE_KEY = "dt_current_session_id";
|
||||
|
||||
@@ -34,6 +33,20 @@ function loadStoredSessions(): ChatSession[] | null {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SESSION_TITLE = "Phiên tra cứu mới";
|
||||
|
||||
// Real usage testing 2026-08-20: a session's sidebar entry stayed
|
||||
// "Phiên tra cứu mới" forever (every session, indistinguishable) and
|
||||
// picking one loaded an empty chat with no way to continue it — the spec's
|
||||
// "history is a re-run shortcut, not a replay" call turned out to read as
|
||||
// broken, not minimal. `/api/transcript` now persists and replays the full
|
||||
// turn (both question and answer), so a session title can be derived from
|
||||
// its own first question instead of staying a placeholder forever.
|
||||
function deriveTitle(text: string): string {
|
||||
const trimmed = text.trim().replace(/\s+/g, " ");
|
||||
return trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed;
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string>("");
|
||||
@@ -41,6 +54,38 @@ export default function ChatPage() {
|
||||
const [queryOverride, setQueryOverride] = useState<{ text: string; token: number } | null>(null);
|
||||
const [monographPicker, setMonographPicker] = useState<MonographPickerState | null>(null);
|
||||
|
||||
// Guards `hydrateSession` to one fetch per session per page load: without
|
||||
// it, switching back and forth between two sessions would re-fetch (and
|
||||
// briefly flash) every time, and a session with messages just sent this
|
||||
// visit could get clobbered by a fetch racing behind it.
|
||||
const hydratedSessionIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const hydrateSession = (id: string) => {
|
||||
if (hydratedSessionIdsRef.current.has(id)) return;
|
||||
hydratedSessionIdsRef.current.add(id);
|
||||
fetch(`/api/transcript?conversation_id=${encodeURIComponent(id)}`)
|
||||
.then((res) => (res.ok ? res.json() : { messages: [] }))
|
||||
.then((data: { messages?: ChatMessage[] }) => {
|
||||
const messages = Array.isArray(data.messages) ? data.messages : [];
|
||||
if (messages.length === 0) return;
|
||||
setMessagesBySession((prev) => ({ ...prev, [id]: messages }));
|
||||
const firstUser = messages.find((m) => m.role === "user");
|
||||
if (firstUser) {
|
||||
setSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === id && s.title === DEFAULT_SESSION_TITLE
|
||||
? { ...s, title: deriveTitle(firstUser.content) }
|
||||
: s
|
||||
)
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort — the session just starts blank, same as before this
|
||||
// feature existed, rather than blocking on a slow/down ai-service.
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const stored = loadStoredSessions();
|
||||
if (stored) {
|
||||
@@ -53,13 +98,17 @@ export default function ChatPage() {
|
||||
})();
|
||||
setSessions(stored);
|
||||
setMessagesBySession(Object.fromEntries(stored.map((s) => [s.id, []])));
|
||||
setCurrentSessionId(stored.find((s) => s.id === storedCurrent)?.id ?? stored[0].id);
|
||||
const resolvedId = stored.find((s) => s.id === storedCurrent)?.id ?? stored[0].id;
|
||||
setCurrentSessionId(resolvedId);
|
||||
hydrateSession(resolvedId);
|
||||
return;
|
||||
}
|
||||
const id = createSessionId();
|
||||
setSessions([{ id, title: "Phiên tra cứu mới", updatedAt: new Date().toISOString() }]);
|
||||
setSessions([{ id, title: DEFAULT_SESSION_TITLE, updatedAt: new Date().toISOString() }]);
|
||||
setMessagesBySession({ [id]: [] });
|
||||
setCurrentSessionId(id);
|
||||
hydratedSessionIdsRef.current.add(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Persist whenever the session list / active session changes — covers
|
||||
@@ -98,12 +147,15 @@ export default function ChatPage() {
|
||||
const newId = createSessionId();
|
||||
const newSession: ChatSession = {
|
||||
id: newId,
|
||||
title: "Phiên tra cứu mới",
|
||||
title: DEFAULT_SESSION_TITLE,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
setSessions((prev) => [newSession, ...prev]);
|
||||
setMessagesBySession((prev) => ({ ...prev, [newId]: [] }));
|
||||
setCurrentSessionId(newId);
|
||||
// Nothing on the server yet for a brand-new session — skip the wasted
|
||||
// round trip `hydrateSession` would otherwise make on first select.
|
||||
hydratedSessionIdsRef.current.add(newId);
|
||||
setQueryOverride(null);
|
||||
setCitations([]);
|
||||
setActiveCitationIndex(null);
|
||||
@@ -113,6 +165,7 @@ export default function ChatPage() {
|
||||
|
||||
const handleSelectSession = (id: string) => {
|
||||
setCurrentSessionId(id);
|
||||
hydrateSession(id);
|
||||
setQueryOverride(null);
|
||||
setCitations([]);
|
||||
setActiveCitationIndex(null);
|
||||
@@ -131,6 +184,7 @@ export default function ChatPage() {
|
||||
const remaining = sessions.filter((s) => s.id !== id);
|
||||
if (remaining.length > 0) {
|
||||
setCurrentSessionId(remaining[0].id);
|
||||
hydrateSession(remaining[0].id);
|
||||
} else {
|
||||
handleNewChat();
|
||||
}
|
||||
@@ -148,6 +202,15 @@ export default function ChatPage() {
|
||||
setMessagesBySession((prev) => {
|
||||
const existing = prev[sessionId] ?? [];
|
||||
const next = typeof update === "function" ? update(existing) : update;
|
||||
// Title the session off its own first question immediately, rather
|
||||
// than leaving every session as the identical, indistinguishable
|
||||
// placeholder until a later reload re-fetches the transcript.
|
||||
if (existing.length === 0 && next.length > 0 && next[0].role === "user") {
|
||||
const title = deriveTitle(next[0].content);
|
||||
setSessions((prevSessions) =>
|
||||
prevSessions.map((s) => (s.id === sessionId ? { ...s, title } : s))
|
||||
);
|
||||
}
|
||||
return { ...prev, [sessionId]: next };
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user