Commit 5ce0ec6e by luoqi

fix(助手): 患者详情页把「当前开着谁」发给模型 —— 跑批抓到的那条,补上

a8c60f7c 跑批当场记下、标着**未修**的那个:患者详情页的助手**从来没把
"当前是哪位患者"发给模型** —— current 里只有 planId + 姓名,姓名只被拿去
预填例句文字。界面上那条不带姓名的例句「这通电话怎么开口比较好?给我两句开场」
当第一句发出去,**模型不知道是谁**。

⇒ 走旁路,与 activeClinicId 同一条:store 的 current 补 patientId,
  前端每次请求带 activePatientId,服务端拼在**现场之后、渠道之前**。

️ 措辞两件事都要说:**默认落点**(没点名时指的是他)+ **可以被推翻**
  (他点了别人的名字就以他说的为准)—— 只写前半句会让它连"查另一个人"都不敢。
 **不注入到工具参数**:注入等于把"查另一位患者"这条路堵死,而他随时会问别人。
 不进 buildSystemPrompt 的五层:那五层按**什么会让它变**分,而这一行每次请求
  都不一样,是**此刻的现场状态**,不是规则。
️ 只发 id —— 姓名由工具返回, 别把界面上的显示名当事实喂给它。
️ 前端可改的入参,越权仍由各工具自己的 assertPatientInScope 挡,
   不因为"是我们自己发的"就当可信。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent a99f8eb8
