Commit 9f8c62f6 by luoqi

feat: 排序话术条件化 + 卡片等说完再出现 + 引导补画像收窄与福利

① **「没被分过的排在前面」改成条件化**。查了口径:池子基线已经排除"还在客服手上"的单,
   分过但已退回/到期回池的**仍在池子里**(T7:退回是正常路径,不能分过一次就永不再召),
   只是排到后面。本地实测池子 1094 人里回池的 **0 人** —— 这时说那句是废话,
   还会让主管以为系统在防一个不存在的情况。
   现在:有回池的人才说「之前分过又退回的 N 人排在后面」,否则只说「优先级高的排在前面」。
   countCandidates 顺带 FILTER 出这个数,不多一次查询。

② **确认单等这段话说完才出现**。上一版只做了"钉到消息末尾",但流式期间卡片已经在下面,
   主管看到的是"卡片先蹦出来、文字再在它上面一行行长" —— 比卡片在前更怪。
   现在流式期间不渲染 sheet,说完再出现。

③ **引导语补两条**(主管不知道能这么用,不说他就只会点确认):
   · 再挑一挑人:「只要商保直付的」「排掉最近来过的」→ 助手先看分布再重新圈;
   · 带个福利:「这批带上『老客户复查免挂号费』」→ 新增 set_benefit,写进 attributes.benefit.text。
   福利链路本来就通(话术 prompt 已接),缺的只是主管怎么填进去。
   ️ 福利在卡片上**只读**(有才显示一个绿标):在 400px 卡里放自由文本框,
   主管十有八九写成一段带承诺的话,而那道护栏在话术生成侧,别污染源头。
   ️ 引导语要求用主管的话说, 不许报工具名。

