Add patient personalization: profile saved once, reused every chat turn

This commit is contained in:
2026-08-25 11:55:13 +07:00
parent 501c226ecd
commit 1ddd878431
12 changed files with 548 additions and 6 deletions
+40 -1
View File
@@ -10,6 +10,8 @@ import type {
SendMessageResponse,
} from "@duoc-thu/shared-types";
import { ChatBubble, CitationBeamOverlay, useTheme } from "@duoc-thu/ui";
import type { PatientProfile } from "@duoc-thu/shared-types";
import { getPatientProfile } from "@duoc-thu/api-client";
import { Composer } from "./Composer";
import { AnswerFeedback } from "./AnswerFeedback";
import {
@@ -54,6 +56,24 @@ interface SectionTextResponse {
}>;
}
/** Prepends a short, natural Vietnamese clause carrying whatever the saved
* profile has (only the fields actually filled in — an empty profile or a
* partially-filled one changes nothing it doesn't have data for). Sent on
* every turn, not just the first: the understanding model's own multi-turn
* merge already treats a repeated fact as a no-op, so there's no need to
* track "did we already say this in this conversation" here. */
function withPatientContext(userText: string, profile: PatientProfile | null): string {
if (!profile) return userText;
const parts: string[] = [];
if (profile.ageText) parts.push(profile.ageText);
if (profile.weightKg != null) parts.push(`${profile.weightKg} kg`);
if (profile.renalFunction) parts.push(`thận: ${profile.renalFunction}`);
if (profile.hepaticFunction) parts.push(`gan: ${profile.hepaticFunction}`);
if (profile.knownAllergies) parts.push(`dị ứng: ${profile.knownAllergies}`);
if (parts.length === 0) return userText;
return `Bệnh nhân ${parts.join(", ")}. ${userText}`;
}
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.";
@@ -132,6 +152,20 @@ export function ChatPanel({
const abortControllerRef = useRef<AbortController | null>(null);
const initialQuerySentRef = useRef<number | undefined>(undefined);
const stopRequestedRef = useRef(false);
// null = anonymous or no saved profile — never touches the outgoing query.
// Fetched once; `getPatientProfile()` itself returns null on a 401, so an
// anonymous visitor never even attempts an authenticated call more than once.
const patientProfileRef = useRef<PatientProfile | null>(null);
useEffect(() => {
getPatientProfile()
.then((profile) => {
patientProfileRef.current = profile;
})
.catch(() => {
patientProfileRef.current = null;
});
}, []);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -172,7 +206,12 @@ export function ChatPanel({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: userText,
// Prepends saved patient context (age/weight/renal/hepatic/allergy)
// to what's actually sent, never to what's shown in the chat
// bubble above — the LLM understanding step already extracts these
// fields from free text every turn (see rag/understanding.py), so
// this reuses that exact path instead of adding a second one.
content: withPatientContext(userText, patientProfileRef.current),
conversationId: sessionId,
responseMode,
}),