Commit 8ff25259 by luoqi

feat: 助手能直接改确认单(edit_assignment_sheet) + 卡片去掉只读的人数块

走查实录:主管说「把杨丽华移出这个确认单」,助手回"我这边没法单独剔除,您先点确认、
之后在批次详情里单条退回" —— 把界面能做的事推回给主管手工做,而卡片就在他眼前。

新增本地工具 edit_assignment_sheet(不走 MCP,同 propose_assignment):
能做的与卡片完全一致 —— 删患者 / 移除客服 / 改派 / 改时效(整批、按客服、按患者)。

 **指令用姓名,不用 planId**:那些 id 从来没进过模型上下文(确认单走侧信道,故意的)。
   模型负责"听懂",**界面拿自己手里那份确认单去匹配**。为了让模型能指名道姓而把明细
   喂给它,等于把当初走侧信道的理由(几百个 uuid、幻觉、成本)全部退回去。
️ **结果由界面回一句进对话**(复用 appendAssistantNote), 模型不许自己宣布成功 ——
   它看不到卡片,匹配不到/同名/客服不在本批都是界面才知道的事。那一句同时进模型上下文,
   下一轮它才不会替系统撒谎(T14)。工具返回值里也写死了"等界面那句再说"。

🔴 修两个实测抓到的坑:
· 指令挂载**必须跨消息找**最后一张确认单 —— 卡片几乎总在上一轮消息里,
  只在当前消息里找永远找不到,而且不报错、界面毫无反应;
· **同名歧义不许猜**:实测「张悦」既是本批客服又是本批患者,模型选了 remove_agent,
  于是"把张悦移出确认单"变成把客服连同 20 条一起移除。现在这种情况直接拒执行并说明,
  提示词也定死默认按**患者**理解(猜错的代价不对称:一下子动 20 条 vs 1 条)。

· 卡片底部那块只读的「人数 本批 340 人 / 要改说…」撤掉:只读信息不该占跟可操作区
  一样重的位置,它想说的两件事都有更好的落点(顶部数字 + 现在可以直接让助手改)。

