Add read-only production runtime audit
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import type { ChatMessage, Citation, SendMessageResponse } from "@duoc-thu/shared-types";
|
||||
import type {
|
||||
AnswerBlock,
|
||||
ChatMessage,
|
||||
Citation,
|
||||
DrugSectionOption,
|
||||
MonographPickerState,
|
||||
SendMessageResponse,
|
||||
} from "@duoc-thu/shared-types";
|
||||
import { ChatBubble, CitationBeamOverlay, useTheme } from "@duoc-thu/ui";
|
||||
import { Composer } from "./Composer";
|
||||
import { AnswerFeedback } from "./AnswerFeedback";
|
||||
@@ -27,9 +34,36 @@ interface ChatPanelProps {
|
||||
onCitationClick?: (citation: Citation, index: number, allCitations: Citation[]) => void;
|
||||
onCitationsLoaded?: (citations: Citation[]) => void;
|
||||
activeCitationIndex?: number | null;
|
||||
monographPicker?: MonographPickerState | null;
|
||||
onMonographChange?: (picker: MonographPickerState | null) => void;
|
||||
onToggleSection?: (sectionKey: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface SectionTextResponse {
|
||||
drug_id: string;
|
||||
section_key: string;
|
||||
section_title: string | null;
|
||||
parts: Array<{
|
||||
part_index: number | null;
|
||||
text: string;
|
||||
is_quarantined: boolean;
|
||||
printed_page_start: number | null;
|
||||
printed_page_end: number | null;
|
||||
physical_page: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
const MONOGRAPH_DISCLAIMER =
|
||||
"Nội dung nguyên văn được lấy 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.";
|
||||
|
||||
const QUICK_SECTION_KEYS = [
|
||||
"chi_dinh",
|
||||
"lieu_luong_va_cach_dung",
|
||||
"chong_chi_dinh",
|
||||
"than_trong",
|
||||
];
|
||||
|
||||
// The client must never be the thing that gives up first.
|
||||
//
|
||||
// The backend's own per-request budget is 40s (`max_wall_clock_ms` in
|
||||
@@ -84,12 +118,16 @@ export function ChatPanel({
|
||||
onCitationClick,
|
||||
onCitationsLoaded,
|
||||
activeCitationIndex = null,
|
||||
monographPicker,
|
||||
onMonographChange,
|
||||
onToggleSection,
|
||||
className,
|
||||
}: ChatPanelProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [responseMode, setResponseMode] = useState<"ai" | "monograph">("ai");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const initialQuerySentRef = useRef<number | undefined>(undefined);
|
||||
@@ -136,6 +174,7 @@ export function ChatPanel({
|
||||
body: JSON.stringify({
|
||||
content: userText,
|
||||
conversationId: sessionId,
|
||||
responseMode,
|
||||
}),
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
@@ -145,7 +184,56 @@ export function ChatPanel({
|
||||
}
|
||||
|
||||
const data: SendMessageResponse = await res.json();
|
||||
const assistantMsg = data.message;
|
||||
let assistantMsg = data.message;
|
||||
|
||||
if (
|
||||
assistantMsg.reason === "select_drug_sections" &&
|
||||
assistantMsg.resolvedDrugId &&
|
||||
!assistantMsg.resolvedDrugId.includes(",")
|
||||
) {
|
||||
const drugId = assistantMsg.resolvedDrugId;
|
||||
const [sectionsResponse, suggestionResponse] = await Promise.all([
|
||||
fetch(`/api/sections?drug_id=${encodeURIComponent(drugId)}`, {
|
||||
cache: "no-store",
|
||||
signal: abortControllerRef.current.signal,
|
||||
}),
|
||||
fetch(`/api/suggest?q=${encodeURIComponent(userText)}`, {
|
||||
cache: "no-store",
|
||||
signal: abortControllerRef.current.signal,
|
||||
}),
|
||||
]);
|
||||
if (!sectionsResponse.ok) {
|
||||
throw new Error("section_list_unavailable");
|
||||
}
|
||||
const rawSections = (await sectionsResponse.json()) as {
|
||||
sections?: Array<{ section_key: string; section_title: string }>;
|
||||
};
|
||||
const suggestionData = suggestionResponse.ok
|
||||
? ((await suggestionResponse.json()) as { suggestions?: string[] })
|
||||
: {};
|
||||
const sections: DrugSectionOption[] = (rawSections.sections ?? []).map(
|
||||
(section) => ({
|
||||
sectionKey: section.section_key,
|
||||
sectionTitle: section.section_title,
|
||||
})
|
||||
);
|
||||
const drugName = suggestionData.suggestions?.[0] ?? userText.trim();
|
||||
const picker: MonographPickerState = {
|
||||
drugId,
|
||||
drugName,
|
||||
sections,
|
||||
selectedSectionKeys: [],
|
||||
};
|
||||
assistantMsg = {
|
||||
...assistantMsg,
|
||||
content:
|
||||
`Chuyên luận ${drugName} có ${sections.length} mục. ` +
|
||||
"Chọn các mục cần xem ở cột bên phải rồi nhấn Gửi tra cứu — " +
|
||||
"nếu không chọn mục nào, hệ thống sẽ hiển thị toàn bộ.",
|
||||
sectionOptions: sections,
|
||||
};
|
||||
onMonographChange?.(picker);
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
|
||||
@@ -175,6 +263,129 @@ export function ChatPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitMonograph = async () => {
|
||||
if (!monographPicker || isLoading) return;
|
||||
const selected = monographPicker.selectedSectionKeys.length
|
||||
? monographPicker.sections.filter((section) =>
|
||||
monographPicker.selectedSectionKeys.includes(section.sectionKey)
|
||||
)
|
||||
: monographPicker.sections;
|
||||
if (selected.length === 0) {
|
||||
setError("Chuyên luận này chưa có mục văn bản để hiển thị.");
|
||||
return;
|
||||
}
|
||||
|
||||
const label = monographPicker.selectedSectionKeys.length
|
||||
? selected.map((section) => section.sectionTitle).join(", ")
|
||||
: "Toàn bộ chuyên luận";
|
||||
const userMsg: ChatMessage = {
|
||||
id: `user-monograph-${Date.now()}`,
|
||||
role: "user",
|
||||
content: `${monographPicker.drugName} — ${label}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
stopRequestedRef.current = false;
|
||||
abortControllerRef.current = new AbortController();
|
||||
const timeoutId = window.setTimeout(
|
||||
() => abortControllerRef.current?.abort(),
|
||||
45_000
|
||||
);
|
||||
|
||||
try {
|
||||
const responses = await Promise.all(
|
||||
selected.map(async (section) => {
|
||||
const response = await fetch(
|
||||
`/api/section-text?drug_id=${encodeURIComponent(
|
||||
monographPicker.drugId
|
||||
)}§ion_key=${encodeURIComponent(section.sectionKey)}`,
|
||||
{ cache: "no-store", signal: abortControllerRef.current!.signal }
|
||||
);
|
||||
if (!response.ok) throw new Error("section_text_unavailable");
|
||||
return (await response.json()) as SectionTextResponse;
|
||||
})
|
||||
);
|
||||
|
||||
const citations: Citation[] = [];
|
||||
const blocks: AnswerBlock[] = [];
|
||||
for (const response of responses) {
|
||||
const claims: AnswerBlock["claims"] = [];
|
||||
for (const [index, part] of response.parts.entries()) {
|
||||
if (
|
||||
part.printed_page_start == null ||
|
||||
part.printed_page_end == null ||
|
||||
part.physical_page == null
|
||||
) {
|
||||
throw new Error("section_provenance_missing");
|
||||
}
|
||||
const chunkId = `${response.drug_id}__${response.section_key}__${
|
||||
part.part_index ?? index
|
||||
}`;
|
||||
citations.push({
|
||||
chunkId,
|
||||
drugName: monographPicker.drugName.toUpperCase(),
|
||||
sectionType: response.section_key,
|
||||
sourceDocument: "Dược thư Quốc gia Việt Nam 2018",
|
||||
sourcePageRange: [part.printed_page_start, part.printed_page_end],
|
||||
physicalPage: part.physical_page,
|
||||
snippet: part.text,
|
||||
isQuarantined: part.is_quarantined,
|
||||
quarantineNotice: part.is_quarantined
|
||||
? "Mục này có bảng hoặc công thức cần đối chiếu trực tiếp trang PDF gốc."
|
||||
: undefined,
|
||||
});
|
||||
claims.push({ text: part.text, sourceIds: [chunkId] });
|
||||
}
|
||||
blocks.push({
|
||||
title:
|
||||
response.section_title ??
|
||||
selected.find((item) => item.sectionKey === response.section_key)
|
||||
?.sectionTitle ??
|
||||
response.section_key,
|
||||
kind: "fact_list",
|
||||
claims,
|
||||
});
|
||||
}
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: `monograph-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: `Nguyên văn ${selected.length} mục của ${monographPicker.drugName}.`,
|
||||
citations,
|
||||
disclaimer: MONOGRAPH_DISCLAIMER,
|
||||
decision: "answerable",
|
||||
reason: "verbatim_sections",
|
||||
grounded: true,
|
||||
generated: false,
|
||||
resolvedDrugId: monographPicker.drugId,
|
||||
blocks,
|
||||
answerMode: "detailed",
|
||||
answerPlan: {
|
||||
verbosity: "detailed",
|
||||
layout: "bullet_list",
|
||||
reasoningMode: "direct_lookup",
|
||||
showHeading: true,
|
||||
needsWarning: false,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
onCitationsLoaded?.(citations);
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err?.name === "AbortError"
|
||||
? "Đã dừng tải chuyên luận."
|
||||
: "Không thể tải đầy đủ nguyên văn các mục đã chọn. Vui lòng thử lại."
|
||||
);
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
setIsLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (abortControllerRef.current) {
|
||||
stopRequestedRef.current = true;
|
||||
@@ -182,6 +393,13 @@ export function ChatPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handleResponseModeChange = (mode: "ai" | "monograph") => {
|
||||
setResponseMode(mode);
|
||||
if (mode === "ai") {
|
||||
onMonographChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => abortControllerRef.current?.abort();
|
||||
}, []);
|
||||
@@ -387,8 +605,38 @@ export function ChatPanel({
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{msg.role === "assistant" &&
|
||||
msg.sectionOptions &&
|
||||
msg.sectionOptions.length > 0 && (
|
||||
<div className="ml-10 flex flex-wrap gap-2 px-4 pb-2">
|
||||
{QUICK_SECTION_KEYS.flatMap((key) => {
|
||||
const section = msg.sectionOptions?.find(
|
||||
(item) => item.sectionKey === key
|
||||
);
|
||||
if (!section) return [];
|
||||
const selected =
|
||||
monographPicker?.selectedSectionKeys.includes(key) ?? false;
|
||||
return [
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onToggleSection?.(key)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1.5 text-xs font-semibold transition-colors",
|
||||
selected
|
||||
? "border-accent-primary bg-accent-primary text-txt-inverse"
|
||||
: "border-border-accent bg-accent-soft text-accent-primary hover:bg-accent-primary hover:text-txt-inverse"
|
||||
)}
|
||||
>
|
||||
{section.sectionTitle}
|
||||
</button>,
|
||||
];
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{msg.role === "assistant" &&
|
||||
msg.traceId &&
|
||||
msg.reason !== "select_drug_sections" &&
|
||||
!msg.traceId.startsWith("fallback-") && (
|
||||
<AnswerFeedback traceId={msg.traceId} conversationId={sessionId} />
|
||||
)}
|
||||
@@ -438,7 +686,17 @@ export function ChatPanel({
|
||||
|
||||
{/* Fixed Composer Bottom Bar */}
|
||||
<div className="p-3 sm:p-4 border-t border-border-subtle bg-surface-elevated/60 backdrop-blur-md">
|
||||
<Composer onSubmit={handleSendMessage} isLoading={isLoading} onStop={handleStop} />
|
||||
<Composer
|
||||
onSubmit={handleSendMessage}
|
||||
isLoading={isLoading}
|
||||
onStop={handleStop}
|
||||
monographPicker={monographPicker}
|
||||
onToggleSection={onToggleSection}
|
||||
onClearMonograph={() => onMonographChange?.(null)}
|
||||
onSubmitMonograph={handleSubmitMonograph}
|
||||
responseMode={responseMode}
|
||||
onResponseModeChange={handleResponseModeChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user