Commit e6e079c2 by luoqi

feat(分配): P1 引导节点(判定层)+ 基数改口径 —— 在岗 × 每天 15 通 × 时效

引导节点(Signal):
- 新增 assignment-signals.ts —— **纯函数**,输入一份已组装的提案、输出整套节点。
  ️ 不查库不看时钟:节点用的每个数都来自这一份提案, 不许再查一次 ——
  否则卡片和节点两个数字都"对"但对不上,那是最难查的一类 bug(T6a 同源)。
- 五个节点:pending / daily_overload / short_supply / expiry_default /
  anchor_nondefault。前三个 tier=action(全部展开),后两个 tier=info(可折叠)。
  🔴 **不截断** —— 防噪音靠分层不靠丢弃:藏起一条主管就不知道有东西卡着。
- 每个节点的默认都是 no-op,「一条不点直接确认」永远安全(有测试锁)。
- ASSIGNMENT_INTENTS:**按钮与模型工具共用的同一组动作 id**。这是「按钮能做的、
  说话也能做」的结构保证, 不是提示词里的一句叮嘱。

基数改口径(产品定 2026-08-12):
- N = 在岗人数 × DAILY_CALLS_PER_AGENT(15) × 时效 D, **不再沿用上一次**。
  原设计"调一次省一次"会让 N 悄悄漂:某次为小格子调成 44,此后永远是 44,
  而主管看不出为什么变小了。
- N 与时效挂钩:D 从 3 改到 5,能承接的量本来就该跟着变。
- ️ 15 仍然**不是容量上限**、 不参与落人 —— 落人只有水位法。
  它只回答"一批推多大",以及当 daily_overload 的判定尺。
- 沿用机制本身留着(resolveBaseline 还在读),要开回来只需把 baseline.batchSize
  接回去。
- basisNote 改成把算式写出来 —— 主管看到 405 的第一反应是"怎么这么多"。

测试:改 11 条旧口径断言,**不变式留住**(如「第二批不为 0」改成断言 >0 而不是
断言具体数字);新增 11 条节点判定。共 1223 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 2ef1a8af
...@@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common'; ...@@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { import {
ASSIGNMENT_EXPIRES_DAYS_DEFAULT, ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
BATCH_SIZE_PER_AGENT_FIRST,
DAILY_CALLS_PER_AGENT, DAILY_CALLS_PER_AGENT,
AssignStrategy, AssignStrategy,
type AgentInfo, type AgentInfo,
...@@ -18,6 +17,8 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de ...@@ -18,6 +17,8 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de
import { resolveClinicId } from '../../common/decorators/resolve-clinic-id'; import { resolveClinicId } from '../../common/decorators/resolve-clinic-id';
import { AgentRosterService } from './agent-roster.service'; import { AgentRosterService } from './agent-roster.service';
import { assertCohortCriteria, cohortWhereSql, type CohortCriteria } from './cohort-filter'; import { assertCohortCriteria, cohortWhereSql, type CohortCriteria } from './cohort-filter';
// ⭐ 引导节点的确定性判定 —— 纯函数,输入一份提案、输出整套节点(见该文件顶部注释)
import { computeSignals } from './assignment-signals';
/** /**
* AssignmentProposalService —— 「1,189 人的格子 → 一批 100 人 → 落到 N 个客服头上」。 * AssignmentProposalService —— 「1,189 人的格子 → 一批 100 人 → 落到 N 个客服头上」。
...@@ -160,25 +161,33 @@ export class AssignmentProposalService { ...@@ -160,25 +161,33 @@ export class AssignmentProposalService {
// 主管想连着圈两批人分,只能把容量往上棚,而容量会被记住 → 下周的"习惯"是个虚高的数。 // 主管想连着圈两批人分,只能把容量往上棚,而容量会被记住 → 下周的"习惯"是个虚高的数。
// 现在:N 决定推多少,水位法决定给谁,负载由 `inHand` 本身表达,不设阈值。 // 现在:N 决定推多少,水位法决定给谁,负载由 `inHand` 本身表达,不设阈值。
/** /**
* 基数 N —— **要被记住的那个数**。 * 时效 D —— 本次指定 > 默认。⚠️ 必须**先算它**,因为 N 要用到(见下)。
* 本次主管指定 > 沿用上一次 > 首次估法 `在岗人数 × 20`。 * 🔴 2026-08-12 起**不再沿用上一次**(产品定)。
* ⚠️ 首次那个 20 **不是容量上限**,只是"没有任何历史时一批推多大"的估法,
* 之后就再也不出现了(沿用上次的值)。
*/ */
const batchSize =
input.targetCount != null
? Math.max(0, Math.floor(input.targetCount))
: (baseline.batchSize ?? agents.length * BATCH_SIZE_PER_AGENT_FIRST);
const expiresInDays = const expiresInDays =
input.expiresInDays != null && input.expiresInDays > 0 input.expiresInDays != null && input.expiresInDays > 0
? Math.floor(input.expiresInDays) ? Math.floor(input.expiresInDays)
: baseline.expiresInDays; : ASSIGNMENT_EXPIRES_DAYS_DEFAULT;
/**
* 基数 N —— 本次主管指定 > 默认 `在岗人数 × 每人每日 15 × D`。
*
* 🔴 2026-08-12 改判(产品定):
* ① **不再沿用上一次**。原设计是"调一次省一次",但它让 N 悄悄漂 ——
* 某次为了一个小格子调成 44,此后所有批次就永远是 44,主管看不出为什么变小了。
* ② **N 与时效挂钩**:一批推多少,取决于这批要在几天内打完。
* D 从 3 天改成 5 天,能承接的量本来就该跟着变。
* ⚠️ 这里的 15(`DAILY_CALLS_PER_AGENT`)**仍然不是容量上限**,⛔ 不参与落人 ——
* 落人只有水位法,没有阈值("容量"被删过一次,理由见 BATCH_SIZE_PER_AGENT_FIRST)。
* 它只回答"一批推多大",以及作为 `daily_overload` 引导节点的判定尺。
* ⚠️ 沿用机制本身留着(`resolveBaseline` 仍在读),只是不再喂给 N ——
* 产品说的是"先不用取上一次",要开回来只需把 `baseline.batchSize` 接回这里。
*/
const batchSize =
input.targetCount != null
? Math.max(0, Math.floor(input.targetCount))
: agents.length * DAILY_CALLS_PER_AGENT * expiresInDays;
const basis: 'inherited' | 'default' | 'explicit' = const basis: 'inherited' | 'default' | 'explicit' =
input.targetCount != null || input.expiresInDays != null input.targetCount != null || input.expiresInDays != null ? 'explicit' : 'default';
? 'explicit'
: baseline.from
? 'inherited'
: 'default';
// 按客服精调:本次传的压过沿用的(同一个人两处都有 → 本次赢) // 按客服精调:本次传的压过沿用的(同一个人两处都有 → 本次赢)
const agentOverrides = sanitizeOverrides({ ...baseline.agentOverrides, ...input.agentOverrides }); const agentOverrides = sanitizeOverrides({ ...baseline.agentOverrides, ...input.agentOverrides });
/// 本批给某人的**名额上限**(没精调 = 不限)。⛔ 只此一处合并,别在 placeAgents / byAgent 各合一遍 /// 本批给某人的**名额上限**(没精调 = 不限)。⛔ 只此一处合并,别在 placeAgents / byAgent 各合一遍
...@@ -429,7 +438,14 @@ export class AssignmentProposalService { ...@@ -429,7 +438,14 @@ export class AssignmentProposalService {
`拿到的全是自己的老客户,这部分不用您费神。` `拿到的全是自己的老客户,这部分不用您费神。`
: ''); : '');
return { /**
* ⭐ 先组装事实,再由 `computeSignals` 从这份事实里算引导节点。
*
* ⚠️ 顺序不能反、也不能各算各的:节点用的每个数(待分配几人、谁分完最重、
* 候选够不够)都必须**来自这一份提案**,⛔ 不许再查一次 —— 否则卡片上的数
* 和节点里的数会对不上,而两个数字都"对"是最难查的那种 bug(T6a 同源)。
*/
const proposal: Omit<AssignmentProposal, 'signals'> = {
clinicId, clinicId,
potentialTreatment: potentialTreatment ?? null, potentialTreatment: potentialTreatment ?? null,
// ⭐ 必须下发 —— 前端确认时要把它写进 criteria 快照,否则批次说不清 // ⭐ 必须下发 —— 前端确认时要把它写进 criteria 快照,否则批次说不清
...@@ -584,6 +600,7 @@ export class AssignmentProposalService { ...@@ -584,6 +600,7 @@ export class AssignmentProposalService {
`${minAfter}~${suggestWaterline} 条),得您定 —— 拖给谁或者移出本批。` `${minAfter}~${suggestWaterline} 条),得您定 —— 拖给谁或者移出本批。`
: `,没有需要您定的了。`), : `,没有需要您定的了。`),
}; };
return { ...proposal, signals: computeSignals(proposal as AssignmentProposal) };
} }
/** /**
...@@ -1301,13 +1318,20 @@ function basisNote(x: { ...@@ -1301,13 +1318,20 @@ function basisNote(x: {
: ''; : '';
// ⚠️ 候选不够被压低时要说清"您设的数没变" —— 否则他以为系统偷偷把设置改小了 // ⚠️ 候选不够被压低时要说清"您设的数没变" —— 否则他以为系统偷偷把设置改小了
const capped = x.target < x.batchSize ? `(符合条件的只有这么多,您设的 ${x.batchSize} 没变)` : ''; const capped = x.target < x.batchSize ? `(符合条件的只有这么多,您设的 ${x.batchSize} 没变)` : '';
/**
* 🔴 2026-08-12 起**不再沿用上一次**(产品定)—— `inherited` 这一支保留只为老快照,
* 新提案不会再走到(见 propose 里 batchSize 的注释)。
* ⚠️ 默认值那句必须把**算法本身**说出来(在岗 × 每天 15 通 × 时效)——
* 主管看到「405 人」的第一反应是"怎么这么多",不给算式他只能猜(T14)。
*/
const from = const from =
x.basis === 'inherited' && x.basisFrom x.basis === 'inherited' && x.basisFrom
? `人数和时效**沿用您 ${ymd(x.basisFrom)} 那次**${capped},要改直接说(比如「这批 200 人」「给 5 天」),下次自动记住。` ? `人数和时效**沿用您 ${ymd(x.basisFrom)} 那次**${capped},要改直接说(比如「这批 200 人」「给 5 天」)。`
: x.basis === 'explicit' : x.basis === 'explicit'
? `人数和时效是**您这次定的**${capped},确认后下次自动沿用。` ? `人数和时效是**您这次定的**${capped}。`
: `人数和时效是**第一次的估算值**${capped} —— 按每位客服 ${BATCH_SIZE_PER_AGENT_FIRST} 条估的,` + : `人数和时效都是**系统默认**${capped} —— 按在岗 ${x.agents} 人 × 每天 ` +
`没有历史数据可参考;觉得不合适直接说个数,确认后下次就按您的来。`; `${DAILY_CALLS_PER_AGENT} 通 × ${x.expiresInDays} 天估的,不是根据历史算的;` +
`觉得不合适直接说个数。`;
return `${how}${tuned}${from}`; return `${how}${tuned}${from}`;
} }
...@@ -1327,6 +1351,10 @@ function emptyProposal( ...@@ -1327,6 +1351,10 @@ function emptyProposal(
}, },
): AssignmentProposal { ): AssignmentProposal {
return { return {
// ⚠️ 空提案不出引导节点:一个人都没有的时候,「待分配」「打不完」这些都无从谈起。
// `total == 0` 是**终止分支**不是节点(见 assignment-agent-flow.md §五)——
// 主管唯一能做的是换人群,而那句话由 selectionNote 说。
signals: [],
clinicId, clinicId,
potentialTreatment: potentialTreatment ?? null, potentialTreatment: potentialTreatment ?? null,
temperature: temperature ?? null, temperature: temperature ?? null,
......
import {
AnchorMode,
DAILY_CALLS_PER_AGENT,
type AssignmentProposal,
type Signal,
} from '@pac/types';
/**
* assignment-signals —— 引导节点的**确定性判定**。
*
* 规格见 docs/design/assignment-agent-flow.md §五。五条设计规则,逐条对应到代码:
* ① **只陈述事实,不给建议** —— `why` 写「他们的专属客服已排满」,
* ⛔ 不写「建议你铺平」。选项中性并列,给建议就是替主管做决定。
* ② **默认永远是 no-op** —— 每个节点的 `defaultLabel` 描述的都是"不动会怎样",
* 这样「一条不点、直接确认」在任何情况下都安全。
* ③ **有后果的全亮,⛔ 不截断** —— 防噪音靠 tier 分层(action 展开 / info 折叠),
* 不靠丢弃。藏起一条主管就不知道有东西卡着。
* ④ **每次重算,不做增量** —— 本模块是纯函数,输入一份提案、输出整套节点。
* ⑤ **不是关卡** —— 只产出数据,⛔ 不阻塞确认。
*
* ⛔ **判定归程序,不归模型。** 模型只拿 title / why / defaultLabel 去组织语言,
* ⛔ 拿不到也不需要 options 的技术细节。
*/
/**
* 动作契约 —— **按钮与模型工具共用的同一组 id**。
*
* ⭐ 这是「按钮能做的、说话也能做」的结构保证:卡片点击直接带 intent 调后端,
* 主管说话则由模型翻译成同一个 intent。⛔ 两边各写一套必然漂,而漂了不报错。
*/
export const ASSIGNMENT_INTENTS = {
/// 待分配的各自回自己的专属客服(⛔ 一条专属关系都不动)
PENDING_TO_OWNER: 'pending.to_owner',
/// 待分配的铺平给在岗(谁手上少先给谁)
PENDING_SPREAD: 'pending.spread',
/// 用池子里等量的无主患者把这批填满(⛔ 不动任何专属关系)
PENDING_REFILL: 'pending.refill',
/// 待分配的整组移出本批
PENDING_REMOVE: 'pending.remove',
/// 改批次时效
EXPIRY_SET: 'expiry.set',
/// 改本批人数
BATCH_SIZE_SET: 'batch_size.set',
/// 换时间档 / 换治疗项(重出一版)
COHORT_WIDEN: 'cohort.widen',
/// 换档位口径(重出一版)
ANCHOR_SWITCH: 'anchor.switch',
} as const;
/** 严重度 —— 排序规则必须确定性,⛔ 不许由模型判断。 */
const SEV = {
/// 有人会被静默漏掉
SILENT_DROP: 1,
/// 会造成错误预期
WRONG_EXPECTATION: 2,
/// 机会性的
OPPORTUNITY: 3,
/// 纯信息
INFO: 4,
} as const;
/**
* 从一份**已经组装好**的提案里算出全套引导节点。
*
* ⚠️ 纯函数:不查库、不看时钟。所有输入都在 proposal 里 ——
* 这样它可以被直接测,也不会因为"算节点时又查了一次"和提案本身对不上(T6a 同源)。
*/
export function computeSignals(p: AssignmentProposal): Signal[] {
const out: Signal[] = [];
const d = Math.max(1, p.expiresInDays);
// ── ① 待分配 —— 唯一一个「不处理就真的有人被漏掉」的节点 ──────────────
if (p.pending.length > 0) {
out.push({
key: 'pending',
severity: SEV.SILENT_DROP,
tier: 'action',
title: `${p.pending.length} 人的专属客服这轮已排满`,
// ⚠️ 事实。⛔ 不写「建议…」——把患者从专属客服手里挪走是关系层面的决定,只有主管拍板。
why: '他们有专属客服,但那位客服本轮已经排满了,所以我没有分配他们。',
defaultLabel: '不处理 = 这批不发给他们',
options: [
{ label: '各自归专属客服', intent: ASSIGNMENT_INTENTS.PENDING_TO_OWNER },
{ label: '铺平给在岗', intent: ASSIGNMENT_INTENTS.PENDING_SPREAD },
...(p.canRefill
? [{ label: '换无主患者补上', intent: ASSIGNMENT_INTENTS.PENDING_REFILL }]
: []),
{ label: '移出本批', intent: ASSIGNMENT_INTENTS.PENDING_REMOVE },
],
});
}
// ── ② 每日工作量 —— 时效内打不完 ────────────────────────────────────
//
// ⚠️ 判据是**分完之后手上最多的那位**能不能在时效内打完(按每天 15 通算),
// ⛔ 不是"这批给了他多少":他手上原本还有别的批次,那些也要打。
// ⚠️ 15(DAILY_CALLS_PER_AGENT)在这里只是**换算尺**,⛔ 不是上限、⛔ 不参与落人。
const heaviest = p.byAgent.reduce<{ name: string | null; loadAfter: number } | null>(
(max, a) => (max === null || a.loadAfter > max.loadAfter ? a : max),
null,
);
if (heaviest && heaviest.loadAfter > DAILY_CALLS_PER_AGENT * d) {
const needDays = Math.ceil(heaviest.loadAfter / DAILY_CALLS_PER_AGENT);
out.push({
key: 'daily_overload',
severity: SEV.WRONG_EXPECTATION,
tier: 'action',
title: `${heaviest.name ?? '有人'}分完后手上 ${heaviest.loadAfter} 条,${d} 天内打不完`,
// ⚠️ 把"按每天 15 通算"这个前提写出来 —— 它是经验值不是实测,主管得看得见前提。
why: `按每天 ${DAILY_CALLS_PER_AGENT} 通算需要 ${needDays} 天,而本批时效是 ${d} 天。`,
defaultLabel: '不处理 = 按现在这样发,到期没打完的会落回池子',
options: [
{ label: `时效延到 ${needDays} `, intent: ASSIGNMENT_INTENTS.EXPIRY_SET, args: { days: needDays } },
{ label: '减少本批人数', intent: ASSIGNMENT_INTENTS.BATCH_SIZE_SET },
],
});
}
// ── ③ 候选不足 ────────────────────────────────────────────────────
//
// ⚠️ 主管设的 N 没变(batchSize),只是这一格没那么多人。
// ⛔ 别把 target 回写成基数 —— 否则点一次小格子,以后所有批次就永远那么小。
if (p.candidateTotal < p.batchSize) {
out.push({
key: 'short_supply',
severity: SEV.WRONG_EXPECTATION,
tier: 'action',
title: `这一格只有 ${p.candidateTotal} 人,不够 ${p.batchSize} `,
why: `符合条件的就这么多,本批实际发 ${p.target} 人。`,
defaultLabel: '不处理 = 有多少发多少',
options: [
{ label: '往后放一档', intent: ASSIGNMENT_INTENTS.COHORT_WIDEN },
{ label: '换个治疗项', intent: ASSIGNMENT_INTENTS.COHORT_WIDEN },
],
});
}
// ── ④ 时效用的是默认值 ───────────────────────────────────────────
//
// ⚠️ tier=info:不管也没事,所以默认折叠。但仍然要说 —— T14:
// 主管看到「3 天」时,"这 3 是哪来的"和这个数本身一样重要。
if (p.basis === 'default') {
out.push({
key: 'expiry_default',
severity: SEV.INFO,
tier: 'info',
title: `时效 ${d} · 本批 ${p.batchSize} 人,都是系统默认`,
why: `按在岗 ${p.byAgent.length} × 每天 ${DAILY_CALLS_PER_AGENT} × ${d} 天估的,不是根据历史算的。`,
defaultLabel: '不处理 = 就用这两个数',
options: [{ label: '改天数', intent: ASSIGNMENT_INTENTS.EXPIRY_SET }],
});
}
// ── ⑤ 档位口径不是默认 ───────────────────────────────────────────
//
// 🔴 同一个「三个月内」,两版口径圈出来是**两批完全不同的人**。
// 主管切过口径之后隔一会儿再回来,很容易忘了自己切过。
if (p.anchorMode === AnchorMode.LAST_VISIT) {
out.push({
key: 'anchor_nondefault',
severity: SEV.INFO,
tier: 'info',
title: '这批是按「末诊距今」圈的',
why: '默认口径是按医生最后一次提到这个治疗算,两种口径圈出来是两批不同的人。',
defaultLabel: '不处理 = 保持按末诊',
options: [{ label: '换回按诊断', intent: ASSIGNMENT_INTENTS.ANCHOR_SWITCH }],
});
}
// ⚠️ 确定性排序:severity 升序,同级按加入顺序(上面的书写顺序即优先级)。
// ⛔ 不截断 —— 分层交给前端(action 展开 / info 折叠)。
return out.sort((a, b) => a.severity - b.severity);
}
import { AnchorMode, type AssignmentProposal } from '@pac/types';
import { ASSIGNMENT_INTENTS, computeSignals } from '../src/modules/plan/assignment-signals';
/**
* 引导节点的判定 —— 规格见 docs/design/assignment-agent-flow.md §五。
*
* 这里锁的是**判定本身**(什么情况该出、severity 怎么排、默认是不是 no-op),
* ⛔ 不锁措辞 —— 措辞会随产品走查改,锁住它只会让每次改文案都红一片。
*/
/** 最小提案骨架:只填判定要用到的字段,其余给零值。 */
function proposal(over: Partial<AssignmentProposal> = {}): AssignmentProposal {
return {
clinicId: 'c1',
potentialTreatment: 'implant',
temperature: 'hot',
anchorMode: AnchorMode.DIAGNOSIS,
candidateTotal: 1000,
target: 405,
batchSize: 405,
expiresInDays: 3,
basis: 'explicit',
basisFrom: null,
agentOverrides: {},
placed: 405,
unplaced: 0,
items: [],
pending: [],
canRefill: false,
poolOwnership: null,
byAgent: [],
skippedAgents: [],
rosterNote: '',
basisNote: '',
selectionNote: '',
pendingNote: '',
refillNote: '',
opsNote: '',
signals: [],
...over,
} as AssignmentProposal;
}
const agent = (name: string, loadAfter: number) =>
({
userId: name,
name,
inHandBefore: 0,
count: loadAfter,
dedicated: 0,
spread: loadAfter,
loadAfter,
expiresInDays: 3,
overridden: false,
}) as AssignmentProposal['byAgent'][number];
const keys = (p: AssignmentProposal) => computeSignals(p).map((s) => s.key);
describe('引导节点 · 判定', () => {
test('⭐ 一切正常 → 一个节点都不出', () => {
expect(keys(proposal())).toEqual([]);
});
test('🔴 有待分配 → 出 pending,且 severity 最高(有人会被静默漏掉)', () => {
const p = proposal({
pending: [{}] as AssignmentProposal['pending'],
byAgent: [agent('张悦', 10)],
basis: 'default',
});
const s = computeSignals(p);
expect(s[0]!.key).toBe('pending');
expect(s[0]!.tier).toBe('action');
// ⚠️ 排在纯信息类之前
expect(s.map((x) => x.severity)).toEqual([...s.map((x) => x.severity)].sort((a, b) => a - b));
});
test('⭐ canRefill=false 时,「换无主患者补上」这个选项不出现(⛔ 不给做不到的选项)', () => {
const no = computeSignals(proposal({ pending: [{}] as AssignmentProposal['pending'] }))[0]!;
expect(no.options.map((o) => o.intent)).not.toContain(ASSIGNMENT_INTENTS.PENDING_REFILL);
const yes = computeSignals(
proposal({ pending: [{}] as AssignmentProposal['pending'], canRefill: true }),
)[0]!;
expect(yes.options.map((o) => o.intent)).toContain(ASSIGNMENT_INTENTS.PENDING_REFILL);
});
test('🔴 分完之后最重的那位在时效内打不完 → 出 daily_overload,并给出要几天', () => {
// 每天 15 通 × 3 天 = 45 条是刚好打得完的量;60 条要 4 天
const p = proposal({ expiresInDays: 3, byAgent: [agent('李莉', 30), agent('张悦', 60)] });
const s = computeSignals(p).find((x) => x.key === 'daily_overload')!;
expect(s).toBeDefined();
expect(s.title).toContain('张悦');
expect(s.why).toContain('4 天');
expect(s.options.some((o) => o.args?.days === 4)).toBe(true);
});
test('⭐ 刚好打得完(45 条 / 3 天)→ ⛔ 不出 daily_overload', () => {
expect(keys(proposal({ expiresInDays: 3, byAgent: [agent('张悦', 45)] }))).not.toContain(
'daily_overload',
);
});
test('⭐ 候选不够 → 出 short_supply', () => {
expect(keys(proposal({ candidateTotal: 44, target: 44, batchSize: 405 }))).toContain(
'short_supply',
);
});
test('⭐ 用的是系统默认基数 → 出 expiry_default,且 tier=info(可折叠)', () => {
const s = computeSignals(proposal({ basis: 'default', byAgent: [agent('a', 1)] })).find(
(x) => x.key === 'expiry_default',
)!;
expect(s.tier).toBe('info');
});
test('⭐ 按末诊口径 → 出 anchor_nondefault;按诊断(默认)→ 不出', () => {
expect(keys(proposal({ anchorMode: AnchorMode.LAST_VISIT }))).toContain('anchor_nondefault');
expect(keys(proposal({ anchorMode: AnchorMode.DIAGNOSIS }))).not.toContain('anchor_nondefault');
});
test('🔴 每个节点的默认都是 no-op —— 「一条不点直接确认」必须永远安全', () => {
const p = proposal({
pending: [{}] as AssignmentProposal['pending'],
candidateTotal: 44,
target: 44,
basis: 'default',
anchorMode: AnchorMode.LAST_VISIT,
expiresInDays: 3,
byAgent: [agent('张悦', 90)],
});
const s = computeSignals(p);
expect(s.length).toBeGreaterThanOrEqual(5);
// ⚠️ 判据:每个 defaultLabel 都在描述"不处理会怎样",⛔ 没有一个是要主管动手的
for (const x of s) expect(x.defaultLabel).toMatch(/^不处理 = |^不/);
});
test('🔴 ⛔ 不截断 —— 五个条件同时成立就出五个,防噪音靠 tier 分层', () => {
const p = proposal({
pending: [{}] as AssignmentProposal['pending'],
candidateTotal: 44,
target: 44,
basis: 'default',
anchorMode: AnchorMode.LAST_VISIT,
byAgent: [agent('张悦', 90)],
});
const s = computeSignals(p);
expect(s.length).toBe(5);
expect(s.filter((x) => x.tier === 'action').map((x) => x.key)).toEqual([
'pending',
'daily_overload',
'short_supply',
]);
expect(s.filter((x) => x.tier === 'info').map((x) => x.key)).toEqual([
'expiry_default',
'anchor_nondefault',
]);
});
test('⛔ 模型拿不到 options 的技术细节 —— why 里不许出现 intent id', () => {
const s = computeSignals(
proposal({ pending: [{}] as AssignmentProposal['pending'], canRefill: true }),
);
for (const x of s) {
expect(x.why).not.toMatch(/pending\.|expiry\.|cohort\./);
expect(x.title).not.toMatch(/pending\.|expiry\.|cohort\./);
}
});
});
...@@ -578,12 +578,65 @@ export const ProposalAgentRowSchema = z.object({ ...@@ -578,12 +578,65 @@ export const ProposalAgentRowSchema = z.object({
}); });
export type ProposalAgentRow = z.infer<typeof ProposalAgentRowSchema>; export type ProposalAgentRow = z.infer<typeof ProposalAgentRowSchema>;
// ═══════════════════════════════════════════════════════════════
// 引导节点(Signal)—— 见 docs/design/assignment-agent-flow.md §五
// ═══════════════════════════════════════════════════════════════
/**
* 分层。防噪音靠**分层**不靠丢弃 —— ⛔ 有后果的一条都不许截断,
* 藏起一条主管就不知道有东西卡着(那正是「待分配」当初要解决的问题)。
*/
export const SignalTierSchema = z.enum([
/// 不管就会有后果 → 全部展开,按 severity 排序
'action',
/// 纯信息,不管也没事 → 默认折叠
'info',
]);
export type SignalTier = z.infer<typeof SignalTierSchema>;
/**
* 一个可点击执行的选项。
*
* ⭐ `intent` 是**按钮与模型工具共用的同一个动作契约** —— 卡片点击直接调后端,
* 主管说话则由模型翻译成同一个 intent。这样「按钮能做的、说话也能做」是**结构保证**,
* ⛔ 不是提示词里的一句叮嘱。
*/
export const SignalOptionSchema = z.object({
label: z.string().describe('按钮文案,给主管看'),
intent: z.string().describe('动作契约 id'),
args: z.record(z.string(), z.unknown()).optional(),
});
export type SignalOption = z.infer<typeof SignalOptionSchema>;
/**
* 引导节点 —— **由程序确定性判定**,⛔ 不由模型判断哪条该提。
*
* ⚠️ `why` 只陈述事实(「他们的专属客服已排满」),⛔ 不给建议(「建议你铺平」)——
* 给建议就是替主管做决定。选项中性并列。
* ⚠️ `defaultLabel` 描述的是**不动会怎样**,而默认永远是 no-op ——
* 这样「一条不点、直接确认」在任何情况下都是安全的。
* ⚠️ 模型只拿 title / why / defaultLabel 用来组织语言,⛔ 不给它 options 的技术细节。
*/
export const SignalSchema = z.object({
key: z.string(),
severity: z.number().int().describe('越小越靠前;排序规则必须确定性'),
tier: SignalTierSchema,
title: z.string().describe('一句话说清是什么'),
why: z.string().describe('一句事实,⛔ 不是建议'),
defaultLabel: z.string().describe('不动会怎样(no-op)'),
options: z.array(SignalOptionSchema),
});
export type Signal = z.infer<typeof SignalSchema>;
/** /**
* 全景确认单的数据体。 * 全景确认单的数据体。
* *
* ⚠️ 三个 `*Note` 字段是**给助手直接照抄的成品句子**,不是给人读的说明文字。 * ⚠️ 三个 `*Note` 字段是**给助手直接照抄的成品句子**,不是给人读的说明文字。
* T14/T20 那两类要求(标注默认值、样本不足不出百分比)全是除法和阈值判断 —— * T14/T20 那两类要求(标注默认值、样本不足不出百分比)全是除法和阈值判断 ——
* 恰恰是 LLM 最不可靠的地方。把话写好交给它抄,任务就从"让模型算对"降级成"让模型照抄"。 * 恰恰是 LLM 最不可靠的地方。把话写好交给它抄,任务就从"让模型算对"降级成"让模型照抄"。
* 🔴 P2 会把这几个字段换成**结构化事实** —— 成品句子有固定形状,模型学会形状就能
* 凭空背出来(实测背出过「已撤销批次:收回 9 条」),而且「照抄」浪费了模型唯一
* 不可替代的能力。见 docs/design/assignment-agent-dev-plan.md P2。
*/ */
export const AssignmentProposalSchema = z.object({ export const AssignmentProposalSchema = z.object({
clinicId: z.string(), clinicId: z.string(),
...@@ -702,6 +755,15 @@ export const AssignmentProposalSchema = z.object({ ...@@ -702,6 +755,15 @@ export const AssignmentProposalSchema = z.object({
* 和这段问的"这活多大"不是一回事,混在一起两边都读不清。 * 和这段问的"这活多大"不是一回事,混在一起两边都读不清。
*/ */
opsNote: z.string(), opsNote: z.string(),
/**
* 🔴 **引导节点** —— 主管这一版还有什么要定的(见 SignalSchema)。
*
* ⚠️ 与三段 `*Note` 的分工:Note 说的是**已经确定的事实**(怎么选的 / 怎么派的),
* signals 说的是**还没确定的**。⛔ 别把待分配同时写进 note 和 signal ——
* 同一件事说两遍,主管会以为是两件事。
* ⚠️ 老确认单没有这个字段 → 消费方按空数组兜底。
*/
signals: z.array(SignalSchema).default([]),
}); });
export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>; export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;
......
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