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
+3 -1
View File
@@ -39,7 +39,9 @@ export function AccountMenu() {
return (
<div className="flex items-center gap-1.5 rounded-full border border-border-subtle bg-surface px-3 py-1.5 text-xs font-semibold text-txt-secondary">
<UserRound className="h-3.5 w-3.5" />
<span>{user.username}</span>
<Link href="/profile" className="hover:underline" title="Hồ sơ bệnh nhân">
{user.username}
</Link>
<button
aria-label="Đăng xuất"
onClick={async () => {
+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,
}),
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import type { PatientProfile } from "@duoc-thu/shared-types";
import { getPatientProfile, savePatientProfile } from "@duoc-thu/api-client";
const EMPTY: PatientProfile = {
ageText: null,
weightKg: null,
renalFunction: null,
hepaticFunction: null,
knownAllergies: null,
};
/** Optional per-account clinical context. Nothing here is required — an
* empty profile behaves exactly like no profile: the chatbot keeps asking
* age/weight per turn, same as an anonymous session. Only a logged-in user
* ever sees this page (`AccountMenu` is the only link to it). */
export function ProfileForm() {
const [profile, setProfile] = useState<PatientProfile>(EMPTY);
const [loaded, setLoaded] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
useEffect(() => {
getPatientProfile()
.then((p) => p && setProfile(p))
.finally(() => setLoaded(true));
}, []);
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
setError(null);
setSaved(false);
setPending(true);
try {
const result = await savePatientProfile(profile);
setProfile(result);
setSaved(true);
} catch {
setError("Không lưu được lúc này. Vui lòng thử lại.");
} finally {
setPending(false);
}
}
if (!loaded) return null;
return (
<div className="flex flex-1 items-center justify-center p-6">
<form
onSubmit={onSubmit}
className="w-full max-w-sm space-y-4 rounded-2xl border border-border-subtle bg-surface p-6 shadow-sm"
>
<div>
<h1 className="text-lg font-bold text-txt-primary">
Hồ bệnh nhân
</h1>
<p className="mt-1 text-xs text-txt-secondary">
Điền sẵn đ không phải nhắc lại tuổi/cân nặng mỗi lượt hỏi. Bỏ
trống mục nào thì chatbot vẫn hỏi lại mục đó như bình thường.
</p>
</div>
<label className="block text-xs font-semibold text-txt-secondary">
Tuổi
<input
type="text"
value={profile.ageText ?? ""}
onChange={(e) => setProfile({ ...profile, ageText: e.target.value })}
placeholder="VD: 8 tuổi, sơ sinh 2 tháng"
className="mt-1 w-full rounded-lg border border-border-subtle bg-surface-hover px-3 py-2 text-sm text-txt-primary"
/>
</label>
<label className="block text-xs font-semibold text-txt-secondary">
Cân nặng (kg)
<input
type="number"
min={0}
max={500}
value={profile.weightKg ?? ""}
onChange={(e) =>
setProfile({
...profile,
weightKg: e.target.value === "" ? null : Number(e.target.value),
})
}
className="mt-1 w-full rounded-lg border border-border-subtle bg-surface-hover px-3 py-2 text-sm text-txt-primary"
/>
</label>
<label className="block text-xs font-semibold text-txt-secondary">
Chức năng thận
<input
type="text"
value={profile.renalFunction ?? ""}
onChange={(e) =>
setProfile({ ...profile, renalFunction: e.target.value })
}
placeholder="VD: suy thận độ 2, Clcr 45 ml/phút"
className="mt-1 w-full rounded-lg border border-border-subtle bg-surface-hover px-3 py-2 text-sm text-txt-primary"
/>
</label>
<label className="block text-xs font-semibold text-txt-secondary">
Chức năng gan
<input
type="text"
value={profile.hepaticFunction ?? ""}
onChange={(e) =>
setProfile({ ...profile, hepaticFunction: e.target.value })
}
placeholder="VD: suy gan nhẹ"
className="mt-1 w-full rounded-lg border border-border-subtle bg-surface-hover px-3 py-2 text-sm text-txt-primary"
/>
</label>
<label className="block text-xs font-semibold text-txt-secondary">
Dị ng đã biết
<input
type="text"
value={profile.knownAllergies ?? ""}
onChange={(e) =>
setProfile({ ...profile, knownAllergies: e.target.value })
}
placeholder="VD: dị ứng penicillin"
className="mt-1 w-full rounded-lg border border-border-subtle bg-surface-hover px-3 py-2 text-sm text-txt-primary"
/>
</label>
{error && <p className="text-xs text-red-500">{error}</p>}
{saved && !error && (
<p className="text-xs text-emerald-600">Đã lưu.</p>
)}
<div className="flex items-center gap-3">
<button
type="submit"
disabled={pending}
className="rounded-lg bg-accent-primary px-4 py-2 text-sm font-semibold text-txt-inverse disabled:opacity-60"
>
{pending ? "Đang lưu..." : "Lưu"}
</button>
<Link href="/" className="text-xs text-txt-secondary hover:underline">
Về trang tra cứu
</Link>
</div>
</form>
</div>
);
}