Add read-only production runtime audit

This commit is contained in:
2026-08-17 11:17:40 +07:00
parent 057d4ed9dc
commit a1de4715a4
106 changed files with 6869 additions and 1782 deletions
+261 -3
View File
@@ -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}${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
)}&section_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>
);
+79 -7
View File
@@ -1,7 +1,8 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Send, Square, Sparkles, Pill, Search, Command } from "lucide-react";
import type { MonographPickerState } from "@duoc-thu/shared-types";
import { Send, Square, Sparkles, Pill, Search, Command, X, BookOpen } from "lucide-react";
import { cn } from "@duoc-thu/ui";
interface ComposerProps {
@@ -9,6 +10,12 @@ interface ComposerProps {
isLoading?: boolean;
onStop?: () => void;
initialValue?: string;
monographPicker?: MonographPickerState | null;
onToggleSection?: (sectionKey: string) => void;
onClearMonograph?: () => void;
onSubmitMonograph?: () => void;
responseMode?: "ai" | "monograph";
onResponseModeChange?: (mode: "ai" | "monograph") => void;
className?: string;
}
@@ -17,6 +24,12 @@ export function Composer({
isLoading = false,
onStop,
initialValue = "",
monographPicker,
onToggleSection,
onClearMonograph,
onSubmitMonograph,
responseMode = "ai",
onResponseModeChange,
className,
}: ComposerProps) {
const [value, setValue] = useState(initialValue);
@@ -103,7 +116,12 @@ export function Composer({
const handleSubmit = () => {
const trimmed = value.trim();
if (!trimmed || isLoading) return;
if (isLoading) return;
if (!trimmed && monographPicker) {
onSubmitMonograph?.();
return;
}
if (!trimmed) return;
onSubmit(trimmed);
setValue("");
setSuggestions([]);
@@ -181,6 +199,36 @@ export function Composer({
{/* Main Composer Box */}
<div className="relative flex flex-col rounded-3xl border border-border-subtle bg-surface p-2 shadow-surface transition-all focus-within:border-border-accent focus-within:shadow-elevated glass-panel">
{monographPicker && (
<div className="flex flex-wrap items-center gap-2 px-2 pt-1 pb-2 border-b border-border-subtle/50">
<button
type="button"
onClick={onClearMonograph}
className="inline-flex items-center gap-1.5 rounded-full bg-accent-primary px-3 py-1.5 text-xs font-bold text-txt-inverse"
>
<Pill className="h-3.5 w-3.5" />
{monographPicker.drugName}
<X className="h-3.5 w-3.5" />
</button>
{monographPicker.selectedSectionKeys.map((sectionKey) => {
const section = monographPicker.sections.find(
(item) => item.sectionKey === sectionKey
);
if (!section) return null;
return (
<button
key={sectionKey}
type="button"
onClick={() => onToggleSection?.(sectionKey)}
className="inline-flex items-center gap-1.5 rounded-full border border-border-accent bg-accent-soft px-3 py-1.5 text-xs font-semibold text-accent-primary"
>
{section.sectionTitle}
<X className="h-3.5 w-3.5" />
</button>
);
})}
</div>
)}
<textarea
ref={inputRef}
value={value}
@@ -192,9 +240,33 @@ export function Composer({
/>
<div className="flex items-center justify-between gap-2 pt-2 px-2 border-t border-border-subtle/50">
<div className="flex items-center gap-1.5 text-[0.7rem] text-txt-muted">
<Command className="w-3 h-3" />
<span className="hidden sm:inline">Nhấn Enter đ gửi Shift+Enter đ xuống dòng</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
onResponseModeChange?.(
responseMode === "ai" ? "monograph" : "ai"
)
}
className={cn(
"inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1.5 text-[0.7rem] font-bold transition-colors",
responseMode === "monograph"
? "border-border-accent bg-accent-soft text-accent-primary"
: "border-border-subtle bg-surface-elevated text-txt-secondary hover:text-accent-primary"
)}
title="Chuyển giữa AI tổng hợp và tra nguyên văn chuyên luận"
>
{responseMode === "ai" ? (
<Sparkles className="h-3.5 w-3.5" />
) : (
<BookOpen className="h-3.5 w-3.5" />
)}
{responseMode === "ai" ? "AI tổng hợp" : "Chuyên luận"}
</button>
<div className="hidden items-center gap-1.5 text-[0.7rem] text-txt-muted sm:flex">
<Command className="w-3 h-3" />
<span>Enter đ gửi Shift+Enter đ xuống dòng</span>
</div>
</div>
<div className="flex items-center gap-2">
@@ -210,11 +282,11 @@ export function Composer({
) : (
<button
onClick={handleSubmit}
disabled={!value.trim()}
disabled={!value.trim() && !monographPicker}
type="button"
className={cn(
"flex items-center gap-1.5 px-4 py-1.5 rounded-xl text-xs font-bold transition-all shadow-sm",
value.trim()
value.trim() || monographPicker
? "bg-accent-primary text-txt-inverse hover:bg-accent-hover"
: "bg-surface-elevated text-txt-muted cursor-not-allowed"
)}
+51 -5
View File
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import type { Citation } from "@duoc-thu/shared-types";
import type { Citation, MonographPickerState } from "@duoc-thu/shared-types";
import { CitationCard } from "@duoc-thu/ui";
import { BookOpen, X, ShieldCheck, Layers, FileSearch } from "lucide-react";
import { cn } from "@duoc-thu/ui";
@@ -10,6 +10,8 @@ interface EvidencePanelProps {
citations: Citation[];
activeCitationIndex: number | null;
onSelectCitation: (citation: Citation, index: number) => void;
monographPicker?: MonographPickerState | null;
onToggleSection?: (sectionKey: string) => void;
onClose?: () => void;
className?: string;
}
@@ -18,6 +20,8 @@ export function EvidencePanel({
citations,
activeCitationIndex,
onSelectCitation,
monographPicker,
onToggleSection,
onClose,
className,
}: EvidencePanelProps) {
@@ -36,13 +40,15 @@ export function EvidencePanel({
</div>
<div>
<h3 className="m-0 text-xs font-bold text-txt-primary flex items-center gap-1.5">
<span>Bằng Chứng Dược Thư</span>
<span>{monographPicker ? "Thuộc tính thuốc" : "Bằng Chứng Dược Thư"}</span>
<span className="rounded-full bg-accent-primary px-2 py-0.5 text-[0.65rem] font-extrabold text-txt-inverse">
{citations.length}
{monographPicker ? monographPicker.sections.length : citations.length}
</span>
</h3>
<p className="m-0 text-[0.68rem] text-txt-muted">
Căn cứ chính thức Dược thư QGVN 2018
{monographPicker
? "Chọn mục cần xem trong chuyên luận"
: "Căn cứ chính thức Dược thư QGVN 2018"}
</p>
</div>
</div>
@@ -60,7 +66,47 @@ export function EvidencePanel({
{/* Citations List */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{citations.length === 0 ? (
{monographPicker ? (
<>
<div className="rounded-2xl border border-border-accent bg-accent-soft/40 p-4">
<p className="m-0 text-sm font-extrabold text-accent-primary">
{monographPicker.drugName}
</p>
<p className="m-0 mt-1 text-[0.7rem] text-txt-muted">
Chuyên luận · Dược thư Quốc gia Việt Nam 2018
</p>
</div>
<div className="space-y-2">
{monographPicker.sections.map((section) => {
const checked = monographPicker.selectedSectionKeys.includes(
section.sectionKey
);
return (
<label
key={section.sectionKey}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-2.5 text-xs transition-colors",
checked
? "border-border-accent bg-accent-soft text-accent-primary font-bold"
: "border-transparent text-txt-secondary hover:border-border-subtle hover:bg-surface-elevated"
)}
>
<input
type="checkbox"
checked={checked}
onChange={() => onToggleSection?.(section.sectionKey)}
className="h-4 w-4 rounded border-border-active accent-[var(--accent-primary)]"
/>
<span>{section.sectionTitle}</span>
</label>
);
})}
</div>
<p className="m-0 rounded-xl bg-surface-elevated p-3 text-[0.7rem] leading-relaxed text-txt-muted">
Không chọn mục nào rồi nhấn Gửi tra cứu đ hiển thị toàn bộ chuyên luận.
</p>
</>
) : citations.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center p-6 text-txt-muted space-y-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-surface-elevated border border-border-subtle text-txt-muted">
<BookOpen className="h-6 w-6" />
+3 -1
View File
@@ -29,6 +29,7 @@ interface SidebarProps {
onNewChat: () => void;
onDeleteSession?: (id: string) => void;
onQuickQuery: (query: string) => void;
historyRefreshKey?: number;
className?: string;
}
@@ -73,6 +74,7 @@ export function Sidebar({
onNewChat,
onDeleteSession,
onQuickQuery,
historyRefreshKey = 0,
className,
}: SidebarProps) {
const [searchTerm, setSearchTerm] = useState("");
@@ -102,7 +104,7 @@ export function Sidebar({
return () => {
cancelled = true;
};
}, [currentSessionId]);
}, [currentSessionId, historyRefreshKey]);
const filteredSessions = sessions.filter((s) =>
s.title.toLowerCase().includes(searchTerm.toLowerCase())