251 lines
12 KiB
TypeScript
251 lines
12 KiB
TypeScript
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,
|
||
};
|
||
}
|