Add production condition retrieval smoke test

This commit is contained in:
2026-08-11 14:58:28 +07:00
parent 59e6ad2d0d
commit 7ebbe1f309
38 changed files with 3752 additions and 121 deletions
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useState } from "react";
import { Check, MessageSquareText, ThumbsDown, ThumbsUp } from "lucide-react";
interface AnswerFeedbackProps {
traceId: string;
conversationId: string;
}
type Rating = "helpful" | "not_helpful";
export function AnswerFeedback({ traceId, conversationId }: AnswerFeedbackProps) {
const [rating, setRating] = useState<Rating | null>(null);
const [comment, setComment] = useState("");
const [state, setState] = useState<"idle" | "saving" | "saved" | "error">("idle");
const choose = (next: Rating) => {
setRating(next);
setState("idle");
};
const submit = async () => {
if (!rating || state === "saving") return;
setState("saving");
try {
const response = await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ traceId, rating, comment, conversationId }),
});
setState(response.ok ? "saved" : "error");
} catch {
setState("error");
}
};
if (state === "saved") {
return (
<div className="ml-11 mt-1 flex items-center gap-1.5 text-[0.7rem] text-status-success">
<Check className="h-3.5 w-3.5" />
Đã ghi nhận phản hồi. Cảm ơn anh/chị.
</div>
);
}
return (
<div className="ml-11 mt-1 max-w-xl text-[0.7rem] text-txt-muted">
<div className="flex flex-wrap items-center gap-1.5">
<span>Câu trả lời này hữu ích không?</span>
<button
type="button"
aria-label="Câu trả lời hữu ích"
aria-pressed={rating === "helpful"}
onClick={() => choose("helpful")}
className={`rounded-lg border p-1.5 transition-colors ${
rating === "helpful"
? "border-status-success bg-status-success-bg text-status-success"
: "border-border-subtle hover:border-border-accent hover:text-txt-primary"
}`}
>
<ThumbsUp className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label="Câu trả lời chưa hữu ích"
aria-pressed={rating === "not_helpful"}
onClick={() => choose("not_helpful")}
className={`rounded-lg border p-1.5 transition-colors ${
rating === "not_helpful"
? "border-status-danger bg-status-danger-bg text-status-danger"
: "border-border-subtle hover:border-border-accent hover:text-txt-primary"
}`}
>
<ThumbsDown className="h-3.5 w-3.5" />
</button>
</div>
{rating && (
<div className="mt-2 flex flex-col gap-2 rounded-xl border border-border-subtle bg-surface p-2.5">
<label className="flex items-center gap-1.5 font-medium text-txt-secondary">
<MessageSquareText className="h-3.5 w-3.5" />
Góp ý thêm (không bắt buộc)
</label>
<textarea
value={comment}
maxLength={2000}
rows={2}
onChange={(event) => setComment(event.target.value)}
placeholder="Ví dụ: thiếu lưu ý suy thận, trích dẫn chưa đúng trang…"
className="resize-y rounded-lg border border-border-subtle bg-surface-elevated px-2.5 py-2 text-xs text-txt-primary outline-none focus:border-border-accent"
/>
<div className="flex items-center justify-between gap-2">
<span>{comment.length}/2000</span>
<button
type="button"
onClick={submit}
disabled={state === "saving"}
className="rounded-lg bg-accent-primary px-3 py-1.5 font-bold text-white disabled:opacity-60"
>
{state === "saving" ? "Đang gửi…" : "Gửi phản hồi"}
</button>
</div>
{state === "error" && (
<p className="m-0 text-status-danger">Chưa lưu đưc phản hồi. Vui lòng thử lại.</p>
)}
</div>
)}
</div>
);
}
+23 -16
View File
@@ -4,6 +4,7 @@ import React, { useState, useEffect, useRef } from "react";
import type { ChatMessage, Citation, SendMessageResponse } from "@duoc-thu/shared-types";
import { ChatBubble, CitationBeamOverlay, useTheme } from "@duoc-thu/ui";
import { Composer } from "./Composer";
import { AnswerFeedback } from "./AnswerFeedback";
import {
Sparkles,
Pill,
@@ -370,22 +371,28 @@ export function ChatPanel({
? [...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={
msgIdx === messages.length - 1 &&
msg.decision === "clarify" &&
!isLoading
? (text) => handleSendMessage(text)
: undefined
}
/>
<React.Fragment key={msg.id}>
<ChatBubble
message={msg}
onCitationClick={(citation, idx, allCitations) =>
onCitationClick?.(citation, idx, allCitations)
}
activeCitationIndex={activeCitationIndex}
onRetry={retryQuery ? () => handleSendMessage(retryQuery) : undefined}
onQuickReply={
msgIdx === messages.length - 1 &&
msg.decision === "clarify" &&
!isLoading
? (text) => handleSendMessage(text)
: undefined
}
/>
{msg.role === "assistant" &&
msg.traceId &&
!msg.traceId.startsWith("fallback-") && (
<AnswerFeedback traceId={msg.traceId} conversationId={sessionId} />
)}
</React.Fragment>
);
})
)}
+48 -2
View File
@@ -16,6 +16,11 @@ interface RagCitation {
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 {
@@ -33,6 +38,12 @@ interface RagResponse {
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;
@@ -40,6 +51,14 @@ interface RagResponse {
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[] {
@@ -119,6 +138,8 @@ const REFUSALS: Record<string, string> = {
"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
@@ -142,6 +163,14 @@ const REFUSALS: Record<string, string> = {
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
@@ -168,12 +197,16 @@ function toCitations(raw: RagCitation[]): Citation[] {
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 [drugSlug, chunkSectionKey] = chunkId.split("__");
const sectionKey = primary.section_key ?? chunkSectionKey;
const isQuarantined = Boolean(attachmentRef);
return {
chunkId,
drugName: drugSlug ? drugSlug.replace(/_/g, " ").toUpperCase() : 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 ?? "",
@@ -309,6 +342,19 @@ export async function POST(request: Request) {
: 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,
};
const responseHeaders = new Headers({
+57
View File
@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
export const runtime = "nodejs";
const API_GATEWAY_URL =
process.env.API_GATEWAY_URL ?? process.env.AI_SERVICE_URL ?? "http://localhost:8000";
export async function POST(request: Request) {
let traceId: string;
let rating: "helpful" | "not_helpful";
let comment: string | null;
let conversationId: string | null;
try {
const body = await request.json();
traceId = typeof body?.traceId === "string" ? body.traceId.trim() : "";
rating = body?.rating;
comment = typeof body?.comment === "string" ? body.comment.trim() || null : null;
conversationId =
typeof body?.conversationId === "string" ? body.conversationId.trim() || null : null;
} catch {
return NextResponse.json({ error: "invalid_body" }, { status: 400 });
}
if (!/^[0-9a-f-]{36}$/i.test(traceId)) {
return NextResponse.json({ error: "invalid_trace_id" }, { status: 400 });
}
if (rating !== "helpful" && rating !== "not_helpful") {
return NextResponse.json({ error: "invalid_rating" }, { status: 400 });
}
if (comment && comment.length > 2000) {
return NextResponse.json({ error: "comment_too_long" }, { status: 400 });
}
if (conversationId && conversationId.length > 128) {
return NextResponse.json({ error: "conversation_id_too_long" }, { status: 400 });
}
const base = API_GATEWAY_URL.replace(/\/v1\/rag(?:\/query)?\/?$/, "");
try {
const upstream = await fetch(`${base}/v1/rag/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
trace_id: traceId,
rating,
comment,
conversation_id: conversationId,
}),
cache: "no-store",
signal: AbortSignal.timeout(8_000),
});
const payload = await upstream.json().catch(() => ({}));
return NextResponse.json(payload, { status: upstream.status });
} catch {
return NextResponse.json({ error: "feedback_upstream_unavailable" }, { status: 503 });
}
}