...@@ -41,6 +41,17 @@ interface ChatBody { ...@@ -41,6 +41,17 @@ interface ChatBody {
* ⚠️ 用 zod 现场解析而不是直接透传:字段少一个模型就会读到 `undefined` 并当成 0。 * ⚠️ 用 zod 现场解析而不是直接透传:字段少一个模型就会读到 `undefined` 并当成 0。
*/ */
sheetState?: unknown; sheetState?: unknown;
/**
* 详情页当前开着的那位患者 —— 与 `activeClinicId` 同一条旁路。
*
* 🔴 他在那一屏说「这通电话怎么开口」「这个患者为什么被召回」时**句子里没有名字**,
* 模型无从知道是谁(界面只拿姓名去预填例句文字,从没发给过它)。
* ⚠️ 它只作**指代的默认落点**告诉模型,⛔ **不注入到工具参数** ——
* 注入等于把"查另一位患者"这条路堵死,而他随时会问别人。
* ⚠️ 前端可改的入参:越权由各工具自己的 `assertPatientInScope` 挡,
* ⛔ 不因为"是我们自己发的"就当可信。
*/
activePatientId?: string;
} }
/** /**
...@@ -189,6 +200,9 @@ export class AssistantController { ...@@ -189,6 +200,9 @@ export class AssistantController {
// ⭐ 主管当前看的诊所 —— propose_assignment 的诊所兜底(见 DTO 上那段) // ⭐ 主管当前看的诊所 —— propose_assignment 的诊所兜底(见 DTO 上那段)
...(body.activeClinicId ? { activeClinicId: body.activeClinicId } : {}), ...(body.activeClinicId ? { activeClinicId: body.activeClinicId } : {}),
// ⚠️ 解不出来就当没有 —— ⛔ 别把半份快照喂给模型(缺的字段会被读成 0) // ⚠️ 解不出来就当没有 —— ⛔ 别把半份快照喂给模型(缺的字段会被读成 0)
...(typeof body.activePatientId === 'string' && body.activePatientId
? { activePatientId: body.activePatientId }
: {}),
...(SheetSnapshotSchema.safeParse(body.sheetState).success ...(SheetSnapshotSchema.safeParse(body.sheetState).success
? { sheetState: SheetSnapshotSchema.parse(body.sheetState) } ? { sheetState: SheetSnapshotSchema.parse(body.sheetState) }
: {}), : {}),
......
...@@ -87,6 +87,11 @@ export interface AssistantChatInput { ...@@ -87,6 +87,11 @@ export interface AssistantChatInput {
* 见 `SheetSnapshotSchema`)。⚠️ 每次请求现取现发,⛔ 这里不许缓存。 * 见 `SheetSnapshotSchema`)。⚠️ 每次请求现取现发,⛔ 这里不许缓存。
*/ */
sheetState?: SheetSnapshot; sheetState?: SheetSnapshot;
/**
* 详情页当前开着的那位患者 —— **只作指代的默认落点**,⛔ 不注入工具参数
* (注入等于堵死"查另一位",而他随时会问别人)。
*/
activePatientId?: string;
/** 当前登录人(本地工具要用 scope 取数);缺省则本地写工具不注册 */ /** 当前登录人(本地工具要用 scope 取数);缺省则本地写工具不注册 */
scope?: TenantScopeContext; scope?: TenantScopeContext;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
...@@ -920,6 +925,25 @@ export class AssistantService { ...@@ -920,6 +925,25 @@ export class AssistantService {
permissions: input.permissions ?? [], permissions: input.permissions ?? [],
voice: input.voice ?? this.config.get('ai.assistantVoice', { infer: true }), voice: input.voice ?? this.config.get('ai.assistantVoice', { infer: true }),
}), }),
/**
* 🔴 **他此刻正开着谁** —— 拼在现场之后、渠道之前(2026-08-15 加)。
*
* 他在患者详情页说「这通电话怎么开口」「这个患者为什么被召回」时**句子里没有名字**。
* 界面本来就知道是谁(那几条例句正是拿他的名字预填的),却从没发给过模型 ——
* ⇒ 上下文走旁路,⛔ 别指望它从对话里猜(同 `activeClinicId` 那条纪律)。
*
* ⚠️ 措辞两件事都要说:**默认落点**(没点名时指的是他)+ **可以被推翻**
* (他点了别人的名字就以他说的为准)—— 只写前半句会让它连"查另一个人"都不敢。
* ⛔ 不进 `buildSystemPrompt` 的五层:那五层是**按什么会让它变**分的,
* 而这一行每次请求都不一样,是**此刻的现场状态**,不是规则。
*/
...(input.activePatientId
? [
`他现在正开着一位患者(patientId: ${input.activePatientId})。` +
`他说「这个患者」「这通电话」而没点名时,指的就是这一位。` +
`他点了别人的名字,就以他说的为准。`,
]
: []),
...(input.channelExtra ? [input.channelExtra] : []), ...(input.channelExtra ? [input.channelExtra] : []),
].join('\n\n'), ].join('\n\n'),
messages: input.messages, messages: input.messages,
......
...@@ -56,11 +56,14 @@ function PlanDetailLoader({ planId }: { planId: string }) { ...@@ -56,11 +56,14 @@ function PlanDetailLoader({ planId }: { planId: string }) {
} }
// 发布"当前工作台患者"(右下角助手按它出场景化开场建议) // 发布"当前工作台患者"(右下角助手按它出场景化开场建议)
const currentPatientName = state.status === 'ready' ? (state.data.patient.name ?? '') : null; const currentPatientName = state.status === 'ready' ? (state.data.patient.name ?? '') : null;
const currentPatientId = state.status === 'ready' ? state.data.patient.id : null;
useEffect(() => { useEffect(() => {
if (currentPatientName) { if (currentPatientName && currentPatientId) {
usePlanSyncStore.getState().setCurrent({ planId, patientName: currentPatientName }); usePlanSyncStore
.getState()
.setCurrent({ planId, patientId: currentPatientId, patientName: currentPatientName });
} }
}, [planId, currentPatientName]); }, [planId, currentPatientId, currentPatientName]);
// W4 末:单患者刷新后 plan 可能已被 supersede / abandoned(scenario 不再命中) // W4 末:单患者刷新后 plan 可能已被 supersede / abandoned(scenario 不再命中)
// → /full 接口返回 PLAN_NOT_FOUND → 这里捕获后 toast + 跳列表(避免用户卡在 error 页) // → /full 接口返回 PLAN_NOT_FOUND → 这里捕获后 toast + 跳列表(避免用户卡在 error 页)
......
...@@ -51,14 +51,16 @@ export default function PetLabPage() { ...@@ -51,14 +51,16 @@ export default function PetLabPage() {
label="模拟成约(庆祝)" label="模拟成约(庆祝)"
onClick={() => { onClick={() => {
const s = usePlanSyncStore.getState(); const s = usePlanSyncStore.getState();
s.setCurrent({ planId: `lab-${Date.now()}`, patientName: '测试患者' }); s.setCurrent({ planId: `lab-${Date.now()}`, patientId: 'lab-patient', patientName: '测试患者' });
s.notify('lab-plan', 'completed'); s.notify('lab-plan', 'completed');
}} }}
/> />
<LabBtn <LabBtn
label="模拟切患者(打招呼)" label="模拟切患者(打招呼)"
onClick={() => onClick={() =>
usePlanSyncStore.getState().setCurrent({ planId: `lab-${Date.now()}`, patientName: '王小明' }) usePlanSyncStore
.getState()
.setCurrent({ planId: `lab-${Date.now()}`, patientId: 'lab-patient', patientName: '王小明' })
} }
/> />
<LabBtn <LabBtn
......
...@@ -6,6 +6,7 @@ import { env } from '@/lib/env'; ...@@ -6,6 +6,7 @@ import { env } from '@/lib/env';
import { emitPetEvent } from '@/lib/pet-events'; import { emitPetEvent } from '@/lib/pet-events';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useAssistantStore } from '@/stores/assistant-store'; import { useAssistantStore } from '@/stores/assistant-store';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
// ⚠️ 「换无主患者补上」要重跑一版 —— 那是换人群,⛔ 不是局部改单 // ⚠️ 「换无主患者补上」要重跑一版 —— 那是换人群,⛔ 不是局部改单
import { assignmentsApi } from '@/components/plans/assignments-api'; import { assignmentsApi } from '@/components/plans/assignments-api';
...@@ -440,6 +441,17 @@ export function useAssistantChat() { ...@@ -440,6 +441,17 @@ export function useAssistantChat() {
...(useAssistantStore.getState().sheetSnapshot ...(useAssistantStore.getState().sheetSnapshot
? { sheetState: useAssistantStore.getState().sheetSnapshot } ? { sheetState: useAssistantStore.getState().sheetSnapshot }
: {}), : {}),
/**
* ⭐ 详情页当前开着的那位患者 —— 与 `activeClinicId` 同一条旁路。
*
* 🔴 他在患者详情页说「这通电话怎么开口」「这个患者为什么被召回」时,
* **句子里没有名字**,模型此前无从知道是谁(界面只拿姓名去预填例句文字,
* 从没发给过它)。⇒ 上下文走旁路,⛔ 别指望模型从对话里猜。
* ⚠️ 只发 id:姓名由工具返回,⛔ 别把界面上的显示名当事实喂给它。
*/
...(usePlanSyncStore.getState().current
? { activePatientId: usePlanSyncStore.getState().current!.patientId }
: {}),
}), }),
signal: controller.signal, signal: controller.signal,
}); });
......
...@@ -25,8 +25,16 @@ interface PlanSyncState { ...@@ -25,8 +25,16 @@ interface PlanSyncState {
/** 列表页认领 / 返池后调用(反向通道) */ /** 列表页认领 / 返池后调用(反向通道) */
notifyClaim: (planId: string, assigneeUserId: string | null) => void; notifyClaim: (planId: string, assigneeUserId: string | null) => void;
/** 当前工作台正在看的患者(详情页 ready 时发布;右下角助手按它出场景化开场建议) */ /** 当前工作台正在看的患者(详情页 ready 时发布;右下角助手按它出场景化开场建议) */
current: { planId: string; patientName: string } | null; /**
setCurrent: (current: { planId: string; patientName: string } | null) => void; * 详情页当前开着的那位。
* ⚠️ `patientId` 2026-08-15 补 —— 原来只有 `planId` + 姓名,够界面用,
* 但**助手要的是 id**:他说「这个患者」「这通电话」而没点名时,
* 模型此前根本不知道是谁(姓名只被拿去预填例句文字,从没发给过它)。
*/
current: { planId: string; patientId: string; patientName: string } | null;
setCurrent: (
current: { planId: string; patientId: string; patientName: string } | null,
) => void;
} }
export const usePlanSyncStore = create<PlanSyncState>((set) => ({ export const usePlanSyncStore = create<PlanSyncState>((set) => ({
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment