Add patient personalization: profile saved once, reused every chat turn
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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ồ sơ 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user