Fix ai-service Dockerfile: bake in drug_entities.json, override its path
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
FROM node:20-slim AS base
|
||||
RUN corepack enable
|
||||
WORKDIR /repo
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/shared-types/package.json packages/shared-types/package.json
|
||||
COPY packages/api-client/package.json packages/api-client/package.json
|
||||
COPY packages/ui/package.json packages/ui/package.json
|
||||
COPY packages/config/package.json packages/config/package.json
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM deps AS build
|
||||
COPY packages/ packages/
|
||||
COPY apps/web/ apps/web/
|
||||
RUN pnpm --filter @duoc-thu/web build
|
||||
|
||||
FROM base AS runtime
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=build /repo /repo
|
||||
WORKDIR /repo/apps/web
|
||||
EXPOSE 3000
|
||||
CMD ["pnpm", "start", "--", "-p", "3000", "-H", "0.0.0.0"]
|
||||
@@ -20,7 +20,7 @@ import { cn } from "@duoc-thu/ui";
|
||||
interface ChatPanelProps {
|
||||
sessionId: string;
|
||||
initialQuery?: string;
|
||||
onCitationClick?: (citation: Citation, index: number) => void;
|
||||
onCitationClick?: (citation: Citation, index: number, allCitations: Citation[]) => void;
|
||||
onCitationsLoaded?: (citations: Citation[]) => void;
|
||||
activeCitationIndex?: number | null;
|
||||
className?: string;
|
||||
@@ -63,6 +63,7 @@ export function ChatPanel({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const initialQuerySentRef = useRef<string | undefined>(undefined);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
@@ -132,7 +133,15 @@ export function ChatPanel({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (initialQuery) {
|
||||
// Guard against firing twice for the same query: React 18 Strict Mode
|
||||
// (dev only) runs this effect setup twice on mount, and with no guard
|
||||
// that sent every quick-prompt click as two identical live requests
|
||||
// (found live 2026-08-07: duplicate "Chỉ định & Tác dụng không mong
|
||||
// muốn của Aspirin" turns in the trace). The ref persists across the
|
||||
// Strict Mode replay, so the second invocation for the same
|
||||
// `initialQuery` is a no-op; a genuinely new query still sends once.
|
||||
if (initialQuery && initialQuerySentRef.current !== initialQuery) {
|
||||
initialQuerySentRef.current = initialQuery;
|
||||
handleSendMessage(initialQuery);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -291,15 +300,31 @@ export function ChatPanel({
|
||||
{messages.length === 0 ? (
|
||||
renderEmptyState()
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<ChatBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
onCitationClick={(citation, idx) => onCitationClick?.(citation, idx)}
|
||||
activeCitationIndex={activeCitationIndex}
|
||||
onRetry={() => handleSendMessage(msg.content)}
|
||||
/>
|
||||
))
|
||||
messages.map((msg, msgIdx) => {
|
||||
// Retry must resend the ORIGINAL user question, not this
|
||||
// bubble's own text — for an assistant bubble, `msg.content` is
|
||||
// the answer/error text itself, so resending it fed the error
|
||||
// message back in as if it were the next question (found live
|
||||
// 2026-08-07: a trace row where the query text WAS literally
|
||||
// "Dịch vụ đang gặp sự cố tạm thời..."). Walk back to the
|
||||
// nearest preceding user turn instead.
|
||||
const retryQuery =
|
||||
msg.role === "assistant"
|
||||
? [...messages.slice(0, msgIdx)].reverse().find((m) => m.role === "user")?.content
|
||||
: undefined;
|
||||
return (
|
||||
<ChatBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
onCitationClick={(citation, idx, allCitations) =>
|
||||
onCitationClick?.(citation, idx, allCitations)
|
||||
}
|
||||
activeCitationIndex={activeCitationIndex}
|
||||
onRetry={retryQuery ? () => handleSendMessage(retryQuery) : undefined}
|
||||
onQuickReply={(text) => handleSendMessage(text)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Loading Indicator */}
|
||||
|
||||
+114
-13
@@ -13,9 +13,11 @@ interface RagCitation {
|
||||
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;
|
||||
text_snippet?: string | null;
|
||||
citation_reason?: string | null;
|
||||
evidence_text?: string | null;
|
||||
}
|
||||
|
||||
interface RagResponse {
|
||||
@@ -25,6 +27,8 @@ interface RagResponse {
|
||||
answer: string | null;
|
||||
resolved_drug_id: string | null;
|
||||
citations: RagCitation[];
|
||||
generated?: boolean;
|
||||
quick_replies?: string[];
|
||||
}
|
||||
|
||||
const REFUSALS: Record<string, string> = {
|
||||
@@ -32,31 +36,123 @@ const REFUSALS: Record<string, string> = {
|
||||
"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.",
|
||||
// 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 tìm thấy căn cứ trong Dược thư để trả lời câu hỏi này.";
|
||||
"Hệ thống không thể xử lý câu hỏi này lúc này. Vui lòng thử lại.";
|
||||
|
||||
function toCitations(raw: RagCitation[], resolvedDrugId: string | null): Citation[] {
|
||||
return raw.map((item) => {
|
||||
const parts = item.chunk_id.split("__");
|
||||
const sectionName = parts.length > 1 ? parts[1] : "";
|
||||
// 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, sectionKey] = chunkId.split("__");
|
||||
const isQuarantined = Boolean(attachmentRef);
|
||||
return {
|
||||
drugName: resolvedDrugId ?? parts[0] ?? item.chunk_id,
|
||||
sectionType: sectionName,
|
||||
sourcePageRange: [item.printed_page_start, item.printed_page_end],
|
||||
snippet: item.text_snippet ?? undefined,
|
||||
reason: item.citation_reason ?? `Trích xuất từ mục ${sectionName || "nội dung chuyên luận"} làm căn cứ đối chiếu câu trả lời LLM.`,
|
||||
chunkId,
|
||||
drugName: drugSlug ? drugSlug.replace(/_/g, " ").toUpperCase() : chunkId,
|
||||
sectionType: sectionKey ?? "",
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -135,14 +231,19 @@ export async function POST(request: Request) {
|
||||
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, rag.resolved_drug_id),
|
||||
citations: isAbstain || noAnswer ? [] : toCitations(rag.citations),
|
||||
disclaimer: DISCLAIMER,
|
||||
traceId: rag.trace_id,
|
||||
decision: rag.decision,
|
||||
reason: rag.reason,
|
||||
grounded: !isAbstain && !noAnswer,
|
||||
generated: !isAbstain && !noAnswer ? 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,
|
||||
};
|
||||
|
||||
return NextResponse.json(
|
||||
|
||||
+18
-4
@@ -73,16 +73,30 @@ export default function ChatPage() {
|
||||
setShowMobileSidebar(false);
|
||||
};
|
||||
|
||||
const handleCitationClick = (citation: Citation, index: number) => {
|
||||
const handleCitationClick = (citation: Citation, index: number, allCitations: Citation[]) => {
|
||||
// Found live 2026-08-07: this used to only set the index into whatever
|
||||
// `citations` array was last loaded (i.e. the MOST RECENT answer's), so
|
||||
// clicking [1] on an older message showed a LATER message's unrelated
|
||||
// drug in the evidence panel (reported live: clicking Omeprazol's own
|
||||
// citation showed Kanamycin). The clicked message's own citation list
|
||||
// must replace the panel's state, not just the index into a stale one.
|
||||
setCitations(allCitations);
|
||||
setActiveCitationIndex(index);
|
||||
setShowMobileEvidence(true);
|
||||
};
|
||||
|
||||
const handleCitationsLoaded = (newCitations: Citation[]) => {
|
||||
setCitations(newCitations);
|
||||
if (newCitations.length > 0) {
|
||||
setActiveCitationIndex(1);
|
||||
}
|
||||
// Deliberately NOT auto-activating citation 1 here (removed
|
||||
// 2026-08-07): this used to fire the beam connector line + card
|
||||
// highlight on every single answer, unprompted, and — since
|
||||
// `CitationBeamOverlay` only recomputes its coordinates on window
|
||||
// resize/scroll, not on the content reflow a just-arrived answer
|
||||
// itself causes — the line frequently ended up pointing at stale
|
||||
// positions, i.e. exactly the "dây trích dẫn dính lung tung" (messy
|
||||
// citation wire) reported live. The beam/highlight now only appears
|
||||
// when the user actually clicks a citation, at which point the
|
||||
// coordinates are computed fresh.
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,10 +13,14 @@ export default function TraCuuPage() {
|
||||
|
||||
function handleCitationClick(citation: Citation) {
|
||||
if (citation.sourcePageRange && citation.sourcePageRange[0]) {
|
||||
const page = citation.sourcePageRange[0];
|
||||
setActivePage(page);
|
||||
// Display the printed page (what's on the paper page, matches the
|
||||
// clinician's physical copy), but navigate the PDF viewer by the
|
||||
// physical page — they diverge by 1-3 pages depending on front-matter
|
||||
// offset, confirmed against the real PDF (physical_page is PyMuPDF's
|
||||
// 0-indexed page; the #page= fragment is 1-indexed).
|
||||
setActivePage(citation.sourcePageRange[0]);
|
||||
setActiveDrug(citation.drugName);
|
||||
setPdfSrc(`/api/pdf#page=${page}`);
|
||||
setPdfSrc(`/api/pdf#page=${citation.physicalPage + 1}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user