本地实测:对话说「把张悦移出这个分配确认单」→ 助手调工具 → 界面执行并回报
→ 确认按钮 340 条变 320 条。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 7cb0048d
...@@ -27,7 +27,7 @@ const DISPATCHER_EXTRA = ` ...@@ -27,7 +27,7 @@ const DISPATCHER_EXTRA = `
4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail 4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail
5. 分错了可以撤销:revoke_assignment(限时;⚠️ 只在主管**明确要求**时调) 5. 分错了可以撤销:revoke_assignment(限时;⚠️ 只在主管**明确要求**时调)
### 条硬约束(违反任何一条都会造成真实损失) ### 条硬约束(违反任何一条都会造成真实损失)
1. **你全程只读。** 唯一改变数据的动作是主管在确认单上点确认,那由界面完成。 1. **你全程只读。** 唯一改变数据的动作是主管在确认单上点确认,那由界面完成。
⛔ **绝不要说「已经分配好了」/「已经派下去了」** —— 正确说法是 ⛔ **绝不要说「已经分配好了」/「已经派下去了」** —— 正确说法是
...@@ -63,7 +63,19 @@ const DISPATCHER_EXTRA = ` ...@@ -63,7 +63,19 @@ const DISPATCHER_EXTRA = `
原样转述 rosterNote,⛔ 不要说成「系统确认在职」,也不要替主管挡人 —— 原样转述 rosterNote,⛔ 不要说成「系统确认在职」,也不要替主管挡人 ——
名册外的客服他照样可以指定(有人只做召回不做回访,名册里查不到)。 名册外的客服他照样可以指定(有人只做召回不做回访,名册里查不到)。
7. **确认单是一张真卡片,就在你这条消息里。** 7. **确认单是一张真卡片,你能直接改它。**
主管说「把杨丽华移出这批」「王强的单给 5 天」「李莉这些转给张悦」→ 调 edit_assignment_sheet。
⛔ **绝不要回答"我做不到 / 你先确认再逐条退回"** —— 卡片就在他眼前,你有工具(2026-08-03 走查)。
⚠️ 患者用**姓名或病历号**指代,客服用姓名;你没有 planId,也不需要。
⚠️⚠️ 「把某某移出这批」默认是**患者**(remove_patient)。只有主管明说
「客服某某不参与 / 某某的单都别给他」才是 remove_agent —— 后者一下子动 20 条,
猜错了主管以为只动了 1 条。实测踩过:「张悦」既是本批客服又是本批患者。
⚠️ 调完**先别宣布成功**:界面执行后会回一句结果(可能"没找到这个人"/"有两个同名"),
等那句出现再据实回复。⛔ 自己先说"已经移除了"是替系统撒谎(T14)。
⚠️ 只能做**局部修改**(删/改派/改时效)。换人群、改批次人数要重跑算法 →
那走 propose_assignment 重出一版,⛔ 不要用 edit 去凑。
8. **确认单就在你这条消息里,别提界面上没有的东西。**
调完 propose_assignment,界面会自动渲染出确认单(含「确认分配 N 条」按钮和时效微调)。 调完 propose_assignment,界面会自动渲染出确认单(含「确认分配 N 条」按钮和时效微调)。
⛔ **不要复述明细**(你也拿不到 planId,那是故意的); ⛔ **不要复述明细**(你也拿不到 planId,那是故意的);
⛔ **不要说"界面上没有按钮"**,更不要引导主管"回复确认分配" —— 按钮就在卡片上,他点就行。 ⛔ **不要说"界面上没有按钮"**,更不要引导主管"回复确认分配" —— 按钮就在卡片上,他点就行。
...@@ -71,7 +83,7 @@ const DISPATCHER_EXTRA = ` ...@@ -71,7 +83,7 @@ const DISPATCHER_EXTRA = `
⚠️ 反过来也一样:**不要提任何界面上不存在的东西**。这是 T14 的直接推论 —— ⚠️ 反过来也一样:**不要提任何界面上不存在的东西**。这是 T14 的直接推论 ——
没有证据的不许写进结论,**界面元素也算证据**。 没有证据的不许写进结论,**界面元素也算证据**。
8. **画像收窄要先看分布,再动手。** 9. **画像收窄要先看分布,再动手。**
主管说「只要商保直付的」「排掉怕疼的」时: 主管说「只要商保直付的」「排掉怕疼的」时:
① 先调 get_cohort_attributes 看这批人里各口子多少人 → ② 如实回一句 ① 先调 get_cohort_attributes 看这批人里各口子多少人 → ② 如实回一句
→ ③ 再带 personaTags 重出确认单。⛔ 不要跳过 ① 直接圈 —— 圈完才发现只剩 3 个人, → ③ 再带 personaTags 重出确认单。⛔ 不要跳过 ① 直接圈 —— 圈完才发现只剩 3 个人,
......
...@@ -209,6 +209,60 @@ export class AssistantService { ...@@ -209,6 +209,60 @@ export class AssistantService {
].join('\n'); ].join('\n');
}, },
}); });
/**
* ⭐ 助手**直接改已经呈现的那张确认单**。
*
* 主管说「把杨丽华移出这批」「王强的单给 5 天」「李莉这些转给张悦」时用。
* 在此之前助手只能回一句"我做不到,你先确认再逐条退回" —— 那是把界面能做的事
* 推回给主管手工做,而这张卡就在他眼前(2026-08-03 走查)。
*
* ⚠️ 指令用**姓名**表达,⛔ 不用 planId:那些 id 根本没进过模型上下文(故意的)。
* 匹配由界面用自己手里那份确认单完成,结果它会回一句话进对话。
* ⚠️ 能做的只有卡片上能做的那几件(删/改派/改时效)。
* 换人群、改批次人数要重跑算法 → 只能重新调 propose_assignment 出一版。
*/
tools.edit_assignment_sheet = tool({
description:
'直接修改**已经呈现**的那张确认单(删患者 / 移除某个客服 / 把患者转给别人 / 改时效)。' +
'\n⚠️ 主管说「把某某移出这批」「某某的单给 5 天」时**就用这个**,' +
'⛔ 不要回答"做不到"、也不要让他先确认再逐条退回 —— 卡片就在他眼前。' +
'\n⚠️ 只能做这几件局部修改。**换人群 / 改批次人数**要重出确认单,那走 propose_assignment。' +
'\n⚠️ 患者用**姓名或病历号**指代,客服用姓名;⛔ 你没有 planId,也不需要。' +
'\n⚠️ 改完**不要说"已完成"**:界面执行后会自己回一句结果(可能没找到人),等那句再说。',
inputSchema: jsonSchema({
type: 'object',
properties: {
ops: {
type: 'array',
description: '一组操作,按顺序执行',
items: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['remove_patient', 'remove_agent', 'move_patient', 'set_expiry'],
},
patient: { type: 'string', description: '患者姓名或病历号' },
agent: { type: 'string', description: '客服姓名' },
toAgent: { type: 'string', description: 'move_patient 的目标客服' },
days: { type: 'number', description: 'set_expiry 的天数(1-90)' },
},
required: ['action'],
},
},
},
required: ['ops'],
}),
execute: async (args: unknown) => {
const ops = (args as { ops?: unknown[] })?.ops;
if (!Array.isArray(ops) || ops.length === 0) return '没有给出任何操作,确认单未改动。';
push({ type: 'assignment_sheet_edit', ops });
// ⚠️ 只回"已交给界面",⛔ 不回"已完成" —— 匹配得到匹配不到是界面才知道的事,
// 它会把结果作为一条消息回到对话里(前端 appendAssistantNote)。
return `已把 ${ops.length} 条修改交给界面执行,结果会由界面回报(可能有人没找到)。请等那句结果,⛔ 不要自己宣布已完成。`;
},
});
} }
// 本地"渲染"工具(不走 MCP):模型把自包含 HTML 片段交给前端,在沙箱 iframe 里渲染成卡片/报表。 // 本地"渲染"工具(不走 MCP):模型把自包含 HTML 片段交给前端,在沙箱 iframe 里渲染成卡片/报表。
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
type AgentOverride, type AgentOverride,
type AssignmentProposal, type AssignmentProposal,
type ProposedItem, type ProposedItem,
type SheetEditOp,
} from '@pac/types'; } from '@pac/types';
import { Can } from '@/components/can'; import { Can } from '@/components/can';
import { assignmentsApi } from '@/components/plans/assignments-api'; import { assignmentsApi } from '@/components/plans/assignments-api';
...@@ -132,6 +133,8 @@ export function AssignmentConfirmSheet({ ...@@ -132,6 +133,8 @@ export function AssignmentConfirmSheet({
state, state,
assignmentId, assignmentId,
onConfirmed, onConfirmed,
edits,
onEditApplied,
}: { }: {
requestId: string; requestId: string;
sheet: AssignmentProposal; sheet: AssignmentProposal;
...@@ -139,6 +142,13 @@ export function AssignmentConfirmSheet({ ...@@ -139,6 +142,13 @@ export function AssignmentConfirmSheet({
assignmentId?: string; assignmentId?: string;
/** 落库成功 → 交给外层把卡片切终态 + 往消息流注入一条文本(补模型记忆) */ /** 落库成功 → 交给外层把卡片切终态 + 往消息流注入一条文本(补模型记忆) */
onConfirmed: (assignmentId: string, summary: string) => void; onConfirmed: (assignmentId: string, summary: string) => void;
/**
* 助手下发的语义编辑指令队列(edit_assignment_sheet)。
* ⚠️ 由**本组件**去匹配姓名 —— 模型手里没有 planId,那是故意的。
*/
edits?: { seq: number; ops: SheetEditOp[] }[];
/** 执行结果回一句进对话(既给主管看,也补进模型上下文 —— 否则它不知道到底成没成) */
onEditApplied?: (summary: string) => void;
}) { }) {
/** /**
* 批次默认时效 —— 初值来自**提案**(沿用主管上一次的值),⛔ 不是写死的 3。 * 批次默认时效 —— 初值来自**提案**(沿用主管上一次的值),⛔ 不是写死的 3。
...@@ -301,6 +311,106 @@ export function AssignmentConfirmSheet({ ...@@ -301,6 +311,106 @@ export function AssignmentConfirmSheet({
const removingGroup = groups.find((g) => g.userId === pendingRemove); const removingGroup = groups.find((g) => g.userId === pendingRemove);
/**
* 🔴 执行助手下发的**语义编辑指令**。
*
* 模型只会说「把杨丽华移出这批」——**姓名由这里去匹配**(它没有 planId,那是故意的)。
* ⚠️ 匹配不到 / 匹配到多个都要**如实回一句**,⛔ 不许静默跳过:
* 模型看不到卡片,静默失败会让它跟主管说"已经移除了",而那个人还在。
* ⚠️ `appliedRef` 记已执行到第几批 —— 组件每次重渲染 edits 都在,不挡就会重复执行。
*/
const appliedRef = useRef(0);
useEffect(() => {
if (!edits?.length) return;
const fresh = edits.filter((e) => e.seq > appliedRef.current);
if (!fresh.length) return;
appliedRef.current = Math.max(...edits.map((e) => e.seq));
const notes: string[] = [];
// ⚠️ 用 items(生效条目)匹配,不是 sheet.items —— 已经删掉的人不该再被"找到"
const findPatients = (q: string) => {
const k = q.trim();
return items.filter((i) => i.patientName === k || i.medicalRecordNumber === k);
};
const findAgent = (q: string) => {
const k = q.trim();
return groups.find((g) => g.name === k || g.userId === k) ?? null;
};
for (const { ops } of fresh) {
for (const op of ops) {
if (op.action === 'remove_agent' || (op.action === 'set_expiry' && op.agent)) {
const name = op.action === 'remove_agent' ? op.agent : op.agent!;
const g = findAgent(name);
if (!g) {
notes.push(`没找到客服「${name}」,跳过`);
continue;
}
// 🔴 同名歧义:实测「张悦」既是本批客服、又是本批患者,模型选了 remove_agent,
// 于是"把张悦移出确认单"变成了把**客服**张悦连同他名下 20 条一起移除。
// ⛔ 这种时候不许猜 —— 猜错动的是 20 条,而主管以为只动了 1 条。
if (findPatients(name).length > 0) {
notes.push(
`「${name}」既是本批客服、又是本批患者,没有执行 —— 请说清是「客服 ${name}」还是患者`,
);
continue;
}
if (op.action === 'remove_agent') {
setDropped((sset) => {
const n = new Set(sset);
for (const it of g.list) n.add(it.planId);
return n;
});
notes.push(`已移除客服「${g.name ?? name}」及其 ${g.list.length} `);
} else {
setDayByPlan((m) => {
const n = { ...m };
for (const it of g.list) n[it.planId] = op.days;
return n;
});
notes.push(`${g.name ?? name}」名下 ${g.list.length} 条时效改为 ${op.days} `);
}
continue;
}
if (op.action === 'set_expiry' && !op.patient) {
setExpiresInDays(op.days);
notes.push(`整批时效改为 ${op.days} `);
continue;
}
// 以下都是"针对某个患者"
const who = op.action === 'set_expiry' ? op.patient! : op.patient;
const hit = findPatients(who);
if (hit.length === 0) {
notes.push(`确认单里没有「${who},跳过`);
continue;
}
if (hit.length > 1) {
// ⛔ 同名时不猜:猜错等于把不该动的人动了,而主管看不出来
notes.push(` ${hit.length} 位叫「${who},没有执行 —— 请用病历号指定`);
continue;
}
const it = hit[0]!;
if (op.action === 'remove_patient') {
setDropped((sset) => new Set(sset).add(it.planId));
notes.push(`已从本批移除「${it.patientName ?? who}`);
} else if (op.action === 'move_patient') {
const to = findAgent(op.toAgent);
if (!to) {
notes.push(`没找到客服「${op.toAgent},${who}」未改派`);
continue;
}
setMoveByPlan((m) => ({ ...m, [it.planId]: to.userId }));
notes.push(`${it.patientName ?? who}」改派给「${to.name ?? op.toAgent}`);
} else {
setDayByPlan((m) => ({ ...m, [it.planId]: op.days }));
notes.push(`${it.patientName ?? who}」时效改为 ${op.days} `);
}
}
}
if (notes.length) onEditApplied?.(`确认单已更新:${notes.join(';')}`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [edits]);
/// 找到离卡片最近的可滚动祖先(助手窗的消息区),拖拽自动滚动用 /// 找到离卡片最近的可滚动祖先(助手窗的消息区),拖拽自动滚动用
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
...@@ -510,39 +620,11 @@ export function AssignmentConfirmSheet({ ...@@ -510,39 +620,11 @@ export function AssignmentConfirmSheet({
</div> </div>
)} )}
{/* ③ 批次级设置 */} {/* ⛔ 这里原来有一块「人数 本批 340 人 / 要改说『这批 680 人』/ 时效为首次默认…」——
<div className="space-y-1.5 border-t border-slate-100 px-3 py-2"> 撤掉(2026-08-03 走查)。人数是**只读**的,而只读信息不该占一块跟可操作区一样重的位置;
{/* 本批人数:**只读**。改它就是换一批人 —— 卡片不做这种事(要回对话) */} 它想说的两件事都已经有更好的落点:
<div className="flex items-center gap-2 text-[11px] text-slate-500"> · 本批多少人 → 顶部「拟分 N 人」和确认按钮上都写着;
<span className="flex-none">人数</span> · 怎么改 → 助手在消息里说,而且现在可以直接让助手改(edit_assignment_sheet)。 */}
<span className="rounded border border-slate-200 px-2 py-0.5 tabular-nums text-slate-600">
本批 {sheet.target}
</span>
<span className="text-[10px] text-slate-400">
{sheet.target < sheet.batchSize
? `候选只有这么多(基数 ${sheet.batchSize} 不变)`
: `要改说「这批 ${sheet.batchSize * 2} 人」`}
</span>
</div>
{/* ⭐ T14:时效/人数这两个数**哪来的**必须当场说清 —— 沿用上次 / 首次默认,信任度不同 */}
<p className="text-[10.5px] leading-relaxed text-slate-400">
时效{' '}
{sheet.basis === 'inherited' && sheet.basisFrom
? `沿用 ${sheet.basisFrom.slice(5, 10).replace('-', '/')} 那次`
: sheet.basis === 'explicit'
? '为本次指定'
: '为首次默认'}
{Object.keys(dayByPlan).length > 0 && `,其中 ${Object.keys(dayByPlan).length} 条单独设了`};
条目可<span className="font-medium text-slate-500">拖到别的客服</span>名下;
换人群 / 改人数回对话让助手重新圈。
</p>
</div>
{/* ⛔ 这里原来把 selectionNote / basisNote / rosterNote 三句原样贴了一遍 —— 撤掉
(2026-08-03 走查:"规则拼成的,用户不需要知道")。
⚠️ 三句话**没有删**,只是不在卡片上重复:助手在消息正文里已经原话转述了一遍
(T14 双保险的"提示词"那一半),卡片再贴一次是同一段话出现两遍。
⛔ 别把服务端那三个字段也删了 —— 助手要靠它们照抄。 */}
{/* ④ 确认 */} {/* ④ 确认 */}
<div className="border-t border-slate-100 px-3 py-2"> <div className="border-t border-slate-100 px-3 py-2">
......
...@@ -131,9 +131,12 @@ function Markdown({ text }: { text: string }) { ...@@ -131,9 +131,12 @@ function Markdown({ text }: { text: string }) {
function BlockView({ function BlockView({
block, block,
onSheetConfirmed, onSheetConfirmed,
onSheetEdited,
}: { }: {
block: Block; block: Block;
onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string) => void; onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string) => void;
/** 助手改确认单后,界面回报的那一句(注入消息流) */
onSheetEdited?: (summary: string) => void;
}) { }) {
// 工具调用不再单独显示卡片 —— 其"正在做什么"通过下方 loading 指示体现(见 MessageView)。 // 工具调用不再单独显示卡片 —— 其"正在做什么"通过下方 loading 指示体现(见 MessageView)。
if (block.kind === 'tool') return null; if (block.kind === 'tool') return null;
...@@ -145,6 +148,10 @@ function BlockView({ ...@@ -145,6 +148,10 @@ function BlockView({
sheet={block.sheet} sheet={block.sheet}
state={block.state} state={block.state}
assignmentId={block.assignmentId} assignmentId={block.assignmentId}
edits={block.edits}
// 执行结果回一句进对话:既给主管看,也补进模型上下文 ——
// 否则它不知道到底成没成(可能有人没找到),下一句就会替系统撒谎
onEditApplied={onSheetEdited}
onConfirmed={(id, summary) => onSheetConfirmed?.(block.requestId, id, summary)} onConfirmed={(id, summary) => onSheetConfirmed?.(block.requestId, id, summary)}
/> />
); );
...@@ -361,10 +368,13 @@ function MessageView({ ...@@ -361,10 +368,13 @@ function MessageView({
message, message,
streaming, streaming,
onSheetConfirmed, onSheetConfirmed,
onSheetEdited,
}: { }: {
message: ChatMessage; message: ChatMessage;
streaming?: boolean; streaming?: boolean;
onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string) => void; onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string) => void;
/** 助手改确认单后,界面回报的那一句(注入消息流) */
onSheetEdited?: (summary: string) => void;
}) { }) {
if (message.role === 'user') { if (message.role === 'user') {
return ( return (
...@@ -387,7 +397,12 @@ function MessageView({ ...@@ -387,7 +397,12 @@ function MessageView({
return ( return (
<div className="min-w-0 space-y-2.5"> <div className="min-w-0 space-y-2.5">
{message.blocks.map((b, i) => ( {message.blocks.map((b, i) => (
<BlockView key={i} block={b} onSheetConfirmed={onSheetConfirmed} /> <BlockView
key={i}
block={b}
onSheetConfirmed={onSheetConfirmed}
onSheetEdited={onSheetEdited}
/>
))} ))}
{showHint && <GeneratingHint label={hintLabel} />} {showHint && <GeneratingHint label={hintLabel} />}
</div> </div>
...@@ -481,6 +496,13 @@ export function AssistantChat({ ...@@ -481,6 +496,13 @@ export function AssistantChat({
settleSheet(requestId, assignmentId); settleSheet(requestId, assignmentId);
appendAssistantNote(summary); appendAssistantNote(summary);
}; };
/**
* 助手改完确认单,界面把**实际结果**回一句进消息流。
*
* ⭐ 理由同上:模型看不到卡片。它只知道"指令已交给界面",成没成、有没有人没找到,
* 全靠这一句。不注入的话它下一轮就会自信地说"已经移除了",而那个人可能还在。
*/
const onSheetEdited = (summary: string) => appendAssistantNote(summary);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const taRef = useRef<HTMLTextAreaElement>(null); const taRef = useRef<HTMLTextAreaElement>(null);
...@@ -713,6 +735,7 @@ export function AssistantChat({ ...@@ -713,6 +735,7 @@ export function AssistantChat({
message={m} message={m}
streaming={status === 'streaming' && i === messages.length - 1 && m.role === 'assistant'} streaming={status === 'streaming' && i === messages.length - 1 && m.role === 'assistant'}
onSheetConfirmed={onSheetConfirmed} onSheetConfirmed={onSheetConfirmed}
onSheetEdited={onSheetEdited}
/> />
)) ))
)} )}
......
'use client'; 'use client';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import type { AssignmentProposal } from '@pac/types'; import type { AssignmentProposal, SheetEditOp } from '@pac/types';
import { env } from '@/lib/env'; 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';
...@@ -49,6 +49,12 @@ export type Block = ...@@ -49,6 +49,12 @@ export type Block =
state: 'pending' | 'confirmed' | 'cancelled'; state: 'pending' | 'confirmed' | 'cancelled';
/** 确认后的批次 id(终态展示用) */ /** 确认后的批次 id(终态展示用) */
assignmentId?: string; assignmentId?: string;
/**
* ⭐ 助手下发的**语义编辑指令队列**(edit_assignment_sheet 走侧信道推来)。
* 卡片组件按 `seq` 只执行新增的那一批,执行完把结果回一句进对话。
* ⚠️ 用队列而不是"最新一条":主管连着说两句时,两批指令都要生效。
*/
edits?: { seq: number; ops: SheetEditOp[] }[];
}; };
/** render_artifact 是本地"渲染"工具,不当普通工具步骤展示,而是渲成卡片。 */ /** render_artifact 是本地"渲染"工具,不当普通工具步骤展示,而是渲成卡片。 */
...@@ -203,6 +209,33 @@ export function useAssistantChat() { ...@@ -203,6 +209,33 @@ export function useAssistantChat() {
return [...blocks, next]; return [...blocks, next];
}); });
break; break;
case 'assignment_sheet_edit':
// ⭐ 助手要改**已经呈现**的那张卡:指令挂到最后一张确认单上,由它自己去匹配执行。
//
// 🔴 必须**跨消息**找,⛔ 不能用 patch(那只改当前这条助手消息)——
// 确认单几乎总是在**上一轮**的消息里(主管看着它才提的要求),
// 只在当前消息里找永远找不到,而且不会报错、界面毫无反应。
// ⚠️ 挂"最后一张"而不是按 requestId 找:主管说"把某某移出这批"指的就是眼前这张,
// 而模型手里根本没有 requestId(服务端铸的,没进它上下文)。
setMessages((prev) => {
for (let mi = prev.length - 1; mi >= 0; mi--) {
const bi = prev[mi]!.blocks.map((b) => b.kind).lastIndexOf('assignment_sheet');
if (bi < 0) continue;
const b = prev[mi]!.blocks[bi] as Extract<Block, { kind: 'assignment_sheet' }>;
const next: Block = {
...b,
edits: [
...(b.edits ?? []),
{ seq: (b.edits?.length ?? 0) + 1, ops: (evt.ops ?? []) as SheetEditOp[] },
],
};
return prev.map((m, k) =>
k === mi ? { ...m, blocks: m.blocks.map((x, j) => (j === bi ? next : x)) } : m,
);
}
return prev;
});
break;
case 'artifact_html': case 'artifact_html':
// 流式:render_artifact 的 html 边生成边到(按 callId upsert,实时"长出"卡片)。 // 流式:render_artifact 的 html 边生成边到(按 callId upsert,实时"长出"卡片)。
patch((blocks) => patch((blocks) =>
......
...@@ -136,6 +136,8 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾 ...@@ -136,6 +136,8 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
四件**局部**操作,判据是**要不要重跑服务端算法**:不要,才放到卡片上。 四件**局部**操作,判据是**要不要重跑服务端算法**:不要,才放到卡片上。
⚠️ 下面这张表里"卡片上做"的四件事,**助手也能替主管做**(同一套语义,见下节)。
| 卡片上做 | 回对话做 | | 卡片上做 | 回对话做 |
|---|---| |---|---|
| 改批次时效(右上角「N 天后自动退回」) | 换人群(换治疗项 / 温度 / 画像条件) | | 改批次时效(右上角「N 天后自动退回」) | 换人群(换治疗项 / 温度 / 画像条件) |
...@@ -155,6 +157,28 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾 ...@@ -155,6 +157,28 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
⚠️ 拖过的条目落库时 `assignStrategy` 改成 **`manual`**(主管指定)—— ⚠️ 拖过的条目落库时 `assignStrategy` 改成 **`manual`**(主管指定)——
不改的话事后分析会把它算成算法的选择,而 T20 要反推的正是"算法选得准不准"。 不改的话事后分析会把它算成算法的选择,而 T20 要反推的正是"算法选得准不准"。
### 助手能直接改确认单(`edit_assignment_sheet`,2026-08-03)
走查实录:主管说「把杨丽华移出这个确认单」,助手回「我这边没法把某一位患者单独剔除,
您先点确认、之后在批次详情里把她单条退回池子」—— 把界面能做的事推回给主管手工做,
而那张卡就在他眼前。
现在给了它一个本地工具,能做的**与卡片完全一致**(删患者 / 移除客服 / 改派 / 改时效)。
**指令用姓名,不用 planId** —— 那些 id 从来没进过模型上下文(确认单走侧信道,故意的)。
模型负责"听懂",**界面拿自己手里那份确认单去匹配**。这条不能破:一旦为了让模型"能指名道姓"
而把明细喂给它,就等于把当初走侧信道的理由(几百个 uuid、幻觉、成本)全部退回去。
⚠️ **结果由界面回一句进对话**,⛔ 模型不许自己宣布成功:它看不到卡片,
匹配不到 / 同名 / 客服不在本批都是界面才知道的事。那一句同时进模型上下文,
下一轮它才不会替系统撒谎(T14)。
🔴 **同名歧义一律不猜**
- 两位同名患者 → 不执行,让主管用病历号指定;
- 同一个名字**既是本批客服又是本批患者**(实测「张悦」就是)→ 不执行,让他说清是哪一个。
猜错的代价不对称:按客服理解一下子动 20 条,而主管以为只动了 1 条。
提示词里也定死了默认按**患者**理解。
⚠️ 移除客服 = **他名下的条目一并移出本批**(患者留在池子里,下批再说), ⚠️ 移除客服 = **他名下的条目一并移出本批**(患者留在池子里,下批再说),
必须有二次确认并把这句话写在弹窗里 —— 否则主管以为只是把人从名单上划掉, 必须有二次确认并把这句话写在弹窗里 —— 否则主管以为只是把人从名单上划掉,
回头发现少了 20 个患者。要把这些人交给别人:先拖走,或回对话让助手重出一版。 回头发现少了 20 个患者。要把这些人交给别人:先拖走,或回对话让助手重出一版。
......
...@@ -385,6 +385,47 @@ export const AssignmentProposalSchema = z.object({ ...@@ -385,6 +385,47 @@ export const AssignmentProposalSchema = z.object({
export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>; export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;
// ============================================================= // =============================================================
// 助手直接改确认单 —— 本地工具 edit_assignment_sheet 的载荷
// =============================================================
/**
* 确认单的**语义编辑指令**。
*
* ⭐ 为什么用「姓名」而不是 planId:模型**拿不到也不该拿到** planId
* (确认单的肥载荷走侧信道直接给界面,不进模型上下文 —— 见 assistant.service 的注释)。
* 所以指令用主管说得出口的东西(患者姓名 / 病历号 / 客服姓名)表达,
* **由界面拿自己手里的那份确认单去匹配**。模型负责"听懂",界面负责"找到人"。
*
* ⚠️ 能做的**只有卡片上能做的那几件**(局部修正),⛔ 不含换人群 / 改批次人数 ——
* 那两件要重跑服务端算法,只能重出一版确认单。
* ⚠️ 匹配不到 / 匹配到多个,由界面回一句话进对话(见 sheetEditReport),
* ⛔ 别让模型自己猜"大概是成功了"。
*/
export const SheetEditOpSchema = z.discriminatedUnion('action', [
z.object({
action: z.literal('remove_patient'),
patient: z.string().describe('患者姓名或病历号'),
}),
z.object({
action: z.literal('remove_agent'),
agent: z.string().describe('客服姓名'),
}),
z.object({
action: z.literal('move_patient'),
patient: z.string(),
toAgent: z.string().describe('转给谁(客服姓名);必须已在本批里'),
}),
z.object({
action: z.literal('set_expiry'),
days: z.number().int().positive().max(90),
/// 三选一:给某个患者 / 给某个客服名下全部 / 都不给 = 整批
patient: z.string().optional(),
agent: z.string().optional(),
}),
]);
export type SheetEditOp = z.infer<typeof SheetEditOpSchema>;
// =============================================================
// 撤销整批 —— POST /pac/v1/plans/assignments/:id/revoke // 撤销整批 —— POST /pac/v1/plans/assignments/:id/revoke
// ============================================================= // =============================================================
......
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