本地实测:说「这批带上「老客户复查免挂号费」」→ 卡片顶部出现「福利 · 老客户复查免挂号费」
绿标,界面回报那句也进了对话。989 tests green。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 3f7b78c2
......@@ -72,8 +72,11 @@ const DISPATCHER_EXTRA = `
猜错了主管以为只动了 1 条。实测踩过:「张悦」既是本批客服又是本批患者。
⚠️ 调完**先别宣布成功**:界面执行后会回一句结果(可能"没找到这个人"/"有两个同名"),
等那句出现再据实回复。⛔ 自己先说"已经移除了"是替系统撒谎(T14)。
⚠️ 只能做**局部修改**(删/改派/改时效)。换人群、改批次人数要重跑算法 →
⚠️ 只能做**局部修改**(删/改派/改时效/**设福利**)。换人群、改批次人数要重跑算法 →
那走 propose_assignment 重出一版,⛔ 不要用 edit 去凑。
⚠️ **福利**(T4:挂批次不挂个人,是话术勾子 + 归因标签):主管说「这批带上『老客户复查免挂号费』」
→ set_benefit,原话写进去。⛔ 不要替他加条件、期限、承诺 —— 那道护栏在话术生成侧,
但你先别污染源头。撤销就传空串。
8. **确认单就在你这条消息里,别提界面上没有的东西。**
调完 propose_assignment,界面会自动渲染出确认单(含「确认分配 N 条」按钮和时效微调)。
......
......@@ -215,15 +215,18 @@ export class AssistantService {
`1)「怎么选的」:${sheet.selectionNote}`,
`2)「怎么分的」:${sheet.basisNote}`,
``,
`最后一句引导他怎么调:`,
`「要调整直接跟我说 —— 比如『把某某移出这批』『某某的单给 5 天』『这批 200 人』,` +
`我直接改;确认无误就点卡片上的『确认分配』。」`,
`最后一句引导他怎么调 —— **四种都要提到**(⚠️ 主管不知道能这么用,不说他就只会点确认):`,
`· 改这一批:「把某某移出这批」「某某的单给 5 天」「这批 200 人」—— 我直接改;`,
`· **再挑一挑人**:「只要商保直付的」「排掉最近来过的」—— 我先看看这批人里各类有多少,再重新圈;`,
`· **带个福利**:「这批带上『老客户复查免挂号费』」—— 我写进批次,生成话术时会用上;`,
`· 确认无误就点卡片上的『确认分配』。`,
``,
`⚠️ 上面两句是**成品句子,原话抄**:里面的数字、"没被分过的排在前面"、"第一次的估算值"` +
`这类措辞都不要改写、不要省。`,
`⛔ 不要说"已经分配好了"(一条都还没落库)。`,
`⛔ 不要出现「排序键 / 收敛 / 铺平 / 水位 / 基数 / 探索配额」这类词 —— 主管看不懂。`,
`⛔ 不要复述患者名单或客服分配明细 —— 卡片上有,而且你手里根本没有。`,
`⚠️ 引导那几句要用**主管的话**说,别报工具名(⛔ 不要出现 get_cohort_attributes 这种词)。`,
].join('\n');
},
});
......@@ -242,7 +245,7 @@ export class AssistantService {
*/
tools.edit_assignment_sheet = tool({
description:
'直接修改**已经呈现**的那张确认单(删患者 / 移除某个客服 / 把患者转给别人 / 改时效)。' +
'直接修改**已经呈现**的那张确认单(删患者 / 移除某个客服 / 把患者转给别人 / 改时效 / 设批次福利)。' +
'\n⚠️ 主管说「把某某移出这批」「某某的单给 5 天」时**就用这个**,' +
'⛔ 不要回答"做不到"、也不要让他先确认再逐条退回 —— 卡片就在他眼前。' +
'\n⚠️ 只能做这几件局部修改。**换人群 / 改批次人数**要重出确认单,那走 propose_assignment。' +
......@@ -259,12 +262,24 @@ export class AssistantService {
properties: {
action: {
type: 'string',
enum: ['remove_patient', 'remove_agent', 'move_patient', 'set_expiry'],
enum: [
'remove_patient',
'remove_agent',
'move_patient',
'set_expiry',
'set_benefit',
],
},
patient: { type: 'string', description: '患者姓名或病历号' },
agent: { type: 'string', description: '客服姓名' },
toAgent: { type: 'string', description: 'move_patient 的目标客服' },
days: { type: 'number', description: 'set_expiry 的天数(1-90)' },
text: {
type: 'string',
description:
'set_benefit 的福利原文(挂在整批上,会进这批人的话术)。空串 = 撤掉福利。' +
'⛔ 原话写,不要替主管加条件/期限/承诺。',
},
},
required: ['action'],
},
......
......@@ -173,10 +173,11 @@ export class AssignmentProposalService {
// `min(基数, 候选总数)`,而候选总数正是下面这次 count 的结果。
// 按基数取一定够(target ≤ 基数),这样 count 与取明细还能并发,不多一个来回。
const fetchLimit = Math.ceil(batchSize * 1.5) + 20;
const [candidateTotal, pool] = await Promise.all([
const [counts, pool] = await Promise.all([
this.countCandidates(scope, criteria, now),
this.selectCandidates(scope, criteria, fetchLimit, now),
]);
const candidateTotal = counts.total;
/**
* 本批**实际**分多少 = `min(基数 N, 候选总数)`。
*
......@@ -307,8 +308,16 @@ export class AssignmentProposalService {
selectionNote:
(candidateTotal <= target
? `符合条件的一共就 ${candidateTotal} 人,这批**全要了**,没有取舍。`
: `符合条件的一共 ${candidateTotal} 人,这批挑了 ${target} 人:**没被分过的排在前面**,` +
`其次是优先级高的。剩下 ${candidateTotal - target} 人在排队,**随时可以再分一批**。`) +
: `符合条件的一共 ${candidateTotal} 人,这批挑了 ${target} 人:` +
// ⚠️ 「没被分过的排在前面」**只在池子里真有回池的人时才说**(2026-08-03 走查)。
// 池子的基线已经排除了"还在客服手上"的单,所以绝大多数时候池子里 100% 都没分过 ——
// 这时说这句是废话,还会让主管以为系统在防什么根本不存在的情况。
// ⛔ 但也不能永远不说:分过又退回的人**确实还在池子里**(T7:退回是正常路径,
// 不能因为分过一次就永不再召),他们排在后面,主管有权知道。
(counts.repeat > 0
? `**之前分过又退回的 ${counts.repeat} 人排在后面**,其余按优先级高的排前面。`
: `**优先级高的排在前面**。`) +
`剩下 ${candidateTotal - target} 人在排队,**随时可以再分一批**。`) +
(exploreN > 0
? `另外有 ${exploreN} 人是特意从排名靠后的位置抽的 —— 用来日后检验"排在前面的是不是真的更容易成"。`
: '') +
......@@ -409,16 +418,17 @@ export class AssignmentProposalService {
scope: TenantScopeContext,
criteria: CohortCriteria,
now: Date,
): Promise<number> {
const rows = await this.prisma.$queryRaw<Array<{ n: bigint | number }>>(
): Promise<{ total: number; repeat: number }> {
const rows = await this.prisma.$queryRaw<Array<{ n: bigint | number; r: bigint | number }>>(
Prisma.sql`
SELECT count(DISTINCT fp.patient_id) AS n
SELECT count(DISTINCT fp.patient_id) AS n,
count(DISTINCT fp.patient_id) FILTER (WHERE fp.assignment_id IS NOT NULL) AS r
FROM followup_plans fp
JOIN patients p ON p.id = fp.patient_id
WHERE ${cohortWhereSql(scope, criteria, now)}
`,
);
return Number(rows[0]?.n ?? 0);
return { total: Number(rows[0]?.n ?? 0), repeat: Number(rows[0]?.r ?? 0) };
}
/**
......
......@@ -170,6 +170,14 @@ export function AssignmentConfirmSheet({
/// 拖拽悬停中的目标客服(高亮用)
const [dragOver, setDragOver] = useState<string | null>(null);
/**
* 批次福利(T4)—— 挂在**批次**上不挂个人,是**话术勾子 + 归因标签**。
* v1 不核销、不接宿主卡券:落库进 `attributes.benefit.text`,生成话术时作为事实输入进 prompt。
* ⚠️ 卡片上**只读**:文案由主管口述、助手写进来(set_benefit)——
* 在 400px 的卡片里放一个自由文本框,主管十有八九会写成一段带承诺的话,
* 而福利话术有硬护栏(不得追加条件/期限/承诺),那道闸在生成侧,不在这里。
*/
const [benefit, setBenefit] = useState<string>('');
/**
* 🔴 拖拽时的**自动滚动**。
*
* 助手窗里一屏只放得下三四个客服,而 HTML5 DnD **不会**自己滚容器 ——
......@@ -271,6 +279,8 @@ export function AssignmentConfirmSheet({
perItemExpiry: Object.keys(dayByPlan).length,
},
},
// T4:福利挂批次(话术勾子 + 归因标签);空串 = 不带福利,⛔ 别落一个空 benefit 对象
...(benefit.trim() ? { attributes: { benefit: { text: benefit.trim() } } } : {}),
expiresInDays,
// ⚠️ 落库的是**卡片当前值**,不是模型原提案。
// 逐条 expiresInDays 只在**与批次不同**时才带:一样还带等于把批次时效冻进每条,
......@@ -372,6 +382,11 @@ export function AssignmentConfirmSheet({
}
continue;
}
if (op.action === 'set_benefit') {
setBenefit(op.text);
notes.push(op.text.trim() ? `本批福利设为「${op.text}` : '已撤掉本批福利');
continue;
}
if (op.action === 'set_expiry' && !op.patient) {
setExpiresInDays(op.days);
notes.push(`整批时效改为 ${op.days} `);
......@@ -447,6 +462,16 @@ export function AssignmentConfirmSheet({
{sheet.unplaced} 人分不下去
</Badge>
)}
{/* 福利:有才显示。⚠️ 必须看得见 —— 它会进这批人的话术,主管点确认前有权知道说了什么 */}
{benefit.trim() && (
<Badge
variant="secondary"
className="max-w-[40%] truncate bg-emerald-50 text-[10px] font-normal text-emerald-700"
title={benefit}
>
福利 · {benefit}
</Badge>
)}
{/* ⭐ 批次时效放**右上角**:它是"这一整批"的设置,和左边"这一整批多少人"同一层级,
放在底部微调区会让人以为它跟下面的逐条时效是一回事。
⚠️ 文案是「N 天**后自动退回**」而不是光一个「时效」——
......
......@@ -395,17 +395,20 @@ function MessageView({
// 每条都挂一个 28px 的圆头像 + 间距 = 白白吃掉一成宽度,而"这段是助手说的"
// 靠气泡形态(用户消息右对齐深底、助手左对齐无底)已经分得清了。
/**
* ⭐ **确认单钉在这条消息的最后**(2026-08-03 走查:文字在前、卡片在后)。
* ⭐ **确认单:钉在消息末尾,且要等这段话说完才出现**(2026-08-03 走查)。
*
* 为什么要在渲染层排序,而不是"让模型先说话":工具调用**必然发生在文字之前**
* 为什么不能靠"让模型先说话"解决:工具调用**必然发生在文字之前**
* (模型要先拿到结果才知道说什么),而卡片是工具执行时经侧信道推来的 ——
* 到达顺序天然是"卡片 → 文字",改不了。
* ⚠️ 用 stable 排序(只把 assignment_sheet 挪到末尾),⛔ 别整体 sort:
* 文字块之间的先后是流式拼出来的,打乱就成乱码了。
* 到达顺序天然是"卡片 → 文字",改不了。所以两件事都在渲染层做:
* ① 排序:把 assignment_sheet 挪到末尾。⛔ 别整体 sort ——
* 文字块之间的先后是流式拼出来的,打乱就成乱码。
* ② **流式期间先不渲染** —— 否则主管看到的是"卡片先蹦出来,文字再在它上面一行行长",
* 比卡片在前更怪。说完了再出现,顺序才成立。
*/
const sheets = message.blocks.filter((b) => b.kind === 'assignment_sheet');
const ordered = [
...message.blocks.filter((b) => b.kind !== 'assignment_sheet'),
...message.blocks.filter((b) => b.kind === 'assignment_sheet'),
...(streaming ? [] : sheets),
];
return (
<div className="min-w-0 space-y-2.5">
......
......@@ -157,13 +157,30 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
⚠️ 拖过的条目落库时 `assignStrategy` 改成 **`manual`**(主管指定)——
不改的话事后分析会把它算成算法的选择,而 T20 要反推的正是"算法选得准不准"。
### 「已分配」在池子口径里指什么(2026-08-03 确认)
矩阵格子 / 候选池 = **当前没人持有的活跃召回**
| | 在池子里吗 |
|---|---|
| 已分配、**还在客服手上** | ❌ 不在(`assignee_user_id IS NULL` 已排除) |
| 分过但**已退回 / 到期回池** | ✅ **在**,只是排序上排到后面 |
第二行是**有意的**:退回是正常路径(T7),不能因为分过一次就永不再召。
⚠️ 但「没被分过的排在前面」这句话**只在池子里真有回池的人时才说** ——
本地实测池子 1094 人里回池的 0 人,说了等于让主管以为系统在防一个不存在的情况。
条件化之后:有回池的人才说「之前分过又退回的 N 人排在后面」,否则只说「优先级高的排在前面」。
### 助手能直接改确认单(`edit_assignment_sheet`,2026-08-03)
走查实录:主管说「把杨丽华移出这个确认单」,助手回「我这边没法把某一位患者单独剔除,
您先点确认、之后在批次详情里把她单条退回池子」—— 把界面能做的事推回给主管手工做,
而那张卡就在他眼前。
现在给了它一个本地工具,能做的**与卡片完全一致**(删患者 / 移除客服 / 改派 / 改时效)。
现在给了它一个本地工具,能做的**与卡片完全一致**,外加一件卡片上没有的:**设批次福利**(T4,落 `attributes.benefit.text`
生成话术时作为事实输入进 prompt —— 那条链路本来就通,缺的只是"主管怎么把福利填进去")。
⚠️ 福利在卡片上**只读**:在 400px 的卡里放自由文本框,主管十有八九会写成一段带承诺的话,
而"不得追加条件/期限/承诺"那道闸在话术生成侧,不在这里。让他口述、助手原话写进去。
**指令用姓名,不用 planId** —— 那些 id 从来没进过模型上下文(确认单走侧信道,故意的)。
模型负责"听懂",**界面拿自己手里那份确认单去匹配**。这条不能破:一旦为了让模型"能指名道姓"
......
......@@ -416,6 +416,16 @@ export const SheetEditOpSchema = z.discriminatedUnion('action', [
toAgent: z.string().describe('转给谁(客服姓名);必须已在本批里'),
}),
z.object({
/**
* 批次福利 —— T4:福利挂在**批次**上,不挂个人;它是**话术勾子 + 归因标签**,
* v1 不核销、不接宿主卡券。落库进 `attributes.benefit.text`,
* 生成话术时作为事实输入进 prompt(已打通,见 plan-script.orchestrator)。
* ⚠️ 空串 = 撤掉福利。
*/
action: z.literal('set_benefit'),
text: z.string().max(200),
}),
z.object({
action: z.literal('set_expiry'),
days: z.number().int().positive().max(90),
/// 三选一:给某个患者 / 给某个客服名下全部 / 都不给 = 整批
......
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