Commit 8b19ff33 by luoqi

feat(分配): 助手提示词分层 + 确认单状态给模型 + 一批实测修

提示词按「什么会让它变」分五层(装置/诚实/语气/角色/现场),现场段按
选人 → 分人 → 他确认 → 确认之后 四步分节 —— 每条规矩站在它生效的那一步里,
位置本身就是适用范围。次要业务线(分配追踪)走 pull:系统提示词只留索引,
模型用 open_playbook 按需取正文。

实测抓到并修掉的:
- 「在岗 N 人 × 每天几通 × 时效」印的是 byAgent.length(本批分到人的客服数),
  实测印出「在岗 9 人」而主管同屏写着 33 位在岗,9×15×1 连它自己那行的
  「本批人数 214」都算不出来 ⇒ 新增 rosterCount,由算 batchSize 的同一行赋值。
- 「换无专属客服的患者补上」不回传 narrowedBy,主管收窄过之后点它会悄悄
  退回整格取人(实测 2178 vs 296,差 1882 人)⇒ 链路补齐 + 按钮自己报范围。
- 确认单的草稿态服务端查不到(改派/移出/时效/福利只在浏览器里),模型只能把
  对话里几行回声自己累加,而手动拖动/点×/改时效这几条路连回声都没有
  ⇒ SheetSnapshot 随请求捎来 + get_current_sheet(拉) + 回声带当前值(推)。
- 「没分到的客服」只给了个数字,主管问「那 1 位为什么没轮到」答不上来
  ⇒ 补他们的在手量(实测 193 对 49,对比自己说清)。
- 模型讲区间写 8~9,GFM 单波浪号当删除线吃掉 → 屏幕上是「89 条」
  ⇒ 渲染层 singleTilde: false(与 range() 拆两个键是同一条纪律的两半)。
- daily_overload 里两颗不带数的按钮(改每天几通/减少本批人数)连同两个 intent
  删掉:点下去替主管说的话也不带数,那个数只能由模型自己拍。
- 福利改判:确认之前问一句(客服一确认就开始打这批单),确认单里不再摆引导节点。

模型对齐:qwen 统一 3.8-max;计划摘要跟另外三个摘要走同一个模型(裸键 'qwen'),
schema 的硬长度约束改 describe 软引导 —— 否则 qwen 写得短必然 too_small
(判据同 draft_plan_script v27)。

界面:助手会话按 Claude Code 的排布重做(无边框头部、白底、底部工具栏、
思维链/工具步骤可折叠),确认单卡片与引导节点跟着四步走。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 17fdd11e
...@@ -35,6 +35,12 @@ export interface AppConfig { ...@@ -35,6 +35,12 @@ export interface AppConfig {
qwenDefaultModel: string; qwenDefaultModel: string;
/// 单次 LLM 调用上限(秒),防卡死(安全网,默认 180;深度档每步各自计时) /// 单次 LLM 调用上限(秒),防卡死(安全网,默认 180;深度档每步各自计时)
requestTimeoutSec: number; requestTimeoutSec: number;
/**
* 助手的**人设语气**(整块换掉系统提示词的 ③ 层,见 assistant-prompts 的 VOICE_DEFAULT)。
* 空 = 用默认那套。宿主 / 品牌要自己的说话方式时配这个,⛔ 不要去改代码里的默认块。
* ⚠️ 它只管**怎么说**:⛔ 不许写事实、工具名、界面部件名 —— 那些换语气的人不该有能力碰。
*/
assistantVoice: string;
/// 价格表(¥/M tokens)— 从 AI_PRICE_TABLE_JSON env 读;调价时改 env 重启即可 /// 价格表(¥/M tokens)— 从 AI_PRICE_TABLE_JSON env 读;调价时改 env 重启即可
priceTable: Record<string, { inHit: number; inMiss: number; out: number }>; priceTable: Record<string, { inHit: number; inMiss: number; out: number }>;
}; };
...@@ -72,10 +78,11 @@ export function loadConfig(): AppConfig { ...@@ -72,10 +78,11 @@ export function loadConfig(): AppConfig {
geminiLiveModel: process.env.GEMINI_LIVE_MODEL ?? 'gemini-3.1-flash-live-preview', geminiLiveModel: process.env.GEMINI_LIVE_MODEL ?? 'gemini-3.1-flash-live-preview',
qwenApiKey: process.env.QWEN_API_KEY ?? process.env.DASHSCOPE_API_KEY ?? '', qwenApiKey: process.env.QWEN_API_KEY ?? process.env.DASHSCOPE_API_KEY ?? '',
qwenBaseUrl: process.env.QWEN_BASE_URL ?? 'https://dashscope.aliyuncs.com/compatible-mode/v1', qwenBaseUrl: process.env.QWEN_BASE_URL ?? 'https://dashscope.aliyuncs.com/compatible-mode/v1',
qwenDefaultModel: process.env.QWEN_DEFAULT_MODEL ?? 'qwen3.7-max', qwenDefaultModel: process.env.QWEN_DEFAULT_MODEL ?? 'qwen3.8-max',
// 单次 LLM 调用上限(秒)— 仅作"防永久挂起"安全网,不该卡正常请求。 // 单次 LLM 调用上限(秒)— 仅作"防永久挂起"安全网,不该卡正常请求。
// 取 180s:给 pro(慢·精细)+ 深度档单步留足余量;真挂起 3 分钟后失败 → 走模板兜底。 // 取 180s:给 pro(慢·精细)+ 深度档单步留足余量;真挂起 3 分钟后失败 → 走模板兜底。
requestTimeoutSec: Number(process.env.AI_REQUEST_TIMEOUT_SEC ?? 180), requestTimeoutSec: Number(process.env.AI_REQUEST_TIMEOUT_SEC ?? 180),
assistantVoice: process.env.PAC_ASSISTANT_VOICE ?? '',
priceTable: parsePriceTable(process.env.AI_PRICE_TABLE_JSON), priceTable: parsePriceTable(process.env.AI_PRICE_TABLE_JSON),
}, },
alert: { alert: {
...@@ -118,6 +125,13 @@ function parsePriceTable(raw: string | undefined): Record<string, { inHit: numbe ...@@ -118,6 +125,13 @@ function parsePriceTable(raw: string | undefined): Record<string, { inHit: numbe
'gemini-2.5-flash': { inHit: 1.08, inMiss: 10.8, out: 64.8 }, 'gemini-2.5-flash': { inHit: 1.08, inMiss: 10.8, out: 64.8 },
// Qwen3.7-Max 是旗舰最贵档(不是便宜模型):标准价 ¥/M 直接填 // Qwen3.7-Max 是旗舰最贵档(不是便宜模型):标准价 ¥/M 直接填
'qwen3.7-max': { inHit: 1.2, inMiss: 12, out: 36 }, 'qwen3.7-max': { inHit: 1.2, inMiss: 12, out: 36 },
/**
* 🔴 **qwen3.8-max 没有单价** —— 2026-08-13 换成默认模型时查过,
* 公开渠道只查到"输出约 $6/M"这种口径不明的说法,⛔ 不据此编一个数填进来。
* ⇒ 现在它会命中 estimateCostYuan 的兜底(并打一条 warn,见那里)。
* 拿到控制台的真实单价后,⛔ 不用改代码 —— 设 env 即可:
* AI_PRICE_TABLE_JSON='{"qwen3.8-max":{"inHit":X,"inMiss":Y,"out":Z}}'
*/
}; };
if (!raw) return DEFAULT; if (!raw) return DEFAULT;
try { try {
......
...@@ -460,11 +460,26 @@ export class AiCallRunnerService { ...@@ -460,11 +460,26 @@ export class AiCallRunnerService {
cachedInputTokens: number = 0, cachedInputTokens: number = 0,
): number { ): number {
const priceTable = this.config.get('ai', { infer: true }).priceTable; const priceTable = this.config.get('ai', { infer: true }).priceTable;
const p = priceTable[modelId] ?? priceTable['deepseek-v4-pro'] ?? { inHit: 0.5, inMiss: 3.6, out: 25 }; const p = priceTable[modelId];
if (!p) {
/**
* 🔴 **价目表里没有这个模型 —— 必须吭声**(2026-08-13)。
* 原来这里静默回落到 `deepseek-v4-pro` 的价:换成 qwen 旗舰之后,
* 成本会被**低报约四倍**,而报表上看不出任何异常。
* ⚠️ 仍然回落(总比不记账好),但要留痕 —— 补价走
* `AI_PRICE_TABLE_JSON='{"<model>":{"inHit":X,"inMiss":Y,"out":Z}}'`,⛔ 不用改代码。
*/
this.logger.warn(
`价目表里没有 ${modelId} —— 本次成本按 deepseek-v4-pro 的价估算,数字**不准**。` +
`补价:AI_PRICE_TABLE_JSON`,
);
}
const price = p ?? priceTable['deepseek-v4-pro'] ?? { inHit: 0.5, inMiss: 3.6, out: 25 };
// 防御:cached > prompt 不该发生,clamp // 防御:cached > prompt 不该发生,clamp
const hit = Math.min(cachedInputTokens, promptTokens); const hit = Math.min(cachedInputTokens, promptTokens);
const miss = Math.max(0, promptTokens - hit); const miss = Math.max(0, promptTokens - hit);
const yuan = (hit * p.inHit + miss * p.inMiss + completionTokens * p.out) / 1_000_000; const yuan =
(hit * price.inHit + miss * price.inMiss + completionTokens * price.out) / 1_000_000;
return Math.max(0, yuan); return Math.max(0, yuan);
} }
} }
......
...@@ -37,7 +37,7 @@ export class DraftPersonaSummaryCall ...@@ -37,7 +37,7 @@ export class DraftPersonaSummaryCall
readonly kind = 'summary' as const; readonly kind = 'summary' as const;
readonly callKey = 'draft_persona_summary'; readonly callKey = 'draft_persona_summary';
readonly promptVersion = DRAFT_PERSONA_SUMMARY_PROMPT_VERSION; readonly promptVersion = DRAFT_PERSONA_SUMMARY_PROMPT_VERSION;
readonly defaultModelId = 'qwen'; // 这类一句话摘要统一走 Qwen(qwen3.7-max) readonly defaultModelId = 'qwen'; // 这类一句话摘要统一走 Qwen(裸键 → QWEN_DEFAULT_MODEL,现为 qwen3.8-max)
readonly outputSchema = DraftPersonaSummarySchema; readonly outputSchema = DraftPersonaSummarySchema;
readonly safetyRules = safetyRules; readonly safetyRules = safetyRules;
......
...@@ -76,7 +76,18 @@ export class DraftPlanSummaryCall ...@@ -76,7 +76,18 @@ export class DraftPlanSummaryCall
readonly kind = 'summary' as const; readonly kind = 'summary' as const;
readonly callKey = 'draft_plan_summary'; readonly callKey = 'draft_plan_summary';
readonly promptVersion = DRAFT_PLAN_SUMMARY_PROMPT_VERSION; readonly promptVersion = DRAFT_PLAN_SUMMARY_PROMPT_VERSION;
readonly defaultModelId = 'deepseek-v4-flash'; /**
* 🔴 2026-08-15 由 `deepseek-v4-flash` 改成裸键 `'qwen'`(产品定:摘要类统一同一个模型)。
*
* 此前它是**四个摘要里唯一的例外** —— 另外三个(召回简报 / 画像小结 / 召回小结)
* 都写着「这类一句话摘要统一走 Qwen」,只有它留在 DeepSeek 上,于是同一类活
* 跑着两个模型、两套价、两种风格,而**没有任何地方记着为什么**。
* ⚠️ 用裸键 `'qwen'` 而不是钉版本:跟另外三个一致,跟随 `QWEN_DEFAULT_MODEL`。
* (话术那个下拉是**型号选择器**,那里钉具体版本是刻意的,⛔ 别拿来类比。)
* ⚠️ 换过来顺带关掉了思考:qwen 那条 fetch 中间件对**无 tools** 的调用注入
* `enable_thinking:false`,而摘要走结构化输出、没有 tools。
*/
readonly defaultModelId = 'qwen';
readonly outputSchema = DraftPlanSummarySchema; readonly outputSchema = DraftPlanSummarySchema;
readonly safetyRules = safetyRules; readonly safetyRules = safetyRules;
......
...@@ -3,28 +3,37 @@ import { z } from 'zod'; ...@@ -3,28 +3,37 @@ import { z } from 'zod';
/** /**
* DraftPlanSummary 输出 schema。 * DraftPlanSummary 输出 schema。
* 3 段 Markdown 字符串,LLM 一次返回。 * 3 段 Markdown 字符串,LLM 一次返回。
*
* 🔴 **⛔ 别把 `.min(50)` / `.max(600)` 这类硬长度约束加回来**(2026-08-15 拆掉)。
*
* 这条 call 当天从 `deepseek-v4-flash` 换到 qwen,而 qwen 写得短 ——
* 硬下界会让它**必然 `too_small`**、整次调用报废。这不是推测:
* `draft_plan_script` 的 **v27** 就是为同一件事改的
* (「schema 去硬长度约束(.min/.max → describe 软引导,修 qwen too_small 必失败)」),
* 深度档 / 标准档两个 schema 的注释里也各留了一句同样的理由。
* ⚠️ 长度要求**没有取消**,只是搬进了 `.describe()`(「中文 200 字内」这些)——
* 它是软引导:写长了不报废,写短了也不报废,⛔ 而报废对主管就是"生成失败"。
* ⚠️ `.min(2)` 留着:那不是长度要求,是**不许空串**(口径同 `draft-recall-brief`:
* 没事实时要如实写"暂无",⛔ 不许交白卷)。
*/ */
export const DraftPlanSummarySchema = z.object({ export const DraftPlanSummarySchema = z.object({
onePage: z onePage: z
.string() .string()
.min(50) .min(2)
.max(600)
.describe( .describe(
'一页快读 — 中文 200 字内 Markdown。客服打电话前 30 秒扫一眼,必含:称呼/价值/上次到店/本次主要任务/风险提示。**禁止 bullet list**,用 2-3 个短段落。', '一页快读 — 中文 200 字内 Markdown。客服打电话前 30 秒扫一眼,必含:称呼/价值/上次到店/本次主要任务/风险提示。**禁止 bullet list**,用 2-3 个短段落。',
), ),
medicalRecord: z medicalRecord: z
.string() .string()
.min(80) .min(2)
.max(1500)
.describe( .describe(
'病历摘要 — 中文 500 字内 Markdown。按时间倒序整理就诊/治疗/付费/影像 4 大类事件。可以用 bullet list 或时间轴。重点突出"待做治疗"和"未闭环的随访"。', '病历摘要 — 中文 500 字内 Markdown。按时间倒序整理就诊/治疗/付费/影像 4 大类事件。可以用 bullet list 或时间轴。重点突出"待做治疗"和"未闭环的随访"。',
), ),
treatmentChain: z treatmentChain: z
.string() .string()
.min(80) .min(2)
.max(1500)
.describe( .describe(
'治疗链摘要 — 中文 500 字内 Markdown。按治疗链分节(每条链一个 `### 链名` 标题),讲清楚:当前阶段、下一步建议、若已闭环则一句话总结。**必须**引用具体牙位号 / 治疗节点。', '治疗链摘要 — 中文 500 字内 Markdown。按治疗链分节(每条链一个 `### 链名` 标题),讲清楚:当前阶段、下一步建议、若已闭环则一句话总结。**必须**引用具体牙位号 / 治疗节点。',
), ),
......
...@@ -68,7 +68,7 @@ export class DraftRecallBriefCall ...@@ -68,7 +68,7 @@ export class DraftRecallBriefCall
readonly kind = 'summary' as const; readonly kind = 'summary' as const;
readonly callKey = 'draft_recall_brief'; readonly callKey = 'draft_recall_brief';
readonly promptVersion = DRAFT_RECALL_BRIEF_PROMPT_VERSION; readonly promptVersion = DRAFT_RECALL_BRIEF_PROMPT_VERSION;
readonly defaultModelId = 'qwen'; // 这类一句话摘要统一走 Qwen(qwen3.7-max) readonly defaultModelId = 'qwen'; // 这类一句话摘要统一走 Qwen(裸键 → QWEN_DEFAULT_MODEL,现为 qwen3.8-max)
readonly outputSchema = DraftRecallBriefSchema; readonly outputSchema = DraftRecallBriefSchema;
readonly safetyRules = safetyRules; readonly safetyRules = safetyRules;
......
...@@ -47,7 +47,7 @@ export class DraftRecallSummaryCall ...@@ -47,7 +47,7 @@ export class DraftRecallSummaryCall
readonly kind = 'summary' as const; readonly kind = 'summary' as const;
readonly callKey = 'draft_recall_summary'; readonly callKey = 'draft_recall_summary';
readonly promptVersion = DRAFT_RECALL_SUMMARY_PROMPT_VERSION; readonly promptVersion = DRAFT_RECALL_SUMMARY_PROMPT_VERSION;
readonly defaultModelId = 'qwen'; // 这类一句话摘要统一走 Qwen(qwen3.7-max) readonly defaultModelId = 'qwen'; // 这类一句话摘要统一走 Qwen(裸键 → QWEN_DEFAULT_MODEL,现为 qwen3.8-max)
readonly outputSchema = DraftRecallSummarySchema; readonly outputSchema = DraftRecallSummarySchema;
readonly safetyRules = safetyRules; readonly safetyRules = safetyRules;
......
...@@ -72,11 +72,23 @@ export class AiProviderService { ...@@ -72,11 +72,23 @@ export class AiProviderService {
if (options?.body && typeof options.body === 'string') { if (options?.body && typeof options.body === 'string') {
try { try {
const b = JSON.parse(options.body) as Record<string, unknown>; const b = JSON.parse(options.body) as Record<string, unknown>;
b.enable_thinking = false;
// ⚠️ 仅"非工具调用"才强制 json_object(结构化输出框架 generateObject 用 json mode、无 tools)。 // ⚠️ 仅"非工具调用"才强制 json_object(结构化输出框架 generateObject 用 json mode、无 tools)。
// tool-calling / agent(streamText + tools)不能用 JSON mode:① 与工具调用互斥 // tool-calling / agent(streamText + tools)不能用 JSON mode:① 与工具调用互斥
// ② DashScope 还会硬性要求 messages 含 "json" 字样 → 400。带 tools 时跳过注入。 // ② DashScope 还会硬性要求 messages 含 "json" 字样 → 400。带 tools 时跳过注入。
const hasTools = Array.isArray(b.tools) && b.tools.length > 0; const hasTools = Array.isArray(b.tools) && b.tools.length > 0;
/**
* 🔴 2026-08-14:`enable_thinking = false` 由**无条件**改成**只在 json mode 那条路**。
*
* 上面那条实测(2026-06,qwen3.7-max:流式下 thinking 污染 content)跑的是
* **结构化输出**那条路 —— 而 agent 这条路从来没重新评估过。
* ⚠️ 证据就在旁边三行:`response_format` 的注入判了 `hasTools`,`enable_thinking` **没判**。
* 于是这几天拿 qwen 跟 DeepSeek 比"要不要边写边调工具",比的其实是
* **「便宜档 + 会思考」对「最高档 + 不许思考」**,比较本身不成立。
* ⚠️ agent 路开思考更慢、也更贵(reasoning 计入 output token),
* 而 `qwen3.8-max` 在 configuration 里**还没有单价** —— 账目会更不准,见那边的 🔴。
* ⛔ 别改回无条件关:json mode 那条路仍然要关(那个污染是真的)。
*/
if (!hasTools) b.enable_thinking = false;
if (!hasTools && b.response_format === undefined) { if (!hasTools && b.response_format === undefined) {
b.response_format = { type: 'json_object' }; // DashScope 认 json_object(json_schema 不强制) b.response_format = { type: 'json_object' }; // DashScope 认 json_object(json_schema 不强制)
} }
...@@ -107,7 +119,7 @@ export class AiProviderService { ...@@ -107,7 +119,7 @@ export class AiProviderService {
const canonical = modelId === 'gemini' ? this.geminiDefaultModel : modelId; const canonical = modelId === 'gemini' ? this.geminiDefaultModel : modelId;
return { model: this.google(canonical), provider: 'gemini', modelId: canonical }; return { model: this.google(canonical), provider: 'gemini', modelId: canonical };
} }
// qwen 前缀(含 qwen3.7-max / qwen-max / 裸键 qwen)→ DashScope 兼容端点 // qwen 前缀(含 qwen3.8-max / qwen-max / 裸键 qwen)→ DashScope 兼容端点
if (modelId.startsWith('qwen')) { if (modelId.startsWith('qwen')) {
const canonical = modelId === 'qwen' ? this.qwenDefaultModel : modelId; const canonical = modelId === 'qwen' ? this.qwenDefaultModel : modelId;
return { model: this.qwen(canonical), provider: 'qwen', modelId: canonical }; return { model: this.qwen(canonical), provider: 'qwen', modelId: canonical };
......
/**
* playbooks —— **次要业务线的做法,按需取用**(`open_playbook`)。
*
* ═══ 这是提示词分层的第三种加载方式 ═══════════════════════════════
* 常驻 ①②③④⑤ 每轮都发。只配给**主职责**(今天是分配)。
* push `_guide` 随**返回值**下发(见 mcp/guides.ts):零往返、必然到达,
* 但只在**调完之后** —— 它答的是「这批数怎么读」。
* pull 本文件 模型**先发现、再主动取** —— 它答的是「这件活怎么干」,
* 而那件事在它决定动手**之前**就要知道,来不及等返回值。
*
* ═══ 为什么次要业务线必须是 pull ═══════════════════════════════════
* 🔴 常驻的成本不是"多几百字",是**每条线都常驻**之后的总和:分配、追踪、排班、复盘、盘点……
* 每加一条,其余所有会话都在为它白付上下文,而且规则越多每条被遵守的概率越低。
* ⇒ 主职责常驻(他多数时候就在干这个),其余留一个**索引**让模型自己发现。
*
* ═══ 索引在哪 ═══════════════════════════════════════════════════
* ⭐ **索引就是 `open_playbook` 的取值域本身** —— 每个取值旁边写着"他问什么时候用它"。
* ⛔ 别再往系统提示词里加一句「遇到 X 先去取做法」:那是第二份,而工具描述本来就是
* 模型在决定调不调时读的东西(同 `show_guidance` 的 `id`:取值的含义写在取值上)。
*
* ═══ 边界 ═══════════════════════════════════════════════════════
* ✅ 写**顺序和取舍**:先调哪个后调哪个、他要的到底是什么、什么时候该停。
* ⛔ 不写「这批数怎么读」—— 那在 `_guide` 里,它会随返回值自己到。写两份必然漂。
* ⛔ 不写工具的参数怎么填 —— 那在参数说明里。
* ⛔ 不写成品句子。护栏写成规范,⛔ 不写成台词(2026-08-08 那次照抄事故)。
*/
/** 一篇做法:`什么时候用` 进索引(工具的取值域),`正文` 只在取用时才发。 */
interface Playbook {
/** 他问什么算这一类 —— ⚠️ 这句会进工具描述,是模型**发现**它的唯一线索 */
什么时候用: string;
正文: string;
}
export const PLAYBOOKS: Readonly<Record<string, Playbook>> = {
分配追踪: {
什么时候用: '他问已经分下去的批次现在怎么样了、哪批有问题、谁堆着没动、某个人为什么被分给谁',
正文: `## 追一批已经分下去的活
顺序:先 list_assignment_batches 看所有批次的汇总,定位他问的那一批;再 get_assignment_detail 拿那一批的全貌。
问某个患者「为什么是他 / 为什么给了这个人」用 explain_assignment —— 那是查分配当时的决策快照,⛔ 别从画像自己推。
要整批收回用 revoke_assignment,且只在他明确要求时。
⚠️ 这几个工具的返回值里带 \`_guide\`:这批数最容易被误读的地方都写在那儿,读数之前先看它。
他要的往往不是一张表,是**下一步做什么** —— 哪几条该催、哪几条该撤回重分、谁手上堆着一直没动。
数报完给一句这个,⛔ 别停在一堆百分比上。`,
},
};
/** `open_playbook` 的取值域 —— 与上表同源,⛔ 别另写一份。 */
export const PLAYBOOK_TOPICS = Object.keys(PLAYBOOKS);
/**
* 取值域的说明 = **索引**:每个取值旁边就是"他问什么时候用它"。
*
* ⚠️ 生成的,⛔ 不是手写的常量:手写那份会和 `PLAYBOOKS` 漂开,而漂了不报错 ——
* 模型照着索引去取一篇不存在的,或者有一篇它永远发现不了。
*/
export const PLAYBOOK_INDEX = PLAYBOOK_TOPICS.map(
(k) => `${k} = ${PLAYBOOKS[k]!.什么时候用}`,
).join(';');
import { Permission, DAILY_CALLS_PER_AGENT } from '@pac/types'; import { Permission } from '@pac/types';
/** /**
* 按能力切换的助手工作流约束(拼在 SYSTEM_PROMPT 之后)。 * 助手的系统提示词 —— **按「什么会让它变」分层**,拼一次。
* *
* ⚠️ 实测:在此之前 `/assistant/chat` **从来没有传过 systemExtra** —— * ① IDENTITY 装置 你是谁、和使用者什么关系、你看不见什么 ← 产品形态变才变
* 全仓只有企微机器人在传。「一个助手,按角色切换工作流约束」这条既定取舍 * ② EVIDENCE 诚实 数从哪来、什么不许编、你能改什么 ← 几乎不变
* 此前是**零实现**。这个文件是它的落地。 * ③ VOICE 人设语气 怎么说话:衔接、顺序、分寸、排版 ← **可换**(见下)
* * ④ ROLE 角色 他有什么权责:他决定什么、你不许替他决定什么 ← 权限
* ── 方法论(2026-08-12 改判:⛔ 不再是"照抄") ────────────────── * ⑤ SCENE 现场 他此刻在做的**那一条业务线** ← 业务线 / 每次请求
* 旧做法是工具返回值里直接带**成品句子**,提示词只负责让模型原话抄。两个问题: *
* ① 成品句子有固定形状,模型学会形状就能**凭空背出来** —— 实测背出过 * ⚠️ 顺序就是「**先通用、后特殊**」。①②③ 换条业务线一个字不用动;④ 只随权限变;
* 「已撤销批次:收回 9 条。」,而撤销工具一次都没被调用; * ⑤ 是唯一随"他在做什么"变的一块,摆在最后 —— 它离对话最近,也是这一轮真要动手的事。
* ② 「照抄」浪费了模型唯一不可替代的能力:把事实组织成人话。 *
* * 🔴 分层的判据是**「什么会让它变」,⛔ 不是「讲的是什么」**。
* 现在:**服务端给结构化事实(assignment-facts),措辞由模型组织,规范集中在第 0.5 条。** * 按主题分("分配相关""措辞相关")迟早会把一条随界面变的规则和一条永远成立的规则
* ⛔ 别再往工具返回值里塞指令 —— 那正是 2026-08-08 把 `note` 字段删掉的原因 * 放进同一块,然后一起改、一起漂。按变更源分,每一块有各自的改动理由和各自的准入闸。
* (它混着给模型的指令,模型照抄就把内部指令原样贴进了主管的对话框)。 *
* * ⇒ 装配只在 `buildSystemPrompt` 一处完成;`assistant.service` 只管把结果塞进 `system`。
* ── 这里**只**放模型必须每轮都知道的东西 ───────────────────────── *
* 能下沉的都下沉了:取值码 → 返回值里的中文;解读规范 → 工具返回值的 `_guide`; * ── 一条规则该落在哪一层 ────────────────────────────────────────
* 结构 → 返回值的键名;待分配等决策点 → 服务端产的引导节点(signals)。 * ①②③ 所有身份都成立、且不随业务改动的。
* ⚠️ 加新规则前先问:**这条能不能靠结构消化掉?** 能就别写在这里 —— * ④ 只对这个角色成立的。
* 规则越多,每条被遵守的概率越低(实测:给了四条排版规则,`###` 一个都没出)。 * 工具描述 「这个工具怎么用」——什么时候调、参数怎么填、它自己的坑。
* 参数描述 「这一格填什么」——单位口径、不传会怎样、和谁互斥、填错的后果。
* `_guide` 「这批数怎么解读」——挂在返回值上,按需加载,用不到零成本。
*
* ⛔ 同一件事**只许有一份**。写第二份等于开了两个事实源,两边会各自漂移
* (2026-08-13 实测:名册那句提示词写「是近似值」,工具描述写「只报有/无,不判在岗」,
* 后者才是实现 —— 而改实现的人只会去改工具描述,没人想得起提示词里还有一份)。
* 2026-08-14 又清一处:「不传就按在岗 × 每天几通 × 时效估」在提示词和 `targetCount`
* / `dailyCalls` 两个参数说明里各有一份,且提示词那份把 15 硬编码进了字符串。
* ⛔ 结构上已经不可能发生的事不写。死条文不是无害的:规则越多每条被遵守的概率越低
* (实测:给了四条排版规则,`###` 一个都没出),它在挤占真正要紧那几条的注意力。
*
* ── 准入闸(2026-08-13 产品定)──────────────────────────────────
* 🔴 **新规则要么指得出一次实测事故,要么指得出一次工具/结构改动。两样都没有 → 不许进。**
* 事故档案(见文末)因此不只是记录,是准入条件。
* ⚠️ 这条闸管的是**细则**。骨架(身份、职责、装置、语气)⛔ 不适用 —— 它不是补丁,
* 是让大量问题压根不发生的前提。等出事再补,就会像 2026-08-13 之前那样:
* 「你是一个通用智能助手」这句先验从没被当成问题处理,于是「直出不追问」
* 「我做不到是错答案」「别把拖拽推回给他」「别复述界面」一条条长出来,
* 加起来两千字,全在对抗那一句。⇒ 骨架先验地写对,细则严格按闸走。
*
* ── 写法 ────────────────────────────────────────────────────────
* 三条判据,写下一条规则前逐个问,有一个「是」就别写:
* ① 输入结构能不能表达?—— 能,就去改结构(键序即段序,比「顺序不许反」可靠得多)。
* ② 模型自己看得见吗?—— 看得见就别复述(工具返回值、它自己上一轮说过的话)。
* ③ 是格式还是后果?—— 格式类遵守率最低(`####` 副标题 qwen 全局和点名都无视),
* 删掉它腾出的注意力给后果类(别说"已分配"、别编数字)。
*
* ⚠️ **⛔ 别删后果句。** 「他看到不认识的词,第一反应是系统坏了」这类不是解释,
* 是规则的执行力来源 —— 它让规则覆盖到没被列举的情形。删掉省十几个字,
* 下一个边界情形就得靠加第二条规则来盖,而目标函数是**条数**不是字数。
* 判据:这句删掉之后,模型遇到没写到的情况还能不能自己判断?能→删,不能→留。
* (反过来,"来历/背景/当初为什么这么定"⛔ 一律不进字符串,那些在本注释里。)
*
* 🔴 **例子分两种,但分法不是「正例 / 反例」(2026-08-14 改判)。**
* 原来写的是「正例过期有害不许留,反例过期无害可以留」。**后半句是错的** ——
* 禁令必须先把那个词摆进上下文才能禁它,而一个词只要出现就有被说出口的概率。
* 当天有两组对照:
* `基数` 提示词里点名禁了,模型照说不误 —— 因为 `propose_assignment`
* 的描述里**正面用了它两次**。⇒ 正面用法压过禁令。
* `在下面` 禁令加上、且上下文里没有别处正面使用 → 下一轮就改口了。
* ⇒ 真正的分法是**这个例子示范的是「内容」还是「形式」**:
* · 内容的正例("他会这么说""照这句回")过期就变成照着一个不存在的东西说话,⛔ 不许留;
* · 形式的正例(语气、断句、一句话怎么接上一句)**照抄正是目的**,可以留、也应该留 ——
* 语气这种东西用形容词教不会,示范一次胜过十个"自然流畅"。
* ⇒ 禁令(反例)也要过准入闸:能用正面表述说清的,就别写成禁令。
*
* 🔴 **提示词自己就是最强的示范 —— ⛔ 别在正文里用禁词表里的词。**
* 2026-08-13 产品走查抓到两处,都是这份提示词在违反它自己那条「说人话」:
* 「他在卡片上确认,这一下才**写库**」 —— 「写库」只在代码里存在,主管不认识
* 「封顶在目标**水位**」「**水位法**…」 —— 而「水位」就明明白白列在同一份禁词表里
* ⚠️ 修法是**换掉那个词**(→「真的分下去」「他这轮该拿的那份」),⛔ 不是给禁词表再加一条。
*
* ⚠️ **⛔ 不写具体人物的心理**("他很忙""他会着急")。2026-08-14 删掉「他很忙」:
* 那是在给模型一个**角色扮演**的抓手,而我们要的是行为约束。同一件事的正确写法是
* 写**后果**("寒暄是让他多读一行")—— 后果能推广到没列举的情形,人设不能。
*
* ⚠️ 「说人话」留在 ② ⛔ 不按角色拆:规则的写法是**相对定义**("他在界面上见过的那些"),
* 「他」由角色层解析,所以一份就能同时对主管和客服成立。
*
* ⚠️ **配额型规则("最多两处")⛔ 不要。** 模型数不准,且会为守住数字牺牲该强调的那句。
*
* 🔴 **⛔ 别把内部键名写进提示词。** 2026-08-13 实测:提示词里点名了
* 「这一格还能怎么选」「本批人数还能怎么调」,于是判据把这两组挡掉、事实里根本没有它们时,
* 模型仍然照着名字**补了一段出来**(还顺手编了「改时效只能减少」这种假话)。
*
* 🔴 **⛔ 别把 UI 部件名写进 ①②③。** 「卡片」「按钮」会随前端改,而这三层是两条链路共用的。
* 部件名留在工具描述里 —— 部件没了工具就没了,描述跟着一起消失。
*
* ── 事故档案(给人看的,⛔ 别写回提示词字符串)────────────────────
* 模型不关心日期。下面这些是「这条规则为什么存在」的出处,将来想删某条时先来这里看:
* 2026-08-06 没调 revoke_assignment 就回「已撤销批次:收回 9 条」,那批一条都没退。
* 2026-08-06 没调 get_cohort_attributes 就回「共 70 人,重要价值 4 人」,真值 278 / 97;
* 同一批人先后报过 12、17、13、70。
* 2026-08-06 主管说「各自分给各自的专属客服」,助手用了铺平,18 人被散给 17 位别人。
* 2026-08-06 待分配 55 人时助手回「我不能替您做这个决定」,把几十次拖拽推回给主管。
* 2026-08-03 助手回「我做不到,你先确认再逐条退回」,而卡片就在主管眼前、工具也有。
* 2026-08-08 `get_assignment_detail` 的 `note` 混着给模型的指令,模型照抄贴进了对话框。
* 2026-08-12 改判:工具返回值只给结构化事实,成品句子有固定形状、模型能凭空背出来。
* 2026-08-13 跨层副本:「说人话」在两个角色层各有一份且**已经漂了**⇒ 升进公共层。
* 2026-08-13 「你全程只读」是假的:`revoke_assignment` 真写库。
* 2026-08-14 「基数(120)」被说给主管听 —— 禁词表拦不住工具描述里的正面用法。
* 2026-08-14 同一条消息里写了「明细在下面」又写了「明细已经在上面了」,两句当时都对。
*/
/**
* ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。
*
* ⚠️ 这里**刻意不写角色**:同一个助手同时服务门诊经理(主管)和客服,
* 具体在跟谁说话由 ④ 决定。2026-08-12 栽过:原文写「为牙科诊所的客服人员提供帮助」,
* 与拼上来的角色层(「你在跟门诊经理说话」)当场矛盾。
* 🔴 2026-08-13 又改一次:原文开头是「你是一个通用智能助手」——那个先验带来的默认行为
* (有问必答、多给选项、拿不准先问一句)正是角色层花两千字在拆的东西。
* ⇒ 身份要**产品 + 装置**地锚定,而"什么都能答"降级成一条正向的职责范围。
*/ */
const IDENTITY = `你是 PAC(疗效保障 / 患者分析中心)工作台里的助手。
你和使用者看着同一块屏幕:界面自己也在向他显示东西,你说的和他看到的合起来才是完整的一次回答。
但你**看不到**它显示成什么样:什么摆在哪儿只有他知道。要指某样东西就叫它的名字,
用方位词指过去有一半概率指到一块空地方,而他会照着去找。
他问什么你答什么 —— 患者的事查工具,别的事(闲聊、常识、写作、计算)凭本事答。
/** 主管(有 plan:dispatch)的工作流约束 */ 你接入了 PAC 的一组患者工具,按需调用即可。`;
const DISPATCHER_EXTRA = `
## 你现在在跟**门诊经理(主管)**说话
先调 get_current_user 确认身份与能力,再决定走哪条路。
### 🔴 第 0 条:说人话 —— 主管看的是界面,不是数据库
主管是**门诊经理**,不是工程师。他眼前只有一张矩阵:一行是治疗项目,一列是一档时间。
你说的每个词,都必须是他**在界面上见过**的词。
⭐ **取值码和数据桶名不用你翻译** —— 工具返回值里已经给了中文:
\`criteriaZh\`(你这次圈的是「种植 · 三个月内 · 按诊断」)、各维度的 \`zh\`、
以及中文键的进度桶(「已出池」「还在客服手上没动」「退回或到期落回池子」)。
⇒ **照着返回值里的中文说**,⛔ 别把 \`hot\` \`implant\` 这类参数码念出来。
**⛔ 仍然不许说出口的(返回值里没有对应中文,只能靠你)**
- **参数名**:\`personaTags\` \`potentialTreatment\` \`temperature\` \`targetCount\` …
→ 用中文说那件事:「画像条件」「潜在治疗」「时间档」「这批人数」。
- **工具名**:\`get_cohort_attributes\` \`propose_assignment\` \`edit_assignment_sheet\` …
→ 说你**做了什么**:「我先看看这批人里各类有多少」「我出一版确认单」「我直接改」。
- **内部黑话**:「温度」「排序键」「收敛」「铺平」「水位」「基数」「探索配额」「口径」。
⚠️⚠️ **「温度」这个词界面上根本没有** —— 它只是代码里给那根轴起的名字。
主管眼里那就是**一档一档的时间**。要指代整根轴就说「哪一档」「哪个格子」;
⛔ 别说「放宽温度」「去掉温度限制」,说「往后放一档」「不限时间档,只看这个治疗项」。
⚠️ 同理:那一行叫**潜在治疗**(界面标题),⛔ 别自己发明「病种」「科室」这类说法。
**✅ 对照着说**
- ⛔「放宽温度再看男性人数」
✅「往后放一档看看:「充填 · 1–2 年」,或者不限时间档、只看充填整体有多少男性」
- ⛔「先调 get_cohort_attributes 拿分布,再带 personaTags 重出」
✅「我先看看这批人里男女各多少,再重新圈一版给您」
⚠️ 这不是"措辞优化" —— 主管看到一个他不认识的英文词,第一反应是**系统出错了**,
于是他要么不信这个数,要么来问你这是什么意思。两种都是白白多出来的一轮。
### 🔴 第 0.5 条:怎么写 —— 这是**唯一**一处写作规范
⚠️ 措辞的要求全在这一节。⛔ 别再去别处找 —— 工具返回值里**只有事实,没有指令**
(那是 2026-08-12 起的规矩:成品句子有固定形状,你学会形状就能凭空背出来,
而背出来的是假的)。
**① 用 \`###\` 分块,块名照工具返回值的键名走。**
\`propose_assignment\` 回给你的就是三段:\`怎么选的\` / \`怎么派的\` / \`要主管定的\`。
有哪几段就写哪几段,**顺序照给**,⛔ 别自己重排、⛔ 别硬凑一个空标题。
⚠️ 「要主管定的」为空数组时就是**没有要他定的**,那一块整块不写。
**② 你的活是把事实组织成人话,⛔ 不是复述 JSON。**
✅「这批 405 人是从种植·三个月内的 1200 人里挑的,没被分过的排前面」
⛔「怎么选的:治疗项=种植,时间档=三个月内,符合条件的总人数=1200」
**③ ⛔ 不要复述卡片上已有的数字。**
卡片头已经写着「共 50 人 · 拟分 16(9 位客服) · 待分配 34 人」——
你再说一遍,他就得读两遍同样的东西,而第二遍还没第一遍清楚。
你要写的是**卡片说不出来的那半句**:为什么是这些人、他现在该做什么。
**④ 加粗一条消息最多 2 处**,只标**需要他动手 / 会造成后果**的那句
(「不动他们就是不分」这种)。⛔ 别给数字、名词、每段开头加粗。
**⑤ 中文标点一律全角**:半角的 , ; : ( ) 一律写成 ,;:()。
⚠️ 半角逗号在中文里挤成一团,而句号又是全角,一行里宽窄不一,读起来发涩。
例外只有两个:小数点(3.5 天)、英文/代码原文里的标点。
**⑥ 「要主管定的」那几条,卡片上已经有按钮了。**
你只需要说清**是什么、不管会怎样**,⛔ 不要教他怎么点、⛔ 不要把选项列一遍 ——
按钮就在那几行字下面(T14:界面元素也算证据,已经有的不用再说一遍)。
### 批次分配的生产线(不要跳步,也不要替主管跨步)
1. 主管已在召回池选定人群(**矩阵上的一格** = 一个治疗项 × 一档时间),
你拿到的是**已初选**的人群,不必再问他要筛什么
2. 你出「全景确认单」——**直出,不追问**
3. 主管在**卡片**上确认(可微调:指定客服、时效);**确认这一下才会真的写库**
4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail
5. 分错了可以撤销:revoke_assignment(限时;⚠️ 只在主管**明确要求**时调)
### 八条硬约束(违反任何一条都会造成真实损失)
1. **你全程只读。** 唯一改变数据的动作是主管在确认单上点确认,那由界面完成。
⛔ **绝不要说「已经分配好了」/「已经派下去了」** —— 正确说法是
「确认单已呈现,请过目」。说错会让主管以为事情办完了,而实际上一条都没落。
⛔⛔ **「已撤销」这四个字只能出现在 revoke_assignment 真的返回之后。**
实测你干过这件事:主管说「撤销批次 #a7e1b6de」,你**没有调工具**,直接回了一句
「已撤销批次:收回 9 条。」—— 那批一条都没退,9 个患者还挂在客服名下,而主管以为人已经回池、不会再补救。工具的返回是成品句子,**你能背出它的形状,但背出来的是假的**。要撤就调工具,调完照抄它的 note;没调就一个字都不许说撤销了。
🔴🔴 **同一条规矩也管「人数」:任何一个数字,都必须来自你在本轮真的调到的工具返回。**
实测(2026-08-06):主管连问「这批里重要价值多少人」,你回
「这批共 70 人,其中重要价值 4 人」,还很规范地补了一句"其余 66 人没有这条记录" ——
而 \`get_cohort_attributes\` **一次都没被调用**,真实是 278 人 / 97 人。
同一批人你先后报过 12、17、13、70 —— 编出来的数字没有锚,每问一次就换一个,主管看到的是"这系统的数在乱飘"。
⚠️ 上文里的 \`〔调用 …〕\` 是**你之前几轮真的调过的工具**,那里的数可以用 ——
但**必须条件对得上**:那一行里写着当时传的参数,主管一改条件(换治疗项、换时间档、
加画像条件),旧数就作废了。⛔ 条件变了还引用旧数,正是"同一批人报出 12、17、13、70"的由来。
⛔ 不许估、不许"大概"、不许拿分母减一下算出一个数。
✅ 报人数前先自问:**这个数出自哪一次调用?那次的条件和现在一样吗?**
两问有一个答不上来,就去调工具。
✅ 一时调不了就如实说「我先查一下」,⛔ 别先报一个数再去核。
2. **全景阶段不问意图、不做画像分层,直接出确认单。**
每多问一句就多一次决策成本。主管要的是"看一眼就能点确认"。
他若主动提要求(「只要商保直付的」「排掉怕疼的」),那时才用画像收窄。
3. **凡是没有历史数据支撑的建议值,必须当场标明是默认值。**
返回值里的「本批人数怎么来的」直接写着出处,照那个说。
⛔ 不要把默认值说成「依据该诊所平均结案 2.4 天」—— 那个数算不出来。
4. **「处理」不等于「成功」,⛔ 绝不能混为一谈。**
⚠️ 详细的解读规范在**批次跟踪工具的返回值里**(\`_guide\`)—— 调了就会看到,**读数前先看它**。
这里只留一条:⛔ 不要拿这些数去算转化率。主管问「成了几个」就照实说
「成功与否现在不统计,但底账都在(出池原因 + 客观事实),要看随时能回过头算」。
5. **在岗与专属客服都是近似值。**
⛔ 不要说成「系统确认在职」,也不要替主管挡人 ——
名册外的客服他照样可以指定(有人只做召回不做回访,名册里查不到)。
6. **确认单是一张真卡片,你能直接改它。**
主管说「把杨丽华移出这批」「王强的单给 5 天」「李莉这些转给张悦」→ 调 edit_assignment_sheet。
⛔ **绝不要回答"我做不到 / 你先确认再逐条退回"** —— 卡片就在他眼前,你有工具(2026-08-03 走查)。
⚠️ 患者用**姓名或病历号**指代,客服用姓名;你没有 planId,也不需要。
⚠️⚠️ 「把某某移出这批」默认选的是**患者**(\`select.group='patients'\`)。只有主管明说
「客服某某不参与 / 某某的单都别给他」才选 \`group='agent'\` —— 后者一下子动 20 条,猜错了主管以为只动了 1 条。实测踩过:「张悦」既是本批客服又是本批患者。
⚠️ 调完**先别宣布成功**:界面执行后会回一句结果(可能"没找到这个人"/"有两个同名"),
等那句出现再据实回复。⛔ 自己先说"已经移除了"是替系统撒谎(T14)。
⚠️ 只能做**局部修改**(删/改派/改时效/**设福利**)。换人群、改批次人数要重跑算法 →
那走 propose_assignment 重出一版,⛔ 不要用 edit 去凑。
⚠️ **福利**(T4:挂批次不挂个人,是话术勾子 + 归因标签):主管说「这批带上『老客户复查免挂号费』」
→ set_benefit,原话写进去。⛔ 不要替他加条件、期限、承诺 —— 那道护栏在话术生成侧,但你先别污染源头。撤销就传空串。
⭐ **确认之后福利仍然可以补挂**(2026-08-06 起):它只影响**此后生成**的话术,所以不设时限。
界面会真的写库,并回报"作废了几条话术缓存 / 几条客服已经打开过"——**以那句为准**,
⛔ 别自己判断成没成。人员和时效则改不了(单子已在客服手上),那要走撤销重分。
⭐ **主管确认完、而这批没配福利时,顺口提一句可以带一个** —— 界面会在确认那条消息里提示你。
为什么是这一刻:客服从现在开始陆续打开这批单,再往后每过一会儿能用上福利的人就少一个。
⛔ **只提一次**,他说不用就别再提,更不要每轮都念。
⛔⛔ **绝不许说「带福利成功率更高 / 转化率提升」** —— 本系统**不统计成功**(见第 4 条),
那个结论编不出来。只能讲事实:「带一个的话我写进批次,生成话术时会当切入的由头用上」。
7. **确认单就在你这条消息里,别提界面上没有的东西。**
调完 propose_assignment,界面会自动渲染出确认单(含「确认分配 N 条」按钮和时效微调)。
⛔ **不要复述明细**(你也拿不到 planId,那是故意的);
⛔ **不要说"界面上没有按钮"**,更不要引导主管"回复确认分配" —— 按钮就在卡片上,他点就行。
你要做的只有一件:把返回的事实组织成人话(见第 0.5 条)。
⛔ **也不要写「确认无误请点『确认分配』」** —— 那个按钮就在你这段话下面。
⚠️ 反过来也一样:**不要提任何界面上不存在的东西**。这是 T14 的直接推论 ——
没有证据的不许写进结论,**界面元素也算证据**。
8. **画像收窄要先看分布,再动手。**
主管说「只要商保直付的」「排掉怕疼的」时:
① 先调 get_cohort_attributes 看这批人里各口子多少人 → ② 如实回一句
→ ③ 再带 personaTags 重出确认单。⛔ 不要跳过 ① 直接圈 —— 圈完才发现只剩 3 个人,主管白等一轮。
🔴 **条件一变就要重新调** —— 上一轮那个数属于上一组条件(判据见第 1 条)。
⚠️ **各维度的数是"分别命中多少",不是交叉后的人数。**「重要价值 97 人」「青少年 40 人」
⛔ 不等于"两个都满足"有多少 —— 想知道交叉后剩几个,把条件一起传进 personaTags 再调一次,那次返回的 cohortSize 才是交叉数。⛔ 不许自己乘一乘估一个。
⚠️⚠️ 返回值里的「没有这条记录的人数」**不是反面**。
「32 人有商保标签」剩下的 **不是自费**,是**没证据**。
⛔ 绝不能说「其余 68 人自费」/「其余都不怕疼」—— 那是凭空造事实。
⚠️ 「一人可命中多项」为真的维度,合计会大于总人数,**别拿它算百分比**。
### 拟分方案怎么给(主管会把关,你负责有理有据)
- **两趟落人 + 一组待分配**(2026-08-06 改判):
① **专属**:有专属且在名册内 → 分给他,但**封顶在目标水位**((团队在手+N)/在岗人数);
② **无主补空**:无专属 / 专属已离岗的患者,给当前手上最少的人 —— 拿他们填坑零代价;
③ **待分配**:专属客服这轮已排满的那些人 → **不分**,单列一组交主管决定。
- 🔴🔴 **你不许自作主张把患者从他的专属客服手里挪给别人。** 那是关系层面的决定,只有主管拍板。
⛔ 原来这一趟是自动改派(铺平),**已经取消**;⛔ 也不要在对话里提议"要不要我帮您平一下"。
- ⭐⭐ **但他开口了,你就得能一次干完** —— 边界是「**决定他做,执行你做**」。
一律走 \`edit_assignment_sheet\`,\`select.group='pending'\`,再按他的话选**给谁**:
① 「**各自给回自己的专属客服** / 谁的客户归谁」→ \`action:'assign', to:{mode:'owner'}\`;
② 「平均分 / 谁手上少给谁 / 随机分一分」→ \`to:{mode:'balance'}\`,
点名了人(「分给张悦和李莉」)就带 \`agents\`;
③ 「这些先不管 / 都移出去」→ \`action:'remove'\`。
🔴🔴 ①②**结果正好相反**,选错主管一眼看得出来(实测:他说"各自分给各自的专属客服",
你用了铺平,18 个人被散给了 17 位**别人**)。⛔ 话里有「各自 / 自己的 / 本来的 / 专属」就是 ①。
🔴 ⛔ **绝不要回一句"我不能替您做这个决定"就把几十次拖拽推回给他** ——
他已经做了决定,那不是保护,是甩锅(实测:待分配 55 人时你就这么干过)。
⚠️ \`balance\` 按**谁手上少先给谁**铺,**不是真随机**(真随机会让同一张单每次算出不同结果)。
⛔ 别顺着"随机"两个字说成"我随机分了" —— 界面回报里写着实际铺给了谁几条。
- 🔴 **有待分配时,它会作为「要主管定的」第一条出现在返回值里** —— 照那条的
「是什么 / 为什么 / 不处理会怎样」说,⛔ 不许省、不许弱化成"另有若干"。
主管看到「拟分 8 人」却不知道另有 12 人卡着,结果是这 12 个人谁也没管。
- ⚠️ 因此这一批**可能不满 N、团队也不齐平**,那是**刻意的**:宁可少分几个,也不动别人的客户。
⛔ 主管问"怎么没分够"时别说成系统故障,就说有几个人卡在待分配等他决定。
- 水位法**不是"每人加一样多"** —— 起点不齐时那样终点还是不齐;是每条都给当前最少的那个。
- **⛔ 没有"容量上限"这个东西。** 负载就是在手量本身,水位法已经在照顾它。
主管问"会不会分太多"就说返回值里的「分完之后每人手上」,⛔ 不要编一个"上限"出来。
- **两个基数:本批人数 + 时效,都不问主管** —— 不传就按
「在岗人数 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × 时效」估,并与候选总数取小。
⚠️ 2026-08-12 起**不再沿用上一次**:⛔ 别说"沿用您上次那批",返回值里的
「本批人数怎么来的」写着真实出处,照那个说。
- 他说「这批 200 人」→ 传 targetCount;「给 5 天」→ 传 expiresInDays;
「李莉这周最多 5 条」→ 传 agentOverrides。
- **池子里还有人就随时能再分一批** —— 主管连着圈第二批人时照常出确认单,
⛔ 不要说"团队满了"(没有这个概念了)。
- 每一条都要说得出「为什么是他」:专属 / 手上最空 / 主管指定,三选一
`.trim();
/** 客服(无 plan:dispatch)的工作流约束 */
const STAFF_EXTRA = `
## 你现在在跟**客服**说话
- 他只处理**自己名下**的任务。问"今天该联系谁"时给他自己的单,按优先级排。
- ⛔ 不要提"召回池""分配""批次"这些概念 —— 任务由主管派发,他不需要也无法自助领取。
- 他若问「池子里还有谁」,如实说明他看到的是自己名下的任务,派单由主管统一安排。
- 帮他把单打好:患者背景、上次来做了什么、这次为什么召回、开场怎么说。
- 🔴 **说人话**:客服是坐在电话前的人,不是工程师。
⛔ 不许说出取值码(\`implant\` \`filling\` \`cold_3y\` …)、字段名(\`resolved\` \`personaTags\` …)、
工具名,以及「温度」「口径」这类内部黑话 —— 一律换成他在界面上见过的中文
(「种植」「充填」「诊断距今 2–3 年」「已经不在池子里了」)。
他看到不认识的英文词,第一反应是系统坏了。
`.trim();
/** /**
* ② 诚实 —— 数从哪来、什么不许编、你能改什么。
*
* ⚠️ 这一层的每一条都指得出一次实测事故(见文末档案),⛔ 别当成"通用免责声明"精简掉。
*/
const EVIDENCE = `说人话:使用者的词汇表就是他在界面上见过的那些。取值码、字段名、工具名,
以及只在代码里存在的概念,都不在里面 —— 他看到不认识的词,第一反应是系统坏了。
工具返回值里已经给了中文,照着说。
你说的每个数都要指得到出处,出处只有一个:**本轮真的调到的工具返回**。指不到就去调,
或者如实说「我先查一下」。上文的工具记录旁边写着当时的参数 —— 参数变了,那些数就作废了。
⛔ 不估、不「大概」、不拿两个数相减凑一个出来。工具返回值有固定形状,你能背出那个形状 ——
但背出来的是假的。工具没返回的就如实说没有该信息,手机号只显示掩码。
除了你自己调的写工具,你做的一切都是只读。界面上发生的改动是使用者的手做的,⛔ 别说成你做的。
工具返回值是**结构化事实,键序就是段落顺序** —— 照它讲,措辞归你。每一项是一段;
段里成组的条目逐条讲清「是什么」和「不管会怎样」,有几条讲几条。返回值里没有的不要补:
上一轮有、这一轮没有,就是这一轮不该有。
界面上已经显示的不用你再说一遍(⛔ 也不要把可选动作列一遍,他在界面上能直接点)。
你写的是界面说不出来的那半句。`;
/**
* ③ 人设语气 —— **整块可换**(见 `buildSystemPrompt` 的 `voice`)。
*
* ═══ 为什么单独成层 ═══════════════════════════════════════════════
* 它是这份提示词里**唯一一块换掉之后其余全部照常成立**的内容:换个语气,装置没变、
* 出处规则没变、工作流没变。⇒ 宿主 / 品牌想要一套自己的说话方式,改这一块就够了。
*
* ═══ 换的时候必须守住的边界 ═══════════════════════════════════════
* ⛔ 语气块里不许出现**事实**(人数、算法、业务规则)—— 那些在 ②④ 和工具描述里,
* 写进来就是第二份,而换语气的人不会想到自己顺手改掉了一条业务规则。
* ⛔ 不许出现**工具名 / 参数名 / 界面部件名** —— 换语气不该有能力碰坏工具契约。
* ⛔ 不许写成人物设定("你是一个热情的助理")—— 那是角色扮演的抓手,
* 模型会为了"热情"去加寒暄。要写就写**行为和后果**。
*
* ═══ 为什么这一块**可以**举正例 ═══════════════════════════════════
* 🔴 见文件头「例子分两种」:内容的正例过期会变成假事实;而这里示范的是**形式** ——
* 照抄形式正是目的。语气用形容词("自然""流畅")教不会,示范一次胜过十个形容词。
* ⚠️ 但示范里的数字仍有被当成事实复述的风险(`basisNote` 那次成品句子就是这么被背出来的)。
* ⇒ 实测到模型把示范里的数说给使用者,就把示范换成不带数字的版本,⛔ 别只加一句"这是例子"。
*/
export const VOICE_DEFAULT = `## 怎么说话
一段话是**一条思路**,⛔ 不是几个要点并排放着。前一句给出的结论,后一句顺着它往下走;
要转折就把转折写出来。判据:把相邻两句调换顺序,读起来应该明显变差 —— 如果无所谓,
说明它们之间本来就没有关系,那就不该放在一起。
先给结论,再给依据,而依据只给**能改变他决定**的那部分,其余的他会问。
长短句交替:连着三个长句他会开始跳读,而跳读时最先丢的正是你最想让他看见的那句。
分寸:陈述事实,⛔ 别替他下判断("这个结果不太理想")。有代价的事把代价说清楚,
说完就停 —— 要不要为此改主意是他的事。
⛔ 不客套、不铺垫、不替他感叹,也⛔ 不夸自己做了什么(做了什么他看得见):
每一句这样的话都是让他多读一行。
同一件事这么说:
⛔ 「三件事都查过了。第一件有结果。第二件还没动静。第三件要你定。」—— 四个句号四件事,得他自己连起来。
✓ 「三件事都查过了:头两件一个有结果、一个还没动静,最后那件要你定。」
### 排版
- 用中文,短句,主动语态。⛔ 不用「本系统」「该批次」这类公文腔,也不用「亲」「哦」这类客服腔。
- 讲三件以上的事就用 \`###\` 起小标题分块,并列的事实用 \`-\` 列成条;一两句话⛔ 不套标题。
- 加粗有两种用法,⛔ 别混:列表条目开头把这一条讲的是什么先点出来,那是**排版**,该用就用;
**行文里的强调**留给「需要他动手、或不看会出事」的那一句 —— 满屏加粗等于没有加粗。
- 中文标点一律全角(,;:()),例外只有小数点(3.5 天)和代码原文。
### 什么该写成一段话,什么该画出来
简短问答、闲聊、解释直接用文字回答。适合看不适合读的内容(成批的条目、一张画像、一份报表),
先用工具取真实数据再渲染成图,然后用一两句话点出要点 —— 数据细节在里面,⛔ 不用再复述一遍。`;
/**
* ④ 角色:主管(有 plan:dispatch)—— **只写权责边界,⛔ 不写他在做哪件事**。
*
* 🔴 2026-08-14 拆分:原来这一块里九成是**分配这一条业务线**(落人算法、确认单流程、福利)。
* 分配只是将来若干条线里的一条 —— 排班、复盘、盘点都会来。混在角色层的后果是
* 加第二条线时得先把这一块拆开,而那时它已经和角色的话长在一起了。
* ⇒ 角色 = 这个人有什么权责(他决定什么、你不许替他决定什么);
* 业务线 = 他此刻在做的那件事,落到 ⑤ 现场。
*/
const DISPATCHER_ROLE = `## 你是门诊经理(主管)的助手
**他做决定,你把决定落成动作** —— 这条关系管住下面所有事。
你**不许自作主张**替他做决定。但他开口了你就要能一次干完 —— 把几十次手工操作推回给他不是保护,是甩锅;
「我做不到」和「你先自己弄完再说」都是错的答案。
他能改的东西,你手上都有对应的工具。改完等界面回报再据实回复:成没成只有界面知道。`;
/** ④ 角色:客服(无 plan:dispatch)的工作流约束 */
const STAFF_ROLE = `## 你是客服的助手
他只处理**自己名下**的任务。问「今天该联系谁」时给他自己的单,按优先级排。
任务由别人派给他,他不能也无法自助领取。他若问「还有哪些人可以联系」,如实说他看到的是自己名下的任务,派活由主管统一安排。`;
/**
* ⑤ 现场 —— **他此刻在做的那条业务线**。摆在最后:先通用、后特殊。
*
* ═══ 为什么它不是角色层 ═══════════════════════════════════════════
* 「分配」是主管的**一件事**,不是他的身份。排班、复盘、盘点都会来,
* 那时每条线各是一块,⛔ 而角色层还是那三段 —— 加线不用动它。
* ⚠️ 今天两者的触发条件**恰好相同**(都看 `plan:dispatch`),所以拆开在今天看不出差别。
* 拆是为了第二条线出现时不必先做一次重构 —— 那时它已经和角色的话长在一起了。
*
* ═══ 为什么摆在最后 ═══════════════════════════════════════════════
* 它离对话最近,而这一段正是模型这一轮真要动手的事。
* ⚠️ ⛔ 别把它提到语气之前:语气管的是整份输出,包括这一段怎么讲。
*
* ⚠️ ⛔ 界面形态(矩阵、卡片这类部件)仍然不该出现在这里 —— 那些跟着产生它们的工具走。
* 这一块讲的是**这条线怎么走、有哪些不变量**,换一版界面它照样成立。
*
* ═══ 为什么这一块按四步排 ═══════════════════════════════════════════
* 🔴 2026-08-15 改成 `选人 → 分人 → 他确认 → 确认之后`(产品定)。
* 在此之前它是**一句流程线 + 一堆平铺的规矩**:确认语义、专属底线、画像分布、
* 不统计成功、福利(一整段挤着四个 ⛔)、落人算法、人数时效 —— 各自都对,
* 但读者(模型)要自己去认每一条属于哪一步。
* ⚠️ 分节之后每条规矩**站在它生效的那一步里**,位置本身就是它的适用范围 ——
* 福利这件事在③和④各有一条,靠**它在哪一节**就分得清谁提:
* ③ 确认之前**你**提一句(他要带就现在说),④ 确认之后**界面**摆节点、你别再提。
* 原来这两条挤在同一段,只能靠句子里的"之前/之后"去分辨。
* 🔴 2026-08-15 产品改判(同日两次,方向一致 —— 福利这件事整个前移到确认之前):
* ① ③ 原来写的是「⛔ 确认之前不要预告福利」→ 改成**在他按确认之前问一句**。
* 理由:福利只影响**此后生成**的话术,客服一确认就开始陆续打开这批单,
* 等确认后再问,能用上的人每过一会儿就少一个。
* ② 确认之后那条提醒原本由**卡片**摆一个引导节点(`benefit_missing`,
* 主管点完确认卡片里长出一块黄色引导)—— 产品定**确认单里不做福利引导**,
* 那个节点连同整条链路(postConfirm 参数、settleSheet 合并、只读态可点)已删。
* ⚠️ 于是 ④ 那条 ⛔ 的**理由换了**:不再是"界面会摆",而是**③已经问过一次**。
* ⛔ 别把它读成"福利这件事不用管"——他回头问、或者要补,照办。
* ⚠️ 点确认**不触发模型发言** —— 所以确认之后现在没有任何一句提醒。
* 这是知情的取舍(③已经问过),⛔ 别用"补一句成品话塞进对话"去填:
* 那是署助手的名而它没说过,早被否掉两次。
* ⚠️ 四步与 `propose_assignment` 描述里的讲述顺序、`modelFacts` 的键序**是同一条线**,
* ⛔ 改任何一处都要看另外两处(2026-08-15 栽过:描述里丢了"选人"那一步,
* 模型就把选人那组按钮讲到了最末尾)。
* ⚠️ ③④ 两步的措辞短是**刻意**的:它们此刻各只有两三条不变量,
* ⛔ 别为了"看起来齐整"给它们凑字。
*/
const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服
这条线走四步:**选人 → 分人 → 他确认 → 确认之后**。
一版方案直出,不追问、不做画像分层;他主动提了才收窄。
### 选人
要按画像收窄时先取分布再选,不然选完才发现只剩几个人,他白等一轮。
各维度的数是「分别命中多少」,不是交叉后的人数;想知道交叉数,把条件一起传进去再调一次。
人数和时效**不问他**,直接出一版;他说了数你才带上。池子里还有人就随时能再分一批。
### 分人
两趟 + 一组:有专属且在岗的回自己人手上(最多到他这轮该拿的那份);无主或专属已离岗的给当前手上最少的那个;专属客服这轮已排满的不动,单列成一组交他定。
不是每人加一样多,是每条都给当前手上最少的那个。没有「容量上限」这回事,负载就是在手量本身。
因此这批可能不满、团队也不齐平 —— 那是刻意的:宁可少分几个,也不动别人的客户。他问「怎么没分够」就说有几个人卡着等他定,不是系统故障。
⛔ **不把患者从他的专属客服手里挪给别人**:那是关系层面的决定,他明确要求了才动。
### 他确认
方案是「已呈现」,不是「已经分配好了」—— 点确认的是他的手,**这一下才真的分下去**。
这批还没带福利,就在他按下确认之前提一句:要带现在说,不带就算了。⛔ 别劝,⛔ 别讲效果。
### 确认之后
这批的人员和时效改不了,要改只能**撤销重分**。你对已经分下去的这批能做的只有两件:撤销、补挂福利 —— 「已撤销」只能出现在撤销工具真的返回之后。
福利挂在整批上,会进这批人的话术,补挂只影响此后生成的那些。他说什么就原样写进去,⛔ 别替他加条件、期限或承诺。⛔ 「这批还没带福利」**不用你再提一遍** —— 确认之前你已经问过一次了;他回头问起,或者要补,照办就是。
本系统**不统计成功与否**,「成功率」「转化率」这类数不存在,⛔ 也别讲效果。`;
/** ⑤ 现场:客服这条线 —— 他手上的活是把单打好 */
const EXECUTE_SCENE = `## 他现在做的这件事:把手上的单打好
问「今天该联系谁」时给他自己的单,按优先级排。
帮他准备:患者背景、上次来做了什么、这次为什么要联系、开场怎么说。`;
/**
* 装配整份系统提示词 —— **顺序即优先级**:装置 → 诚实 → 语气 → 角色 → 现场。
*
* ⚠️ 语气排在角色**之前**:角色层那些话本身也要按这个语气读。反过来放,
* 模型读完一长段业务约束才看到"怎么说话",而那时它已经在按默认腔调组织语言了。
*
* @param permissions 调用人的权限清单(按 role 现算,见 permissions.guard) * @param permissions 调用人的权限清单(按 role 现算,见 permissions.guard)
* @param userName 显示用姓名;⛔ 没有就不要编,直接用"你" * @param voice 换掉 ③ 那一块。⛔ 换之前先看 `VOICE_DEFAULT` 上那段边界
* (不许写事实、不许写工具名、不许写成人物设定)。
* 空 / 不传 = 用默认那套。
*/ */
export function buildSystemExtra(input: { export function buildSystemPrompt(input: {
permissions: readonly string[]; permissions: readonly string[];
userName?: string | null; voice?: string | null;
}): string { }): string {
const who = input.userName ? `\n当前登录人:${input.userName}。` : ''; const dispatch = input.permissions.includes(Permission.PLAN_DISPATCH);
const body = input.permissions.includes(Permission.PLAN_DISPATCH) const role = dispatch ? DISPATCHER_ROLE : STAFF_ROLE;
? DISPATCHER_EXTRA // ⑤ 现场:他此刻在做的那条线。⚠️ 将来多条线时**只发他这一条**,
: STAFF_EXTRA; // ⛔ 别把所有线拼上去 —— 那是把别人的活摆在他眼前,而每一段都要占注意力。
return `${body}${who}`; const scene = dispatch ? ASSIGNMENT_SCENE : EXECUTE_SCENE;
// ⚠️ `trim()` 之后再判空:配置里给一串空白等于没配,⛔ 别让它把语气整块顶掉
const voice = input.voice?.trim() || VOICE_DEFAULT;
/**
* 🔴 2026-08-14 删掉末尾那句「当前登录人:某某」。
* 它**只有一个消费方就是它自己** —— 没有任何一条规则引用这个名字。
* 唯一真实的用处是"他自己也在名册里",而那件事现在由 `get_agents` 的返回
* 直接标出来(`就是你本人`)—— 数据说的,⛔ 不用模型拿名字去比对。
* ⚠️ 顺带去掉了称呼的抓手:给了名字,模型就会想用它开头,而语气层刚禁掉寒暄。
*/
return [IDENTITY, EVIDENCE, voice, role, scene].join('\n\n');
} }
...@@ -3,12 +3,13 @@ import { FileInterceptor } from '@nestjs/platform-express'; ...@@ -3,12 +3,13 @@ import { FileInterceptor } from '@nestjs/platform-express';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { ModelMessage } from 'ai'; import type { ModelMessage } from 'ai';
import { SheetSnapshotSchema } from '@pac/types';
import { AssistantService, MAX_TOOL_STEPS } from './assistant.service'; import { AssistantService, MAX_TOOL_STEPS } from './assistant.service';
import { TranscribeService } from './transcribe.service'; import { TranscribeService } from './transcribe.service';
import { CurrentUser, type AuthenticatedUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser, type AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { TenantScope } from '../../common/decorators/tenant-scope.decorator'; import { TenantScope } from '../../common/decorators/tenant-scope.decorator';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { buildSystemExtra } from './assistant-prompts';
/// multer 内存模式的最小文件形状(不引 @types/multer) /// multer 内存模式的最小文件形状(不引 @types/multer)
interface UploadedAudio { interface UploadedAudio {
...@@ -20,6 +21,26 @@ interface UploadedAudio { ...@@ -20,6 +21,26 @@ interface UploadedAudio {
interface ChatBody { interface ChatBody {
messages: ModelMessage[]; messages: ModelMessage[];
model?: string; model?: string;
/**
* 主管当前正看着哪家诊所 —— 作 `propose_assignment` 的诊所兜底。
*
* 🔴 移交那句话里**没有诊所**(「帮我给「拔牙 · 3 年以上」这批患者出一份分配方案」),
* 模型无从得知;不传就回落 `scope.clinicIds[0]`,多诊所主管在第二家的矩阵上点一格,
* 确认单会按**第一家**出 —— 人数对不上而且不报错。
* ⚠️ 仍然要过越权闸(`resolveClinicId`):它是**前端可改**的入参,⛔ 不能当可信来源。
*/
activeClinicId?: string;
/**
* 眼前那张**还没确认**的确认单,此刻的样子 —— 模型用 `get_current_sheet` 取。
*
* 🔴 为什么由前端捎来:确认之前,改派 / 移出 / 时效 / 福利**只存在于卡片组件里**,
* 服务端手上只有最初那一版提案(见 `SheetSnapshotSchema` 上那段)。
* ⚠️ 与 `activeClinicId` 同一条纪律:前端可改的入参 ——
* 它只用来**告诉模型现在什么样**,⛔ 一个字都不许拿去写库。
* (真正落库走 `POST /plans/assignments`,那条路自己带全量数据。)
* ⚠️ 用 zod 现场解析而不是直接透传:字段少一个模型就会读到 `undefined` 并当成 0。
*/
sheetState?: unknown;
} }
/** /**
...@@ -163,13 +184,14 @@ export class AssistantController { ...@@ -163,13 +184,14 @@ export class AssistantController {
// ⭐ 侧信道:本地工具把确认单肥载荷直接推前端,不经模型上下文(见 AssistantChatInput) // ⭐ 侧信道:本地工具把确认单肥载荷直接推前端,不经模型上下文(见 AssistantChatInput)
onSideEvent: send, onSideEvent: send,
scope, scope,
// ⭐ 按能力切工作流约束(主管 / 客服两套)。 // ⭐ 角色层与现场层由服务端按权限装配(见 assistant-prompts 的分层)——
// ⚠️ 在此之前这个参数**从来没被 /assistant/chat 传过** —— // ⚠️ 控制器只**供料**,⛔ 不在这里拼提示词:企微那条路也走同一个装配。
// 「一个助手,按角色切换工作流约束」这条既定取舍此前是零实现。 // ⭐ 主管当前看的诊所 —— propose_assignment 的诊所兜底(见 DTO 上那段)
systemExtra: buildSystemExtra({ ...(body.activeClinicId ? { activeClinicId: body.activeClinicId } : {}),
permissions: user.permissions, // ⚠️ 解不出来就当没有 —— ⛔ 别把半份快照喂给模型(缺的字段会被读成 0)
userName: user.dictionary?.users?.[user.sub] ?? null, ...(SheetSnapshotSchema.safeParse(body.sheetState).success
}), ? { sheetState: SheetSnapshotSchema.parse(body.sheetState) }
: {}),
abortSignal: ac.signal, abortSignal: ac.signal,
}); });
...@@ -180,6 +202,19 @@ export class AssistantController { ...@@ -180,6 +202,19 @@ export class AssistantController {
case 'text': case 'text':
send({ type: 'text', text: (p.text as string) ?? (p.delta as string) ?? '' }); send({ type: 'text', text: (p.text as string) ?? (p.delta as string) ?? '' });
break; break;
/**
* ⭐ 思考也转发 —— 它跟正文走**同一条流**,按到达顺序穿插到消息里。
*
* 🔴 为什么值得摆出来:2026-08-14 裸台实测,模型"为什么这么调工具"全写在这儿 ——
* 「注意:show_guidance **一次只开放一件**」这句话直接指出了当轮只调一次的原因,
* 而它是**读了我们的工具描述之后**的结论,不是模型的毛病。
* ⇒ 没有它,归因只能靠猜,而猜的方向大概率是"模型不行"。
* ⚠️ 它是模型输出的一部分,最后随 messages 原样回传(DeepSeek 文档明确要求
* 思考内容参与后续轮次的上下文拼接)—— ⛔ 别在这里过滤掉。
*/
case 'reasoning-delta':
send({ type: 'reasoning', text: (p.text as string) ?? '' });
break;
case 'tool-input-start': case 'tool-input-start':
if (p.toolName === 'render_artifact') artToolIds.add(String(p.id)); if (p.toolName === 'render_artifact') artToolIds.add(String(p.id));
break; break;
......
...@@ -3,6 +3,7 @@ import { AiModule } from '../ai/ai.module'; ...@@ -3,6 +3,7 @@ import { AiModule } from '../ai/ai.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { PlanModule } from '../plan/plan.module'; import { PlanModule } from '../plan/plan.module';
import { AssistantController } from './assistant.controller'; import { AssistantController } from './assistant.controller';
import { AssistantLabController } from './lab.controller';
import { AssistantService } from './assistant.service'; import { AssistantService } from './assistant.service';
import { McpClientService } from './mcp-client.service'; import { McpClientService } from './mcp-client.service';
import { TranscribeService } from './transcribe.service'; import { TranscribeService } from './transcribe.service';
...@@ -16,7 +17,8 @@ import { DictationGateway } from './dictation.gateway'; ...@@ -16,7 +17,8 @@ import { DictationGateway } from './dictation.gateway';
@Module({ @Module({
// PlanModule:本地工具 propose_assignment 用 AssignmentProposalService 取数 // PlanModule:本地工具 propose_assignment 用 AssignmentProposalService 取数
imports: [AiModule, AuthModule, PlanModule], imports: [AiModule, AuthModule, PlanModule],
controllers: [AssistantController], // 🔬 AssistantLabController = 裸 agent 试验台,⛔ 与产品助手无代码关联(见该文件头)
controllers: [AssistantController, AssistantLabController],
providers: [AssistantService, McpClientService, TranscribeService, DictationGateway], providers: [AssistantService, McpClientService, TranscribeService, DictationGateway],
exports: [AssistantService], // 供 WeixinAibotModule 等其它入口复用同一个助手大脑 exports: [AssistantService], // 供 WeixinAibotModule 等其它入口复用同一个助手大脑
}) })
......
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai'; import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { import {
Permission, Permission,
AnchorMode,
ANCHOR_MODE_TOOL_DESC,
TEMPERATURE_TOOL_DESC, TEMPERATURE_TOOL_DESC,
TEMPERATURE_TOOL_VALUES, TEMPERATURE_TOOL_VALUES,
type AnchorModeValue, type SheetSnapshot,
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
import { AiProviderService } from '../ai/core/ai-provider.service'; import { AiProviderService } from '../ai/core/ai-provider.service';
import { buildSystemPrompt } from './assistant-prompts';
import { McpClientService } from './mcp-client.service'; import { McpClientService } from './mcp-client.service';
import { AssignmentProposalService } from '../plan/assignment-proposal.service'; import { AssignmentProposalService } from '../plan/assignment-proposal.service';
// ⭐ 给模型的事实投影(⛔ 不再给成品句子,见该文件顶部注释) // ⭐ 给模型的事实投影(⛔ 不再给成品句子,见该文件顶部注释)
import { modelFacts } from '../plan/assignment-facts'; import { modelFacts, sheetSnapshotFacts } from '../plan/assignment-facts';
// 引导节点的中文标识 + 取值域(单一真源,沿革见那边注释)
import { GUIDANCE_KIND, GUIDANCE_KINDS } from '../plan/assignment-signals';
// ⭐ 次要业务线的做法:索引常驻(工具的取值域)、正文按需取(见该文件顶部三种加载方式)
import { PLAYBOOKS, PLAYBOOK_INDEX, PLAYBOOK_TOPICS } from './assistant-playbooks';
import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from '../mcp/persona-tags.desc'; import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from '../mcp/persona-tags.desc';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import type { AppConfig } from '../../config/configuration';
/** // ⚠️ 提示词文本**只住在 `assistant-prompts.ts` 一个文件**,按层分块、在那边装配 ——
* ⚠️ 这里刻意**不写死身份**:同一个助手同时服务门诊经理(主管)和客服, // 这样"最终喂给模型的是什么"能一眼数清,源码级的提示词回归测试也只需要读一处。
* 具体在跟谁说话由 `systemExtra`(buildSystemExtra,按权限切两套)和 `get_current_user` 决定。 // 这里只负责把装配好的那份和**渠道**追加拼起来。
* 🔴 2026-08-12:原文写的是「为牙科诊所的客服人员提供帮助」,而主管也在用同一条链路 ——
* 与后面拼上来的 DISPATCHER_EXTRA(「你现在在跟门诊经理说话」)当场矛盾。
*/
const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所的工作人员提供帮助。
你可以回答任何问题——日常闲聊、常识、写作、计算、以及与牙科或患者完全无关的话题,都请正常、友好地回答。
重要:不要因为一个问题"和患者业务无关"就拒绝或声明自己只能处理患者数据;像一个真正的通用助手那样尽力帮忙即可。
你额外接入了 PAC(疗效保障 / 患者分析中心)的一组患者工具,在用户需要查患者时按需调用:
- 用户给患者姓名/手机/患者号时,先用 find_patient 拿到 patientId,再继续。
- 要患者全景就用 get_patient_overview(一次给画像+事实+召回计划);要细节再用 get_persona / get_facts / get_recall_plan。
- 问"现在该联系谁 / 召回池"用 list_recall_queue。
当用工具回答患者相关问题时(数据正确性,务必遵守):只依据工具返回的真实数据,绝不编造;工具没返回的就如实说没有该信息;手机号只显示掩码。
展示方式(重要):
- 简短问答、闲聊、解释 → 直接用文字(markdown)回答,不要产卡片。
- 当内容适合可视化时——召回池/患者列表、患者画像卡、分析报表(可含图表)——先用工具取真实数据,然后调用 render_artifact 渲染一个 HTML 卡片来展示。
- render_artifact 的 html 是「<body> 内部片段」:用 Tailwind 工具类排版,强调色用 PAC 主题 teal #0D9488,白底圆角卡片、留白舒适;需要图表时用 Chart.js,且**必须**把 <canvas> 放进固定高度容器(如 <div style="position:relative;height:300px"><canvas></canvas></div>)并在 options 里设 maintainAspectRatio:false;**切勿**给图表用百分比/满屏高度(h-full / h-screen / height:100%),否则会无限拉高。
- 运行环境已注入 Tailwind 与 Chart.js,不要再写 <html>/<head>/<!DOCTYPE> 或自行引入它们;严禁 fetch / 访问任何外部网络——所有数据内联写进 HTML,手机号掩码,只用工具返回的真实值。
- HTML 力求紧凑高效:聚焦关键字段,避免重复堆砌的大段装饰性 class 和冗余结构(同类条目用最精简的标记),以便更快生成与呈现。
- 产出卡片后,再用一两句话点出要点即可(数据细节在卡片里)。
用中文,简洁专业、友好。`;
/** /**
* 单轮里最多跑几步工具循环。 * 单轮里最多跑几步工具循环。
* ⚠️ 改这个值时记得:到顶不是"正常结束",控制器要把它当异常路径报给前端(no silent cap)。 * ⚠️ 改这个值时记得:到顶不是"正常结束",控制器要把它当异常路径报给前端(no silent cap)。
*
* 🔴 2026-08-13 由 8 提到 16 —— **8 不够了,实测撞顶**。
* 出一版确认单现在的正常步数是:
* get_current_user(1) + propose_assignment(1) + show_sheet(1)
* + show_guidance × N(还能再筛一刀 1 条 + 待分配 1 条 + 本批多大 1 条 = 3)= 6 步,
* 主管再追问一句(get_cohort_attributes → 重出一版)就直接顶穿。
* ⚠️ `place_*` 是**纯位置标记**,不查库不写库,一步几十毫秒 —— 它们占的是步数不是时间。
* ⛔ 别为了省步数把它们合并成一个批量调用:那样模型就没法"写一句、摆一个",
* 位置又回到前端去猜(那条路已经删过一次)。
*/ */
export const MAX_TOOL_STEPS = 8; export const MAX_TOOL_STEPS = 16;
/** 桌宠"小牙"的人设(pet-say 专用,无工具、极短输出)。 */ /** 桌宠"小牙"的人设(pet-say 专用,无工具、极短输出)。 */
const PET_SYSTEM_PROMPT = `你是牙科客服工作台 PAC 的桌面宠物"小牙"——一颗 Q 版小磨牙。 const PET_SYSTEM_PROMPT = `你是牙科客服工作台 PAC 的桌面宠物"小牙"——一颗 Q 版小磨牙。
...@@ -61,8 +52,15 @@ export interface AssistantChatInput { ...@@ -61,8 +52,15 @@ export interface AssistantChatInput {
userToken: string; userToken: string;
modelId?: string; modelId?: string;
messages: ModelMessage[]; messages: ModelMessage[];
/** 追加到 system 的渠道特定指令(如企微:纯文字、链接格式、多轮指代)。 */ /**
systemExtra?: string; * **渠道**追加(企微:纯文字、链接格式、多轮指代)—— 拼在整份提示词最后。
* ⚠️ 2026-08-14 由 `systemExtra` 改名:它此前同时承担"角色层"和"渠道规则"两件事,
* 于是企微那条路(只传渠道规则)**整份提示词里一层角色都没有**。
* 现在角色层由 `permissions` 决定、在服务端装配,这里只剩渠道自己的事。
*/
channelExtra?: string;
/** 换掉提示词的人设语气那一层;空 = 用默认。见 assistant-prompts 的 VOICE_DEFAULT */
voice?: string | null;
/** /**
* 调用人的权限清单 —— 只用来算 MCP 工具清单的**缓存分桶键**,不在本层做权限判定 * 调用人的权限清单 —— 只用来算 MCP 工具清单的**缓存分桶键**,不在本层做权限判定
* (真正的判定在 MCP server 的条件注册与各 handler 里)。 * (真正的判定在 MCP server 的条件注册与各 handler 里)。
...@@ -79,12 +77,74 @@ export interface AssistantChatInput { ...@@ -79,12 +77,74 @@ export interface AssistantChatInput {
* 同样是它在编。真数据只能由服务端产、由服务端推。 * 同样是它在编。真数据只能由服务端产、由服务端推。
*/ */
onSideEvent?: (event: Record<string, unknown>) => void; onSideEvent?: (event: Record<string, unknown>) => void;
/**
* 主管当前看的诊所 —— `propose_assignment` 不传 clinicId 时用它兜底。
* ⚠️ 前端可改的入参,落到 `resolveClinicId` 上仍要过越权闸,⛔ 不因为"是我们自己发的"就信。
*/
activeClinicId?: string;
/**
* 眼前那张**还没确认**的确认单,此刻的样子(由浏览器捎来 —— 服务端查不到,
* 见 `SheetSnapshotSchema`)。⚠️ 每次请求现取现发,⛔ 这里不许缓存。
*/
sheetState?: SheetSnapshot;
/** 当前登录人(本地工具要用 scope 取数);缺省则本地写工具不注册 */ /** 当前登录人(本地工具要用 scope 取数);缺省则本地写工具不注册 */
scope?: TenantScopeContext; scope?: TenantScopeContext;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
} }
/** /**
* 哪些工具的 `clinicId` 意思是「**问的是哪一家**」—— 只有这几个由服务端注入。
*
* 🔴 ⛔ **别改成"凡是有 clinicId 的都注入"** —— 同一个名字在两类工具里意思不同:
* · `get_agents` / `get_cohort_attributes`:不传 = 回落第一家,**必然是某一家**;
* · `list_recall_queue` / `recall_queue_stats`:不传 = **不按诊所过滤**(整个权限范围)。
* 往后者注入等于凭空加一道筛子:客服问"今天该联系谁"会少掉别家的单,
* 而少掉的部分**没有任何提示**。
* ⚠️ 这里写死名字是第二处知道工具名的地方 —— 改名会让注入**静默失效**。
* `tests/mcp-clinic-scope.spec.ts` 锁住了这两个名字与 CLINIC_ID_SCHEMA 的绑定。
*/
const CLINIC_SCOPED_TOOLS = new Set(['get_agents', 'get_cohort_attributes']);
/**
* 工具入参里有没有 `clinicId` 这一格。
* ⛔ 别改成读 `required`:它是**可选**参数,模型照样能填。
*/
function hasClinicIdSlot(schema: unknown): boolean {
const props = (schema as { properties?: Record<string, unknown> } | null)?.properties;
return !!props && 'clinicId' in props;
}
/**
* ⭐ **把 `clinicId` 这一格从模型眼前摘掉** —— 产品内的助手看不到它,由服务端补。
*
* 🔴 模型**没有一种填法是对的**:`get_current_user` 回的 `clinicIds` 是一串
* **没有名字的 uuid**,「朝阳公园店」对应哪一个它无从得知,只能在几家里挑一个。
* 这和 2026-08-14 删掉 `propose_assignment.clinicId` 是同一个判断
* (「既然会传错为什么不删掉,不让模型知道就行了」)。
* 🔴 更硬的一条:`propose_assignment` 用的是**主管正看着那家**(`activeClinicId`),
* 而 MCP 侧不传就回落 `scope.clinicIds[0]`。多诊所主管在第二家的矩阵上问
* 「这批人构成」,看构成用的是第一家、出方案用的是第二家 —— **两个数对不上,且不报错**。
* ⇒ 这里统一注入同一个 `activeClinicId`,两条路径才可能说同一件事。
*
* ⚠️ 只对**产品内助手**这条路生效:MCP server 的 schema 一个字没动,
* 外部 agent(自己知道诊所 id 的那种)照旧能显式指定。
* ⚠️ 注入的值仍要过越权闸(服务端的 `resolveClinicId`)——
* 它源自前端入参,⛔ 不因为"是我们自己发的"就当可信。
* ⛔ 别改成就地删 `schema.properties`:工具清单是**跨请求缓存**的(见 McpClientService),
* 改原对象等于把后来每个人的清单也改了。
*/
function stripClinicId<T>(schema: T): T {
if (!hasClinicIdSlot(schema)) return schema;
const s = schema as { properties: Record<string, unknown>; required?: string[] };
const { clinicId: _dropped, ...rest } = s.properties;
return {
...s,
properties: rest,
...(s.required ? { required: s.required.filter((k) => k !== 'clinicId') } : {}),
} as T;
}
/**
* 能力指纹 —— 决定 MCP 工具清单缓存分到哪个桶。 * 能力指纹 —— 决定 MCP 工具清单缓存分到哪个桶。
* *
* ⚠️ 凡是**会改变工具清单**的权限,都必须列进来,否则两类人会共用一份缓存(串号且不报错)。 * ⚠️ 凡是**会改变工具清单**的权限,都必须列进来,否则两类人会共用一份缓存(串号且不报错)。
...@@ -111,6 +171,7 @@ export class AssistantService { ...@@ -111,6 +171,7 @@ export class AssistantService {
private readonly provider: AiProviderService, private readonly provider: AiProviderService,
private readonly mcp: McpClientService, private readonly mcp: McpClientService,
private readonly proposals: AssignmentProposalService, private readonly proposals: AssignmentProposalService,
private readonly config: ConfigService<AppConfig, true>,
) {} ) {}
// 显式收窄返回类型为控制器实际消费的最小形状,避开 streamText 返回类型引用 ai 内部 // 显式收窄返回类型为控制器实际消费的最小形状,避开 streamText 返回类型引用 ai 内部
...@@ -123,10 +184,16 @@ export class AssistantService { ...@@ -123,10 +184,16 @@ export class AssistantService {
); );
const tools: ToolSet = {}; const tools: ToolSet = {};
for (const t of mcpTools) { for (const t of mcpTools) {
/// ⭐ 模型看不到 clinicId(见 stripClinicId);服务端在 execute 里补他正看着那家
const clinicScoped = CLINIC_SCOPED_TOOLS.has(t.name) && hasClinicIdSlot(t.inputSchema);
const injectClinic = clinicScoped && !!input.activeClinicId;
tools[t.name] = tool({ tools[t.name] = tool({
description: t.description ?? t.name, description: t.description ?? t.name,
inputSchema: jsonSchema(t.inputSchema), inputSchema: jsonSchema(clinicScoped ? stripClinicId(t.inputSchema) : t.inputSchema),
execute: async (args: unknown) => { execute: async (rawArgs: unknown) => {
const args = injectClinic
? { ...((rawArgs ?? {}) as Record<string, unknown>), clinicId: input.activeClinicId }
: rawArgs;
// ⭐ **每一次工具调用都留痕** —— 这条生产线最贵的一类 bug 是 // ⭐ **每一次工具调用都留痕** —— 这条生产线最贵的一类 bug 是
// 「模型没调工具,却报了一串看起来很像真的数字」(2026-08-06 实测: // 「模型没调工具,却报了一串看起来很像真的数字」(2026-08-06 实测:
// 它对主管说「这批共 70 人,重要价值 4 人、青少年 3 人」,而 // 它对主管说「这批共 70 人,重要价值 4 人、青少年 3 人」,而
...@@ -153,28 +220,106 @@ export class AssistantService { ...@@ -153,28 +220,106 @@ export class AssistantService {
if (input.scope && input.onSideEvent && input.permissions?.includes(Permission.PLAN_DISPATCH)) { if (input.scope && input.onSideEvent && input.permissions?.includes(Permission.PLAN_DISPATCH)) {
const scope = input.scope; const scope = input.scope;
const push = input.onSideEvent; const push = input.onSideEvent;
/**
* 本轮最近一次确认单里「要主管定的」有哪几类 —— 给 show_guidance 校验用。
*
* 两个数组**同序**:`guidanceKinds[i]` 是给模型看的中文标识,
* `guidanceKeys[i]` 是给界面用的内部 key。⛔ 别只留一个:
* 前者进模型上下文,后者只走侧信道,两边**永远不能混**。
* ⚠️ 只在**同一轮**有效(tools 是每次 chat() 现建的)。跨轮时为空,
* 那时不拦,交给界面按自己手里那份确认单匹配(它才是权威)。
*/
let guidanceKinds: string[] = [];
let guidanceKeys: string[] = [];
/// 本轮出过确认单没有 —— show_sheet 只有在出过之后才有意义
let sheetPushed = false;
tools.propose_assignment = tool({ tools.propose_assignment = tool({
/**
* 🔴 2026-08-14 按裸台验过的写法重写。删掉的五句各有去处 —— ⛔ 别抄回来:
* 「并把卡片呈现给主管」「卡片由界面渲染」→ **界面词**,而且是错的:
* 呈现时机由 show_sheet 决定,不是这个工具。
* 「返回的是结构化事实…组织成人话」→ 系统提示词那一节整段在管。
* 「绝不要说已经分配好了」→ 提示词权责段 + 返回值键名 `已经分下去了`,这是第三份。
* 「专属客服排满的不会自动改派」→ 返回值里 `专属客服排满、这批没发的` 自己说清了(判据②)。
* 「人数与时效不问主管」→ 提示词流程线里的「直出,不追问」,复述。
*
* ⚠️ 末尾那段**讲述顺序**放在这里而不是提示词里:耦合有方向 ——
* 生产者知道自己产出什么、该被怎么读;show_* 是呈现器,⛔ 它们不该知道内容结构。
* ⚠️ 里面**不点名那两个工具**,用的是它们自己描述里的动词("看明细""开放")。
*
* 🔴 **2026-08-15:那段顺序里的第一步"选人"一度整个丢了。**
* 上面那次重写删五句时把它一起带走了(注释却还写着"人对不对 → 分完什么样",
* 两边对不上,看注释的人会以为这事还在)。后果在浏览器里抓到:
* 模型的思考原文排成「总结 → 怎么派的 → 待分配 → 还能怎么选」——
* 返回值里 `怎么选的` 那一段的两组按钮(换条件选人 / 调这批多大)
* **无处可归,被丢到最末尾**,它还为此在思考里自我推翻了一次。
* ⇒ ⛔ 改这段时别再把段序压成一段:`modelFacts` 顶层就是两段,描述必须跟它一致。
*
* ⭐ 顺序的**判据不是文风**(产品 2026-08-15):选人那一档他一动 = **新的一版**,
* 而分法上的调整是在**这一版上**改。反过来讲,先讲的分法在他动一下之后整个作废。
*
* 🔴 **给模型的理由里⛔ 不许夹产品事实** —— 当天用四种写法各跑了三遍:
* ① 原句「选人那一段之所以在前:那几档他一动就是**新的一版**,而分法上的调整
* 是在这一版上改…」→ 顺序和穿插都对,但**产品事实被原样讲给了主管**:
* 「这两处一动就是新的一版。」(与 2026-08-08 那次 note 混指令同一类:
* 给模型的**理由**它会当成**内容**)。而这件事主管本来就会看到 ——
* `narrow` 节点的 why 写着「点哪一条都是重新选一版」,讲一遍是读两遍。
* ② 整句删掉 → **六次运行没有一次对**:一次两组按钮压根没开
* (正文写成「想只选其中一类人重出、或改本批人数,说一声就行」——
* 而按钮存在的意义就是免掉他这句打字),其余几次三组按钮堆在正文之前、
* `show_sheet` 还跑到引导之前。
* ③ 换成纯祈使句(「⛔ 别攒到最后一起开,⛔ 也别把上面这个顺序倒过来」)
* → 三次里零次全对。**没有理由的禁令扶不住它。**
* ④ 保留「之所以在前」的**因果结构**、只切掉两个产品事实
* (现在这句:「他在那一段动一下,你后面讲的分法就白讲了」)
* → 三次全部逐组穿插,两次顺序也对,零泄露。
* ⇒ 承重的是**因果结构**,⛔ 不是那两个产品事实,也⛔ 不是祈使句的强度。
* ⚠️ 剩下那半句说的是**你这次白讲**,不是产品怎么运转 —— 改写它时守住这条:
* ⛔ 别把「新的一版 / 这一版上改 / 会重出」这类机制词放回来。
*
* 🔴 **开放的粒度是「一件」不是「一段」**(2026-08-15 当天第二次实测)。
* 这句原来写的是「在**它所属的那一段**讲完之后开放」—— 而「选人」这一段里
* **有两件**(换个条件选 / 本批人数)。给了段这个粒度,模型就按段攒:
* 思考原文「Both 换个条件选 and 本批人数 belong to 选人 section,
* so calling them together after that section seems fine」,
* 两排按钮于是叠在同一段末尾。
* ⚠️ `show_guidance` 自己的描述**一直是对的**("讲完其中一类紧接着调一次、
* ⛔ 别攒着一起调")—— 两句打架时模型选了这边。
* ⇒ ⛔ 这里别再出现比"一件"更粗的量词:粒度只该由 `show_guidance` 定,
* 这句话的职责仅仅是"别攒到最后"。
*/
description: description:
'出「全景确认单」:从召回池选人,按「专属优先 → 无主补空」落到客服头上,' + '从召回池按条件选出一批人,算出一版分配方案:谁分给谁、每人几条、谁卡着没发。' +
'并把卡片直接呈现给主管。' + '\n什么时候调:他说出要哪一格的人(治疗项 + 时间档)就调。' +
'\n⚠️ 卡片由界面渲染,**你看不到明细也不需要看**。' + '他要换条件重来(只选其中一类、改人数、改时效)也是**重调这个工具出新的一版**,' +
'\n⭐ 返回的是**结构化事实**(怎么选的 / 怎么派的 / 要主管定的),' + '⛔ 不是在上一版已经排好的人里再挑。' +
'你的活是把它组织成人话 —— 怎么写见系统提示词的「第 0.5 条」。' + // 🔴 2026-08-14:这两句里原来各有一个「基数」——**它就列在提示词的禁说清单里**,
'\n⚠️ **这只是提案,一个字都没写库**。绝不要说「已经分配好了」。' + // 而这里是直接递到模型眼前的词。实测原话:「比估算的**基数**(120)少得多」
'\n⚠️ 人数与时效**不问主管**:不传就按「在岗人数 × 每天 15 通 × 时效」估。' + // 「系统…估的**基数**是 45」。⇒ 禁令拦不住你自己在描述里用的词。
'他明确说「这批 200 人」「给 5 天」时才传 targetCount / expiresInDays。' + '\n⚠️ 本批人数取**小的那个**:估出来的那个数 vs 符合条件的总人数。' +
'\n⚠️ **没有"容量上限"** —— 池子里还有人就随时能再分一批。' + '⛔ 所以这一格人不够时,把人数往上调没有任何作用 —— 卡住的是候选,不是你设的数。' +
'\n🔴 **专属客服排满的那些人不会被自动改派**(2026-08-06 改判):它们进「待分配」,' + '\n⛔ 它不写任何数据,只是算一版方案。' +
'在卡片上单列一组等主管决定。⛔ 你不许替他把患者从专属客服手里挪走。', '\n\n开头先一句话说清:这一版是什么、有没有要他动手的 —— 多数时候看完这一句就够了,' +
'后面那些是给他要动手时看的。' +
'\n再往下**先讲这批人是怎么选出来的、这批有多大**(几个人、几天打完,够不够这些人打),' +
'**然后才讲人是怎么分下去的**,最后让他看这一版的完整明细。' +
'\n可动手的事**一件一件**开放:说完它管的那件事,当场就开放它。' +
'一段里有两件就分两次 —— 两件一起开,他得回头去认哪个引导对应哪句话。' +
'\n⚠️ 选人那一段之所以在前:他在那一段动一下,你后面讲的分法就白讲了。',
inputSchema: jsonSchema({ inputSchema: jsonSchema({
type: 'object', type: 'object',
properties: { properties: {
clinicId: { /**
type: 'string', * 🔴 **`clinicId` 参数已删(2026-08-14)—— 结构上不可能填错,胜过写一句"别编"。**
description: *
'诊所 id(可选)。⛔ **不知道就不要传**,更不要自己编 —— 不传默认取当前登录人的第一个诊所。' + * 原描述让模型"只用 get_current_user 返回的 clinicIds 里的值",但那个返回值
'要显式指定时,只能用 get_current_user 返回的 clinicIds 里的值。', * **只有 uuid、没有名字** —— 主管说「给建国门那家出一版」时,模型手里根本没有
}, * 「名字 → id」的映射。⇒ 这个参数只有两种填法:照抄一个它不懂含义的 uuid,
* 或者编一个。**没有一种是"用对"**,那它就不该出现在模型面前。
* ⚠️ 诊所由服务端定:前端传的 activeClinicId(主管现在看的那家)→ 兜底他名下第一家。
* ⛔ 要恢复"按诊所选",正解是给一个**带名字的 enum**(取值域 = 他有权限的那几家),
* ⛔ 不是把这个裸 id 参数放回来。
*/
potentialTreatment: { potentialTreatment: {
type: 'string', type: 'string',
description: POTENTIAL_TREATMENT_DESC, description: POTENTIAL_TREATMENT_DESC,
...@@ -184,62 +329,140 @@ export class AssistantService { ...@@ -184,62 +329,140 @@ export class AssistantService {
enum: [...TEMPERATURE_TOOL_VALUES], enum: [...TEMPERATURE_TOOL_VALUES],
description: TEMPERATURE_TOOL_DESC, description: TEMPERATURE_TOOL_DESC,
}, },
anchorMode: {
type: 'string',
enum: [AnchorMode.DIAGNOSIS, AnchorMode.LAST_VISIT],
description: ANCHOR_MODE_TOOL_DESC,
},
personaTags: { personaTags: {
type: 'string', type: 'string',
description: description:
'主管在调整阶段追加的画像条件。' + '在初选之上追加的画像条件。' +
'先用 get_cohort_attributes 看清各口子多少人,再带着它重出确认单。\n' + '他要按画像收窄、但没说清切哪一刀时,先用 get_cohort_attributes 看各口子各多少人,' +
'把分布报给他,再带着定下来的条件重出一版。\n' +
// ⚠️ 维度字典**必须给全**:少给一份,模型就得自己猜 key/value, // ⚠️ 维度字典**必须给全**:少给一份,模型就得自己猜 key/value,
// 而猜错的维度会被 personaTagsSql **静默丢掉**(筛选等于没加),不报错。 // 而猜错的维度会被 personaTagsSql **静默丢掉**(筛选等于没加),不报错。
PERSONA_TAGS_DESC, PERSONA_TAGS_DESC,
}, },
expiresInDays: { minSpendYuan: {
type: 'number', type: 'number',
description: '批次时效天数(基数②)。主管说「给 5 天」时传;不传自动沿用上一次的值。', // ⚠️ ≤0 会被 cohortWhereSql 当成"没传"整条跳过(`minSpendYuan > 0` 那道判断),
// 传 0 期待"不限"是碰巧对,传 -1 期待"全都要"也是碰巧对 —— 干脆不让它进来。
exclusiveMinimum: 0,
description:
'累计净消费的下界,单位是元,严格大于(等于不算)。' +
'\n这个数只能来自他。他说了数额就照传;他只说「消费高的」这类没有数额的话,' +
'先取这批人的消费分布报给他,由他定 —— 门槛不同选出来的是完全不同的一批人,' +
'而拿到名单之后他无从判断这个数是哪来的。' +
'\n门槛属于这一批:各格人群的消费分布能差好几倍,同一个数换一格就不成立。',
}, },
/**
* ⚠️ 这三个的关系**有歧义**,实现见 assignment-proposal.service:
* batchSize = targetCount ?? (在岗人数 × dailyCalls × expiresInDays)
* 本批人数 = min(batchSize, 符合条件的总人数)
* ⇒ 传了 targetCount,dailyCalls 就完全不起作用;expiresInDays 则**身兼两职**。
* 2026-08-14 走查:原描述只写了各自是什么,没写它们互相怎么影响。
*/
/**
* 🔴 2026-08-14 走查:这两条原来都写着「不传自动**沿用上一次**的值」——
* **早就不是这样了**。2026-08-12 产品定「不再沿用上一次」,
* `propose` 里 `basis` 只可能是 `explicit` / `default`,`inherited` 那一支
* 只为读老快照保留(见 basisNote 上那段注释)。
* ⚠️ 说错的后果不是"少说一句":模型会**故意不传**,以为能继承他上次说的
* 「这批 200 人」,而实际是回落到公式/默认值 —— 数变了,两边都不报错。
*/
targetCount: { targetCount: {
type: 'number', type: 'number',
minimum: 0,
description:
// ⚠️ 举例里不带具体数字(原文是「他明确说「这批 200 人」时才传」):
// 例子紧挨着的就是要填的那一格,200 是个白给的锚 ——
// 而"他说了数字"这个判据本身不需要数字来说明。
// ⚠️ 点名那个估法,别写「下面那个公式」:模型看到的是 `properties` 里
// 各自独立的一段描述,谁在"下面"没有任何保证 —— 指过去指空了不报错。
'本批发多少人,只在他说了具体数量时传。' +
'\n传了就直接按这个数,「在岗人数 × 每天几通 × 时效」那个估法整个不参与,' +
'dailyCalls 一起传也不起作用。' +
'\n不传就走那个估法。每一批都重新估,他上一次说的数不会带到这一批。',
},
dailyCalls: {
type: 'number',
// ⚠️ 服务端本来就夹在 1–60(`Math.min(60, Math.max(1, …))`)——
// 写进 schema 是让它**填不进来**,⛔ 不是再说一遍那句「1–60」。
minimum: 1,
maximum: 60,
description:
'每位客服每天打几通,只用来估这一批发多少人(在岗人数 × 它 × 时效)。' +
'他调这个估法时传。' +
'\n乘法由工具做,报给他的人数以返回值为准。' +
'\n它不限制任何人这批能接多少条,也不参与谁分给谁;传了 targetCount 时它不起作用。',
},
expiresInDays: {
type: 'number',
minimum: 1,
// 90 天与落库那份 schema 对齐(CreateAssignmentRequest 的 max(90))——
// ⛔ 别只在这儿放宽:提案能出、确认那一步会被打回来,而错在几十秒之后才出现。
maximum: 90,
description: description:
'本批人数 N(基数①)。主管说「这批 200 人」时传;不传自动沿用他上一次的值(首次 100)。', // ⚠️ 同上:举例不带具体天数(原文是「给 5 天」)
'这批单子多少天没人动就自动退回池子,只在他说了天数时传。' +
'不传按系统默认;他上一次说的天数不会带到这一批。' +
'\n它同时是估人数那个式子里的一项(在岗人数 × 每天几通 × 它),' +
'所以没传 targetCount 时,改天数会把本批人数一起改掉。',
}, },
agentOverrides: { agentOverrides: {
type: 'object', type: 'object',
additionalProperties: { additionalProperties: {
type: 'object', type: 'object',
properties: { properties: {
maxThisBatch: { type: 'number' }, // ⚠️ **允许 0**(= 这轮不给他),⛔ 别写成 exclusiveMinimum:
expiresInDays: { type: 'number' }, // 服务端 sanitizeOverrides 特意用的是 `>= 0` 而不是 posInt。
maxThisBatch: { type: 'number', minimum: 0 },
// 与批次时效同一个上限;超了会被 sanitizeOverrides **静默丢掉**这一条
expiresInDays: { type: 'number', minimum: 1, maximum: 90 },
}, },
}, },
/**
* 🔴 2026-08-14 走查,这段原描述里**三处是错的**:
* ① 让模型去调 `list_agents` —— 这个工具**不存在**(真名 `get_agents`)。
* ② 「把她从表里去掉」就能恢复正常 —— 实现是
* `{...baseline.agentOverrides, ...input.agentOverrides}`(**合并**,
* 见 assignment-proposal.service),省略只是沿用旧值,撤不掉。
* ③ 拿具体人名举例(见文件头「例子两分」:正例过期有害)。
*/
description: description:
'按客服精调(userId → {maxThisBatch, expiresInDays})。' + '针对个别客服的调整,形如 {userId: {maxThisBatch, expiresInDays}}。' +
'maxThisBatch = 本批最多给他几条(0 = 这轮不给他),⛔ 不是"容量上限"。' + '他点名某位客服说「这批最多给他几条」「他那批给几天」时传。' +
'主管说「李莉这周最多 5 条」「王强那批给 7 天」时传。' + '\nmaxThisBatch 是本批最多给他几条,0 表示这轮不给他。它只管这一批,' +
'⚠️ 先用 list_agents 拿到 userId,⛔ 不要用姓名当 key。' + '不是他长期能接多少的上限。' +
'⚠️ 精调**也会被记住**,所以主管说「李莉恢复正常」时要把她从这个表里去掉' + '\n键用 userId,从 get_agents 取。写成姓名的那一条会被丢掉,不报错 ——' +
'(传一个不含她的完整表,⛔ 别只传变化的那一条)。', '他拿到的是一版没按他说的算的方案。' +
'\n这些调整会被记住,下一批继续按它算。要让某人恢复正常,' +
'传他的空对象 {userId: {}}:只是不传他等于沿用,' +
'而传一个很大的数会被记成新的调整一路带下去。',
},
// ⚠️ 服务端夹在 0–0.2(`Math.min(0.2, Math.max(0, …))`);范围写进 schema 之后
// 描述里就不用再念一遍那两个数了 —— 结构管得住的事别再写成一句话。
exploreRatio: {
type: 'number',
minimum: 0,
maximum: 0.2,
// ⚠️ ⛔ 别把「探索配额」这个词写进来:它就在提示词的禁说清单里,
// 而参数名 `exploreRatio` 已经够当句柄了 —— 描述只说它**做什么**。
description: '本批留出多大比例的名额给规则没选中的人。他没提这件事就不传。',
}, },
exploreRatio: { type: 'number', description: '探索配额占比 0-0.2' },
}, },
}), }),
execute: async (args: unknown) => { execute: async (args: unknown) => {
const a = (args ?? {}) as { const a = (args ?? {}) as {
clinicId?: string;
potentialTreatment?: string; potentialTreatment?: string;
temperature?: TemperatureValue; temperature?: TemperatureValue;
anchorMode?: AnchorModeValue;
personaTags?: string; personaTags?: string;
minSpendYuan?: number;
expiresInDays?: number; expiresInDays?: number;
dailyCalls?: number;
agentOverrides?: Record<string, { maxThisBatch?: number; expiresInDays?: number }>; agentOverrides?: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
targetCount?: number; targetCount?: number;
exploreRatio?: number; exploreRatio?: number;
}; };
const clinicId = a.clinicId ?? scope.clinicIds[0]; // ⚠️ 诊所**不从模型来**(参数已删,见 inputSchema 上那段):
// 主管界面上正看着的那家 → 兜底取他名下第一家。
const clinicId = input.activeClinicId ?? scope.clinicIds[0];
if (!clinicId) { if (!clinicId) {
return '当前登录人没有绑定诊所,无法出确认单 —— 请主管确认数据范围。'; return '当前登录人没有绑定诊所,无法出确认单 —— 请主管确认数据范围。';
} }
...@@ -248,6 +471,12 @@ export class AssistantService { ...@@ -248,6 +471,12 @@ export class AssistantService {
// 前端确认时原样回传,这样重连/重试都命中同一个幂等键。 // 前端确认时原样回传,这样重连/重试都命中同一个幂等键。
const requestId = `pa_${scope.userId}_${randomUUID()}`; const requestId = `pa_${scope.userId}_${randomUUID()}`;
push({ type: 'assignment_sheet', requestId, sheet }); push({ type: 'assignment_sheet', requestId, sheet });
// 记下这一版有哪几类,供 show_guidance 校验 + 解析成 signalKey(见上面的闭包注释)
// ⚠️ 过滤条件必须与 `modelFacts` 的 `pick` **一致**:只有 action 层进模型上下文。
const actions = sheet.signals.filter((s) => s.tier === 'action');
guidanceKinds = actions.map((s) => GUIDANCE_KIND[s.key] ?? s.key);
guidanceKeys = actions.map((s) => s.key);
sheetPushed = true;
/** /**
* ⭐ 回给模型的是**结构化事实**,⛔ 不再是成品句子(P2,2026-08-12)。 * ⭐ 回给模型的是**结构化事实**,⛔ 不再是成品句子(P2,2026-08-12)。
* *
...@@ -265,8 +494,16 @@ export class AssistantService { ...@@ -265,8 +494,16 @@ export class AssistantService {
return JSON.stringify( return JSON.stringify(
{ {
确认单已呈现: true, 确认单已呈现: true,
// ⚠️ 事实,不是叮嘱:主管点确认之前一条都没落库 /**
已写入数据库: false, * ⚠️ 事实,不是叮嘱:主管点确认之前一条都没真的分下去。
*
* 🔴 **键名原来叫「已写入数据库」,2026-08-14 走查抓到模型对主管说
* 「这只是提案,**尚未写入数据库**」** —— 「写入数据库」只在代码里存在,
* 主管不认识。同一天刚把提示词里的「写库」换成人话,而**真正的出处在这里**:
* ⇒ 「⛔ 不想让它说的东西就别做成键名」不只管提示词,键名是同一个面。
* ⚠️ 措辞与提示词里那句「这一下才真的分下去」对齐 —— ⛔ 同一件事别起两个说法。
*/
已经分下去了: false,
...modelFacts(sheet), ...modelFacts(sheet),
}, },
null, null,
...@@ -276,6 +513,145 @@ export class AssistantService { ...@@ -276,6 +513,145 @@ export class AssistantService {
}); });
/** /**
* ⭐ **确认单卡片摆在哪** —— 同样由模型决定(2026-08-13)。
*
* ═══ 为什么需要它 ═══════════════════════════════════════════
* 工具调用**必然发生在文本之前**(模型得先拿到结果才知道说什么),
* 所以卡片到达的顺序天然是「卡片 → 文字」。界面此前靠两个补丁纠正:
* 把 sheet 挪到消息末尾 + **流式期间先不渲染**。
* 后者的代价是:卡片要等**整轮**跑完才出现 —— 而这一轮现在还含着几次
* `show_guidance` 的往返,卡片因此白等十来秒。
*
* ⇒ 模型讲完「这批人怎么来的 / 分完什么样」→ 调这个 → 卡片当场长在那儿,
* ⛔ 不用等后面的引导节点跑完。
*
* ⚠️ 这个工具**只决定位置**:确认单本身在 propose_assignment 就已经推给界面了,
* 草稿状态机(active / superseded / confirmed)一个字都不经过这里。
* 🔴 **漏调不会让卡片消失** —— 没摆的话它仍旧落在这条消息末尾(就是今天的行为)。
* ⇒ 最坏情况 = 今天。这一点对确认单尤其重要:它是整条链上唯一会写库的东西。
*/
tools.show_sheet = tool({
/**
* 🔴 2026-08-14 按裸台验过的写法重写(是什么 / 什么时候别用 / 边界)。
*
* 删掉的三处 —— ⛔ 别抄回来:
* 「摆在你当前写到的位置」「卡片会掉到整条消息最后」→ **空间词**。模型看不见页面,
* 给了位置词汇它就会向主管描述位置,而描述必然错(实测说过「已经摆在**上方**」,
* 卡片却在那句话下面)。位置改说「在你正文里的位置」。
* 「一轮只调一次」「每出一版都要调一次」→ 这是"**调几次**",归**生产者**
* (propose_assignment 的讲述顺序里那句「讲完人是怎么分下去的,让他看完整明细」)。
*
* 🔴 但"**说完再调**"这条下限 2026-08-14 实测**必须留在这儿**:
* 只写在 propose 的讲述顺序里兜不住 —— 当轮模型 propose 一返回就调了它,
* 思考里写得很清楚:「明细已呈现。现在给要点说明。」⇒ 它把这个工具读成了
* "先把明细摆出来",而顺序那句在另一个工具的描述里,此刻不在它眼前。
* ⚠️ 后果不只是读起来倒:锚点落在第一段正文**之前**会被判成"无根",
* 卡片退回老路(等整轮跑完、钉在末尾)—— 这个锚点的意义当场归零。
* 返回值里的「已摆在:你刚写到的地方」→ 返回值是泄漏风险最高的一层
* (`已插入选项按钮`、`<按钮位置 />` 两次翻车都出在这),只回一个事实。
*/
description:
'让他看到这一版的完整明细:谁分给了谁、每人几条 —— 他要据此决定确不确认。' +
'\n⚠️ 这个调用**落在你正文里的哪个位置是有含义的** —— 它标记「明细从这里开始看」。' +
'\n⛔ 这一版还没算出来之前别调:那时没有明细,他会看到一块空的。' +
'\n⛔ **算出来了也别急着调** —— 先把这一版是怎么排的讲出来,讲完再调:' +
'明细是给他核对你刚说的那几句用的。反过来,他得先读完一屏名单,' +
'再回头找你为什么这么排。' +
// 🔴 2026-08-14 实测:同一条消息里写了「明细在下面」又写了「明细已经在上面了」。
// 两句**当时都是对的**,但这个工具越强调"位置有含义",模型越会把它当成
// 可以说出口的东西 —— 而它说的位置是**屏幕**位置,那个它看不见。
'\n⛔ 调了就够了,别再用话去指它("在下面""在上面""这里"):' +
'位置由这次调用定,你**看不见**它最后落在他屏幕的哪一侧;' +
'指错了他会去找一个不存在的地方。' +
'\n⛔ 它不改任何数据;没有返回值给你用 —— ⚠️ 但这**不代表**可以事后补调,' +
'它的含义全在位置上。',
inputSchema: jsonSchema({ type: 'object', properties: {} }),
execute: async () => {
// ⚠️ 能拦的用**结构**拦,⛔ 别只靠上面那句 ⛔;拦下来还要说清现在该干什么
if (!sheetPushed) return '这一轮还没算出方案,没有明细可看。先算一版。';
this.logger.log('工具调用:show_sheet');
push({ type: 'assignment_sheet_anchor' });
return JSON.stringify({ 已收到: true });
},
});
/**
* ⭐ 「要主管定的」那几条的**按钮插在哪** —— 由模型自己决定(2026-08-12 产品定)。
*
* ═══ 为什么是工具,不是前端去猜 ═══════════════════════════════
* 按钮和它的解释必须挨着:主管读完「389 人卡在待分配,不处理 = 这批不发给他们」,
* 下一眼就该看到那四个选项。而**谁知道这句话写在哪** —— 只有刚写完它的模型。
* 前端曾经用 `text.indexOf(标题)` 去猜(已删):模型换个措辞就匹配不上,
* 而匹配不上就渲染不出来 = 把「还有 389 人没发出去」静默藏起来。
*
* ⇒ 模型写完那一条 → 调这个工具 → 界面在**此刻这个位置**长出按钮 → 继续往下写。
* 这和 `render_artifact` 是同一个模式:工具调用即"在流里插一个界面块"。
*
* ⚠️ **漏调不会丢东西**:没被插进正文的那几条,仍然照旧显示在确认单卡片上
* (界面按已插入的编号去重)。⇒ 这个工具优化的是**位置**,⛔ 不是可见性。
*/
tools.show_guidance = tool({
/**
* 🔴 2026-08-14 三处改动,都是裸台验过的:
* ① `编号`(number)→ `id`(中文标识 enum)。序号没有语义,模型得自己记账
* "我讲的这段是第几条";标识让它按**意思**对上。沿革见 `GUIDANCE_KIND`。
* ② 位置的说法从"插在你当前写到的位置"改成"**落在你正文里的哪个位置**" ——
* 前者是**空间词**,而模型看不见页面,给了词汇它就会向主管描述位置并说错。
* ③ 返回值只回一个事实:原来回「已插入选项按钮: <标题>」,模型下一步会把这句
* 标题**再复述一遍**;「位置: 你刚写到的地方」更是白给它一套空间词汇。
* ⚠️ 「什么时候调」里那句「⛔ 别攒着一起调」是**实测有效**的(合并成一段讲时
* 两组按钮会挤在一起),⛔ 别当成啰嗦删掉。
*/
description:
'把他接下来可以动手的事开放给他 —— 他直接点就行,⛔ 不必再打字把需求描述一遍。' +
'\n什么时候调:讲完其中一类,**紧接着**调一次、传它的 id;再讲下一类,再调一次。' +
'\n⚠️ 这个调用**落在你正文里的哪个位置是有含义的** —— 它标记「到这里为止,这一类我讲完了」。' +
'⛔ 所以别攒着一起调 —— 哪怕只攒了两类,前一类的动作也会落到后一类的说明后面。' +
'\n⛔ 还没讲到的别提前调 —— 他看到一件没头没尾的事,只会回头来问。' +
'\n⛔ 它不改任何数据;没有返回值给你用 —— ⚠️ 但这**不代表**可以事后补调,' +
'它的含义全在位置上。',
inputSchema: jsonSchema({
type: 'object',
properties: {
id: {
type: 'string',
// ⚠️ 判据是固定的四类,所以 enum 是静态的 —— ⛔ 别做成"随每版提案变",
// 工具清单在 propose_assignment 跑之前就建好了,那时还不知道这一版有哪几类。
enum: [...GUIDANCE_KINDS],
description:
'要开放哪一类:' +
'待分配 = 有人卡着没发出去、等他定去向;' +
'换个条件选 = 这一格可以只选其中一类人,重出一版;' +
'本批人数 = 这一批发多少人,可以调;' +
'打不完 = 按当前时效算这批打不完,可以调时效或每天几通。' +
'\n⚠️ 只能开放**这一版真的有**的那几类;没有的会被退回,并告诉你这一版有哪几类。',
},
},
required: ['id'],
}),
execute: async (args: unknown) => {
const kind = String((args as { id?: unknown })?.id ?? '');
const at = guidanceKinds.indexOf(kind);
if (at === -1) {
// ⚠️ 错误里**把这一版真有哪几类列出来** —— 这是模型自纠的唯一线索
if (!sheetPushed) return '这一轮还没算出方案,没有可开放的事。先算一版。';
return guidanceKinds.length
? `这一版没有「${kind}」。只有:${guidanceKinds.join('、')}。`
: '这一版没有可开放的事,一件都不用调。';
}
// ⭐ 本地工具也要留痕 —— 「模型到底调没调」是这条生产线最贵的一类问题,
// 没有这行日志就只能靠界面上有没有按钮反推(见上面 MCP 工具那段注释)。
this.logger.log(`工具调用:show_guidance ${kind}`);
// ⚠️ 侧信道直接给**解析好的 signalKey** —— 界面不用再做 标识→key 的映射,
// 而 key 也不会进模型上下文(侧信道不经过模型)。⛔ 别让两边各解析一次。
push({ type: 'assignment_guidance', signalKey: guidanceKeys[at] });
// ⚠️ 只回事实。⛔ 别在这里写「继续往下写」之类的叮嘱 ——
// 工具返回值里塞指令,模型照抄就把内部指令贴进了主管的对话框(2026-08-08 栽过)。
return JSON.stringify({ 已收到: kind });
},
});
/**
* ⭐ 助手**直接改已经呈现的那张确认单**。 * ⭐ 助手**直接改已经呈现的那张确认单**。
* *
* 主管说「把杨丽华移出这批」「王强的单给 5 天」「李莉这些转给张悦」时用。 * 主管说「把杨丽华移出这批」「王强的单给 5 天」「李莉这些转给张悦」时用。
...@@ -287,40 +663,94 @@ export class AssistantService { ...@@ -287,40 +663,94 @@ export class AssistantService {
* ⚠️ 能做的只有卡片上能做的那几件(删/改派/改时效)。 * ⚠️ 能做的只有卡片上能做的那几件(删/改派/改时效)。
* 换人群、改批次人数要重跑算法 → 只能重新调 propose_assignment 出一版。 * 换人群、改批次人数要重跑算法 → 只能重新调 propose_assignment 出一版。
*/ */
/**
* ⭐ **次要业务线的做法,模型自己来取**(pull 式加载,见 assistant-playbooks 顶部)。
*
* 索引就是下面 `topic` 的取值域 —— 每个取值旁边写着"他问什么时候用它",
* 所以模型**不需要提示词里再有一句**「遇到 X 先取做法」。
*/
tools.open_playbook = tool({
description:
'取一篇做法:某一类活按什么顺序做、他要的到底是什么。' +
'\n什么时候调:他提的事落进下面某个取值的范围里,**动手之前**先取一次。' +
'\n⛔ 它不查任何数据、不改任何东西 —— 取回来的是做法,事实仍然要去调对应的工具。' +
'\n同一件事一轮取一次就够了,取回来的内容这一轮一直有效。',
inputSchema: jsonSchema({
type: 'object',
properties: {
topic: {
type: 'string',
enum: [...PLAYBOOK_TOPICS],
description: `要取哪一篇:${PLAYBOOK_INDEX}`,
},
},
required: ['topic'],
}),
execute: async (args: unknown) => {
const topic = String((args as { topic?: unknown })?.topic ?? '');
const pb = PLAYBOOKS[topic];
// ⚠️ 取错了要**把有哪几篇列出来** —— 这是模型自纠的唯一线索(同 show_guidance)
if (!pb) return `没有「${topic}」这一篇。有的是:${PLAYBOOK_TOPICS.join('、')}。`;
this.logger.log(`工具调用:open_playbook ${topic}`);
return pb.正文;
},
});
/**
* 🔴 **查眼前那张确认单现在什么样** —— 2026-08-15 加。
*
* ═══ 为什么非要有它 ═══════════════════════════════════════════
* 确认之前那张单是**草稿**:主管拖一个人、点一个 ×、改一次时效,
* 数就变了,而这些改动**只存在于浏览器的卡片组件里**。
* 模型此前唯一的信息来源是对话里那几行「确认单已更新:…」,于是:
* ① 它得**自己把几行累加**才能得出当前值(实测原话
* 「系统排的 313 人…加上刚还回专属客服的 182 人…一共 495 人」——
* 这次算对了,但那是心算,错了不报错);
* ② 手动拖动 / 点 × / 改时效这几条路**当时连回声都没有**,
* 它连累加的材料都没有 —— 卡片上写着 3 天,它嘴上还说 1 天。
* ⇒ 现在两条一起补:回声带上当前值(推),这个工具随时能取(拉)。
*
* ⚠️ 它查的是**随本次请求捎来的那份快照**,⛔ 不是服务端的什么状态 ——
* 服务端没有这个东西,也⛔ 不许为它去开一条"草稿回传"的写路径。
* ⚠️ 无参数:只有一张单可查(现役那张)。⛔ 别加 requestId 之类的参数,
* 模型没有办法知道该填哪个(同 `propose_assignment.clinicId` 被删掉那次的判断)。
*/
tools.get_current_sheet = tool({
description:
'看他眼前那张确认单**现在**什么样:已排好几条、落在几位客服头上、' +
'还有几个待他定、时效几天、带没带福利。' +
'\n什么时候调:他在卡片上动过手(拖人、移出、改时效、改福利)之后,' +
'你要报数、要判断"还差什么"、或者他问"现在什么情况",先调它。' +
'\n⚠️ 卡片上的改动**你看不见** —— ⛔ 别拿出方案那一版的数去算现在的数。' +
'\n⛔ 它不改任何东西,只是看一眼。',
inputSchema: jsonSchema({ type: 'object', properties: {} }),
execute: async () => {
// ⚠️ 没有 = **他手上根本没有确认单**,⛔ 别回一个空壳让模型以为是"全 0 的单"
if (!input.sheetState) {
return '他手上现在没有确认单 —— 还没出过,或者那一版已经作废了。';
}
this.logger.log('工具调用:get_current_sheet');
return JSON.stringify(sheetSnapshotFacts(input.sheetState), null, 2);
},
});
tools.edit_assignment_sheet = tool({ tools.edit_assignment_sheet = tool({
description: description:
'直接修改**已经呈现**的那张确认单。' + '改他眼前那张确认单:移出谁、改派给谁、改时效、设本批福利。' +
'\n⚠️ 主管说「把某某移出这批」「某某的单给 5 天」时**就用这个**,' + '\n什么时候调:他对这一版提出具体改动就调。这几件事界面上他自己也能做,' +
'⛔ 不要回答"做不到"、也不要让他先确认再逐条退回 —— 卡片就在他眼前。' + '而他开口说了就是要你替他做完 —— 让他先确认再逐条退回,等于把几十次拖拽推回给他。' +
'\n🔴 **一条指令 = 三件事拼起来:选谁(select)· 干什么(action)· 给谁(to)**。' + '\n一条指令由三件事拼起来:**选谁**(select)· **干什么**(action)· **给谁**(to,只有改派要)。' +
'别去背短语,按这三问填:' + '⚠️ 按这三问填,⛔ 别去背短语 —— 每一格该填什么、有哪些取值,' +
'\n ① **选谁**:点名的人→`{group:"patients",patients:["王强","李莉"]}`;' + '各自的参数说明里写着,那里也写了选错会怎样。' +
'某位客服名下全部→`{group:"agent",agent:"张悦"}`;' + '\n\n它只改这一版的**局部**。换人群、改本批人数都要重算,那走 propose_assignment。' +
'「待分配」那一组→`{group:"pending"}`;整批→`{group:"batch"}`。' + '\n患者用姓名或病历号指代,客服用姓名 —— 匹配由界面拿它手里那份单子完成。' +
'\n ② **干什么**:改派→`assign`;移出本批→`remove`;改时效→`set_expiry`(配 days);' + // ⚠️ 这里说的是**先后**不是**位置**:位置("上面")是他屏幕上的事,你看不见,
'设批次福利→`set_benefit`(配 text,select 必须是 batch,空串=撤掉)。' + // 写成位置等于教模型去描述界面 —— 而 base 里明令 ⛔ 别描述界面位置。
'\n ③ **给谁**(只有 `assign` 要):' + '\n⚠️ 改完**什么都不用补**:界面会把结果直接发进对话,而且**先于你这段话**到他那儿。' +
'\n · `{mode:"owner"}` = **各自回自己的专属客服**,一条专属关系都不动;' + '⛔ 别说"已完成"(成没成只有界面知道),也⛔ 别说"等结果再告诉您" ——' +
'\n · `{mode:"balance"}` = 谁手上少先给谁(铺平);' + '他读到那句时结果早就在了,顺序是倒的。' +
'点名了人就加 `agents:["张悦","李莉"]` 把范围限定在他们里(只点一位 = 都给他)。' +
'\n🔴🔴 **`owner` 和 `balance` 结果正好相反,选错主管一眼看得出来**(实测栽过):' +
'他说「把待分配的患者**各自分给各自的专属客服**」,助手用了铺平,' +
'18 个人被散给了 17 位**别人**。' +
'⛔ 话里出现「各自 / 自己的 / 本来的 / 专属 / 谁的客户归谁」→ `owner`;' +
'「平均分 / 谁手上少给谁 / 铺下去 / 随机分一分」→ `balance`。' +
'\n⚠️ `balance` **不是真随机**(那会让同一张单每次算出不同结果),' +
'界面回报里会说清铺给了谁几条,**照抄**;⛔ 别顺着"随机"两个字复述成"我随机分了"。' +
'⛔ 把患者从专属客服手里挪走是主管的决定:**他没明确要求就不要用**,更不要主动提议。' +
'\n⚠️ 举几个拼法(⛔ 别当成清单,没列到的组合照样按三问拼):' +
'「把王强移出这批」→`{select:{group:"patients",patients:["王强"]},action:"remove"}`;' +
'「待分配的各自给回专属客服」→`{select:{group:"pending"},action:"assign",to:{mode:"owner"}}`;' +
'「张悦名下的分给别人」→`{select:{group:"agent",agent:"张悦"},action:"assign",to:{mode:"balance"}}`;' +
'「待分配的时效给 5 天」→`{select:{group:"pending"},action:"set_expiry",days:5}`。' +
'\n⚠️ 只能做这几件局部修改。**换人群 / 改批次人数**要重出确认单,那走 propose_assignment。' +
'\n⚠️ 患者用**姓名或病历号**指代,客服用姓名;⛔ 你没有 planId,也不需要。' +
'\n⚠️ 改完**不要说"已完成"**:界面执行后会自己回一句结果(可能没找到人),等那句再说。' +
'\n⚠️ 批次**已经确认分配**之后:只有 `set_benefit` 还能改(福利只影响此后生成的话术,' + '\n⚠️ 批次**已经确认分配**之后:只有 `set_benefit` 还能改(福利只影响此后生成的话术,' +
'界面会真的写库并回报"作废了几条话术缓存 / 几条客服已经打开过");' + '界面会真的改并回报"作废了几条话术缓存 / 几条客服已经打开过");' +
'人员和时效**改不了** —— 单子已经在客服手上,那要走撤销重分。' + '人员和时效**改不了** —— 单子已经在客服手上,那要走撤销重分。' +
'⛔ 这两种情况都以界面回的那句为准,别自己判断成没成。', '⛔ 这两种情况都以界面回的那句为准,别自己判断成没成。',
inputSchema: jsonSchema({ inputSchema: jsonSchema({
...@@ -328,56 +758,90 @@ export class AssistantService { ...@@ -328,56 +758,90 @@ export class AssistantService {
properties: { properties: {
ops: { ops: {
type: 'array', type: 'array',
description: '一组操作,按顺序执行', description: '一组修改,按给出的顺序依次执行。',
items: { items: {
type: 'object', type: 'object',
properties: { properties: {
select: { select: {
type: 'object', type: 'object',
description: '① 选谁 —— 这条指令作用在哪些人身上', description: '这条修改作用在哪些人身上。',
properties: { properties: {
/**
* ⭐ 取值的**含义写在取值上** —— 与 `show_guidance` 的 `id` 同一个做法。
* 四个值摆在一起、各自带一句话,模型是在选而不是在猜;
* 判据只写在工具描述正文里时,它读到这一格已经隔了很远。
*/
group: { group: {
type: 'string', type: 'string',
enum: ['patients', 'agent', 'pending', 'batch'], enum: ['patients', 'agent', 'pending', 'batch'],
description:
'patients=点名的这几位患者;agent=某位客服名下的全部;' +
'pending=待分配那一组;batch=整批。' +
'\n「把某某移出这批」里的某某默认是**患者**。' +
'只有他明说「客服某某不参与 / 某某的单都别给他」才用 agent ——' +
'agent 一次动的是几十条,而同一个名字可能既是本批客服又是本批患者,' +
'选错了他会以为只动了一条。',
}, },
patients: { patients: {
type: 'array', type: 'array',
items: { type: 'string' }, items: { type: 'string' },
description: 'group=patients 时:患者姓名或病历号,可给多个', description: '患者姓名或病历号,可给多个。group=patients 时必填。',
},
agent: {
type: 'string',
description: '客服姓名。group=agent 时必填。',
}, },
agent: { type: 'string', description: 'group=agent 时:客服姓名' },
}, },
required: ['group'], required: ['group'],
}, },
action: { action: {
type: 'string', type: 'string',
enum: ['assign', 'remove', 'set_expiry', 'set_benefit'], enum: ['assign', 'remove', 'set_expiry', 'set_benefit'],
description: '② 干什么', description:
'assign=改派,配 to;remove=移出本批;set_expiry=改时效,配 days;' +
'set_benefit=设本批福利,配 text,且 select 必须是整批。',
}, },
to: { to: {
type: 'object', type: 'object',
description: description:
'③ 给谁 —— 只有 action=assign 要。owner=各自回自己的专属客服(不动任何专属关系);' + '改派给谁,action=assign 时必填。' +
'balance=谁手上少先给谁。', '\nowner = 各自回自己的专属客服,一条专属关系都不动;' +
'balance = 谁手上少先给谁。' +
// ⚠️ 关键词逐个列全:这五个都来自实测那次选反(他说「各自分给各自的专属客服」,
// 助手用了铺平,18 个人被散给了 17 位别人)。少一个就少一条判据。
'\n两者结果正好相反,而他一眼就看得出来:他说「各自 / 自己的 / 本来的 /' +
' 专属 / 谁的客户归谁」指的是 owner,说「平均分 / 谁手上少给谁 / 铺下去 /' +
' 随机分一分」指的是 balance。' +
'\n把患者从他的专属客服手里挪走是他的决定,他明确要求了才用 balance。' +
'\nbalance 是按在手量铺的,不是随机 —— 同一张单每次算出来都一样。' +
'他说「随机分一分」时也用它,但界面回报里说的是铺给了谁几条,照那句转述。',
properties: { properties: {
mode: { type: 'string', enum: ['owner', 'balance'] }, mode: { type: 'string', enum: ['owner', 'balance'] },
agents: { agents: {
type: 'array', type: 'array',
items: { type: 'string' }, items: { type: 'string' },
description: description:
'mode=balance 时可选:把候选限定在这几位客服(姓名)。' + '把候选限定在这几位客服(姓名),mode=balance 时可选,只点一位就是都给他。' +
'不给 = 本批所有在岗(含一条都没分到的 —— 他们恰恰手上最空)。' + '不给 = 本批所有在岗,含这轮一条都没分到的那些 —— 他们手上恰恰最空。' +
'⛔ mode=owner 不吃这个参数:收谁由患者自己的专属关系决定。', '\nmode=owner 时不看这个参数:收谁由患者自己的专属关系决定。',
}, },
}, },
required: ['mode'], required: ['mode'],
}, },
days: { type: 'number', description: 'set_expiry 的天数(1-90)' }, // 与批次时效同一个上限(落库那份 schema 是 max(90))——
// ⛔ 只写在描述里的话,91 会一路走到确认才被打回来
days: {
type: 'number',
minimum: 1,
maximum: 90,
description: '新的时效天数,action=set_expiry 时必填。',
},
text: { text: {
type: 'string', type: 'string',
description: description:
'set_benefit 的福利原文(挂在整批上,会进这批人的话术)。空串 = 撤掉福利。' + '福利原文,action=set_benefit 时必填;空串表示撤掉本批福利。' +
'⛔ 原话写,不要替主管加条件/期限/承诺。', '它挂在整批上,会进这批人的话术。' +
'\n照他的原话写 —— 条件、期限、承诺都由他说了才有。',
}, },
}, },
required: ['select', 'action'], required: ['select', 'action'],
...@@ -390,9 +854,16 @@ export class AssistantService { ...@@ -390,9 +854,16 @@ export class AssistantService {
const ops = (args as { ops?: unknown[] })?.ops; const ops = (args as { ops?: unknown[] })?.ops;
if (!Array.isArray(ops) || ops.length === 0) return '没有给出任何操作,确认单未改动。'; if (!Array.isArray(ops) || ops.length === 0) return '没有给出任何操作,确认单未改动。';
push({ type: 'assignment_sheet_edit', ops }); push({ type: 'assignment_sheet_edit', ops });
// ⚠️ 只回"已交给界面",⛔ 不回"已完成" —— 匹配得到匹配不到是界面才知道的事, /**
// 它会把结果作为一条消息回到对话里(前端 appendAssistantNote)。 * ⚠️ 只回**事实**,⛔ 一个字的指令都不许有。
return `已把 ${ops.length} 条修改交给界面执行,结果会由界面回报(可能有人没找到)。请等那句结果,⛔ 不要自己宣布已完成。`; *
* 🔴 2026-08-13:原来这里写着「请等那句结果,⛔ 不要自己宣布已完成」——
* 模型把它当话说了出去:「已把这条修改交给界面执行,等它回报结果再告诉您。」
* 而界面的结果**就在那句话上面一行**(它是同步注入的),读起来是倒的:
* 结果已经出来了,助手却还在说"等结果"。
* ⇒ 指令归工具描述,返回值只留事实(这条规矩 2026-08-08 就定了,这里漏网)。
*/
return JSON.stringify({ 已交给界面执行: ops.length, 界面已把结果发进对话: true });
}, },
}); });
} }
...@@ -401,14 +872,25 @@ export class AssistantService { ...@@ -401,14 +872,25 @@ export class AssistantService {
// html 通过 tool-call 入参流式到前端;execute 仅回执,模型据此继续给一句话总结。 // html 通过 tool-call 入参流式到前端;execute 仅回执,模型据此继续给一句话总结。
tools.render_artifact = tool({ tools.render_artifact = tool({
description: description:
'把一段自包含 HTML(<body> 内部片段)渲染成可视化卡片/报表展示给用户。适合召回池列表、患者画像卡、分析报表(可含图表)。用 Tailwind 工具类排版,可含 <canvas>+<script> 画 Chart.js 图表;运行环境已注入 Tailwind 与 Chart.js,勿自行引入,勿访问外部网络,数据内联、手机号掩码、只用真实数据。', '把一段自包含 HTML(<body> 内部片段)渲染成可视化卡片/报表展示给用户。' +
'适合召回池列表、患者画像卡、分析报表(可含图表)。' +
'\n排版用 Tailwind 工具类,强调色用 PAC 主题 teal #0D9488,白底圆角卡片、留白舒适。' +
'\n运行环境已注入 Tailwind 与 Chart.js:⛔ 不要写 <html>/<head>/<!DOCTYPE>,也不要自行引入它们。' +
'\n图表用 Chart.js。<canvas> **必须**放进固定高度容器' +
'(如 <div style="position:relative;height:300px"><canvas></canvas></div>)' +
'并在 options 里设 maintainAspectRatio:false —— 给图表用百分比或满屏高度' +
'(h-full / h-screen / height:100%)会让它无限拉高。' +
'\n数据全部内联写进 HTML,只用工具返回的真实值,手机号掩码;这个片段不能访问外部网络(⛔ 勿 fetch)。' +
'\n标记力求精简:聚焦关键字段,同类条目用最短的结构,生成更快。',
inputSchema: jsonSchema({ inputSchema: jsonSchema({
type: 'object', type: 'object',
properties: { properties: {
title: { type: 'string', description: '卡片标题(可选)' }, title: { type: 'string', description: '卡片标题,可不给。' },
html: { html: {
type: 'string', type: 'string',
description: '<body> 内部 HTML 片段(Tailwind 类;可含 <canvas>+<script> 图表)', description:
'`<body>` 内部的 HTML 片段:Tailwind 工具类排版,图表用 `<canvas>` + `<script>`。' +
'完整要求见本工具说明。',
}, },
}, },
required: ['html'], required: ['html'],
...@@ -416,15 +898,30 @@ export class AssistantService { ...@@ -416,15 +898,30 @@ export class AssistantService {
execute: async () => '已在界面渲染该卡片。', execute: async () => '已在界面渲染该卡片。',
}); });
// 2. resolve provider(可切换)+ streamText 跑 model-driven tool-calling 循环
const resolved = this.provider.resolve(input.modelId ?? 'deepseek');
/**
* ⚠️ 日志打**解析后的型号**(`deepseek-v4-flash`),⛔ 不是逻辑键(`deepseek`)。
* 裸键会被 `AI_DEFAULT_MODEL` 解析成具体型号,只打裸键的话
* 「这一轮到底跑的哪个模型」从日志里根本答不出来 —— 实测被这个问住过。
*/
this.logger.log( this.logger.log(
`assistant chat: model=${input.modelId ?? 'deepseek'} tools=${Object.keys(tools).join(',')}`, `assistant chat: model=${resolved.modelId} tools=${Object.keys(tools).join(',')}`,
); );
const { model } = resolved;
// 2. resolve provider(可切换)+ streamText 跑 model-driven tool-calling 循环
const { model } = this.provider.resolve(input.modelId ?? 'deepseek');
return streamText({ return streamText({
model, model,
system: input.systemExtra ? `${SYSTEM_PROMPT}\n\n${input.systemExtra}` : SYSTEM_PROMPT, /**
* ⭐ 整份系统提示词在**一处**装配(装置 → 诚实 → 语气 → 角色 → 现场),
* 渠道规则拼在最后 —— 它是对既有约束的**追加**,不该插在中间改变阅读顺序。
*/
system: [
buildSystemPrompt({
permissions: input.permissions ?? [],
voice: input.voice ?? this.config.get('ai.assistantVoice', { infer: true }),
}),
...(input.channelExtra ? [input.channelExtra] : []),
].join('\n\n'),
messages: input.messages, messages: input.messages,
tools, tools,
// 防失控:最多 8 步工具循环。 // 防失控:最多 8 步工具循环。
......
import { Body, Controller, Logger, Post, Req, Res } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Request, Response } from 'express';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { createOpenAICompatible, type OpenAICompatibleProvider } from '@ai-sdk/openai-compatible';
import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
import type { AppConfig } from '../../config/configuration';
import {
DAILY_CALLS_PER_AGENT,
TEMPERATURE_TOOL_DESC,
TEMPERATURE_TOOL_VALUES,
type TemperatureValue,
} from '@pac/types';
import { AiProviderService } from '../ai/core/ai-provider.service';
import { McpClientService } from './mcp-client.service';
import { AssignmentProposalService } from '../plan/assignment-proposal.service';
import { modelFacts } from '../plan/assignment-facts';
import { GUIDANCE_KIND, GUIDANCE_KINDS } from '../plan/assignment-signals';
import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from '../mcp/persona-tags.desc';
import { CurrentUser, type AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { TenantScope } from '../../common/decorators/tenant-scope.decorator';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
/**
* 🔬 assistant-lab —— **裸的 agent 试验台**(2026-08-14)。
*
* 目的:看模型的**原生 agent 能力**能到什么程度 —— 边写正文边调工具、把按钮和卡片
* 摆在它写到的位置。⛔ 这里**没有任何补偿**:不重排、不补标题、不兜底、不删历史。
* 模型漏调工具,界面上就真的什么都没有 —— 那正是要观察的东西。
*
* ═══ 与产品助手(AssistantController)的关系:**没有关系** ═════════════
* ⛔ 不复用 `AssistantService`、⛔ 不复用 `buildSystemExtra`、⛔ 不复用前端那套块模型。
* 提示词直接贴在本文件里(下面 `SYSTEM`),改一版跑一版,⛔ 别去 import 产品那份 ——
* 两边各自演进才是这个试验台的意义。
* ⚠️ 只复用**基础设施**:provider(模型路由)、MCP 客户端(工具清单)、
* `AssignmentProposalService`(真取数)、`modelFacts`(同一份事实投影)。
* 它们不是"助手的逻辑",是这条业务线本身。
*
* ═══ 历史怎么传:**服务端原样回吐** ═══════════════════════════════════
* 每轮结束把 `result.response.messages`(AI SDK 生成的 assistant / tool 消息,
* 含真正的 tool-call 与 tool-result)整包发给前端,前端**原样存、原样回传**。
* ⇒ 前端一行历史重建逻辑都没有,也就没有"拍平成文本 → 模型照抄格式"那类坑
* (产品那边为此栽过两次:`〔调用 …〕` 被抄进正文、位置空壳被当成输出格式)。
*
* ═══ 执行过程(debug)════════════════════════════════════════════════
* `step` 事件是**纯 debug 通道**:只给界面画「执行过程」用,
* ⛔ 不进 `messages`、⛔ 不进任何回传给模型的东西。
*/
/** 单轮最多几步工具循环 —— 到顶会以 `step_limit` 事件报出来,⛔ 不静默截断。 */
const MAX_STEPS = 16;
// 🔴 引导标识与取值域已升进 `assignment-signals.ts`(产品共用同一份)——
// 裸台曾经自己维护过一份,搬回产品后那就是第二份副本,⛔ 已删。
/**
* 系统提示词 —— 🔬 **直接贴在这里,改完重启就生效**。
*
* ⚠️ 起点是 2026-08-14 产品助手那一份的快照(base + 主管层)。
* ⛔ 别改成 import:试验台就是要能单独改一句话跑一轮,不牵动线上那份。
*
* ═══ 🔴 三层职责边界 —— 这条是为了**在将来的变化中保持不变** ═══════════
*
* 系统提示词 你是谁、跟谁说话、你们的关系;以及贯穿**所有**输出的规范
* (说人话、数字有出处、只读边界、怎么把结构化事实讲成话、写作风格)。
* ⛔ **一个工具名都不出现**,⛔ 不写"什么时候调哪个工具"。
*
* 工具描述 这个工具干什么、什么时候调、参数从哪来、调了会怎样。
* ⭐ "会怎样"必须写 —— 没有后果的时机要求就是一条没理由的仪式,
* 而没理由的格式规则遵守率最低(qwen 批量调工具正是不理解时机为何重要)。
*
* 工具返回值 只回**事实**。⛔ 不含指令,⛔ 不含模型用不上的词汇。
*
* ⚠️ 判据(加一句话之前先问):**换一批工具、改一版界面之后,这句话还成立吗?**
* 成立 → 提示词;不成立 → 它属于某个工具,去那个工具的描述里写。
*
* 🔴 **删一句话之前,先问它承担的是不是「调几次」或「什么时候调」。**
* 2026-08-14 实测,思维链把这条写得明明白白:我按"通用/简洁"重写工具描述时,
* 把「有几条就该调几次」换成了「⛔ 别攒着最后一次性全调」——只说了别批量、没说每条都要,
* 再配上开头那句「某**一件**事」,模型的思考里当场推出:
* 「注意:show_guidance **一次只开放一件**,就是我刚讲完的那件(待分配)。」
* 于是三条引导只讲了一条、也只调了一次。
* 同一轮还有第二处:⛔「没有返回内容可用」本是为降低泄漏风险加的,它反手用成了推迟的理由——
* 「不过 show_sheet 和 show_guidance 没有返回内容可用。**我在讲完话之后再调**。」
* ⇒ 措辞的**每一处删减都可能带走一条正在起作用的约束**,而模型会把剩下的话往
* 对自己最省事的方向读。⛔ 别只做减法不看它接手给谁。
*
* 🔴 **但"接手"要交给结构,别交给另一句话。**(同日产品复盘)
* 我第一版的补法是加一句「「要主管定的」里有几条,你就要调几次」—— 那是**跨工具耦合**:
* `show_guidance` 凭什么知道 `propose_assignment` 返回值里有个键叫「要主管定的」?
* 那个键一改,这句话就是错的。
* ⇒ 正解是把**取值域连同每一类的含义**写进参数(`enum` + 逐条解释):
* 四类摆在那儿,模型自然知道"这不是只有一件事",⛔ 一句"调几次"都不用写。
* ⚠️ 判据:这句话要是提到了**别的工具的字段名**,它就放错地方了。
*
* 🔴 **⛔ 别把"位置"告诉模型。** 它看不见页面,位置对它没有对应物 ——
* 给了词汇它就会用,而用了必然编:产品那版实测说过「确认单已经摆在**上方**」,
* 而卡片在那句话的**下面**。⇒ 位置是**界面**从调用时机推出来的,
* 模型只需要知道"讲完这一条就调一次"和"调了会发生什么"。
*/
const SYSTEM = `你是 PAC(疗效保障 / 患者分析中心)工作台里的助手。
你和使用者看着同一块屏幕:界面自己也在向他显示东西,你说的和他看到的合起来才是完整的一次回答。
但你**看不到**它显示成什么样 —— ⛔ 别描述界面上的位置或长相("上面""下面""这里")。
他问什么你答什么 —— 患者的事查工具,别的事(闲聊、常识、写作、计算)凭本事答。
你接入了 PAC 的一组患者工具,按需调用即可。
说人话:使用者的词汇表就是他在界面上见过的那些。取值码(\`cold_3y\`)、字段名(\`personaTags\`)、
工具名(\`get_cohort_attributes\`),以及只在代码里存在的概念(温度、口径、水位、基数、探索配额、排序键),
都不在里面 —— 他看到不认识的词,第一反应是系统坏了。工具返回值里已经给了中文,照着说。
你说的每个数都要指得到出处,出处只有一个:**本轮真的调到的工具返回**。指不到就去调,
或者如实说「我先查一下」。上文的工具记录旁边写着当时的参数 —— 参数变了,那些数就作废了。
⛔ 不估、不「大概」、不拿两个数相减凑一个出来。工具返回值有固定形状,你能背出那个形状 ——
但背出来的是假的。工具没返回的就如实说没有该信息,手机号只显示掩码。
除了你自己调的写工具,你做的一切都是只读。界面上发生的改动是使用者的手做的,⛔ 别说成你做的。
写作风格(管你所有的输出):
- 用中文,简洁专业、友好。短句,主动语态,⛔ 不用「本系统」「该批次」这类公文腔。
- 用 markdown 组织:讲三件以上的事就用 \`###\` 起小标题分块,并列的事实用 \`-\` 列成条。
- 加粗留给「需要对方动手、或不看会出事」的那一句 —— 满屏加粗等于没有加粗。
- 中文标点一律全角(,;:()),例外只有小数点(3.5 天)和代码原文。
## 你是门诊经理(主管)的助手
他坐在一张矩阵前:一行一个治疗项目,一列一档时间。他很忙,一眼要看出这批人是谁、有没有卡住的、该不该点确认。
**他做决定,你把决定落成动作** —— 这条关系管住下面所有事。
界面上那根时间轴没有名字,就是一档一档的时间:说「往后放一档」,不说「放宽温度」。
### 怎么把工具给的事实讲出来
工具返回值是**结构化事实,键序就是段落顺序** —— 照它讲,措辞归你。
返回值里每一项是一段。段里成组的条目逐条讲清「是什么」和「不管会怎样」,有几条讲几条。返回值里没有的不要补:上一轮有、这一轮没有,就是这一轮不该有。
界面上已经显示的不用你再说一遍(⛔ 也不要把可选动作列一遍,他在界面上能直接点)。你写的是界面说不出来的那半句:为什么是这些人、他现在该做什么。
### 决定他做,执行你做
这条线是:他在矩阵上选人群 → 你出确认单(直出,不追问、不做画像分层)→ 他在卡片上确认,**这一下才真的分下去** → 之后可跟踪、可撤销。他主动提要求时才收窄。
你手上唯一动得了已经分下去那批的是撤销工具,「已撤销」只能出现在它真的返回之后。确认单是「已呈现」,不是「已经分配好了」——点确认的是他的手。
你**不许自作主张**替他做决定,尤其不把患者从他的专属客服手里挪给别人:那是关系层面的决定。但他开口了你就要能一次干完 —— 把几十次拖拽推回给他不是保护,是甩锅。
本系统**不统计成功与否**,「成功率」「转化率」这类数不存在。
### 拟分怎么落人
两趟 + 一组:有专属且在岗的回自己人手上(最多到他这轮该拿的那份);无主或专属已离岗的给当前手上最少的那个;专属客服这轮已排满的不动,单列成一组交他定。
因此这批可能不满、团队也不齐平 —— 那是刻意的:宁可少分几个,也不动别人的客户。他问「怎么没分够」就说有几个人卡在待分配等他定,不是系统故障。
不是每人加一样多,是每条都给当前手上最少的那个。没有「容量上限」这回事,负载就是在手量本身。
人数和时效不问他:不传就按「在岗人数 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × 时效」估,并与符合条件的总人数取小。他说了数才传。池子里还有人就随时能再分一批。`;
// 🔴 `relabel`(编号→标识)与 `regroup`(要主管定的→怎么派的)已删(2026-08-14):
// 两者都搬进了产品的 `modelFacts`,裸台再做一遍就是**空转** —— 而空转的代码
// 最贵的地方是它看起来还在服役。
interface LabBody {
/** 上一轮服务端原样吐回去的 messages + 这一轮的 user 消息。⛔ 前端不做任何重建。 */
messages: ModelMessage[];
model?: string;
/**
* 🔬 qwen 的思考开关(对 deepseek / gemini 无效,它们本来就开着)。
*
* 🔴 2026-08-14 查出来的:共用的 `AiProviderService` 里有个 fetch 中间件,
* **无条件**给每一个 qwen 请求塞 `enable_thinking = false` —— 连 agent 这条带工具的链路也塞。
* 理由写在那边(2026-06 实测:流式下 thinking 会污染 content),但那次跑的是
* **结构化输出**那条路(generateObject + json mode、无工具)。
* ⚠️ 证据就在它旁边三行:`response_format` 的注入判了 `hasTools`,`enable_thinking` **没判**。
* ⇒ 这几天拿 qwen 跟 DeepSeek 比"要不要边写边调工具",其实是
* **「便宜档 + 会思考」对「最高档 + 不许思考」**,比较本身不成立。
*/
thinking?: boolean;
/** 主管当前看的诊所 —— `propose_assignment` 不传 clinicId 时兜底。 */
activeClinicId?: string;
}
@ApiTags('assistant')
@ApiBearerAuth('accessToken')
@Controller('assistant/lab')
export class AssistantLabController {
private readonly logger = new Logger(AssistantLabController.name);
/**
* 🔬 试验台专用的 qwen 实例 —— **思考开着**。
*
* ⛔ 不改共用的 `AiProviderService`:那一份产品也在用,改了两边一起变,
* 而这一轮只是要验"开了思考 qwen 会不会好起来"。
* ⚠️ 只塞 `enable_thinking`,⛔ 不碰 `response_format` —— agent 这条路一定带 tools,
* 而 DashScope 的 json mode 与工具调用互斥(共用那份注释里写着)。
* ⚠️ `@ai-sdk/openai-compatible@2.0.48` 本身就解析 `reasoning_content`,⛔ 不用升级 SDK。
*/
private readonly qwenThinking: OpenAICompatibleProvider;
private readonly qwenDefaultModel: string;
constructor(
private readonly provider: AiProviderService,
private readonly mcp: McpClientService,
private readonly proposals: AssignmentProposalService,
config: ConfigService<AppConfig, true>,
) {
const ai = config.get('ai', { infer: true });
this.qwenDefaultModel = ai.qwenDefaultModel;
this.qwenThinking = createOpenAICompatible({
name: 'qwen-thinking',
apiKey: ai.qwenApiKey,
baseURL: ai.qwenBaseUrl,
supportsStructuredOutputs: false,
// ⚠️ 用 `Parameters<typeof fetch>[0]` 而不是写死 `string | URL | Request` ——
// 本文件从 express 引了同名的 `Request`,直接写会撞名(共用那份没引 express,没这问题)。
fetch: (async (url: Parameters<typeof globalThis.fetch>[0], options?: RequestInit) => {
if (options?.body && typeof options.body === 'string') {
try {
const b = JSON.parse(options.body) as Record<string, unknown>;
b.enable_thinking = true;
options = { ...options, body: JSON.stringify(b) };
} catch {
/* 非 JSON body 不动 */
}
}
return fetch(url, options);
}) as typeof fetch,
});
}
/** 模型路由:qwen + 开思考 → 走试验台自己那份;其余一律交给共用的 provider。 */
private resolveModel(
modelId: string,
thinking: boolean,
): { model: Parameters<typeof streamText>[0]['model']; modelId: string; thinking: boolean } {
if (thinking && modelId.startsWith('qwen')) {
const canonical = modelId === 'qwen' ? this.qwenDefaultModel : modelId;
return { model: this.qwenThinking(canonical), modelId: canonical, thinking: true };
}
const r = this.provider.resolve(modelId);
// ⚠️ deepseek / gemini 本来就带思考;只有 qwen 会被共用那份中间件关掉
return { model: r.model, modelId: r.modelId, thinking: !r.modelId.startsWith('qwen') };
}
@Post('chat')
@ApiOperation({ summary: '🔬 裸 agent 试验台(SSE)—— ⛔ 无任何补偿,只看原生能力' })
async chat(
@Req() req: Request,
@Res() res: Response,
@Body() body: LabBody,
@CurrentUser() user: AuthenticatedUser,
@TenantScope() scope: TenantScopeContext,
): Promise<void> {
const token = (req.headers['authorization'] ?? '').replace(/^Bearer\s+/i, '');
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders?.();
const send = (e: Record<string, unknown>): void => {
res.write(`data: ${JSON.stringify(e)}\n\n`);
};
const ac = new AbortController();
req.on('close', () => ac.abort());
try {
const tools = await this.buildTools(token, user, scope, body.activeClinicId, send);
const { model, modelId, thinking } = this.resolveModel(
body.model ?? 'deepseek',
body.thinking !== false,
);
this.logger.log(
`lab chat: model=${modelId} thinking=${thinking ? 'on' : 'off'} tools=${Object.keys(tools).join(',')}`,
);
const result = streamText({
model,
system: SYSTEM,
messages: body.messages ?? [],
tools,
stopWhen: stepCountIs(MAX_STEPS),
abortSignal: ac.signal,
});
for await (const part of result.fullStream) {
const p = part as Record<string, unknown> & { type: string };
switch (p.type) {
case 'text-delta':
case 'text':
send({ type: 'text', text: (p.text as string) ?? (p.delta as string) ?? '' });
break;
/**
* 🔬 思考也照原样转发 —— 它跟正文走**同一条流**,按到达顺序穿插。
*
* ⭐ 这一条是这个试验台的主要观察点之一:**模型是在哪一刻想的**
* (写正文之前?调工具之前?拿到工具结果之后?)。
* ⚠️ 只是**显示**分开,⛔ 它本身仍然是模型输出的一部分,
* 最后随 `messages` 原样回传(DeepSeek 文档明确要求思考内容参与后续上下文拼接)。
*/
case 'reasoning-delta':
send({ type: 'reasoning', text: (p.text as string) ?? '' });
break;
// ⚠️ 下面三个是**纯 debug 通道**,只画「执行过程」用,⛔ 不进 messages。
case 'tool-call':
send({ type: 'step', phase: 'call', id: p.toolCallId, tool: p.toolName, args: p.input });
break;
case 'tool-result':
send({ type: 'step', phase: 'result', id: p.toolCallId, tool: p.toolName, result: p.output });
break;
case 'tool-error':
send({
type: 'step',
phase: 'error',
id: p.toolCallId,
tool: p.toolName,
error: p.error instanceof Error ? p.error.message : String(p.error),
});
break;
case 'finish':
// 🔴 到顶要报出来 —— 静默截断会让模型停在「我先查一下」而使用者以为查完了
if (p.finishReason === 'tool-calls') send({ type: 'step_limit', limit: MAX_STEPS });
break;
case 'error':
send({ type: 'error', error: String(p.error) });
break;
default:
break;
}
}
/**
* ⭐ 本轮生成的 assistant / tool 消息**原样吐回前端**,下一轮原样带回来。
* 这是这个试验台"上下文不加工"的落点:前端一行重建逻辑都没有。
*/
const resp = await result.response;
send({ type: 'messages', messages: resp.messages });
send({ type: 'done' });
} catch (err) {
send({ type: 'error', error: err instanceof Error ? err.message : String(err) });
} finally {
res.end();
}
}
/**
* 工具清单 = MCP 那一整套 + 三个本地工具(与产品助手同一批,行为一致)。
*
* ⚠️ `place_sheet` / `place_guidance` 是**纯位置标记**:不查库不写库,
* 唯一作用是让界面知道"卡片/按钮该出现在正文的哪一处"。
*/
private async buildTools(
token: string,
user: AuthenticatedUser,
scope: TenantScopeContext,
activeClinicId: string | undefined,
send: (e: Record<string, unknown>) => void,
): Promise<ToolSet> {
const mcpTools = await this.mcp.listTools(token, 'dispatch');
const tools: ToolSet = {};
for (const t of mcpTools) {
/**
* 🔬 裸台**不注册** `get_current_user`(2026-08-14)。
*
* 这条流程用不上它:身份由系统提示词说了(「你是门诊经理的助手」),
* 能不能分配由**工具清单**说了(拿得到 propose_assignment 就是能),
* 诊所由服务端定(clinicId 参数已删)。⇒ 调它是纯花一步。
* ⚠️ 实测它每轮开头都被调一次,返回值除了"我是主管"没被用上过。
* ⛔ 别顺手把别的工具也摘掉:其余那些是**没被用到**,不是**不该存在** ——
* 模型有没有能力自己判断"用不上就不调",正是这个试验台要观察的。
*/
if (t.name === 'get_current_user') continue;
tools[t.name] = tool({
description: t.description ?? t.name,
inputSchema: jsonSchema(t.inputSchema),
execute: async (args: unknown) => this.mcp.callTool(token, t.name, args),
});
}
void user;
/** 本轮「要主管定的」有哪几条 —— 标题给界面画绿框,标识给工具校验 */
let guidanceTitles: string[] = [];
let guidanceKinds: string[] = [];
/** 本轮算出过方案没有 —— 两个呈现器都靠它拦"还没有东西可呈现就先调了" */
let hasProposal = false;
tools.propose_assignment = tool({
/**
* 按同一套写法重写(2026-08-14):是什么 / 什么时候用 / 什么时候别用 / 边界。
*
* 删掉的四句,各有各的去处 —— ⛔ 别抄回来:
* 「并把卡片呈现给主管」 → 界面词,而且**是错的**:呈现时机由 show_sheet 决定
* 「返回的是结构化事实…组织成人话」→ 系统提示词的「怎么把工具给的事实讲出来」已经管了
* 「绝不要说已经分配好了」 → 系统提示词的权责段 + 返回值键名 `已经分下去了` 各一份,这是第三份
* 「专属客服排满的不会自动改派」 → 返回值里 `专属客服排满、这批没发的` 自己就说清了(判据②)
* ⚠️ 「不问人数时效、直出不追问」也删了 —— 系统提示词的流程线里写着,这里是复述。
*/
description:
'从召回池按条件选出一批人,算出一版分配方案:谁分给谁、每人几条、谁卡着没发。' +
'\n什么时候调:他说出要哪一格的人(治疗项 + 时间档)就调。' +
'他要换条件重来(只选其中一类、改人数、改时效)也是**重调这个工具出新的一版**,' +
'⛔ 不是在上一版已经排好的人里再挑。' +
'\n⚠️ 本批人数取**小的那个**:估出来的基数 vs 符合条件的总人数。' +
'⛔ 所以这一格人不够时,把人数往上调没有任何作用 —— 卡住的是候选,不是基数。' +
'\n⛔ 它不写任何数据,只是算一版方案。' +
/**
* 🔴 **讲述顺序写在这里,⛔ 不在系统提示词里**(2026-08-14 产品定)。
*
* 理由是**耦合方向**:`propose_assignment` 是生产者,它知道自己产出什么、该被怎么读;
* `show_sheet` / `show_guidance` 是呈现器,⛔ 它们不该知道内容结构
* (上一轮就是因为 show_guidance 提到了「要主管定的」这个键才删的)。
* ⚠️ 这里**不点名那两个工具**,用的是它们自己描述里的动词("看明细""开放")——
* 语义对得上就够了,⛔ 别把工具名写进来,那又成了跨工具引用。
* ⚠️ 段落顺序**由键序表达**(`modelFacts` 的键序),这里只写键序表达不了的三件事:
* 开头给结论 / 明细什么时候看 / 可动手的东西就近开放。
* 🔴 初稿还列了「② 这批人对不对 ③ 分完什么样」—— 那是**把键名又抄了一遍**;
* 还写了「这是主管的读法」「他很忙」这种具体人物与心理描写,
* 以及一句光禁不说后果的「⛔ 别自己换」。三条都违反本文件自己的写法约定,已删。
*
* 顺序本身是站在使用者的决策链上定的:他只做一个决定 —— 点不点确认。
* 要不要我管 → 人对不对 → 分得合不合理 → 现在做什么
*/
'\n开头先一句话说清:这一版是什么、有没有要他动手的 —— 多数时候看完这一句就够了,' +
'后面那些是给他要动手时看的。' +
'\n讲完人是怎么分下去的,让他看这一版的完整明细。' +
'\n可动手的事,在**它所属的那一段讲完之后**开放:他读到哪儿就在哪儿能动手,' +
'攒到最后他得把上下文重新想一遍。',
inputSchema: jsonSchema({
type: 'object',
properties: {
/**
* 🔴 **`clinicId` 参数已删(2026-08-14)—— 结构上不可能填错,胜过写一句"别编"。**
*
* 上一版写的是「⛔ 不知道就别传,更不要自己编」+ 后果说明。但复盘发现:
* `get_current_user` 回的 `clinicIds` **只有 uuid、没有名字**,
* 主管说「给建国门那家出一版」时,模型手里根本没有「名字 → id」的映射。
* ⇒ 这个参数只有两种填法:照抄一个它不懂含义的 uuid,或者编一个。
* **没有一种是"用对"** —— 那它就不该出现在模型面前。
* ⚠️ 诊所由服务端定:前端传的 activeClinicId(主管现在看的那家)→ 兜底取他名下第一家。
* ⛔ 要恢复"按诊所选"这个能力,正解是给一个**带名字的 enum**(取值域 = 他真正有权限的那几家),
* ⛔ 不是把这个裸 id 参数放回来。
*/
potentialTreatment: { type: 'string', description: POTENTIAL_TREATMENT_DESC },
temperature: {
type: 'string',
enum: [...TEMPERATURE_TOOL_VALUES],
description: TEMPERATURE_TOOL_DESC,
},
personaTags: { type: 'string', description: PERSONA_TAGS_DESC },
minSpendYuan: {
type: 'number',
description:
'只选累计净消费**高于**这个数的人,单位是**元**(严格大于,等于不算)。' +
'\n⛔ 别自己拍这个数 —— 他没说数字就先去查这批人的分布,' +
'凭空定一个门槛,他拿到的是一批说不清来历的人。',
},
/**
* ⚠️ 这三个的关系是**有歧义**的,实现见 assignment-proposal.service:
* batchSize = targetCount ?? (在岗人数 × dailyCalls × expiresInDays)
* 本批人数 = min(batchSize, 符合条件的总人数)
* ⇒ 传了 targetCount,dailyCalls 就完全不起作用;expiresInDays 则**身兼两职**。
* 2026-08-14 走查:原描述只写了各自是什么,没写它们互相怎么影响。
*/
targetCount: {
type: 'number',
description:
'本批发多少人。他明确说「这批 200 人」时才传。' +
'\n⚠️ 传了它就**直接按这个数**,下面那个换算公式整个不参与(dailyCalls 传了也白传)。',
},
dailyCalls: {
type: 'number',
description:
'「每人每天打几通」,1–60。**只用来估本批人数**(在岗人数 × 它 × 时效)。' +
'\n⛔ 它不是容量上限,⛔ 也不参与"谁分给谁"。⚠️ 传了 targetCount 时它不起作用。',
},
expiresInDays: {
type: 'number',
description:
'批次时效天数。他明确说了天数(「给 5 天」)时才传。' +
'\n⚠️ 它**身兼两职**:① 单据多久到期自动退回池子;' +
'② 没传 targetCount 时,它还参与估本批人数。改它两件事一起变。',
},
},
}),
execute: async (args: unknown) => {
const a = (args ?? {}) as {
potentialTreatment?: string;
temperature?: TemperatureValue;
personaTags?: string;
minSpendYuan?: number;
expiresInDays?: number;
dailyCalls?: number;
targetCount?: number;
};
// ⚠️ 诊所**不从模型来**(参数已删,见上):主管现在看的那家 → 兜底他名下第一家
const clinicId = activeClinicId ?? scope.clinicIds[0];
if (!clinicId) return '当前登录人没有绑定诊所,出不了方案。';
const sheet = await this.proposals.propose(scope, { ...a, clinicId });
const actions = sheet.signals.filter((s) => s.tier === 'action');
guidanceTitles = actions.map((s) => s.title);
guidanceKinds = actions.map((s) => GUIDANCE_KIND[s.key] ?? s.key);
hasProposal = true;
// 🔬 试验台只推一个**概要**给界面(要看的是"摆在哪",不是卡片本身)
send({
type: 'sheet_ready',
summary: {
共: sheet.candidateTotal,
已排好: sheet.placed,
待分配: sheet.pending.length,
时效天数: sheet.expiresInDays,
},
});
const facts = modelFacts(sheet);
return JSON.stringify({ 确认单已呈现: true, 已经分下去了: false, ...facts }, null, 2);
},
});
/**
* ═══ 两个"给他看"的工具 —— 命名与措辞的取舍 ═══════════════════════
*
* 🔴 原来叫 `place_sheet` / `place_guidance`。`place` 是**空间词**,而模型看不见页面 ——
* 工具名是模型接触到的**第一层**词汇,比描述和返回值都靠前,给了它空间词汇,
* 它就会向主管描述位置,而描述必然会错(产品实测:说「确认单已经摆在**上方**」,
* 卡片却在那句话下面)。
* ⇒ 改成 `show_*`:**`show` 描述的是模型真能意图的动作**(现在把这个给他看),
* `place` 描述的是它感知不到的结果(摆在某处)。时机是它的,位置是界面推出来的。
* ⚠️ 保留「引导」这个词(`show_guidance`)—— 它确实是这类节点最贴切的叫法。
*
* ⚠️ 时机要求 + 它的后果**都写在这里**(⛔ 不在系统提示词里,见 SYSTEM 上方的边界)。
* ⚠️ 返回值只回一个事实,⛔ 不含"位置""你刚写到的地方"这类模型用不上的词 ——
* 返回值是泄漏风险最高的一层(`已插入选项按钮`、`<按钮位置 />` 两次都出在这)。
*/
tools.show_sheet = tool({
// ⚠️「什么时候调」不在这儿 —— 它归生产者(propose_assignment 的讲述顺序里那句
// 「讲完人是怎么分下去的,让他看这一版的完整明细」)。呈现器只说自己是什么。
description:
'让他看到这一版的完整明细:谁分给了谁、每人几条 —— 他要据此决定确不确认。' +
'\n⚠️ 这个调用**落在你正文里的哪个位置是有含义的** —— 它标记「明细从这里开始看」。' +
'\n⛔ 这一版还没算出来之前别调:那时没有明细,他会看到一块空的。' +
'\n⛔ 它不改任何数据;没有返回值给你用 —— ⚠️ 但这**不代表**可以事后补调,' +
'它的含义全在位置上。',
inputSchema: jsonSchema({ type: 'object', properties: {} }),
execute: async () => {
// ⚠️ 跟 show_guidance 一样,能拦的用**结构**拦,⛔ 别只靠上面那句 ⛔。
// 拦下来还要**说清现在该干什么** —— 这是模型自纠的唯一线索。
if (!hasProposal) return '这一轮还没算出方案,没有明细可看。先算一版。';
send({ type: 'place', what: 'sheet' });
return JSON.stringify({ 已收到: true });
},
});
tools.show_guidance = tool({
description:
'把他接下来可以动手的事开放给他 —— 他直接点就行,⛔ 不必再打字把需求描述一遍。' +
'\n什么时候调:讲完其中一类,**紧接着**调一次、传它的 id;再讲下一类,再调一次。' +
'\n⚠️ 这个调用**落在你正文里的哪个位置是有含义的** —— 它标记「到这里为止,这一类我讲完了」。' +
// ⚠️ 2026-08-14 实测:它把「换个条件选」和「本批人数」写成同一节的两条 bullet,
// 然后连着调两次 —— 于是前一类的动作落到了后一类的说明后面。
// ⇒ 这句从「别攒到最后」改成「哪怕只攒两类也不行」,并把后果说具体。
// ⛔ 别写成「不许把两类写进同一段」:那是在管它怎么分段,越界了(③级格式规则最不被遵守)。
'⛔ 所以别攒着一起调 —— 哪怕只攒了两类,前一类的动作也会落到后一类的说明后面。' +
'\n⛔ 还没讲到的别提前调 —— 他看到一件没头没尾的事,只会回头来问。' +
'\n⛔ 它不改任何数据;没有返回值给你用 —— ⚠️ 但这**不代表**可以事后补调,' +
'它的含义全在位置上。',
inputSchema: jsonSchema({
type: 'object',
properties: {
id: {
type: 'string',
// ⚠️ 判据是固定的四类,所以 enum 是静态的 —— ⛔ 别做成"随每版提案变",
// 工具清单在 propose_assignment 跑之前就建好了,那时还不知道这一版有哪几类。
enum: GUIDANCE_KINDS,
/**
* 🔴 **每一类的含义写在这里,⛔ 不去引用别的工具的返回值结构。**
* 上一版写的是「取自 propose_assignment 返回值里「要主管定的」每一条的 `标识`」——
* 那是**跨工具耦合**:那个键名一改这句就错了,而且工具之间不该知道对方的内部形状。
* ⭐ 把四类摆出来,模型自然知道「这不是只有一件事」,⛔ 不需要再写一句"有几条调几次"。
*/
description:
'要开放哪一类:' +
'待分配 = 有人卡着没发出去、等他定去向;' +
'换个条件选 = 这一格可以只选其中一类人,重出一版;' +
'本批人数 = 这一批发多少人,可以调;' +
'打不完 = 按当前时效算这批打不完,可以调时效或每天几通。' +
'\n⚠️ 只能开放**这一版真的有**的那几类;没有的会被退回,并告诉你这一版有哪几类。',
},
},
required: ['id'],
}),
execute: async (args: unknown) => {
const kind = String((args as { id?: unknown })?.id ?? '');
const index = guidanceKinds.indexOf(kind) + 1;
if (index === 0) {
// ⚠️ 错误里**把这一版真有哪几条列出来** —— 这是模型自纠的唯一线索
if (!hasProposal) return '这一轮还没算出方案,没有可开放的事。先算一版。';
return guidanceKinds.length
? `这一版没有「${kind}」。只有:${guidanceKinds.join('、')}。`
: '这一版没有可开放的事,一件都不用调。';
}
// ⚠️ title 只走**界面**这条侧信道(画绿框用),⛔ 不进返回值 ——
// 把标题回给模型,它下一步就会把这句话再复述一遍(实测过)。
send({ type: 'place', what: 'guidance', index, kind, title: guidanceTitles[index - 1] ?? '' });
return JSON.stringify({ 已收到: kind });
},
});
return tools;
}
}
...@@ -4,9 +4,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; ...@@ -4,9 +4,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod'; import { z } from 'zod';
import { import {
Permission, Permission,
AnchorMode,
ANCHOR_MODE_TOOL_DESC,
parseAnchorMode,
TEMPERATURE_TOOL_DESC, TEMPERATURE_TOOL_DESC,
TEMPERATURE_TOOL_VALUES, TEMPERATURE_TOOL_VALUES,
} from '@pac/types'; } from '@pac/types';
...@@ -55,6 +52,14 @@ function jsonResult(data: unknown) { ...@@ -55,6 +52,14 @@ function jsonResult(data: unknown) {
* *
* ⚠️ 原来是 `z.string()` 必填:模型没有 id 又必须填,只好编一个(实测编出了 `"CL001"`)。 * ⚠️ 原来是 `z.string()` 必填:模型没有 id 又必须填,只好编一个(实测编出了 `"CL001"`)。
* 把它改成可选不是"放宽",而是**把猜的动机拿掉** —— 服务端本来就知道该用哪个诊所。 * 把它改成可选不是"放宽",而是**把猜的动机拿掉** —— 服务端本来就知道该用哪个诊所。
*
* 🔴 2026-08-14:**产品内的助手已经完全看不到这一格了** ——
* `AssistantService` 在包 MCP 工具时把它从 schema 里摘掉,并注入主管正看着那家
* (见那边的 `stripClinicId`)。理由是"没有一种填法是对的":下面这句
* 「用 get_current_user 返回的 clinicIds 里的值」给的是**一串没有名字的 uuid**,
* 模型只能在几家里挑一个。
* ⇒ 这里保留这一格**是给外部 agent 的**(它们从自己的配置里知道诊所 id),
* ⛔ 别因为"产品内没人用了"就删:删了外部客户端就再也指定不了诊所。
*/ */
const CLINIC_ID_SCHEMA = z const CLINIC_ID_SCHEMA = z
.string() .string()
...@@ -121,8 +126,18 @@ export class McpServerFactory { ...@@ -121,8 +126,18 @@ export class McpServerFactory {
server.registerTool( server.registerTool(
'get_current_user', 'get_current_user',
{ {
/**
* 🔴 2026-08-14 删掉「**每轮对话开始时先调它**」:
* ① 它要解决的事**结构上已经解决了** —— 权限决定的是**工具清单**(条件注册),
* 没有 `plan:dispatch` 的人根本看不到分配那几个工具,"走哪条工作流"不用问;
* ② 每轮一步,而这信息在一次会话里是静态的(实测:第一轮调、第二轮就不调了,
* 规则本身已被部分无视);
* ③ 「据此决定走哪条工作流」是**流程**描述,属于系统提示词的角色层,写在工具描述里越界。
*/
description: description:
'当前登录人是谁、能做什么。**每轮对话开始时先调它**,据此决定走主管工作流(可发起批次分配)还是客服工作流(只处理自己的任务)。', '当前登录人是谁、能做什么(可发起批次分配 / 只处理自己名下的任务)。' +
'\n⚠️ 要用到 clinicIds、或要如实回答"我能不能做某件事"时才调 —— ' +
'能不能做**不用问它**:做得了的事,对应的工具就在你手上;做不了的,那个工具压根不会出现。',
inputSchema: {}, inputSchema: {},
}, },
async () => async () =>
...@@ -188,7 +203,10 @@ export class McpServerFactory { ...@@ -188,7 +203,10 @@ export class McpServerFactory {
'get_persona', 'get_persona',
{ {
description: description:
'患者全量画像:RFM 价值分群 / 生命周期 / 治疗史 / 治疗敏感 / 禁忌 / 潜在治疗 等所有 persona 特征(PAC 的推断分析层)。', // ⚠️ 「RFM」「persona」是**内部词**:同一类词漏给用户已经栽过(cold_3y 那次)。
// 工具描述里出现,模型就会顺手当成"这个东西的名字"说出去。
'患者的全量画像:价值分群 / 生命周期阶段 / 治疗史 / 治疗敏感 / 禁忌 / 潜在治疗 等全部画像特征。' +
'\n⚠️ 这一层是 PAC **推断**出来的,⛔ 不是原始记录 —— 原始记录见 get_facts。',
inputSchema: { patientId: z.string() }, inputSchema: { patientId: z.string() },
}, },
async ({ patientId }) => { async ({ patientId }) => {
...@@ -201,7 +219,9 @@ export class McpServerFactory { ...@@ -201,7 +219,9 @@ export class McpServerFactory {
'get_facts', 'get_facts',
{ {
description: description:
'患者客观事实数据(诊断/治疗/预约/收费等 active 版本时间轴)。只给事实,不含推断分析(分析见 get_persona)。', '患者真实发生过的事(诊断 / 治疗 / 预约 / 收费等,按时间排)。' +
'\n⚠️ 只回**当前有效的那一版**:改过的记录不会连着旧版一起出现,⛔ 别当成"改过两次"。' +
'\n只给发生过的事,⛔ 不含任何推断(推断见 get_persona)。',
inputSchema: { inputSchema: {
patientId: z.string(), patientId: z.string(),
limit: z.number().int().min(1).max(200).optional(), limit: z.number().int().min(1).max(200).optional(),
...@@ -232,12 +252,21 @@ export class McpServerFactory { ...@@ -232,12 +252,21 @@ export class McpServerFactory {
'list_recall_queue', 'list_recall_queue',
{ {
description: description:
'召回工作台:列出待跟进的召回计划(view=pool 未分配召回池 / mine 我名下),按优先级排序。回答"现在该联系谁 / 今日推荐"。' + '召回工作台:列出待跟进的召回计划,按优先级排序。回答"现在该联系谁 / 今日推荐"。' +
'\n支持画像圈人(personaTags)+ 病种(scenario)+ 关键字(keyword:姓名/手机/患者号)+ 真实号(phoneVerified)筛选。' + '\n支持画像圈人(personaTags)+ 病种(scenario)+ 关键字(keyword:姓名/手机/患者号)+ 真实号(phoneVerified)筛选。' +
// 🔴 服务端会**静默降级**:没有看全池权限的人,`pool` 一律按 `mine` 执行
// (见下面 handler 的 canSeePool —— 刻意不报错,客服问"今天该联系谁"是正当需求)。
// ⚠️ 但模型不知道这件事就会把自己名下那几条**说成整个池子**,而数字是真的、
// 口径是错的 —— 这正是 T14「口径对数」最难查的形状。
'\n⚠️ 没有看全池权限的人,即使按池子查,回的也**只有他自己名下**的 —— ' +
'⛔ 拿不准就别说"池子里一共…",说"你手上还有…"。' +
'\n' + '\n' +
PERSONA_TAGS_DESC, PERSONA_TAGS_DESC,
inputSchema: { inputSchema: {
view: z.enum(['pool', 'mine']).optional().describe('pool=未分配召回池(默认) / mine=我名下'), view: z
.enum(['pool', 'mine'])
.optional()
.describe('pool=还没分下去的召回池(默认) / mine=他自己名下的'),
personaTags: z.string().optional().describe(PERSONA_TAGS_DESC), personaTags: z.string().optional().describe(PERSONA_TAGS_DESC),
scenario: z scenario: z
.string() .string()
...@@ -250,7 +279,15 @@ export class McpServerFactory { ...@@ -250,7 +279,15 @@ export class McpServerFactory {
.enum(['priority_desc', 'priority_asc', 'created_desc']) .enum(['priority_desc', 'priority_asc', 'created_desc'])
.optional() .optional()
.describe('排序,默认 priority_desc'), .describe('排序,默认 priority_desc'),
clinicId: z.string().optional(), // ⚠️ **不能**换成 CLINIC_ID_SCHEMA:那份说的是「不传 = 取第一个诊所」,
// 而这里不传 = **不按诊所过滤**。说反了会让人以为看的是全部,其实只有一家。
clinicId: z
.string()
.optional()
.describe(
'只看某一家诊所。不传 = **不按诊所过滤**(他权限范围内全部)。' +
'⛔ 不知道 id 就不要传,更不要自己编 —— 编出来的 id 会被当场拒掉。',
),
limit: z.number().int().min(1).max(100).optional(), limit: z.number().int().min(1).max(100).optional(),
}, },
}, },
...@@ -286,13 +323,24 @@ export class McpServerFactory { ...@@ -286,13 +323,24 @@ export class McpServerFactory {
{ {
description: description:
'召回池数字概览:总量 + 优先级分档(高≥70 / 中 40-69 / 低<40)+ 病种分布。' + '召回池数字概览:总量 + 优先级分档(高≥70 / 中 40-69 / 低<40)+ 病种分布。' +
'"今日推荐"先用它给摘要(如"今天 320 条,缺牙 80 / 牙周 60,高优 45"),再用 list_recall_queue 取明细。' + '\n他问"今天有多少人要跟"时先用它给摘要,再用 list_recall_queue 取明细。' +
'支持同样的 view / personaTags / scenario / clinicId 圈定范围。', // 同 list_recall_queue:没有看全池权限的人只统计自己的 —— 否则**总数本身**就泄漏了池子规模
'\n⚠️ 没有看全池权限的人,回的总数**只是他自己名下**的 —— ⛔ 别说成"池子里一共…"。' +
'\n支持同样的 view / personaTags / scenario / clinicId 圈定范围。',
inputSchema: { inputSchema: {
view: z.enum(['pool', 'mine']).optional().describe('pool=召回池(默认) / mine=我名下'), view: z
.enum(['pool', 'mine'])
.optional()
.describe('pool=召回池(默认) / mine=他自己名下的'),
personaTags: z.string().optional().describe(PERSONA_TAGS_DESC), personaTags: z.string().optional().describe(PERSONA_TAGS_DESC),
scenario: z.string().optional(), scenario: z.string().optional(),
clinicId: z.string().optional(), clinicId: z
.string()
.optional()
.describe(
'只看某一家诊所。不传 = 不按诊所过滤(他权限范围内全部)。' +
'这个 id 只有他明确指定某家诊所时才有;范围外或不存在的 id 会被当场拒掉。',
),
}, },
}, },
async ({ view, personaTags, scenario, clinicId }) => { async ({ view, personaTags, scenario, clinicId }) => {
...@@ -325,40 +373,80 @@ export class McpServerFactory { ...@@ -325,40 +373,80 @@ export class McpServerFactory {
'get_agents', 'get_agents',
{ {
description: description:
'某诊所的在岗客服名册 + 各自在手负载。分配前用它看"有哪些人、谁手上空"。' + // 🔴 2026-08-14 改:原文是「**分配前用它**看…」,而 `propose_assignment` 内部
// 自己就会取名册 —— 那句话在**主动制造一次白花的调用**(实测抓到一轮:
// 写字之前先调了它,返回的数一个都没用上)。
'某诊所的在岗客服名册 + 各自在手负载。他问名册、问谁手上空、问某人负载时用它。' +
'\n⚠️ 出方案**不需要**先调它 —— 方案自己会取名册。' +
'\n⚠️ 在岗按「近 N 月有回访记录」近似判定,**不代表系统确认在职**;' + '\n⚠️ 在岗按「近 N 月有回访记录」近似判定,**不代表系统确认在职**;' +
'名册外的客服也可以指定(用 include 传 userId)。' + '名册外的客服也可以指定(用 include 传 userId)。' +
'\n⚠️ 这里只回**在手**(客观量),不回容量 —— 容量是主管的基数(沿用他上一次的值),' + // 🔴 2026-08-14 走查,原文两处都过期了:
'⛔ **不要替他做减法说"还能吃 N 个"**,他知道谁在休假,你不知道。' + // ①「容量是主管的基数(**沿用他上一次的值**)」—— 沿用 2026-08-12 就断开了;
// ②「基数」本身就列在提示词的禁说清单里,而这里是**直接递给模型**的词。
'\n⚠️ 这里只回**在手**(客观量)。⛔ **不要替他做减法说"还能吃 N 个"** ——' +
'落人只看谁手上少,没有"容量上限"这回事;而谁在休假只有他知道,你不知道。' +
'要看本批每人分多少,那是 propose_assignment 出的确认单的事。', '要看本批每人分多少,那是 propose_assignment 出的确认单的事。',
inputSchema: { inputSchema: {
clinicId: CLINIC_ID_SCHEMA, clinicId: CLINIC_ID_SCHEMA,
months: z.number().int().min(1).max(36).optional().describe('在岗窗口月数,默认 12'), months: z
.number()
.int()
.min(1)
.max(36)
.optional()
.describe('按最近几个月有没有回访记录判定在岗,默认 12 个月。'),
include: z include: z
.array(z.string()) .array(z.string())
.optional() .optional()
.describe('额外带上这些 userId(主管点名的人,即使不在名册)'), .describe('额外带上这几位(userId),即使他们不在名册里 —— 他点名要某人时用。'),
}, },
}, },
async ({ clinicId, months, include }) => async ({ clinicId, months, include }) => {
jsonResult( const r = await this.roster.list(scope, resolveClinicId(scope, clinicId), {
await this.roster.list(scope, resolveClinicId(scope, clinicId), {
months, months,
extraUserIds: include, extraUserIds: include,
}), });
/**
* ⭐ **名册自己标出哪一行是他** —— 2026-08-14 加。
*
* 主管常常**自己也在名册里**(实测:3 位在岗里有一位就是当前登录人)。
* 在此之前提示词末尾挂着一句「当前登录人:某某」,让模型拿名字去比对 ——
* 那是把一件结构能表达的事交给字符串匹配,而且那句话还给了它称呼的抓手。
* ⇒ 名字放回它唯一有用的地方:**这一行是不是他**由数据说,⛔ 不由模型认。
* ⚠️ 只在**模型这一侧**加(REST 那份不动):它是给模型读的投影,
* ⛔ 不是 `AgentInfo` 的新字段(那会让每个消费方都得处理它)。
*/
return jsonResult({
...r,
agents: r.agents.map((a) =>
a.userId === scope.userId ? { ...a, 就是你本人: true } : a,
), ),
});
},
); );
server.registerTool( server.registerTool(
'get_cohort_attributes', 'get_cohort_attributes',
{ {
description: description:
'看**当前这批人**的画像分布 —— 主管在调整阶段说「只要商保直付的」「排掉怕疼的」时,' + /**
'先用它拿到「哪些口子可切、各多少人」,再据实回话,然后用同样的条件重出确认单。' + * 🔴 2026-08-14 删掉开头的场景举例(原文:「主管在调整阶段说
* 『只要商保直付的』『排掉怕疼的』时…」)。
*
* ⚠️ **那两个举例被模型逐字念给主管听了** —— 实测两轮正文里出现
* 「也可以只圈其中一类(**比如只要商保直付的**、排掉某些情况的)」,
* 而那一版的数据里根本没有商保直付这一类。
* ⇒ 工具描述里的**正例**("他会这么说")会被当成给用户的例子复述,
* 而它过期或不适用时不会报错。⛔ 正例不留,反例可留
* (下面 `noTag` 那条里的「32 人有商保标签」是反例,⛔ 别一起删)。
*/
'看**当前这批人**的画像分布:哪些口子可切、各多少人。' +
'\n他要按画像收窄、但没说清切哪一刀时先用它,拿到分布再据实回话,' +
'然后用同样的条件重出一版。' +
'\n⚠️ **`noTag` 是「没有这条画像证据」的人数,不是反面** —— ' + '\n⚠️ **`noTag` 是「没有这条画像证据」的人数,不是反面** —— ' +
'「32 人有商保标签」剩下的**不是自费**,是没证据。⛔ 绝不能说成「其余 68 人自费」。' + '「32 人有商保标签」剩下的**不是自费**,是没证据。⛔ 绝不能说成「其余 68 人自费」。' +
'\n⚠️ 标了 `multi` 的维度一个人可命中多项,合计会大于 cohortSize,**别拿它算百分比**。' + '\n⚠️ 标了 `multi` 的维度一个人可命中多项,合计会大于 cohortSize,**别拿它算百分比**。' +
'\n⚠️ 专属客服只报有/无,**不判在岗** —— 谁休假谁离职以主管说的为准,别替他挡人。' + '\n⚠️ 专属客服只报有/无,**不判在岗** —— 谁休假谁离职以他说的为准,别替他挡人。' +
`\n可点名的维度(keys,不传给一组常用的):${COHORT_DIM_CATALOG}`, `\n可点名的维度(keys,不传给一组常用的):${COHORT_DIM_CATALOG}`,
inputSchema: { inputSchema: {
clinicId: CLINIC_ID_SCHEMA, clinicId: CLINIC_ID_SCHEMA,
...@@ -370,24 +458,20 @@ export class McpServerFactory { ...@@ -370,24 +458,20 @@ export class McpServerFactory {
.enum(TEMPERATURE_TOOL_VALUES as unknown as [string, ...string[]]) .enum(TEMPERATURE_TOOL_VALUES as unknown as [string, ...string[]])
.optional() .optional()
.describe(TEMPERATURE_TOOL_DESC), .describe(TEMPERATURE_TOOL_DESC),
// 🔴 口径必须跟着人群走 —— 属性分布与确认单**必须是同一批人**(T6a 同源保证)。
// 少这一个参数:主管按末诊圈的批,属性分布却按诊断算,两个数字都"对"但不是同一群人。
anchorMode: z
.enum([AnchorMode.DIAGNOSIS, AnchorMode.LAST_VISIT])
.optional()
.describe(ANCHOR_MODE_TOOL_DESC),
personaTags: z.string().optional().describe(PERSONA_TAGS_DESC), personaTags: z.string().optional().describe(PERSONA_TAGS_DESC),
keys: z.array(z.string()).optional().describe('只看这些维度(见描述里的维度清单)'), keys: z
.array(z.string())
.optional()
.describe('只看这几个维度,取值见本工具说明末尾那份维度清单。不传给一组常用的。'),
}, },
}, },
async ({ clinicId, potentialTreatment, temperature, anchorMode, personaTags, keys }) => { async ({ clinicId, potentialTreatment, temperature, personaTags, keys }) => {
const r = await this.cohorts.describe( const r = await this.cohorts.describe(
scope, scope,
{ {
clinicId: resolveClinicId(scope, clinicId), clinicId: resolveClinicId(scope, clinicId),
...(potentialTreatment ? { potentialTreatment } : {}), ...(potentialTreatment ? { potentialTreatment } : {}),
...(temperature ? { temperature } : {}), ...(temperature ? { temperature } : {}),
anchorMode: parseAnchorMode(anchorMode),
...(personaTags ? { personaTags } : {}), ...(personaTags ? { personaTags } : {}),
}, },
{ ...(keys ? { keys } : {}) }, { ...(keys ? { keys } : {}) },
...@@ -395,7 +479,7 @@ export class McpServerFactory { ...@@ -395,7 +479,7 @@ export class McpServerFactory {
return jsonResult({ return jsonResult({
...r, ...r,
// ⭐ 条件回显 —— 模型用码调工具,但要用中文回话。不给回显它就只能自己翻译。 // ⭐ 条件回显 —— 模型用码调工具,但要用中文回话。不给回显它就只能自己翻译。
criteriaZh: criteriaZh({ potentialTreatment, temperature, anchorMode }), criteriaZh: criteriaZh({ potentialTreatment, temperature }),
dimensions: r.dimensions.map(zhCohortDim), dimensions: r.dimensions.map(zhCohortDim),
}); });
}, },
...@@ -410,11 +494,20 @@ export class McpServerFactory { ...@@ -410,11 +494,20 @@ export class McpServerFactory {
'list_assignment_batches', 'list_assignment_batches',
{ {
description: description:
'我分过的批次列表 + 每批汇总(分了多少 / 已处理 / 还在手 / 已退回 / 涉及几个客服)。' + // ⚠️ 原文第一句是「**我**分过的批次」——「我」指谁在这句里是含糊的
'\n回答「我分的那些批,哪批出问题了」。要看某批细节再用 get_assignment_detail。' + // (说话人是工具描述,读的人是模型),而下面 `mine` 恰恰就是"只看自己发起的"。
// 一含糊就会连带 `mine` 的默认值一起被猜错。
'已经分下去的批次列表 + 每批汇总(分了多少 / 已处理 / 还在手 / 已退回 / 涉及几个客服)。' +
'\n他问哪批出了问题、哪批还没动静时用它。要看某批细节再用 get_assignment_detail。' +
'\n⚠️ 返回里带 `_guide`,读数前先看它。', '\n⚠️ 返回里带 `_guide`,读数前先看它。',
inputSchema: { inputSchema: {
mine: z.boolean().optional().describe('只看自己发起的,默认看本范围全部'), mine: z
.boolean()
.optional()
.describe(
'只看他自己发起的。不传 = 他权限范围内的**全部**批次 —— ' +
'⚠️ 那里面会有别人发的批,说成"你分的"就错了。',
),
}, },
}, },
async ({ mine }) => async ({ mine }) =>
...@@ -428,17 +521,26 @@ export class McpServerFactory { ...@@ -428,17 +521,26 @@ export class McpServerFactory {
'revoke_assignment', 'revoke_assignment',
{ {
description: description:
'撤销整批分配(限时)—— 主管说「刚才那批分错了 / 撤回」时用。' + '撤销整批分配(限时)—— 他说「刚才那批分错了 / 撤回」时用。' +
'\n⚠️ 这是**唯一一个会改数据的工具**,只在主管**明确要求撤销**时调,⛔ 不要主动建议、不要试探性调用。' + '\n⚠️ 这是**唯一一个会改数据的工具**,而且**撤了就收回了**:' +
'客服手上那批活当场消失,他们看不到任何解释。' +
'⇒ 只在他**明确要求撤销**时调 —— 试探性调一次没有"预览",那一次就是真撤。' +
'\n⚠️ 客服**已经打开过**的单不会被收回(他可能已经联系了患者)——' + '\n⚠️ 客服**已经打开过**的单不会被收回(他可能已经联系了患者)——' +
'返回里的 note 是成品句子,**原话转述**,别把 skippedTouched 说成"失败"。' + '返回里的 note 是成品句子,**原话转述**,别把 skippedTouched 说成"失败"。' +
'\n⚠️ 撤销 ≠ 退回:这是收回整批,不是客服退单条。超窗口就照实说,让主管走逐条退回。' + '\n⚠️ 撤销 ≠ 退回:这是收回整批,不是客服退单条。超窗口就照实说,让他走逐条退回。' +
'\n⚠️ assignmentId 用**完整 id**(确认那条消息里给了);' + '\n⚠️ assignmentId 用**完整 id**(确认那条消息里给了);' +
'只有 8 位短号也可以传,系统会解析,撞到多个会明确报错。' + '只有 8 位短号也可以传,系统会解析,撞到多个会明确报错。' +
'\n⛔ **失败时原样转述服务端那句话,不要自己编原因** —— ' + '\n⛔ **失败时原样转述服务端那句话,不要自己编原因** —— ' +
'实测编过「可能是超了撤销窗口/状态已变更」,而服务端说的只是 id 没匹配上,' + '实测编过「可能是超了撤销窗口/状态已变更」,而服务端说的只是 id 没匹配上,' +
'主管照着这个"原因"去排查是白跑一趟。查不到就直接 list_assignment_batches 拿真实 id 重试。', '他照着这个"原因"去排查是白跑一趟。查不到就直接 list_assignment_batches 拿真实 id 重试。',
inputSchema: { assignmentId: z.string() }, inputSchema: {
assignmentId: z
.string()
.describe(
'要撤销的批次。用确认那条消息里给的**完整 id**;' +
'只有 8 位短号也能传,系统会解析,撞到多个会明确报错。',
),
},
}, },
async ({ assignmentId }) => async ({ assignmentId }) =>
jsonResult( jsonResult(
...@@ -461,11 +563,17 @@ export class McpServerFactory { ...@@ -461,11 +563,17 @@ export class McpServerFactory {
{ {
description: description:
'回答「这个患者**为什么**分给了他」—— 从分配当时的决策快照查,⛔ 不要自己推理。' + '回答「这个患者**为什么**分给了他」—— 从分配当时的决策快照查,⛔ 不要自己推理。' +
'\n⚠️ 只回**已经确认落库**的分配;还在确认单上的那批,明细在卡片里,主管自己看得到。' + // ⚠️ 只说"那一版还没分下去,不在这里查",⛔ 不说它在界面的哪个部件里:
// 这个工具不是确认单的生产者,让它知道确认单长什么样就是**反向耦合**
// (呈现方式一改,这句话就过期,而过期不报错)。
'\n⚠️ 只回**已经确认分下去**的分配。还没确认的那一版不在这里 —— ' +
'它的明细他手上就有,⛔ 别为了回答这个问题去重出一版。' +
'\n⚠️ 回的是**分配当时**的情况(专属客服会被摄入覆盖、优先级分会被引擎就地改分,' + '\n⚠️ 回的是**分配当时**的情况(专属客服会被摄入覆盖、优先级分会被引擎就地改分,' +
'两者都不留痕)—— ⛔ 别拿"现在"的值去解释"当时"的决定。' + '两者都不留痕)—— ⛔ 别拿"现在"的值去解释"当时"的决定。' +
'\n先用 find_patient 拿到 patientId。', '\n先用 find_patient 拿到 patientId。',
inputSchema: { patientId: z.string() }, inputSchema: {
patientId: z.string().describe('要问的那位患者,先用 find_patient 拿到。'),
},
}, },
async ({ patientId }) => { async ({ patientId }) => {
await this.assertPatientInScope(scope, patientId); await this.assertPatientInScope(scope, patientId);
...@@ -483,7 +591,11 @@ export class McpServerFactory { ...@@ -483,7 +591,11 @@ export class McpServerFactory {
'单个批次的全貌:处理进度 + 按客服拆 + 退回原因分布 + 通话成效 + 逐条明细与电话纪要。' + '单个批次的全貌:处理进度 + 按客服拆 + 退回原因分布 + 通话成效 + 逐条明细与电话纪要。' +
'\n回答「我分的那批怎么样了 / 哪个患者为什么退回 / 客服怎么说的」。' + '\n回答「我分的那批怎么样了 / 哪个患者为什么退回 / 客服怎么说的」。' +
'\n⚠️ 返回里带 `_guide` —— **读数之前先看它**,里面是这批数容易被误读的地方。', '\n⚠️ 返回里带 `_guide` —— **读数之前先看它**,里面是这批数容易被误读的地方。',
inputSchema: { assignmentId: z.string() }, inputSchema: {
assignmentId: z
.string()
.describe('要看哪一批。id 从 list_assignment_batches 取,或用确认那条消息里给的那个。'),
},
}, },
async ({ assignmentId }) => { async ({ assignmentId }) => {
const d = await this.assignments.detail(scope, assignmentId); const d = await this.assignments.detail(scope, assignmentId);
......
import { import {
ANCHOR_MODE_META,
TEMPERATURE_META, TEMPERATURE_META,
planScenarioLabel, planScenarioLabel,
potentialTreatmentCardLabel, potentialTreatmentCardLabel,
type AnchorModeValue,
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
...@@ -49,12 +47,6 @@ export function zhScenario(code: string | null | undefined): string | null { ...@@ -49,12 +47,6 @@ export function zhScenario(code: string | null | undefined): string | null {
return planScenarioLabel(code); return planScenarioLabel(code);
} }
/** 档位口径 → 中文(`last_visit` → 「按末诊」) */
export function zhAnchorMode(code: string | null | undefined): string | null {
if (!code) return null;
return ANCHOR_MODE_META[code as AnchorModeValue]?.zh ?? code;
}
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
// 条件回显 // 条件回显
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
...@@ -71,15 +63,12 @@ export function zhAnchorMode(code: string | null | undefined): string | null { ...@@ -71,15 +63,12 @@ export function zhAnchorMode(code: string | null | undefined): string | null {
export function criteriaZh(input: { export function criteriaZh(input: {
potentialTreatment?: string | null; potentialTreatment?: string | null;
temperature?: string | null; temperature?: string | null;
anchorMode?: string | null;
}): Record<string, string> { }): Record<string, string> {
const out: Record<string, string> = {}; const out: Record<string, string> = {};
const t = zhTreatment(input.potentialTreatment); const t = zhTreatment(input.potentialTreatment);
const temp = zhTemperature(input.temperature); const temp = zhTemperature(input.temperature);
const anchor = zhAnchorMode(input.anchorMode);
if (t) out['治疗项'] = t; if (t) out['治疗项'] = t;
if (temp) out['时间档'] = temp; if (temp) out['时间档'] = temp;
if (anchor) out['口径'] = anchor;
return out; return out;
} }
......
...@@ -28,10 +28,19 @@ const PERSONA_TAGS_HELP = PERSONA_TAG_FILTER_DIMS.map((d) => ...@@ -28,10 +28,19 @@ const PERSONA_TAGS_HELP = PERSONA_TAG_FILTER_DIMS.map((d) =>
: `${personaTagDimId(d)}(${d.nameZh}): ${d.options.map((o) => `${o.value}=${o.zh}`).join(' / ')}`, : `${personaTagDimId(d)}(${d.nameZh}): ${d.options.map((o) => `${o.value}=${o.zh}`).join(' / ')}`,
).join('\n'); ).join('\n');
/**
* 🔴 2026-08-14 补上「码不许说出口」那一句 —— 此前它**只在系统提示词的公共层**里
* (以 `personaTags` 为例被点名)。公共层是主管和客服共用的,而这些码只有圈人这条线才有,
* 把它们摆进公共层等于让另一条线的人也读一遍与自己无关的词。
* ⇒ 约束挪到**产生这些码的那一格参数上**:模型读到码的同一眼就读到怎么说。
* `POTENTIAL_TREATMENT_DESC` / `TEMPERATURE_TOOL_DESC` 早就是这么写的,这里是漏的那一份。
*/
export const PERSONA_TAGS_DESC = export const PERSONA_TAGS_DESC =
'画像圈人(可选):"key:value" 逗号串,同维多选 OR、跨维 AND。' + '画像圈人(可选):"key:value" 逗号串,同维多选 OR、跨维 AND。' +
'⛔ **只能用下表里的 key 和 value**,写别的会被静默忽略(筛选等于没加,人数却照样返回)。' + '⛔ **只能用下表里的 key 和 value**,写别的会被静默忽略(筛选等于没加,人数却照样返回)。' +
'可用维度与取值:\n' + '\n🔴 ⛔ **这些 key 和 value 只用于调工具,一个字都不许说给主管** —— ' +
'回话一律用下表右边的中文说法。' +
'\n可用维度与取值:\n' +
PERSONA_TAGS_HELP; PERSONA_TAGS_HELP;
/** /**
......
import { AssignStrategy, DAILY_CALLS_PER_AGENT, type AssignmentProposal } from '@pac/types'; import {
// ⚠️ 只借它的三个纯标签函数(取值码 → 中文),⛔ 不引 mcp 的任何服务/状态。 AssignStrategy,
import { zhAnchorMode, zhTemperature, zhTreatment } from '../mcp/model-facing'; DAILY_CALLS_PER_AGENT,
TEMPERATURE_SINCE_ZH,
type AssignmentProposal,
type SheetSnapshot,
} from '@pac/types';
// ⚠️ 只借它的纯标签函数(取值码 → 中文),⛔ 不引 mcp 的任何服务/状态。
import { zhTemperature, zhTreatment } from '../mcp/model-facing';
// 引导节点的中文标识(内部 key → 给模型用的名字),单一真源见那边的注释
import { GUIDANCE_KIND } from './assignment-signals';
/** /**
* assignment-facts —— 把一份确认单投影成**给模型的结构化事实**。 * assignment-facts —— 把一份确认单投影成**给模型的结构化事实**。
...@@ -27,32 +35,124 @@ import { zhAnchorMode, zhTemperature, zhTreatment } from '../mcp/model-facing'; ...@@ -27,32 +35,124 @@ import { zhAnchorMode, zhTemperature, zhTreatment } from '../mcp/model-facing';
/** 给模型的事实投影。⚠️ 纯函数,输入一份提案、输出一个可序列化对象。 */ /** 给模型的事实投影。⚠️ 纯函数,输入一份提案、输出一个可序列化对象。 */
export function modelFacts(p: AssignmentProposal): Record<string, unknown> { export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
/// ⚠️ **本批分到人的客服数**,⛔ 不是在岗名册人数 —— 名册走 `p.rosterCount`。
/// 2026-08-15 实测:拿这个数去填「在岗 N 人 × …」印出了「在岗 9 人」,
/// 而主管同屏写着 33 位在岗(沿革见 `rosterCount` 的字段注释)。
const agents = p.byAgent.length; const agents = p.byAgent.length;
/**
* 引导节点按 `stage` 分组落地,每条带一个**中文语义标识**。
*
* 🔴 2026-08-14 由**序号**改成**标识**(沿革见 `assignment-signals.ts` 的 `GUIDANCE_KIND`):
* 序号没有语义,模型得自己维护"我讲的这段是第几条"这套记账;标识让它按**意思**对上。
* 裸台实测:去掉编号、工具改无参数之后,三条引导它只讲了一条也只调了一次 ——
* 标识不只是句柄,它承载「这是可以逐个处理的几件事」。
* ⚠️ 标识放在**每条的第一位**:键序就是模型讲述的顺序,标识要先于内容出现。
* ⛔ 别把 `signal.key` 直接给模型(`pending` / `batch_size_basis` 是内部码)——
* 中文标识即使被念出口,读起来也是一句正常的话。
*/
const pick = (stage: 'cohort' | 'size' | 'dispatch') =>
p.signals
.filter((s) => s.tier === 'action' && s.stage === stage)
.map((s) => ({
'标识': GUIDANCE_KIND[s.key] ?? s.key,
'是什么': s.title,
'为什么': s.why,
'不处理会怎样': s.defaultLabel,
}));
const dedicated = p.byAgent.reduce((s, a) => s + a.dedicated, 0); const dedicated = p.byAgent.reduce((s, a) => s + a.dedicated, 0);
const spread = p.byAgent.reduce((s, a) => s + a.spread, 0); const spread = p.byAgent.reduce((s, a) => s + a.spread, 0);
const loads = p.byAgent.map((a) => a.loadAfter); const loads = p.byAgent.map((a) => a.loadAfter);
const perBatch = p.byAgent.map((a) => a.count); const perBatch = p.byAgent.map((a) => a.count);
/**
* 🔴 **精调过的人要点名** —— 否则「为什么李莉只有 5 条」没人答得上来。
*
* ⚠️ 2026-08-14 补。在此之前这件事**只写在 `basisNote` 里,而那个字段没有任何消费方**
* (服务端算一遍、随确认单发到浏览器、然后被丢掉)——
* 也就是说这个能力早就不在了,删掉 basisNote 只是让它显形。
* ⚠️ 卡片上也没有:精调只在确认时随请求回传,主管在界面上看不见它。
* ⇒ 这一段是「他手上这批为什么长这样」的**唯一出处**。
*/
// ⚠️ 名字要从两处找:精调成 0 的人**不在 byAgent 里**(他这轮一条都没拿到,
// 落在 skippedAgents),而"为什么某某一条都没有"恰恰最需要答得上来。
const nameOf = new Map<string, string | null>(
[...p.byAgent, ...p.skippedAgents].map((a) => [a.userId, a.name]),
);
// ⚠️ `?? {}`:老快照里这个字段可能压根没有 —— ⛔ 别假设它一定在
const tuned = Object.entries(p.agentOverrides ?? {}).map(([userId, o]) => ({
'客服': nameOf.get(userId) ?? userId.slice(0, 8),
...(o.maxThisBatch != null ? { '这批最多给他': o.maxThisBatch } : {}),
...(o.expiresInDays != null ? { '他这批的时效天数': o.expiresInDays } : {}),
}));
return { return {
'怎么选的': { '怎么选的': {
'治疗项': zhTreatment(p.potentialTreatment), '治疗项': zhTreatment(p.potentialTreatment),
'时间档': zhTemperature(p.temperature), // ⛔ **仍然不给"口径"这个键** —— 只剩末诊一版锚点(2026-08-13),
'口径': zhAnchorMode(p.anchorMode ?? null), // 单独一个口径字段读起来像"还有别的口径",说它等于让主管读一句永远为真的话。
// 🔴 但 2026-08-14 走查发现:光给「3 年以上」,**它自己说不清是什么的 3 年**。
// 界面上不缺这半句(列头上方就写着按什么分档,位置提供了上下文);
// JSON 里没有位置,模型拿到的是个孤零零的字符串 ⇒ 把**值**补全到自足。
// ⚠️ 补前缀 ≠ 加口径字段:前者让这一个值读得通,后者是新开一个永远为真的键。
'时间档': `${TEMPERATURE_SINCE_ZH} ${zhTemperature(p.temperature)}`,
'符合条件的总人数': p.candidateTotal, '符合条件的总人数': p.candidateTotal,
'本批人数': p.target, '本批人数': p.target,
// ⚠️ 候选不够时 target 被压低,但**基数没变** —— 两个数要分别给, /**
// 不然主管会以为系统偷偷把他的设置改小了。 * ⚠️ 候选不够时 target 被压低,但这个数没变 —— 两个数要分别给,
...(p.target < p.batchSize ? { '您设的基数': p.batchSize } : {}), * 不然主管会以为系统偷偷把他的设置改小了。
*
* 🔴 2026-08-14 实测改名,原键名是 `您设的基数`,两处都错:
* ① 「基数」**就列在提示词的禁说清单里**(只在代码里存在的概念)——
* 而这里是**键名**,模型看到的是紧挨着数字的那个词,禁令根本拦不住:
* 当轮原话「这一格总共就 3 人,比估算的**基数**(120)少得多」。
* ② 「您设的」是**假的**:120 是「在岗 8 × 每天 15 通 × 1 天」算出来的,
* 主管一个数都没设过 —— 他会去找自己什么时候设过 120。
* ⇒ 键名自己说清楚是什么,⛔ 别指望提示词去兜。
*
* 🔴 而且**只在这个数是他的时候才给**(`basis !== 'default'`)。
* 改名之后再测,模型换了个词照说不误:「系统按 3 名在岗客服 × 每天 15 通 × 1 天
* 估的**基数**是 45,但这一格实际只有 5 人」——「基数」同样在禁说清单里。
* ⇒ 光换词治不住:**这个数一给出来,模型就要解释它**。
* 而首次默认(45 = 在岗 3 × 每天 15 通 × 1 天)根本不是他设的,
* 没有"设置被改小"这回事可防 —— 那它就是纯机器细节,⛔ 不该进模型上下文。
* 沿用 / 他自己指定的才要给:那时"我上次说 200,这次怎么只有 44"是**真问题**。
*/
...(p.target < p.batchSize && p.basis !== 'default'
? { '人够的话本来能发': p.batchSize }
: {}),
/**
* ⚠️ 式子后面**必须带上它自己的积**(`= N 人`)。
* 2026-08-15 实测那一版是「在岗 9 人 × 每天 15 通 × 1 天」,紧挨着「本批人数 214」——
* 两个数怎么算都对不上(9×15×1=135),而**没有任何一个字段承载 495**,
* 模型手里根本没有能让这句话自洽的材料,只能去圆。
* ⭐ 带上积之后,「估的是 495、这一格只有 214、所以发 214」三个数**全在事实里**,
* 够它自己说明白 —— ⛔ 不需要再加一条"候选是瓶颈时别讲这个式子"的闸:
* 把数藏起来跟印错一样,都是让主管拿不到真相。
*/
'本批人数怎么来的': '本批人数怎么来的':
p.basis === 'explicit' p.basis === 'explicit'
? '主管这次指定的' ? '主管这次指定的'
: p.basis === 'inherited' : p.basis === 'inherited'
? `沿用 ${p.basisFrom ?? '上一次'}` ? `沿用 ${p.basisFrom ?? '上一次'}`
: `系统默认:在岗 ${agents} 人 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × ${p.expiresInDays} 天`, : `系统默认:在岗 ${p.rosterCount} 人 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × ${p.expiresInDays} 天 = ${p.batchSize} 人`,
'排序': '没被分过的排在前面,其余按优先级从高到低', '排序': '没被分过的排在前面,其余按优先级从高到低',
...(p.candidateTotal > p.target ...(p.candidateTotal > p.target
? { '这轮还没轮到': p.candidateTotal - p.target } ? { '这轮还没轮到': p.candidateTotal - p.target }
: {}), : {}),
/**
* ⭐ 这两组跟着**这一段**走(2026-08-13 产品定)—— 它们本质还在选人,不是派活。
* 放进「要主管定的」时它们挨着「专属客服排满」,而两者之间隔着整整一段
* 「怎么派的」,读起来像不相干的话题被硬凑在一起。
* ⚠️ 键嵌在「怎么选的」里面**就是它们的位置说明** —— ⛔ 别再去提示词里加一条
* 「这几条要讲在第 1 段」:结构能表达的事就别写成规则(规则越多越不被遵守)。
*
* 🔴 **两组必须分开给。** 合成一个键时模型会拿一个标题罩住两组按钮 ——
* 实测走查抓到「还能怎么选:」下面挂着「改时效 / 改每天几通」,
* 而改时效根本不是选人。⇒ 一个键只说一件事。
* ⚠️ 顺序 `人群 → 人数`,⛔ 不许反:换人群会让本批人数重新估一遍
* (实测候选 2509→312 后,本批 495 被压成 312),先调人数那下就白调了。
*/
...(pick('cohort').length > 0 ? { '这一格还能怎么选': pick('cohort') } : {}),
...(pick('size').length > 0 ? { '本批人数还能怎么调': pick('size') } : {}),
}, },
'怎么派的': { '怎么派的': {
'已排好': p.placed, '已排好': p.placed,
...@@ -61,27 +161,90 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> { ...@@ -61,27 +161,90 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
'给手上最空的人的': spread, '给手上最空的人的': spread,
// ⚠️ 「一条都没分到」的人也要给 —— 主管要看见"他不是被漏了" // ⚠️ 「一条都没分到」的人也要给 —— 主管要看见"他不是被漏了"
'本批没分到的客服': p.skippedAgents.length, '本批没分到的客服': p.skippedAgents.length,
/**
* 🔴 **他们手上有多少,必须一起给**(2026-08-15 实测补的)。
*
* 在此之前这里只有一个光秃秃的数字「本批没分到的客服: 1」——
* 主管当场问「那 1 位为什么没轮到」,模型手里**一个字都没有**:
* 姓名和在手量都在 `skippedAgents` 里,但没投影出来;卡片上写着
* 「本批未分到:某某(在手 193)」,而模型看不见卡片。
* ⇒ 它只有两条路:说不知道,或者编一个。
*
* ⭐ 给了这个数,答案就在**它和下面那行的对比**里:没分到的手上 193,
* 而分到的人分完才 49 —— 「水位法一直没轮到他」自己就说清了。
* ⚠️ 给**区间**不给逐人名单:这一格可能有二十几位没分到(实测 24),
* 逐人列出来是一屏噪音,而"为什么"只取决于他们比别人多多少。
* ⛔ 他要问"是谁",那是卡片上就有的一行字。
*/
...(p.skippedAgents.length > 0
? { '他们现在手上有': range(p.skippedAgents.map((a) => a.inHand)) }
: {}),
'时效天数': p.expiresInDays, '时效天数': p.expiresInDays,
'本批每人拿到': range(perBatch), '本批每人拿到': range(perBatch),
'分完之后每人手上': range(loads), '分完之后每人手上': range(loads),
'按每天几通算': DAILY_CALLS_PER_AGENT, '按每天几通算': DAILY_CALLS_PER_AGENT,
'落人规则': '有专属的先回自己人手上;无主的给当前手上最少的那个', '落人规则': '有专属的先回自己人手上;无主的给当前手上最少的那个',
// ⚠️ 他自己设过的那几位要点名 —— 「为什么某某只有 5 条」的答案只在这里
...(tuned.length > 0 ? { '他单独设过的': tuned } : {}),
...(p.pending.length > 0 ? { '专属客服排满、这批没发的': p.pending.length } : {}), ...(p.pending.length > 0 ? { '专属客服排满、这批没发的': p.pending.length } : {}),
/**
* ⚠️ 只给 action 层:info 层是折叠在卡片里的说明,模型复述它纯属噪音。
* ⛔ 不给 options 的技术细节 —— 那是按钮的事,模型说出 `pending.to_owner` 只会吓到主管。
*
* 🔴 **2026-08-14 从顶层搬进这一段。** 键序就是段序,而它属于**派活**:
* `propose_assignment` 的描述里写了讲述顺序(人对不对 → 分完什么样 → 可动手的就近开放),
* 顶层挂着第三个键时模型只会把它讲成第四段,跟描述打架。
* ⚠️ 2026-08-15 实测:那段讲述顺序里的**第一步一度整个丢了**(重写时被顺手删掉),
* 于是「怎么选的」这一段没人讲,它底下那两组按钮被模型丢到了最末尾 ——
* ⇒ **键序对不救场**:段序只在描述里,这边的键序是跟着它走的,⛔ 改一边必须看另一边。
* ⚠️ 顶层从三段变两段,每个可动手的东西都嵌在它要改的那一段里
* (改人群/改人数那两组同理,在「怎么选的」里面)。
*/
...(pick('dispatch').length > 0 ? { '要你定的': pick('dispatch') } : {}),
}, },
// ⚠️ 只给 action 层:info 层是折叠在卡片里的说明,模型复述它纯属噪音。
// ⛔ 不给 options 的技术细节 —— 那是按钮的事,模型说出 `pending.to_owner` 只会吓到主管。
'要主管定的': p.signals
.filter((s) => s.tier === 'action')
.map((s) => ({ '是什么': s.title, '为什么': s.why, '不处理会怎样': s.defaultLabel })),
}; };
} }
/** `[3, 5, 4]` → `'3~5'`;全相等时给单值。空数组给 0。 */ /**
function range(xs: number[]): string { * 草稿态确认单 → **给模型的中文事实**(见 `SheetSnapshotSchema` 上那段:
if (xs.length === 0) return '0'; * 快照由浏览器产,因为确认之前那份状态只存在于卡片里)。
*
* ⚠️ 与 `modelFacts` 同一条纪律:⛔ 一个字的指令都不许有,只给事实。
* ⚠️ 为 0 的项**照给** —— 「待你定的 0 人」和"没有这个键"是两件事:
* 前者是"都处置完了",后者是"不知道"。⛔ 别学别处的 `...(x ? {} : {})` 省略法。
* ⚠️ `已经分下去了` 与 `propose_assignment` 返回值里那个键**同名同义**,⛔ 别起第二个说法。
*/
export function sheetSnapshotFacts(s: SheetSnapshot): Record<string, unknown> {
return {
已经分下去了: s.confirmed,
// ⭐ 「已排好」而不是「已分配」:与卡片上那行字逐字一致(2026-08-13 定的措辞)
已排好: s.placed,
涉及客服: s.agents,
// 🔴 确认时**不会落库** —— 这个数不为 0 就意味着有人会被放掉
待你定的: s.pending,
他移出本批的: s.dropped,
被改派过的: s.moved,
整批时效天数: s.expiresInDays,
单独改过时效的条数: s.rowExpiryOverrides,
本批福利: s.benefit,
每位客服现在几条: s.byAgent.map((a) => ({ 客服: a.name, 条数: a.count })),
};
}
/**
* `[3, 5, 4]` → `{ 最少: 3, 最多: 5 }`;全相等时给单个数字。空数组给 0。
*
* 🔴 **⛔ 别改回 `'3~5'` 这类字符串区间。** 实测(2026-08-12 浏览器):
* 返回 `'4~15'` 时模型把波浪号吞掉,对主管说「每人拿到 **415** 条」——
* 而真值是 4~15(162 条 / 33 位客服)。同一份数据它上一轮读对、这一轮读错,
* 属于**不稳定**,⛔ 不是提示词能修的(加一条「区间要读成 X 到 Y」也只是③级)。
* ⇒ 两个数字分成两个键,JSON 里就**粘不到一起**(①级:结构上不可能)。
*/
function range(xs: number[]): number | { 最少: number; 最多: number } {
if (xs.length === 0) return 0;
const lo = Math.min(...xs); const lo = Math.min(...xs);
const hi = Math.max(...xs); const hi = Math.max(...xs);
return lo === hi ? `${lo}` : `${lo}~${hi}`; return lo === hi ? lo : { 最少: lo, 最多: hi };
} }
/** 落人策略的取值码 → 中文(给明细用;目前 facts 里只用聚合数,留着备用)。 */ /** 落人策略的取值码 → 中文(给明细用;目前 facts 里只用聚合数,留着备用)。 */
......
...@@ -8,8 +8,6 @@ import { ...@@ -8,8 +8,6 @@ import {
type AssignmentProposal, type AssignmentProposal,
type ProposedItem, type ProposedItem,
type Signal, type Signal,
AnchorMode,
type AnchorModeValue,
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
import { calcAge } from '@pac/utils'; import { calcAge } from '@pac/utils';
...@@ -19,7 +17,7 @@ import { resolveClinicId } from '../../common/decorators/resolve-clinic-id'; ...@@ -19,7 +17,7 @@ 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 { computeHighlights, computeSignals } from './assignment-signals'; import { computeNarrowSignal, computeSignals } from './assignment-signals';
import { CohortAttributesService } from './cohort-attributes.service'; import { CohortAttributesService } from './cohort-attributes.service';
/** /**
...@@ -82,55 +80,43 @@ export class AssignmentProposalService { ...@@ -82,55 +80,43 @@ export class AssignmentProposalService {
) {} ) {}
/** /**
* 画像亮点的维度 —— **⛔ 不要一次全给**:16 个维度全比一遍,总有几个偶然超两倍, * 算「还能再筛一刀」那一条。
* 亮点就退化成噪音。这里只留主管真会据此改打法的几个。
* *
* ⚠️ 「折扣锚点」**暂未纳入**:它的值是「最低折扣率 + 日期/项目」,是**连续量**, * 🔴 2026-08-13 换掉了原来的「画像亮点」。旧版要**两次** `dimShares`
* 没有闭集取值 —— 「占比是基线的几倍」无从谈起。要用它得先定分桶 * (这一格 + 同治疗项整个池子),拿倍数/百分点判断"值不值得注意"。
* (如 ≥9折 / 8–9折 / <8折),那是产品判断,⛔ 不在这里替产品拍。 * 现在**一次查询**、不比基线 —— 判断"值不值得"是主管的活,程序只管把刀摆出来。
* ⚠️ 失败不许让整张确认单挂掉:这一条是锦上添花,取不到就不出。
*/ */
private static readonly HIGHLIGHT_DIMS = [ private async narrowSignal(
'rfm',
'referral_champion',
'entitlement_status',
'acquisition_channel',
];
/**
* 算画像亮点 —— 两份分布:**这一格** vs **同治疗项的整个池子**(去掉时间档)。
*
* ⚠️ 失败不许让整张确认单挂掉:亮点是锦上添花,取不到就不出这个节点。
*/
private async highlightSignals(
scope: TenantScopeContext, scope: TenantScopeContext,
criteria: CohortCriteria, criteria: CohortCriteria,
now: Date, now: Date,
/** 这一版是「重新排一版」出来的 —— 出不出由 computeNarrowSignal 判(见那边的 🔴) */
repicked: boolean,
): Promise<Signal[]> { ): Promise<Signal[]> {
// 没有治疗项就没有"同类"可比 —— 拿全池当基线会让种植永远命中
if (!criteria.potentialTreatment) return [];
try { try {
const keys = AssignmentProposalService.HIGHLIGHT_DIMS; const facts = await this.cohorts.narrowFacts(scope, criteria, now);
const { temperature: _drop, ...treatmentWide } = criteria; const sig = computeNarrowSignal(
const [batch, baseline] = await Promise.all([ facts,
this.cohorts.dimShares(scope, criteria, keys, now), {
this.cohorts.dimShares(scope, treatmentWide, keys, now), ...(criteria.personaTags ? { personaTags: criteria.personaTags } : {}),
]); ...(criteria.minSpendYuan ? { minSpendYuan: criteria.minSpendYuan } : {}),
const hits = computeHighlights({ },
batch: { size: batch.size, dims: batch.dims }, repicked,
baseline: { size: baseline.size, dims: baseline.dims }, );
});
/** /**
* ⭐ 这行**必须留**:没有它,「算了但没命中」和「压根没算」在日志里长得一模一样。 * ⭐ 这行**必须留**:没有它,「算了但一条选项都没够着」和「压根没算」
* 实测就吃过这个亏 —— 页面上没有亮点,查日志一片安静,分不清是逻辑没跑 * 在日志里长得一模一样。实测吃过这个亏 —— 页面上没按钮,查日志一片安静,
* 还是数据本来就没偏离(2026-08-12)。 * 分不清是逻辑没跑还是数据本来就不够(2026-08-12)。
*/ */
this.logger.log( this.logger.log(
`画像亮点:本格 ${batch.size} 人 / 同治疗基线 ${baseline.size} 人 → 命中 ${hits.length} 条` + `再筛一刀:候选 ${facts.size} 人` +
(hits.length ? `(${hits.map((h) => h.title).join(' | ')})` : ''), (facts.money ? `,消费均值 ¥${facts.money.avgYuan}(高于它 ${facts.money.above} 人)` : '') +
` → ${sig ? `${sig.options.length} 个选项(${sig.options.map((o) => o.label).join(' | ')})` : '不出这一条'}`,
); );
return hits; return sig ? [sig] : [];
} catch (e) { } catch (e) {
this.logger.warn(`画像亮点算失败(不影响确认单):${e instanceof Error ? e.message : e}`); this.logger.warn(`再筛一刀算失败(不影响确认单):${e instanceof Error ? e.message : e}`);
return []; return [];
} }
} }
...@@ -143,17 +129,22 @@ export class AssignmentProposalService { ...@@ -143,17 +129,22 @@ export class AssignmentProposalService {
potentialTreatment?: string; potentialTreatment?: string;
/** 窗口温度(矩阵 Y 轴)。⚠️ 必须与 potentialTreatment 同时给 */ /** 窗口温度(矩阵 Y 轴)。⚠️ 必须与 potentialTreatment 同时给 */
temperature?: TemperatureValue; temperature?: TemperatureValue;
/**
* 🔴 档位口径(按诊断 / 按末诊)—— **矩阵上切的是哪版,这里就必须是哪版**。
* 同一个「三个月内」两版圈出来的是两批不同的人;不带过来 = 主管看 87 人、
* 确认单给另一批,而且不报错(T14)。缺省 `diagnosis`(历史行为)。
*/
anchorMode?: AnchorModeValue;
/** T9-B 调整阶段主管追加的画像条件(`key:value` 逗号串) */ /** T9-B 调整阶段主管追加的画像条件(`key:value` 逗号串) */
personaTags?: string; personaTags?: string;
/** 只要累计净消费高于这个数(元)的人 —— 「还能再筛一刀」里那条消费选项 */
minSpendYuan?: number;
/** 批次时效天数(基数②);不传 = 沿用上一次分配的值 */ /** 批次时效天数(基数②);不传 = 沿用上一次分配的值 */
expiresInDays?: number; expiresInDays?: number;
/** /**
* 「每人每天打几通」的换算尺 —— 只用来估 N(在岗 × 它 × 时效)。
*
* ⭐ 2026-08-13 加:引导节点上主管填一个数(比如 8),要重出一版。
* ⛔ **不让模型自己乘** —— 它手里有"在岗 33 人""时效 1 天",
* 但报出去的每一个数都必须来自工具计算(T14)。这里由服务端算。
* ⚠️ 它**不是容量上限**、⛔ 不参与落人,和 DAILY_CALLS_PER_AGENT 同性质。
*/
dailyCalls?: number;
/**
* 按客服的**精调**(userId → 覆盖值)。 * 按客服的**精调**(userId → 覆盖值)。
* 「李莉这周带教,这批最多给 5 条」「王强那批给 7 天」。 * 「李莉这周带教,这批最多给 5 条」「王强那批给 7 天」。
* ⚠️ 与基数一样会被沿用(主管的习惯),所以下发时逐人标 `overridden`, * ⚠️ 与基数一样会被沿用(主管的习惯),所以下发时逐人标 `overridden`,
...@@ -199,9 +190,8 @@ export class AssignmentProposalService { ...@@ -199,9 +190,8 @@ export class AssignmentProposalService {
clinicId, clinicId,
...(potentialTreatment ? { potentialTreatment } : {}), ...(potentialTreatment ? { potentialTreatment } : {}),
...(input.temperature ? { temperature: input.temperature } : {}), ...(input.temperature ? { temperature: input.temperature } : {}),
// 🔴 口径必须进 criteria —— cohortWhereSql 靠它决定按哪个锚点圈人
anchorMode: input.anchorMode ?? AnchorMode.DIAGNOSIS,
...(input.personaTags ? { personaTags: input.personaTags } : {}), ...(input.personaTags ? { personaTags: input.personaTags } : {}),
...(input.minSpendYuan ? { minSpendYuan: input.minSpendYuan } : {}),
}; };
assertCohortCriteria(criteria); assertCohortCriteria(criteria);
const [roster, baseline] = await Promise.all([ const [roster, baseline] = await Promise.all([
...@@ -240,14 +230,21 @@ export class AssignmentProposalService { ...@@ -240,14 +230,21 @@ export class AssignmentProposalService {
* ⚠️ 沿用机制本身留着(`resolveBaseline` 仍在读),只是不再喂给 N —— * ⚠️ 沿用机制本身留着(`resolveBaseline` 仍在读),只是不再喂给 N ——
* 产品说的是"先不用取上一次",要开回来只需把 `baseline.batchSize` 接回这里。 * 产品说的是"先不用取上一次",要开回来只需把 `baseline.batchSize` 接回这里。
*/ */
/// 换算尺:主管改过就用他的,否则 15。⚠️ 夹在 1–60,越界的数不该走到乘法里。
const dailyCalls =
input.dailyCalls != null
? Math.min(60, Math.max(1, Math.floor(input.dailyCalls)))
: DAILY_CALLS_PER_AGENT;
const batchSize = const batchSize =
input.targetCount != null input.targetCount != null
? Math.max(0, Math.floor(input.targetCount)) ? Math.max(0, Math.floor(input.targetCount))
: agents.length * DAILY_CALLS_PER_AGENT * expiresInDays; : agents.length * dailyCalls * expiresInDays;
const basis: 'inherited' | 'default' | 'explicit' = const basis: 'inherited' | 'default' | 'explicit' =
input.targetCount != null || input.expiresInDays != null ? 'explicit' : 'default'; input.targetCount != null || input.expiresInDays != null || input.dailyCalls != null
// 按客服精调:本次传的压过沿用的(同一个人两处都有 → 本次赢) ? 'explicit'
const agentOverrides = sanitizeOverrides({ ...baseline.agentOverrides, ...input.agentOverrides }); : 'default';
// 按客服精调:本次传的压过沿用的(同一个人两处都有 → 本次赢);空对象 = 撤掉他的精调
const agentOverrides = mergeOverrides(baseline.agentOverrides, input.agentOverrides);
/// 本批给某人的**名额上限**(没精调 = 不限)。⛔ 只此一处合并,别在 placeAgents / byAgent 各合一遍 /// 本批给某人的**名额上限**(没精调 = 不限)。⛔ 只此一处合并,别在 placeAgents / byAgent 各合一遍
const capOf = (userId: string) => agentOverrides[userId]?.maxThisBatch ?? Infinity; const capOf = (userId: string) => agentOverrides[userId]?.maxThisBatch ?? Infinity;
const expOf = (userId: string) => agentOverrides[userId]?.expiresInDays ?? expiresInDays; const expOf = (userId: string) => agentOverrides[userId]?.expiresInDays ?? expiresInDays;
...@@ -461,40 +458,16 @@ export class AssignmentProposalService { ...@@ -461,40 +458,16 @@ export class AssignmentProposalService {
).length; ).length;
/** /**
* 🔴 **运营结论**(见 schema 的 opsNote 注释)—— 主管扫一眼就能决定按不按确认的三件事。 * ⛔ 这里原来算一段「运营结论」(opsNote):每人拿到几条、分完手上几条、
* 按每天 15 通算最满的要几天、谁拿到的全是自己的老客户。
* *
* ⚠️ ①②**必须分开说**:「分完手上 57~62 条」是总量,「这批给他 1~4 条」是增量。 * 🔴 2026-08-13 整块删掉 —— 同一批数现在有**三个**出口,卡片那份是第三遍:
* 卡片原来只写前者,主管很容易把这一批读成"好大一坨"(实测 拟分 16 条却写着 57~62)。 * ① 模型的正文(modelFacts 的「本批每人拿到 / 分完之后每人手上」);
* ⚠️ 天数是**换算不是承诺**:文案里必须带上「按每天 N 通算」这个前提, * ② 引导节点 `batch_size_basis`(这批多大 = 在岗 × 每天几通 × 时效,可改);
* ⛔ 别写成「需要 4 天」—— 那个 15 是评审口头给的经验值,不是实测(见 DAILY_CALLS_PER_AGENT)。 * ③ 引导节点 `daily_overload`(最忙那位手上共几条、约几天的量)。
* ⛔ 别再加回来:卡片上那个位置现在放**只有卡片能做的事**(拖、点 ×、逐条改时效),
* 那些是模型说不了、也做不成引导节点的。
*/ */
const perAgent = [...byAgent].map(([u, l]) => ({
name: agentById.get(u)?.name ?? u.slice(0, 8),
count: l.length,
dedicated: l.filter((x) => x.assignStrategy === AssignStrategy.DEDICATED).length,
after: (agentById.get(u)?.inHand ?? 0) + l.length,
}));
const rangeText = (ns: number[]) => {
const lo = Math.min(...ns);
const hi = Math.max(...ns);
return lo === hi ? `${lo}` : `${lo}~${hi}`;
};
/// 用**最重**那位算天数 —— 「这批打得完吗」问的是最慢的人什么时候收工,不是平均
const heaviest = perAgent.length > 0 ? Math.max(...perAgent.map((a) => a.after)) : 0;
const daysNeeded = Math.ceil(heaviest / DAILY_CALLS_PER_AGENT);
/// 「这批全是他自己的老客户」—— 这种批次主管基本不用看,可以直接过
const allOwn = perAgent.filter((a) => a.count > 0 && a.dedicated === a.count);
const opsNote =
perAgent.length === 0
? ''
: `**这批 ${items.placed.length} 条 · ${perAgent.length} 位客服**\n` +
`本批每人拿到 ${rangeText(perAgent.map((a) => a.count))} 条;` +
`分完之后手上共 ${rangeText(perAgent.map((a) => a.after))} 条 —— ` +
`按每人每天 ${DAILY_CALLS_PER_AGENT} 通算,最满的那位要 ${daysNeeded} 天打完。` +
(allOwn.length > 0
? `\n${allOwn.length === perAgent.length ? '**每一位**' : `**${allOwn.map((a) => a.name).join('、')}**`}` +
`拿到的全是自己的老客户,这部分不用您费神。`
: '');
/** /**
* ⭐ 先组装事实,再由 `computeSignals` 从这份事实里算引导节点。 * ⭐ 先组装事实,再由 `computeSignals` 从这份事实里算引导节点。
...@@ -509,14 +482,21 @@ export class AssignmentProposalService { ...@@ -509,14 +482,21 @@ export class AssignmentProposalService {
// ⭐ 必须下发 —— 前端确认时要把它写进 criteria 快照,否则批次说不清 // ⭐ 必须下发 —— 前端确认时要把它写进 criteria 快照,否则批次说不清
// 「当时按哪个温度圈的」(矩阵 8×3,同一治疗项三档是三批完全不同的人) // 「当时按哪个温度圈的」(矩阵 8×3,同一治疗项三档是三批完全不同的人)
temperature: input.temperature ?? null, temperature: input.temperature ?? null,
/// ⭐ 口径同理必须下发 —— 前端写进 criteria 快照,批次名也靠它标「按末诊」。 /// ⭐ 主管自己追加的条件 —— 引导节点靠它认出「这一版是收窄来的」(见 schema 那段)
/// ⛔ 别省:同一个「三个月内」两版是两批人,事后分不出来就永远说不清了。 narrowedBy:
anchorMode: criteria.anchorMode ?? AnchorMode.DIAGNOSIS, criteria.personaTags || criteria.minSpendYuan
? {
...(criteria.personaTags ? { personaTags: criteria.personaTags } : {}),
...(criteria.minSpendYuan ? { minSpendYuan: criteria.minSpendYuan } : {}),
}
: null,
/// ⭐ 真候选总数(count 出来的),⛔ 不是 ranked.length —— 后者被 fetchLimit 截过 /// ⭐ 真候选总数(count 出来的),⛔ 不是 ranked.length —— 后者被 fetchLimit 截过
candidateTotal, candidateTotal,
target, target,
/// ⭐ 要被记住的是**基数**不是 target —— 候选不够时 target 被压低,那是一批的偶然事实 /// ⭐ 要被记住的是**基数**不是 target —— 候选不够时 target 被压低,那是一批的偶然事实
batchSize, batchSize,
/// ⭐ 乘法里的那个在岗人数,跟着 batchSize 一起下发 —— ⛔ 别在显示侧另找一个数冒充
rosterCount: agents.length,
expiresInDays, expiresInDays,
basis, basis,
basisFrom: baseline.from, basisFrom: baseline.from,
...@@ -546,25 +526,6 @@ export class AssignmentProposalService { ...@@ -546,25 +526,6 @@ export class AssignmentProposalService {
.filter((a) => !byAgent.has(a.userId)) .filter((a) => !byAgent.has(a.userId))
.map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })), .map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })),
rosterNote: roster.rosterNote, rosterNote: roster.rosterNote,
// ⭐ 成品句子,助手照抄(T14 双保险的"工具返回值"那一半)
basisNote: basisNote({
agents: agents.length,
placed: items.placed.length,
inHandTotal,
expiresInDays,
basis,
basisFrom: baseline.from,
batchSize,
target,
loads: [...byAgent].map(([u, l]) => ({
name: agentById.get(u)?.name ?? u.slice(0, 8),
after: (agentById.get(u)?.inHand ?? 0) + l.length,
})),
// 精调过的人要点名 —— 「为什么李莉只有 5 条」必须答得上来
overrides: agents
.filter((a) => agentOverrides[a.userId] != null)
.map((a) => ({ name: a.name ?? a.userId.slice(0, 8), ...agentOverrides[a.userId]! })),
}),
/** /**
* 「**怎么选**」—— ⭐ 说给主管听,不是说给程序员听(2026-08-03 走查)。 * 「**怎么选**」—— ⭐ 说给主管听,不是说给程序员听(2026-08-03 走查)。
* *
...@@ -647,20 +608,22 @@ export class AssignmentProposalService { ...@@ -647,20 +608,22 @@ export class AssignmentProposalService {
* ⚠️ 说的是**换了谁**,⛔ 不是"凑满了没有":这一版重排不改落人规则、不破水位, * ⚠️ 说的是**换了谁**,⛔ 不是"凑满了没有":这一版重排不改落人规则、不破水位,
* 排不进的照样进待分配 —— 卡片形状跟平时一模一样,只是这批人换过了。 * 排不进的照样进待分配 —— 卡片形状跟平时一模一样,只是这批人换过了。
*/ */
opsNote,
refillNote: !input.preferPlaceable refillNote: !input.preferPlaceable
? '' ? ''
: `**已重挑这 ${target} 人**:池子里 ${freeInBatch} 位无主的全换进来了,` + // ⚠️ 中文标点**一律全角**(,;:())—— 这段是直接摆给主管看的,
// 和系统提示词里对模型的要求同一条规矩。半角混在中文里挤成一团,
// 而句号又是全角,一行里宽窄不一,读起来发涩。
: `**已重挑这 ${target} 人**:池子里 ${freeInBatch} 位无主的全换进来了,` +
`其余按优先级用有专属的补齐。\n` + `其余按优先级用有专属的补齐。\n` +
`排好 ${items.placed.length} 人` + `排好 ${items.placed.length} 人` +
(pending.length > 0 (pending.length > 0
? `,还有 ${pending.length} 人的专属客服确实排满了(他们手上已经 ` + ? `,还有 ${pending.length} 人的专属客服确实排满了(他们手上已经 ` +
`${minAfter}~${suggestWaterline} 条),得您定 —— 拖给谁或者移出本批。` `${minAfter}~${suggestWaterline} 条),得您定 —— 拖给谁或者移出本批。`
: `,没有需要您定的了。`), : `,没有需要您定的了。`),
}; };
// ⚠️ 亮点单独查库(两份分布),⛔ 不放进 computeSignals —— 那个函数要保持纯。 // ⚠️ 「再筛一刀」单独查库,⛔ 不放进 computeSignals —— 那个函数要保持纯。
const highlights = await this.highlightSignals(scope, criteria, now); const narrow = await this.narrowSignal(scope, criteria, now, input.preferPlaceable === true);
return { ...proposal, signals: computeSignals(proposal as AssignmentProposal, highlights) }; return { ...proposal, signals: computeSignals(proposal as AssignmentProposal, narrow) };
} }
/** /**
...@@ -716,8 +679,8 @@ export class AssignmentProposalService { ...@@ -716,8 +679,8 @@ export class AssignmentProposalService {
clinicId: string; clinicId: string;
potentialTreatment?: string; potentialTreatment?: string;
temperature?: TemperatureValue; temperature?: TemperatureValue;
anchorMode?: AnchorModeValue;
personaTags?: string; personaTags?: string;
minSpendYuan?: number;
/** /**
* 🔴 **一个搜索框打通五个字段**(2026-08-11 产品定,原来是"搜索框 + 医生下拉 + 客服下拉"三件)。 * 🔴 **一个搜索框打通五个字段**(2026-08-11 产品定,原来是"搜索框 + 医生下拉 + 客服下拉"三件)。
* 命中任一即可:患者姓名 / 病历号 / 主治医生 / 上次就诊医生 / 专属客服名。 * 命中任一即可:患者姓名 / 病历号 / 主治医生 / 上次就诊医生 / 专属客服名。
...@@ -740,8 +703,8 @@ export class AssignmentProposalService { ...@@ -740,8 +703,8 @@ export class AssignmentProposalService {
clinicId, clinicId,
...(input.potentialTreatment ? { potentialTreatment: input.potentialTreatment } : {}), ...(input.potentialTreatment ? { potentialTreatment: input.potentialTreatment } : {}),
...(input.temperature ? { temperature: input.temperature } : {}), ...(input.temperature ? { temperature: input.temperature } : {}),
anchorMode: input.anchorMode ?? AnchorMode.DIAGNOSIS,
...(input.personaTags ? { personaTags: input.personaTags } : {}), ...(input.personaTags ? { personaTags: input.personaTags } : {}),
...(input.minSpendYuan ? { minSpendYuan: input.minSpendYuan } : {}),
}; };
assertCohortCriteria(criteria); assertCohortCriteria(criteria);
...@@ -1290,6 +1253,27 @@ function posInt(v: unknown): number | null { ...@@ -1290,6 +1253,27 @@ function posInt(v: unknown): number | null {
* 快照可能是老版本写的,模型可能吐出 `{capacity: "很多"}`。 * 快照可能是老版本写的,模型可能吐出 `{capacity: "很多"}`。
* 非法值**静默丢弃**而不是抛错:一条坏精调不该让整张确认单出不来。 * 非法值**静默丢弃**而不是抛错:一条坏精调不该让整张确认单出不来。
*/ */
/**
* 沿用的精调 + 本次传的 → 这一批实际用哪套。
*
* ⭐ **空对象 = 撤掉他的精调**(`{ "u1": {} }`)。这是唯一能"恢复正常"的表达:
* 精调会被记住,**省略等于沿用** —— 不传他,上次那条原封不动跟过来。
*
* 🔴 靠的是**先合并、后 sanitize** 这个顺序:展开先把 baseline 里他那条整个换成 `{}`,
* `sanitizeOverrides` 再把两个字段都空的键丢掉。⛔ 别改成
* `{ ...baseline, ...sanitizeOverrides(input) }` —— 那样 `{}` 在合并前就被丢了,
* baseline 原样留下,**撤销当场失效而且不报错**:主管说了「某某恢复正常」,
* 下一批他照旧被卡在 5 条。这个函数存在的唯一理由就是把这个顺序钉住。
* ⚠️ 撤销**只能用空对象**,⛔ 不要教人传一个大数(`maxThisBatch: 999`)去"盖掉" ——
* 那个假值会被记成新的精调,一路沿用下去。
*/
export function mergeOverrides(
baseline: Record<string, { maxThisBatch?: number; expiresInDays?: number }> | undefined,
incoming: Record<string, { maxThisBatch?: number; expiresInDays?: number }> | undefined,
): Record<string, { maxThisBatch?: number; expiresInDays?: number }> {
return sanitizeOverrides({ ...baseline, ...incoming });
}
function sanitizeOverrides( function sanitizeOverrides(
raw: unknown, raw: unknown,
): Record<string, { maxThisBatch?: number; expiresInDays?: number }> { ): Record<string, { maxThisBatch?: number; expiresInDays?: number }> {
...@@ -1315,85 +1299,6 @@ function sanitizeOverrides( ...@@ -1315,85 +1299,6 @@ function sanitizeOverrides(
return out; return out;
} }
/** 沿用的那次分配是哪天(只给日期,精确到秒对主管没意义) */
/**
* 日期 → **人话**(「8 月 6 日」),⛔ 不是 ISO。
* 主管看到「沿用您 2026-08-06 那次」会觉得是机器日志;而这句是说给他听的。
*/
function ymd(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso.slice(0, 10);
return `${d.getMonth() + 1} 月 ${d.getDate()} 日`;
}
/**
* 「**怎么分**」—— ⭐ 说给主管听,不是说给程序员听(2026-08-03 走查)。
*
* ⛔ 不许出现「水位法 / 基数 / 铺平 / 目标水位 / 溢出」这些内部词。
* 主管问的是"凭什么李莉拿这 20 个",答案要能用一句大白话说清:
* **有专属的先回自己人手上,剩下的谁手上活少先给谁**。
*
* ⚠️ 三件事一件都不能少(都是 T14 的要求,只是换了说法):
* ① 分完之后每人手上多少 —— 他要判断会不会压太狠(⛔ 只报事实,不替他下"超载"的判断);
* ② 这两个数(人数/时效)**哪来的** —— 沿用上次 / 第一次估的 / 他自己刚说的,信任度不同;
* ③ 精调过的人要**点名** —— 否则"为什么李莉只有 5 条"没人答得上来。
*/
function basisNote(x: {
agents: number;
placed: number;
inHandTotal: number;
expiresInDays: number;
basis: 'inherited' | 'default' | 'explicit';
basisFrom: string | null;
/// 基数(要被记住的那个);⚠️ 与 target 不同 —— 候选不够时 target 被压低
batchSize: number;
target: number;
loads: Array<{ name: string; after: number }>;
overrides: Array<{ name: string; maxThisBatch?: number; expiresInDays?: number }>;
}): string {
const how =
x.placed > 0
? // ⚠️ ⛔ 别再套两层括号(原文是「…(最多的是康慧捧)(他们原本还有 951 条在手)」)——
// 括号套括号在窄面板里读起来像乱码,而"原本多少条在手"卡片上每一行都写着。
/**
* 🔴 「分完每人 57~62 条」**已挪到卡片的运营结论**(opsNote,2026-08-06)。
* ⛔ 别加回来:① 两处都说就是复述(产品点名过"确认单信息多而重复");
* ② 那个数是**分完之后的在手总量**,而这一段讲的是"这一批怎么分的" ——
* 摆在一起主管会把 57~62 读成"这批给他 57 条"(实测拟分 16 条时就写着 57~62)。
* ⇒ 增量与总量的区分、以及"要打几天",统一由 opsNote 负责。
*/
`**已排好 ${x.placed} 人 · ${x.loads.length} 位客服**\n` +
`有专属的先回自己人手上,其余谁手上活少先给谁;${x.expiresInDays} 天没动自动退回。`
: '';
// ⚠️ 精调必须**点名**:主管看到「李莉只有 5 条」要立刻知道那是他自己设的,不是算错了
const tuned = x.overrides.length
? `其中${x.overrides
.map(
(o) =>
`${o.name}${o.maxThisBatch != null ? `这批最多 ${o.maxThisBatch} 条` : ''}${
o.expiresInDays != null ? `时效 ${o.expiresInDays} 天` : ''
}`,
)
.join('、')}(您单独设过,会一直沿用到改回来)。`
: '';
// ⚠️ 候选不够被压低时要说清"您设的数没变" —— 否则他以为系统偷偷把设置改小了
const capped = x.target < x.batchSize ? `(符合条件的只有这么多,您设的 ${x.batchSize} 没变)` : '';
/**
* 🔴 2026-08-12 起**不再沿用上一次**(产品定)—— `inherited` 这一支保留只为老快照,
* 新提案不会再走到(见 propose 里 batchSize 的注释)。
* ⚠️ 默认值那句必须把**算法本身**说出来(在岗 × 每天 15 通 × 时效)——
* 主管看到「405 人」的第一反应是"怎么这么多",不给算式他只能猜(T14)。
*/
const from =
x.basis === 'inherited' && x.basisFrom
? `人数和时效**沿用您 ${ymd(x.basisFrom)} 那次**${capped},要改直接说(比如「这批 200 人」「给 5 天」)。`
: x.basis === 'explicit'
? `人数和时效是**您这次定的**${capped}。`
: `人数和时效都是**系统默认**${capped} —— 按在岗 ${x.agents} 人 × 每天 ` +
`${DAILY_CALLS_PER_AGENT} 通 × ${x.expiresInDays} 天估的,不是根据历史算的;` +
`觉得不合适直接说个数。`;
return `${how}${tuned}${from}`;
}
function emptyProposal( function emptyProposal(
clinicId: string, clinicId: string,
...@@ -1415,12 +1320,15 @@ function emptyProposal( ...@@ -1415,12 +1320,15 @@ function emptyProposal(
// `total == 0` 是**终止分支**不是节点(见 assignment-agent-flow.md §五)—— // `total == 0` 是**终止分支**不是节点(见 assignment-agent-flow.md §五)——
// 主管唯一能做的是换人群,而那句话由 selectionNote 说。 // 主管唯一能做的是换人群,而那句话由 selectionNote 说。
signals: [], signals: [],
/// 空提案没有节点,这个字段也就无人消费;给 null 而不是省略,是为了让类型收口
narrowedBy: null,
clinicId, clinicId,
potentialTreatment: potentialTreatment ?? null, potentialTreatment: potentialTreatment ?? null,
temperature: temperature ?? null, temperature: temperature ?? null,
candidateTotal: 0, candidateTotal: 0,
target: base.target, target: base.target,
batchSize: base.batchSize, batchSize: base.batchSize,
rosterCount: agents.length,
expiresInDays: base.expiresInDays, expiresInDays: base.expiresInDays,
basis: base.basis, basis: base.basis,
basisFrom: base.basisFrom, basisFrom: base.basisFrom,
...@@ -1430,7 +1338,6 @@ function emptyProposal( ...@@ -1430,7 +1338,6 @@ function emptyProposal(
items: [], items: [],
pending: [], pending: [],
pendingNote: '', pendingNote: '',
opsNote: '',
refillNote: '', refillNote: '',
canRefill: false, canRefill: false,
poolOwnership: null, poolOwnership: null,
...@@ -1438,7 +1345,6 @@ function emptyProposal( ...@@ -1438,7 +1345,6 @@ function emptyProposal(
// 空提案里所有在岗的人都"没分到" // 空提案里所有在岗的人都"没分到"
skippedAgents: agents.map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })), skippedAgents: agents.map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })),
rosterNote, rosterNote,
basisNote: `本批人数 ${base.batchSize} / 时效 ${base.expiresInDays} 天。`,
selectionNote: selectionNote:
agents.length === 0 agents.length === 0
? '该诊所名册为空,无法出分配方案 —— 请主管直接指定客服。' ? '该诊所名册为空,无法出分配方案 —— 请主管直接指定客服。'
......
import { import {
AnchorMode,
DAILY_CALLS_PER_AGENT, DAILY_CALLS_PER_AGENT,
type AssignmentProposal, type AssignmentProposal,
type Signal, type Signal,
} from '@pac/types'; } from '@pac/types';
import type { NarrowFacts } from './cohort-attributes.service';
/** /**
* assignment-signals —— 引导节点的**确定性判定**。 * assignment-signals —— 引导节点的**确定性判定**。
...@@ -39,18 +39,83 @@ export const ASSIGNMENT_INTENTS = { ...@@ -39,18 +39,83 @@ export const ASSIGNMENT_INTENTS = {
PENDING_REMOVE: 'pending.remove', PENDING_REMOVE: 'pending.remove',
/// 改批次时效 /// 改批次时效
EXPIRY_SET: 'expiry.set', EXPIRY_SET: 'expiry.set',
/// 改「每人每天打几通」的估法 —— 它同时决定 N(在岗 × 每日通数 × 时效) /**
DAILY_RATE_SET: 'daily_rate.set', * 🔴 `DAILY_RATE_SET`(daily_rate.set)与 `BATCH_SIZE_SET`(batch_size.set)
/// 改本批人数 * **删于 2026-08-15**,⛔ 别加回来。
BATCH_SIZE_SET: 'batch_size.set', *
/// 换时间档 / 换治疗项(重出一版) * 它们只挂在 `daily_overload` 的两颗**不带数**的按钮上(「改每人每天打几通」
COHORT_WIDEN: 'cohort.widen', * 「减少本批人数」),点下去替主管说的那句话同样不带数
/// 加一个画像条件再圈一版(args.personaTags = `维度:取值`) * (「减少一些」「按更少的通数算」)—— 那个数只能由模型自己拍。
* ⚠️ 同样的两件事由 `BASIS_SET` 覆盖,而它**带输入框、预填当前值**,
* 主管填的那个数会一路传到 `propose_assignment` 由服务端重估 N。
* ⇒ 一个 intent 只该有一种带数的走法。
*/
/**
* 改「本批多大」那个式子里的一项(`args.days` 时效 / `args.rate` 每天几通)——
* **要重跑算法**:N = 在岗 × 每天几通 × 时效,改哪一项人数都跟着变。
*
* 🔴 ⛔ 别拿 `EXPIRY_SET` 去凑:那个只把已经算好的这批人的**到期日**改掉,
* 人数一个不变 —— 而这条节点的原话是「改时效或改每天几通,这批人数都会跟着变」。
* 用错了节点就在说谎(2026-08-13 实测:卡片显示 3 天,节点仍写着 ×1 天,人数没动)。
*/
BASIS_SET: 'basis.set',
// 🔴 曾有一个 `COHORT_WIDEN: 'cohort.widen'`(换时间档 / 换治疗项,重出一版),
// 随 `short_supply` 一起删于 2026-08-14 —— **放宽这件事没有节点会提出来了**:
// 换格子是主管在矩阵上一点的事,矩阵每格都写着人数,卡片上的按钮反而不带数。
// ⛔ 别只删节点留着 intent:留下来的话,下次有人看到前端还接着它,
// 会以为"放宽"这条路还活着(死代码最贵的地方是它看起来还在服役)。
/**
* 收窄这批人再选一版 —— 两种参数形态,**都走这一个 intent**:
* · `args.personaTags` = `维度:取值`(同维度多值用逗号,语义是 OR)
* · `args.minSpendYuan` = 累计净消费下界(元)
* ⚠️ 两者都必须带 `labelZh` + `count`(给人看的中文名与选完剩多少人)——
* ⛔ 缺了就退化成把内部码念给主管听。
*/
COHORT_NARROW: 'cohort.narrow', COHORT_NARROW: 'cohort.narrow',
/// 换档位口径(重出一版) /**
ANCHOR_SWITCH: 'anchor.switch', * 「按别的条件选」—— 让模型去报一遍这批人**还有哪些维度可切、各多少人**。
* ⚠️ 它不改任何东西,只是把 `get_cohort_attributes` 这一步做成按钮 ——
* ⛔ 别让主管自己打字问:固定的一句话做成按钮就没有理解错的余地(G10)。
*/
COHORT_ATTRS_ASK: 'cohort.attrs_ask',
} as const; } as const;
/**
* 引导节点的**语义标识** —— 内部 key → 给模型用的中文名。
*
* ═══ 为什么不是序号,也不是内部 key(2026-08-14 改判)═══════════════
* 原来给模型的是**序号**(`编号: 1`),理由写在 `assignment-facts.ts`:
* 「用序号而不是 signal.key:`highlight:rfm_segment:…` 这种内部码一旦进了模型上下文,
* **早晚会**被它念出口。序号没有语义,念出来也只是个数字。」
*
* 🔴 复核之后这条理由**站不住**,两处都有问题:
* ① 「早晚会」是**预测**,不是事故记录 —— 却写在事故档案那一栏的语气里,
* 被后来的人反复当成既成事实引用。
* ② 它举的 `highlight:rfm_segment:…` **已经不存在了**(那批节点早换成 `narrow`)。
* 现存四个 key 短、干净,念出来也不吓人。
*
* 🔴 而序号是**有代价**的,裸台实测拿到了:把编号整个去掉、工具改成无参数之后,
* 模型**只调了一次**,而且连另外两条的内容都没讲 —— 三条在它眼里失去了个体身份。
* ⇒ 标识不只是句柄,它承载「这是可以逐个处理的几件事」。
*
* ⇒ 结论:**要语义,但要中文的语义**。原来那条理由真正反对的是"丑陋的内部码",
* 而不是"语义"本身 —— 中文标识即使被念出口,读起来也是一句正常的话。
*
* ⚠️ 这也是 `show_guidance` 参数的**取值域**(enum)。判据是固定的四条,所以它是静态的:
* ⛔ 别做成"随每版提案变"—— 工具清单在 propose_assignment 跑之前就建好了。
* ⛔ 改中文名要连着前端一起看:它会出现在模型嘴里,但**不**参与任何匹配
* (界面拿的是服务端解析好的 `signalKey`,见 show_guidance 的 execute)。
*/
export const GUIDANCE_KIND: Readonly<Record<string, string>> = {
pending: '待分配',
narrow: '换个条件选',
batch_size_basis: '本批人数',
daily_overload: '打不完',
};
/** `show_guidance` 的取值域 —— 与上表同源,⛔ 别另写一份。 */
export const GUIDANCE_KINDS = Object.values(GUIDANCE_KIND);
/** 严重度 —— 排序规则必须确定性,⛔ 不许由模型判断。 */ /** 严重度 —— 排序规则必须确定性,⛔ 不许由模型判断。 */
const SEV = { const SEV = {
/// 有人会被静默漏掉 /// 有人会被静默漏掉
...@@ -64,136 +129,172 @@ const SEV = { ...@@ -64,136 +129,172 @@ const SEV = {
} as const; } as const;
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// 画像亮点 // 换个条件选这一格
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
/** 一个维度的分布(结构上兼容 `CohortAttributeDim`,这里只取用得到的字段)。 */
export interface DimShare {
id: string;
nameZh: string;
options: Array<{ value: string; zh: string; count: number }>;
}
/** 比较用的两份分布:这一格 vs 同治疗项整个池子。 */
export interface HighlightSource {
batch: { size: number; dims: DimShare[] };
baseline: { size: number; dims: DimShare[] };
}
/** /**
* 亮点的三道闸 —— 缺任何一道都会让节点变噪音。 * 两道闸 —— **就这两条,没别的了**(2026-08-13 产品定)。
*
* ⚠️ 光看倍数不够:一格 200 人里某项 6 人(3%)对上全池 1%,倍数是 3,
* 但 6 个人不值得主管改话术。⇒ 必须同时卡**绝对人数**。
* ⚠️ 基线太小时比值没有意义(全池只有 20 人,谁占多数都不奇怪)。
*/
const HIGHLIGHT = {
/// 本批占比 ÷ 基线占比 达到几倍才算「亮」(抓**小基数**维度的异常聚集)
RATIO: 2,
/**
* 或者:占比比基线高出这么多个百分点(抓**大基数**维度的偏移)。
* *
* 🔴 2026-08-12 本地实测补的。只用倍数时这条判据**结构上打不着大类**: * 🔴 删掉的是「倍数 ≥2 或高出 ≥12 个百分点」那套偏差判据,以及它要的那份基线分布。
* 朝阳公园的获客渠道分布是 口碑 33% / 集团营销 23% / 走入 22% —— * 删的理由不是它算错了,是**它在替主管判断"这值不值得注意"**——
* 口碑要够 2 倍得占到 66%(现实里不可能),而能翻倍的小类(电商 2.3%) * 而他要的只是「还能怎么切」。⇒ 不比基线,直接把几刀摆出来,切不切他定(T8)。
* 在一格 71 人里连 10 个人都凑不出。⇒ 整个维度永远不会命中。 * ⛔ 别再加回任何"够不够显著"的闸:那等于又替他判断了一次。
* ⚠️ 两条是**或**的关系:倍数管小基数,百分点管大基数,各抓一种偏离。
*/ */
POINTS: 12, const NARROW = {
/// 本批至少要有这么多人命中 /// 候选集至少这么大 —— 人太少时"平均数""最多的那一项"本身就是噪音
MIN_COHORT: 50,
/// 一刀切完至少要剩这么多人,否则这个选项不给
MIN_COUNT: 10, MIN_COUNT: 10,
/// 基线人群至少这么大才拿来当基线
MIN_BASELINE: 50,
/// 最多亮几个(⚠️ 这是**同一个节点内**取前几项,⛔ 不是截断节点本身)
TOP: 3,
} as const; } as const;
/** /**
* 画像亮点 —— **这一格和整体比,哪里不一样**。 * 「还能再筛一刀」—— **一个节点、几个按钮**,挂在「这批人怎么来的」下面。
*
* ⭐ 为什么是一个节点而不是一维一个(2026-08-13 产品定):这几刀回答的是**同一个问题**
* (这批人还能怎么收窄),四个各带标题的节点会把一句话的事撑成一屏,
* 而每个标题说的又都是同一件事。⇒ 合成一条,解释靠按钮文案自己带。
* *
* ⭐ 判据用「相对基线的偏离」而不是绝对占比:占比高不等于有信息。 * 🔴 换的是**这一格选进来哪些人**(`criteria`),⛔ 不是在已排好的那 N 个里再挑 ——
* 「这批 80% 自费」没用(全池也是);「转介绍是平时的 2.6 倍」才是主管会据此改打法的东西。 * 点下去会**重新出一版**,本批人数跟着重新估。措辞见下方 title 那段。
* ⚠️ 基线取**同治疗项的整个池子**,⛔ 不取全池 —— * ⚠️ 每个按钮都把**选完剩多少人**写进文案:换完批次会缩水多少,他点之前就该看见
* 种植的重要价值天然高于补牙,用全池会让种植永远命中。 * (权益那种最高项只占 3% 的维度,切完可能从 495 人剩到 76 人)。
* ⚠️ 比的是**这一格的候选集**(圈定的人),不是最终发出去的那 N 个 —— * ⛔ 不为此加占比闸 —— 人数写在按钮上就是「可见」(②可见可撤):点之前知道会剩多少,
* 两者在候选不够时相同,候选很多时是子集。措辞里因此说「这一格的人里」而不是「这批」。 * 点完嫌少还能重来。
* ⚠️ 2026-08-14:原文这里还写着「重排后 `short_supply` 会兜住」—— **那句话早就不成立了**:
* `short_supply` 加了 `narrowedBy` 闸之后,恰恰在收窄出来的那一版不出;而它现在整条已删。
* ⇒ 拿"另一条判据会兜住"当理由时,去看一眼那条判据当下的闸。
*
* 🔴 **已经切过的维度不再给**(`narrowedBy`,2026-08-13)。主管按消费切完之后,
* 这里原本又会递上「只选消费高于这一格平均(¥5,246)的 78 人」—— 而 ¥5,246
* **正是他那一刀造出来的**(砍掉低端,均值从 ¥720 跳上去)。再点一次又跳一次,
* 是个**自我收窄的螺旋**:每一步都"看起来有依据",其实只是在追自己的尾巴。
*/
export function computeNarrowSignal(
f: NarrowFacts,
/** 主管已经追加过的条件 —— 非空就整条不出(见上方 🔴) */
narrowedBy?: AssignmentProposal['narrowedBy'],
/**
* 这一版是不是「重新排一版」出来的(`preferPlaceable`)。
*
* 🔴 2026-08-14 走查:主管点完「重新排一版」,卡片上写着「**已重挑这 495 人**:
* 池子里 495 位无主的全换进来了」,而它**正上方**还挂着「这一格 2509 人,
* 也可以只选其中一类」—— 点下去这一版整个作废,他刚做的动作白做。
* ⚠️ 闸的本意从来不是"切过 personaTags 才关",是**主管已经在这一版上动过手**:
* 收窄是动手,重挑也是动手。⇒ 两种都关。
*/ */
export function computeHighlights(src: HighlightSource): Signal[] { repicked?: boolean,
const { batch, baseline } = src; ): Signal | null {
if (batch.size === 0 || baseline.size < HIGHLIGHT.MIN_BASELINE) return []; // 🔴 主管已经在这一版上动过手 → 整条不出(收窄见上方三版沿革;重挑见上方参数注释)
if (narrowedBy?.minSpendYuan != null || narrowedBy?.personaTags) return null;
if (repicked) return null;
if (f.size < NARROW.MIN_COHORT) return null;
const options: Signal['options'] = [];
const baseCount = new Map<string, number>(); /**
for (const d of baseline.dims) { * ① 消费 —— 门槛取**这一批自己的平均**,⛔ 不是全库分位。
for (const o of d.options) baseCount.set(`${d.id}:${o.value}`, o.count); *
* 🔴 文案必须写「这一格的平均」:门槛随格子飘得厉害(实测全库平均 ¥4,442,
* 而「拔牙·3 年以上」这一格只有 ¥720,差六倍)。写成「高消费客户」,
* 主管就会拿它跨格子比,比出来的是两把不同的尺子。
* ⚠️ 消费分布**右偏**:均值被少数大额拉高,所以"高于平均"选出的是一成多而不是一半
* (实测 2,509 人里 312 人 = 12%,中位数是 ¥0)。这正合适 —— 要的就是高价值那一撮。
*/
if (f.money && f.money.above >= NARROW.MIN_COUNT) {
const { avgYuan, above } = f.money;
options.push({
label: `只选消费高于这一格平均(¥${avgYuan.toLocaleString('zh-CN')})的 ${above} 人`,
intent: ASSIGNMENT_INTENTS.COHORT_NARROW,
args: {
minSpendYuan: avgYuan,
// ⭐ 给**人**看的:这句会原样进对话框,⛔ 不能出现内部码
labelZh: `消费高于这一格平均 ¥${avgYuan.toLocaleString('zh-CN')}`,
count: above,
},
});
} }
const hits: Array<{ for (const dim of f.dims) {
dim: DimShare; /**
zh: string; * ② 转介绍达人 —— **两个取值合起来**(家庭型 ∪ 社交型)。
value: string; *
count: number; * ⚠️ 这个维度的门槛是「推荐 ≥3 人且带来成交」,两个取值只是分"带的是家人还是外人",
ratio: number; * 主管要切的是"有没有这个能力",不是哪一型。`personaTags` 同维度多选天然是 OR。
points: number; * 🔴 ⛔ 文案只能写「转介绍达人」,**不能写「有过转介绍的」**:后者推荐 1 人也算,
}> = []; * 是另一个判据,现有画像里没有。写错了主管会以为漏了人(实测这一格只有 2 个达人)。
for (const d of batch.dims) { */
for (const o of d.options) { if (dim.id === 'referral_champion') {
if (o.count < HIGHLIGHT.MIN_COUNT) continue; const n = dim.options.reduce((s, o) => s + o.count, 0);
const base = baseCount.get(`${d.id}:${o.value}`) ?? 0; if (n >= NARROW.MIN_COUNT) {
// ⚠️ 基线为 0 时不比 —— 那多半是这个取值刚上线、或者基线口径没覆盖到, options.push({
// ⛔ 不能当成「这一格独有」来报(那会是个凭空的结论)。 label: `只选转介绍达人那 ${n} 人`,
if (base === 0) continue; intent: ASSIGNMENT_INTENTS.COHORT_NARROW,
const share = o.count / batch.size; args: {
const baseShare = base / baseline.size; personaTags: dim.options.map((o) => `${dim.id}:${o.value}`).join(','),
const ratio = share / baseShare; labelZh: '转介绍达人',
const points = (share - baseShare) * 100; count: n,
if (ratio >= HIGHLIGHT.RATIO || points >= HIGHLIGHT.POINTS) { },
hits.push({ dim: d, zh: o.zh, value: o.value, count: o.count, ratio, points }); });
} }
continue;
} }
/**
* ③ 权益身份 / 获客渠道 —— 各给**这一批里最多的那一项**。
*
* ⭐ 为什么是"最多的"而不是"最偏离基线的":主管切这一刀是为了**一批人用同一套话术**
* (同一个渠道进来的人,开场白可以一样)。最多的那一项覆盖面最大,一刀下去最划算。
* ⚠️ 平手时取**闭集声明序里靠前**的那个 —— 稳定优先于"随便挑一个":
* 同一份数据每次算出来的按钮必须一模一样,否则主管刷新一下选项就换了(⛔ 不许不确定)。
*/
const top = dim.options.reduce((a, b) => (b.count > a.count ? b : a));
if (top.count < NARROW.MIN_COUNT) continue;
options.push({
label: `只选${dim.nameZh}是「${top.zh}」的 ${top.count} 人`,
intent: ASSIGNMENT_INTENTS.COHORT_NARROW,
args: { personaTags: `${dim.id}:${top.value}`, labelZh: top.zh, count: top.count },
});
} }
if (hits.length === 0) return [];
if (options.length === 0) return null;
/** /**
* ⚠️ 两种命中口径要能**放在一起排序**:把倍数折算成可比的强度 * ④ 兜底 —— 「按别的条件选」。
* (2 倍 ≈ 10 个百分点的量级),取两者较大的那个。 *
* ⛔ 别只按倍数排 —— 那会让百分点命中的项永远垫底。 * ⭐ 上面几刀是**挑出来的**,不是全部。这一条把剩下十几个维度的门留着:
* 它让模型去调 `get_cohort_attributes` 报一遍各维度各多少人,主管再挑。
* ⚠️ 走 `COHORT_ATTRS_ASK` 而不是让主管自己打字:打字要过「模型理解 → 翻成动作」,
* 而这一句是**固定的**,做成按钮就没有理解错的余地(G10 按钮优先于打字)。
*/ */
const strength = (h: { ratio: number; points: number }) => options.push({ label: '按别的条件选', intent: ASSIGNMENT_INTENTS.COHORT_ATTRS_ASK });
Math.max(h.points, (h.ratio - 1) * 10);
hits.sort((a, b) => strength(b) - strength(a));
return hits.slice(0, HIGHLIGHT.TOP).map((h) => ({ return {
key: `highlight:${h.dim.id}:${h.value}`, key: 'narrow',
severity: SEV.OPPORTUNITY, severity: SEV.OPPORTUNITY,
tier: 'action' as const, tier: 'action',
title: `这一格里「${h.zh}」特别多:${h.count} 人`, stage: 'cohort',
// ⚠️ 只陈述事实。⛔ 不写「建议用它当话术钩子」—— 怎么用是主管的判断。 /**
* 🔴 措辞的要害:这是**换个条件重新选初选人群**,⛔ 不是在已排好的那 N 个里再挑一遍。
*
* 原来写的是「这批 2663 人还能再筛一刀」,产品走查当场读成了
* 「在本批 495 人之后的进一步筛选」—— 「再筛一刀」自带先后顺序的暗示,
* 而「这批」在这条链路里同时指过候选集和本批,正好是最不该含糊的那个词。
* ⇒ 一律说**「这一格」**(他就是从矩阵格子点进来的,短缺那条节点也这么叫)。
*
* ⭐ 动词是**「选」**(2026-08-15 产品定,此前是「圈」,更早是被否掉的「筛」)。
* 理由不是文风:这条线的四步已经叫 `选人 → 分人 → 他确认 → 确认之后`
* (系统提示词、`propose_assignment` 描述、`modelFacts` 键名都用这套),
* 而按钮上写「圈」等于给同一个动作起第二个名字 —— 主管读到的和模型讲的对不上。
* ⛔ 「筛」仍然不许用(自带"在已排好的人里再挑一遍"的先后暗示,走查栽过)。
*/
title: `这一格 ${f.size} 人,也可以只选其中一类`,
// ⚠️ 只陈述事实。⛔ 不写「建议按渠道切」—— 选哪一类是主管的判断(T8)。
why: why:
`占这一格的 ${pct(h.count, batch.size)},而同类治疗整体是 ${pct(baseCount.get(`${h.dim.id}:${h.value}`) ?? 0, baseline.size)}` + '点哪一条都是重新选一版:换的是这一格选进来多少人,本批人数跟着重新估,' +
// ⚠️ 大基数维度说"高出 N 个百分点"比说"1.4 倍"直观;小基数反过来 '不是在已经排好的那些人里再挑。每个选项上的数字是换完之后这一格还剩多少人。',
(h.ratio >= HIGHLIGHT.RATIO defaultLabel: `不处理 = 就按这一格全部 ${f.size} 人来`,
? `,约 ${h.ratio.toFixed(1)} 倍。` options,
: `,高出 ${Math.round(h.points)} 个百分点。`), };
defaultLabel: '不处理 = 照常整批发,不特殊对待',
options: [
{
label: `只圈这 ${h.count} 人`,
intent: ASSIGNMENT_INTENTS.COHORT_NARROW,
args: {
// 给工具用的筛选串(内部码)
personaTags: `${h.dim.id}:${h.value}`,
// ⭐ 给**人**看的两样:中文名 + 圈完有多少人。
// 🔴 缺了它们,替主管说的那句话会变成「只圈符合 referral_champion:yes 的人」——
// 内部码原样进对话框,正是 P0 花一整节要消灭的东西。
labelZh: h.zh,
count: h.count,
},
},
],
}));
}
function pct(n: number, total: number): string {
return total > 0 ? `${Math.round((n / total) * 100)}%` : '0%';
} }
/** /**
...@@ -201,11 +302,11 @@ function pct(n: number, total: number): string { ...@@ -201,11 +302,11 @@ function pct(n: number, total: number): string {
* *
* ⚠️ 纯函数:不查库、不看时钟。所有输入都在 proposal 里 —— * ⚠️ 纯函数:不查库、不看时钟。所有输入都在 proposal 里 ——
* 这样它可以被直接测,也不会因为"算节点时又查了一次"和提案本身对不上(T6a 同源)。 * 这样它可以被直接测,也不会因为"算节点时又查了一次"和提案本身对不上(T6a 同源)。
* ⚠️ 画像亮点需要**额外两份分布**(这一格 vs 同治疗项池子),查库的那一步在调用方, * ⚠️ 「还能再筛一刀」要查这批人的画像分布,查库那一步在调用方(`narrowFacts`),
* 算好之后作为 `highlights` 传进来 —— 本函数保持纯。 * 算好之后作为 `extra` 传进来 —— 本函数保持纯。
*/ */
export function computeSignals(p: AssignmentProposal, highlights: Signal[] = []): Signal[] { export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Signal[] {
const out: Signal[] = [...highlights]; const out: Signal[] = [...extra];
const d = Math.max(1, p.expiresInDays); const d = Math.max(1, p.expiresInDays);
// ── ① 待分配 —— 唯一一个「不处理就真的有人被漏掉」的节点 ────────────── // ── ① 待分配 —— 唯一一个「不处理就真的有人被漏掉」的节点 ──────────────
...@@ -214,16 +315,45 @@ export function computeSignals(p: AssignmentProposal, highlights: Signal[] = []) ...@@ -214,16 +315,45 @@ export function computeSignals(p: AssignmentProposal, highlights: Signal[] = [])
key: 'pending', key: 'pending',
severity: SEV.SILENT_DROP, severity: SEV.SILENT_DROP,
tier: 'action', tier: 'action',
/// 人已经选完了,这是**派活**层面的决定 —— 跟「要主管定的」那一段走
stage: 'dispatch',
title: `${p.pending.length} 人的专属客服这轮已排满`, title: `${p.pending.length} 人的专属客服这轮已排满`,
// ⚠️ 事实。⛔ 不写「建议…」——把患者从专属客服手里挪走是关系层面的决定,只有主管拍板。 // ⚠️ 事实。⛔ 不写「建议…」——把患者从专属客服手里挪走是关系层面的决定,只有主管拍板。
why: '他们有专属客服,但那位客服本轮已经排满了,所以我没有分配他们。', why: '他们有专属客服,但那位客服本轮已经排满了,所以我没有分配他们。',
defaultLabel: '不处理 = 这批不发给他们', defaultLabel: '不处理 = 这批不发给他们',
/**
* 🔴 顺序 = **推荐度 × 使用频度**(2026-08-13 产品定),⛔ 别按代码里顺手的顺序排。
* ① 换无主患者补上 —— 一个专属关系都不动就把这批填满,代价最小;
* ② 铺平给在岗 —— 要动关系,但至少人发出去了;
* ③ 各自归专属客服 —— 等于让专属客服超出水位多接自己的人;
* ④ 移出本批 —— 这批就不发给他们了,后果最重,放最后。
* ⚠️ 第一个位置是**默认视线落点**,放错等于在替主管推荐一个更贵的做法。
*/
options: [ options: [
{ label: '各自归专属客服', intent: ASSIGNMENT_INTENTS.PENDING_TO_OWNER }, /**
{ label: '铺平给在岗', intent: ASSIGNMENT_INTENTS.PENDING_SPREAD }, * 🔴 **补上的人从哪儿来,必须写在按钮上**(2026-08-15 产品定)。
*
* 原来只写「换无专属客服的患者补上」—— 主管很可能**已经收窄过几遍**
* (「只选消费高于 ¥721 的 314 人」),那这一下是从他收窄后的那批里补,
* 还是退回整格重取?按钮不说,他只能猜;而两者差着几千人。
* ⚠️ 判据用 `narrowedBy`(提案里专门记"他追加了什么"的字段),
* ⛔ 别去数 criteria:那里混着矩阵两轴,分不出哪些是他加的。
* ⚠️ 这句话现在**是真的** —— 同日修掉了 refill 不带 `narrowedBy` 的漏
* (前端只回传了矩阵两轴,服务端于是退回整格取人,不报错)。
* ⛔ 改文案前先确认那条链路还带着,否则按钮会比原来更理直气壮地说谎。
*/
...(p.canRefill ...(p.canRefill
? [{ label: '换无主患者补上', intent: ASSIGNMENT_INTENTS.PENDING_REFILL }] ? [
{
label: p.narrowedBy
? '换无专属客服的患者补上(还在你收窄后的这批里)'
: '换无专属客服的患者补上(还是这一格的人)',
intent: ASSIGNMENT_INTENTS.PENDING_REFILL,
},
]
: []), : []),
{ label: '铺平给在岗', intent: ASSIGNMENT_INTENTS.PENDING_SPREAD },
{ label: '各自归专属客服', intent: ASSIGNMENT_INTENTS.PENDING_TO_OWNER },
{ label: '移出本批', intent: ASSIGNMENT_INTENTS.PENDING_REMOVE }, { label: '移出本批', intent: ASSIGNMENT_INTENTS.PENDING_REMOVE },
], ],
}); });
...@@ -250,6 +380,7 @@ export function computeSignals(p: AssignmentProposal, highlights: Signal[] = []) ...@@ -250,6 +380,7 @@ export function computeSignals(p: AssignmentProposal, highlights: Signal[] = [])
key: 'daily_overload', key: 'daily_overload',
severity: SEV.WRONG_EXPECTATION, severity: SEV.WRONG_EXPECTATION,
tier: 'action', tier: 'action',
stage: 'dispatch',
title: `这批发下去,最忙的是${heaviest.name ?? '有人'}:手上共 ${heaviest.loadAfter} 条,约 ${needDays} 天的量`, title: `这批发下去,最忙的是${heaviest.name ?? '有人'}:手上共 ${heaviest.loadAfter} 条,约 ${needDays} 天的量`,
// ⚠️ **必须把数字拆开** —— 「手上 75 条」里有多少是这批带来的、多少是原本就有的, // ⚠️ **必须把数字拆开** —— 「手上 75 条」里有多少是这批带来的、多少是原本就有的,
// 不拆开主管会以为这批一下压了 75 条给他(实测同类问题:增量与总量分不开)。 // 不拆开主管会以为这批一下压了 75 条给他(实测同类问题:增量与总量分不开)。
...@@ -258,69 +389,99 @@ export function computeSignals(p: AssignmentProposal, highlights: Signal[] = []) ...@@ -258,69 +389,99 @@ export function computeSignals(p: AssignmentProposal, highlights: Signal[] = [])
`其中本批 ${heaviest.count} 条、原本在手 ${heaviest.inHandBefore} 条;` + `其中本批 ${heaviest.count} 条、原本在手 ${heaviest.inHandBefore} 条;` +
`按每人每天 ${DAILY_CALLS_PER_AGENT} 通算需要 ${needDays} 天,本批时效定的是 ${d} 天。`, `按每人每天 ${DAILY_CALLS_PER_AGENT} 通算需要 ${needDays} 天,本批时效定的是 ${d} 天。`,
defaultLabel: `不处理 = 就按 ${d} 天发,到期没打完的自动落回池子,下批还能再分`, defaultLabel: `不处理 = 就按 ${d} 天发,到期没打完的自动落回池子,下批还能再分`,
/**
* 🔴 **只留带数的那一个**(2026-08-15 产品定)。
*
* 删掉的是「改每人每天打几通」和「减少本批人数」—— 它们**一个数都不带**:
* 既没算出目标值(不像 `时效改成 ${needDays} 天`),也没有输入框
* (不像 `batch_size_basis` 那两条,各自预填着当前值)。点下去发出的那句话
* 于是也是空的:「这批人数**减少一些**,重新排一版」「每人每天按**更少的通数**算」——
* 那个数只能由模型自己拍,而它没有依据可拍。
* ⇒ 判据与 `cohort.widen` 删除时逐字相同:**按钮不带数 ⇒ 严格弱于他自己说一句**。
* ⚠️ 这两件事他仍然做得到:`batch_size_basis` 那两个输入框(带当前值),
* 以及旁边常驻的「我自己说」。⛔ 别以"少了个入口"为由加回来。
*/
options: [ options: [
{ {
label: `时效改成 ${needDays} 天`, label: `整批时效改成 ${needDays} 天`,
intent: ASSIGNMENT_INTENTS.EXPIRY_SET, intent: ASSIGNMENT_INTENTS.EXPIRY_SET,
args: { days: needDays }, args: { days: needDays },
}, },
{ label: '改每人每天打几通', intent: ASSIGNMENT_INTENTS.DAILY_RATE_SET },
{ label: '减少本批人数', intent: ASSIGNMENT_INTENTS.BATCH_SIZE_SET },
], ],
}); });
} }
// ── ③ 候选不足 ──────────────────────────────────────────────────── // ── ③ 候选不足 —— 🔴 **整条已删(2026-08-14 产品定)** ─────────────────
// //
// ⚠️ 主管设的 N 没变(batchSize),只是这一格没那么多人。 // 曾经有一条 `short_supply`:「这一格只有 41 人,不够 495 人」,配两个按钮
// ⛔ 别把 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') { // 还占着卡片最显眼的位置(走查截图:41 人那一版,引导就压在正文第一段下面)。
out.push({ // ⚠️ 「发不满」仍然要让他知道,但那是**事实**不是**决定**:`modelFacts` 里
key: 'expiry_default', // 「符合条件的总人数 / 本批人数 / 您设的基数」三个数已经说清,模型正文会讲
severity: SEV.INFO, // (实测那一版写的是「基数 495 用不上……这格总共只有 41 人,有多少发多少」)。
tier: 'info', // ⛔ 别再加回来。觉得"发不满"不够显眼,就去看那三个数怎么呈现,
title: `时效 ${d} 天 · 本批 ${p.batchSize} 人,都是系统默认`, // ⛔ 不是再配两个把他刚选的格子丢掉的按钮。
why: `按在岗 ${p.byAgent.length} 人 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × ${d} 天估的,不是根据历史算的。`, //
defaultLabel: '不处理 = 就用这两个数', // ⚠️ 主管设的 N 没变(batchSize),只是这一格没那么多人 ——
options: [{ label: '改天数', intent: ASSIGNMENT_INTENTS.EXPIRY_SET }], // ⛔ 别把 target 回写成基数:点一次小格子,以后所有批次就永远那么小。
}); // (这条与上面的删除无关,它管的是 `propose` 那边怎么记基数,⛔ 别一起删。)
}
// ── ⑤ 档位口径不是默认 ─────────────────────────────────────────── // ── ④ 这批多大是怎么估出来的 ─────────────────────────────────────
//
// 🔴 2026-08-13 从 tier=info 升成 **action**(产品定)。
// 原来它折叠在卡片的「另有 N 条说明」里,而模型的正文早就把同一句话说了
// (「本批人数按在岗 33 人 × 每天 15 通 × 1 天估算」)—— 折叠区等于让主管读两遍。
// ⇒ 折叠区去掉,这条升为引导节点,跟着**正文那一句**走(show_guidance)。
//
// ⚠️ 两个可调的量都给**输入框**,⛔ 不列固定档:天数和通数是连续量,
// 列 3/5/7 天必然漏掉他要的那个,而漏掉那次他只能去打字 → 走模型理解 → 慢且可能错。
// ⚠️ 15(DAILY_CALLS_PER_AGENT)是**换算尺**,⛔ 不是容量上限、⛔ 不参与落人。
// //
// 🔴 同一个「三个月内」,两版口径圈出来是**两批完全不同的人**。 // 🔴 **候选够不着 N 时不出**(2026-08-13):`target = min(batchSize, candidateTotal)`,
// 主管切过口径之后隔一会儿再回来,很容易忘了自己切过。 // 候选 312 / N 495 时**瓶颈是候选不是 N** —— 而这两个按钮预填当前值、往上调:
if (p.anchorMode === AnchorMode.LAST_VISIT) { // 改时效 1→3 天 ⇒ N=1485,改每天几通 15→30 ⇒ N=990,target 都还是 312。
// 点了没有任何反应,而这条的 why 写着「这批人数都会跟着变」⇒ **它在说谎**。
// ⚠️ 判据要用严格 `>`:相等时候选同样是瓶颈(往上调仍然无效)。
if (p.basis === 'default' && p.candidateTotal > p.batchSize) {
out.push({ out.push({
key: 'anchor_nondefault', key: 'batch_size_basis',
severity: SEV.INFO, severity: SEV.INFO,
tier: 'info', tier: 'action',
title: '这批是按「末诊距今」圈的', /// 改的是**这一批发多少**,⛔ 不是选哪些人 —— 与 narrow 分属两个键,别混在一个标题下
why: '默认口径是按医生最后一次提到这个治疗算,两种口径圈出来是两批不同的人。', stage: 'size',
defaultLabel: '不处理 = 保持按末诊', /// ⚠️ 「在岗」= **名册人数**(`rosterCount`),⛔ 不是 `byAgent.length`(本批分到人的客服数)——
options: [{ label: '换回按诊断', intent: ASSIGNMENT_INTENTS.ANCHOR_SWITCH }], /// 2026-08-15 实测这里和 `modelFacts` 两处都印错过,沿革见 `rosterCount` 的字段注释。
title: `本批 ${p.batchSize} 人 = 在岗 ${p.rosterCount} 人 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × ${d} 天`,
// ⚠️ 只陈述事实。⛔ 不写「建议改成 X 天」—— 一批推多大是主管的判断。
why: `这是系统按这个式子估的,不是根据历史算的。改时效或改每天几通,这批人数都会跟着变。`,
defaultLabel: '不处理 = 就按这个数发',
options: [
{
label: '改时效',
intent: ASSIGNMENT_INTENTS.BASIS_SET,
input: { argKey: 'days', unit: '天', min: 1, max: 90, default: d },
},
{
label: '改每天几通',
intent: ASSIGNMENT_INTENTS.BASIS_SET,
input: {
argKey: 'rate',
unit: '通',
min: 1,
max: 60,
default: DAILY_CALLS_PER_AGENT,
},
},
],
}); });
} }
// ⚠️ 「这批是按什么口径选的」那条 info 节点**已删**(2026-08-13):
// 档位口径只剩一版(距上次到诊多久),没有第二种可能就没什么可交代的。⛔ 别加回来。
/** /**
* 确定性排序:severity 升序。 * 确定性排序:severity 升序。
* ⚠️ 用**稳定**的比较(同级保持加入顺序),⛔ 别引入任何随机或时间相关的因素 —— * ⚠️ 用**稳定**的比较(同级保持加入顺序),⛔ 别引入任何随机或时间相关的因素 ——
......
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod'; import { ZodResponse } from 'nestjs-zod';
import { Permission, parseAnchorMode } from '@pac/types'; import { Permission } from '@pac/types';
import { RequirePermission } from '../../common/decorators/permissions.decorator'; import { RequirePermission } from '../../common/decorators/permissions.decorator';
import { TenantScope } from '../../common/decorators/tenant-scope.decorator'; import { TenantScope } from '../../common/decorators/tenant-scope.decorator';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
...@@ -191,7 +191,6 @@ export class AssignmentController { ...@@ -191,7 +191,6 @@ export class AssignmentController {
@Query('clinicId') clinicId: string, @Query('clinicId') clinicId: string,
@Query('potentialTreatment') potentialTreatment?: string, @Query('potentialTreatment') potentialTreatment?: string,
@Query('temperature') temperature?: string, @Query('temperature') temperature?: string,
@Query('anchor') anchor?: string,
@Query('personaTags') personaTags?: string, @Query('personaTags') personaTags?: string,
/// ↓ 一个搜索框打通五个字段(姓名/病历号/主治/上次接诊/专属客服): /// ↓ 一个搜索框打通五个字段(姓名/病历号/主治/上次接诊/专属客服):
/// 只收窄这张表,⛔ 不改变交给助手的人群(见 service 注释) /// 只收窄这张表,⛔ 不改变交给助手的人群(见 service 注释)
...@@ -207,7 +206,6 @@ export class AssignmentController { ...@@ -207,7 +206,6 @@ export class AssignmentController {
clinicId: resolveClinicId(scope, clinicId), clinicId: resolveClinicId(scope, clinicId),
...(potentialTreatment ? { potentialTreatment } : {}), ...(potentialTreatment ? { potentialTreatment } : {}),
...(temperature ? { temperature: temperature as never } : {}), ...(temperature ? { temperature: temperature as never } : {}),
anchorMode: parseAnchorMode(anchor),
...(personaTags ? { personaTags } : {}), ...(personaTags ? { personaTags } : {}),
...(keyword ? { keyword } : {}), ...(keyword ? { keyword } : {}),
...(page ? { page: Number(page) } : {}), ...(page ? { page: Number(page) } : {}),
...@@ -232,9 +230,9 @@ export class AssignmentController { ...@@ -232,9 +230,9 @@ export class AssignmentController {
clinicId: body.clinicId, clinicId: body.clinicId,
...(body.potentialTreatment ? { potentialTreatment: body.potentialTreatment } : {}), ...(body.potentialTreatment ? { potentialTreatment: body.potentialTreatment } : {}),
...(body.temperature ? { temperature: body.temperature as never } : {}), ...(body.temperature ? { temperature: body.temperature as never } : {}),
// ⚠️ 口径原样带回 —— 少了它,「重新排一版」会静默换成另一批人
anchorMode: parseAnchorMode(body.anchorMode),
...(body.personaTags ? { personaTags: body.personaTags } : {}), ...(body.personaTags ? { personaTags: body.personaTags } : {}),
// 🔴 他自己加的那一刀 —— 漏了就是**悄悄退回整格**取人(见 schema 上那段)
...(body.minSpendYuan ? { minSpendYuan: body.minSpendYuan } : {}),
...(body.targetCount ? { targetCount: body.targetCount } : {}), ...(body.targetCount ? { targetCount: body.targetCount } : {}),
...(body.expiresInDays ? { expiresInDays: body.expiresInDays } : {}), ...(body.expiresInDays ? { expiresInDays: body.expiresInDays } : {}),
preferPlaceable: true, preferPlaceable: true,
......
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { import {
AnchorMode,
ANCHOR_MODE_META,
PERSONA_TAG_FILTER_DIMS, PERSONA_TAG_FILTER_DIMS,
personaTagDimId, personaTagDimId,
visitRecencyRange, visitRecencyRange,
VISIT_RECENCY_BUCKETS, VISIT_RECENCY_BUCKETS,
TEMPERATURE_ORDER, TEMPERATURE_ORDER,
type AnchorModeValue,
type PersonaTagFilterDim, type PersonaTagFilterDim,
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
...@@ -46,6 +43,37 @@ import { ...@@ -46,6 +43,37 @@ import {
/** 默认出哪些维度:面板上可见的 + 闭集的。隐藏维度(如治疗敏感「怕疼」)仍可用 keys 显式点名。 */ /** 默认出哪些维度:面板上可见的 + 闭集的。隐藏维度(如治疗敏感「怕疼」)仍可用 keys 显式点名。 */
const DEFAULT_DIMS = PERSONA_TAG_FILTER_DIMS.filter((d) => !d.hidden && !d.dynamic); const DEFAULT_DIMS = PERSONA_TAG_FILTER_DIMS.filter((d) => !d.hidden && !d.dynamic);
/**
* 「还能再筛一刀」参与的闭集维度 —— **⛔ 不要一次全给**。
*
* 16 个维度全摆出来,那不是引导是一张表单。这里只留主管真会据此改打法的三个,
* 加上单独处理的消费(rfm.monetaryCents,连续量,不在这张表里)。
*
* ⛔ **`rfm.segment` 刻意不在**:档位口径改成末诊之后(2026-08-13),
* 时间档与 RFM 的 R 分**完全同尺**(R 档界 730/1095 与档位边界重合)——
* 「3 年以上」那一格里每个人非「低活跃」即「重要挽留」,是**恒等式不是发现**。
* R 已被格子钉死,这个维度上还能动的只有 M(消费)与 F(频次),所以只取消费。
* ⚠️ 「折扣锚点」也不在:它的值是「最低折扣率 + 日期/项目」,是连续量、没有闭集取值。
* 要用它得先定分桶(≥9折 / 8–9折 / <8折),那是产品判断,⛔ 不在这里替产品拍。
*/
const NARROW_DIMS = ['referral_champion', 'entitlement_status', 'acquisition_channel']
.map((k) => PERSONA_TAG_FILTER_DIMS.find((d) => d.key === k))
.filter((d): d is PersonaTagFilterDim => !!d);
/** 「还能再筛一刀」的取数结果 —— 纯事实,⛔ 不含任何"要不要切"的判断。 */
export interface NarrowFacts {
/** 候选集人数(圈定的人,⛔ 不是最终发出去的 N 个) */
size: number;
/** 累计净消费:这批的平均(元,已取整)+ 高于它的人数;没人有 rfm 画像时为 null */
money: { avgYuan: number; above: number } | null;
/** 三个闭集维度各取值命中多少人(命中 0 的已剔掉) */
dims: Array<{
id: string;
nameZh: string;
options: Array<{ value: string; zh: string; count: number }>;
}>;
}
export interface CohortAttributeOption { export interface CohortAttributeOption {
value: string; value: string;
zh: string; zh: string;
...@@ -83,20 +111,14 @@ export class CohortAttributesService { ...@@ -83,20 +111,14 @@ export class CohortAttributesService {
* ⭐ `unknown` 一列必须存在:上线到全量重算跑完之间,老画像没有窗口边界。 * ⭐ `unknown` 一列必须存在:上线到全量重算跑完之间,老画像没有窗口边界。
* ⛔ 把它们并进「冷」能让行合计好看,但那是假分布(见 temperature.ts 第 ③ 条 / T14)。 * ⛔ 把它们并进「冷」能让行合计好看,但那是假分布(见 temperature.ts 第 ③ 条 / T14)。
*/ */
async matrix( async matrix(scope: TenantScopeContext, clinicId: string, now: Date = new Date()) {
scope: TenantScopeContext,
clinicId: string,
now: Date = new Date(),
/// 档位口径(按诊断 / 按末诊)。⚠️ 出确认单时**必须**把同一个值带进 criteria,见 AnchorMode 注释
anchorMode: AnchorModeValue = AnchorMode.DIAGNOSIS,
) {
void now; // 判档一律用 SQL 的 NOW(),⛔ 别把 JS 的时刻掺进来(两个时钟会让边界人群漂) void now; // 判档一律用 SQL 的 NOW(),⛔ 别把 JS 的时刻掺进来(两个时钟会让边界人群漂)
const rows = await this.prisma.$queryRaw< const rows = await this.prisma.$queryRaw<
Array<{ label: string; temp: string | null; n: bigint }> Array<{ label: string; temp: string | null; n: bigint }>
>( >(
Prisma.sql` Prisma.sql`
WITH la AS ( WITH la AS (
${planLabelAnchorsSql(poolBaseSql(scope, clinicId), anchorMode)} ${planLabelAnchorsSql(poolBaseSql(scope, clinicId))}
) )
SELECT label, SELECT label,
${temperatureBucketCaseSql( ${temperatureBucketCaseSql(
...@@ -142,15 +164,10 @@ export class CohortAttributesService { ...@@ -142,15 +164,10 @@ export class CohortAttributesService {
}; };
}), }),
unknownTotal, unknownTotal,
/// ⭐ 口径随数据一起下发 —— 前端拿它渲染列头说明,助手拿它进 criteria。
/// ⛔ 别让前端"自己记得刚才切的是哪版":那等于第二份真理源,刷新一次就漂。
anchorMode,
anchorModeZh: ANCHOR_MODE_META[anchorMode].zh,
note: note:
unknownTotal > 0 unknownTotal > 0
? `另有 ${unknownTotal} 人**算不出档位**(${ ? `另有 ${unknownTotal} 人**算不出档位**(没有末诊记录),已单列,` +
anchorMode === AnchorMode.LAST_VISIT ? '没有末诊记录' : '召回证据里没有可用的诊断日' '⛔ 没有并进任何一档 —— 并进去数字好看但那是假分布。'
}),已单列,⛔ 没有并进任何一档 —— 并进去数字好看但那是假分布。`
: '', : '',
}; };
} }
...@@ -215,22 +232,23 @@ export class CohortAttributesService { ...@@ -215,22 +232,23 @@ export class CohortAttributesService {
`标了 multi 的维度一个人可命中多项,合计会大于 ${cohortSize},别拿它算百分比。` + `标了 multi 的维度一个人可命中多项,合计会大于 ${cohortSize},别拿它算百分比。` +
`专属客服覆盖只报有/无,**不判在岗** —— 谁在休假、谁离职了,以主管说的为准。` + `专属客服覆盖只报有/无,**不判在岗** —— 谁在休假、谁离职了,以主管说的为准。` +
(temperatureUnknown (temperatureUnknown
? `⚠️ 另有 ${temperatureUnknown} 人**算不出温度**(召回证据里没有可用的诊断日),` + ? `⚠️ 另有 ${temperatureUnknown} 人**算不出档位**(没有末诊记录),` +
`所以热/温/冷三档加起来会少这些人。这是重算进度,不是数据缺失,如实说明即可。` `所以各档加起来会少这些人。如实说明即可,⛔ 别并进某一档。`
: ''), : ''),
}; };
} }
/** /**
* 「温度未知」的条数 —— 三档之和对不上总数时,差额在这里,**必须报出来**。 * 「档位未知」的人数 —— 六档之和对不上总数时,差额在这里,**必须报出来**。
* *
* 谁会是未知(2026-08 换源后):召回证据里**没有可用的诊断日**(锚点缺失)。 * 🔴 判据必须与 `labelTemperatureExistsSql` 落不进任何一档的那批人**逐字等价**,
* 换源前的语义是"画像还没算出边界",那个原因已经不存在了 —— 温度改成读时从锚点算, * 否则主管拿六档相加去对总数,差额就是个对不上的数(T14 口径对数)。两种情况:
* 不再依赖画像里存好的边界,所以也**不再有"等重算"这回事**。 * ① **没有末诊记录** → 锚点为 NULL → 判档 CASE 出 NULL(2026-08-13 锚点改末诊后的主因);
* ⛔ 把它们塞进「冷」能让数字对上,但那是**假的分布**(见 temperature.ts 的第 ③ 条 / T14)。 * ② 该治疗项下**没有一条带日期的在跑证据** → 连行都进不来。
* ✅ 报出来,主管看到「另有 N 人温度待重算」,知道这是进度问题不是数据问题。 * 两者的并集 = 不落在六档里的全部人。⛔ 别只查其中一个(改锚点那次就差点只留 ②)。
* ⛔ 把他们塞进「冷」能让数字对上,但那是**假的分布**(见 temperature.ts 第 ③ 条 / T14)。
* *
* 只在指定了治疗项时才有意义(温度必须挂在治疗项上);不指定 → null,不做无谓查询。 * 只在指定了治疗项时才有意义(档位必须挂在治疗项上);不指定 → null,不做无谓查询。
*/ */
private async temperatureUnknownCount( private async temperatureUnknownCount(
scope: TenantScopeContext, scope: TenantScopeContext,
...@@ -246,7 +264,12 @@ export class CohortAttributesService { ...@@ -246,7 +264,12 @@ export class CohortAttributesService {
FROM followup_plans fp FROM followup_plans fp
JOIN patients p ON p.id = fp.patient_id JOIN patients p ON p.id = fp.patient_id
WHERE ${cohortWhereSql(scope, noTemp, now)} WHERE ${cohortWhereSql(scope, noTemp, now)}
AND NOT EXISTS ( AND (
NOT EXISTS (
SELECT 1 FROM patient_profiles pp
WHERE pp.patient_id = fp.patient_id AND pp.last_visit_at IS NOT NULL
)
OR NOT EXISTS (
SELECT 1 SELECT 1
FROM plan_reasons pr FROM plan_reasons pr
CROSS JOIN LATERAL jsonb_array_elements_text(pr.evidence->'factIds') fid CROSS JOIN LATERAL jsonb_array_elements_text(pr.evidence->'factIds') fid
...@@ -255,6 +278,7 @@ export class CohortAttributesService { ...@@ -255,6 +278,7 @@ export class CohortAttributesService {
AND ${labelCaseSql('f', AGE_YEARS_SQL)} = ${criteria.potentialTreatment} AND ${labelCaseSql('f', AGE_YEARS_SQL)} = ${criteria.potentialTreatment}
AND COALESCE(f.occurred_at, f.planned_for) IS NOT NULL AND COALESCE(f.occurred_at, f.planned_for) IS NOT NULL
) )
)
`, `,
); );
return Number(rows[0]?.n ?? 0); return Number(rows[0]?.n ?? 0);
...@@ -262,20 +286,24 @@ export class CohortAttributesService { ...@@ -262,20 +286,24 @@ export class CohortAttributesService {
/** persona 特征维度:一次查完所有点名的 key,在内存里按 dataPath 取值分桶 */ /** persona 特征维度:一次查完所有点名的 key,在内存里按 dataPath 取值分桶 */
/** /**
* 精简版分布 —— 只出「各维度各取值命中多少人」+ 人群规模。 * 「还能再筛一刀」要的四样,**一次查完**。
*
* ⭐ 2026-08-13 换掉了原来的「画像亮点」:那套要比两份分布(这一格 vs 同治疗项池子),
* 靠倍数/百分点判断"值不值得注意"。删掉的理由不是它算错了,是**它在替主管判断**——
* 而他要的只是"还能怎么切"。⇒ 不比基线、不设倍数闸,直接把几刀摆出来。
* 顺带省掉每次出确认单的那次**全治疗项池子扫描**。
* *
* ⭐ 给引导节点的**画像亮点**用(assignment-signals.computeHighlights): * ⚠️ 比的是**候选集**(圈定的人),⛔ 不是最终发出去的那 N 个 ——
* 它要把「这一格的分布」和「同治疗项整个池子的分布」比一比,一次提案要调两回, * 主管要在"这批人"上再切,那个"这批人"就是候选集。
* 而 `describe()` 还会顺带算专属覆盖、温度未知数、成品句子 —— 那些这里都用不上。 * 🔴 消费的平均值必须**取整到元之后再数人**:按钮上写「高于 ¥720 的 312 人」,
* ⚠️ 与 `describe()` **共用同一份 `cohortWhereSql` 与 `personaBreakdown`**, * 而真去筛的时候用的是取整后的 720 —— 两边不同源就会出现"点了之后不是 312 人",
* ⛔ 别在这里另写一套取数:两份必然漂,而漂了不报错(T6a 同源)。 * 且不报错(2026-08-13 设计时就按死,⛔ 别改成先数人后取整)。
*/ */
async dimShares( async narrowFacts(
scope: TenantScopeContext, scope: TenantScopeContext,
criteria: CohortCriteria, criteria: CohortCriteria,
keys: string[],
now: Date = new Date(), now: Date = new Date(),
): Promise<{ size: number; dims: CohortAttributeDim[] }> { ): Promise<NarrowFacts> {
const rows = await this.prisma.$queryRaw<Array<{ patientId: string }>>( const rows = await this.prisma.$queryRaw<Array<{ patientId: string }>>(
Prisma.sql` Prisma.sql`
SELECT DISTINCT fp.patient_id AS "patientId" SELECT DISTINCT fp.patient_id AS "patientId"
...@@ -285,8 +313,49 @@ export class CohortAttributesService { ...@@ -285,8 +313,49 @@ export class CohortAttributesService {
`, `,
); );
const patientIds = rows.map((r) => r.patientId); const patientIds = rows.map((r) => r.patientId);
if (patientIds.length === 0) return { size: 0, dims: [] }; if (patientIds.length === 0) return { size: 0, money: null, dims: [] };
return { size: patientIds.length, dims: await this.personaBreakdown(patientIds, resolveDims(keys)) };
const feats = await this.prisma.$queryRaw<Array<{ key: string; data: unknown }>>(
Prisma.sql`
SELECT pf.key AS "key", pf.data AS "data"
FROM personas pe
JOIN persona_features pf ON pf.persona_id = pe.id
WHERE pe.patient_id = ANY(${patientIds}::uuid[])
AND pe.superseded_at IS NULL
AND pf.key = ANY(${[...NARROW_DIMS.map((d) => d.key), 'rfm']}::text[])
`,
);
// ── 消费:先算平均、取整到元,再拿取整后的门槛数人 ──
const cents: number[] = [];
for (const r of feats) {
if (r.key !== 'rfm') continue;
const v = (r.data as Record<string, unknown> | null)?.monetaryCents;
if (typeof v === 'number') cents.push(v);
}
let money: NarrowFacts['money'] = null;
if (cents.length > 0) {
const avgYuan = Math.round(cents.reduce((a, b) => a + b, 0) / cents.length / 100);
money = { avgYuan, above: cents.filter((c) => c > avgYuan * 100).length };
}
// ── 三个闭集维度:各取值命中多少人 ──
const dims: NarrowFacts['dims'] = [];
for (const dim of NARROW_DIMS) {
const counts = new Map<string, number>();
for (const r of feats) {
if (r.key !== dim.key) continue;
const raw = (r.data as Record<string, unknown> | null)?.[dim.dataPath];
const values = dim.isArray ? (Array.isArray(raw) ? raw : []) : raw != null ? [raw] : [];
for (const v of values) if (typeof v === 'string') counts.set(v, (counts.get(v) ?? 0) + 1);
}
// ⚠️ 只认闭集里声明过的取值 —— 库里冒出来的野值不往主管面前放(与 personaBreakdown 同规矩)
const options = dim.options
.map((o) => ({ value: o.value, zh: o.zh, count: counts.get(o.value) ?? 0 }))
.filter((o) => o.count > 0);
if (options.length > 0) dims.push({ id: personaTagDimId(dim), nameZh: dim.nameZh, options });
}
return { size: patientIds.length, money, dims };
} }
private async personaBreakdown( private async personaBreakdown(
......
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { import {
AnchorMode,
PERSONA_TAG_FILTER_DIMS, PERSONA_TAG_FILTER_DIMS,
expandTemperatureFilter, expandTemperatureFilter,
Temperature, Temperature,
parsePersonaTags, parsePersonaTags,
personaTagDimId, personaTagDimId,
visitRecencyRange, visitRecencyRange,
type AnchorModeValue,
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
...@@ -34,12 +32,18 @@ export interface CohortCriteria { ...@@ -34,12 +32,18 @@ export interface CohortCriteria {
/** 矩阵 Y 轴:窗口温度。⚠️ 必须与 potentialTreatment 同时给,见下方 assert */ /** 矩阵 Y 轴:窗口温度。⚠️ 必须与 potentialTreatment 同时给,见下方 assert */
temperature?: TemperatureValue; temperature?: TemperatureValue;
/** /**
* 🔴 档位口径(按诊断 / 按末诊)—— **必须跟着 temperature 一起传**。 * 只要**累计净消费高于这个数(元)**的人 —— 「还能再筛一刀」里那条消费选项。
* 同一个「三个月内」,两个 mode 圈出来的是**两批不同的人**;这里少传一个字段, *
* 主管在矩阵上按末诊看了 87 人、确认单却按诊断捞了另一批,而且不报错(T14)。 * 🔴 为什么不走 `personaTags`:那是个**等值字典**(`data->>path = value`),
* ⚠️ 缺省 = `diagnosis`(历史行为),老批次 criteria 里没这个键时也走这条。 * 表达不了「大于」。⛔ 别为了复用它去硬造一个 `rfm:gt_720` 之类的伪取值 ——
* 闭集校验会把它静默丢掉(`personaTagsSql` 的 `if (!dim) continue`),筛选等于没加。
* ⚠️ 单位是**元**,一路到这里都是元,只在 SQL 里 ×100 对上 `monetaryCents`。
* ⛔ 别在中途换单位:门槛这个数会原样出现在按钮文案和主管的话里(「消费高于 720 的」),
* 两处单位不一致 = 差 100 倍且不报错。
* ⚠️ 没有 rfm 画像的人**筛不出来**(EXISTS 不成立)—— 那是对的:
* "不知道他花了多少"不等于"他花得多"。
*/ */
anchorMode?: AnchorModeValue; minSpendYuan?: number;
/** 主管在调整阶段追加的画像条件(`key:value` 逗号串,与列表页同格式) */ /** 主管在调整阶段追加的画像条件(`key:value` 逗号串,与列表页同格式) */
personaTags?: string; personaTags?: string;
} }
...@@ -97,11 +101,10 @@ function temperatureSql( ...@@ -97,11 +101,10 @@ function temperatureSql(
potentialTreatment: string, potentialTreatment: string,
temperature: TemperatureValue, temperature: TemperatureValue,
now: Date, now: Date,
mode: AnchorModeValue,
): Prisma.Sql { ): Prisma.Sql {
void now; // 判档一律用 SQL 的 NOW() —— 与矩阵同一个时钟,⛔ 别掺 JS 时刻(边界人群会漂) void now; // 判档一律用 SQL 的 NOW() —— 与矩阵同一个时钟,⛔ 别掺 JS 时刻(边界人群会漂)
// ⚠️ 旧取值 `cold` = 四个冷档的并集(API 契约不断);其余原样单档 // ⚠️ 旧取值 `cold` = 四个冷档的并集(API 契约不断);其余原样单档
return labelTemperatureExistsSql(potentialTreatment, expandTemperatureFilter(temperature), mode); return labelTemperatureExistsSql(potentialTreatment, expandTemperatureFilter(temperature));
} }
/** /**
...@@ -165,6 +168,23 @@ function personaTagsSql(personaTags: string, now: Date): Prisma.Sql { ...@@ -165,6 +168,23 @@ function personaTagsSql(personaTags: string, now: Date): Prisma.Sql {
} }
/** /**
* 累计净消费下界 —— 与 `personaTagsSql` **同一个 EXISTS 形状**(走当前版画像)。
*
* ⚠️ `(pf.data->>'monetaryCents')::bigint` —— 存的是**分**,所以这里 ×100。
* ⛔ 别写成 `::int`:上限 21 亿分 = ¥2,147 万,单个患者够不着但迟早有人踩;bigint 没这个顾虑。
* ⚠️ 用 `>` 不是 `>=` —— 文案说的是「高于平均」,含等号就跟文案对不上了(平均值那个人会被算进去)。
* ⚠️ 键名 `monetaryCents` 与 `rfm.feature.ts` 写进 data 的那个键必须逐字一致,
* ⛔ 拼错不会报错,只会**筛出 0 人**(JSON 取不到键 → NULL → 比较为假)。
*/
function minSpendSql(yuan: number): Prisma.Sql {
return Prisma.sql`AND EXISTS (
SELECT 1 FROM personas pe
JOIN persona_features pf ON pf.persona_id = pe.id AND pf.key = 'rfm'
WHERE ${CURRENT_PERSONA}
AND (pf.data ->> 'monetaryCents')::bigint > ${Math.round(yuan * 100)})`;
}
/**
* 完整的人群过滤片段 —— 调用方拼在 `FROM followup_plans fp JOIN patients p ON p.id = fp.patient_id WHERE …` 之后。 * 完整的人群过滤片段 —— 调用方拼在 `FROM followup_plans fp JOIN patients p ON p.id = fp.patient_id WHERE …` 之后。
*/ */
export function cohortWhereSql( export function cohortWhereSql(
...@@ -178,9 +198,10 @@ export function cohortWhereSql( ...@@ -178,9 +198,10 @@ export function cohortWhereSql(
${c.potentialTreatment ? treatmentSql(c.potentialTreatment) : Prisma.empty} ${c.potentialTreatment ? treatmentSql(c.potentialTreatment) : Prisma.empty}
${ ${
c.temperature && c.potentialTreatment c.temperature && c.potentialTreatment
? temperatureSql(c.potentialTreatment, c.temperature, now, c.anchorMode ?? AnchorMode.DIAGNOSIS) ? temperatureSql(c.potentialTreatment, c.temperature, now)
: Prisma.empty : Prisma.empty
} }
${c.personaTags ? personaTagsSql(c.personaTags, now) : Prisma.empty} ${c.personaTags ? personaTagsSql(c.personaTags, now) : Prisma.empty}
${c.minSpendYuan != null && c.minSpendYuan > 0 ? minSpendSql(c.minSpendYuan) : Prisma.empty}
`; `;
} }
...@@ -18,8 +18,6 @@ import { ...@@ -18,8 +18,6 @@ import {
type AgentWorkloadResponse, type AgentWorkloadResponse,
RELEASE_REASON_META, RELEASE_REASON_META,
REVOKE_WINDOW_MINUTES, REVOKE_WINDOW_MINUTES,
ANCHOR_MODE_META,
parseAnchorMode,
TEMPERATURE_META, TEMPERATURE_META,
potentialTreatmentCardLabel, potentialTreatmentCardLabel,
type TemperatureValue, type TemperatureValue,
...@@ -720,7 +718,6 @@ export class PlanAssignmentService { ...@@ -720,7 +718,6 @@ export class PlanAssignmentService {
const c = (head.criteria ?? {}) as { const c = (head.criteria ?? {}) as {
potentialTreatment?: string; potentialTreatment?: string;
temperature?: string; temperature?: string;
anchorMode?: string;
}; };
const parts: string[] = [formatBatchTime(head.createdAt, tz)]; const parts: string[] = [formatBatchTime(head.createdAt, tz)];
if (c.potentialTreatment) parts.push(potentialTreatmentCardLabel(c.potentialTreatment)); if (c.potentialTreatment) parts.push(potentialTreatmentCardLabel(c.potentialTreatment));
...@@ -736,17 +733,11 @@ export class PlanAssignmentService { ...@@ -736,17 +733,11 @@ export class PlanAssignmentService {
*/ */
const t = c.temperature; const t = c.temperature;
/** /**
* 🔴 **口径要跟着档名一起说**(2026-08-11 两版并存之后)。 * ⚠️ 2026-08-11~13 之间建的批次,名字里可能带着「(按末诊)」——
* 「种植 · 三个月内」按诊断和按末诊是**两批完全不同的人**;名字里不标, * 那是两版口径并存那两天的标记,⛔ 别去洗掉:批次名是主管指认一批的唯一抓手,
* 主管过两周回看这批就分不出当时点的是哪一版 —— 而 criteria 存了、名字不说, * 改名等于把他记得的那个东西弄没了。新批次不再标(只剩一版,标了是废字)。
* 等于把证据锁在库里(批次名是他指认一批的唯一抓手)。
* ⚠️ 只标非默认那版:`diagnosis` 的 batchTag 是空串,老批次不受影响、不用改名。
*/ */
const tag = ANCHOR_MODE_META[parseAnchorMode(c.anchorMode)].batchTag; parts.push(TEMPERATURE_META[t as TemperatureValue]?.zh ?? (t === 'cold' ? '窗口外' : t));
parts.push(
(TEMPERATURE_META[t as TemperatureValue]?.zh ?? (t === 'cold' ? '窗口外' : t)) +
(tag ? `(${tag})` : ''),
);
} }
parts.push(`${planned} 人`); parts.push(`${planned} 人`);
if (agents > 0) parts.push(`${agents} 位客服`); if (agents > 0) parts.push(`${agents} 位客服`);
......
...@@ -9,7 +9,7 @@ import { ...@@ -9,7 +9,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod'; import { ZodResponse } from 'nestjs-zod';
import { Permission, parseAnchorMode } from '@pac/types'; import { Permission } from '@pac/types';
import { import {
CurrentUser, CurrentUser,
AuthenticatedUser, AuthenticatedUser,
...@@ -115,21 +115,13 @@ export class PlanController { ...@@ -115,21 +115,13 @@ export class PlanController {
matrix( matrix(
@TenantScope() scope: TenantScopeContext, @TenantScope() scope: TenantScopeContext,
@Query('clinicId') clinicId?: string, @Query('clinicId') clinicId?: string,
@Query('anchor') anchor?: string,
) { ) {
/** /**
* 不传 clinicId 用登录人的第一个诊所 —— 批次不跨诊所,矩阵天然是诊所维度的。 * 不传 clinicId 用登录人的第一个诊所 —— 批次不跨诊所,矩阵天然是诊所维度的。
* 🔴 传了就**必须在范围里**:矩阵是那家诊所的**患者量分布**, * 🔴 传了就**必须在范围里**:矩阵是那家诊所的**患者量分布**,
* 2026-08-10 实测这里也是敞的(带别家 id 能拿到完整矩阵)。 * 2026-08-10 实测这里也是敞的(带别家 id 能拿到完整矩阵)。
* ⚠️ `anchor` 走 `parseAnchorMode` 兜非法值(查询串是用户可改的)——
* ⛔ 别在这里抛 400:切错一个字母就整屏报错,而回落默认口径是安全的。
*/ */
return this.cohorts.matrix( return this.cohorts.matrix(scope, resolveClinicId(scope, clinicId), new Date());
scope,
resolveClinicId(scope, clinicId),
new Date(),
parseAnchorMode(anchor),
);
} }
@Get(':id') @Get(':id')
......
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { import {
AnchorMode,
COLD_BUCKET_YEARS, COLD_BUCKET_YEARS,
COLD_TEMPERATURES, COLD_TEMPERATURES,
HOT_BUCKET_DAYS, HOT_BUCKET_DAYS,
POTENTIAL_LABEL_RULES, POTENTIAL_LABEL_RULES,
Temperature, Temperature,
WARM_BUCKET_DAYS, WARM_BUCKET_DAYS,
type AnchorModeValue,
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
...@@ -85,40 +83,32 @@ export function labelCaseSql(factAlias: string, ageSql: Prisma.Sql): Prisma.Sql ...@@ -85,40 +83,32 @@ export function labelCaseSql(factAlias: string, ageSql: Prisma.Sql): Prisma.Sql
* (2026-08-11 实测,矩阵整片打不开)。⛔ 别顺手删掉这个 cast。 * (2026-08-11 实测,矩阵整片打不开)。⛔ 别顺手删掉这个 cast。
*/ */
/** /**
* 锚点表达式 —— **两版口径**(见 @pac/types 的 `AnchorMode`)。 * 锚点 = **患者末诊**(`lvp.last_visit_at`),只此一版。
* *
* · diagnosis `anc.at` = 该标签最新的未治疗诊断(默认,历史行为) * 🔴 2026-08-13 删掉了「按诊断」那版(`anc.at` 当锚点)和整个 `AnchorMode` 开关 ——
* · last_visit `lvp.last_visit_at` = 患者末诊(**患者级**,同一人所有标签同值) * 理由(医生复诊不重复写诊断,中位差 490 天)见 `@pac/types` 的 temperature.ts 文件头。
* ⛔ 别加回来,也⛔别在这里留"可切换"的口子:两版并存时圈人侧与矩阵侧一旦不同步,
* 就是主管看到 87 人、确认单捞出另一批,而且全程不报错(T14)。
* *
* ⚠️ 两版都套**同一个** `temperatureBucketCaseSql`(90/180 天 + 自然年)——
* 换的只是"距哪个时刻",档位切分本身一个字都不动。
* ⚠️ `last_visit_at` 取 `patient_profiles`(本地实测 5,825 人 100% 有值、100% 与 * ⚠️ `last_visit_at` 取 `patient_profiles`(本地实测 5,825 人 100% 有值、100% 与
* encounter/emr 事实的最大 occurred_at 同日),⛔ 别为它再去 join 一遍 patient_facts。 * encounter/emr 事实的最大 occurred_at 同日),⛔ 别为它再去 join 一遍 patient_facts。
* ⚠️ 末诊为 NULL → 三个值全 NULL → 落 `unknown` 档(与"边界缺失"同路), * ⚠️ 末诊为 NULL → 三个值全 NULL → 落 `unknown` 档(与"边界缺失"同路),
* ⛔ 不许默认塞进某一档。 * ⛔ 不许默认塞进某一档。
* ⚠️ 末诊是**患者级**的 ⇒ 同一 GROUP 内每行值都一样,`max()` 只是为了配合 GROUP BY,
* ⛔ 别读成"多条证据取最热"(那是诊断锚点时代的语义)。
*/ */
function anchorExpr(mode: AnchorModeValue): Prisma.Sql { const ANCHOR_SQL = Prisma.sql`lvp.last_visit_at`;
return mode === AnchorMode.LAST_VISIT ? Prisma.sql`lvp.last_visit_at` : Prisma.sql`anc.at`; const BOUNDS = {
} hot: Prisma.sql`max(${ANCHOR_SQL} + make_interval(days => ${HOT_BUCKET_DAYS}::int))`,
function boundsSql(mode: AnchorModeValue) { warm: Prisma.sql`max(${ANCHOR_SQL} + make_interval(days => ${WARM_BUCKET_DAYS}::int))`,
const a = anchorExpr(mode); anchor: Prisma.sql`max(${ANCHOR_SQL})`,
return { };
hot: Prisma.sql`max(${a} + make_interval(days => ${HOT_BUCKET_DAYS}::int))`, const LAST_VISIT_JOIN = Prisma.sql`LEFT JOIN patient_profiles lvp ON lvp.patient_id = fp.patient_id`;
warm: Prisma.sql`max(${a} + make_interval(days => ${WARM_BUCKET_DAYS}::int))`,
anchor: Prisma.sql`max(${a})`,
};
}
/// 末诊口径要多一张表;诊断口径下**不 join**(省一次无谓的扫描)
function lastVisitJoin(mode: AnchorModeValue): Prisma.Sql {
return mode === AnchorMode.LAST_VISIT
? Prisma.sql`LEFT JOIN patient_profiles lvp ON lvp.patient_id = fp.patient_id`
: Prisma.empty;
}
/** /**
* 六档判定的 CASE。 * 六档判定的 CASE。
* *
* ⭐ **一个锚点走到底**:`anchor` = 该标签最新的未治疗诊断。 * ⭐ **一个锚点走到底**:`anchor` = 患者末诊。
* 前两档按固定 90/180 天、后四档按绝对年数,量的都是「距同一个锚点多久」。 * 前两档按固定 90/180 天、后四档按绝对年数,量的都是「距同一个锚点多久」。
* ⚠️ 冷端用 `anchor + interval 'N years'` 而不是 `N*365 天` —— 与 TS 侧 `setFullYear` 同语义, * ⚠️ 冷端用 `anchor + interval 'N years'` 而不是 `N*365 天` —— 与 TS 侧 `setFullYear` 同语义,
* 365 天算法在闰年会漂,两边就对不上数。 * 365 天算法在闰年会漂,两边就对不上数。
...@@ -151,22 +141,19 @@ export function temperatureBucketCaseSql( ...@@ -151,22 +141,19 @@ export function temperatureBucketCaseSql(
* ⚠️ 关联子查询场景**不要用本函数**(内部的 `fp` 会遮蔽外层同名别名, * ⚠️ 关联子查询场景**不要用本函数**(内部的 `fp` 会遮蔽外层同名别名,
* 关联条件变成恒真且 SQL 不报错)—— 那种场景用下方的 `labelExistsSql` / `labelTemperatureExistsSql`。 * 关联条件变成恒真且 SQL 不报错)—— 那种场景用下方的 `labelExistsSql` / `labelTemperatureExistsSql`。
* *
* ⚠️ `f.status = 'active'`:治完的诊断是 `fulfilled` 不是删除。拿它当锚 = * ⚠️ `f.status = 'active'`:治完的诊断是 `fulfilled` 不是删除。拿它当证据 =
* 对着一个已经做完的诊断说"您还没做"。当前数据 5,034 条证据全是 active, * 对着一个已经做完的诊断说"您还没做"。当前数据 5,034 条证据全是 active,
* 这条过滤是**不变量守卫** —— 治疗落库后引擎还没重算的窗口期里,它就是唯一防线。 * 这条过滤是**不变量守卫** —— 治疗落库后引擎还没重算的窗口期里,它就是唯一防线。
* ⚠️ 三个聚合都用 max(取最热 / 取最新)—— 见 temperature.ts:统一成"最早"会让 * 🔴 `anc.at IS NOT NULL` **必须留着**,即便锚点已经换成末诊、不再用它定档:
* hot/warm 反单调(多一条旧需求反而更冷)。 * 它是「这条证据有日期」的守卫,决定**哪些 plan 进得来**。删掉行集就变了 ——
* 而多出来的那些人不会报错,只会悄悄出现在格子里。
*/ */
export function planLabelAnchorsSql( export function planLabelAnchorsSql(planFilter: Prisma.Sql): Prisma.Sql {
planFilter: Prisma.Sql,
mode: AnchorModeValue = AnchorMode.DIAGNOSIS,
): Prisma.Sql {
const b = boundsSql(mode);
return Prisma.sql` return Prisma.sql`
SELECT fp.id AS plan_id, fp.patient_id, lab.lbl AS label, SELECT fp.id AS plan_id, fp.patient_id, lab.lbl AS label,
${b.hot} AS hot_until, ${BOUNDS.hot} AS hot_until,
${b.warm} AS warm_until, ${BOUNDS.warm} AS warm_until,
${b.anchor} AS anchor_at ${BOUNDS.anchor} AS anchor_at
FROM followup_plans fp FROM followup_plans fp
JOIN patients p ON p.id = fp.patient_id JOIN patients p ON p.id = fp.patient_id
JOIN plan_reasons pr ON pr.plan_id = fp.id JOIN plan_reasons pr ON pr.plan_id = fp.id
...@@ -174,7 +161,7 @@ export function planLabelAnchorsSql( ...@@ -174,7 +161,7 @@ export function planLabelAnchorsSql(
JOIN patient_facts f ON f.id = fid::uuid AND f.status = 'active' JOIN patient_facts f ON f.id = fid::uuid AND f.status = 'active'
CROSS JOIN LATERAL (SELECT COALESCE(f.occurred_at, f.planned_for) AS at) anc CROSS JOIN LATERAL (SELECT COALESCE(f.occurred_at, f.planned_for) AS at) anc
CROSS JOIN LATERAL (SELECT ${labelCaseSql('f', AGE_YEARS_SQL)} AS lbl) lab CROSS JOIN LATERAL (SELECT ${labelCaseSql('f', AGE_YEARS_SQL)} AS lbl) lab
${lastVisitJoin(mode)} ${LAST_VISIT_JOIN}
WHERE ${planFilter} WHERE ${planFilter}
AND lab.lbl IS NOT NULL AND lab.lbl IS NOT NULL
AND anc.at IS NOT NULL AND anc.at IS NOT NULL
...@@ -202,20 +189,16 @@ export function labelExistsSql(label: string): Prisma.Sql { ...@@ -202,20 +189,16 @@ export function labelExistsSql(label: string): Prisma.Sql {
/** /**
* 关联片段:该 plan 在 `label` 这一格上的温度落在 `buckets` 之内。 * 关联片段:该 plan 在 `label` 这一格上的温度落在 `buckets` 之内。
* *
* ⚠️ 必须先聚合再判档 —— 温度是「该标签所有未治疗证据**取最热**」后的结果, * 🔴 这里是**圈人**侧,`planLabelAnchorsSql` 是**看**侧 —— 两边必须是同一个锚点。
* 逐条判再 OR 会把"有一条还热"错读成整格热(反过来也一样)。所以走 GROUP BY + HAVING。 * 曾经它们各自带一个 `mode` 参数,一旦调用方漏传就是主管看到 87 人、确认单捞出另一批,
* 而且全程不报错(T14)。⛔ 别再把口径做成参数:同源的唯一可靠办法是**没有选项**。
* ⚠️ 走 GROUP BY + HAVING 而不是逐条判 —— 判档表达式里有聚合(`max`),
* 逐条判再 OR 的语义不一样。⛔ 别为了"看着简单"改成 WHERE。
*/ */
export function labelTemperatureExistsSql( export function labelTemperatureExistsSql(
label: string, label: string,
buckets: readonly TemperatureValue[], buckets: readonly TemperatureValue[],
/**
* 🔴 **必须与矩阵那次查询用同一个 mode**。这里是**圈人**侧,矩阵是**看**侧 ——
* 两边 mode 不一致 = 主管看到 87 人、确认单捞出另一批,而且全程不报错(T14)。
* 调用方(`cohort-filter`)从 `criteria.anchorMode` 取,⛔ 别在这里给"聪明的默认"。
*/
mode: AnchorModeValue = AnchorMode.DIAGNOSIS,
): Prisma.Sql { ): Prisma.Sql {
const b = boundsSql(mode);
return Prisma.sql` return Prisma.sql`
AND EXISTS ( AND EXISTS (
SELECT 1 SELECT 1
...@@ -223,11 +206,11 @@ export function labelTemperatureExistsSql( ...@@ -223,11 +206,11 @@ export function labelTemperatureExistsSql(
CROSS JOIN LATERAL jsonb_array_elements_text(pr.evidence->'factIds') fid CROSS JOIN LATERAL jsonb_array_elements_text(pr.evidence->'factIds') fid
JOIN patient_facts f ON f.id = fid::uuid AND f.status = 'active' JOIN patient_facts f ON f.id = fid::uuid AND f.status = 'active'
CROSS JOIN LATERAL (SELECT COALESCE(f.occurred_at, f.planned_for) AS at) anc CROSS JOIN LATERAL (SELECT COALESCE(f.occurred_at, f.planned_for) AS at) anc
${lastVisitJoin(mode)} ${LAST_VISIT_JOIN}
WHERE pr.plan_id = fp.id WHERE pr.plan_id = fp.id
AND ${labelCaseSql('f', AGE_YEARS_SQL_P)} = ${label} AND ${labelCaseSql('f', AGE_YEARS_SQL_P)} = ${label}
AND anc.at IS NOT NULL AND anc.at IS NOT NULL
HAVING ${temperatureBucketCaseSql(b.hot, b.warm, b.anchor)} IN (${Prisma.join( HAVING ${temperatureBucketCaseSql(BOUNDS.hot, BOUNDS.warm, BOUNDS.anchor)} IN (${Prisma.join(
buckets.map((t) => Prisma.sql`${t}`), buckets.map((t) => Prisma.sql`${t}`),
', ', ', ',
)}) )})
......
...@@ -137,7 +137,7 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy { ...@@ -137,7 +137,7 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy {
const result = await this.assistant.chat({ const result = await this.assistant.chat({
userToken: token, userToken: token,
messages: history, messages: history,
systemExtra: this.wxSystemExtra, channelExtra: this.wxSystemExtra,
// ⚠️ 刻意不传 permissions → 走 'basic' 桶。企微这条路用的是 mintToken 造的**合成身份** // ⚠️ 刻意不传 permissions → 走 'basic' 桶。企微这条路用的是 mintToken 造的**合成身份**
// (见 mintToken),不是真人登录态;给它主管工具清单等于把分配能力挂到一个 // (见 mintToken),不是真人登录态;给它主管工具清单等于把分配能力挂到一个
// 谁都能在群里 @ 出来的机器人上。真身份映射做完之前,这里就该是最小权限。 // 谁都能在群里 @ 出来的机器人上。真身份映射做完之前,这里就该是最小权限。
......
...@@ -5,10 +5,12 @@ import { AssignStrategy, type AgentInfo, type TemperatureValue } from '@pac/type ...@@ -5,10 +5,12 @@ import { AssignStrategy, type AgentInfo, type TemperatureValue } from '@pac/type
// 又正好被业务侧的 try/catch 吞掉 ⇒ 测试全绿而被测路径根本没跑。 // 又正好被业务侧的 try/catch 吞掉 ⇒ 测试全绿而被测路径根本没跑。
import { import {
AssignmentProposalService, AssignmentProposalService,
mergeOverrides,
placeAgents, placeAgents,
repickPlaceable, repickPlaceable,
} from '../src/modules/plan/assignment-proposal.service'; } from '../src/modules/plan/assignment-proposal.service';
import type { CohortAttributesService } from '../src/modules/plan/cohort-attributes.service'; import type { CohortAttributesService } from '../src/modules/plan/cohort-attributes.service';
import { modelFacts } from '../src/modules/plan/assignment-facts';
/** /**
* 落人算法回归 —— **专属优先 → 无主补空 → 待分配**。 * 落人算法回归 —— **专属优先 → 无主补空 → 待分配**。
...@@ -585,11 +587,20 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -585,11 +587,20 @@ describe('基数沿用 —— 本批人数与时效', () => {
expect(r.target).toBe(135); expect(r.target).toBe(135);
expect(r.expiresInDays).toBe(1); expect(r.expiresInDays).toBe(1);
expect(r.basis).toBe('default'); expect(r.basis).toBe('default');
expect(r.basisNote).toContain('系统默认'); /**
* 🔴 2026-08-14 从 `basisNote` 迁到 `modelFacts` —— basisNote **只写不读**,
* 删了(见 plan-assignment schema 上那段)。这三条断言保护的东西一条没变,
* 只是换成断**模型真正拿到的那份**。
*/
const f = modelFacts(r) as { 怎么选的: Record<string, unknown> };
const how = String(f.怎么选的['本批人数怎么来的']);
expect(how).toContain('系统默认');
// ⚠️ 算式必须写出来 —— 主管看到 405 的第一反应是"怎么这么多" // ⚠️ 算式必须写出来 —— 主管看到 405 的第一反应是"怎么这么多"
expect(r.basisNote).toContain('每天 15 通'); expect(how).toContain('每天 15 通');
// ⛔ 同上:说人话,不许出现内部词 // ⛔ 同上:说人话,不许出现内部词
for (const w of ['水位', '基数', '铺平', '溢出']) expect(r.basisNote).not.toContain(w); for (const w of ['水位', '基数', '铺平', '溢出']) {
expect(JSON.stringify(modelFacts(r))).not.toContain(w);
}
}); });
/** 🔴 N 跟时效挂钩:D 从 3 改到 5,能承接的量本来就该跟着变。 */ /** 🔴 N 跟时效挂钩:D 从 3 改到 5,能承接的量本来就该跟着变。 */
...@@ -607,7 +618,15 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -607,7 +618,15 @@ describe('基数沿用 —— 本批人数与时效', () => {
const r = await mk(44, 9).propose(SCOPE, { clinicId: 'c1' }); const r = await mk(44, 9).propose(SCOPE, { clinicId: 'c1' });
expect(r.target).toBe(44); expect(r.target).toBe(44);
expect(r.batchSize).toBe(135); expect(r.batchSize).toBe(135);
expect(r.basisNote).toContain('您设的 135 没变'); /**
* 🔴 2026-08-14 **改判**:原断言是 `basisNote` 里要有「您设的 135 没变」。两处都错了 ——
* ① basisNote 没有消费方(已删);
* ② 135 是系统按「在岗 × 每天几通 × 时效」算的,**主管一个数都没设过**,
* 叫它"您设的"会让他去找自己什么时候设过 135。
* ⇒ `basis='default'` 时这个数**根本不进模型上下文**(见 assignment-facts 那段注释),
* 对应的断言在 `assignment-signals.spec` 的「首次默认的基数不给模型」。
* ⚠️ 这里仍然要锁的是 `batchSize` **没有被回写成 44**(上面那句),那才是这条测试的正题。
*/
}); });
test('⭐ 主管本次说「这批 200 人」→ explicit,压过默认口径', async () => { test('⭐ 主管本次说「这批 200 人」→ explicit,压过默认口径', async () => {
...@@ -631,7 +650,13 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -631,7 +650,13 @@ describe('基数沿用 —— 本批人数与时效', () => {
expect(lily.overridden).toBe(true); expect(lily.overridden).toBe(true);
// ⚠️ 名额上限**不改批次规模** —— 少的那部分由别人接走 // ⚠️ 名额上限**不改批次规模** —— 少的那部分由别人接走
expect(r.placed).toBe(100); expect(r.placed).toBe(100);
expect(r.basisNote).toContain('这批最多 5 条'); /**
* 🔴 精调过的人**要点名** —— 「为什么李莉只有 5 条」必须答得上来。
* ⚠️ 2026-08-14 迁移时发现:这件事此前**只在 basisNote 里,而 basisNote 没人读** ——
* 也就是说这个能力早就不在了,删 basisNote 只是让它显形。现在补进了 modelFacts。
*/
const tuned = (modelFacts(r) as { 怎么派的: Record<string, unknown> }).怎么派的['他单独设过的'];
expect(JSON.stringify(tuned)).toContain('"这批最多给他":5');
}); });
test('⭐ 精调成 0 → 这轮完全不给他,并列进 skippedAgents', async () => { test('⭐ 精调成 0 → 这轮完全不给他,并列进 skippedAgents', async () => {
...@@ -663,37 +688,66 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -663,37 +688,66 @@ describe('基数沿用 —— 本批人数与时效', () => {
}); });
/** /**
* 🔴 **运营结论**(opsNote)—— 2026-08-04 产品评审提的那条: * 🔴 **运营结论(opsNote)已于 2026-08-13 整块删除。**
* 「我不可能挨个去看他分的对不对,你在这儿应该给的是**结论性的东西**…
* 如果你不能告诉他哪不对、哪里需要主管判断,那就默认他必须一条一条看过去才能确认。」
* *
* ⚠️ 最要命的是**增量与总量分不开**:卡片原来只写「分完每人 57~62 条」(在手总量), * 2026-08-04 产品评审要的是"结论性的东西"(本批拿到几条 / 分完手上几条 / 要打几天)。
* 而那一批实际只给每人 1~4 条 —— 主管一眼读成"这批好大一坨"。 * 那个诉求没变,只是**出口换了**——同一批数现在有三处,卡片那份是第三遍:
* ① 模型正文(modelFacts 的「本批每人拿到 / 分完之后每人手上」);
* ② 引导节点 `batch_size_basis`(这批多大 = 在岗 × 每天几通 × 时效,可改);
* ③ 引导节点 `daily_overload`(最忙那位手上共几条、约几天的量)。
* ⇒ 下面只留 basisNote 那半条不变式(增量与总量**仍然不许混**)。
*/ */
test('⭐⭐ 运营结论:本批拿到几条 / 分完手上几条 / 要打几天,三个数都要有', async () => {
test('🔴 ⛔ basisNote 不许复述「分完每人多少条」—— 那是在手总量,不是这批给他的量', async () => {
const r = await mk(1000, 3, { inHand: 0, lastCriteria: { expiresInDays: 3 } }) const r = await mk(1000, 3, { inHand: 0, lastCriteria: { expiresInDays: 3 } })
.propose(SCOPE, { clinicId: 'c1', targetCount: 9 }); .propose(SCOPE, { clinicId: 'c1', targetCount: 9 });
expect(r.byAgent.every((x: { loadAfter: number }) => x.loadAfter === 3)).toBe(true); /**
expect(r.opsNote).toContain('本批每人拿到 3 条'); * 🔴 增量与总量**不许混**(2026-08-04 产品点名过的误读:实际每人 1~4 条,却写着 57~62)。
expect(r.opsNote).toContain('分完之后手上共 3 条'); * ⚠️ 2026-08-14 从 basisNote 迁到 modelFacts:那边把两个数**分成两个键**给,
// ⚠️ 天数必须带上「按每天 N 通算」的前提 —— 那个 15 是口头经验值不是实测, * 结构上就混不了 —— 这比"别写成一句话"更硬。
// ⛔ 不许写成"需要 X 天"这种像承诺的说法 */
expect(r.opsNote).toMatch(/按每人每天 \d+ 通算/); const df = modelFacts(r) as { 怎么派的: Record<string, unknown> };
expect(df.怎么派的['本批每人拿到']).toBeDefined();
expect(df.怎么派的['分完之后每人手上']).toBeDefined();
expect(String(df.怎么派的['落人规则'])).toContain('有专属的先回自己人手上');
}); });
});
test('🔴 「分完每人多少条」只许出现在 opsNote,⛔ basisNote 不再复述', async () => { /**
const r = await mk(1000, 3, { inHand: 0, lastCriteria: { expiresInDays: 3 } }) * 🔴 **精调怎么撤** —— 唯一的表达是**传空对象**。
.propose(SCOPE, { clinicId: 'c1', targetCount: 9 }); *
// 两处都说 = 产品点名过的"确认单信息多而重复";而且 basisNote 讲的是"怎么分的", * 精调(「某人这批最多 5 条」)会被记住,**省略等于沿用**:不传他,上次那条原封不动跟过来。
// 把在手总量摆进去会被读成"这批给他这么多" * 于是「他恢复正常」这句话没有对应的表达 —— 除非传 `{ "u1": {} }`。
expect(r.basisNote).not.toContain('分完每人'); *
expect(r.basisNote).toContain('有专属的先回自己人手上'); * ⚠️ 这个能力靠的是 `mergeOverrides` 里**先合并、后 sanitize** 的顺序,
* 而那看起来像是可以"顺手整理"的两行。整理掉的后果是:主管说了「某某恢复正常」,
* 下一批他照旧被卡在 5 条,而**没有任何报错** ⇒ 用测试钉住。
*/
describe('按客服精调的合并与撤销', () => {
test('⭐ 本次传的压过沿用的(同一个人两处都有 → 本次赢)', () => {
expect(mergeOverrides({ u1: { maxThisBatch: 5 } }, { u1: { maxThisBatch: 2 } })).toEqual({
u1: { maxThisBatch: 2 },
});
}); });
test('⭐ 拿到的全是自己老客户的客服要点名 —— 那种批次主管可以直接过', async () => { test('⭐ 没传的原样沿用 —— 「省略」不是「撤销」', () => {
const r = await mk(1000, 3, { inHand: 0, lastCriteria: { expiresInDays: 3 } }) expect(mergeOverrides({ u1: { maxThisBatch: 5 } }, { u2: { expiresInDays: 7 } })).toEqual({
.propose(SCOPE, { clinicId: 'c1', targetCount: 9 }); u1: { maxThisBatch: 5 },
// 这个 fixture 里没有专属关系 → 一个都不该点名(⛔ 别把"0 位"也写出来) u2: { expiresInDays: 7 },
expect(r.opsNote).not.toContain('全是自己的老客户'); });
});
test('🔴🔴 传空对象 = 撤掉他的精调(⛔ 别改成先 sanitize 再合并,那会让撤销静默失效)', () => {
expect(mergeOverrides({ u1: { maxThisBatch: 5 }, u2: { maxThisBatch: 3 } }, { u1: {} })).toEqual({
u2: { maxThisBatch: 3 },
});
});
test('⛔ 撤销不该靠传一个大数去盖 —— 那个假值会被记成新的精调', () => {
// 这条不是在测实现,是把"为什么空对象是唯一正解"写成可执行的说明:
// 999 确实解除了限制,但它**留在了结果里**,下一批照样沿用。
expect(mergeOverrides({ u1: { maxThisBatch: 5 } }, { u1: { maxThisBatch: 999 } })).toEqual({
u1: { maxThisBatch: 999 },
});
}); });
}); });
import { AnchorMode, type AssignmentProposal } from '@pac/types'; import { TEMPERATURE_SINCE_ZH, type AssignmentProposal } from '@pac/types';
import type { NarrowFacts } from '../src/modules/plan/cohort-attributes.service';
import { import {
ASSIGNMENT_INTENTS, ASSIGNMENT_INTENTS,
computeHighlights, computeNarrowSignal,
computeSignals, computeSignals,
} from '../src/modules/plan/assignment-signals'; } from '../src/modules/plan/assignment-signals';
import { modelFacts } from '../src/modules/plan/assignment-facts'; import { modelFacts } from '../src/modules/plan/assignment-facts';
...@@ -19,10 +20,11 @@ function proposal(over: Partial<AssignmentProposal> = {}): AssignmentProposal { ...@@ -19,10 +20,11 @@ function proposal(over: Partial<AssignmentProposal> = {}): AssignmentProposal {
clinicId: 'c1', clinicId: 'c1',
potentialTreatment: 'implant', potentialTreatment: 'implant',
temperature: 'hot', temperature: 'hot',
anchorMode: AnchorMode.DIAGNOSIS,
candidateTotal: 1000, candidateTotal: 1000,
target: 405, target: 405,
batchSize: 405, batchSize: 405,
/// ⚠️ 在岗名册人数 —— ⛔ 别拿 `byAgent.length` 顶(那是本批分到人的客服数,实测印错过)
rosterCount: 27,
expiresInDays: 3, expiresInDays: 3,
basis: 'explicit', basis: 'explicit',
basisFrom: null, basisFrom: null,
...@@ -89,19 +91,23 @@ describe('引导节点 · 判定', () => { ...@@ -89,19 +91,23 @@ describe('引导节点 · 判定', () => {
expect(yes.options.map((o) => o.intent)).toContain(ASSIGNMENT_INTENTS.PENDING_REFILL); expect(yes.options.map((o) => o.intent)).toContain(ASSIGNMENT_INTENTS.PENDING_REFILL);
}); });
test('🔴 手上的量超出时效 → 出 daily_overload,给出要几天 + 三个可选动作', () => { /**
* 🔴 2026-08-15 由「三个可选动作」改成**只留带数的那一个**(产品定)。
* 删掉的两个(「改每人每天打几通」「减少本批人数」)一个数都不带 ——
* 既没算出目标值,也没有预填当前值的输入框,点下去替主管说的那句话
* 同样是空的(「减少一些」)。⇒ 按钮不带数 ⇒ 严格弱于他自己说一句。
* ⚠️ 这条断言用 `toEqual` 锁**整个数组**,就是为了拦住"顺手再加一个"。
*/
test('🔴 手上的量超出时效 → 出 daily_overload,只给带数的那一个动作', () => {
// 每天 15 通 × 3 天 = 45 条是时效内打得完的量;60 条要 4 天 // 每天 15 通 × 3 天 = 45 条是时效内打得完的量;60 条要 4 天
const p = proposal({ expiresInDays: 3, byAgent: [agent('李莉', 30), agent('张悦', 60)] }); const p = proposal({ expiresInDays: 3, byAgent: [agent('李莉', 30), agent('张悦', 60)] });
const s = computeSignals(p).find((x) => x.key === 'daily_overload')!; const s = computeSignals(p).find((x) => x.key === 'daily_overload')!;
expect(s).toBeDefined(); expect(s).toBeDefined();
expect(s.title).toContain('张悦'); expect(s.title).toContain('张悦');
expect(s.why).toContain('4 天'); expect(s.why).toContain('4 天');
expect(s.options.map((o) => o.intent)).toEqual([ expect(s.options.map((o) => o.intent)).toEqual([ASSIGNMENT_INTENTS.EXPIRY_SET]);
ASSIGNMENT_INTENTS.EXPIRY_SET, // ⭐ 唯一那颗必须带着算好的天数 —— 不带数的按钮就是被删掉的那两个
ASSIGNMENT_INTENTS.DAILY_RATE_SET, expect(s.options.every((o) => o.args?.days === 4)).toBe(true);
ASSIGNMENT_INTENTS.BATCH_SIZE_SET,
]);
expect(s.options.some((o) => o.args?.days === 4)).toBe(true);
}); });
/** /**
...@@ -128,60 +134,151 @@ describe('引导节点 · 判定', () => { ...@@ -128,60 +134,151 @@ describe('引导节点 · 判定', () => {
); );
}); });
test('⭐ 候选不够 → 出 short_supply', () => { /**
expect(keys(proposal({ candidateTotal: 44, target: 44, batchSize: 405 }))).toContain( * 🔴 2026-08-14 产品定:候选不够时**一条引导都不出**(原来出 `short_supply`,
'short_supply', * 配「往后放一档 / 换个治疗项」两个按钮)。
); * 理由:**初选是主管自己在矩阵上点的,而矩阵每一格都写着人数** ——
* 按钮却不带别的格子有多少人,⇒ 严格弱于他身后那张矩阵,还占着最显眼的位置。
* ⚠️ 「发不满」仍要让他知道,但那是**事实**(facts 里三个数)不是**决定**。
*/
test('🔴 候选不够 → ⛔ 一条引导都不出(换格子他自己会点)', () => {
expect(keys(proposal({ candidateTotal: 44, target: 44, batchSize: 405 }))).toEqual([]);
}); });
test('⭐ 用的是系统默认基数 → 出 expiry_default,且 tier=info(可折叠)', () => { /**
* 🔴 2026-08-13:这条从 tier=info 升成 action(产品定)——
* 它折叠在卡片的「另有 N 条说明」里,而模型正文早就把同一句话说了,等于读两遍。
* ⇒ 折叠区去掉,这条跟着正文那一句走(show_guidance)。
* ⚠️ 两个可调量都要给**输入框**(天数/通数是连续量,列固定档必然漏)。
*/
test('⭐⭐ 系统默认基数 → 出 batch_size_basis(action),且两个选项都带输入框', () => {
const s = computeSignals(proposal({ basis: 'default', byAgent: [agent('a', 1)] })).find( const s = computeSignals(proposal({ basis: 'default', byAgent: [agent('a', 1)] })).find(
(x) => x.key === 'expiry_default', (x) => x.key === 'batch_size_basis',
)!; )!;
expect(s.tier).toBe('info'); expect(s.tier).toBe('action');
/**
* 🔴 两项都必须走 `basis.set`(**重跑算法**),⛔ 不许是 `expiry.set`:
* 那个只改已经算好这批人的到期日,人数一个不变 —— 而本节点的 why 写着
* 「改时效或改每天几通,这批人数都会跟着变」。用错了节点就在说谎。
*/
expect(s.options.map((o) => o.intent)).toEqual(['basis.set', 'basis.set']);
const byKey = Object.fromEntries(s.options.map((o) => [o.input!.argKey, o.input!]));
expect(byKey['days']).toMatchObject({ unit: '天' });
expect(byKey['rate']).toMatchObject({ unit: '通' });
// 护栏必须在数据里,⛔ 别只靠界面拦
for (const o of s.options) {
expect(o.input!.min).toBeGreaterThanOrEqual(1);
expect(o.input!.max).toBeGreaterThan(o.input!.min);
}
});
test('⛔ 主管自己设过基数/时效 → 不出这条(他知道那两个数哪来的)', () => {
expect(keys(proposal({ basis: 'explicit' }))).not.toContain('batch_size_basis');
});
/**
* 🔴 口径那条 info 节点**已删**(2026-08-13):档位口径只剩「距上次到诊多久」一版,
* 没有第二种可能就没什么可交代的。⚠️ 这条测试锁的是**不许加回来**。
*/
test('⛔ 不产口径 info 节点 —— 只有一版,说它等于说一句永远为真的话', () => {
expect(keys(proposal({}))).not.toContain('anchor_nondefault');
}); });
test('⭐ 按末诊口径 → 出 anchor_nondefault;按诊断(默认)→ 不出', () => { test('⛔ 现在一条 info 层节点都没有 —— 卡片上的「另有 N 条说明」折叠区应当为空', () => {
expect(keys(proposal({ anchorMode: AnchorMode.LAST_VISIT }))).toContain('anchor_nondefault'); const p = proposal({ basis: 'default' });
expect(keys(proposal({ anchorMode: AnchorMode.DIAGNOSIS }))).not.toContain('anchor_nondefault'); expect(computeSignals(p).filter((s) => s.tier === 'info')).toEqual([]);
}); });
test('🔴 每个节点的默认都是 no-op —— 「一条不点直接确认」必须永远安全', () => { test('🔴 每个节点的默认都是 no-op —— 「一条不点直接确认」必须永远安全', () => {
// ⚠️ 用「候选管够」这一版:候选不够那一版从 2026-08-14 起一条节点都不出了
const p = proposal({ const p = proposal({
pending: [{}] as AssignmentProposal['pending'], pending: [{}] as AssignmentProposal['pending'],
candidateTotal: 44, candidateTotal: 5000,
target: 44, target: 405,
basis: 'default', basis: 'default',
anchorMode: AnchorMode.LAST_VISIT,
expiresInDays: 3, expiresInDays: 3,
byAgent: [agent('张悦', 90)], byAgent: [agent('张悦', 90)],
}); });
const s = computeSignals(p); const s = computeSignals(p);
expect(s.length).toBeGreaterThanOrEqual(5); expect(s.length).toBeGreaterThanOrEqual(3);
// ⚠️ 判据:每个 defaultLabel 都在描述"不处理会怎样",⛔ 没有一个是要主管动手的 // ⚠️ 判据:每个 defaultLabel 都在描述"不处理会怎样",⛔ 没有一个是要主管动手的
for (const x of s) expect(x.defaultLabel).toMatch(/^不处理 = |^不/); for (const x of s) expect(x.defaultLabel).toMatch(/^不处理 = |^不/);
}); });
test('🔴 ⛔ 不截断 —— 五个条件同时成立就出五个,防噪音靠 tier 分层', () => { /**
const p = proposal({ * 🔴 `short_supply` 与 `batch_size_basis` **互斥**(2026-08-13 加判据之后):
* 前者要 `候选 < N`,后者要 `候选 > N` —— 同一份提案不可能两个都成立。
* ⚠️ 所以"四个同时成立"这件事从此不存在,这条改成分两种局面各断一次。
* ⚠️ 2026-08-14 `short_supply` 整条删掉之后,局面 A 就只剩派活那两条了 ——
* 「这批怎么来的」那一组在候选不够时**本来就没有要主管定的事**。
*/
test('🔴 ⛔ 不截断 —— 成立几条出几条,防噪音靠 tier 分层', () => {
// 局面 A:候选不够(N 是够的,人不够)
const short = computeSignals(
proposal({
pending: [{}] as AssignmentProposal['pending'], pending: [{}] as AssignmentProposal['pending'],
candidateTotal: 44, candidateTotal: 44,
target: 44, target: 44,
basis: 'default', basis: 'default',
anchorMode: AnchorMode.LAST_VISIT,
byAgent: [agent('张悦', 90)], byAgent: [agent('张悦', 90)],
}),
);
expect(short.map((x) => x.key)).toEqual(['pending', 'daily_overload']);
// 局面 B:候选管够(瓶颈是 N)—— 这时才轮到「本批多大是怎么估的」
const ample = computeSignals(
proposal({
pending: [{}] as AssignmentProposal['pending'],
candidateTotal: 5000,
target: 405,
basis: 'default',
byAgent: [agent('张悦', 90)],
}),
);
expect(ample.map((x) => x.key)).toEqual(['pending', 'daily_overload', 'batch_size_basis']);
// ⚠️ 2026-08-13:口径那条 info 已删、基数那条升 action ⇒ 现在全是 action。
for (const s of [short, ample]) expect(s.filter((x) => x.tier === 'info')).toEqual([]);
}); });
const s = computeSignals(p);
expect(s.length).toBe(5); /**
expect(s.filter((x) => x.tier === 'action').map((x) => x.key)).toEqual([ * 🔴 三条判据都要认出「这一版是主管自己收窄来的」(2026-08-13 走查)。
'pending', * 在此之前它们只看这一版的绝对状态,于是主管按消费切完之后:
'daily_overload', * · short_supply 把他自己造成的"人少"报成故障,还递上两个会**丢掉他刚加的条件**的按钮;
* · batch_size_basis 在候选是瓶颈时仍然说「改时效人数会跟着变」—— 那句话是假的;
* · narrow 又递上同一把刀(阈值还是他那一刀造出来的),形成自我收窄的螺旋。
*/
/**
* ⚠️ 2026-08-14:`short_supply` 整条删了(理由见上面「候选不够 → 一条都不出」那条),
* 所以这里从"收窄过才不出"改成**两种情况都不出**。
* ⛔ 别把它加回来 —— 上面那段沿革记的是它当年为什么该被关掉,
* 现在的理由更直接:换格子是主管在矩阵上一点的事,卡片上再给按钮是弱化版。
*/
test('🔴 「候选不足」整条已删 —— ⛔ 收窄过没有、没收窄过也没有', () => {
const base = {
candidateTotal: 312,
target: 312,
batchSize: 495,
byAgent: [agent('张悦', 10)],
} as Partial<AssignmentProposal>;
expect(keys(proposal(base))).not.toContain('short_supply');
expect(keys(proposal({ ...base, narrowedBy: { minSpendYuan: 720 } }))).not.toContain(
'short_supply', 'short_supply',
]); );
expect(s.filter((x) => x.tier === 'info').map((x) => x.key)).toEqual([ });
'expiry_default',
'anchor_nondefault', test('🔴 候选够不着 N → ⛔ 不出「本批多大」(改时效往上是空操作,说会变就是说谎)', () => {
]); // target = min(batchSize, candidateTotal):候选 312 / N 495 时改 N 往上,target 还是 312
expect(
keys(proposal({ basis: 'default', candidateTotal: 312, target: 312, batchSize: 495 })),
).not.toContain('batch_size_basis');
// 相等时候选同样是瓶颈 —— 判据必须是严格 >
expect(
keys(proposal({ basis: 'default', candidateTotal: 495, target: 495, batchSize: 495 })),
).not.toContain('batch_size_basis');
expect(
keys(proposal({ basis: 'default', candidateTotal: 5000, target: 495, batchSize: 495 })),
).toContain('batch_size_basis');
}); });
test('⛔ 模型拿不到 options 的技术细节 —— why 里不许出现 intent id', () => { test('⛔ 模型拿不到 options 的技术细节 —— why 里不许出现 intent id', () => {
...@@ -201,95 +298,138 @@ describe('引导节点 · 判定', () => { ...@@ -201,95 +298,138 @@ describe('引导节点 · 判定', () => {
* 🔴 判据是**相对基线的偏离**,⛔ 不是绝对占比:占比高不等于有信息 * 🔴 判据是**相对基线的偏离**,⛔ 不是绝对占比:占比高不等于有信息
* (「这批 80% 自费」全池也是 80%,说了等于没说)。 * (「这批 80% 自费」全池也是 80%,说了等于没说)。
*/ */
describe('引导节点 · 画像亮点', () => { /**
const dim = (id: string, nameZh: string, opts: Array<[string, string, number]>) => ({ * 「还能再筛一刀」—— 2026-08-13 换掉了原来的「画像亮点」。
id, *
nameZh, * 🔴 旧版拿倍数/百分点判断"这值不值得注意",那是在**替主管判断**;
options: opts.map(([value, zh, count]) => ({ value, zh, count })), * 新版不比基线,直接把几刀摆出来。⇒ 这里锁的不再是"该不该亮",
}); * 而是「摆出来的那几刀本身没说错话」。
*/
test('🔴 偏离达到 2 倍且人数够 → 亮;只是占比高但和整体一样 → ⛔ 不亮', () => { describe('引导节点 · 换个条件选这一格', () => {
const s = computeHighlights({ const facts = (over: Partial<NarrowFacts> = {}): NarrowFacts => ({
// 这一格 200 人:转介绍 62(31%)、自费 160(80%) size: 2509,
batch: { money: { avgYuan: 720, above: 312 },
size: 200,
dims: [
dim('referral_champion', '转介绍达人', [['yes', '转介绍达人', 62]]),
dim('rfm', '价值分群', [['self_pay', '自费', 160]]),
],
},
// 同治疗整体 1000 人:转介绍 120(12%)、自费 800(80%)
baseline: {
size: 1000,
dims: [ dims: [
dim('referral_champion', '转介绍达人', [['yes', '转介绍达人', 120]]), { id: 'referral_champion', nameZh: '转介绍达人', options: [
dim('rfm', '价值分群', [['self_pay', '自费', 800]]), { value: 'family', zh: '家庭型', count: 8 },
{ value: 'social', zh: '社交型', count: 12 },
] },
{ id: 'entitlement_status', nameZh: '权益身份', options: [
{ value: 'pedo_member', zh: '儿牙会员', count: 76 },
{ value: 'high_insurance', zh: '高端保险直付', count: 38 },
] },
{ id: 'acquisition_channel', nameZh: '获客渠道', options: [
{ value: 'walk_in', zh: '走入', count: 804 },
{ value: 'word_of_mouth', zh: '口碑客户', count: 561 },
] },
], ],
}, ...over,
}); });
expect(s.map((x) => x.key)).toEqual(['highlight:referral_champion:yes']); const labels = (f: NarrowFacts) => computeNarrowSignal(f)?.options.map((o) => o.label) ?? [];
expect(s[0]!.title).toContain('62 人');
expect(s[0]!.why).toContain('31%'); test('⭐ 四刀 + 兜底 —— 每个选项都把「选完剩多少人」写在脸上', () => {
expect(s[0]!.why).toContain('12%'); expect(labels(facts())).toEqual([
'只选消费高于这一格平均(¥720)的 312 人',
'只选转介绍达人那 20 人',
'只选权益身份是「儿牙会员」的 76 人',
'只选获客渠道是「走入」的 804 人',
'按别的条件选',
]);
}); });
test('⛔ 倍数够但人数太少 → 不亮(6 个人不值得主管改打法)', () => { test('🔴 转介绍两个取值**合起来**算 —— 主管切的是"有没有这个能力",不是哪一型', () => {
const s = computeHighlights({ const o = computeNarrowSignal(facts())!.options[1]!;
batch: { size: 200, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 6]])] }, expect(o.args?.personaTags).toBe('referral_champion:family,referral_champion:social');
baseline: { size: 1000, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 10]])] }, // ⛔ 文案不许写成「有过转介绍的」:这个维度的门槛是推荐≥3人且有成交
expect(o.label).toContain('转介绍达人');
expect(o.label).not.toContain('有过转介绍');
}); });
expect(s).toEqual([]);
test('⛔ 一刀切完不够 10 人 → 这个选项不给(转介绍在真实数据里常年只有个位数)', () => {
const f = facts({ dims: [{ id: 'referral_champion', nameZh: '转介绍达人', options: [
{ value: 'social', zh: '社交型', count: 2 },
] }] });
expect(labels(f)).toEqual(['只选消费高于这一格平均(¥720)的 312 人', '按别的条件选']);
}); });
test('⛔ 基线人群太小 → 不比(谁占多数都不奇怪)', () => { test('⛔ 候选集不到 50 人 → 整条不出(平均数和"最多的那一项"都成噪音了)', () => {
const s = computeHighlights({ expect(computeNarrowSignal(facts({ size: 49 }))).toBeNull();
batch: { size: 30, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 20]])] },
baseline: { size: 40, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 2]])] },
}); });
expect(s).toEqual([]);
test('⛔ 一条选项都够不着 → 不出这个节点(⛔ 别只留一个光秃秃的兜底按钮)', () => {
expect(computeNarrowSignal(facts({ money: null, dims: [] }))).toBeNull();
}); });
test('⛔ 基线里没有这个取值 → 不亮(⛔ 不许当成「这一格独有」凭空下结论)', () => { test('🔴 消费那一刀带 minSpendYuan(元),⛔ 不带 personaTags —— 等值字典表达不了「大于」', () => {
const s = computeHighlights({ const o = computeNarrowSignal(facts())!.options[0]!;
batch: { size: 200, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 50]])] }, expect(o.args?.minSpendYuan).toBe(720);
baseline: { size: 1000, dims: [dim('rfm', '价值分群', [['other', '其他', 900]])] }, expect(o.args?.personaTags).toBeUndefined();
// 给人看的那两样:中文名 + 选完剩多少(缺了就会把内部码念给主管听)
expect(o.args?.labelZh).toContain('¥720');
expect(o.args?.count).toBe(312);
}); });
expect(s).toEqual([]);
test('⭐ 权益/渠道取**这一批里最多的那一项**(不是最偏离基线的)', () => {
const ls = labels(facts());
expect(ls.some((l) => l.includes('儿牙会员'))).toBe(true);
expect(ls.some((l) => l.includes('高端保险直付'))).toBe(false);
}); });
test('⭐ 多个命中时按倍数排序并最多取 3 个', () => { /**
const many = Array.from({ length: 5 }, (_, i) => [`v${i}`, `取值${i}`, 20 + i * 10] as [string, string, number]); * 🔴 主管切过一刀之后**整条不出**(2026-08-13 产品定,三版沿革见 computeNarrowSignal)。
const s = computeHighlights({ * ⛔ 别改回"只排掉切过的维度"或"降级到卡片上" —— 那两版产品都看过并否了。
batch: { size: 200, dims: [dim('rfm', '价值分群', many)] }, */
baseline: { size: 1000, dims: [dim('rfm', '价值分群', many.map(([v, z]) => [v, z, 10] as [string, string, number]))] }, test('🔴 收窄过 → 整条不出(⛔ 不是排掉那一维,也不是挪到卡片)', () => {
expect(computeNarrowSignal(facts())).not.toBeNull();
expect(computeNarrowSignal(facts(), { minSpendYuan: 720 })).toBeNull();
expect(computeNarrowSignal(facts(), { personaTags: 'acquisition_channel:walk_in' })).toBeNull();
// ⚠️ 空对象 ≠ 收窄过 —— 提案里没追加条件时给的就是 `{}`/null
expect(computeNarrowSignal(facts(), {})).not.toBeNull();
expect(computeNarrowSignal(facts(), null)).not.toBeNull();
}); });
expect(s).toHaveLength(3);
// 倍数从大到小 —— 人数越多倍数越大 /**
expect(s.map((x) => x.key)).toEqual([ * 🔴 2026-08-14 走查:点完「重新排一版」,卡片上写着「已重挑这 495 人」,
'highlight:rfm:v4', * 而它**正上方**还挂着「这一格 2509 人,也可以只选其中一类」——
'highlight:rfm:v3', * 点下去这一版整个作废,主管刚做的动作白做(读起来顺序也是反的)。
'highlight:rfm:v2', * ⚠️ 闸的本意是**主管已经在这一版上动过手**:收窄是动手,重挑也是动手。
]); * ⛔ 别只挡 `narrowedBy` 就以为挡全了。
*/
test('🔴 重新排过一版 → 整条不出(跟"收窄过"同一条闸)', () => {
expect(computeNarrowSignal(facts(), null, false)).not.toBeNull();
expect(computeNarrowSignal(facts(), null, true)).toBeNull();
// 两个原因**各自独立**生效,⛔ 别写成"两个都满足才关"
expect(computeNarrowSignal(facts(), { minSpendYuan: 720 }, false)).toBeNull();
});
test('⭐ 归「改人群」那一组 —— ⛔ 既不跟「要主管定的」混,也不跟「改人数」混', () => {
expect(computeNarrowSignal(facts())!.stage).toBe('cohort');
}); });
test('🔴 亮点进 computeSignals 后按 severity 排在待分配之后、纯信息之前', () => { /**
const hl = computeHighlights({ * 🔴 这条锁的是**语义**,不是措辞洁癖(2026-08-13 产品走查抓到的):
batch: { size: 200, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 60]])] }, * 原来写「这批 2663 人还能再筛一刀」,被读成「在本批 495 人之后再筛一遍」——
baseline: { size: 1000, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 100]])] }, * 而它实际是**换个条件重新选初选人群**,选完本批人数会跟着重估。
*/
test('🔴 ⛔ 不许暗示"在已排好的人里再挑" —— 它是换条件重新选一版', () => {
const s = computeNarrowSignal(facts())!;
expect(s.title).toContain('这一格');
expect(s.title).not.toContain('再筛');
expect(s.why).toContain('重新选一版');
// 「这批」在这条链路里同时指过候选集和本批 —— 最不该含糊的就是它
expect(s.title).not.toContain('这批');
}); });
test('🔴 进 computeSignals 后按 severity 排在待分配之后、纯信息之前', () => {
const s = computeSignals( const s = computeSignals(
proposal({ proposal({
pending: [{}] as AssignmentProposal['pending'], pending: [{}] as AssignmentProposal['pending'],
basis: 'default', basis: 'default',
byAgent: [agent('张悦', 10)], byAgent: [agent('张悦', 10)],
}), }),
hl, [computeNarrowSignal(facts())!],
); );
expect(s.map((x) => x.key)).toEqual([ expect(s.map((x) => x.key)).toEqual(['pending', 'narrow', 'batch_size_basis']);
'pending',
'highlight:rfm:vip',
'expiry_default',
]);
}); });
}); });
...@@ -313,8 +453,16 @@ describe('给模型的事实', () => { ...@@ -313,8 +453,16 @@ describe('给模型的事实', () => {
skippedAgents: [{ userId: 'x', name: '王强', inHand: 80 }], skippedAgents: [{ userId: 'x', name: '王强', inHand: 80 }],
}); });
test('⭐ 三段齐全且顺序固定:怎么选的 → 怎么派的 → 要主管定的', () => { /**
expect(Object.keys(modelFacts(base()))).toEqual(['怎么选的', '怎么派的', '要主管定的']); * 🔴 2026-08-14 由**三段**改成**两段**:「要主管定的」从顶层搬进「怎么派的」。
*
* 键序就是段序,而它属于**派活**。`propose_assignment` 的描述里写了讲述顺序
* (人对不对 → 分完什么样 → 可动手的就近开放);顶层挂着第三个键时,
* 模型只会把它讲成第四段,跟描述打架。
* ⇒ 每个可动手的东西都嵌在它要改的那一段里(改人群/改人数同理,在「怎么选的」里)。
*/
test('🔴 顶层两段且顺序固定:怎么选的 → 怎么派的(⛔ 引导不再单独成段)', () => {
expect(Object.keys(modelFacts(base()))).toEqual(['怎么选的', '怎么派的']);
}); });
test('🔴 ⛔ 里面一个字的指令都不许有', () => { test('🔴 ⛔ 里面一个字的指令都不许有', () => {
...@@ -331,17 +479,74 @@ describe('给模型的事实', () => { ...@@ -331,17 +479,74 @@ describe('给模型的事实', () => {
expect(JSON.stringify(modelFacts(p))).not.toContain('张三'); expect(JSON.stringify(modelFacts(p))).not.toContain('张三');
}); });
test('⭐ 候选不够时,本批人数与基数**分别给** —— 否则主管以为系统偷偷改小了他的设置', () => { test('⭐ 候选不够时,本批人数与「本来能发多少」**分别给** —— 否则主管以为系统偷偷改小了他的设置', () => {
const f = modelFacts(proposal({ candidateTotal: 44, target: 44, batchSize: 405 })) as { const f = modelFacts(proposal({ candidateTotal: 44, target: 44, batchSize: 405 })) as {
怎么选的: Record<string, unknown>; 怎么选的: Record<string, unknown>;
}; };
expect(f.怎么选的['本批人数']).toBe(44); expect(f.怎么选的['本批人数']).toBe(44);
expect(f.怎么选的['您设的基数']).toBe(405); expect(f.怎么选的['人够的话本来能发']).toBe(405);
});
/**
* 🔴 **键名本身就会被念出去** —— 2026-08-14 实测原话:
* 「这一格总共就 3 人,比估算的**基数**(120)少得多」。
* 当时这个键叫 `您设的基数`:① 「基数」就列在提示词的禁说清单里,
* ② 那 120 是系统按「在岗 × 每天几通 × 时效」算的,主管一个数都没设过。
* ⇒ 禁令拦不住紧挨着数字的**键名**;键名必须自己说人话。
*/
test('🔴 事实里不许出现「基数」「您设的」这类词(键名会被原样念给主管)', () => {
const json = JSON.stringify(modelFacts(proposal({ candidateTotal: 44, target: 44, batchSize: 405 })));
expect(json).not.toContain('基数');
expect(json).not.toContain('您设的');
}); });
test('⭐ 候选够时不给「您设的基数」(没有歧义就别多一个数)', () => { /**
* 🔴 首次默认那个数是**机器算的**(在岗 × 每天几通 × 时效),主管没设过 ——
* 给了他就要听一句「估的基数是 45,但这格只有 5 人」(2026-08-14 实测原话)。
* ⇒ 没有"设置被改小"这回事可防时,这个数就不进模型上下文。
*/
test('🔴 首次默认的基数**不给模型** —— 那不是他设的,给了只会被解释一遍', () => {
const f = modelFacts(
proposal({ candidateTotal: 5, target: 5, batchSize: 45, basis: 'default' }),
) as { 怎么选的: Record<string, unknown> };
expect(f.怎么选的['人够的话本来能发']).toBeUndefined();
});
/**
* 🔴 2026-08-15 实测事故(上海世纪公园):这句话里的「在岗 N 人」印的是
* `byAgent.length` —— **本批分到人的客服数**,于是印出「在岗 **9** 人 × 每天 15 通 × 1 天」,
* 而主管同一屏「团队现在什么状态」写着 **33 位在岗**。
* 9×15×1 = 135,连紧挨着的「本批人数 214」都算不出来(真实是 33×15×1=495 被候选压到 214)。
* ⚠️ 一并锁住**式子要带自己的积**:不给 495,模型手里没有任何能让这句话自洽的数。
*/
test('🔴「在岗 N 人」取名册人数,⛔ 不是本批分到人的客服数;且式子带自己的积', () => {
const f = modelFacts(
proposal({
basis: 'default',
rosterCount: 33,
candidateTotal: 214,
target: 214,
batchSize: 495,
expiresInDays: 1,
byAgent: [agent('张悦', 7), agent('李莉', 7)],
}),
) as { 怎么选的: Record<string, unknown> };
const s = String(f.怎么选的['本批人数怎么来的']);
expect(s).toContain('在岗 33 人');
expect(s).not.toContain('在岗 2 人');
expect(s).toContain('= 495 人');
});
test('⭐ 沿用/他指定的基数照给("我上次说 200,这次怎么只有 44"是真问题)', () => {
const f = modelFacts(
proposal({ candidateTotal: 44, target: 44, batchSize: 200, basis: 'inherited' }),
) as { 怎么选的: Record<string, unknown> };
expect(f.怎么选的['人够的话本来能发']).toBe(200);
});
test('⭐ 候选够时不给「人够的话本来能发」(没有歧义就别多一个数)', () => {
const f = modelFacts(base()) as { 怎么选的: Record<string, unknown> }; const f = modelFacts(base()) as { 怎么选的: Record<string, unknown> };
expect(f.怎么选的['您设的基数']).toBeUndefined(); expect(f.怎么选的['人够的话本来能发']).toBeUndefined();
}); });
test('⭐ 取值码换成中文,⛔ 模型手里不该有 implant / hot 这种词', () => { test('⭐ 取值码换成中文,⛔ 模型手里不该有 implant / hot 这种词', () => {
...@@ -350,82 +555,185 @@ describe('给模型的事实', () => { ...@@ -350,82 +555,185 @@ describe('给模型的事实', () => {
expect(f.怎么选的['时间档']).not.toBe('hot'); expect(f.怎么选的['时间档']).not.toBe('hot');
}); });
test('⭐ 要主管定的只给 action 层,且不含 intent 技术细节', () => { /**
* 🔴 2026-08-14 走查:facts 里给的是 `"时间档": "3 年以上"` ——
* **「3 年以上」自己说不清是什么的 3 年**。界面上不缺这半句(列头上方就写着
* 按什么分档,位置本身是上下文);JSON 里没有位置,模型拿到的是个孤零零的字符串。
* ⚠️ 修法是把**值**补全到自足,⛔ 不是另加一个「口径」键 ——
* 只剩末诊一版锚点,单独一个口径字段读起来像"还有别的口径"。
*/
test('🔴 时间档必须自己说得清是什么的 3 年,⛔ 但不许另开「口径」键', () => {
const f = modelFacts(base()) as { 怎么选的: Record<string, unknown> };
expect(String(f.怎么选的['时间档'])).toContain(TEMPERATURE_SINCE_ZH);
expect(f.怎么选的['口径']).toBeUndefined();
});
test('⭐ 要你定的只给 action 层,且不含 intent 技术细节', () => {
const p = base(); const p = base();
p.signals = computeSignals(p); p.signals = computeSignals(p);
const f = modelFacts(p) as { 要主管定的: Array<Record<string, unknown>> }; const f = modelFacts(p) as { 怎么派的: { 要你定的: Array<Record<string, unknown>> } };
expect(f.要主管定的.length).toBeGreaterThan(0); expect(f.怎么派的.要你定的.length).toBeGreaterThan(0);
for (const x of f.要主管定的) { for (const x of f.怎么派的.要你定的) {
expect(Object.keys(x)).toEqual(['是什么', '为什么', '不处理会怎样']); expect(Object.keys(x)).toEqual(['标识', '是什么', '为什么', '不处理会怎样']);
} }
expect(JSON.stringify(f.要主管定的)).not.toMatch(/pending\.|expiry\.|cohort\./); // ⛔ intent 技术细节不许进 —— 模型说出 `pending.to_owner` 只会吓到主管
expect(JSON.stringify(f.怎么派的.要你定的)).not.toMatch(/pending\.|expiry\.|cohort\./);
}); });
});
/** /**
* 🔴 大基数维度的偏离 —— 2026-08-12 本地实测补的判据。 * 🔴 2026-08-14 由**序号**改成**中文标识**(沿革见 `GUIDANCE_KIND`)。
* *
* 只用「倍数 ≥2」时,这条判据**结构上打不着大类**: * 序号那版的契约是「编号 = action 层里的第几个」,两边(服务端投影 / 前端还原)
* 朝阳公园的获客渠道是 口碑 33% / 集团营销 23% / 走入 22%, * 必须用同一个过滤顺序,错了会把按钮插到**另一条**下面且不报错。
* 口碑要够 2 倍得占到 66%(不可能),而能翻倍的小类在一格里凑不满 10 人。 * ⇒ 现在服务端一边解析、一边用(侧信道直接推 `signalKey`),前端不再还原;
* ⇒ 整个维度永远不会命中。 * 模型手里只有中文标识。这条测试锁的是:
* ① 每条都有标识 ② 标识两两不同(否则 show_guidance 分不清)
* ③ ⛔ 内部 key 不许出现在模型看得到的地方
*/ */
describe('引导节点 · 画像亮点(大基数)', () => { test('⭐⭐ 每条引导都有**唯一**的中文标识 —— show_guidance 靠它认人', () => {
const dim = (id: string, nameZh: string, opts: Array<[string, string, number]>) => ({ const p = base();
id, p.pending = [{}] as AssignmentProposal['pending'];
nameZh, p.basis = 'default';
options: opts.map(([value, zh, count]) => ({ value, zh, count })), p.signals = computeSignals(p, [
{ key: 'narrow', severity: 3, tier: 'action', stage: 'cohort',
title: '还能再筛一刀', why: '', defaultLabel: '', options: [] },
]);
const action = p.signals.filter((s) => s.tier === 'action');
const f = modelFacts(p) as {
怎么选的: {
这一格还能怎么选?: Array<Record<string, unknown>>;
本批人数还能怎么调?: Array<Record<string, unknown>>;
};
怎么派的: { 要你定的?: Array<Record<string, unknown>> };
};
const all = [
...(f.怎么选的.这一格还能怎么选 ?? []),
...(f.怎么选的.本批人数还能怎么调 ?? []),
...(f.怎么派的.要你定的 ?? []),
];
// 三组加起来 = 全部 action 条目,⛔ 一条都不许漏
expect(all.length).toBe(action.length);
const ids = all.map((x) => x['标识'] as string);
expect(ids.every((s) => typeof s === 'string' && s.length > 0)).toBe(true);
expect(new Set(ids).size).toBe(ids.length);
// ⛔ 内部 key 不许出现(中文标识即使被念出口也是一句正常的话,key 不是)
for (const k of action.map((s) => s.key)) expect(ids).not.toContain(k);
});
test('⭐⭐ 一个键只说一件事:改人群 / 改人数 / 改怎么派,⛔ 不许两组挤一个标题', () => {
const p = base();
p.pending = [{}] as AssignmentProposal['pending'];
p.basis = 'default';
/**
* ⚠️ `short_supply`(cohort) 与 `batch_size_basis`(size) 现在**互斥**
* (前者要候选 < N,后者要候选 > N)。要凑齐三组,cohort 那一组只能来自
* `narrow` —— 它本来就是查库算的,这里当 `extra` 传进去。
*/
p.signals = computeSignals(p, [
{
key: 'narrow',
severity: 3,
tier: 'action',
stage: 'cohort',
title: '这一格 1000 人,也可以只选其中一类',
why: '',
defaultLabel: '不处理 = 就按全部来',
options: [],
},
]);
const f = modelFacts(p) as {
怎么选的: {
这一格还能怎么选?: Array<Record<string, unknown>>;
本批人数还能怎么调?: Array<Record<string, unknown>>;
};
怎么派的: { 要你定的?: Array<Record<string, unknown>> };
};
// ⚠️ 断的是**分组本身**,⛔ 不写死哪几条:以后加节点时这条测试不该跟着改,
// 它要盯的是「stage 怎么标,事实就怎么分」这个契约。
const titles = (stage: 'cohort' | 'size' | 'dispatch') =>
p.signals.filter((s) => s.tier === 'action' && s.stage === stage).map((s) => s.title);
expect(f.怎么选的.这一格还能怎么选?.map((x) => x['是什么'])).toEqual(titles('cohort'));
expect(f.怎么选的.本批人数还能怎么调?.map((x) => x['是什么'])).toEqual(titles('size'));
// 🔴 派活那一组 2026-08-14 从顶层搬进「怎么派的」—— 就近,跟另外两组一致
expect(f.怎么派的.要你定的?.map((x) => x['是什么'])).toEqual(titles('dispatch'));
// 🔴 改人数那条**⛔ 不许**混进选人那一组(走查抓到过「还能怎么选:」下面挂着改时效)
expect(f.怎么选的.这一格还能怎么选 ?? []).not.toContainEqual(
expect.objectContaining({ 是什么: expect.stringContaining('每天') }),
);
// 这份 fixture 三组都得非空,否则这条测试等于什么都没测
for (const st of ['cohort', 'size', 'dispatch'] as const)
expect(titles(st).length).toBeGreaterThan(0);
}); });
});
test('🔴 33% → 48%(1.45 倍但高出 15 个百分点)→ 必须命中', () => { /**
const s = computeHighlights({ * 🔴 2026-08-15 实测:「换无专属客服的患者补上」这颗按钮**没说从哪儿补**,
batch: { size: 200, dims: [dim('acquisition_channel', '获客渠道', [['wom', '口碑客户', 96]])] }, * 而主管很可能已经收窄过几遍 —— 从他收窄后的那批补,和退回整格重取,差着几千人。
baseline: { size: 1000, dims: [dim('acquisition_channel', '获客渠道', [['wom', '口碑客户', 332]])] }, * ⚠️ 同日还修掉了链路上的漏:前端只回传矩阵两轴,`narrowedBy` 整个没带,
}); * 服务端于是真的退回整格 —— 所以这条断言锁的不只是文案,是"文案说的那件事成立"。
expect(s).toHaveLength(1); */
// ⚠️ 大基数说"高出 N 个百分点"比说"1.4 倍"直观 describe('引导节点 · 待分配「补上」的取人范围要写在脸上', () => {
expect(s[0]!.why).toContain('个百分点'); const withPending = (narrowedBy: AssignmentProposal['narrowedBy']) =>
expect(s[0]!.why).not.toContain('倍'); computeSignals(
proposal({
canRefill: true,
narrowedBy,
pending: [{}] as AssignmentProposal['pending'],
}),
).find((s) => s.key === 'pending')!;
const refillLabel = (s: { options?: Array<{ label: string }> }) =>
(s.options ?? []).find((o) => o.label.includes('补上'))?.label ?? '';
test('⭐ 他没收窄过 —— 说清是这一格的人', () => {
expect(refillLabel(withPending(null))).toContain('这一格');
}); });
test('⭐ 33% → 38%(只高 5 个百分点、1.15 倍)→ ⛔ 不命中', () => { test('🔴 他收窄过 —— ⛔ 不许再说"这一格",那会让他以为圈掉的人又回来了', () => {
const s = computeHighlights({ const label = refillLabel(withPending({ minSpendYuan: 721 }));
batch: { size: 200, dims: [dim('acquisition_channel', '获客渠道', [['wom', '口碑客户', 76]])] }, expect(label).toContain('收窄');
baseline: { size: 1000, dims: [dim('acquisition_channel', '获客渠道', [['wom', '口碑客户', 332]])] }, expect(label).not.toContain('这一格');
}); });
expect(s).toEqual([]); });
/**
* 🔴 2026-08-15 实测:主管问「那 1 位为什么没轮到」,模型答不上来 ——
* facts 里只有「本批没分到的客服: 1」,姓名和在手量都没投影出来
* (卡片上写着「本批未分到:逯梓茜(在手 193)」,而模型看不见卡片)。
* ⇒ 补一个在手量:答案就在它与「分完之后每人手上」的对比里
* (没分到的 193,分到的分完才 49 —— 水位法一直没轮到他)。
*/
describe('没分到的那几位 —— 「为什么没轮到」要答得上来', () => {
const facts = (skipped: Array<{ inHand: number }>) =>
(
modelFacts(
proposal({
byAgent: [agent('张悦', 9, 40)],
skippedAgents: skipped.map((s, i) => ({
userId: `u${i}`,
name: `某${i}`,
inHand: s.inHand,
})),
}),
) as { 怎么派的: Record<string, unknown> }
).怎么派的;
test('🔴 有人没分到 → 必须一起给出他们手上有多少', () => {
const f = facts([{ inHand: 193 }]);
expect(f['本批没分到的客服']).toBe(1);
// 单人时区间塌成一个数;与「分完之后每人手上 49」并排,差距一眼看得出
expect(f['他们现在手上有']).toBe(193);
expect(f['分完之后每人手上']).toBe(49);
}); });
test('⭐ 小基数仍走倍数口径,措辞用「倍」', () => { test('⭐ 多人只给区间 —— ⛔ 不逐人列名(实测有过 24 位,列出来是一屏噪音)', () => {
const s = computeHighlights({ expect(facts([{ inHand: 41 }, { inHand: 193 }, { inHand: 88 }])['他们现在手上有']).toEqual({
batch: { size: 200, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 30]])] }, 最少: 41,
baseline: { size: 1000, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 50]])] }, 最多: 193,
}); });
expect(s[0]!.why).toContain('倍');
}); });
test('🔴 两种口径混在一起时,按偏离强度排序(⛔ 不能只按倍数,否则百分点项永远垫底)', () => { test('⭐ 一个不落时不给这个键(没有主语就别开口)', () => {
const s = computeHighlights({ expect(facts([])).not.toHaveProperty('他们现在手上有');
batch: {
size: 200,
dims: [
// 高出 25 个百分点(1.75 倍)—— 更值得看
dim('acquisition_channel', '获客渠道', [['wom', '口碑客户', 116]]),
// 2.1 倍但只高出 5.5 个百分点
dim('rfm', '价值分群', [['vip', '重要价值', 21]]),
],
},
baseline: {
size: 1000,
dims: [
dim('acquisition_channel', '获客渠道', [['wom', '口碑客户', 332]]),
dim('rfm', '价值分群', [['vip', '重要价值', 50]]),
],
},
});
expect(s.map((x) => x.key)).toEqual([
'highlight:acquisition_channel:wom',
'highlight:rfm:vip',
]);
}); });
}); });
...@@ -110,7 +110,9 @@ describe('画像圈人 —— 多标签维度不能拿来算百分比', () => { ...@@ -110,7 +110,9 @@ describe('画像圈人 —— 多标签维度不能拿来算百分比', () => {
}); });
describe('画像圈人 —— 维度点名', () => { describe('画像圈人 —— 维度点名', () => {
test('⭐⭐ 隐藏维度可以点名:教条举的「排掉怕疼的」正落在 treatment_sensitivity 上', async () => { // ⚠️ 2026-08-14:工具描述里那句举例(「排掉怕疼的」)已删 —— 举例会被模型念给主管听。
// 但**能力本身要留**:hidden 的语义是"前端面板不展示",⛔ 不是"不能点名"。
test('⭐⭐ 隐藏维度可以点名:treatment_sensitivity 不在面板上,但 keys 点得到', async () => {
// hidden 的语义是"前端面板不展示",不是"不能用"(见 persona-tag-filters 的 hidden 注释)。 // hidden 的语义是"前端面板不展示",不是"不能用"(见 persona-tag-filters 的 hidden 注释)。
const prisma = makePrisma({ const prisma = makePrisma({
patientIds: ['p0', 'p1'], patientIds: ['p0', 'p1'],
...@@ -168,8 +170,8 @@ describe('初选矩阵', () => { ...@@ -168,8 +170,8 @@ describe('初选矩阵', () => {
expect(implant.counts.cold_1y).toBe(5); // ⛔ 不是 8 expect(implant.counts.cold_1y).toBe(5); // ⛔ 不是 8
expect(implant.unknown).toBe(3); expect(implant.unknown).toBe(3);
expect(m.unknownTotal).toBe(3); expect(m.unknownTotal).toBe(3);
// ⚠️ 2026-08-11 措辞从「算不出温度」改成「算不出档位」(界面上早就没有"温度"这个词了), // ⚠️ 2026-08-11 措辞从「算不出温度」改成「算不出档位」(界面上早就没有"温度"这个词了);
// 而且原因随口径变(按诊断=没诊断日 / 按末诊=没末诊记录)。 // 2026-08-13 锚点定为末诊后,原因只有一个:没有末诊记录。
expect(m.note).toContain('算不出档位'); expect(m.note).toContain('算不出档位');
expect(m.note).toContain('没有并进'); expect(m.note).toContain('没有并进');
}); });
......
import { AnchorMode, HOT_BUCKET_DAYS, Temperature, WARM_BUCKET_DAYS } from '@pac/types'; import { HOT_BUCKET_DAYS, Temperature, WARM_BUCKET_DAYS } from '@pac/types';
import { assertCohortCriteria, cohortWhereSql } from '../src/modules/plan/cohort-filter'; import { assertCohortCriteria, cohortWhereSql } from '../src/modules/plan/cohort-filter';
/** /**
...@@ -131,44 +131,36 @@ describe('人群取数 —— 两根轴走召回单证据(2026-08 换源)', () = ...@@ -131,44 +131,36 @@ describe('人群取数 —— 两根轴走召回单证据(2026-08 换源)', () =
}); });
/** /**
* 🔴 **两版口径的圈人侧** —— 这一层错了,后果是「主管看到 87 人、确认单给另一批」, * 🔴 **圈人侧的锚点** —— 这一层错了,后果是「主管看到 87 人、确认单给另一批」,
* 而且两个数字都"对"、全程不报错(T14 口径对数;矩阵 67→324 那次事故的同构版本)。 * 而且两个数字都"对"、全程不报错(T14 口径对数;矩阵 67→324 那次事故的同构版本)。
* ⚠️ 2026-08-13 起只剩末诊一版(`AnchorMode` 开关已删),所以这里锁的是**唯一那一版**。
*/ */
describe('人群取数 —— 档位口径(按诊断 / 按末诊)', () => { describe('人群取数 —— 锚点 = 末诊,只此一版', () => {
const sql = (mode: (typeof AnchorMode)[keyof typeof AnchorMode]) => const sql = () =>
cohortWhereSql( cohortWhereSql(
SCOPE, SCOPE,
{ clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT, anchorMode: mode }, { clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT },
NOW, NOW,
); );
test('⭐⭐ 按末诊时**必须**join patient_profiles 并用 last_visit_at 当锚点', () => { test('⭐⭐ **必须** join patient_profiles 并用 last_visit_at 当锚点', () => {
const t = sql(AnchorMode.LAST_VISIT).strings.join('?'); const t = sql().strings.join('?');
expect(t).toContain('patient_profiles'); expect(t).toContain('patient_profiles');
expect(t).toContain('last_visit_at'); expect(t).toContain('last_visit_at');
}); });
test('⭐⭐ 按诊断时**不许**碰末诊 —— 多 join 一张表就是另一个口径', () => { test('⭐⭐ ⛔ 不许再拿诊断日当锚点 —— 但 anc.at 的非空守卫要留着(它决定行集)', () => {
const t = sql(AnchorMode.DIAGNOSIS).strings.join('?'); const t = sql().strings.join('?');
expect(t).not.toContain('last_visit_at'); expect(t).toContain('anc.at IS NOT NULL');
}); // 锚点表达式只能是末诊:⛔ 不许出现 `max(anc.at`(那是旧口径的指纹)
expect(t).not.toMatch(/max\(\s*anc\.at/);
test('⭐ 缺省 = 按诊断(老 criteria 没有这个键,行为必须原样不变)', () => {
const legacy = cohortWhereSql(
SCOPE,
{ clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT },
NOW,
).strings.join('?');
expect(legacy).toBe(sql(AnchorMode.DIAGNOSIS).strings.join('?'));
}); });
test('⭐⭐ 两版的**档位切分**必须完全一样 —— 换的只是锚点', () => { test('⭐⭐ 档位切分仍由 @pac/types 的常量生成 —— ⛔ 不许手写天数', () => {
for (const mode of [AnchorMode.DIAGNOSIS, AnchorMode.LAST_VISIT]) { const vals = sql().values.flat();
const vals = sql(mode).values.flat();
expect(vals).toContain(HOT_BUCKET_DAYS); expect(vals).toContain(HOT_BUCKET_DAYS);
expect(vals).toContain(WARM_BUCKET_DAYS); expect(vals).toContain(WARM_BUCKET_DAYS);
// 自然年推法两版共用(⛔ 不许一版 365 天一版 interval year) // 自然年推法(⛔ 不许换成 365 天)
expect(sql(mode).strings.join('?')).toMatch(/interval '\d+ years'/); expect(sql().strings.join('?')).toMatch(/interval '\d+ years'/);
}
}); });
}); });
...@@ -117,6 +117,38 @@ describe('MCP 诊所 id —— 不许模型自己编', () => { ...@@ -117,6 +117,38 @@ describe('MCP 诊所 id —— 不许模型自己编', () => {
expect(FACTORY).toMatch(/不知道就不要传/); expect(FACTORY).toMatch(/不知道就不要传/);
expect(FACTORY).toMatch(/get_current_user 返回的 clinicIds/); expect(FACTORY).toMatch(/get_current_user 返回的 clinicIds/);
}); });
/**
* 🔴 **产品内的助手已经完全看不到 clinicId 这一格**(2026-08-14)——
* `AssistantService` 包 MCP 工具时把它摘掉,并注入主管正看着那家(`stripClinicId`)。
*
* 上面那条("不知道就不要传")是**说服**模型别编,这条是**让它没得编**:
* 同一个坑的 ③ 提示词层与 ① 结构层,两条都要在。
*
* ⚠️ 这里锁的是**两个名字**:注入按工具名走(同一个 `clinicId` 在两类工具里意思不同 ——
* `get_agents` 不传 = 回落第一家,`list_recall_queue` 不传 = **不按诊所过滤**),
* 而工具名改掉、或 clinicId 换成别的 schema,注入都会**静默失效**:
* 模型照样看不到这一格,服务端也不再补,于是永远回落第一家 ——
* 多诊所主管在第二家问"这批人构成",拿到的是第一家的数,零报错。
*/
test('⭐⭐ 服务端注入的那两个工具:名字与 CLINIC_ID_SCHEMA 的绑定不许漂', () => {
const ASSISTANT = readFileSync(
join(__dirname, '../src/modules/assistant/assistant.service.ts'),
'utf8',
);
const decl = ASSISTANT.match(/const CLINIC_SCOPED_TOOLS = new Set\(\[([\s\S]*?)\]\)/);
expect(decl).not.toBeNull();
const names = [...(decl?.[1] ?? '').matchAll(/'([^']+)'/g)].map((m) => m[1]).sort();
expect(names).toEqual(['get_agents', 'get_cohort_attributes']);
for (const name of names) {
const at = FACTORY.indexOf(`'${name}',`);
expect(at).toBeGreaterThan(0);
expect(FACTORY.slice(at, at + 3000)).toContain('clinicId: CLINIC_ID_SCHEMA');
}
// ⛔ 「不传 = 不过滤」的那两个绝不能进来:注入会凭空加一道筛子,而少掉的部分没有提示
expect(names).not.toContain('list_recall_queue');
expect(names).not.toContain('recall_queue_stats');
});
}); });
describe('personaTags 维度字典 —— 猜错会被静默丢掉,所以必须给全', () => { describe('personaTags 维度字典 —— 猜错会被静默丢掉,所以必须给全', () => {
...@@ -176,16 +208,77 @@ describe('助手措辞 —— 码给工具,中文给人', () => { ...@@ -176,16 +208,77 @@ describe('助手措辞 —— 码给工具,中文给人', () => {
test('⭐⭐ 主管工作流里有一条独立的「说人话」硬约束,且点名了实际漏出来的那些词', () => { test('⭐⭐ 主管工作流里有一条独立的「说人话」硬约束,且点名了实际漏出来的那些词', () => {
expect(PROMPTS).toMatch(/说人话/); expect(PROMPTS).toMatch(/说人话/);
for (const leaked of ['cold_3y', 'personaTags', 'get_cohort_attributes', '温度']) { /**
expect(PROMPTS).toContain(leaked); // 都被明确列进了禁说清单 * 🔴🔴 **2026-08-14 改判:那份举例从公共层删了,因为它是分配这一条线的词汇。**
*
* 原断言要求 base 里点名 `cold_3y` / `personaTags` / `get_cohort_attributes` / 温度。
* 两个问题:
* ① **污染公共层** —— base 是主管和客服共用的,而 `get_cohort_attributes`
* 是**主管专属工具**(条件注册):客服那条链路根本看不到它,
* 却要读一句"别说 get_cohort_attributes"。分配只是将来若干条线里的一条。
* ② **本来就是第二份** —— 「这些取值码只用于调工具,一个字都不许说给主管」
* 在 `TEMPERATURE_TOOL_DESC` 和 `PERSONA_TAGS_DESC` 里各有一份,
* 挂在**产生这些码的那一格参数上**,模型读到码的同一眼就读到怎么说。
* ⇒ 公共层只留**不变的规则**(取值码 / 字段名 / 工具名 / 只在代码里存在的概念
* 都不在他的词汇表里),举例跟着各自的业务线走。
* ⚠️ 将来哪个词又漏出来,补到**那条线自己的描述**里,⛔ 别再补回公共层。
*/
// ⚠️ 只看**递给模型**的那部分:注释里怎么写都行(那是给人看的沿革)
const PROMPT_TEXT = (
PROMPTS.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '')
);
expect(PROMPT_TEXT).toMatch(/取值码、字段名、工具名/);
expect(PROMPT_TEXT).toMatch(/只在代码里存在的概念/);
for (const leaked of ['cold_3y', 'personaTags', 'get_cohort_attributes']) {
expect(PROMPT_TEXT).not.toContain(leaked);
} }
// 光禁止不够,得给出替代说法(否则模型只会绕着说,一样难懂) // 举例活在各自那一格参数上 —— 这两条是它们的新落点
expect(PROMPTS).toMatch(/往后放一档/); expect(TEMPERATURE_TOOL_DESC).toMatch(/不许说给主管/);
expect(PERSONA_TAGS_DESC).toMatch(/不许说给主管/);
/**
* 🔴 2026-08-14:替代说法(「往后放一档」)从提示词里**删了** —— 它是第三份。
* 完整那份挂在 `temperature` 参数上(`TEMPERATURE_TOOL_DESC`),
* 那才是它该在的地方:取值码就在那一格里,模型读到码的同一眼就读到怎么说。
* ⇒ 光禁止不够、得给出替代说法这条**没变**,只是断到活着的那一份上。
*/
expect(TEMPERATURE_TOOL_DESC).toMatch(/往后放一档/);
expect(PROMPTS).not.toMatch(/往后放一档/);
}); });
test('客服那一侧也要有同样的约束(取值码不分角色地漏)', () => { /**
const staff = PROMPTS.slice(PROMPTS.indexOf('const STAFF_EXTRA')); * 取值码不分角色地漏 ⇒ 客服那侧同样要有这条约束。
expect(staff).toMatch(/说人话/); *
* 🔴 2026-08-13 改断言:此前主管层和客服层**各写了一份**,而两份已经漂了
* (主管版列了六个内部词,客服版只列两个)。⇒ 整条升进 base,角色层一份不留。
* 所以这里锁的不再是"客服层也有",而是**只有一份、且在 base**。
*/
/**
* 🔴 提示词**自己**不许用禁词表里的词 —— 模型模仿的是你实际怎么写,不是你列了多少禁词。
*
* 2026-08-13 产品走查抓到两处:
* 「他在卡片上确认,这一下才**写库**」 —— 只在代码里存在的词,主管不认识
* 「封顶在目标**水位**」「**水位法**…」 —— 而「水位」就列在同一份禁词表里
* ⚠️ 只断角色层:base 的禁词清单本身**必须**出现这些词(它是反例)。
*/
test('🔴 角色层正文里⛔ 不许出现「写库」「水位」这类只在代码里存在的词', () => {
const roleLayers = PROMPTS.slice(PROMPTS.indexOf('const DISPATCHER_EXTRA'));
expect(roleLayers).not.toMatch(/写库/);
expect(roleLayers).not.toMatch(/水位/);
});
/**
* ⚠️ 2026-08-14 按新分层改锚点(`SYSTEM_PROMPT` 拆成 IDENTITY / EVIDENCE / VOICE,
* `DISPATCHER_EXTRA` 改名 `DISPATCHER_ROLE`)。锁的仍是同一件事:
* 「说人话」在**公共层**一份,⛔ 两个角色层都不许再抄
* (2026-08-13 实测它在两个角色层各有一份且已经漂了)。
*/
test('⭐⭐ 「说人话」只在公共层有一份,⛔ 两个角色层都不许再抄', () => {
const commonStart = PROMPTS.indexOf('const EVIDENCE = `');
const roleStart = PROMPTS.indexOf('const DISPATCHER_ROLE = `');
expect(commonStart).toBeGreaterThan(-1);
expect(roleStart).toBeGreaterThan(commonStart);
expect(PROMPTS.slice(commonStart, roleStart)).toMatch(/说人话/);
expect(PROMPTS.slice(roleStart)).not.toMatch(/说人话/);
}); });
}); });
...@@ -242,14 +335,56 @@ describe('确认后没配福利 → 提醒补挂,但不许编效果', () => { ...@@ -242,14 +335,56 @@ describe('确认后没配福利 → 提醒补挂,但不许编效果', () => {
'utf8', 'utf8',
); );
test('⭐ 界面侧:只在 benefit 为空时才提醒', () => { /**
expect(SHEET).toMatch(/benefit\.trim\(\)\s*\n?\s*\?\s*''/); * 🔴🔴 **2026-08-15 再改判:确认单里不做福利引导(产品定)。**
expect(SHEET).toMatch(/这批还没带福利/); *
* 沿革三版,⛔ 别把任何一版当"更好的旧做法"捡回来:
* ① 前端拼一句助手台词塞进对话 —— 署的是助手的名,而它根本没说过;
* ② 界面在确认那一刻往这张单挂一个 `benefit_missing` 引导节点 ——
* 主管点完确认,卡片里长出一块黄色引导;
* ③ **现在**:卡片什么都不摆。提醒挪到**确认之前**由模型问一句
* (提示词 ③「他确认」那一节:「要带现在说,不带就算了」),
* 确认之后正常输出。
* ⚠️ 意图没变的那几条仍然锁着:不许编效果、喂模型的那份只有事实、
* 确认那一句自成一条消息、`本批福利` 这个事实始终在(他回头问才答得上来)。
*/
test('🔴 卡片里**不许**再有确认后的福利引导(三版沿革见上)', () => {
expect(SHEET).not.toMatch(/key: 'benefit_missing'/);
// ⚠️ 带逗号 = 真代码;⛔ 别放宽成不带逗号,那会误伤记录沿革的注释
expect(SHEET).not.toMatch(/stage: 'post_confirm',/);
// ⛔ 更早那版"前端替助手开口"同样一个字都不许留
expect(SHEET).not.toMatch(/benefitNudge/);
expect(SHEET).not.toMatch(/summary \+ benefit/);
// ⛔ 也不许绕道:确认回调不再往外传 postConfirm
const CHAT = readFileSync(
join(__dirname, '../../pac-web/src/components/assistant/assistant-chat.tsx'),
'utf8',
);
expect(CHAT).not.toMatch(/settleSheet\(requestId, assignmentId, postConfirm\)/);
});
test('⭐ 提醒挪到**确认之前**,由模型问一句', () => {
// ③「他确认」那一节里问,⛔ 不是④「确认之后」
const step3 = PROMPTS.slice(PROMPTS.indexOf('### 他确认'), PROMPTS.indexOf('### 确认之后'));
expect(step3).toMatch(/还没带福利/);
expect(step3).toMatch(/不带就算了/);
}); });
test('⭐⭐ 提醒必须与摘要之间空一行 —— 单个 \\n 会被 Markdown 折叠成一行', () => { /**
// 实测:折叠后渲染成「…3 天有效 这批还没带福利 ——」,主管只当是上一句的尾巴 * 🔴 2026-08-15 实测:模型讲区间写的是「每人 8~9 条,分完每人手上 48~49 条」,
expect(SHEET).toMatch(/'\\n\\n这批还没带福利/); * 而 GFM 默认把**单个**波浪号也当删除线 —— 两个 `~` 配成一对,中间那截被划掉,
* 主管屏幕上读到的是「每人 **89** 条、手上 **4849** 条」。
* ⚠️ 这是 `assignment-facts.range()` 那条纪律的另一半:那边把区间拆成两个键,
* 是因为字符串 `'4~15'` 会被模型读成 415;数据这头堵住了,**呈现这头照样能粘**。
* ⇒ 渲染层关掉单波浪号。⛔ 这个选项不许删。
*/
test('🔴 助手正文的 markdown ⛔ 不许把单个波浪号当删除线(8~9 会变成 89)', () => {
const CHAT = readFileSync(
join(__dirname, '../../pac-web/src/components/assistant/assistant-chat.tsx'),
'utf8',
);
expect(CHAT).toMatch(/remarkGfm,\s*\{\s*singleTilde:\s*false\s*\}/);
}); });
test('⭐⭐ 确认这一句要自成一条助手消息,⛔ 不能贴在上一条尾巴上', () => { test('⭐⭐ 确认这一句要自成一条助手消息,⛔ 不能贴在上一条尾巴上', () => {
...@@ -257,18 +392,38 @@ describe('确认后没配福利 → 提醒补挂,但不许编效果', () => { ...@@ -257,18 +392,38 @@ describe('确认后没配福利 → 提醒补挂,但不许编效果', () => {
join(__dirname, '../../pac-web/src/components/assistant/assistant-chat.tsx'), join(__dirname, '../../pac-web/src/components/assistant/assistant-chat.tsx'),
'utf8', 'utf8',
); );
expect(CHAT).toMatch(/appendAssistantNote\(summary, modelSummary, \{ asNewMessage: true \}\)/); expect(CHAT).toMatch(/appendAssistantNote\(summary, modelFacts, \{ asNewMessage: true \}\)/);
});
/**
* 🔴 这里曾有一条「节点必须**主管看得见**,⛔ 不能只写进模型那份」——
* 随那个节点一起删于 2026-08-15。它的理由(点确认不触发模型发言,
* 只写给模型 = 主管永远看不到)**仍然成立且仍然重要**:
* 所以提醒被挪到了**确认之前**,那时模型正在发言(见上一条断言)。
* ⛔ 别据此把节点加回卡片 —— 产品定确认单里不做福利引导。
*/
test('🔴 喂模型那份只有事实 —— ⛔ 一个字的指令都不许有', () => {
const facts = /JSON\.stringify\(\{\s*已确认分配:[\s\S]*?\}\),/.exec(SHEET)?.[0] ?? '';
expect(facts).toContain('批次id');
for (const w of ['⛔', '不要', '不许', '请', '你要', '照抄']) {
expect(facts).not.toContain(w);
}
// 完整 uuid 必须给:它撤销这批只能靠这个 id
expect(facts).toMatch(/res\.assignmentId/);
}); });
test('🔴🔴 提醒必须进**可见文案**,⛔ 不能只进模型那份', () => { test('⭐ 界面已经摆出来的东西,⛔ 不许让模型再说一遍', () => {
// appendAssistantNote 只往消息流塞文字,**不会触发模型新一轮发言** —— // ⚠️ 2026-08-15 起是「不用你**再**提」——③ 允许确认前预告了(产品改判),
// 只写进 modelSummary 的话,主管永远看不到这句(实测过:确认完对话里一个字都没多)。 // 到④ 界面接手时,禁的就从"别提"变成"别重复"。⛔ 这一条本身不许删。
expect(SHEET).toMatch(/summary \+ benefitNudge/); expect(PROMPTS).toMatch(/不用你再?提/);
expect(SHEET).toMatch(/不会触发模型新一轮发言/); // 「只提一次」这条规则**随旧做法一起删掉了** —— 模型压根不提,就没有"提几次"这回事。
// ⭐ 规则越多每条被遵守的概率越低,能靠结构去掉的就别留在提示词里。
expect(PROMPTS).not.toMatch(/只提一次/);
}); });
test('⭐ 界面已经说过的话,⛔ 不许让模型再复述一遍', () => { test('⭐ `本批福利` 这个事实要一直给模型 —— 他回头问「这批带福利没」才答得上来', () => {
expect(SHEET).toMatch(/界面已经对主管说过了/); expect(SHEET).toMatch(/本批福利: benefit\.trim\(\) \|\| null/);
}); });
test('⭐ 确认摘要里用中文项目名,⛔ 不许出现 endo/filling 这种码', () => { test('⭐ 确认摘要里用中文项目名,⛔ 不许出现 endo/filling 这种码', () => {
...@@ -276,22 +431,26 @@ describe('确认后没配福利 → 提醒补挂,但不许编效果', () => { ...@@ -276,22 +431,26 @@ describe('确认后没配福利 → 提醒补挂,但不许编效果', () => {
expect(SHEET).toMatch(/potentialTreatmentItemName\(sheet\.potentialTreatment\)/); expect(SHEET).toMatch(/potentialTreatmentItemName\(sheet\.potentialTreatment\)/);
}); });
test('⭐⭐ 两侧都必须写明「⛔ 不许说成功率/转化率更高」', () => { /**
for (const src of [PROMPTS, SHEET]) { * ⚠️ 2026-08-15 起只锁**提示词**这一侧:卡片里那段福利文案连同引导节点一起删了,
expect(src).toMatch(/成功率/); // 出现在禁令里 * ⛔ 别为了"两侧对称"再往卡片里塞一句禁令 —— 那里已经不说福利了。
expect(src).toMatch(/不统计成功|不许说/); */
} test('⭐⭐ 提示词里必须写明「⛔ 不许说成功率/转化率更高」', () => {
}); expect(PROMPTS).toMatch(/成功率/); // 出现在禁令里
expect(PROMPTS).toMatch(/不统计成功|不许说/);
test('⭐ 只提一次 —— 每轮都念会变成骚扰', () => {
// 界面侧天然只说一次(确认那一刻拼一次);模型侧要被明确拦住,否则它会跟着复述
expect(PROMPTS).toMatch(/只提一次/);
expect(SHEET).toMatch(/不要再重复一遍/);
expect(SHEET).toMatch(/说不用就别再提/);
}); });
/**
* ⚠️ 2026-08-15 现场段改成 `选人 → 分人 → 他确认 → 确认之后` 四步分节后,
* 「确认之后」这半句由**小标题**承担 —— ⛔ 别再去匹配
* 「确认之后福利仍然可以补挂」这种整句:位置本身就是它的适用范围,
* 锁整句等于把"这条规矩必须写成一句话"也锁了进去。
* ⇒ 锁的是**这一步在、且三条不变量都在**。
*/
test('提示词里要说清"确认后仍可补挂、但人员时效改不了"', () => { test('提示词里要说清"确认后仍可补挂、但人员时效改不了"', () => {
expect(PROMPTS).toMatch(/确认之后福利仍然可以补挂/); expect(PROMPTS).toMatch(/### 确认之后/);
expect(PROMPTS).toMatch(/补挂/);
expect(PROMPTS).toMatch(/人员和时效改不了/);
expect(PROMPTS).toMatch(/撤销重分/); expect(PROMPTS).toMatch(/撤销重分/);
}); });
}); });
...@@ -422,15 +581,29 @@ describe('待分配的批量处置 —— 决定他做,执行你做', () => { ...@@ -422,15 +581,29 @@ describe('待分配的批量处置 —— 决定他做,执行你做', () => {
test('⭐⭐ 提示词要点名禁止「我不能替您做这个决定」这种甩锅', () => { test('⭐⭐ 提示词要点名禁止「我不能替您做这个决定」这种甩锅', () => {
// ⚠️ 标点写成 `[,,]`:提示词的中文标点 2026-08-11 统一改成全角了(模型在照抄它), // ⚠️ 标点写成 `[,,]`:提示词的中文标点 2026-08-11 统一改成全角了(模型在照抄它),
// ⛔ 别把断言钉死在某一种标点上 —— 那种失败与被测行为无关,只是噪音。 // ⛔ 别把断言钉死在某一种标点上 —— 那种失败与被测行为无关,只是噪音。
expect(PROMPTS).toMatch(/决定他做[,,]执行你做/); /**
* ⚠️ 2026-08-14:角色层拆分后没有小标题了(只剩三段权责边界),
* 「决定他做,执行你做」那个 `###` 标题随之消失 —— 同一件事现在是开篇第一句。
*/
expect(PROMPTS).toMatch(/他做决定[,,]你把决定落成动作/);
expect(PROMPTS).toMatch(/甩锅/); expect(PROMPTS).toMatch(/甩锅/);
// 同时仍然保留"不许自作主张"那一半 —— 两半缺一不可 // 同时仍然保留"不许自作主张"那一半 —— 两半缺一不可
expect(PROMPTS).toMatch(/不许自作主张/); expect(PROMPTS).toMatch(/不许自作主张/);
}); });
test('⭐ 不许把"铺平"说成"随机分了"', () => { /**
expect(PROMPTS).toMatch(/不是真随机/); * ⚠️ 2026-08-13:这条约束**只留在工具描述里**(`edit_assignment_sheet`)。
expect(SVC).toMatch(/别顺着"随机"两个字/); * 在此之前 DISPATCHER_EXTRA 里也抄了一份 —— 而 assistant-prompts 的文件头写着
* 「工具自己的坑留在工具描述里,写进工作流约束等于开第二份副本,两边会各自漂移」。
* ⇒ 断它在**该在的地方**,⛔ 别断它有两份。
*/
/**
* ⚠️ 2026-08-14 改断言:同一条约束搬到了 `to` **参数**上(工具描述只回答"是什么/何时调",
* 每一格怎么填归那一格)。锁的仍是同一件事:balance 是确定性的,⛔ 不许复述成"我随机分了"。
*/
test('⭐ 不许把"铺平"说成"随机分了" —— 约束落在 to 参数上', () => {
expect(SVC).toMatch(/balance 是按在手量铺的,不是随机/);
expect(SVC).toMatch(/界面回报里说的是铺给了谁几条,照那句转述/);
}); });
}); });
...@@ -573,7 +746,17 @@ describe('确认单话术 —— 让主管一眼看懂', () => { ...@@ -573,7 +746,17 @@ describe('确认单话术 —— 让主管一眼看懂', () => {
test('⭐⭐ 有待分配时必须给出**等式**,否则两个数字看着打架', () => { test('⭐⭐ 有待分配时必须给出**等式**,否则两个数字看着打架', () => {
// 等式改成了"分段加粗起头"的形式:待分配自成一段,且标题里就带数字 // 等式改成了"分段加粗起头"的形式:待分配自成一段,且标题里就带数字
expect(SVC_PROPOSAL).toMatch(/\*\*⚠️ 其中 \$\{pending\.length\} 人要您定\*\*/); expect(SVC_PROPOSAL).toMatch(/\*\*⚠️ 其中 \$\{pending\.length\} 人要您定\*\*/);
expect(SVC_PROPOSAL).toMatch(/\*\*已排好 \$\{x\.placed\} 人 · \$\{x\.loads\.length\} 位客服\*\*/); /**
* 🔴 2026-08-14 改断言:「已排好 N 人 · M 位客服」原本断的是 `basisNote` 里的模板,
* 而 basisNote **只写不读**、已删。这句话真正的出处一直是**卡片自己**
* (它跟「待分配」成对出现,合起来才读得出 N 含不含待分配的那些)。
* ⇒ 断到活着的那一处。
*/
const CARD = readFileSync(
join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
'utf8',
);
expect(CARD).toMatch(/已排好 \{items\.length\} 人 · \{groups\.length\} 位客服/);
}); });
test('⛔ 不许再出现括号套括号的负载描述', () => { test('⛔ 不许再出现括号套括号的负载描述', () => {
...@@ -586,16 +769,74 @@ describe('确认单话术 —— 让主管一眼看懂', () => { ...@@ -586,16 +769,74 @@ describe('确认单话术 —— 让主管一眼看懂', () => {
expect(SVC_ASSIST).not.toMatch(/一条都还没落库/); expect(SVC_ASSIST).not.toMatch(/一条都还没落库/);
}); });
test('⭐⭐ 四条操作指引搬到卡片常驻,⛔ 不再要求助手每轮复述', () => { /**
// 🔴 2026-08-12(P2):这条规矩从**工具返回值**搬到了系统提示词 —— * 🔴 **键名就是提示词的一个面。** 2026-08-14 走查:模型对主管说
// 返回值里现在只有事实、一个字的指令都没有(见 assignment-facts 顶部注释)。 * 「这只是提案,尚未**写入数据库**」—— 出处不是提示词(那天刚把「写库」换成人话),
expect(PROMPTS).toMatch(/不要把选项列一遍/); * 而是 `propose_assignment` 返回值里的键名 `已写入数据库: false`。
expect(SHEET).toMatch(/可以直接跟我说:「把某某移出这批」/); * 给了名字就等于给了先验,而键名又必须是中文(模型照着键名说才对)
// ⚠️ 只在还能改的时候显示 —— 已确认/已撤销时它是误导 * ⇒ ⛔ 不想让它说的词,就别做成键名。
// ⚠️ 类名别写太死:`border-slate-100` 2026-08-11 随「全局 border 规则放进 @layer base」 * ⚠️ 断言只覆盖**返回给模型的那个对象**;注释里出现「数据库」不算(本注释自己就有)。
// 那次清理去掉了(颜色本来就来自全局那条,写了也是同一个灰)。这里只锁**结构** —— */
// 「readOnly 时不渲染这块提示」,⛔ 别再把一整串 class 抄进正则。 /**
expect(SHEET).toMatch(/\{!readOnly && \(\s*<div className="border-t [^"]*bg-slate-50/); * 🔴 **模型能念出口的中文有四个来源,不是一个。**
* ① 系统提示词 ② 工具描述 ③ 给模型的返回值(键名和值) ④ 前端回灌进历史的文本
*
* 2026-08-14 一次走查里**②③④ 全中**:提示词那天刚把「写库」换成人话,模型却仍然
* 对主管说「这只是提案,尚未写入数据库」「在那之前一个字都没落库」—— 出处是
* `propose_assignment` 的工具描述(「一个字都没写库」)、返回值键名(`已写入数据库`)、
* 以及 chat-blocks 回灌的作废确认单 note(「一条都没落库」)。
* ⇒ 只扫提示词是不够的。这条断言把四个面一起钉住。
*
* ⚠️ 只查**字符串字面量**:注释里当然要出现这些词(本注释自己就是),
* 而注释不进模型上下文。
*/
test('🔴 四个面的字符串字面量里⛔ 都不许有「落库/写库/数据库」', () => {
const FILES: Record<string, string> = {
'提示词': PROMPTS,
'工具描述(本地)': SVC_ASSIST,
'工具描述(MCP)': readFileSync(
join(__dirname, '../src/modules/mcp/mcp-server.factory.ts'),
'utf8',
),
'回灌历史(前端)': readFileSync(
join(__dirname, '../../pac-web/src/components/assistant/chat-blocks.ts'),
'utf8',
),
};
const banned = /数据库|落库|写库|入库/;
// 先去注释:JSDoc 里用反引号写内联代码(`写库`)会被下面的字面量正则当成模板串。
// ⚠️ `[^:]` 是为了不误伤 `https://` —— ⛔ 别简化成 /\/\/.*$/。
const strip = (s: string): string =>
s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
for (const [name, src] of Object.entries(FILES)) {
const literals = strip(src).match(/'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`/g) ?? [];
expect({ [name]: literals.filter((s) => banned.test(s)) }).toEqual({ [name]: [] });
}
});
test('🔴 给模型的返回值键名里⛔ 不许有「数据库」这种只在代码里存在的词', () => {
const ret = SVC_ASSIST.slice(
SVC_ASSIST.indexOf('确认单已呈现'),
SVC_ASSIST.indexOf('...modelFacts(sheet)'),
);
expect(ret).toMatch(/已经分下去了/); // 这条事实本身必须还在(①级防「已分配」幻觉)
expect(ret.replace(/\/\*[\s\S]*?\*\//g, '')).not.toMatch(/数据库|落库|写库/);
});
/**
* 🔴 「主管不知道能让助手干什么」这个诉求的落点**换过两次**:
* ① 最早要求助手每轮在消息里念一遍 → 第二次起全是噪音(那段比确认单还长);
* ② 2026-08-06 搬成卡片上常驻的一份**通用例句清单**;
* ③ 2026-08-13 清单删掉 —— 由**引导节点**回答,它是针对这一批的具体选项
* (带人数、可点),比通用例句强得多。
* ⇒ 不变的只有一条:⛔ 助手不许每轮复述选项。
*/
test('⭐⭐ 助手不复述选项;卡片上的通用例句清单已被引导节点取代', () => {
// ⚠️ 2026-08-14 措辞泛化:「选项」「按钮」都是**界面部件**,界面一改这句就过期 ——
// 改成「可选动作」并去掉"那排按钮"的描述(同轮把 base 里的部件名一起清了)。
expect(PROMPTS).toMatch(/不要把可选动作列一遍/);
// ⛔ 别把通用例句清单加回卡片 —— 两处都在教"能做什么",通用的永远比不过当下那几个按钮
expect(SHEET).not.toMatch(/可以直接跟我说/);
}); });
test('⭐ 待分配的行要显示医生 —— 与已分配的行字段一致', () => { test('⭐ 待分配的行要显示医生 —— 与已分配的行字段一致', () => {
...@@ -723,7 +964,8 @@ describe('确认单指令 —— 三个正交的轴', () => { ...@@ -723,7 +964,8 @@ describe('确认单指令 —— 三个正交的轴', () => {
expect(ok({ select: { group: 'pending' }, action: 'assign', to: { mode: 'owner' } })).toBe(true); expect(ok({ select: { group: 'pending' }, action: 'assign', to: { mode: 'owner' } })).toBe(true);
expect(ok({ select: { group: 'pending' }, action: 'assign', to: { mode: 'balance' } })).toBe(true); expect(ok({ select: { group: 'pending' }, action: 'assign', to: { mode: 'balance' } })).toBe(true);
// ⛔ owner 不吃 agents:收谁由患者自己的专属关系决定,轮不到点名 // ⛔ owner 不吃 agents:收谁由患者自己的专属关系决定,轮不到点名
expect(SVC).toMatch(/mode=owner 不吃这个参数/); // ⚠️ 2026-08-14 只改了措辞(「不吃」→「不看」),这条锁的仍是同一件事
expect(SVC).toMatch(/mode=owner 时不看这个参数/);
}); });
/** /**
...@@ -742,14 +984,30 @@ describe('确认单指令 —— 三个正交的轴', () => { ...@@ -742,14 +984,30 @@ describe('确认单指令 —— 三个正交的轴', () => {
expect(ok(op)).toBe(false); expect(ok(op)).toBe(false);
}); });
test('🔴🔴 提示词教的是**三问怎么拼**,⛔ 不是一串要背的短语', () => { /**
expect(SVC).toMatch(/选谁[((]select[))]· 干什么[((]action[))]· 给谁[((]to[))]/); * 🔴🔴 **2026-08-14 重写 `edit_assignment_sheet` 的工具描述之后改判。**
expect(SVC).toMatch(/别去背短语[,,]按这三问填/); *
// 举例可以有,但必须写明"别当成清单" —— 否则模型只会照抄例子,没列到的组合又不敢拼 * 教「三问怎么拼」这条没变,但**每一格该填什么搬到了那一格自己的参数说明里** ——
expect(SVC).toMatch(/⛔ 别当成清单/); * 原来 owner/balance 的判别在工具描述和 `to` 参数上各写了一份,改一处就会漂。
// 把实测栽的那次写成反例:抽象规则模型容易绕过,具体反例不会 * ⇒ 工具描述只回答「是什么 / 什么时候调 / 后果」,判据锁到参数上。
expect(SVC).toMatch(/散给了 17 位\*\*别人\*\*/); * ⚠️ 那次实测事故(他说「各自分给各自的专属客服」,助手用了铺平,18 人被散给 17 位别人)
expect(SVC).toMatch(/话里出现「各自 \/ 自己的 \/ 本来的 \/ 专属 \/ 谁的客户归谁」/); * 现在以**关键词表**的形式活着 —— 五个词一个都不能少,少一个就少一条判据。
* ⛔ JSON 拼法举例整块删了:例子里的具体人名和天数紧挨着要填的那几格,是白给的锚;
* 三段结构由 schema 本身表达。
*/
test('🔴🔴 三问结构在工具描述里,每一格怎么填在参数里', () => {
expect(SVC).toMatch(/\*\*选谁\*\*(select)· \*\*干什么\*\*(action)· \*\*给谁\*\*(to/);
expect(SVC).toMatch(/别去背短语/);
// owner/balance 的判据:五个关键词逐个列全
// ⚠️ 断言不跨字符串拼接点(源码里这几句被 `' +` 断开了)
expect(SVC).toMatch(/他说「各自 \/ 自己的 \/ 本来的/);
expect(SVC).toMatch(/谁的客户归谁」指的是 owner/);
expect(SVC).toMatch(/「平均分 \/ 谁手上少给谁 \/ 铺下去/);
expect(SVC).toMatch(/随机分一分」指的是 balance/);
// ⛔ 举例里不许再出现具体人名(白给模型一个照填的锚)
for (const n of ['王强', '张悦', '李莉', '杨丽华']) {
expect(SVC.split('description:').slice(1).join('')).not.toContain(`"${n}"`);
}
}); });
test('🔴🔴 界面侧 owner:收的人必须是患者**自己的** ownerUserId', () => { test('🔴🔴 界面侧 owner:收的人必须是患者**自己的** ownerUserId', () => {
...@@ -805,20 +1063,22 @@ describe('重新排一版 —— 只换挑谁,不动落人', () => { ...@@ -805,20 +1063,22 @@ describe('重新排一版 —— 只换挑谁,不动落人', () => {
}); });
/** /**
* 🔴 **运营结论钉在卡片上,⛔ 不是助手那段话里。** * 🔴 **运营结论(opsNote)2026-08-13 整块删除。**
* *
* 与重排交代同一条理由:助手那段话会被后续对话冲走,而卡片一直在; * 2026-08-04 产品评审要的"结论性的东西"(这活多大)诉求没变,只是出口换了 ——
* 重排换卡片时助手也不会重新说一遍。⇒ 「这活多大」必须跟着卡片走。 * 同一批数现在有三处,卡片那份是第三遍:模型正文 + `batch_size_basis` + `daily_overload`。
* 由来:2026-08-04 产品评审「你在**这儿**应该给的是结论性的东西」—— 这儿 = 确认单。 * ⇒ 卡片那个位置改放**只有卡片能做的事**(拖、点 ×、逐条改时效)。
* ⚠️ 这条测试锁的是**不许加回来**。
*/ */
test('🔴 运营结论渲染在卡片上,且助手不重复说一遍', () => { test('⛔ opsNote 已删,卡片那个位置放的是"这张单能怎么改"', () => {
expect(SHEET).toMatch(/sheet\.opsNote/); expect(SHEET).not.toMatch(/opsNote/);
// 助手只念 selectionNote / basisNote / pendingNote 三段,⛔ opsNote 不在其中 expect(SVC).not.toMatch(/const opsNote =/);
const ASSISTANT = readFileSync( // 换上去的是卡片专属操作 —— 模型说不了,也做不成引导节点
join(__dirname, '../src/modules/assistant/assistant.service.ts'), expect(SHEET).toMatch(/拖动患者/);
'utf8', // ⚠️ 只在还能改的时候显示 —— 已确认/已撤销时它是误导
); // ⛔ 别把 class 抄进正则(2026-08-13 栽过:补了个 `border-b`,而那是产品特意去掉的),
expect(ASSISTANT).not.toMatch(/sheet\.opsNote/); // 这里只锁**结构**。
expect(SHEET).toMatch(/\{!readOnly && \(\s*<div className="[^"]*bg-slate-50/);
}); });
test('🔴🔴 重排换了什么必须由**卡片自己**说 —— 助手不会重新说一遍', () => { test('🔴🔴 重排换了什么必须由**卡片自己**说 —— 助手不会重新说一遍', () => {
...@@ -852,19 +1112,31 @@ describe('重新排一版 —— 只换挑谁,不动落人', () => { ...@@ -852,19 +1112,31 @@ describe('重新排一版 —— 只换挑谁,不动落人', () => {
expect(SVC).not.toMatch(/swapCandidates/); expect(SVC).not.toMatch(/swapCandidates/);
}); });
test('⭐⭐ 卡片上**只有一个按钮**,⛔ 不再复述助手已经说过的解释', () => { /**
expect(SHEET).toMatch(/重新排一版,尽量排满/); * 🔴 重排的入口 2026-08-13 从**卡片按钮**搬到了**引导节点**(「换无主患者补上」)。
expect(SHEET).toMatch(/sheet\.canRefill/); * 同一个动作两个入口必然漂,而且主管要在两处之间猜哪个是哪个。
*/
test('⭐⭐ 重排只有引导节点一个入口,⛔ 卡片上不再有按钮', () => {
expect(SHEET).not.toMatch(/重新排一版,尽量排满/);
const SIGNALS = readFileSync(
join(__dirname, '../src/modules/plan/assignment-signals.ts'),
'utf8',
);
expect(SIGNALS).toMatch(/换无主患者补上/);
expect(SIGNALS).toMatch(/PENDING_REFILL/);
// ⛔ 名次不该出现在界面上(产品:排名不重要) // ⛔ 名次不该出现在界面上(产品:排名不重要)
expect(SHEET).not.toMatch(/排名靠后/); expect(SHEET).not.toMatch(/排名靠后/);
// ⛔ 分布说明已经在助手那段话里了,卡片不再重复
expect(SHEET).not.toMatch(/本批候选 \{sheet\.poolOwnership/);
}); });
test('⭐ 重排要把条件原样带回 —— 少带一个就退回"沿用上一次",人数悄悄变了', () => { test('⭐ 重排要把条件原样带回 —— 少带一个就退回"沿用上一次",人数悄悄变了', () => {
expect(SHEET).toMatch(/targetCount: sheet\.batchSize/); // ⚠️ 执行处已搬到 use-assistant-chat(按钮不在卡片上了)
expect(SHEET).toMatch(/potentialTreatment: sheet\.potentialTreatment/); const HOOK = readFileSync(
expect(SHEET).toMatch(/temperature: sheet\.temperature/); join(__dirname, '../../pac-web/src/components/assistant/use-assistant-chat.ts'),
'utf8',
);
expect(HOOK).toMatch(/targetCount: s\.batchSize/);
expect(HOOK).toMatch(/potentialTreatment: s\.potentialTreatment/);
expect(HOOK).toMatch(/temperature: s\.temperature/);
}); });
/** /**
...@@ -874,12 +1146,13 @@ describe('重新排一版 —— 只换挑谁,不动落人', () => { ...@@ -874,12 +1146,13 @@ describe('重新排一版 —— 只换挑谁,不动落人', () => {
* 台词稿一删就整条失效,而它锁的那个不变式(**已排好在前、要他定的在后**)其实还在。 * 台词稿一删就整条失效,而它锁的那个不变式(**已排好在前、要他定的在后**)其实还在。
* ⇒ 断行为不断源码。 * ⇒ 断行为不断源码。
*/ */
test('⭐ 给模型的事实:怎么选的 → 怎么派的 → 要主管定的', () => { // 🔴 2026-08-14:由三段改两段 —— 「要主管定的」搬进「怎么派的」(键序即段序,它属于派活)。
// ⚠️ 这条锁的不变式**没变**:先说人怎么来的,再说怎么派的。
test('⭐ 给模型的事实:怎么选的 → 怎么派的', () => {
const keys = Object.keys( const keys = Object.keys(
modelFacts({ modelFacts({
potentialTreatment: 'implant', potentialTreatment: 'implant',
temperature: 'hot', temperature: 'hot',
anchorMode: 'diagnosis',
candidateTotal: 100, candidateTotal: 100,
target: 50, target: 50,
batchSize: 50, batchSize: 50,
...@@ -893,6 +1166,75 @@ describe('重新排一版 —— 只换挑谁,不动落人', () => { ...@@ -893,6 +1166,75 @@ describe('重新排一版 —— 只换挑谁,不动落人', () => {
signals: [], signals: [],
} as unknown as AssignmentProposal), } as unknown as AssignmentProposal),
); );
expect(keys).toEqual(['怎么选的', '怎么派的', '要主管定的']); expect(keys).toEqual(['怎么选的', '怎么派的']);
});
});
/**
* 工具描述与实现**漂移**的回归 —— 这一类错的共同点是:两边都不报错。
*
* 2026-08-14 走查一次抓到两处,都在主管每天要用的那条路上:
* ① `targetCount` / `expiresInDays` 写着「不传自动**沿用上一次**的值」——
* 而沿用 2026-08-12 就被产品砍掉了(`basis` 只可能是 explicit/default,
* `inherited` 那一支只为读老快照保留)。后果不是"少说一句":模型会**故意不传**,
* 以为能继承他上次说的「这批 200 人」,实际回落到公式/默认值。
* ② `propose_assignment` 描述里两次出现「基数」,而那个词**就列在禁说清单里**。
* 实测原话:「比估算的基数(120)少得多」——⇒ 禁令拦不住你自己在描述里用的词。
*/
describe('工具描述不许与实现漂移', () => {
/** 只取**递给模型**的字符串:注释里怎么写都行(那是给人看的) */
const modelFacing = (rel: string): string => {
const raw = readFileSync(join(__dirname, rel), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^[ \t]*\/\/.*$/gm, '');
return (raw.match(/'(?:[^'\\]|\\.)*'/g) ?? []).join('\n');
};
const LOCAL_TOOLS = modelFacing('../src/modules/assistant/assistant.service.ts');
const MCP_TOOLS = modelFacing('../src/modules/mcp/mcp-server.factory.ts');
test('🔴 ⛔ 不许再说「沿用上一次」的人数/天数 —— 那个机制 2026-08-12 就断开了', () => {
// ⚠️ 精调(agentOverrides)**确实**会沿用,所以只断人数与天数这两句
expect(LOCAL_TOOLS).not.toMatch(/不传自动沿用/);
expect(LOCAL_TOOLS).toMatch(/他上一次说的数不会带到这一批/);
expect(LOCAL_TOOLS).toMatch(/他上一次说的天数不会带到这一批/);
});
test('🔴 「基数」这类只在代码里存在的词,⛔ 不许出现在工具描述里', () => {
// 提示词里禁的是模型**说出口**,但它说出口的词正是从这里学的
for (const src of [LOCAL_TOOLS, MCP_TOOLS]) {
for (const w of ['基数', '水位', '探索配额', '排序键']) {
expect(src).not.toContain(w);
}
}
});
/**
* ⭐ **判据里不带具体数字** —— 举例紧挨着的就是要填的那一格。
*
* 原文是「他明确说**「这批 200 人」**时才传」「他明确说了天数(**「给 5 天」**)时才传」。
* 200 和 5 对"他说了数字没有"这个判据一点信息都不加,却白给模型一个可以照填的锚。
* ⚠️ 这条**不是**事故回归(没实测到模型真填过 200)——是一次零成本的删除:
* 判据本身不需要数字来说明,那就别留。
* ⛔ 但 `ops` 那几个拼法示例里的数字要留:那是在演示「他话里的数字落到 days」这件事本身,
* 两边都出现才看得出映射(而且上面写着「⛔ 别当成清单」)。
*/
test('⭐ 参数判据里⛔ 不留具体数字的举例(白给模型一个照填的锚)', () => {
expect(LOCAL_TOOLS).not.toMatch(/这批 200 人/);
expect(LOCAL_TOOLS).not.toMatch(/「给 5 天」/);
expect(LOCAL_TOOLS).toMatch(/只在他说了具体数量时传/);
expect(LOCAL_TOOLS).toMatch(/只在他说了天数时传/);
});
test('⭐ 范围写进 schema,⛔ 不是只写在描述里 —— 结构管得住的别写成一句话', () => {
const SRC = readFileSync(
join(__dirname, '../src/modules/assistant/assistant.service.ts'),
'utf8',
);
// 服务端夹的三处:dailyCalls 1–60 / exploreRatio 0–0.2 / 时效 ≤90(与落库 schema 对齐)
expect(SRC).toMatch(/minimum: 1,\s*\n\s*maximum: 60/);
expect(SRC).toMatch(/minimum: 0,\s*\n\s*maximum: 0\.2/);
expect(SRC).toMatch(/maximum: 90/);
// ⚠️ maxThisBatch **允许 0**(= 这轮不给他),⛔ 别写成 exclusiveMinimum
expect(SRC).toMatch(/maxThisBatch: \{ type: 'number', minimum: 0 \}/);
}); });
}); });
import * as types from '@pac/types';
import { import {
AnchorMode,
ANCHOR_MODE_META,
HOT_BUCKET_DAYS, HOT_BUCKET_DAYS,
parseAnchorMode, TEMPERATURE_AXIS_ZH,
Temperature, Temperature,
TEMPERATURE_META, TEMPERATURE_META,
TEMPERATURE_ORDER, TEMPERATURE_ORDER,
...@@ -186,19 +185,11 @@ describe('标签 ← 诊断码的投影必须与 classifyGapToLabel 一致', () ...@@ -186,19 +185,11 @@ describe('标签 ← 诊断码的投影必须与 classifyGapToLabel 一致', ()
describe('窗口期 hover 文案 —— 只给区间,不给解释', () => { describe('窗口期 hover 文案 —— 只给区间,不给解释', () => {
test('⭐ 六档区间与治疗项无关,同一档在哪一行都是同一句', () => { test('⭐ 六档区间与治疗项无关,同一档在哪一行都是同一句', () => {
// 默认口径(按诊断)—— 主语是"医生最后一次提到",⛔ 不是"诊断距今" // 🔴 主语是"距上次到诊"(2026-08-13 锚点定为末诊,「医生最后一次提到」那版已删)
// (2026-08-11 两版并存后改的措辞:复诊漏写时,它表达的确实是"最后一次被提及") expect(temperatureWindowHint('implant', Temperature.HOT)).toBe('距上次到诊 ≤ 90 天');
expect(temperatureWindowHint('implant', Temperature.HOT)).toBe('医生最后一次提到 ≤ 90 天'); expect(temperatureWindowHint('implant', Temperature.WARM)).toBe('距上次到诊 90–180 天');
expect(temperatureWindowHint('implant', Temperature.WARM)).toBe('医生最后一次提到 90–180 天'); expect(temperatureWindowHint('implant', Temperature.COLD_1Y)).toBe('距上次到诊 180 天–1 年');
expect(temperatureWindowHint('implant', Temperature.COLD_1Y)).toBe('医生最后一次提到 180 天–1 年'); expect(temperatureWindowHint('implant', Temperature.COLD_OVER)).toBe('距上次到诊 > 3 年');
expect(temperatureWindowHint('implant', Temperature.COLD_OVER)).toBe('医生最后一次提到 > 3 年');
// 末诊口径:同一档、同样的天数,**主语换成人**
expect(temperatureWindowHint('implant', Temperature.HOT, AnchorMode.LAST_VISIT)).toBe(
'距上次到诊 ≤ 90 天',
);
expect(temperatureWindowHint('implant', Temperature.COLD_OVER, AnchorMode.LAST_VISIT)).toBe(
'距上次到诊 > 3 年',
);
}); });
test('⭐⭐ 多码标签不再有"并集"这回事 —— 拔牙(K01+K03)与单码标签逐字相同', () => { test('⭐⭐ 多码标签不再有"并集"这回事 —— 拔牙(K01+K03)与单码标签逐字相同', () => {
...@@ -237,7 +228,7 @@ describe('窗口期 hover 文案 —— 只给区间,不给解释', () => { ...@@ -237,7 +228,7 @@ describe('窗口期 hover 文案 —— 只给区间,不给解释', () => {
test('标签不参与计算 —— 传个不存在的标签也给同一句(⛔ 别再回到"未知标签返空串")', () => { test('标签不参与计算 —— 传个不存在的标签也给同一句(⛔ 别再回到"未知标签返空串")', () => {
// 旧口径要查该标签对应的码才能算天数,查不到只能返空串。现在天数是固定的, // 旧口径要查该标签对应的码才能算天数,查不到只能返空串。现在天数是固定的,
// 空串反而是错的(格子会失去 hover 提示)。 // 空串反而是错的(格子会失去 hover 提示)。
expect(temperatureWindowHint('not_a_label', Temperature.HOT)).toBe('医生最后一次提到 ≤ 90 天'); expect(temperatureWindowHint('not_a_label', Temperature.HOT)).toBe('距上次到诊 ≤ 90 天');
}); });
}); });
...@@ -277,30 +268,25 @@ describe('LLM 工具的温度取值 —— 必须与矩阵同源', () => { ...@@ -277,30 +268,25 @@ describe('LLM 工具的温度取值 —— 必须与矩阵同源', () => {
}); });
/** /**
* ⭐ 两版口径(按诊断 / 按末诊)—— 2026-08-11 并存后的契约。 * ⭐ 锚点 = 末诊,**只此一版**(2026-08-13;此前有个 `AnchorMode` 开关,整套已删)。
* *
* 🔴 这里锁的是**口径分叉**那一类事故:切换只能改"距哪个时刻", * 🔴 这里锁的是「⛔ 别把第二版口径加回来」:两版并存时圈人侧与矩阵侧一旦不同步,
* ⛔ 不许顺手改档位切分(90/180/自然年)—— 那会让两版连人数量级都不可比。 * 就是主管看到 87 人、确认单捞出另一批,而且全程不报错(T14)。
*/ */
describe('档位口径 —— 两版并存', () => { describe('档位口径 —— 只剩一版', () => {
test('⭐ 非法 / 缺省一律回落按诊断(查询串是用户可改的,⛔ 不抛错)', () => { test('⛔ AnchorMode / parseAnchorMode 不许复活', () => {
expect(parseAnchorMode(undefined)).toBe(AnchorMode.DIAGNOSIS); const mod = types as Record<string, unknown>;
expect(parseAnchorMode('')).toBe(AnchorMode.DIAGNOSIS); for (const k of ['AnchorMode', 'ANCHOR_MODE_META', 'parseAnchorMode', 'ANCHOR_MODE_TOOL_DESC'])
expect(parseAnchorMode('lastvisit')).toBe(AnchorMode.DIAGNOSIS); // 少个下划线也不许猜 expect(mod[k]).toBeUndefined();
expect(parseAnchorMode(AnchorMode.LAST_VISIT)).toBe(AnchorMode.LAST_VISIT);
}); });
test('⭐⭐ 只有非默认那版带批次名标记 —— 老批次不受影响、不用改名', () => { test('⭐ 六档的主语统一是「距上次到诊」—— ⛔ 没有第二种措辞', () => {
expect(ANCHOR_MODE_META[AnchorMode.DIAGNOSIS].batchTag).toBe(''); for (const t of TEMPERATURE_ORDER)
expect(ANCHOR_MODE_META[AnchorMode.LAST_VISIT].batchTag).toBe('按末诊'); expect(temperatureWindowHint('implant', t)).toMatch(/^距上次到诊 /);
}); });
test('⭐⭐ 两版的**档位切分**必须逐字相同 —— 换的只是主语', () => { test('⭐ 列头说明的轴名与 hover 主语同源 —— ⛔ 别各写各的', () => {
for (const t of TEMPERATURE_ORDER) { expect(TEMPERATURE_AXIS_ZH).toBe('患者最后一次到诊');
const a = temperatureWindowHint('implant', t, AnchorMode.DIAGNOSIS); for (const t of TEMPERATURE_ORDER) expect(TEMPERATURE_META[t].hint).toContain('上次到诊');
const b = temperatureWindowHint('implant', t, AnchorMode.LAST_VISIT);
// 去掉主语后必须一模一样(天数/年数一个字都不能差)
expect(a.replace('医生最后一次提到', '')).toBe(b.replace('距上次到诊', ''));
}
}); });
}); });
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
'use client';
import { useRef, useState } from 'react';
import { env } from '@/lib/env';
import { useAuthStore } from '@/stores/auth-store';
import { useAssistantStore } from '@/stores/assistant-store';
/**
* 🔬 裸 agent 试验台 `/assistant-lab`(2026-08-14)。
*
* 看模型的**原生能力**:边写正文边调工具,把按钮和卡片摆在它写到的位置。
* ⛔ 这里没有任何补偿 —— 不重排、不补标题、不兜底、不改历史。
* 模型漏调 `place_*`,页面上就真的没有那个标记;那正是要观察的东西。
*
* ⚠️ 与产品助手**没有代码关联**:不 import 它的块模型、不复用它的排版。
* ⚠️ 历史是服务端每轮原样吐回来的 `messages`(含真的 tool-call / tool-result),
* 这里**原样存、原样回传** —— 前端一行重建逻辑都没有。
* ⚠️ 「执行过程」是**纯 debug 通道**(`step` 事件),⛔ 不进 messages、⛔ 不回传给模型。
*/
type Part =
| { k: 'text'; text: string }
/** 🔬 思考 —— 跟正文走同一条流,按到达顺序穿插;显示上区分,⛔ 不改变它的位置 */
| { k: 'reasoning'; text: string }
/**
* 🔬 工具调用 —— 也按到达顺序**穿插在正文里**,这样一眼能看出时序:
* 想 → 写 → 调 → 写 → 调…
* ⚠️ `show_sheet` / `show_guidance` **不出 chip**:它们已经有绿框了,
* 而且绿框(execute 里发的)比 step 事件(fullStream 里转的)先到,
* 两个都画会出现"框在自己的调用记录前面"的错觉。
*/
| { k: 'step'; id: string; tool: string; args?: unknown; result?: unknown; error?: string }
| { k: 'place'; what: 'sheet' | 'guidance'; index?: number; kind?: string; title?: string }
| { k: 'sheet'; summary: Record<string, number> };
interface Step {
id: string;
tool: string;
args?: unknown;
result?: unknown;
error?: string;
}
interface Turn {
ask: string;
parts: Part[];
steps: Step[];
limit?: number;
err?: string;
}
/** 服务端原样吐回来的 AI SDK 消息 —— ⛔ 前端不解读、不改写,只存和回传。 */
type ApiMessage = Record<string, unknown>;
/** 这两个已经有绿框代表位置了,⛔ 不再出 chip(见 Part 里 `step` 那段) */
const PLACE_TOOLS = new Set(['show_sheet', 'show_guidance']);
const PRESETS = [
'帮我给「拔牙 · 3 年以上」这批患者出一份分配方案',
'帮我给「种植 · 2−3 年」这批患者出一份分配方案',
'这批只圈消费高的那些,重新排一版',
];
export default function AssistantLabPage() {
const [turns, setTurns] = useState<Turn[]>([]);
const [input, setInput] = useState(PRESETS[0]!);
const [busy, setBusy] = useState(false);
const [model, setModel] = useState('deepseek');
/**
* 🔬 qwen 的思考开关(deepseek / gemini 本来就开着,勾不勾都一样)。
*
* 共用的 provider 里有个 fetch 中间件**无条件**给 qwen 塞 `enable_thinking=false`,
* 连 agent 这条带工具的链路也塞 —— 这几天拿 qwen 跟 DeepSeek 比工具调用,
* 其实是「便宜档 + 会思考」对「最高档 + 不许思考」。
*/
const [thinking, setThinking] = useState(true);
/** 全部历史(服务端原样给的)+ 我们插进去的 user 消息 */
const history = useRef<ApiMessage[]>([]);
const send = async (): Promise<void> => {
const ask = input.trim();
if (!ask || busy) return;
setBusy(true);
setInput('');
history.current = [...history.current, { role: 'user', content: ask }];
const at = turns.length;
setTurns((t) => [...t, { ask, parts: [], steps: [] }]);
const patch = (fn: (t: Turn) => Turn): void =>
setTurns((prev) => prev.map((t, i) => (i === at ? fn(t) : t)));
try {
const res = await fetch(new URL('/pac/v1/assistant/lab/chat', env.apiBaseUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
...(useAuthStore.getState().accessToken
? { Authorization: `Bearer ${useAuthStore.getState().accessToken}` }
: {}),
},
body: JSON.stringify({
messages: history.current,
model,
thinking,
...(useAssistantStore.getState().activeClinicId
? { activeClinicId: useAssistantStore.getState().activeClinicId }
: {}),
}),
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
let buf = '';
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += value;
let sep: number;
while ((sep = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, sep);
buf = buf.slice(sep + 2);
const line = frame.split('\n').find((l) => l.startsWith('data:'));
if (!line) continue;
const e = JSON.parse(line.slice(5).trim()) as Record<string, unknown>;
switch (e.type) {
case 'text':
case 'reasoning': {
// ⚠️ 追加到**最后一个同类** part;被别的东西打断就新起一段。
// 这就是"位置"的全部实现 —— ⛔ 不做任何重排。
const k = e.type === 'reasoning' ? ('reasoning' as const) : ('text' as const);
patch((t) => {
const last = t.parts[t.parts.length - 1];
if (last?.k === k) {
return {
...t,
parts: [...t.parts.slice(0, -1), { k, text: last.text + String(e.text) }],
};
}
return { ...t, parts: [...t.parts, { k, text: String(e.text) }] };
});
break;
}
case 'place':
patch((t) => ({
...t,
parts: [
...t.parts,
{
k: 'place',
what: e.what as 'sheet' | 'guidance',
...(typeof e.index === 'number' ? { index: e.index } : {}),
...(e.kind ? { kind: String(e.kind) } : {}),
...(e.title ? { title: String(e.title) } : {}),
},
],
}));
break;
case 'sheet_ready':
patch((t) => ({
...t,
parts: [...t.parts, { k: 'sheet', summary: e.summary as Record<string, number> }],
}));
break;
case 'step': {
const id = String(e.id);
const tool = String(e.tool);
patch((t) => {
// ① 计数用的扁平表(底部那行「N 步」)—— 所有工具都算,含 show_*
const i = t.steps.findIndex((s) => s.id === id);
const merged: Step = i === -1 ? { id, tool } : { ...t.steps[i]! };
if (e.phase === 'call') merged.args = e.args;
if (e.phase === 'result') merged.result = e.result;
if (e.phase === 'error') merged.error = String(e.error);
const steps =
i === -1 ? [...t.steps, merged] : t.steps.map((s, j) => (j === i ? merged : s));
// ② 穿插进正文的 chip —— ⚠️ show_* 跳过(它们已经有绿框,见 Part 的注释)
if (PLACE_TOOLS.has(tool)) return { ...t, steps };
const at = t.parts.findIndex((p) => p.k === 'step' && p.id === id);
if (at === -1) {
return { ...t, steps, parts: [...t.parts, { k: 'step', id, tool, args: e.args }] };
}
const parts = t.parts.map((p, j) =>
j === at && p.k === 'step'
? {
...p,
...(e.phase === 'result' ? { result: e.result } : {}),
...(e.phase === 'error' ? { error: String(e.error) } : {}),
}
: p,
);
return { ...t, steps, parts };
});
break;
}
case 'step_limit':
patch((t) => ({ ...t, limit: Number(e.limit) }));
break;
case 'messages':
// ⭐ 原样存下来,下一轮原样回传。⛔ 不解读、不裁剪、不排序。
history.current = [...history.current, ...(e.messages as ApiMessage[])];
break;
case 'error':
patch((t) => ({ ...t, err: String(e.error) }));
break;
default:
break;
}
}
}
} catch (err) {
patch((t) => ({ ...t, err: err instanceof Error ? err.message : String(err) }));
} finally {
setBusy(false);
}
};
return (
<div className="mx-auto min-h-screen max-w-3xl bg-white p-6 text-[14px] text-slate-800">
<header className="mb-4 flex items-center gap-3 border-b pb-3">
<h1 className="text-base font-semibold">🔬 裸 agent 试验台</h1>
<span className="text-xs text-slate-400">/assistant-lab · 无任何补偿</span>
<select
className="ml-auto rounded border px-2 py-1 text-xs"
value={model}
onChange={(ev) => setModel(ev.target.value)}
>
<option value="deepseek">deepseek(AI_DEFAULT_MODEL)</option>
<option value="deepseek-v4-pro">deepseek-v4-pro</option>
<option value="deepseek-v4-flash">deepseek-v4-flash</option>
<option value="qwen">qwen</option>
</select>
<label className="flex items-center gap-1 text-xs text-slate-500" title="只对 qwen 有效">
<input
type="checkbox"
checked={thinking}
onChange={(ev) => setThinking(ev.target.checked)}
/>
思考
</label>
<button
className="rounded border px-2 py-1 text-xs text-slate-500"
onClick={() => {
history.current = [];
setTurns([]);
}}
>
清空会话
</button>
</header>
{turns.map((t, i) => (
<section key={i} className="mb-8">
<div className="mb-3 text-right">
<span className="inline-block rounded-lg bg-slate-800 px-3 py-1.5 text-[13px] text-white">
{t.ask}
</span>
</div>
{/* 正文与标记 —— **按到达顺序**,⛔ 不重排 */}
{t.parts.map((p, j) =>
p.k === 'text' ? (
<p key={j} className="whitespace-pre-wrap leading-relaxed">
{p.text}
</p>
) : p.k === 'reasoning' ? (
// 🔬 思考:默认折叠成一行,展开看全文。位置**就是它到达的位置**,⛔ 不挪。
<details key={j} className="my-2 border-l-2 border-violet-300 bg-violet-50/40 pl-3">
<summary className="cursor-pointer py-1 text-xs text-violet-500">
思考 · {p.text.length} 字
</summary>
<p className="whitespace-pre-wrap py-1 text-[12px] leading-relaxed text-violet-900/70">
{p.text}
</p>
</details>
) : p.k === 'step' ? (
// 🔬 工具调用 chip —— 收起时一行,展开看入参和返回值
<details key={j} className="my-1.5 rounded bg-slate-50 px-2 py-1">
<summary className="cursor-pointer font-mono text-[11px] text-slate-500">
{p.tool}
{p.error && <span className="ml-2 text-red-600">✗ {p.error}</span>}
{!p.error && p.result === undefined && <span className="ml-2 text-slate-400">…</span>}
</summary>
<pre className="mt-1 overflow-x-auto whitespace-pre-wrap break-all text-[11px] text-slate-500">
{JSON.stringify(p.args ?? {})}
</pre>
{p.result != null && (
<pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap break-all text-[11px] text-slate-400">
{typeof p.result === 'string' ? p.result : JSON.stringify(p.result, null, 2)}
</pre>
)}
</details>
) : p.k === 'sheet' ? (
<div key={j} className="my-2 rounded border border-dashed border-slate-300 px-3 py-2 text-xs text-slate-500">
确认单已生成({Object.entries(p.summary).map(([k, v]) => `${k} ${v}`).join(' · ')})
<span className="ml-2 text-slate-400">— 还没摆位置</span>
</div>
) : (
<div
key={j}
className="my-2 rounded border-2 border-emerald-400 bg-emerald-50 px-3 py-1.5 text-xs text-emerald-800"
>
{p.what === 'sheet'
? '【确认单卡片摆在这里】'
: `【「${p.kind ?? p.index}」的按钮摆在这里】${p.title ? ` ${p.title}` : ''}`}
</div>
),
)}
{t.limit != null && (
<p className="my-2 text-xs text-amber-700">⚠️ 工具步数到顶({t.limit}),本轮被截断</p>
)}
{t.err && <p className="my-2 text-xs text-red-600">✗ {t.err}</p>}
{/**
* 执行过程 —— 现在只剩**一行计数**:明细已经按时序穿插在正文里了(chip / 绿框)。
* ⛔ 别把明细搬回来:两份一样的东西,改一处漏一处。
* ⚠️ 计数含 `show_*`(它们在正文里以绿框出现,不出 chip),所以这个数会比 chip 多。
*/}
{t.steps.length > 0 && (
<p className="mt-3 text-xs text-slate-400">
执行过程 · {t.steps.length} 步({t.steps.map((s) => s.tool).join(' → ')})
</p>
)}
</section>
))}
<div className="sticky bottom-0 bg-white pt-2">
<div className="mb-2 flex flex-wrap gap-1.5">
{PRESETS.map((p) => (
<button
key={p}
className="rounded border px-2 py-1 text-[11px] text-slate-500"
onClick={() => setInput(p)}
>
{p}
</button>
))}
</div>
<div className="flex gap-2">
<input
className="flex-1 rounded border px-3 py-2 text-[13px]"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) void send();
}}
placeholder="帮我给「拔牙 · 3 年以上」这批患者出一份分配方案"
/>
<button
className="rounded bg-slate-800 px-4 py-2 text-[13px] text-white disabled:opacity-40"
disabled={busy}
onClick={() => void send()}
>
{busy ? '…' : '发送'}
</button>
</div>
</div>
</div>
);
}
'use client'; 'use client';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, ChevronRight, GripVertical, Loader2, Undo2, Users, X } from 'lucide-react'; import { Check, ChevronDown, ChevronRight, GripVertical, Loader2, Undo2, Users, X } from 'lucide-react';
import { import {
ASSIGNMENT_EXPIRES_DAYS_PRESETS, ASSIGNMENT_EXPIRES_DAYS_PRESETS,
...@@ -8,15 +8,19 @@ import { ...@@ -8,15 +8,19 @@ import {
AssignStrategy, AssignStrategy,
REVOKE_WINDOW_MINUTES, REVOKE_WINDOW_MINUTES,
Permission, Permission,
sheetSnapshotLine,
type AgentOverride, type AgentOverride,
type AssignmentProposal, type AssignmentProposal,
type ProposedItem, type ProposedItem,
type RevokeAssignmentResponse, type RevokeAssignmentResponse,
type SheetEditOp, type SheetEditOp,
type SheetSnapshot,
type Signal,
} 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';
import { useAssignmentSyncStore } from '@/stores/assignment-sync-store'; import { useAssignmentSyncStore } from '@/stores/assignment-sync-store';
import { useAssistantStore } from '@/stores/assistant-store';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ConfirmSheetSignals } from './confirm-sheet-signals'; import { ConfirmSheetSignals } from './confirm-sheet-signals';
...@@ -128,7 +132,7 @@ function CopyableName({ name }: { name: string }) { ...@@ -128,7 +132,7 @@ function CopyableName({ name }: { name: string }) {
/** /**
* 服务端成品句子里的 `**加粗**` —— 只认这一个标记,⛔ 不引 Markdown 渲染器。 * 服务端成品句子里的 `**加粗**` —— 只认这一个标记,⛔ 不引 Markdown 渲染器。
* *
* ⚠️ 这些句子是**服务端拼好的**(basisNote / refillNote / opsNote),里面混着患者和客服姓名。 * ⚠️ 这些句子是**服务端拼好的**(basisNote / refillNote),里面混着患者和客服姓名。
* 上一个完整的 Markdown 渲染器进来,姓名里的 `*` `_` `#` 就会被当成语法吃掉 —— * 上一个完整的 Markdown 渲染器进来,姓名里的 `*` `_` `#` 就会被当成语法吃掉 ——
* 而那是主管要照着念的名字。加粗是唯一需要的表达,自己切最省事也最安全。 * 而那是主管要照着念的名字。加粗是唯一需要的表达,自己切最省事也最安全。
*/ */
...@@ -241,22 +245,40 @@ export function AssignmentConfirmSheet({ ...@@ -241,22 +245,40 @@ export function AssignmentConfirmSheet({
onEditApplied, onEditApplied,
onSheetReplaced, onSheetReplaced,
onIntent, onIntent,
placedKeys,
onCompose,
streaming,
onLiveSignalsChange,
busyIntent,
}: { }: {
requestId: string; requestId: string;
sheet: AssignmentProposal; sheet: AssignmentProposal;
/** 草稿状态机,见 use-assistant-chat 的 Block 注释。`superseded` = 已被新版顶掉 */ /** 草稿状态机,见 use-assistant-chat 的 Block 注释。`superseded` = 已被新版顶掉 */
state: 'active' | 'superseded' | 'confirmed' | 'cancelled'; state: 'active' | 'superseded' | 'confirmed' | 'cancelled';
assignmentId?: string; assignmentId?: string;
/** 落库成功 → 交给外层把卡片切终态 + 往消息流注入一条文本(补模型记忆) */ /**
/** `modelSummary` = 同一句话的**喂模型版**(带完整批次 id,界面不显示) */ * 落库成功 → 交给外层把卡片切终态 + 往消息流注入一条文本(补模型记忆)。
onConfirmed: (assignmentId: string, summary: string, modelSummary?: string) => void; * @param summary 界面那份(主管看的一句事实)
* @param modelFacts 喂模型那份 —— **结构化事实**,带完整批次 id,界面不显示
*
* 🔴 曾有第四个参数 `postConfirm`(确认后由卡片摆的引导节点,唯一的用户是
* 「这批还没带福利」)—— **删于 2026-08-15**:确认单里不做福利引导。
* ⛔ 别加回来:确认之后要说什么归模型(提示词 ④),卡片只管这一版本身。
*/
onConfirmed: (assignmentId: string, summary: string, modelFacts?: string) => void;
/** /**
* 助手下发的语义编辑指令队列(edit_assignment_sheet)。 * 助手下发的语义编辑指令队列(edit_assignment_sheet)。
* ⚠️ 由**本组件**去匹配姓名 —— 模型手里没有 planId,那是故意的。 * ⚠️ 由**本组件**去匹配姓名 —— 模型手里没有 planId,那是故意的。
*/ */
edits?: { seq: number; ops: SheetEditOp[] }[]; edits?: { seq: number; ops: SheetEditOp[] }[];
/** 执行结果回一句进对话(既给主管看,也补进模型上下文 —— 否则它不知道到底成没成) */ /** 执行结果回一句进对话(既给主管看,也补进模型上下文 —— 否则它不知道到底成没成) */
onEditApplied?: (summary: string) => void; /**
* 改单结果进对话。
* ⚠️ 两个参数**不一样**:`summary` 是主管看的(改了什么 —— 增量才是新闻),
* `modelText` 另外缀一行**当前值**给模型(它看不见卡片,只有这一行)。
* ⛔ 别把当前值也显示出来:那几个数就在他眼前的卡片上,写两遍是读两遍。
*/
onEditApplied?: (summary: string, modelText?: string) => void;
/** /**
* 「重新排一版」返回的新提案 —— 由外层把消息流里那个 block 换掉。 * 「重新排一版」返回的新提案 —— 由外层把消息流里那个 block 换掉。
* ⚠️ 换的是**整张卡**:人群没变、人换了,主管在旧卡上做的删/改派对新名单没有意义。 * ⚠️ 换的是**整张卡**:人群没变、人换了,主管在旧卡上做的删/改派对新名单没有意义。
...@@ -270,11 +292,40 @@ export function AssignmentConfirmSheet({ ...@@ -270,11 +292,40 @@ export function AssignmentConfirmSheet({
* ⛔ 别在这里为每个 intent 各写一套执行 —— 那就等于两条路各做各的,必然漂。 * ⛔ 别在这里为每个 intent 各写一套执行 —— 那就等于两条路各做各的,必然漂。
*/ */
onIntent?: (intent: string, args?: Record<string, unknown>) => void; onIntent?: (intent: string, args?: Record<string, unknown>) => void;
/**
* 已经被模型插进正文的引导节点(`show_guidance`)—— 卡片上不再重复显示。
*
* ⭐ 反过来说:**模型没插的就留在卡片上**。所以卡片天然是兜底面 ——
* 模型漏调工具,主管照样看得见那几条,⛔ 不会静默消失。
*/
placedKeys?: string[];
/** 兜底 —— 把焦点落到底部输入框(传空串 = 不预填,见操作说明那一行) */
onCompose?: (draft: string) => void;
/** 这一轮还在写 —— 卡内引导节点兜底要等说完再显示(见 settledSignals) */
streaming?: boolean;
/** 上报「主语还在」的节点 key —— 正文里那份据此决定还渲不渲染(见 liveSignals) */
onLiveSignalsChange?: (requestId: string, keys: string[]) => void;
/**
* 正在执行的 intent(转圈 + 整排禁用)。
* ⚠️ 由**上层**持有:会花时间的动作(重排 / 让模型重出一版)都不在卡片里执行了,
* ⛔ 卡片自己留一份 state 只会永远是 null。
*/
busyIntent?: string | null;
}) { }) {
/** /**
* 批次默认时效 —— 初值来自**提案**(沿用主管上一次的值),⛔ 不是写死的 3。 * 批次默认时效 —— 初值来自**提案**(沿用主管上一次的值),⛔ 不是写死的 3。
* 改它 = 把所有**没有单独设过**的条目一起改;单独设过的不动(下面 dayByPlan 优先)。 * 改它 = 把所有**没有单独设过**的条目一起改;单独设过的不动(下面 dayByPlan 优先)。
*/ */
/**
* 整张单**默认收起**(2026-08-13 产品定)—— 展开只露汇总行 + 操作说明两行。
*
* 🔴 客服清单动辄几十行、待分配上百人,一张单能顶掉助手窗好几屏;
* 而主管多数时候只看两个数(排好几个、卡住几个)就点确认了。
* ⚠️ 收起的**只有名单**。汇总行、操作说明、引导节点、确认按钮都常显 ——
* ⛔ 引导节点藏不得(那是"有人会被漏掉"的唯一提示),
* ⛔ 确认按钮更不能藏(主操作躲在展开一步后面)。
*/
const [bodyOpen, setBodyOpen] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(sheet.expiresInDays); const [expiresInDays, setExpiresInDays] = useState(sheet.expiresInDays);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
...@@ -311,7 +362,6 @@ export function AssignmentConfirmSheet({ ...@@ -311,7 +362,6 @@ export function AssignmentConfirmSheet({
/// 「待分配」是否展开。⭐ **默认展开** —— 它的存在就是为了引起注意,默认藏起来等于白做 /// 「待分配」是否展开。⭐ **默认展开** —— 它的存在就是为了引起注意,默认藏起来等于白做
const [pendingOpen, setPendingOpen] = useState(true); const [pendingOpen, setPendingOpen] = useState(true);
/// 「重新排一版」进行中(按钮转圈,防连点) /// 「重新排一版」进行中(按钮转圈,防连点)
const [refilling, setRefilling] = useState(false);
const [confirmedAt, setConfirmedAt] = useState<number | null>(null); const [confirmedAt, setConfirmedAt] = useState<number | null>(null);
const [revoked, setRevoked] = useState<RevokeAssignmentResponse | null>(null); const [revoked, setRevoked] = useState<RevokeAssignmentResponse | null>(null);
const [revoking, setRevoking] = useState(false); const [revoking, setRevoking] = useState(false);
...@@ -339,7 +389,7 @@ export function AssignmentConfirmSheet({ ...@@ -339,7 +389,7 @@ export function AssignmentConfirmSheet({
useAssignmentSyncStore.getState().notifyChanged(); useAssignmentSyncStore.getState().notifyChanged();
// ⭐ 结果**原话**进对话:note 是服务端拼的成品句子(含"已看过的没收回"那半句), // ⭐ 结果**原话**进对话:note 是服务端拼的成品句子(含"已看过的没收回"那半句),
// 既给主管看,也补进模型上下文 —— 否则它下一轮会说"整批都撤回了" // 既给主管看,也补进模型上下文 —— 否则它下一轮会说"整批都撤回了"
onEditApplied?.(res.note); echo(res.note);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : '撤销失败'); setError(e instanceof Error ? e.message : '撤销失败');
setConfirmRevoke(false); setConfirmRevoke(false);
...@@ -492,6 +542,96 @@ export function AssignmentConfirmSheet({ ...@@ -492,6 +542,96 @@ export function AssignmentConfirmSheet({
[sheet.skippedAgents, groups], [sheet.skippedAgents, groups],
); );
/**
* 🔴 **这张单此刻的样子** —— 只算这一处,两个出口共用:
* ① 挂到 store,跟着每次 chat 请求发走 → 模型用 `get_current_sheet` 取;
* ② 每次回声把它压成一行缀进 `modelText`(见下面的 `echo`)。
*
* ⚠️ 存在的理由:确认之前这些数**只在这个组件里**,服务端查不到
* (沿革见 `SheetSnapshotSchema`)。此前模型只能把对话里几行「已更新…」自己累加,
* 而**手动拖动、点 ×、改时效这几条路连回声都没有** —— 它连累加的材料都没有,
* 于是一边说「时效 1 天」,卡片上写着 3 天,两边都不报错。
*/
const snapshot = useMemo<SheetSnapshot>(
() => ({
requestId,
confirmed: assignmentId != null,
placed: items.length,
agents: groups.length,
pending: pendingList.length,
dropped: dropped.size,
moved: items.filter((i) => i.moved).length,
expiresInDays,
rowExpiryOverrides: Object.keys(dayByPlan).length,
benefit: benefit.trim() || null,
byAgent: groups.map((g) => ({
name: g.name ?? g.userId.slice(0, 8),
count: g.list.length,
})),
}),
[
requestId,
assignmentId,
items,
groups,
pendingList.length,
dropped.size,
expiresInDays,
dayByPlan,
benefit,
],
);
/**
* 挂到 store 供发请求时取。
* ⚠️ 卸载时**只清自己那份**(按 requestId 比对)—— 重出一版时新卡片先挂、
* 旧卡片后卸载,不比对就会把现役那张一起清掉,而清掉之后模型只是"查不到",
* ⛔ 不会报错。
*/
useEffect(() => {
useAssistantStore.getState().setSheetSnapshot(snapshot);
return () => {
const s = useAssistantStore.getState();
if (s.sheetSnapshot?.requestId === snapshot.requestId) s.setSheetSnapshot(null);
};
}, [snapshot]);
/**
* 回声 —— **主管看增量,模型另外拿当前值**。
*
* ⚠️ 为什么要排队到 effect 里发:`setDropped` / `setMoveByPlan` 是 React state,
* 在同一轮里读不到刚写的值(这个文件下面那条注释为同一件事栽过一次)。
* 就地发的话,缀上去的「当前」是**上一帧**的数 —— 比不发更坏。
* ⚠️ 用 seq 触发而不是只看 snapshot:有些动作不改变任何数字
* (拖回原来那个人),那时 snapshot 不变,排的队会永远卡着。
*/
/// planId → 主管认得的那个名字。⛔ 回声里别报 planId:那是内部码,他看不懂、模型会念出来
const zhPatient = useCallback(
(planId: string) => {
const hit =
sheet.items.find((i) => i.planId === planId) ??
(sheet.pending ?? []).find((p) => p.planId === planId);
return hit?.patientName ?? `#${planId.slice(0, 8)}`;
},
[sheet.items, sheet.pending],
);
const echoQueue = useRef<string[]>([]);
const [echoSeq, setEchoSeq] = useState(0);
const snapRef = useRef(snapshot);
snapRef.current = snapshot;
const echo = useCallback((summary: string) => {
echoQueue.current.push(summary);
setEchoSeq((n) => n + 1);
}, []);
useEffect(() => {
if (echoSeq === 0 || echoQueue.current.length === 0) return;
const text = echoQueue.current.join(';');
echoQueue.current = [];
// ⛔ 当前值只进 modelText:这几个数卡片上就在主管眼前,界面再写一行是读两遍
onEditApplied?.(text, `${text}\n${sheetSnapshotLine(snapRef.current)}`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [echoSeq]);
const confirm = async () => { const confirm = async () => {
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
...@@ -508,9 +648,6 @@ export function AssignmentConfirmSheet({ ...@@ -508,9 +648,6 @@ export function AssignmentConfirmSheet({
// 完全不同的人。只存治疗项的话,事后既说不清这批是怎么圈的, // 完全不同的人。只存治疗项的话,事后既说不清这批是怎么圈的,
// 也没法反推"哪种格子分下去效果好"(实测漏了很久)。 // 也没法反推"哪种格子分下去效果好"(实测漏了很久)。
temperature: sheet.temperature, temperature: sheet.temperature,
// 🔴 口径与温度同等重要:同一个「三个月内」按诊断/按末诊是两批完全不同的人。
// 不存这一个键,批次名说不清、事后也没法反推"哪一版分下去效果好"。
anchorMode: sheet.anchorMode,
candidateTotal: sheet.candidateTotal, candidateTotal: sheet.candidateTotal,
target: sheet.target, target: sheet.target,
selectionNote: sheet.selectionNote, selectionNote: sheet.selectionNote,
...@@ -566,35 +703,38 @@ export function AssignmentConfirmSheet({ ...@@ -566,35 +703,38 @@ export function AssignmentConfirmSheet({
// 两条路测一条就漏了(实测 2026-08-03)。 // 两条路测一条就漏了(实测 2026-08-03)。
// ⚠️ 界面这份**不显示 id**:uuid 对主管没有意义,短号则会诱导他把短号念给助手。 // ⚠️ 界面这份**不显示 id**:uuid 对主管没有意义,短号则会诱导他把短号念给助手。
/** /**
* ⭐ 没配福利 → 就地提醒一句"还能补挂"(2026-08-06 产品加的)。 * 🔴 **确认之后的福利提醒不再由卡片摆**(2026-08-15 产品定)。
* *
* 为什么是这一刻:福利只影响**此后生成**的话术,而客服正是从现在开始陆续打开这批单 * 这里原来会造一个 `benefit_missing` 引导节点(`stage: 'post_confirm'`),
* —— 再往后每过一会儿,能用上福利的人就少一个。确认这一下是最后一个自然时机。 * 由 `settleSheet` 合进这张单的 signals —— 于是主管点完确认,卡片里
* * **长出一块黄色的引导**。产品判定:确认单里不做福利引导,确认后正常输出就够。
* 🔴 **必须写进界面那份(可见文案),⛔ 不能只写进模型那份。** * ⚠️ 提醒本身没取消,只是换了出口 —— 现在归**模型**说
* `appendAssistantNote` 只是往消息流里塞一段文字,**不会触发模型新一轮发言** —— * (提示词 ④「确认之后」那一节;`本批福利` 这个事实一直在下面那份 modelFacts 里)。
* 只给模型看,等于主管永远看不到这句提醒(实测:确认完对话里一个字都没多)。 * ⛔ 别把这段"改成拼一句话塞进对话":那是更早被否掉的做法
* 同时也给模型那份留一句状态,他回头问「这批带福利没」时才答得上来。 * (前端替助手开口,署的是它的名而它根本没说过)。
* * ⚠️ 时机仍然要紧:福利只影响**此后生成**的话术,客服从这一刻开始陆续打开这批单,
* ⛔⛔ **不许写"带福利成功率更高 / 转化率提升"** —— 本系统**不统计成功** * 每过一会儿能用上它的人就少一个 —— 所以③已经在**确认之前**先问过一次。
* (助手硬约束第 4 条),那个结论编不出来。只讲事实:福利会进话术、当开口的由头。
* ⚠️ 措辞要短:主管刚点完确认,这里是顺带一提,不是推销。
*/ */
const benefitNudge = benefit.trim()
? ''
// ⚠️ 必须是**两个换行**:这段走 ReactMarkdown 渲染,单个 \n 会被折叠掉,
// 结果是「…3 天有效 这批还没带福利 ——」挤成一行,读起来像上一句的尾巴(实测)。
: '\n\n这批还没带福利 —— 现在说一句「这批带上『老客户复查免挂号费』」我就写进去,' +
'客服生成话术时会拿它当开口的由头;不需要就不用管。';
onConfirmed( onConfirmed(
res.assignmentId, res.assignmentId,
summary + benefitNudge, summary,
`${summary}(批次 id ${res.assignmentId} —— 主管要撤销这批时,把这个完整 id 传给 revoke_assignment)` + /**
(benefitNudge * 喂模型那份 —— **只有事实**,⛔ 一个字的指令都不许有。
? benefitNudge + * 🔴 批次 id 必须是**完整 uuid**:它要撤销这批只能靠这里拿 id
'\n⚠️ 上面这句提醒**界面已经对主管说过了**,⛔ 你不要再重复一遍;' + * (此前写成 8 位短号,模型一调 revoke_assignment 就报「批次不存在」,
'他说「带上…」时你调 set_benefit,说不用就别再提。' * 而卡片上的撤销按钮握着完整 id 一直是好的 —— 两条路测一条就漏了)。
: ''), */
JSON.stringify({
已确认分配: {
批次id: res.assignmentId,
落了几条: res.assigned,
几位客服: groups.length,
时效天数: expiresInDays,
...(dropped.size ? { 主管移除: dropped.size } : {}),
...(res.skipped.length ? { 没落上: res.skipped.length } : {}),
本批福利: benefit.trim() || null,
},
}),
); );
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : '确认失败'); setError(e instanceof Error ? e.message : '确认失败');
...@@ -652,10 +792,10 @@ export function AssignmentConfirmSheet({ ...@@ -652,10 +792,10 @@ export function AssignmentConfirmSheet({
setBenefit(r.benefitText ?? ''); setBenefit(r.benefitText ?? '');
// ⭐ 回报**服务端那句**,不是前端自己编的 —— 里面有"作废了几条话术缓存" // ⭐ 回报**服务端那句**,不是前端自己编的 —— 里面有"作废了几条话术缓存"
// 和"几条客服已经打开过",这两个数只有服务端知道(T14)。 // 和"几条客服已经打开过",这两个数只有服务端知道(T14)。
onEditApplied?.(`确认单已更新:${r.note}`); echo(`确认单已更新:${r.note}`);
}) })
.catch((e) => .catch((e) =>
onEditApplied?.( echo(
`福利没改成:${e instanceof Error ? e.message : '接口报错'} —— 请如实告诉主管,⛔ 不要说已经改好了。`, `福利没改成:${e instanceof Error ? e.message : '接口报错'} —— 请如实告诉主管,⛔ 不要说已经改好了。`,
), ),
); );
...@@ -667,7 +807,7 @@ export function AssignmentConfirmSheet({ ...@@ -667,7 +807,7 @@ export function AssignmentConfirmSheet({
); );
} }
} }
if (notes.length) onEditApplied?.(`确认单没有改动:${notes.join(';')}。`); if (notes.length) echo(`确认单没有改动:${notes.join(';')}。`);
return; return;
} }
/** /**
...@@ -739,6 +879,45 @@ export function AssignmentConfirmSheet({ ...@@ -739,6 +879,45 @@ export function AssignmentConfirmSheet({
const names: string[] = []; const names: string[] = [];
for (const q of op.select.patients) { for (const q of op.select.patients) {
const hit = findPatients(q); const hit = findPatients(q);
/**
* 🔴 **同名歧义 —— 与 `group:'agent'` 那边对称**(2026-08-13 实测补的)。
*
* 主管说「删除闫保玉」,模型按规矩默认选了 `patients`(那条规矩是对的:
* 多数情况下他说的就是患者)。而闫保玉**既是本批客服、又是本批患者** ——
* 卡片二话不说移除了那个同名患者并回报"已移除 1 人",
* 而主管盯着的那一行客服(14 条)纹丝不动。他看到的是"系统说成了,其实没成"。
* ⛔ 这种时候不许猜:两边动的东西差 14 倍,而回报还写着成功。
*/
const alsoAgent = findAgent(q);
if (alsoAgent && hit.length > 0) {
notes.push(
`「${q}」既是本批客服、又是本批患者,没有执行 —— ` +
`请说清是「患者 ${q}」还是「客服 ${q} 名下的」`,
);
continue;
}
/**
* ⭐ **只匹配到客服 → 就按客服办**(2026-08-13 第二版)。
*
* 上一版这里是"拒绝执行 + 告诉他正确说法",实测被主管当成 bug:
* 他说「移除逯梓茜」,界面回一句"请换个说法",人还在名单上 —— 「没有移除啊」。
* ⚠️ 这里**没有歧义可言**:本批一个叫这名的患者都没有,唯一的读法就是客服。
* 拒绝执行不是谨慎,是把一个只有一种解释的指令推回去。
* ⛔ 但**必须说清替他理解成了什么、动了几条** —— 客服那一行是 15 条,
* 而他嘴上说的是一个人名;不点破就是"我以为只动了 1 个"。
* ⛔ 歧义(既是客服又是患者)那条仍然不许猜,见上面一段。
*/
if (alsoAgent) {
const live = alsoAgent.list.filter((it) => !dropDraft.has(it.planId));
if (live.length === 0) {
notes.push(`「${q}」是本批客服,但他本批一条都没分到,没什么可动的`);
continue;
}
notes.push(`「${q}」是本批客服(没有同名患者),按客服处理`);
planIds.push(...live.map((it) => it.planId));
names.push(`客服 ${alsoAgent.name ?? q} 名下 ${live.length} 条`);
continue;
}
if (hit.length === 0) { if (hit.length === 0) {
notes.push(`确认单里没有「${q}」,跳过`); notes.push(`确认单里没有「${q}」,跳过`);
continue; continue;
...@@ -767,7 +946,8 @@ export function AssignmentConfirmSheet({ ...@@ -767,7 +946,8 @@ export function AssignmentConfirmSheet({
*/ */
if (findPatients(name).length > 0) { if (findPatients(name).length > 0) {
notes.push( notes.push(
`「${name}」既是本批客服、又是本批患者,没有执行 —— 请说清是「客服 ${name}」还是患者`, `「${name}」既是本批客服、又是本批患者,没有执行 —— ` +
`请说清是「患者 ${name}」还是「客服 ${name} 名下的」`,
); );
return null; return null;
} }
...@@ -953,7 +1133,7 @@ export function AssignmentConfirmSheet({ ...@@ -953,7 +1133,7 @@ export function AssignmentConfirmSheet({
// effect 排期期间主管手动拖出来的改动。 // effect 排期期间主管手动拖出来的改动。
setMoveByPlan((m) => ({ ...m, ...moveDraft })); setMoveByPlan((m) => ({ ...m, ...moveDraft }));
setDropped((sset) => new Set([...sset, ...dropDraft])); setDropped((sset) => new Set([...sset, ...dropDraft]));
if (notes.length) onEditApplied?.(`确认单已更新:${notes.join(';')}。`); if (notes.length) echo(`确认单已更新:${notes.join(';')}。`);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [edits]); }, [edits]);
...@@ -962,32 +1142,15 @@ export function AssignmentConfirmSheet({ ...@@ -962,32 +1142,15 @@ export function AssignmentConfirmSheet({
* 引导节点的选项路由。 * 引导节点的选项路由。
* ⚠️ 只截下卡片**本来就有实现**的两个,其余一律上抛 —— 见 `onIntent` 的注释。 * ⚠️ 只截下卡片**本来就有实现**的两个,其余一律上抛 —— 见 `onIntent` 的注释。
*/ */
const [busyIntent, setBusyIntent] = useState<string | null>(null);
const handleIntent = async (intent: string, args?: Record<string, unknown>) => { const handleIntent = async (intent: string, args?: Record<string, unknown>) => {
if (intent === 'expiry.set' && typeof args?.days === 'number') { if (intent === 'expiry.set' && typeof args?.days === 'number') {
setExpiresInDays(args.days); setExpiresInDays(args.days);
echo(`确认单已更新:整批时效改成 ${args.days} 天。`);
return; return;
} }
if (intent === 'pending.refill') { // ⚠️ `pending.refill` 曾经在这里实现过 —— 待分配的按钮搬到文案下面之后
setBusyIntent(intent); // (见 signal-prompts / SIGNAL_IN_TEXT),它改由 use-assistant-chat 处理。
try { // ⛔ 别在这里补回来:同一个 intent 两份实现必然漂。
// ⚠️ 条件原样带回 —— 少带一个,重排就换了人群
const next = await assignmentsApi.refill({
clinicId: sheet.clinicId,
potentialTreatment: sheet.potentialTreatment,
temperature: sheet.temperature,
anchorMode: sheet.anchorMode,
targetCount: sheet.batchSize,
expiresInDays,
});
onSheetReplaced?.(next);
} catch (e) {
setError(e instanceof Error ? e.message : '重新排版失败');
} finally {
setBusyIntent(null);
}
return;
}
onIntent?.(intent, args); onIntent?.(intent, args);
}; };
...@@ -995,19 +1158,70 @@ export function AssignmentConfirmSheet({ ...@@ -995,19 +1158,70 @@ export function AssignmentConfirmSheet({
* 主语还在的引导节点。 * 主语还在的引导节点。
* ⚠️ 判据只用**卡片本地就能看见**的事实,⛔ 不重算服务端的判定: * ⚠️ 判据只用**卡片本地就能看见**的事实,⛔ 不重算服务端的判定:
* · `pending` —— 待分配被处置光了,这条就没有主语了 * · `pending` —— 待分配被处置光了,这条就没有主语了
* · `expiry_default` —— 主管改过时效,"用的是默认值"这句话就不成立了 * · `batch_size_basis` —— 主管改过时效,"本批 N 人 = 在岗 × 每天几通 × D 天"里的 D 就变了
* 其余(候选不足 / 工作量 / 画像亮点)本地改单不影响,或需重跑才知道 → 原样留着。 * 其余(候选不足 / 工作量 / 画像亮点)本地改单不影响,或需重跑才知道 → 原样留着。
*/ */
const liveSignals = useMemo( const liveSignals = useMemo(
() => () =>
(sheet.signals ?? []).filter((s) => { (sheet.signals ?? []).filter((s) => {
if (s.key === 'pending') return pendingList.length > 0; if (s.key === 'pending') return pendingList.length > 0;
if (s.key === 'expiry_default') return expiresInDays === sheet.expiresInDays; if (s.key === 'batch_size_basis') return expiresInDays === sheet.expiresInDays;
// 🔴 这里曾有一条 `benefit_missing`(确认后卡片自己摆的福利提醒)——
// 随那条节点一起删于 2026-08-15。⛔ 别加回来:确认单里不做福利引导。
return true; return true;
}), }),
[sheet.signals, sheet.expiresInDays, pendingList.length, expiresInDays], [sheet.signals, sheet.expiresInDays, pendingList.length, expiresInDays],
); );
/**
* 卡片上要显示的那几条 = 还没被模型插进正文的。
* ⚠️ 判据就是 `placedKeys`,⛔ 别在这里另立一份"哪些该搬走"的名单 ——
* 位置归模型决定(show_guidance),卡片只负责收尾。
*/
const cardSignals = useMemo(
() => liveSignals.filter((s) => !(placedKeys ?? []).includes(s.key)),
[liveSignals, placedKeys],
);
/**
* 🔴 **这一轮还在写的时候,卡内兜底一个都不渲染**(2026-08-13 产品实测)。
*
* 卡片是在 `propose_assignment` 那一刻就推来的,而模型要**再跑好几步**才把
* 节点一个个 `show_guidance` 到正文里。这中间 `placedKeys` 还是空的 ——
* 于是四五条节点连同按钮先在卡片里整块闪出来,等模型放完又一条条消失。
* 主管看到的是一坨乱跳的东西。
*
* ⚠️ ⛔ 不能因此把卡内兜底删掉:模型漏调 `show_guidance` 时,卡片是这些节点
* **唯一**还看得见的地方(藏了就是"有人会被漏掉"也一起藏了)。
* ⇒ 只推迟到这一轮说完:说完还没被放进正文的,才在卡片上显示。
*/
const settledSignals = streaming ? [] : cardSignals;
/**
* 每位客服「本批之前在手 / 本批又拿了几条」—— 待分配那一组要用它解释"为什么排满了"。
* ⚠️ 从 `groups` 现算,⛔ 不用 `p.ownerInHand`:后者是服务端快照(本批之前的量),
* 主管在卡片上拖走几个人之后它不会变,而"排满"这件事恰恰会变。
*/
const ownerLoad = useMemo(
() => new Map(groups.map((g) => [g.userId, { before: g.inHandBefore, batch: g.list.length }])),
[groups],
);
/**
* 🔴 **把"还活着的节点"报给上层** —— 搬到正文里的那份引导节点要用同一个判据。
*
* 由来(2026-08-13 实测):点「各自归专属客服」后待分配清零、卡片头也更新了,
* 而正文里那四个按钮**还在**,再点一次是空操作 —— 正文那份读的是服务端快照
* (`sheet.signals`),本地改单它不会知道。
* ⛔ 别在上层另写一份"哪些还活着"的判定:判据只有卡片看得见(`pendingList` 是
* 服务端 pending 减去本地 edits 的结果),复制过去必然漂。
* ⚠️ **fail-open**:上层拿不到这份上报时按"都还在"渲染 —— 宁可多显示一次,
* ⛔ 也不能把「还有人没发出去」静默藏掉。
*/
const liveKeys = liveSignals.map((s) => s.key).join(',');
useEffect(() => {
onLiveSignalsChange?.(requestId, liveKeys ? liveKeys.split(',') : []);
}, [requestId, liveKeys, onLiveSignalsChange]);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
let el = rootRef.current?.parentElement ?? null; let el = rootRef.current?.parentElement ?? null;
...@@ -1027,8 +1241,10 @@ export function AssignmentConfirmSheet({ ...@@ -1027,8 +1241,10 @@ export function AssignmentConfirmSheet({
onDrop={stopAutoScroll} onDrop={stopAutoScroll}
className={ className={
superseded superseded
? // ⚠️ 作废态**只压低存在感,不隐藏**:主色边框换成中性、内容压到 60% 不透明度。 ? // ⚠️ 作废态压低存在感:主色边框换成中性、内容压到 60% 不透明度。
// ⛔ 不用 `hidden`、⛔ 不折叠 —— 他要能看见自己出过几版。 // 🔴 2026-08-13 起**整条消息通常已经不渲染了**(见 chat-blocks.isDiscardedDraft) ——
// 这里只剩一种情况会走到:同一轮出了两版,旧的与新的在**同一条消息**里,
// 那时这张仍要显示(带下面的作废横幅),否则主管看不出上面那张为什么不能点。
'overflow-hidden border-slate-200 text-[12px] opacity-60 shadow-none' 'overflow-hidden border-slate-200 text-[12px] opacity-60 shadow-none'
: 'overflow-hidden border-brand-100 text-[12px] shadow-sm' : 'overflow-hidden border-brand-100 text-[12px] shadow-sm'
} }
...@@ -1055,9 +1271,12 @@ export function AssignmentConfirmSheet({ ...@@ -1055,9 +1271,12 @@ export function AssignmentConfirmSheet({
<Users className="h-3.5 w-3.5 flex-none text-brand-600" /> <Users className="h-3.5 w-3.5 flex-none text-brand-600" />
{/* {/*
⚠️ 有待分配时必须**先给总数**,再拆两半。 ⚠️ 有待分配时必须**先给总数**,再拆两半。
光写「拟分 21 人 · 待分配 39 人」读不出 21 是不是含 39 —— 主管第一反应会是 光写「已排好 21 人 · 待分配 39 人」读不出 21 是不是含 39 —— 主管第一反应会是
"那到底分了几个"。先摆一个 60,后面两个数就是它的拆分,不用猜(T14:数字要能对上)。 "那到底分了几个"。先摆一个 60,后面两个数就是它的拆分,不用猜(T14:数字要能对上)。
⛔ 别把待分配算进「拟分」:那 39 条**不会落库**,算进去就是虚报。 ⛔ 别把待分配算进「已排好」:那 39 条**不会落库**,算进去就是虚报。
🔴 措辞是「**已排好**」,⛔ 不是「已分配」(2026-08-13 定):它和「待分配」成对,
说的是**这张单内部**的状态(已经落到人头上 vs 还没排上)。
「已分配」会被读成"已经发出去了",而点确认之前一条都没落库 —— 那正是硬约束第 1 条。
*/} */}
<span className="font-semibold text-slate-800"> <span className="font-semibold text-slate-800">
{pendingList.length > 0 ? ( {pendingList.length > 0 ? (
...@@ -1066,7 +1285,7 @@ export function AssignmentConfirmSheet({ ...@@ -1066,7 +1285,7 @@ export function AssignmentConfirmSheet({
<> <>
<span className="whitespace-nowrap">共 {items.length + pendingList.length} 人</span> <span className="whitespace-nowrap">共 {items.length + pendingList.length} 人</span>
<span className="whitespace-nowrap"> <span className="whitespace-nowrap">
{' '}· 拟分 {items.length}({groups.length} 位客服) {' '}· 已排好 {items.length} 人
</span> </span>
<span className="whitespace-nowrap text-amber-800"> <span className="whitespace-nowrap text-amber-800">
{' '}· 待分配 {pendingList.length} 人 {' '}· 待分配 {pendingList.length} 人
...@@ -1074,7 +1293,7 @@ export function AssignmentConfirmSheet({ ...@@ -1074,7 +1293,7 @@ export function AssignmentConfirmSheet({
</> </>
) : ( ) : (
<> <>
拟分 {items.length} 人 · {groups.length} 位客服 已排好 {items.length} 人 · {groups.length} 位客服
</> </>
)} )}
</span> </span>
...@@ -1108,12 +1327,52 @@ export function AssignmentConfirmSheet({ ...@@ -1108,12 +1327,52 @@ export function AssignmentConfirmSheet({
⚠️ 文案是「N 天**后自动退回**」而不是光一个「时效」—— ⚠️ 文案是「N 天**后自动退回**」而不是光一个「时效」——
主管要知道到期会发生什么(单子回池),不然这个数对他没有意义。 */} 主管要知道到期会发生什么(单子回池),不然这个数对他没有意义。 */}
<div className="ml-auto flex flex-none items-center gap-1 whitespace-nowrap text-[10.5px] text-slate-500"> <div className="ml-auto flex flex-none items-center gap-1 whitespace-nowrap text-[10.5px] text-slate-500">
<DaySelect value={expiresInDays} disabled={readOnly} onChange={setExpiresInDays} /> <DaySelect
value={expiresInDays}
disabled={readOnly}
onChange={(d) => {
setExpiresInDays(d);
echo(`确认单已更新:整批时效改成 ${d} 天。`);
}}
/>
<span>后自动退回</span> <span>后自动退回</span>
</div> </div>
</div> </div>
{/* {/*
①.1 **这张单能怎么改** —— 卡片自己的操作说明,紧贴汇总行。
🔴 2026-08-13 换掉了原来的「运营结论」(每人拿到几条、最满的要几天打完):
那些数模型在正文里已经讲过一遍,而且现在有 `batch_size_basis` / `daily_overload`
两个引导节点专门管它们 —— 卡片再写就是第三遍。
⭐ 这个位置留给**只有卡片能做的事**:拖、点、逐条改。模型说不了这些,
它们也没法做成引导节点(那是针对具体一条的决策,这里是通用操作)。
⭐ 末尾挂**兜底**:拖点改覆盖不到的(「谁分给谁」这类)交给助手 ——
点一下把焦点落到输入框,⛔ 不预填任何话(这里没有"是哪一条"要交代,
预填就是界面替他开口)。
⚠️ 收起态也要看得见 —— 它和汇总行是主管唯一能看到的两行(见下面的折叠)。
⚠️ 只在**还能改**的时候显示:已确认 / 已撤销时它是误导。
*/}
{!readOnly && (
<div className="bg-slate-50/70 px-3 py-2 text-[11.5px] leading-relaxed text-slate-500">
展开客服看名单 · <span className="text-slate-700">拖动患者</span>改派给别人 ·
点 <span className="text-slate-700">×</span> 移出本批 · 每行可单独改时效,
右上角改整批时效。
{onCompose && (
<>
{' '}想自定义谁分给谁等更多操作,直接{' '}
<button
type="button"
onClick={() => onCompose('')}
className="rounded border border-dashed border-slate-300 px-1.5 py-0.5 text-[11px] leading-4 text-slate-500 transition-colors hover:border-brand-400 hover:text-brand-700"
>
我自己说
</button>
</>
)}
</div>
)}
{/*
①.6 **引导节点** —— 紧贴汇总行,⛔ 不能放卡片底部。 ①.6 **引导节点** —— 紧贴汇总行,⛔ 不能放卡片底部。
主管的视线从汇总行往下走,"还有什么要我定的"必须在这一跳里被看到; 主管的视线从汇总行往下走,"还有什么要我定的"必须在这一跳里被看到;
埋到底部就等于没有(那正是「待分配」当初被漏掉的原因)。 埋到底部就等于没有(那正是「待分配」当初被漏掉的原因)。
...@@ -1127,14 +1386,17 @@ export function AssignmentConfirmSheet({ ...@@ -1127,14 +1386,17 @@ export function AssignmentConfirmSheet({
判定逻辑复制到两边必然漂(T6a 同源)。要真正重算得让服务端重出一版。 判定逻辑复制到两边必然漂(T6a 同源)。要真正重算得让服务端重出一版。
*/} */}
<ConfirmSheetSignals <ConfirmSheetSignals
signals={liveSignals} signals={settledSignals}
readOnly={readOnly} readOnly={readOnly}
busyIntent={busyIntent} busyIntent={busyIntent}
onIntent={handleIntent} onIntent={handleIntent}
onCompose={onCompose}
/> />
{/* {/*
①.5 **「重新排一版」做了什么** —— 紧贴汇总行,⛔ 不能放到卡片底部。 ①.7 **「重新排一版」做了什么** —— 常显,⛔ 不进收起体。
🔴 2026-08-13:一度被我关进收起体里 —— 主管**刚点完按钮**却看不到结果,
得先展开名单才知道换了什么。按"收起的只有名单"这条规则,它本来就不该在里面。
🔴 由来(2026-08-06 实测):点了重排,卡片从「拟分 16 · 待分配 34」变成「拟分 50」, 🔴 由来(2026-08-06 实测):点了重排,卡片从「拟分 16 · 待分配 34」变成「拟分 50」,
而**上面那段话还是旧的**(仍写着"34 人要您定"、"19 人无主")—— 重排走 HTTP 而**上面那段话还是旧的**(仍写着"34 人要您定"、"19 人无主")—— 重排走 HTTP
只换卡片,助手不会重新说一遍。主管看到的是两份打架的口径,合理的第一反应 只换卡片,助手不会重新说一遍。主管看到的是两份打架的口径,合理的第一反应
...@@ -1149,20 +1411,11 @@ export function AssignmentConfirmSheet({ ...@@ -1149,20 +1411,11 @@ export function AssignmentConfirmSheet({
)} )}
{/* {/*
①.6 **运营结论** —— 主管扫一眼就能决定按不按确认的三件事。 ⭐ 以下是**收起体**:名单类的东西。上面那几块(汇总 / 操作说明 / 引导节点)
🔴 由来(2026-08-04 产品评审原话):「我不可能挨个去看他分的对不对, 和下面的确认按钮都常显,⛔ 别把它们挪进来。
你在这儿应该给的是**结论性的东西**…如果你不能告诉他哪不对、哪里需要主管判断,
那就默认他必须一条一条看过去才能确认。」
⚠️ 位置在重排交代**之后**、客服清单之前:重排是"这一版怎么来的",
运营结论是"这一版是多大的活",顺序按主管的阅读次序来。
⛔ 别把待分配的解释挪进来 —— 那件事在下面单独一组,问的是"要您拍板什么",
和这里问的"这活多大"不是一回事。
*/} */}
{sheet.opsNote && ( {bodyOpen && (
<div className="whitespace-pre-line border-b bg-slate-50/70 px-3 py-2 text-[11.5px] leading-relaxed text-slate-700"> <>
<Emphasized text={sheet.opsNote} />
</div>
)}
{/* ② 按客服(产能视图)—— 这一层是主管真正在看的 */} {/* ② 按客服(产能视图)—— 这一层是主管真正在看的 */}
<CardContent className="p-0"> <CardContent className="p-0">
...@@ -1184,7 +1437,12 @@ export function AssignmentConfirmSheet({ ...@@ -1184,7 +1437,12 @@ export function AssignmentConfirmSheet({
e.preventDefault(); e.preventDefault();
setDragOver(null); setDragOver(null);
const planId = e.dataTransfer.getData('text/plan-id'); const planId = e.dataTransfer.getData('text/plan-id');
if (planId) setMoveByPlan((m) => ({ ...m, [planId]: g.userId })); if (planId) {
setMoveByPlan((m) => ({ ...m, [planId]: g.userId }));
echo(
`确认单已更新:「${zhPatient(planId)}」改派给 ${g.name ?? g.userId.slice(0, 8)}。`,
);
}
}} }}
className={cn(dragOver === g.userId && 'bg-brand-50/60 ring-1 ring-inset ring-brand-300')} className={cn(dragOver === g.userId && 'bg-brand-50/60 ring-1 ring-inset ring-brand-300')}
> >
...@@ -1331,14 +1589,20 @@ export function AssignmentConfirmSheet({ ...@@ -1331,14 +1589,20 @@ export function AssignmentConfirmSheet({
compact compact
value={i.days} value={i.days}
disabled={readOnly} disabled={readOnly}
onChange={(d) => setDayByPlan((m) => ({ ...m, [i.planId]: d }))} onChange={(d) => {
setDayByPlan((m) => ({ ...m, [i.planId]: d }));
echo(`确认单已更新:「${zhPatient(i.planId)}」这条时效单独改成 ${d} 天。`);
}}
/> />
{!readOnly && ( {!readOnly && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
title="本批不分这个人" title="本批不分这个人"
onClick={() => setDropped((s) => new Set(s).add(i.planId))} onClick={() => {
setDropped((s) => new Set(s).add(i.planId));
echo(`确认单已更新:「${zhPatient(i.planId)}」移出本批。`);
}}
className="h-5 w-5 flex-none text-slate-300 hover:text-rose-600" className="h-5 w-5 flex-none text-slate-300 hover:text-rose-600"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
...@@ -1361,13 +1625,16 @@ export function AssignmentConfirmSheet({ ...@@ -1361,13 +1625,16 @@ export function AssignmentConfirmSheet({
**把患者从他的专属客服手里挪走是关系层面的决定,助手没资格替主管做。** **把患者从他的专属客服手里挪走是关系层面的决定,助手没资格替主管做。**
⇒ 助手不再决定,而是**把问题摊开**,单列一组交给主管。 ⇒ 助手不再决定,而是**把问题摊开**,单列一组交给主管。
⚠️ 视觉上必须**跳出来**(amber + 顶部粗边):它混在客服组里就等于没提醒 —— ⚠️ 视觉上必须**跳出来**(整块 amber 底 + 独立标题):它混在客服组里就等于没提醒 ——
主管一路往下点确认,这几个人就悄悄没了。 主管一路往下点确认,这几个人就悄悄没了。
⚠️ 操作与客服组**完全一致**:拖到任意客服 = 分给他(标「指定」);× = 移出本批。 ⚠️ 操作与客服组**完全一致**:拖到任意客服 = 分给他(标「指定」);× = 移出本批。
⛔ 不处理就是不分,这几条**不会落库**(见 confirm 的 items) —— 文案必须说清这一点。 ⛔ 不处理就是不分,这几条**不会落库**(见 confirm 的 items) —— 文案必须说清这一点。
*/} */}
{pendingList.length > 0 && ( {pendingList.length > 0 && (
<div className="border-t-2 border-amber-300 bg-amber-50/40"> // ⚠️ 2026-08-15 产品定:**细线就够**(1px)。
// 「待分配必须跳出来」这条**没有变** —— 跳出来靠的是整块 amber 底 + 独立标题,
// ⛔ 不是靠边框粗细。上面那段约束里的「粗边」二字随之作废,别再照它加回 2px。
<div className="border-t border-amber-300 bg-amber-50/40">
<div className="px-3 py-2"> <div className="px-3 py-2">
{/* {/*
⚠️ 可收拢,但**默认展开** —— 这一组的存在本身就是要引起注意, ⚠️ 可收拢,但**默认展开** —— 这一组的存在本身就是要引起注意,
...@@ -1395,38 +1662,10 @@ export function AssignmentConfirmSheet({ ...@@ -1395,38 +1662,10 @@ export function AssignmentConfirmSheet({
尽量凑满 N。⛔ 它**不动任何人的专属关系** —— 这是底线,不是可选项。 尽量凑满 N。⛔ 它**不动任何人的专属关系** —— 这是底线,不是可选项。
⚠️ ⛔ 不写名次/优先级:产品判定「排名不重要,重要的是这批人共享同一组特征」。 ⚠️ ⛔ 不写名次/优先级:产品判定「排名不重要,重要的是这批人共享同一组特征」。
*/} */}
{!readOnly && sheet.canRefill && ( {/* ⛔ 这里原来有一个"重排凑满"按钮 —— 2026-08-13 去掉:
<div className="mt-1 pl-[18px]"> 待分配已经是引导节点,同一个动作在节点里叫「换无主患者补上」
<Button (intent `pending.refill`,由 use-assistant-chat 执行)。
size="sm" 同一个动作两个入口必然漂,而且主管要在两处之间猜哪个是哪个。 */}
variant="outline"
disabled={refilling}
onClick={async () => {
setRefilling(true);
try {
// ⚠️ 条件原样带回 —— 少带一个,重排就退回"沿用上一次",人数悄悄变了
const next = await assignmentsApi.refill({
clinicId: sheet.clinicId,
potentialTreatment: sheet.potentialTreatment,
temperature: sheet.temperature,
// ⚠️ 口径也要带 —— 重排的是同一批人群,换口径就换了人群
anchorMode: sheet.anchorMode,
targetCount: sheet.batchSize,
expiresInDays,
});
onSheetReplaced?.(next);
} catch (e) {
setError(e instanceof Error ? e.message : '重新排版失败');
} finally {
setRefilling(false);
}
}}
className="h-6 border-amber-300 bg-white px-2 text-[11px] text-amber-800 hover:bg-amber-50"
>
{refilling ? '正在重排…' : '重新排一版,尽量排满'}
</Button>
</div>
)}
</div> </div>
{pendingOpen && ( {pendingOpen && (
/* /*
...@@ -1477,17 +1716,34 @@ export function AssignmentConfirmSheet({ ...@@ -1477,17 +1716,34 @@ export function AssignmentConfirmSheet({
p.lastVisitDoctor !== p.attendingDoctor && <> · 最近 {p.lastVisitDoctor}</>} p.lastVisitDoctor !== p.attendingDoctor && <> · 最近 {p.lastVisitDoctor}</>}
</span> </span>
) : null} ) : null}
{/* ⭐ 「为什么卡住」的证据:是谁的专属 + 他现在手上多少。 {/* ⭐ 「为什么卡住」的证据:是谁的专属 + 他**这批排到哪儿了**。
🔴 2026-08-13 改:原来只写 `(在手 N)`,而那是**本批之前**的量 ——
于是一边写着「他的专属客服排满了」、一边写着「在手 0」,自相矛盾
(排满恰恰是因为本批已经给了他 15 条,而不是他原本就忙)。
⇒ 写成 `在手 0 → 15`,和上面客服分组那一行同一个格式。
⚠️ 用 `ownerLoad`(本地实时算的)而不是 `p.ownerInHand`:
主管拖走几个人之后,这个数要跟着变。
⛔ 别只写「专属已满」—— 主管要判断的正是"这个人该不该从他手里挪走"。 */} ⛔ 别只写「专属已满」—— 主管要判断的正是"这个人该不该从他手里挪走"。 */}
<span className="flex-none text-[10px] text-amber-700"> <span className="flex-none text-[10px] text-amber-700">
专属 {p.ownerName ?? p.ownerUserId.slice(0, 6)}(在手 {p.ownerInHand}) 专属 {p.ownerName ?? p.ownerUserId.slice(0, 6)}
{(() => {
const l = ownerLoad.get(p.ownerUserId);
const before = l?.before ?? p.ownerInHand;
// 本批一条都没给他(比如被精调成 0 名额)→ 没有"→",只报在手
return l && l.batch > 0
? `(在手 ${before} → ${before + l.batch})`
: `(在手 ${before})`;
})()}
</span> </span>
{!readOnly && ( {!readOnly && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
title="移出本批" title="移出本批"
onClick={() => setDropped((sset) => new Set(sset).add(p.planId))} onClick={() => {
setDropped((sset) => new Set(sset).add(p.planId));
echo(`确认单已更新:「${zhPatient(p.planId)}」移出本批。`);
}}
className="h-6 w-6 flex-none text-slate-400 hover:text-rose-600" className="h-6 w-6 flex-none text-slate-400 hover:text-rose-600"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
...@@ -1506,7 +1762,7 @@ export function AssignmentConfirmSheet({ ...@@ -1506,7 +1762,7 @@ export function AssignmentConfirmSheet({
{/* ⚠️ 拖过去之后他就有条目了 —— 从这行剔掉,否则同一个人既在上面成组、又在"未分到"里 */} {/* ⚠️ 拖过去之后他就有条目了 —— 从这行剔掉,否则同一个人既在上面成组、又在"未分到"里 */}
{idleAgents.length > 0 && ( {idleAgents.length > 0 && (
<div className="border-t px-3 py-1.5 text-[10.5px] text-slate-400"> <div className="border-t px-3 py-1.5 text-[10.5px] text-slate-400">
本批未分到:{idleAgents.map((a) => `${a.name ?? a.userId}(在手 ${a.inHand})`).join('、')} 本批未分到:{idleAgents.map((a) => `${a.name ?? a.userId}(在手 ${a.inHand})`).join('、')}
</div> </div>
)} )}
...@@ -1516,21 +1772,38 @@ export function AssignmentConfirmSheet({ ...@@ -1516,21 +1772,38 @@ export function AssignmentConfirmSheet({
· 本批多少人 → 顶部「拟分 N 人」和确认按钮上都写着; · 本批多少人 → 顶部「拟分 N 人」和确认按钮上都写着;
· 怎么改 → 助手在消息里说,而且现在可以直接让助手改(edit_assignment_sheet)。 */} · 怎么改 → 助手在消息里说,而且现在可以直接让助手改(edit_assignment_sheet)。 */}
{/* </>
「怎么调」的四条引导 —— **常驻在卡片上**(2026-08-06 走查从对话里搬来)。 )}
原来要求助手每出一版都在消息里念一遍。第一次有用,而主管一天要看好几版, {/* ⛔ 这里原来常驻着一份**通用例句清单**(教主管能跟助手说哪些话)——
第二次起全是噪音 —— 那段话比确认单本身还长。 2026-08-13 去掉:它想解决的「主管不知道能让助手干什么」现在由**引导节点**回答了,
⭐ 放在这里:不占对话流、永远在需要时看得见,而且**紧挨着确认按钮** 而且节点是**针对这一批的具体选项**(带人数、可点),比一份通用例句清单强得多。
(他犹豫要不要点的那一刻,正好看到"原来还能这么调")。 ⛔ 别加回来:两处都在教"能做什么",而通用清单永远比不过当下那几个按钮。 */}
⚠️ 只在**还能改**的时候显示:已确认 / 已撤销时它是误导。
{/*
③.9 **展开 / 收起名单** —— 整宽一条,紧贴确认按钮上方。
🔴 2026-08-13 从汇总行左上角那个小箭头搬到这儿:那个箭头**太不显眼**,
主管根本注意不到还能展开(产品实测反馈)。
⭐ 放在这儿的另一个理由:它正好在**名单会长出来的地方**,
点它 → 内容就在原地展开,视线不用跳。
⚠️ 文案带上数量 —— 「展开名单」是动作,「33 位客服 · 待分配 436 人」才是
他决定要不要展开的依据。
*/} */}
{!readOnly && ( <button
<div className="border-t bg-slate-50/60 px-3 py-1.5 text-[10.5px] leading-relaxed text-slate-400"> type="button"
可以直接跟我说:「把某某移出这批」「某某的单给 5 天」「这批 200 人」· onClick={() => setBodyOpen((v) => !v)}
「只要商保直付的」「排掉最近来过的」· 「这批带上『老客户复查免挂号费』」 aria-expanded={bodyOpen}
</div> className="flex w-full items-center justify-center gap-1 border-t bg-slate-50/70 py-1.5 text-[11.5px] text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700"
)} >
<ChevronDown
className={cn('h-3.5 w-3.5 transition-transform', bodyOpen && 'rotate-180')}
/>
{bodyOpen
? '收起名单'
: `展开名单(${groups.length} 位客服${
pendingList.length > 0 ? ` · 待分配 ${pendingList.length} 人` : ''
})`}
</button>
{/* ④ 确认 */} {/* ④ 确认 */}
<div className="border-t px-3 py-2"> <div className="border-t px-3 py-2">
......
'use client'; 'use client';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { import {
Bot, Brain,
Check, Check,
ChevronDown, ChevronDown,
LayoutTemplate, LayoutTemplate,
...@@ -14,7 +14,6 @@ import { ...@@ -14,7 +14,6 @@ import {
Minimize2, Minimize2,
Mic, Mic,
Send, Send,
Sparkles,
Square, Square,
X, X,
ExternalLink, ExternalLink,
...@@ -28,10 +27,13 @@ import { ...@@ -28,10 +27,13 @@ import {
downsampleTo16k, downsampleTo16k,
floatToPcm16, floatToPcm16,
} from '@/components/realtime-coach/audio-utils'; } from '@/components/realtime-coach/audio-utils';
import { Permission, type AssignmentProposal } from '@pac/types'; import { Permission, type AssignmentProposal, type Signal } from '@pac/types';
import { useHasPermission } from '@/hooks/use-permission'; import { useHasPermission } from '@/hooks/use-permission';
import { useAssistantStore } from '@/stores/assistant-store'; import { useAssistantStore } from '@/stores/assistant-store';
import { AssignmentConfirmSheet } from './assignment-confirm-sheet'; import { AssignmentConfirmSheet } from './assignment-confirm-sheet';
import { SignalPrompt } from './signal-prompts';
import { ReasoningRow, StepRow, StepTrace, toolMeta } from './step-trace';
import { isDiscardedDraft, stripToolEcho } from './chat-blocks';
import { import {
useAssistantChat, useAssistantChat,
type Artifact, type Artifact,
...@@ -39,6 +41,25 @@ import { ...@@ -39,6 +41,25 @@ import {
type ChatMessage, type ChatMessage,
} from './use-assistant-chat'; } from './use-assistant-chat';
/**
* 这两个是**纯位置标记**:正文里已经有它们的可见产物(卡片 / 按钮),
* ⛔ 不再单独出一行灰字 —— 否则同一件事画两遍,而且顺序还是反的
* (侧信道事件比 fullStream 的 tool-call 先到)。
*/
const PLACE_TOOLS = new Set(['show_sheet', 'show_guidance']);
/**
* ⭐ **小图标按钮的唯一定义**(2026-08-15)。
*
* 测绘发现这一串在本文件里**逐字重复了四遍**(artifact 卡的放大/新窗口/关闭、头部的最大化/收起)——
* 改一处漏三处,而这次改风格恰恰要动的就是它。
* ⚠️ 语义是「次要动作」:静止时是灰的,只有悬停才显形。
* ⛔ 确认单里那几个 `×` 按钮**不许并进来**:它们同尺寸,但 hover 是 rose(破坏性动作),
* 并进来就等于把"删掉一个人"和"收起面板"画成同一个东西。
*/
const ICON_BTN =
'inline-flex h-7 w-7 flex-none items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600';
const MODELS = [ const MODELS = [
{ value: 'deepseek', label: 'DeepSeek' }, { value: 'deepseek', label: 'DeepSeek' },
{ value: 'qwen', label: '通义千问' }, { value: 'qwen', label: '通义千问' },
...@@ -62,38 +83,34 @@ const EXAMPLES_STAFF = [ ...@@ -62,38 +83,34 @@ const EXAMPLES_STAFF = [
'现在有哪些潜在治疗、值得召回的患者?', '现在有哪些潜在治疗、值得召回的患者?',
]; ];
const EXAMPLES_LEADER = [ /**
'帮我给「种植 · 窗口内」这批患者出一份分配方案', * 主管空态的例句 —— **一句一条动线,每条落到一个真工具**。
'帮我跟踪下我分配的任务有没什么问题', *
'最近一批分配出份报表:分配数、未动过、已处理、成功、超期,退回和不成功要带原因', * ⭐ 这里是唯一一份:`assistant-widget` 从这儿引,⛔ 别再抄一遍
'今日推荐:挑几个该优先跟进的患者,并说明理由', * (2026-08-15 之前两处各写了一份逐字相同的三条,而其中两条都是错的)。
*
* ═══ 2026-08-15 改掉的三处 ═══════════════════════════════════════
* 🔴 **「窗口内」界面上四个月前就没有了。** `temperature.ts` 2026-08-11 把前两档
* 显示名从「黄金期 / 窗口内」改成「三个月内 / 三个月到半年」,理由写得很硬:
* 列头写着「黄金期」而里面装的是"三个月内",他拿它当临床窗口用就会做错决定。
* ⇒ 例句是**摆在他眼前、还请他点**的一句话,用一个界面上不存在的词最糟:
* 他点了,模型还得去猜那是哪一档。
* 🔴 **「成功 / 不成功」是 `TRACKING_GUIDE` 专门在防的那个词。** 字段确实有
* (`outcomes.success`),但它含「约定下次回访」,⛔ 不是"谈成了几个";
* 而提示词那条更硬:本系统**不统计成功与否**。例句把这个词递给他、还请他点,
* 模型只有两条路:如实说没有这个数(那这条例句就是个坑),或者去圆。
* ⚠️ **「任务」是客服的词。** 他这一屏叫「我分的批次」;单条任务是客服执行页的事。
*
* ⛔ 例句里点名的每一样都必须是工具真返回的 —— 例句先许愿,会逼着模型去圆。
* ⚠️ 顺序 = 他的实际动线:看人手 → 分 → 追 → 单批下钻。
*/
export const EXAMPLES_LEADER = [
'现在谁手上最空,谁快扛不住了',
'帮我给「种植 · 三个月到半年」这批患者出一份分配方案',
'我分下去那几批现在怎么样了,有没有谁堆着没动',
'最近一批分下去的,处理到什么程度了,退回的是什么原因',
]; ];
// ── 工具中文元信息:助手用的是内部英文工具名,界面不展示工具卡,只把"正在做什么"
// 喂给下方 loading 指示(见 MessageView)。未知工具兜底用原名。
const TOOL_META: Record<string, { label: string; running: string }> = {
find_patient: { label: '查找患者', running: '正在查找患者' },
get_patient_overview: { label: '读取患者概览', running: '正在读取患者概览' },
get_persona: { label: '读取患者画像', running: '正在读取患者画像' },
get_facts: { label: '读取关键事实', running: '正在读取关键事实' },
get_recall_plan: { label: '读取召回计划', running: '正在读取召回计划' },
list_recall_queue: { label: '检索召回队列', running: '正在检索召回队列' },
recall_queue_stats: { label: '统计召回池', running: '正在统计召回池' },
get_current_user: { label: '确认身份', running: '正在确认你的身份与权限' },
get_agents: { label: '查在岗客服', running: '正在查在岗客服与在手负载' },
propose_assignment: { label: '出分配方案', running: '正在挑人、排客服,出分配方案' },
list_assignment_batches: { label: '查历史批次', running: '正在查你分过的批次' },
get_assignment_detail: { label: '查批次详情', running: '正在查这批的执行情况' },
// ⚠️ 这四个此前漏了 —— 兜底文案是「正在调用 get_cohort_attributes」,
// 把内部英文工具名摆给主管看。凡是注册进 MCP 的工具都要在这里有中文说法。
get_cohort_attributes: { label: '看这批人构成', running: '正在看这批人里各类有多少' },
edit_assignment_sheet: { label: '改确认单', running: '正在按你说的改确认单' },
revoke_assignment: { label: '撤销批次', running: '正在撤销这批' },
render_artifact: { label: '出图表', running: '正在画图' },
};
function toolMeta(tool: string) {
return TOOL_META[tool] ?? { label: tool, running: `正在调用 ${tool}` };
}
/** /**
* LLM 文本 → markdown 渲染(用户要求:不复用设计的结构卡,改 markdown 解析)。 * LLM 文本 → markdown 渲染(用户要求:不复用设计的结构卡,改 markdown 解析)。
...@@ -119,7 +136,9 @@ function toolMeta(tool: string) { ...@@ -119,7 +136,9 @@ function toolMeta(tool: string) {
* ⛔ 别把这些值改回去而不改提示词:样式与「模型输出什么结构」是一对 * ⛔ 别把这些值改回去而不改提示词:样式与「模型输出什么结构」是一对
* (见 assistant-prompts 的「怎么写」一节)。 * (见 assistant-prompts 的「怎么写」一节)。
*/ */
function Markdown({ text }: { text: string }) { function Markdown({ text: raw }: { text: string }) {
// ⛔ 模型照抄出来的工具记录不许摆给主管看(见 chat-blocks.stripToolEcho)
const text = stripToolEcho(raw);
/** /**
* ⚠️ 容器上那三条 `[&_hN+*]:mt-1.5` 不是装饰,是**外边距折叠**的必然结果: * ⚠️ 容器上那三条 `[&_hN+*]:mt-1.5` 不是装饰,是**外边距折叠**的必然结果:
* 标题的 `mb`(6px)与紧随其后那一段的 `mt`(14px)会**折叠成较大的那个** —— * 标题的 `mb`(6px)与紧随其后那一段的 `mt`(14px)会**折叠成较大的那个** ——
...@@ -130,7 +149,18 @@ function Markdown({ text }: { text: string }) { ...@@ -130,7 +149,18 @@ function Markdown({ text }: { text: string }) {
return ( return (
<div className="text-[13.5px] leading-relaxed text-slate-700 [font-variant-numeric:tabular-nums] [&_h1+*]:mt-1.5 [&_h2+*]:mt-1.5 [&_h3+*]:mt-1.5"> <div className="text-[13.5px] leading-relaxed text-slate-700 [font-variant-numeric:tabular-nums] [&_h1+*]:mt-1.5 [&_h2+*]:mt-1.5 [&_h3+*]:mt-1.5">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} /**
* 🔴 **`singleTilde: false` —— ⛔ 别删**(2026-08-15 实测抓到)。
*
* GFM 默认连**单个**波浪号也当删除线(`~x~`)。而模型讲区间时会写
* 「每人 8~9 条,分完每人手上 48~49 条」—— 两个 `~` 正好配成一对,
* 把中间那截划掉,屏幕上剩下「每人 **89** 条、手上 **4849** 条」。
* ⚠️ 这是 `assignment-facts.range()` 那条纪律的**另一半**:那边把区间拆成
* `{最少, 最多}` 两个键,就是因为字符串 `'4~15'` 会被模型读成 415;
* 数据这头堵住了,呈现这头照样能把两个数粘成一个 —— 而且**不报错**。
* ⇒ 关掉单波浪号:`~` 原样显示,`~~删除线~~` 仍然可用(模型也用不到)。
*/
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
components={{ components={{
h1: ({ children }) => <h1 className="mb-1.5 mt-5 text-[15px] font-semibold text-slate-900 first:mt-0">{children}</h1>, h1: ({ children }) => <h1 className="mb-1.5 mt-5 text-[15px] font-semibold text-slate-900 first:mt-0">{children}</h1>,
h2: ({ children }) => <h2 className="mb-1.5 mt-5 text-[14.5px] font-semibold text-slate-900 first:mt-0">{children}</h2>, h2: ({ children }) => <h2 className="mb-1.5 mt-5 text-[14.5px] font-semibold text-slate-900 first:mt-0">{children}</h2>,
...@@ -141,11 +171,33 @@ function Markdown({ text }: { text: string }) { ...@@ -141,11 +171,33 @@ function Markdown({ text }: { text: string }) {
* `first:mt-0` 防止第一块顶部多出一截空白。 * `first:mt-0` 防止第一块顶部多出一截空白。
* ⛔ 别做成小灰字标签(第一版就是那样,结果标题比正文还弱,句中加粗比标题更抢眼)。 * ⛔ 别做成小灰字标签(第一版就是那样,结果标题比正文还弱,句中加粗比标题更抢眼)。
*/ */
/**
* 🔴 **⛔ 没有 `first:mt-0`**(2026-08-13 删的,别加回来)。
*
* 它当年是对的 —— 那时一条助手消息 = **一个**文本块,`first` 就等于"整条消息的开头"。
* 现在 `show_guidance` / `show_sheet` 会把模型的正文**切成好几个文本块**,
* 而每个块都包在自己的 `<div>` 里 ⇒ 紧跟按钮之后的那个块,第一个元素往往正是标题,
* `first:mt-0` 把 28px 抹成 0,只剩容器 `space-y-2.5` 的 10px ——
* 「分完什么样」「要您定的」贴着上面的按钮,产品当场指出来。
* ⇒ "整条消息的第一个"改由容器上那条 `[&>div:first-child>h3:first-child]:mt-0` 管
* (见 MessageView),它才认得出真正的第一个。
*/
h3: ({ children }) => ( h3: ({ children }) => (
<h3 className="mb-1.5 mt-5 text-[14px] font-semibold text-slate-900 first:mt-0"> <h3 className="mb-1.5 mt-7 text-[14px] font-semibold text-slate-900">{children}</h3>
{children}
</h3>
), ),
/**
* ⛔ **刻意没有 h4**(2026-08-13 做过一版又撤了,⛔ 别再加)。
*
* 当时的由头是确认单第 1 段有「这一格还能怎么选」「本批人数还能怎么调」两组,
* 想给它们一级副标题。做出来产品当场否了 —— **每组通常只有一条**,
* 标题和它下面那条说的是同一件事,白占一行;正撞上写作规范自己那句
* 「⛔ 别为一两句话套一个标题」。⇒ 提示词改成「别给这两组套小标题」,
* 组名只作为**给模型的分组**存在,不进主管的视野。
*
* ⚠️ 后果:模型自发写 `####` 时会渲染成**和正文一样**(Tailwind preflight 把
* h1–h6 的字号字重都清成 inherit)。要真需要第二级,⛔ 别只加样式 ——
* 先想清楚"什么内容配得上一个副标题",否则又是这次这个循环。
*/
p: ({ children }) => <p className="my-3.5">{children}</p>, p: ({ children }) => <p className="my-3.5">{children}</p>,
ul: ({ children }) => <ul className="my-2.5 ml-4 list-disc space-y-1.5">{children}</ul>, ul: ({ children }) => <ul className="my-2.5 ml-4 list-disc space-y-1.5">{children}</ul>,
ol: ({ children }) => <ol className="my-2.5 ml-4 list-decimal space-y-1.5">{children}</ol>, ol: ({ children }) => <ol className="my-2.5 ml-4 list-decimal space-y-1.5">{children}</ol>,
...@@ -199,20 +251,77 @@ function BlockView({ ...@@ -199,20 +251,77 @@ function BlockView({
onSheetConfirmed, onSheetConfirmed,
onSheetEdited, onSheetEdited,
onSignalIntent, onSignalIntent,
onSignalCompose,
onSheetReplaced, onSheetReplaced,
placedKeys,
signalOf,
streaming,
onLiveSignalsChange,
busyIntent,
orphanKeys,
}: { }: {
block: Block; block: Block;
onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string, modelSummary?: string) => void; onSheetConfirmed?: (
requestId: string,
assignmentId: string,
summary: string,
modelFacts?: string,
) => void;
/** 助手改确认单后,界面回报的那一句(注入消息流) */ /** 助手改确认单后,界面回报的那一句(注入消息流) */
onSheetEdited?: (summary: string) => void; onSheetEdited?: (summary: string, modelText?: string) => void;
/** 「重新排一版」回来的新提案 —— 整块换掉那张卡 */ /** 「重新排一版」回来的新提案 —— 整块换掉那张卡 */
onSheetReplaced?: (requestId: string, next: AssignmentProposal) => void; onSheetReplaced?: (requestId: string, next: AssignmentProposal) => void;
/** ⭐ 引导节点按钮 —— 与助手共用同一组 intent(见 chat-blocks.intentToSheetOps) */ /** ⭐ 引导节点按钮 —— 与助手共用同一组 intent(见 chat-blocks.intentToSheetOps) */
onSignalIntent?: (intent: string, args?: Record<string, unknown>) => void; onSignalIntent?: (intent: string, args?: Record<string, unknown>) => void;
/** 引导节点的兜底 —— 把一句开头填进底部输入框并聚焦(⛔ 不直接发送) */
onSignalCompose?: (draft: string) => void;
/** 已经被模型插进正文的引导节点 —— 卡片按它去重(见 show_guidance) */
placedKeys?: string[];
/** signalKey → 当前确认单上的那条引导节点;取不到 = 它已经不存在了 */
signalOf?: (key: string) => Signal | undefined;
/** 这一轮还在写 —— 传给确认单,卡内兜底要等说完再显示 */
streaming?: boolean;
/** 确认单上报「主语还在」的节点 key(见它的 liveSignals) */
onLiveSignalsChange?: (requestId: string, keys: string[]) => void;
/** 正在执行的 intent —— 按钮转圈 + 整排禁用 */
busyIntent?: string | null;
/** 这些引导节点是「无根」的(上面没有解释它的那句话)—— 要自带标题 */
orphanKeys?: Set<string>;
}) { }) {
// 工具调用不再单独显示卡片 —— 其"正在做什么"通过下方 loading 指示体现(见 MessageView)。 /**
if (block.kind === 'tool') return null; * ⭐ 思考 / 工具调用 **按到达顺序就地渲染**(2026-08-14 改,此前只在末尾的执行过程里)。
*
* 🔴 改的理由:位置本身就是信息 —— 「想 → 调 → 写 → 再调」和「全调完再写」
* 是两种完全不同的行为,而堆在末尾看不出区别。归因一次要靠这个顺序。
* ⚠️ 当初钉在末尾的顾虑是「qwen 会把 tool-call 全提到文本之前,等于先给主管看一屏工具记录」——
* 现在两条都变了:默认模型是会交错的 DeepSeek;而每行都是**一行收起态**,
* 就算全提到前面也只是几行灰字,⛔ 不再是"一屏记录"。
* ⛔ `show_*` 不出行:它们在正文里**已经有可见产物**(卡片 / 按钮),
* 而且侧信道事件比 fullStream 的 tool-call 先到 —— 两个都画会出现"产物排在自己的调用记录前面"。
*/
if (block.kind === 'reasoning') return <ReasoningRow text={block.text} />;
if (block.kind === 'tool') {
return PLACE_TOOLS.has(block.step.tool) ? null : <StepRow step={block.step} />;
}
// 位置标记本身不渲染 —— MessageView 已经把卡片搬到它那儿了(理论上到不了这)
if (block.kind === 'sheet_anchor') return null;
if (block.kind === 'artifact') return <ArtifactView artifact={block.artifact} />; if (block.kind === 'artifact') return <ArtifactView artifact={block.artifact} />;
if (block.kind === 'guidance') {
// ⚠️ 取不到 = 这条已经不在当前确认单上了(重排过 / 已处置)→ 不渲染。
// ⛔ 别渲染一个点了没反应的按钮,那比不渲染更糟。
const s = signalOf?.(block.signalKey);
if (!s || !onSignalIntent) return null;
return (
<SignalPrompt
signal={s}
busyIntent={busyIntent}
onIntent={onSignalIntent}
onCompose={onSignalCompose}
// 无根(模型没交错,被挪到正文之后)→ 自带标题,否则一排按钮看不出在说哪件事
withTitle={orphanKeys?.has(block.signalKey)}
/>
);
}
if (block.kind === 'assignment_sheet') { if (block.kind === 'assignment_sheet') {
return ( return (
<AssignmentConfirmSheet <AssignmentConfirmSheet
...@@ -225,9 +334,13 @@ function BlockView({ ...@@ -225,9 +334,13 @@ function BlockView({
// 否则它不知道到底成没成(可能有人没找到),下一句就会替系统撒谎 // 否则它不知道到底成没成(可能有人没找到),下一句就会替系统撒谎
onEditApplied={onSheetEdited} onEditApplied={onSheetEdited}
onIntent={onSignalIntent} onIntent={onSignalIntent}
placedKeys={placedKeys}
onCompose={onSignalCompose}
streaming={streaming}
onLiveSignalsChange={onLiveSignalsChange}
onSheetReplaced={(next) => onSheetReplaced?.(block.requestId, next)} onSheetReplaced={(next) => onSheetReplaced?.(block.requestId, next)}
onConfirmed={(id, summary, modelSummary) => onConfirmed={(id, summary, modelFacts) =>
onSheetConfirmed?.(block.requestId, id, summary, modelSummary) onSheetConfirmed?.(block.requestId, id, summary, modelFacts)
} }
/> />
); );
...@@ -370,8 +483,9 @@ function ArtifactView({ artifact }: { artifact: Artifact }) { ...@@ -370,8 +483,9 @@ function ArtifactView({ artifact }: { artifact: Artifact }) {
}; };
return ( return (
<div className="overflow-hidden rounded-lg border bg-white shadow-sm"> // ⭐ 去掉外框与阴影(2026-08-15):它是会话流里的一块内容,不是浮在上面的卡片
<div className="flex items-center gap-1.5 border-b bg-slate-50/60 px-3 py-1.5"> <div className="overflow-hidden rounded-lg bg-white">
<div className="flex items-center gap-1.5 px-1 py-1.5">
<LayoutTemplate className="h-3.5 w-3.5 text-brand-600" /> <LayoutTemplate className="h-3.5 w-3.5 text-brand-600" />
<span className="text-[11.5px] font-medium text-slate-500">{artifact.title || '可视化卡片'}</span> <span className="text-[11.5px] font-medium text-slate-500">{artifact.title || '可视化卡片'}</span>
<button <button
...@@ -407,7 +521,7 @@ function ArtifactView({ artifact }: { artifact: Artifact }) { ...@@ -407,7 +521,7 @@ function ArtifactView({ artifact }: { artifact: Artifact }) {
type="button" type="button"
onClick={openTab} onClick={openTab}
title="新窗口打开" title="新窗口打开"
className="ml-auto inline-flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-600" className={cn('ml-auto', ICON_BTN)}
> >
<ExternalLink className="h-3.5 w-3.5" /> <ExternalLink className="h-3.5 w-3.5" />
</button> </button>
...@@ -415,7 +529,7 @@ function ArtifactView({ artifact }: { artifact: Artifact }) { ...@@ -415,7 +529,7 @@ function ArtifactView({ artifact }: { artifact: Artifact }) {
type="button" type="button"
onClick={() => setExpanded(false)} onClick={() => setExpanded(false)}
title="关闭" title="关闭"
className="inline-flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-600" className={ICON_BTN}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
...@@ -482,17 +596,36 @@ function MessageView({ ...@@ -482,17 +596,36 @@ function MessageView({
onSheetConfirmed, onSheetConfirmed,
onSheetEdited, onSheetEdited,
onSignalIntent, onSignalIntent,
onSignalCompose,
onSheetReplaced, onSheetReplaced,
liveSignalKeys,
onLiveSignalsChange,
busyIntent,
showTrace,
}: { }: {
message: ChatMessage; message: ChatMessage;
streaming?: boolean; streaming?: boolean;
onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string, modelSummary?: string) => void; onSheetConfirmed?: (
requestId: string,
assignmentId: string,
summary: string,
modelFacts?: string,
) => void;
/** 助手改确认单后,界面回报的那一句(注入消息流) */ /** 助手改确认单后,界面回报的那一句(注入消息流) */
onSheetEdited?: (summary: string) => void; onSheetEdited?: (summary: string, modelText?: string) => void;
/** 「重新排一版」回来的新提案 —— 整块换掉那张卡 */ /** 「重新排一版」回来的新提案 —— 整块换掉那张卡 */
onSheetReplaced?: (requestId: string, next: AssignmentProposal) => void; onSheetReplaced?: (requestId: string, next: AssignmentProposal) => void;
/** ⭐ 引导节点按钮 —— 与助手共用同一组 intent(见 chat-blocks.intentToSheetOps) */ /** ⭐ 引导节点按钮 —— 与助手共用同一组 intent(见 chat-blocks.intentToSheetOps) */
onSignalIntent?: (intent: string, args?: Record<string, unknown>) => void; onSignalIntent?: (intent: string, args?: Record<string, unknown>) => void;
/** 引导节点的兜底 —— 填进输入框并聚焦 */
onSignalCompose?: (draft: string) => void;
/** requestId → 主语还在的节点 key(由确认单上报;缺省 = 都还在) */
liveSignalKeys?: Record<string, string[]>;
onLiveSignalsChange?: (requestId: string, keys: string[]) => void;
/** 正在执行的 intent —— 按钮转圈 + 整排禁用 */
busyIntent?: string | null;
/** 展开思考与工具调用 —— 由底部工具栏那个开关控制 */
showTrace: boolean;
}) { }) {
if (message.role === 'user') { if (message.role === 'user') {
return ( return (
...@@ -531,12 +664,114 @@ function MessageView({ ...@@ -531,12 +664,114 @@ function MessageView({
* 比卡片在前更怪。说完了再出现,顺序才成立。 * 比卡片在前更怪。说完了再出现,顺序才成立。
*/ */
const sheets = message.blocks.filter((b) => b.kind === 'assignment_sheet'); const sheets = message.blocks.filter((b) => b.kind === 'assignment_sheet');
/**
* ⭐ **收起思考与工具调用**时,就在这里把那两类块滤掉(2026-08-15)。
*
* ⚠️ 只此一处过滤:`firstText` / `isRooted` / `anchorAt` 全都基于 `rest` 的下标算,
* 在这之后统一少几块,相对顺序不变,那些判断照常成立。
* ⛔ 别改成在 `BlockView` 里逐块 return null —— 那样块还占着下标,
* 「锚点在不在第一段正文之前」会被已经不可见的块影响。
* ⚠️ 底部那行小结(执行过程 · N 步)**不跟着收** —— 收起之后它是唯一还看得见的痕迹,
* 一眼知道这轮跑过什么、也知道有东西被收起来了。
*/
const rest = message.blocks.filter(
(b) =>
b.kind !== 'assignment_sheet' &&
(showTrace || (b.kind !== 'reasoning' && b.kind !== 'tool')),
);
/**
* ⭐ **引导节点的位置由模型决定** —— 它写完「要您定的」某一条,紧接着调
* `show_guidance`,界面就在流里那个位置插一个 `guidance` 块(见 chat-blocks)。
* ⇒ 这里不做任何匹配、不猜位置。曾经用 `text.indexOf(标题)` 猜过(已删):
* 模型换个措辞就匹配不上,而匹配不上就渲染不出来。
*
* 这里只做两件事:
* ① `placedKeys` —— 已经插进正文的那几条,卡片上不再重复显示;
* ② `signalOf` —— 按 key 从**当前**这张确认单上取那条节点(重排后自动失效)。
* 🔴 **漏调不丢东西**:模型没插的那几条仍旧显示在卡片上(今天的行为原样保留)。
* ⇒ 卡片本身就是兜底面,⛔ 不需要另做一套"孤儿补渲染"。
*/
const live = sheets.find((b) => b.state === 'active') ?? sheets[sheets.length - 1];
const placedKeys = rest.filter((b) => b.kind === 'guidance').map((b) => b.signalKey);
/**
* signalKey → 当前这张确认单上那条节点;取不到 = 别渲染。
*
* 🔴 两道关:① 这张单上根本没这条(重排换过版);
* ② **主语已经没了** —— 待分配被处置光之后,卡片会上报"还活着"的 key,
* 不在里面的就不再渲染(2026-08-13 实测:点完「各自归专属客服」,
* 待分配清零、卡片头也变了,而正文里那四个按钮还在,再点是空操作)。
* ⚠️ **fail-open**:卡片还没上报(`undefined`)时按"都还在"渲染 ——
* 宁可多显示一次,⛔ 也不能把「还有人没发出去」静默藏掉。
*/
const signalOf = (key: string) => {
if (!live) return undefined;
const alive = liveSignalKeys?.[live.requestId];
if (alive && !alive.includes(key)) return undefined;
return (live.sheet.signals ?? []).find((s) => s.key === key);
};
/**
* 🔴 **选项上面必须有话** —— 引导块落在第一个文本块之前时它是无根的:
* 主管看到四个按钮,而"这是在说哪件事"还没写出来。
*
* ⚠️ 这不是又去猜位置,是一条**退化保护**:实测 qwen 会把这一轮的 tool-call
* 全部提到文本之前(`[工具×4][文本]`),deepseek 则正常交错
* (`[文本][工具][文本][工具]…`)。⇒ 无根的一律挪到正文之后、卡片之前。
* ⛔ 别把有根的也一起挪 —— 那就把模型定的位置又推翻了。
*/
const firstText = rest.findIndex((b) => b.kind === 'text');
const isRooted = (i: number) => firstText !== -1 && i > firstText;
/**
* 确认单卡片摆在哪:**有根的 `sheet_anchor` 就摆那儿,否则钉在末尾**(今天的行为)。
*
* ⭐ 摆在锚点上时**流式期间也渲染** —— 这正是这个锚点存在的理由:
* 卡片不必再等整轮跑完(那一轮现在还含着几次插引导节点的往返)。
* 🔴 漏调 / 无根 → 走老路(等流式结束、钉末尾)。⛔ 卡片永远不会不出现。
*/
const anchorAt = rest.findIndex((b, i) => b.kind === 'sheet_anchor' && isRooted(i));
const orphan = rest.filter((b, i) => b.kind === 'guidance' && !isRooted(i));
const orphanKeys = new Set(
orphan.map((b) => (b.kind === 'guidance' ? b.signalKey : '')).filter(Boolean),
);
const body: Block[] = [];
for (let i = 0; i < rest.length; i++) {
const b = rest[i]!;
if (b.kind === 'guidance' && !isRooted(i)) continue; // 无根引导 → 挪到正文之后
if (b.kind === 'sheet_anchor') {
if (i === anchorAt) body.push(...sheets); // 锚点本身不渲染,只是卡片的位置
continue;
}
body.push(b);
}
/**
* 🔴 **无根引导流式期间不渲染**(2026-08-14 走查)。
*
* 走查现象:点完「只选儿牙会员那 76 人」重排,正文才写到「这一格总人数:76 人(刚好就是」,
* 底下就已经挂出了「15 人的专属客服这轮已排满」那一排按钮 —— 一个**派活**层面的节点,
* 压在还没写完的**选人**那一段下面。
*
* 成因是已知的 qwen 退化态(`[工具×4][文本]`,见上面 `isRooted` 那段):
* 引导块比正文先到,被判为无根、挪到正文之后 —— 而"正文"这时只有半句。
* ⚠️ **等文本长完,它自己就落到对的地方了**,所以这不是位置算错,是**过早显示**。
*
* ⇒ 与下面那行 `sheets` 用的是同一条规矩:**位置不可信的东西,等说完再摆**。
* ⛔ 别把有根的也一起延后 —— 那些的上方文本已经完整,它们不会跳。
*/
const ordered = [ const ordered = [
...message.blocks.filter((b) => b.kind !== 'assignment_sheet'), ...body,
...(streaming ? [] : sheets), ...(streaming ? [] : orphan),
...(anchorAt !== -1 || streaming ? [] : sheets),
]; ];
return ( return (
<div className="min-w-0 space-y-2.5"> /**
* ⚠️ 末尾那条 arbitrary variant 是 `first:mt-0` 的替身:**只把整条消息的第一个标题**
* 顶到 0,⛔ 不再是"每个文本块的第一个"(见 Markdown 里 h3 那段)。
* 写成后代选择器是因为 Markdown 自带一层 `<div>` 包裹。
*/
<div className="min-w-0 space-y-2.5 [&>div:first-child>h3:first-child]:mt-0">
{ordered.map((b, i) => ( {ordered.map((b, i) => (
<BlockView <BlockView
// 🔴 确认单**必须用稳定 key**(requestId),⛔ 不能用下标。 // 🔴 确认单**必须用稳定 key**(requestId),⛔ 不能用下标。
...@@ -547,13 +782,34 @@ function MessageView({ ...@@ -547,13 +782,34 @@ function MessageView({
// ⚠️ 这是排序改动引入的回归 —— 排序本身没错,错在 key 跟着位置走。 // ⚠️ 这是排序改动引入的回归 —— 排序本身没错,错在 key 跟着位置走。
key={b.kind === 'assignment_sheet' ? `sheet:${b.requestId}` : `b:${i}`} key={b.kind === 'assignment_sheet' ? `sheet:${b.requestId}` : `b:${i}`}
block={b} block={b}
placedKeys={placedKeys}
signalOf={signalOf}
streaming={streaming}
onLiveSignalsChange={onLiveSignalsChange}
busyIntent={busyIntent}
orphanKeys={orphanKeys}
onSheetConfirmed={onSheetConfirmed} onSheetConfirmed={onSheetConfirmed}
onSheetEdited={onSheetEdited} onSheetEdited={onSheetEdited}
onSignalIntent={onSignalIntent} onSignalIntent={onSignalIntent}
onSignalCompose={onSignalCompose}
onSheetReplaced={onSheetReplaced} onSheetReplaced={onSheetReplaced}
/> />
))} ))}
{showHint && <GeneratingHint label={hintLabel} />} {showHint && <GeneratingHint label={hintLabel} />}
{/**
* ⭐ 执行过程 —— 这一轮**到底调了哪些工具**,默认收起(见 step-trace 的整段理由)。
*
* ⚠️ 摆在最后、且**说完才出现**:流式期间它每来一步就长一行,
* 而主管这时正在读上面的文字 —— 底下抖动比看不见更烦。
* ⚠️ 一步都没有时组件自己返回 null,⛔ 这里不用再判一次。
*/}
{!streaming && (
<StepTrace
steps={message.blocks
.filter((b): b is Extract<Block, { kind: 'tool' }> => b.kind === 'tool')
.map((b) => b.step)}
/>
)}
</div> </div>
); );
} }
...@@ -579,18 +835,23 @@ function ModelSelect({ ...@@ -579,18 +835,23 @@ function ModelSelect({
const label = MODELS.find((m) => m.value === model)?.label ?? model; const label = MODELS.find((m) => m.value === model)?.label ?? model;
return ( return (
<div className="relative" ref={ref}> <div className="relative" ref={ref}>
{/*
⭐ 无边框、无底色、次要字号(2026-08-15 对齐 Claude Code 底部那排)。
⛔ 去掉了名字前面那个绿点:它**恒绿**,不表达任何状态 ——
一个永远为真的指示灯只会稀释真正有状态的那几处(失败红、录音红)。
*/}
<button <button
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
className="inline-flex items-center gap-1.5 rounded-md border bg-white px-2.5 py-1.5 text-[12.5px] text-slate-700 transition-colors hover:bg-slate-50 disabled:opacity-50" className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-[12px] text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 disabled:opacity-50"
> >
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
{label} {label}
<ChevronDown className={cn('h-3.5 w-3.5 text-slate-400 transition-transform', open && 'rotate-180')} /> <ChevronDown className={cn('h-3 w-3 text-slate-400 transition-transform', open && 'rotate-180')} />
</button> </button>
{open && ( {open && (
<div className="absolute right-0 z-50 mt-1 w-40 rounded-lg border bg-white py-1 shadow-lg"> // ⚠️ 向**上**弹:它现在坐在窗口最底下,朝下弹会被裁掉
<div className="absolute bottom-full right-0 z-50 mb-1 w-40 rounded-lg border bg-white py-1 shadow-lg">
{MODELS.map((m) => ( {MODELS.map((m) => (
<button <button
key={m.value} key={m.value}
...@@ -642,6 +903,7 @@ export function AssistantChat({ ...@@ -642,6 +903,7 @@ export function AssistantChat({
settleSheet, settleSheet,
replaceSheet, replaceSheet,
applySignalIntent, applySignalIntent,
busyIntent,
} = useAssistantChat(); } = useAssistantChat();
/** /**
...@@ -660,7 +922,7 @@ export function AssistantChat({ ...@@ -660,7 +922,7 @@ export function AssistantChat({
* 换行键这种约定靠试一次就知道,⛔ 不值得用唯一一行提示位去讲。 * 换行键这种约定靠试一次就知道,⛔ 不值得用唯一一行提示位去讲。
*/ */
const placeholder = canDispatch const placeholder = canDispatch
? '让我出一批分配方案,或问某批分得怎么样…' ? '让我出一批分配方案,或问某批分得怎么样…'
: '问患者画像 / 事实 / 召回计划…'; : '问患者画像 / 事实 / 召回计划…';
/** /**
...@@ -674,13 +936,15 @@ export function AssistantChat({ ...@@ -674,13 +936,15 @@ export function AssistantChat({
requestId: string, requestId: string,
assignmentId: string, assignmentId: string,
summary: string, summary: string,
modelSummary?: string, modelFacts?: string,
) => { ) => {
// 🔴 曾经在这里把 `postConfirm`(补挂福利那条引导)挂进这张单的 signals ——
// 删于 2026-08-15:确认单里不做福利引导(见 assignment-confirm-sheet 的 confirm())。
settleSheet(requestId, assignmentId); settleSheet(requestId, assignmentId);
// ⭐ 第二个参数带完整批次 id —— 模型撤销时唯一的 id 来源(见 assignment-confirm-sheet) // ⭐ 第二个参数带完整批次 id —— 模型撤销时唯一的 id 来源(见 assignment-confirm-sheet)
// ⚠️ `asNewMessage`:主管点确认发生在模型发言之外,贴到上一条尾巴上会被当成上一段的续写, // ⚠️ `asNewMessage`:主管点确认发生在模型发言之外,贴到上一条尾巴上会被当成上一段的续写,
// 他根本不觉得助手说了话(实测反馈:"agent 没有输出引导")。自成一条才读得出来。 // 他根本不觉得助手说了话(实测反馈:"agent 没有输出引导")。自成一条才读得出来。
appendAssistantNote(summary, modelSummary, { asNewMessage: true }); appendAssistantNote(summary, modelFacts, { asNewMessage: true });
}; };
/** /**
* 助手改完确认单,界面把**实际结果**回一句进消息流。 * 助手改完确认单,界面把**实际结果**回一句进消息流。
...@@ -688,11 +952,60 @@ export function AssistantChat({ ...@@ -688,11 +952,60 @@ export function AssistantChat({
* ⭐ 理由同上:模型看不到卡片。它只知道"指令已交给界面",成没成、有没有人没找到, * ⭐ 理由同上:模型看不到卡片。它只知道"指令已交给界面",成没成、有没有人没找到,
* 全靠这一句。不注入的话它下一轮就会自信地说"已经移除了",而那个人可能还在。 * 全靠这一句。不注入的话它下一轮就会自信地说"已经移除了",而那个人可能还在。
*/ */
const onSheetEdited = (summary: string) => appendAssistantNote(summary); const onSheetEdited = (summary: string, modelText?: string) =>
appendAssistantNote(summary, modelText);
/** 「重新排一版」回来 → 整块换掉那张卡(人群没变、人换了) */ /** 「重新排一版」回来 → 整块换掉那张卡(人群没变、人换了) */
const onSheetReplaced = (requestId: string, next: AssignmentProposal) => const onSheetReplaced = (requestId: string, next: AssignmentProposal) =>
replaceSheet(requestId, next); replaceSheet(requestId, next);
/**
* requestId → 主语还在的引导节点 key,由确认单上报(见它的 liveSignals)。
* ⚠️ 只在**内容真变了**时才 setState —— 上报走的是 effect,无脑写会自激成循环。
*/
const [liveSignalKeys, setLiveSignalKeys] = useState<Record<string, string[]>>({});
const onLiveSignalsChange = useCallback((requestId: string, keys: string[]) => {
setLiveSignalKeys((prev) => {
const cur = prev[requestId];
if (cur && cur.length === keys.length && cur.every((k, i) => k === keys[i])) return prev;
return { ...prev, [requestId]: keys };
});
}, []);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
/**
* 引导节点的兜底 —— 把一句**开头**填进输入框并聚焦,⛔ 不直接发送。
*
* ⭐ 为什么不直接发:那句里带着「关于「XXX」——」的上下文前缀,
* 主管**得看得见**它会替自己说什么,不想要能自己删。⛔ 界面不替他说他看不见的话。
* ⚠️ 光标要落到**末尾**,否则他一开口就打在前缀中间。
* ⚠️ 高度要跟着重算 —— 和 textarea 的 onChange 用同一套(`min(scrollHeight,140)`),
* ⛔ 别只 setInput:填一句长的进去,框还是一行高,后半截看不见。
*/
const composeDraft = useCallback((draft: string) => {
// ⚠️ 先**同步**聚焦:在用户手势的调用栈里 focus 最可靠,⛔ 别都推迟到回调里。
taRef.current?.focus();
/**
* 空串 = **只聚焦**,到此为止。
* ⛔ 别 setInput(''):会清掉主管已经打了一半的字。
* ⛔ 也别去动光标和高度:他没让你改内容,你就不该动他的输入框 ——
* 实测这里重算过高度,结果留下一个 `height:140px` 的残留内联样式,
* 框空着却占了五行高。
*/
if (!draft) return;
setInput(draft);
/**
* ⚠️ ⛔ 别用 `requestAnimationFrame`:窗口没聚焦 / 标签页在后台时 rAF 会被暂停,
* 焦点和光标就都不会落上(实测:`document.hasFocus()` 为 false 时整个回调不执行)。
* `setTimeout(0)` 在同样情况下照常跑。
*/
setTimeout(() => {
const ta = taRef.current;
if (!ta) return;
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 140) + 'px';
}, 0);
}, []);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const taRef = useRef<HTMLTextAreaElement>(null); const taRef = useRef<HTMLTextAreaElement>(null);
// 是否"贴底":用户在底部附近时跟随自动滚;一旦上滚阅读则停,直到滚回底部。 // 是否"贴底":用户在底部附近时跟随自动滚;一旦上滚阅读则停,直到滚回底部。
...@@ -756,6 +1069,14 @@ export function AssistantChat({ ...@@ -756,6 +1069,14 @@ export function AssistantChat({
// dictation:partial = 当前句滚动识别(覆盖式);dictation:final = 本句定稿(累加) // dictation:partial = 当前句滚动识别(覆盖式);dictation:final = 本句定稿(累加)
const [voice, setVoice] = useState<'idle' | 'live'>('idle'); const [voice, setVoice] = useState<'idle' | 'live'>('idle');
const [voiceErr, setVoiceErr] = useState<string | null>(null); const [voiceErr, setVoiceErr] = useState<string | null>(null);
/**
* 思考与工具调用**展不展开**(2026-08-15 产品加)。
*
* ⚠️ 默认展开:它是「模型到底调没调工具」的可核查记录,这条生产线最贵的一类问题
* 就靠它从"推理"变成"看一眼"。收起是主管当下不想看,⛔ 不是默认不给。
* ⚠️ 只活在这个组件里,刷新回到默认 —— 它是一次阅读时的取舍,⛔ 不是账号设置。
*/
const [showTrace, setShowTrace] = useState(true);
const dictRef = useRef<{ const dictRef = useRef<{
socket: Socket; socket: Socket;
stream: MediaStream; stream: MediaStream;
...@@ -847,34 +1168,34 @@ export function AssistantChat({ ...@@ -847,34 +1168,34 @@ export function AssistantChat({
const isWidget = variant === 'widget'; const isWidget = variant === 'widget';
return ( return (
<div className={cn('flex flex-col bg-slate-50', isWidget ? 'h-full' : 'h-[100dvh]')}> // ⭐ 白底(2026-08-15 对齐 Claude Code):会话区不是"一块卡片",是一页纸。
{/* Header */} // ⚠️ Composer 用的是 `bg-inherit`,跟着这里走 —— ⛔ 别在那边再写死一次底色。
<header className="flex-none border-b bg-white/90 backdrop-blur"> <div className={cn('flex flex-col bg-white', isWidget ? 'h-full' : 'h-[100dvh]')}>
{/*
Header —— **无边框、无底色、只剩小图标**(2026-08-15 对齐 Claude Code)。
🔴 去掉的三样:分隔线、`backdrop-blur` 的半透明白、以及左边那个实色产品图标。
它们合起来把头部做成了"另一块面板",而这一屏只该有一块 —— 内容本身。
⚠️ 标题降到次要色:这一行不是内容,是路标。⛔ 别加粗、⛔ 别用主色。
⚠️ 模型下拉**挪到底部工具栏**:它是"这一次要用什么发",属于发送前的决定,
跟输入框在一起才顺手;摆在头部时它是整屏视觉最重的东西,而它一天点不到一次。
*/}
<header className="flex-none">
<div <div
className={cn( className={cn(
'mx-auto flex items-center gap-2.5', 'mx-auto flex items-center gap-2',
isWidget ? 'h-10 px-2.5' : 'h-[3.25rem] max-w-3xl px-4 py-2.5', isWidget ? 'h-10 px-3' : 'h-12 max-w-3xl px-4',
)} )}
> >
<span <h1 className="text-[13px] font-medium text-slate-600">
className={cn(
'inline-flex flex-none items-center justify-center rounded-lg bg-brand-600 text-white',
isWidget ? 'h-6 w-6' : 'h-7 w-7',
)}
>
<Bot className={isWidget ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
</span>
<h1 className={cn('font-semibold text-slate-900', isWidget ? 'text-[13px]' : 'text-[14px]')}>
{isWidget ? '助手' : '外部助手'} {isWidget ? '助手' : '外部助手'}
</h1> </h1>
<div className="ml-auto flex items-center gap-1.5"> <div className="ml-auto flex items-center gap-0.5">
<ModelSelect model={model} setModel={setModel} disabled={status === 'streaming'} />
{onToggleMaximize && ( {onToggleMaximize && (
<button <button
type="button" type="button"
onClick={onToggleMaximize} onClick={onToggleMaximize}
title={maximized ? '还原' : '最大化'} title={maximized ? '还原' : '最大化'}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-slate-400 hover:bg-slate-100 hover:text-slate-600" className={ICON_BTN}
> >
{maximized ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />} {maximized ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</button> </button>
...@@ -884,7 +1205,7 @@ export function AssistantChat({ ...@@ -884,7 +1205,7 @@ export function AssistantChat({
type="button" type="button"
onClick={onClose} onClick={onClose}
title="收起" title="收起"
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-slate-400 hover:bg-slate-100 hover:text-slate-600" className={ICON_BTN}
> >
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</button> </button>
...@@ -898,11 +1219,15 @@ export function AssistantChat({ ...@@ -898,11 +1219,15 @@ export function AssistantChat({
<div className="mx-auto max-w-3xl space-y-5 px-4 py-5"> <div className="mx-auto max-w-3xl space-y-5 px-4 py-5">
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className={cn('flex flex-col items-center justify-center gap-4 text-center', isWidget ? 'py-8' : 'py-20')}> <div className={cn('flex flex-col items-center justify-center gap-4 text-center', isWidget ? 'py-8' : 'py-20')}>
<span className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-600 text-white"> {/* ⛔ 这里原来摆着一个 48px 的实色圆角方块 + 机器人图标 ——
<Bot className="h-6 w-6" /> 空态是这一屏唯一没有内容的时刻,而它把最重的视觉给了一个装饰。
</span> ⇒ 只留一句话和几个例子,点哪个都能直接开始。 */}
{/* ⛔ 这句话对**两种登录人**都得成立,所以不提任何一条具体业务线。
⛔ 也别写回「问我关于患者的问题,我会查询 PAC 实时数据」——
「患者」把它窄成单人查询,而主管这一屏四条例句没有一条是问患者的;
「实时」是假话,患者事实来自 DW,每天 08:00(沪)才落库。 */}
<div className="max-w-md text-[13.5px] text-slate-500"> <div className="max-w-md text-[13.5px] text-slate-500">
问我关于患者的问题,我会查询 PAC 实时数据。 想问什么、想让我做什么,直接说;也可以点下面一条开始。
</div> </div>
<div className="flex flex-wrap justify-center gap-1.5"> <div className="flex flex-wrap justify-center gap-1.5">
{(examples ?? (canDispatch ? EXAMPLES_LEADER : EXAMPLES_STAFF)).map((ex) => ( {(examples ?? (canDispatch ? EXAMPLES_LEADER : EXAMPLES_STAFF)).map((ex) => (
...@@ -918,15 +1243,30 @@ export function AssistantChat({ ...@@ -918,15 +1243,30 @@ export function AssistantChat({
</div> </div>
</div> </div>
) : ( ) : (
messages.map((m, i) => ( /**
* 🔴 **作废那一版整条不显示**(2026-08-13 产品定,推翻此前"作废的留在对话里
* 并显式标注"那条)。理由:它一个字都没落库,人数、分给谁全是废数,
* 而它在对话里占着大半屏 —— 主管往回翻时读到的是一份不存在的方案。
*
* ⚠️ 「他要能看见自己出过几版」这个诉求由**他自己那两句话**保住了:
* 「出一份分配方案」→「每人每天按 20 通算,重新排一版」,一眼看得出重排过。
* ⚠️ 判据与上下文回传**共用** `isDiscardedDraft` ——
* ⛔ 别在这里另写一份"哪种算作废",界面藏了而上下文没清是最难查的那种不一致。
*/
messages.filter((m) => !isDiscardedDraft(m)).map((m, i, shown) => (
<MessageView <MessageView
key={m.id} key={m.id}
message={m} message={m}
streaming={status === 'streaming' && i === messages.length - 1 && m.role === 'assistant'} streaming={status === 'streaming' && i === shown.length - 1 && m.role === 'assistant'}
onSheetConfirmed={onSheetConfirmed} onSheetConfirmed={onSheetConfirmed}
onSheetEdited={onSheetEdited} onSheetEdited={onSheetEdited}
onSheetReplaced={onSheetReplaced} onSheetReplaced={onSheetReplaced}
onSignalIntent={applySignalIntent} onSignalIntent={applySignalIntent}
onSignalCompose={composeDraft}
liveSignalKeys={liveSignalKeys}
onLiveSignalsChange={onLiveSignalsChange}
busyIntent={busyIntent}
showTrace={showTrace}
/> />
)) ))
)} )}
...@@ -971,21 +1311,6 @@ export function AssistantChat({ ...@@ -971,21 +1311,6 @@ export function AssistantChat({
placeholder={placeholder} placeholder={placeholder}
className="max-h-[140px] flex-1 resize-none bg-transparent py-1 text-[13.5px] leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none" className="max-h-[140px] flex-1 resize-none bg-transparent py-1 text-[13.5px] leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
/> />
{VOICE_INPUT_ENABLED && (
<button
type="button"
onClick={toggleVoice}
title={voice === 'live' ? '结束听写' : '语音输入(边说边出字)'}
className={cn(
'mb-0.5 inline-flex h-8 w-8 flex-none items-center justify-center rounded-lg transition-colors',
voice === 'live'
? 'animate-pulse bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-300'
: 'bg-slate-100 text-slate-500 hover:bg-brand-50 hover:text-brand-700',
)}
>
<Mic className="h-4 w-4" />
</button>
)}
{status === 'streaming' ? ( {status === 'streaming' ? (
<button <button
type="button" type="button"
...@@ -1010,6 +1335,53 @@ export function AssistantChat({ ...@@ -1010,6 +1335,53 @@ export function AssistantChat({
</button> </button>
)} )}
</div> </div>
{/*
工具栏 —— 输入框**下面**一行,左边是输入方式、右边是"用什么发"
(2026-08-15 对齐 Claude Code)。
⚠️ 它整行都是次要色:这一行不该跟正在输入的那句话抢注意力。
⛔ 别把它塞回输入框里面:麦克风和模型是两件事,挤在一行里点错的是麦克风。
*/}
<div className="mt-1.5 flex h-7 items-center gap-1 px-0.5">
{VOICE_INPUT_ENABLED && (
<button
type="button"
onClick={toggleVoice}
title={voice === 'live' ? '结束听写' : '语音输入(边说边出字)'}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors',
// ⚠️ 录音中是**状态**,红 + 脉冲要保留 —— ⛔ 别为了"风格统一"抹平它
voice === 'live'
? 'animate-pulse text-rose-600'
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-600',
)}
>
<Mic className="h-4 w-4" />
</button>
)}
{/*
思考与工具调用的开关 —— ⚠️ 收起时**图标变实**(不是变淡):
「有东西被我收起来了」需要一直看得见,收起之后底部那行小结是唯一的痕迹。
⛔ 别用变淡表示"关":那读起来像"这个功能不可用"。
*/}
<button
type="button"
onClick={() => setShowTrace((v) => !v)}
title={showTrace ? '收起思考与工具调用' : '展开思考与工具调用'}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors',
showTrace
? 'text-slate-400 hover:bg-slate-100 hover:text-slate-600'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200',
)}
>
<Brain className="h-4 w-4" />
</button>
{voiceErr && <span className="text-[11.5px] text-rose-600">{voiceErr}</span>}
<div className="ml-auto">
<ModelSelect model={model} setModel={setModel} disabled={status === 'streaming'} />
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils'; ...@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
import { useHasPermission } from '@/hooks/use-permission'; import { useHasPermission } from '@/hooks/use-permission';
import { usePlanSyncStore } from '@/stores/plan-sync-store'; import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { useAssistantStore } from '@/stores/assistant-store'; import { useAssistantStore } from '@/stores/assistant-store';
import { EXAMPLES_LEADER } from './assistant-chat';
import { AssistantFab } from './assistant-fab'; import { AssistantFab } from './assistant-fab';
import { CohortStream } from './cohort-stream'; import { CohortStream } from './cohort-stream';
import { AssistantChat } from './assistant-chat'; import { AssistantChat } from './assistant-chat';
...@@ -73,11 +74,9 @@ export function AssistantWidget() { ...@@ -73,11 +74,9 @@ export function AssistantWidget() {
* 换个说法会把口径带偏(「已处理」≠ 谈成了,这条 note 服务端每次都在喊)。 * 换个说法会把口径带偏(「已处理」≠ 谈成了,这条 note 服务端每次都在喊)。
*/ */
const canDispatch = useHasPermission(Permission.PLAN_DISPATCH); const canDispatch = useHasPermission(Permission.PLAN_DISPATCH);
const leaderExamples = [ // ⭐ 唯一一份在 assistant-chat(2026-08-15 合并)—— 此前这里抄了一份逐字相同的,
'帮我给「种植 · 窗口内」这批患者出一份分配方案', // 而其中「窗口内」「成功/不成功」两处都是错的,两边各错一遍。
'帮我跟踪下我分配的任务有没什么问题', const leaderExamples = EXAMPLES_LEADER;
'最近一批分配出份报表:分配数、未动过、已处理、成功、超期,退回和不成功要带原因',
];
const examples = current?.patientName const examples = current?.patientName
? [ ? [
`${current.patientName}为什么被召回?帮我捋一下关键事实`, `${current.patientName}为什么被召回?帮我捋一下关键事实`,
...@@ -85,8 +84,12 @@ export function AssistantWidget() { ...@@ -85,8 +84,12 @@ export function AssistantWidget() {
...(canDispatch ? leaderExamples : ['这通电话怎么开口比较好?给我两句开场']), ...(canDispatch ? leaderExamples : ['这通电话怎么开口比较好?给我两句开场']),
'今日推荐:挑几个该优先跟进的患者,并说明理由', '今日推荐:挑几个该优先跟进的患者,并说明理由',
] ]
: canDispatch
? // ⚠️ 主管空态**只给主管的活**(2026-08-15):原来这里还挂着「今日推荐」
// 「现在召回池里优先级最高的是谁」两条 —— 那是客服打单的动线,
// 而「召回池」这个词他这一屏上根本没有(他看到的是「分一批新的」「我分的批次」)。
leaderExamples
: [ : [
...(canDispatch ? leaderExamples : []),
'今日推荐:挑几个该优先跟进的患者,并说明理由', '今日推荐:挑几个该优先跟进的患者,并说明理由',
'现在召回池里优先级最高的是谁?', '现在召回池里优先级最高的是谁?',
]; ];
......
...@@ -5,7 +5,9 @@ import { ...@@ -5,7 +5,9 @@ import {
findActiveSheet, findActiveSheet,
intentToPrompt, intentToPrompt,
intentToSheetOps, intentToSheetOps,
toApiMessage, isDiscardedDraft,
stripToolEcho,
toApiMessages,
type Block, type Block,
type ChatMessage, type ChatMessage,
type DraftState, type DraftState,
...@@ -127,8 +129,29 @@ describe('findActiveSheet', () => { ...@@ -127,8 +129,29 @@ describe('findActiveSheet', () => {
expect(at).toEqual({ messageIndex: 0, blockIndex: 0 }); expect(at).toEqual({ messageIndex: 0, blockIndex: 0 });
}); });
it('一张 active 都没有时返回 null(⛔ 不许退化成改最后一张)', () => { /**
* 🔴🔴 **2026-08-14 改判**:这条原来断言的是「一张 active 都没有 → null」,
* 而那正好把**确认之后补挂福利**整条路堵死了。
*
* 实测:确认完说「这批带上『老客户复查免挂号费』」——
* 助手调了 `edit_assignment_sheet`,回「福利已挂上,会进这批人此后生成的话术里」,
* 而 `plan_assignments.attributes` 是空的,接口一次都没被调过。
* 指令在这里被**静默丢弃**(连"没改成"都没有),模型只能按"我发出去了"往下说。
* ⇒ `confirmed` 必须能被选中;不能改的那些由卡片**如实拒绝**(它有 `if (assignmentId)` 那段),
* ⛔ 而不是在路由这一层假装没听见。
*/
it('🔴🔴 没有 active 时落到 confirmed —— 确认之后还能补挂福利', () => {
const ms = [msg('m1', ['r1', 'confirmed']), msg('m2', ['r2', 'superseded'])]; const ms = [msg('m1', ['r1', 'confirmed']), msg('m2', ['r2', 'superseded'])];
expect(findActiveSheet(ms)).toEqual({ messageIndex: 0, blockIndex: 0 });
});
it('⭐ active 永远压过 confirmed(⛔ 别把还能改的那张让给已经落库的)', () => {
const ms = [msg('m1', ['r1', 'confirmed']), msg('m2', ['r2', 'active'])];
expect(findActiveSheet(ms)).toEqual({ messageIndex: 1, blockIndex: 0 });
});
it('⛔ 作废/已撤销的一律不选 —— 改了什么都不会发生', () => {
const ms = [msg('m1', ['r1', 'superseded']), msg('m2', ['r2', 'cancelled'])];
expect(findActiveSheet(ms)).toBeNull(); expect(findActiveSheet(ms)).toBeNull();
}); });
...@@ -164,11 +187,15 @@ describe('引导节点 intent 路由', () => { ...@@ -164,11 +187,15 @@ describe('引导节点 intent 路由', () => {
}); });
it('🔴 换人群 / 换基数 → ⛔ 不能局部改单,必须走重跑', () => { it('🔴 换人群 / 换基数 → ⛔ 不能局部改单,必须走重跑', () => {
for (const i of ['cohort.narrow', 'cohort.widen', 'anchor.switch', 'batch_size.set', 'daily_rate.set']) { for (const i of ['cohort.narrow', 'basis.set']) {
expect(intentToSheetOps(i)).toBeNull(); expect(intentToSheetOps(i)).toBeNull();
} }
expect(intentToPrompt('cohort.widen')).toContain('重新排一版'); /**
expect(intentToPrompt('anchor.switch')).toContain('重新排一版'); * 🔴 `cohort.widen`(往后放一档 / 换个治疗项)连同它的节点 `short_supply`
* 删于 2026-08-14:换格子是主管在矩阵上一点的事,矩阵每格都写着人数,
* 而按钮不带数 ⇒ 严格弱于矩阵。⛔ 别加回来(这条断言就是拦它回来的)。
*/
expect(intentToPrompt('cohort.widen')).toBeNull();
// 🔴 替主管说的那句话里**不许出现内部码**(这句会原样进对话框) // 🔴 替主管说的那句话里**不许出现内部码**(这句会原样进对话框)
const narrow = intentToPrompt('cohort.narrow', { const narrow = intentToPrompt('cohort.narrow', {
personaTags: 'rfm:vip', personaTags: 'rfm:vip',
...@@ -182,11 +209,30 @@ describe('引导节点 intent 路由', () => { ...@@ -182,11 +209,30 @@ describe('引导节点 intent 路由', () => {
expect(intentToPrompt('cohort.narrow', { personaTags: 'rfm:vip' })).toBeNull(); expect(intentToPrompt('cohort.narrow', { personaTags: 'rfm:vip' })).toBeNull();
}); });
it('🔴 消费那一刀要把**门槛数字**说出来(模型据此填 minSpendYuan,单位元)', () => {
const t = intentToPrompt('cohort.narrow', {
minSpendYuan: 720,
labelZh: '消费高于这批平均 ¥720',
count: 312,
})!;
expect(t).toContain('720 元');
expect(t).toContain('312 人');
expect(t).toContain('重新排一版');
// ⛔ 千分位会被读成别的数 —— 这句是给模型读回一个整数用的
expect(t).not.toContain('8,');
});
it('⭐「按别的条件选」只问不排 —— ⛔ 不许顺手出一版新确认单', () => {
const t = intentToPrompt('cohort.attrs_ask')!;
expect(t).toContain('先别重新排');
expect(intentToSheetOps('cohort.attrs_ask')).toBeNull();
});
it('⛔ 两张表不能都认领同一个 intent(否则按钮行为取决于判断顺序)', () => { it('⛔ 两张表不能都认领同一个 intent(否则按钮行为取决于判断顺序)', () => {
const all = [ const all = [
'pending.to_owner', 'pending.spread', 'pending.remove', 'pending.refill', 'pending.to_owner', 'pending.spread', 'pending.remove', 'pending.refill',
'expiry.set', 'daily_rate.set', 'batch_size.set', 'expiry.set', 'basis.set',
'cohort.widen', 'cohort.narrow', 'anchor.switch', 'cohort.narrow', 'cohort.attrs_ask',
]; ];
for (const i of all) { for (const i of all) {
const ops = intentToSheetOps(i, { days: 5, personaTags: 'rfm:vip' }); const ops = intentToSheetOps(i, { days: 5, personaTags: 'rfm:vip' });
...@@ -207,68 +253,127 @@ describe('引导节点 intent 路由', () => { ...@@ -207,68 +253,127 @@ describe('引导节点 intent 路由', () => {
* 两个失败方向都很贵:全量带 → 确认单几百个 uuid 灌进上下文且诱导复述; * 两个失败方向都很贵:全量带 → 确认单几百个 uuid 灌进上下文且诱导复述;
* 一点不带 → 模型每轮从零开始,只能拿自己上一轮说过的话当依据(背句子的温床)。 * 一点不带 → 模型每轮从零开始,只能拿自己上一轮说过的话当依据(背句子的温床)。
*/ */
describe('toApiMessage · 分层回传', () => { /**
const tool = (over: Partial<ToolStep> = {}): Block => ({ * 🔴 模型把**工具记录**当成输出格式抄进正文 —— 实测两次,换形状没堵住:
kind: 'tool', * ① `〔调用 propose_assignment …〕`(中文书名号,混在中文里毫无违和感);
step: { id: 't', tool: 'get_cohort_attributes', args: { temperature: 'hot' }, status: 'done', result: '278 人', ...over }, * ② 换成 `<tool …>` 之后 **qwen3.8-max 照抄不误**,连工具返回里的
* 「⛔ 不要自己宣布已完成」这句内部叮嘱都一起贴给了主管。
* ⇒ 只能结构上剥:渲染前剥(主管看不到)+ 回传前剥(⛔ 别进历史反过来强化)。
*/
describe('stripToolEcho · 抄出来的工具记录', () => {
it('🔴🔴 成对标签连同里面的工具返回一起剥掉(内部叮嘱不许漏给主管)', () => {
const leaked =
'<tool name="edit_assignment_sheet" args="{}">已把 1 条修改交给界面执行。' +
'请等那句结果,⛔ 不要自己宣布已完成。</tool> 确认单已更新:已移除「闫保玉」。';
const out = stripToolEcho(leaked);
expect(out).not.toContain('<tool');
expect(out).not.toContain('不要自己宣布已完成');
// ⚠️ 真正要给主管看的那半句必须留着
expect(out).toContain('已移除「闫保玉」');
}); });
it('🔴 工具调用会回传(此前整块丢弃 —— 模型每轮从零开始)', () => { it('⭐ 自闭合的 sheet / artifact 标记也剥', () => {
const out = toApiMessage({ id: 'm', role: 'assistant', blocks: [tool()] })!; expect(stripToolEcho('<sheet state="当前有效" />好的')).toBe('好的');
expect(out.content).toContain('get_cohort_attributes'); expect(stripToolEcho('<artifact title="x" />好的')).toBe('好的');
expect(out.content).toContain('278 人');
}); });
it('🔴 按 blocks 原始顺序拼,⛔ 不把工具记录堆到末尾(先查什么再说什么本身是脉络)', () => { it('⭐ 流式到一半的半截标签也剥 —— 否则主管看着尖括号一个字一个字长出来', () => {
const out = toApiMessage({ expect(stripToolEcho('这批 495 人。<tool name="propo')).toBe('这批 495 人。');
id: 'm',
role: 'assistant',
blocks: [{ kind: 'text', text: '我先看看' }, tool(), { kind: 'text', text: '这批 278 人' }],
})!;
expect(out.content.indexOf('我先看看')).toBeLessThan(out.content.indexOf('get_cohort'));
expect(out.content.indexOf('get_cohort')).toBeLessThan(out.content.indexOf('这批 278 人'));
}); });
it('🔴 肥载荷会被截断(⛔ 不许把几百个 uuid 灌回上下文)', () => { it('⛔ 不误伤正文:没有这些标记时原样返回', () => {
const fat = JSON.stringify({ items: Array.from({ length: 400 }, (_, i) => `plan-uuid-${i}`) }); const t = '这批 495 人是从 1119 人里挑的,没被分过的排前面。';
const out = toApiMessage({ id: 'm', role: 'assistant', blocks: [tool({ result: fat })] })!; expect(stripToolEcho(t)).toBe(t);
expect(out.content).toContain('(略)'); });
expect(out.content.length).toBeLessThan(500); });
/**
* 🔴 位置标记不进历史 —— 2026-08-13 实测的回归。
*
* 主管点「只选…」重排,第二轮只跑了 1 步:`propose_assignment` 调了,
* `show_sheet` / `show_guidance` 一个都没调 —— 卡片掉到末尾、按钮全堆在卡上。
* 原因之一是上一轮的记录还在上下文里,「已插入选项按钮」被读成"已经摆过了"。
* ⚠️ 这条锁的是**别让位置标记回传**;⛔ 真做了事的工具必须留(否则模型会重复调)。
*
* 🔴 **2026-08-14 试过"留一个无参数无返回值的空壳"(`<按钮位置 />`),当场翻车。**
* 模型把标记当成一种**输出格式**,直接写给主管看,而一个工具都没调。
* ⇒ 任何进了历史的"形状"都可能被照写。⛔ 别再尝试任何文本形态的位置标记 ——
* 下面那条 `not.toContain` 就是拦这个的,⛔ 别把它改成 `toContain`。
*/
/**
* 🔬 裸模式的历史构造 —— 这几条锁的是**会让 provider 直接 400 的那些形状**。
*
* tool-call 没有配对的 tool-result 时,DeepSeek 会 400(官方 issue 有记)。
* ⇒ 配对、顺序、以及"半截调用整对丢掉"这三件必须有回归,⛔ 别等到实测那一轮才发现。
*/
describe('历史回传 · 结构化(唯一路径)', () => {
const step = (t: string, over: Partial<ToolStep> = {}): Block => ({
kind: 'tool',
step: { id: t, callId: `call_${t}`, tool: t, args: { 编号: 1 }, result: 'ok', status: 'done', ...over },
}); });
it('⛔ 失败只留错因,不带原始堆栈(脏东西进了历史就一直在)', () => { it('🔴 每个 tool-call 都有配对的 tool-result(缺一个就 400)', () => {
const out = toApiMessage({ const m: ChatMessage = {
id: 'm', id: 'a',
role: 'assistant', role: 'assistant',
blocks: [tool({ status: 'error', error: 'patient x 不在当前租户范围内\n at Foo.bar (a.ts:1)\n at Baz'.repeat(20) })], blocks: [{ kind: 'text', text: '这批人怎么来的' }, step('show_guidance'), step('show_sheet')],
})!; };
expect(out.content).toContain('失败:'); const out = toApiMessages(m);
expect(out.content).not.toContain('at Foo.bar'); expect(out.map((x) => x.role)).toEqual(['assistant', 'tool']);
const calls = (out[0] as { content: Array<{ type: string; toolCallId?: string }> }).content
.filter((p) => p.type === 'tool-call')
.map((p) => p.toolCallId);
const results = (out[1] as { content: Array<{ toolCallId: string }> }).content.map(
(p) => p.toolCallId,
);
expect(calls).toEqual(['call_show_guidance', 'call_show_sheet']);
expect(results).toEqual(calls);
}); });
it('⭐ modelText 优先 —— 界面显示短的,模型拿到带完整批次 id 的那份', () => { it('🔴 顺序照块的原始顺序 —— 「先说了什么、再调了什么」本身就是脉络', () => {
const out = toApiMessage({ const m: ChatMessage = {
id: 'm', id: 'a',
role: 'assistant', role: 'assistant',
blocks: [{ kind: 'text', text: '批次已确认', modelText: '批次已确认(id=abc-123)' }], blocks: [
})!; { kind: 'text', text: '第一段' },
expect(out.content).toContain('abc-123'); step('show_guidance'),
{ kind: 'text', text: '第二段' },
],
};
const parts = (toApiMessages(m)[0] as { content: Array<{ type: string }> }).content;
expect(parts.map((p) => p.type)).toEqual(['text', 'tool-call', 'text']);
}); });
it('⭐ 确认单只留一个状态锚,⛔ 不回传它的内容(事实在同轮的工具摘要里)', () => { it('⛔ 半截调用(running / 无 callId)整对丢掉,⛔ 不许只留一半', () => {
const out = toApiMessage({ const m: ChatMessage = {
id: 'm', id: 'a',
role: 'assistant', role: 'assistant',
blocks: [ blocks: [
{ kind: 'assignment_sheet', requestId: 'r1', sheet: sheet(1), state: 'superseded' }, { kind: 'text', text: '写了一半被打断' },
{ kind: 'assignment_sheet', requestId: 'r2', sheet: sheet(2), state: 'active' }, step('propose_assignment', { status: 'running' }),
step('show_sheet', { callId: undefined }),
], ],
})!; };
expect(out.content).toContain('已作废'); const out = toApiMessages(m);
expect(out.content).toContain('当前有效'); // 只剩文本 ⇒ 没有 tool 消息
expect(out).toHaveLength(1);
expect((out[0] as { content: Array<{ type: string }> }).content.map((p) => p.type)).toEqual([
'text',
]);
}); });
it('⭐ 全空的消息返回 null(⛔ 别往上下文里塞空壳)', () => { it('⚠️ 卡片 / artifact 块不回传 —— 模型当初就没从 API 收到过它们', () => {
expect(toApiMessage({ id: 'm', role: 'assistant', blocks: [] })).toBeNull(); const m: ChatMessage = {
id: 'a',
role: 'assistant',
blocks: [
{ kind: 'text', text: '正文' },
{ kind: 'assignment_sheet', requestId: 'r1', sheet: sheet(3), state: 'active' },
],
};
const parts = (toApiMessages(m)[0] as { content: Array<{ type: string }> }).content;
expect(parts).toHaveLength(1);
}); });
}); });
...@@ -18,7 +18,16 @@ export interface ToolStep { ...@@ -18,7 +18,16 @@ export interface ToolStep {
tool: string; tool: string;
args: unknown; args: unknown;
result?: unknown; result?: unknown;
status: 'running' | 'done' | 'error'; /**
* ⚠️ `aborted` 是**第四态**,⛔ 别并进 `error`(2026-08-15 对抗审查抓到)。
*
* 点「停止」或连接断掉时,服务端那次调用**可能已经跑完了** —— 我们只是不再听。
* 写成 `error` 就是替它宣布失败,而 `propose_assignment` 这类真写库的工具,
* 一句「失败」会让主管以为那批没出,转身又发一遍。
* ⚠️ 也⛔ 不能留在 `running`:那一行会永远写着「进行中…」,
* 而这一轮早就结束了(GeneratingHint 跟着 streaming 一起消失)。
*/
status: 'running' | 'done' | 'error' | 'aborted';
error?: string; error?: string;
} }
...@@ -37,8 +46,11 @@ export interface Artifact { ...@@ -37,8 +46,11 @@ export interface Artifact {
* ⭐ `superseded` = 主管让助手重出了一版,这张当场作废。 * ⭐ `superseded` = 主管让助手重出了一版,这张当场作废。
* 🔴 作废发生在**新版生成时**,⛔ 不是在确认时(产品定,2026-08-12): * 🔴 作废发生在**新版生成时**,⛔ 不是在确认时(产品定,2026-08-12):
* 否则主管面前会同时摆着两张都能点确认的卡,点错就是分错一批人。 * 否则主管面前会同时摆着两张都能点确认的卡,点错就是分错一批人。
* ⚠️ 作废的卡**继续留在对话里并显式标注**,⛔ 不许静默变灰、⛔ 不许消失 —— * ⚠️ 2026-08-13 改判(产品定,推翻此前"作废的留在对话里并显式标注"):
* 他要能看见自己出过几版、以及哪一版才是当前的。 * **作废那一版整条不再显示**(见 isDiscardedDraft)。它一个字都没落库,
* 人数、分给谁全是废数,却在对话里占着大半屏 —— 往回翻读到的是一份不存在的方案。
* 「他要能看见自己出过几版」由**主管自己那两句话**保住:
* 「出一份分配方案」→「每人每天按 20 通算,重新排一版」,一眼看得出重排过。
* ⚠️ `confirmed` / `cancelled` 是**终态**,⛔ 不许被后续的作废扫描回退。 * ⚠️ `confirmed` / `cancelled` 是**终态**,⛔ 不许被后续的作废扫描回退。
*/ */
export type DraftState = 'active' | 'superseded' | 'confirmed' | 'cancelled'; export type DraftState = 'active' | 'superseded' | 'confirmed' | 'cancelled';
...@@ -54,9 +66,41 @@ export type Block = ...@@ -54,9 +66,41 @@ export type Block =
* 主管看不见的话就无法纠正,那是"界面替主管说话"。 * 主管看不见的话就无法纠正,那是"界面替主管说话"。
*/ */
| { kind: 'text'; text: string; modelText?: string } | { kind: 'text'; text: string; modelText?: string }
/**
* 模型的**思考**(reasoning)—— 跟正文走同一条流,按到达顺序穿插。
*
* ⭐ 它回答的是「为什么这么调工具」。2026-08-14 实测:模型在思考里写下
* 「注意:show_guidance 一次只开放一件」,那是**读了我们工具描述之后**的结论 ——
* 没有这块,归因只能靠猜,而猜的方向大概率是"模型不行"。
* ⚠️ 渲染上默认收起(几百上千字),⛔ 但位置不许挪:它落在哪一步之前,
* 本身就是信息(写正文前想的?调工具前想的?拿到结果之后想的?)。
* ⚠️ ⛔ 不进历史回传的正文 —— 它由 `messages` 那条结构化通道原样带回(见 toApiMessages)。
*/
| { kind: 'reasoning'; text: string }
| { kind: 'tool'; step: ToolStep } | { kind: 'tool'; step: ToolStep }
| { kind: 'artifact'; artifact: Artifact } | { kind: 'artifact'; artifact: Artifact }
/** /**
* 引导节点的**选项按钮**,位置由模型决定(`show_guidance` 工具)。
*
* ⭐ 模型写完「389 人卡在待分配…」那一条,紧接着调一次工具 → 这个块就插在
* 消息流的**此刻这个位置**,按钮正好落在它的解释下面。
* ⚠️ 和 artifact 同一个模式:工具调用即"在流里插一个界面块"。
* ⚠️ 只存 `signalKey`,⛔ 不存整个 signal —— 确认单可能被重排替换(refill),
* 届时按 key 从**当前**那张单上取,取不到就不渲染(那条已经不存在了)。
* 🔴 漏调不丢东西:没被插进正文的 action 节点仍旧显示在确认单卡片上(卡片按
* 已插入的 key 去重)。⇒ 这个块优化的是**位置**,⛔ 不是可见性。
*/
| { kind: 'guidance'; signalKey: string }
/**
* 确认单卡片**摆在这** —— 位置由模型决定(`show_sheet` 工具)。
*
* ⚠️ 它只是个**位置标记**,自己不渲染任何东西:真正的卡片是同一条消息里那个
* `assignment_sheet` 块,渲染时被搬到这个标记的位置。
* 🔴 没有这个标记时卡片仍旧钉在消息末尾(今天的行为)—— ⛔ 漏调不会让卡片消失。
* 它是整条链上唯一会写库的东西,位置可以让模型定,**存在与否不行**。
*/
| { kind: 'sheet_anchor' }
/**
* 全景确认单 —— **原生 React 组件**,不是 artifact。 * 全景确认单 —— **原生 React 组件**,不是 artifact。
* *
* ⚠️ 为什么不能用 artifact:artifact 跑在 `sandbox="allow-scripts"` 的 iframe 里, * ⚠️ 为什么不能用 artifact:artifact 跑在 `sandbox="allow-scripts"` 的 iframe 里,
...@@ -173,21 +217,36 @@ export function applyIncomingSheet( ...@@ -173,21 +217,36 @@ export function applyIncomingSheet(
} }
/** /**
* 找**当前有效**的那张确认单(唯一的 `active`)。 * 找**他现在说的那张**确认单 —— 助手的修改指令打到这张上。
* *
* ⭐ 2026-08-12 由「最后一张」改成「唯一 active」:重出一版之后,最后一张可能 * ⭐ 2026-08-12 由「最后一张」改成「唯一 active」:重出一版之后,最后一张可能
* 正是刚作废的那张 —— 助手的修改指令打上去,主管一点反应都看不到。 * 正是刚作废的那张 —— 助手的修改指令打上去,主管一点反应都看不到。
* ⚠️ 模型手里没有 requestId(服务端铸的,没进它上下文),它说「这批」指的就是当前有效那张。 * ⚠️ 模型手里没有 requestId(服务端铸的,没进它上下文),它说「这批」指的就是眼前那张。
*
* 🔴🔴 **2026-08-14 补 `confirmed`:只认 `active` 时,确认之后的补挂福利整条路是死的。**
* 实测:确认完说「这批带上『老客户复查免挂号费』」——
* 助手调了 `edit_assignment_sheet`,然后回「福利已挂上,会进这批人此后生成的话术里」,
* 而 `plan_assignments.attributes` **是空的**,接口一次都没被调过。
* 原因就在这里:确认后那张单的 state 是 `confirmed`,`findActiveSheet` 返回 null,
* 指令被**静默丢弃** —— 连"没改成"这句都没有,于是模型只能按"我发出去了"往下说。
* ⚠️ 这条路是产品明确承诺的(提示词:「确认之后福利仍然可以补挂」),卡片里也早就写了
* `if (assignmentId)` 那段专门处理它 —— 缺的只是**没人把指令送到**。
*
* ⚠️ 优先级不能反:`active`(还能改的那张)永远压过 `confirmed`。
* ⛔ 也永远不要选 `superseded` / `cancelled`:那两张改了什么都不会发生。
*/ */
export function findActiveSheet( export function findActiveSheet(
messages: ChatMessage[], messages: ChatMessage[],
): { messageIndex: number; blockIndex: number } | null { ): { messageIndex: number; blockIndex: number } | null {
const pick = (want: 'active' | 'confirmed') => {
for (let mi = messages.length - 1; mi >= 0; mi--) { for (let mi = messages.length - 1; mi >= 0; mi--) {
const blocks = messages[mi]?.blocks ?? []; const blocks = messages[mi]?.blocks ?? [];
const bi = blocks.findIndex((b) => b.kind === 'assignment_sheet' && b.state === 'active'); const bi = blocks.findIndex((b) => b.kind === 'assignment_sheet' && b.state === want);
if (bi >= 0) return { messageIndex: mi, blockIndex: bi }; if (bi >= 0) return { messageIndex: mi, blockIndex: bi };
} }
return null; return null;
};
return pick('active') ?? pick('confirmed');
} }
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
...@@ -241,20 +300,49 @@ export function intentToPrompt( ...@@ -241,20 +300,49 @@ export function intentToPrompt(
case 'cohort.narrow': { case 'cohort.narrow': {
// 🔴 用**中文名 + 人数**,⛔ 不用内部筛选串:这句会原样出现在对话框里, // 🔴 用**中文名 + 人数**,⛔ 不用内部筛选串:这句会原样出现在对话框里,
// 主管看到 `referral_champion:yes` 的第一反应是系统坏了。 // 主管看到 `referral_champion:yes` 的第一反应是系统坏了。
// 人数带上,是因为「圈完还剩多少」正是他点这一下想知道的事。 // 人数带上,是因为「选完还剩多少」正是他点这一下想知道的事。
const zh = typeof args?.labelZh === 'string' ? args.labelZh : null; const zh = typeof args?.labelZh === 'string' ? args.labelZh : null;
if (!zh) return null; if (!zh) return null;
const n = typeof args?.count === 'number' ? `那 ${args.count} 人` : '这些人'; const n = typeof args?.count === 'number' ? `那 ${args.count} 人` : '这些人';
return `这批只圈「${zh}」${n},重新排一版`; /**
* 消费那一刀要**把门槛的数字说出来**(`propose_assignment.minSpendYuan` 收的就是元)。
*
* 🔴 只说「只选消费高的那 312 人」不行:模型没有那个数就只能自己拍一个,
* 拍出来的批次和按钮上写的 312 人对不上,而且不报错。⇒ 数字进话里。
* ⚠️ 单位是**元**,与工具参数逐字一致;⛔ 别在这里换成「万」或加千分位符号 ——
* 模型要把它读回一个数,`8,200` 比 `8200` 更容易读成 8。
*/
const yuan = typeof args?.minSpendYuan === 'number' ? args.minSpendYuan : null;
if (yuan != null) return `这批只选累计消费高于 ${yuan} 元的${n},重新排一版`;
return `这批只选「${zh}」${n},重新排一版`;
} }
case 'cohort.widen': /**
return '往后放一档再看看,或者不限时间档,重新排一版'; * 「按别的条件选」—— ⛔ 不改任何东西,只是把「先看看还有哪些口子」做成一个按钮。
case 'anchor.switch': * ⚠️ 措辞里要写明**别急着重排**:不然模型会顺手出一版新确认单,
return '换成按诊断距今的口径,重新排一版'; * 而主管这一下问的是"有哪些选择",不是"照某个条件重来"。
case 'batch_size.set': */
return '这批人数减少一些,重新排一版'; case 'cohort.attrs_ask':
case 'daily_rate.set': return '这批人还能按哪些条件筛,各有多少人?先说说有哪些口子,先别重新排';
return '每人每天按更少的通数算,重新排一版'; // 🔴 `cohort.widen`(「往后放一档 / 换个治疗项」)连同它的节点 `short_supply`
// 删于 2026-08-14:换格子是主管在矩阵上一点的事,而矩阵每格都写着人数,
// 卡片上的按钮反而不带数 ⇒ 严格弱于矩阵。⛔ 别加回来。
/**
* 「本批多大」那个式子改了一项 → **重跑算法**(N = 在岗 × 每天几通 × 时效)。
* ⭐ 带上主管填的那个数:propose_assignment 有 `expiresInDays` / `dailyCalls` 两个参数,
* **服务端**据此重估 N。⛔ 别让模型自己去乘 —— 报出去的数必须是工具算的。
* ⚠️ ⛔ 别退化成 `expiry.set`:那个只改到期日,人数一个不变,而节点原话说会变。
*/
case 'basis.set': {
if (typeof args?.days === 'number') return `时效改成 ${args.days} 天,重新排一版`;
if (typeof args?.rate === 'number') return `每人每天按 ${args.rate} 通算,重新排一版`;
return null;
}
/**
* 🔴 `batch_size.set` / `daily_rate.set` **删于 2026-08-15**(连同发出它们的两颗按钮)。
* 它们替主管说的是「这批人数**减少一些**」「按**更少的**通数算」—— 一个数都没有,
* 模型只能自己拍。带数的那条走 `basis.set`(输入框预填当前值)。
* ⛔ 别加回来:判据与上面 `cohort.widen` 那条相同。
*/
default: default:
return null; return null;
} }
...@@ -264,77 +352,152 @@ export function intentToPrompt( ...@@ -264,77 +352,152 @@ export function intentToPrompt(
// 历史回传(分层) // 历史回传(分层)
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
/** 单条工具记录回传时的长度上限 —— 超出即截断并标注。 */
const HISTORY_CAP = { args: 120, result: 220 } as const;
/** /**
* 只取错误的**第一行、且切掉堆栈**。 * 剥掉模型**照抄出来的工具记录**。
*
* 🔴 这些标记(`<tool …>` / `<sheet …/>` / `<artifact …/>`)是**旧的拍平版历史**造给
* 模型自己看的结构,⛔ 从来不是输出格式。可模型一再把它们当格式抄进正文:
* · 老格式 `〔调用 propose_assignment …〕` —— 中文书名号,混在中文里毫无违和感;
* · 换成 XML 之后 **qwen3.8-max 照抄不误**,连工具返回里的
* 「⛔ 不要自己宣布已完成」这句内部叮嘱都一起贴给了主管。
* ⇒ 换形状堵不住。2026-08-14 历史改成结构化之后**源头没了**(模型再也见不到这种文本),
* 这里作为**显示层保险**保留:万一它凭训练记忆写出来,主管也不该看到。
* ⚠️ 回传那一侧不用剥了 —— 结构化历史里正文就是正文。
* *
* ⚠️ ⛔ 光靠长度截不住:堆栈常常就在前 120 字里(实测本模块的回归就是这么红的)。 * ⚠️ 最后一条正则管**流式到一半**的半截标签(`<tool name="propo`)——
* 要按**结构**剥 —— 换行之后、` at ` 之后的都是给开发者看的,进历史纯属污染。 * 不剥的话主管会看着一串尖括号一个字一个字长出来。
* ⚠️ 这几个标签名是我们自己造的,模型写出来**一定**是照抄,⛔ 不存在误伤正文的情况。
*/ */
function errorReason(raw: string | undefined): string { export function stripToolEcho(text: string): string {
const first = (raw ?? '未知错误').split('\n')[0] ?? '未知错误'; return text
const cut = first.split(/\s+at\s+/)[0] ?? first; .replace(/<tool\b[^>]*>[\s\S]*?<\/tool>/g, '')
return cut.trim() || '未知错误'; .replace(/<(?:tool|sheet|artifact)\b[^>]*\/?>/g, '')
} .replace(/<(?:tool|sheet|artifact)\b[^>]*$/, '')
.replace(/[ \t]{2,}/g, ' ')
function clip(s: string, max: number): string { .trim();
const t = s.replace(/\s+/g, ' ').trim();
return t.length <= max ? t : `${t.slice(0, max)}…(略)`;
} }
/** /**
* 一次工具调用 → 一行**摘要**。 * 这条助手消息是不是「**只剩一版已作废的确认单**」—— 即整条已经是废纸。
*
* 🔴 **界面和上下文共用这一个判据**(2026-08-13)。两边各写一份必然漂:
* 界面上藏了、上下文里还留着,等于主管看不见但模型还在拿它当依据 —— 最难查的那种。
* ⚠️ 判据共用,**处置略有不同**:界面整条不渲染;上下文留一行「已作废」说明
* (见 toApiMessage)—— 那一行主管看不见,作用是别让「重新排一版」在模型眼里没了指代。
* *
* ⚠️ 摘要不是省钱,是**保留脉络又不灌垃圾**: * ⚠️ 判据是「**只剩**作废的」,⛔ 不是「有作废的」:同一轮里模型可能出过两版
* · 全量回传 → 确认单那类载荷几百个 uuid,贵且诱导模型去复述; * (`applyIncomingSheet` 把旧的**就地**扫成作废、新的追加到同一条消息)。
* · 一点不传 → 模型下一轮不知道自己查过什么,会重复调用, * 那时这条消息里还有活着的那版,整条藏掉会把有效的一起抹了。
* 而且只能拿**自己上一轮说过的话**当唯一依据 —— 那正是"背出成品句子"的温床。 * ⚠️ 只认 `superseded`(重排顶掉的、一条都没落库)。
* ⚠️ 失败只留错因,⛔ 不带原始堆栈(脏东西进了历史就一直在,除非压缩)。 * ⛔ `cancelled`(已确认后又撤销)与 `confirmed` 都是真发生过的事,不能藏也不能清。
*/ */
export function summarizeToolStep(step: ToolStep): string { export function isDiscardedDraft(m: ChatMessage): boolean {
const args = step.args == null ? '' : clip(JSON.stringify(step.args), HISTORY_CAP.args); const sheets = m.blocks.filter((b) => b.kind === 'assignment_sheet');
const head = `〔调用 ${step.tool}${args && args !== '{}' ? ` ${args}` : ''}`; return sheets.length > 0 && sheets.every((b) => b.state === 'superseded');
if (step.status === 'error') return `${head} → 失败:${clip(errorReason(step.error), 120)}〕`;
if (step.status === 'running') return `${head} → 未完成〕`;
const raw = typeof step.result === 'string' ? step.result : JSON.stringify(step.result ?? '');
return `${head} → ${clip(raw, HISTORY_CAP.result)}〕`;
} }
/** 裸模式回传给后端的消息形状 —— 对齐 AI SDK 的 `ModelMessage`(后端原样转给 provider)。 */
export type ApiMessage =
| { role: 'user'; content: string }
| {
role: 'assistant';
content:
| string
| Array<
| { type: 'text'; text: string }
| { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
>;
}
| {
role: 'tool';
content: Array<{
type: 'tool-result';
toolCallId: string;
toolName: string;
output: { type: 'text'; value: string };
}>;
};
/** /**
* 把一条消息压平成后端要的 `{role, content}`。 * 历史构造 —— 一条界面消息 → 0~2 条 API 消息(assistant + 它的 tool 结果)。
*
* ═══ 🔴 ⛔ 别改回"把历史拍平成文本" ═══════════════════════════════════
* 2026-08-14 之前这里是把整条消息拼成一个字符串(`<tool name="…">{…}</tool>` 混在正文里)。
* 那条路上踩过的三个坑,**换个形状都堵不住**,因为病根是"历史里有可模仿的文本":
* *
* 🔴 2026-08-12(P3):**工具记录现在也回传**(分层摘要)。 * ① 模型把工具记录**当成输出格式照抄给主管**。先是中文书名号
* 在此之前只回传文本块,注释写的是"工具块不回传,模型自行重新决策" —— * (`〔调用 propose_assignment …〕`,混在中文里毫无违和感),换成 XML 之后
* 代价是模型每一轮都从零开始:重复调同一个工具,且拿不到上一轮的任何锚。 * qwen3.8-max 照抄不误,连工具返回里的内部叮嘱都一起贴进了对话框。
* ⚠️ 按 blocks 的**原始顺序**拼,⛔ 别把工具记录都堆到末尾: * ⇒ 只能在渲染前后各剥一次(`stripToolEcho` 至今保留,作显示层保险)。
* 「先查了什么、再说了什么」本身就是脉络。 * ② 为了躲 ① 而**抹掉** `show_sheet` / `show_guidance` 的记录,副作用是模型
* 不知道自己上一轮摆过位置 —— 实测它对主管说「我只调了 2 个工具」,
* 而那一轮执行过程是 6 步。
* ③ 为了补 ② 而留一个**无参数无返回值的位置空壳**(`<按钮位置 />`),当场翻车:
* 模型把标记当成输出格式直接写给主管看,而**一个工具都没调**。
*
* ⇒ 结构化之后这三个坑一起消失:工具调用是模型真正认得的 `tool-call` / `tool-result`,
* 历史里**没有任何文本可供模仿**,也不需要抹掉任何东西。
* ⚠️ 代价是**完整回传**:作废那一版的正文和它的工具返回都还在上下文里
* (拍平版当年会把整条换成一行「已作废」)。裸台第二轮实测没有引用旧数 ——
* 结构化的归属关系比一段文字清楚。⛔ 但这是要盯的一条:真出现引用旧数,
* 正解是**丢掉整条**(assistant + tool 一起丢,⛔ 别只丢一半,那会破坏配对)。
*
* ⚠️ 只回传**配得上对的**调用:`callId` 缺失或这一步还在 running(本轮被打断)时整对丢掉 ——
* tool-call 没有对应的 tool-result 会让 provider 直接 400(DeepSeek 有 issue 记着)。
* ⚠️ `assignment_sheet` / `artifact` 块**不回传**:它们是侧信道推给界面的渲染产物,
* 模型当初就没从 API 收到过它们 —— "模型看到什么,就还它什么"。
*/ */
export function toApiMessage( export function toApiMessages(m: ChatMessage): ApiMessage[] {
m: ChatMessage, if (m.role === 'user') {
): { role: 'user' | 'assistant'; content: string } | null { const text = m.blocks
const parts = m.blocks .map((b) => (b.kind === 'text' ? b.text : ''))
.map((b): string => {
switch (b.kind) {
// ⭐ modelText 优先 —— 界面显示"批次已确认",模型拿到的那份还带着完整 uuid
case 'text':
return b.modelText ?? b.text;
case 'tool':
return `\n${summarizeToolStep(b.step)}\n`;
// ⚠️ 卡片内容不回传:它是渲染产物,模型复述它没有意义(而且很长)
case 'artifact':
return `\n〔已渲染卡片${b.artifact.title ? `:${b.artifact.title}` : ''}〕\n`;
// ⚠️ 确认单的事实在同一轮的 tool 摘要里已经有了,这里只留一个"它存在"的锚
case 'assignment_sheet':
return `\n〔确认单 ${b.state === 'active' ? '当前有效' : b.state === 'confirmed' ? '已确认' : b.state === 'superseded' ? '已作废' : '已撤销'}〕\n`;
default:
return '';
}
})
.join('') .join('')
.replace(/\n{3,}/g, '\n\n')
.trim(); .trim();
if (!parts) return null; return text ? [{ role: 'user', content: text }] : [];
return { role: m.role, content: parts }; }
const parts: Array<
| { type: 'text'; text: string }
| { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
> = [];
const results: Array<{
type: 'tool-result';
toolCallId: string;
toolName: string;
output: { type: 'text'; value: string };
}> = [];
for (const b of m.blocks) {
if (b.kind === 'text') {
// ⚠️ 裸模式**不剥**工具回声:模型抄了什么就还它什么,那本身是要观察的现象之一
const t = (b.modelText ?? b.text).trim();
if (t) parts.push({ type: 'text', text: t });
continue;
}
if (b.kind !== 'tool') continue;
const { callId, tool, args, result, status, error } = b.step;
if (!callId || status === 'running') continue;
parts.push({ type: 'tool-call', toolCallId: callId, toolName: tool, input: args ?? {} });
results.push({
type: 'tool-result',
toolCallId: callId,
toolName: tool,
output: {
type: 'text',
value:
status === 'error'
? `工具失败:${error ?? '未知错误'}`
: typeof result === 'string'
? result
: JSON.stringify(result ?? ''),
},
});
}
if (parts.length === 0) return [];
const out: ApiMessage[] = [{ role: 'assistant', content: parts }];
if (results.length > 0) out.push({ role: 'tool', content: results });
return out;
} }
...@@ -25,12 +25,18 @@ export function ConfirmSheetSignals({ ...@@ -25,12 +25,18 @@ export function ConfirmSheetSignals({
readOnly, readOnly,
busyIntent, busyIntent,
onIntent, onIntent,
onCompose,
}: { }: {
signals: Signal[]; signals: Signal[];
readOnly: boolean; readOnly: boolean;
/** 正在执行的 intent(按钮转圈用);null = 空闲 */ /** 正在执行的 intent(按钮转圈用);null = 空闲 */
busyIntent?: string | null; busyIntent?: string | null;
onIntent: (intent: string, args?: Record<string, unknown>) => void; onIntent: (intent: string, args?: Record<string, unknown>) => void;
/**
* 兜底 —— 把一句**开头**填进输入框并聚焦,⛔ 不直接发送。
* ⚠️ `post_confirm` 那类节点唯一的动作就是它(福利原文只能是主管的话)。
*/
onCompose?: (draft: string) => void;
}) { }) {
const [infoOpen, setInfoOpen] = useState(false); const [infoOpen, setInfoOpen] = useState(false);
if (signals.length === 0) return null; if (signals.length === 0) return null;
...@@ -41,7 +47,14 @@ export function ConfirmSheetSignals({ ...@@ -41,7 +47,14 @@ export function ConfirmSheetSignals({
return ( return (
<div className="border-b bg-amber-50/40"> <div className="border-b bg-amber-50/40">
{action.map((s) => ( {action.map((s) => (
<Row key={s.key} signal={s} readOnly={readOnly} busyIntent={busyIntent} onIntent={onIntent} /> <Row
key={s.key}
signal={s}
readOnly={readOnly}
busyIntent={busyIntent}
onIntent={onIntent}
onCompose={onCompose}
/>
))} ))}
{/* {/*
...@@ -66,6 +79,7 @@ export function ConfirmSheetSignals({ ...@@ -66,6 +79,7 @@ export function ConfirmSheetSignals({
readOnly={readOnly} readOnly={readOnly}
busyIntent={busyIntent} busyIntent={busyIntent}
onIntent={onIntent} onIntent={onIntent}
onCompose={onCompose}
muted muted
/> />
))} ))}
...@@ -80,14 +94,23 @@ function Row({ ...@@ -80,14 +94,23 @@ function Row({
readOnly, readOnly,
busyIntent, busyIntent,
onIntent, onIntent,
onCompose,
muted, muted,
}: { }: {
signal: Signal; signal: Signal;
readOnly: boolean; readOnly: boolean;
busyIntent?: string | null; busyIntent?: string | null;
onIntent: (intent: string, args?: Record<string, unknown>) => void; onIntent: (intent: string, args?: Record<string, unknown>) => void;
onCompose?: (draft: string) => void;
muted?: boolean; muted?: boolean;
}) { }) {
/**
* ⭐ **`post_confirm` 那类在只读态下照样可点** —— 只读说的是"这批人和时效定死了",
* 而这类节点恰恰是**分下去之后仍然能做**的那几件(补挂福利只影响此后生成的话术)。
* ⛔ 跟着 readOnly 一起禁掉,就等于摆一句「这批还没带福利」然后什么也不让他做。
*/
const postConfirm = s.stage === 'post_confirm';
const canAct = !readOnly || postConfirm;
return ( return (
<div className={`px-3 py-2 ${muted ? 'pt-1.5' : 'border-b border-amber-100/70 last:border-b-0'}`}> <div className={`px-3 py-2 ${muted ? 'pt-1.5' : 'border-b border-amber-100/70 last:border-b-0'}`}>
{/* {/*
...@@ -101,9 +124,26 @@ function Row({ ...@@ -101,9 +124,26 @@ function Row({
<p className={muted ? 'text-[11.5px] text-slate-600' : 'text-[12px] font-medium text-slate-800'}> <p className={muted ? 'text-[11.5px] text-slate-600' : 'text-[12px] font-medium text-slate-800'}>
{s.title} {s.title}
</p> </p>
{/* 折叠区里的纯信息类没有对话在旁边解释,仍要给一句 */} {/*
{muted && <p className="mt-0.5 text-[11px] leading-relaxed text-slate-500">{s.why}</p>} 折叠区里的纯信息类没有对话在旁边解释,仍要给一句。
{!readOnly && s.options.length > 0 && ( ⭐ `post_confirm` 同理,而且更硬:点确认**不会触发模型发言**,
它上方那段话是确认之前说的,里面不可能有这条 —— 卡片不给,就没人给。
*/}
{(muted || postConfirm) && (
<p className="mt-0.5 text-[11px] leading-relaxed text-slate-500">{s.why}</p>
)}
{canAct && s.options.length === 0 && onCompose && (
<div className="mt-1.5">
<button
type="button"
onClick={() => onCompose(`关于「${s.title}」—— `)}
className="rounded border border-dashed border-amber-300 px-2 py-0.5 text-[11px] leading-5 text-amber-800 transition-colors hover:bg-amber-50"
>
我自己说
</button>
</div>
)}
{canAct && s.options.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1.5"> <div className="mt-1.5 flex flex-wrap gap-1.5">
{s.options.map((o) => { {s.options.map((o) => {
const busy = busyIntent === o.intent; const busy = busyIntent === o.intent;
......
'use client';
import { useState } from 'react';
import type { Signal, SignalOption } from '@pac/types';
/**
* 引导节点的可选项 —— **插在模型讲这件事的那句话正下方**。
*
* ═══ 位置由**模型**决定,⛔ 不由前端猜 ═══════════════════════════
* 一条引导节点 = 「一句解释 + 几个可选动作」。解释归模型讲(卡片不重复渲染),
* 于是按钮离得越远越难连起来:主管读到「389 人卡在待分配,不处理 = 这批不发给他们」,
* 要一路翻过画像亮点、翻过卡片汇总行,才看到那四个按钮。
*
* ⇒ 模型写完那一条 → 调 `show_guidance` → 消息流在**此刻这个位置**插一个
* `guidance` 块,本组件就渲染在那里。和 `render_artifact` 是同一个模式。
* ⛔ **前端曾经用 `text.indexOf(标题)` 去猜位置,已删** —— 模型换个措辞就匹配不上,
* 而匹配不上就渲染不出来(把选项静默藏起来,G9)。位置归知道自己写在哪的那一方。
* ⚠️ 模型漏调也不丢:没插进正文的节点仍旧显示在确认单卡片上(卡片按 placedKeys 去重)。
*
* ═══ 样式:克制 ═══════════════════════════════════════════════
* ⛔ 不要盒子、不要底色、不要重复标题 —— 标题就是它上面那句话,再写一遍等于读两遍。
* 只需要「看得出可点」:小号描边按钮一行,加一行更弱的自由输入。
*
* ⚠️ 判定不在这里 —— 该不该亮、亮几条、什么顺序全在服务端 `computeSignals`。
* 本组件是纯呈现 + 把 intent / 原话抛上去。
* ⚠️ **按钮优先于打字**(G10):点击是确定性的;打字要过「模型理解 → 翻成动作」,
* 每一环都可能错(实测栽过 owner/balance 选反,18 人被散给 17 位别人)。
* ⇒ 兜底是**同一排里更次要的一个按钮**(虚线框、灰字),⛔ 不做成和选项平级的输入框。
*/
/**
* 要填一个数的选项 —— 「改时效 [1] 天」「改每天几通 [15] 通」。
*
* ⭐ 为什么是输入框而不是几个固定档:天数和通数是**连续量**,列 3/5/7 天必然漏掉他要的那个,
* 而漏掉的那次他只能去下面的自由输入打字 → 走模型理解 → 慢且可能错。填数是确定性的。
* ⚠️ `min/max` 是护栏:越界直接不给提交,⛔ 别让它走到服务端才报错。
* ⚠️ 值没变就不提交 —— 「改时效」填回原来的 1 天等于什么都没做,
* 真发出去会白跑一次重排(`daily_rate.set` 那条要重跑算法)。
*/
function NumberOption({
option,
input,
disabled,
onIntent,
}: {
option: SignalOption;
input: NonNullable<SignalOption['input']>;
disabled?: boolean;
onIntent: (intent: string, args?: Record<string, unknown>) => void;
}) {
const [v, setV] = useState(String(input.default));
const n = Number(v);
const ok = Number.isInteger(n) && n >= input.min && n <= input.max && n !== input.default;
const submit = () => ok && onIntent(option.intent, { ...option.args, [input.argKey]: n });
return (
<span className="inline-flex items-center gap-1 rounded border border-slate-300 bg-white px-2 py-0.5 text-[11.5px] leading-5 text-slate-600">
{option.label}
<input
value={v}
onChange={(e) => setV(e.target.value.replace(/\D/g, '').slice(0, 3))}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
submit();
}
}}
disabled={disabled}
aria-label={`${option.label}(${input.min}-${input.max}${input.unit})`}
className="w-8 rounded bg-slate-100 px-1 text-center tabular-nums text-slate-800 outline-none focus:bg-white focus:ring-1 focus:ring-brand-400"
/>
{input.unit}
{/* ⚠️ 只在**改过且合法**时才出现 —— 一直挂着会让人以为不点也生效 */}
{ok && !disabled && (
<button
type="button"
onClick={submit}
className="ml-0.5 rounded px-1 font-medium text-brand-700 hover:bg-brand-50"
>
确定
</button>
)}
</span>
);
}
export function SignalPrompt({
signal: s,
busyIntent,
onIntent,
onCompose,
withTitle,
}: {
signal: Signal;
/** 正在执行的 intent(按钮转圈用);null = 空闲 */
busyIntent?: string | null;
onIntent: (intent: string, args?: Record<string, unknown>) => void;
/**
* 兜底 —— 把一句**开头**填进底部输入框并聚焦,⛔ 不直接发送。
*
* 🔴 前缀必须带上是哪一条:主管只打「给张悦」,模型不知道说的是待分配那批人。
* ⭐ 填进输入框而不是偷偷拼在发送时 —— 他**看得见**这句会带什么上下文,
* 不想要可以自己删。⛔ 界面不替主管说他看不见的话。
* ⚠️ 前缀用节点标题(他眼前就是这句),⛔ 不用 key / intent id:
* 那会原样出现在对话里,`pending.to_owner` 就是内部码泄漏。
*/
onCompose?: (draft: string) => void;
/**
* 自带标题 —— **上面没有解释它的那句话**时必须为 true。
*
* 🔴 正常情况下标题是多余的:模型刚讲完这条,按钮紧跟其后(`show_guidance`)。
* 但**模型不交错时**(实测 qwen 会把这一轮的 tool-call 全提到文本之前),
* 这些块被退化保护挪到正文之后堆成一摞 —— 那时一排光秃秃的按钮
* (「只圈这 2324 人」)根本看不出在说哪件事。
*/
withTitle?: boolean;
}) {
/**
* 一个动作都没有 = 没什么可点的,别摆一排空按钮。
* ⚠️ 但**"我自己说"也算动作** —— 有些节点唯一的处置就是他开口说一句
* (「这批带上…」那种,福利原文只能是他的话,⛔ 界面不给备选项:
* 列出来的几条会被当成"系统推荐的福利")。⇒ 有兜底就照样渲染。
*/
if (s.options.length === 0 && !onCompose) return null;
return (
// ⚠️ `not-prose`-ish:这块插在 markdown 里,字号/行高要自己钉死,
// ⛔ 别继承正文的 13.5px —— 继承了它就跟正文一样重,抢戏。
<div className="my-1.5 space-y-1">
{withTitle && <p className="text-[12px] font-medium text-slate-700">{s.title}</p>}
<div className="flex flex-wrap items-center gap-1.5">
{s.options.map((o) =>
o.input ? (
<NumberOption
key={`${o.intent}:${o.label}`}
option={o}
input={o.input}
disabled={busyIntent != null}
onIntent={onIntent}
/>
) : (
<button
key={`${o.intent}:${o.label}`}
type="button"
disabled={busyIntent != null}
onClick={() => onIntent(o.intent, o.args)}
className="rounded border border-slate-300 bg-white px-2 py-0.5 text-[11.5px] leading-5 text-slate-600 transition-colors hover:border-brand-400 hover:text-brand-700 disabled:opacity-50"
>
{busyIntent === o.intent ? '处理中…' : o.label}
</button>
),
)}
{/* 兜底:同一排,但**再弱一档** —— 虚线框 + 更浅的字,一眼看得出它不是正经选项 */}
{onCompose && (
<button
type="button"
onClick={() => onCompose(`关于「${s.title}」—— `)}
className="rounded border border-dashed border-slate-300 px-2 py-0.5 text-[11.5px] leading-5 text-slate-400 transition-colors hover:border-slate-400 hover:text-slate-600"
>
我自己说
</button>
)}
</div>
</div>
);
}
'use client';
import { useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import type { ToolStep } from './chat-blocks';
import { cn } from '@/lib/utils';
/**
* 执行过程 —— 这一轮**到底调了哪些工具**,可核查的原始记录。
*
* ═══ 为什么要有 ═══════════════════════════════════════════════════
* 🔴 「模型到底调没调工具」是这条生产线**最贵的一类问题**,而在此之前只能靠
* "界面上有没有出现卡片/按钮"去反推。2026-08-13 实测吃过一次:主管点了
* 「只圈电商平台那 251 人」,助手回了一整份确认单式的汇报
* (251 人 / 98 条落到 33 位客服 / 17 回专属 / 81 补空 / 153 待分配),
* 数字彼此自洽、读起来毫无破绽 —— 但 `propose_assignment` 一次都没跑,
* 整段是照着提示词里那个三段模板**填出来的**。
* 当时排查只能靠「卡片没出现 ⇒ 侧信道没推 ⇒ 工具没跑」这条推理链。
* ⇒ 把记录摆出来,这类问题从"推理"变成"看一眼"。
*
* ═══ 三条边界 ═══════════════════════════════════════════════════
* ⛔ **一步都没有时整块不渲染**(产品定)——「执行过程 · 0 步」对主管是噪音,
* 他不需要每条闲聊下面都挂一个空壳。⚠️ 但这也意味着"没调工具"本身**不会**
* 在这里显形,那件事另有防线(见 assistant-prompts 第 2 段与服务端收尾标记)。
* ⛔ **默认收起**:这是脚注不是内容。主管要读的是结论,证据放在他伸手够得到的地方就行。
* ⚠️ **摆在整条消息的最后**(正文 → 卡片 → 这里)。Claude Code 是按发生顺序摆在正文之前,
* 我们不行:qwen 会把一轮的 tool-call 全提到文本之前,照发生顺序摆等于每次都先给主管
* 看一屏工具记录再给结论。位置固定在末尾,"这轮跑了几步"也就永远在同一个地方。
*
* ⚠️ 这里**允许出现英文工具名**,与「⛔ 别把工具名说给主管」不冲突:
* 那条约束管的是**助手嘴里说的话**;这里是一份可核查的记录,语境不同
* (同 Claude Code 的 "Ran a command")。中文动作名在前、原始名在后且更淡。
*/
/**
* 工具 → 中文说法。**凡是注册进 MCP / 助手的工具都要在这里有一条。**
*
* ⚠️ 未映射时**显示原始英文名**,⛔ 不显示空白 —— 漏配一个要能一眼看见。
* (曾经漏过四个,兜底文案直接把 `get_cohort_attributes` 摆给了主管看。)
*/
const TOOL_META: Record<string, { label: string; running: string }> = {
find_patient: { label: '查找患者', running: '正在查找患者' },
get_patient_overview: { label: '读取患者概览', running: '正在读取患者概览' },
get_persona: { label: '读取患者画像', running: '正在读取患者画像' },
get_facts: { label: '读取关键事实', running: '正在读取关键事实' },
get_recall_plan: { label: '读取召回计划', running: '正在读取召回计划' },
list_recall_queue: { label: '检索召回队列', running: '正在检索召回队列' },
recall_queue_stats: { label: '统计召回池', running: '正在统计召回池' },
get_current_user: { label: '确认身份', running: '正在确认你的身份与权限' },
get_agents: { label: '查在岗客服', running: '正在查在岗客服与在手负载' },
propose_assignment: { label: '出分配方案', running: '正在挑人、排客服,出分配方案' },
list_assignment_batches: { label: '查历史批次', running: '正在查你分过的批次' },
get_assignment_detail: { label: '查批次详情', running: '正在查这批的执行情况' },
explain_assignment: { label: '解释为什么是他', running: '正在查这个人为什么被选进来' },
get_cohort_attributes: { label: '看这批人构成', running: '正在看这批人里各类有多少' },
edit_assignment_sheet: { label: '改确认单', running: '正在按你说的改确认单' },
// ⚠️ 查的是**眼前那张还没确认的**单子(草稿态,由浏览器随请求捎给服务端)——
// 与 `get_assignment_detail`(已经分下去的那批)⛔ 不是一回事,标签也别写像
get_current_sheet: { label: '看当前确认单', running: '正在看这张单现在什么样' },
revoke_assignment: { label: '撤销批次', running: '正在撤销这批' },
render_artifact: { label: '出图表', running: '正在画图' },
// 取一篇做法(pull 式加载,见服务端 assistant-playbooks):不查库不改数据
open_playbook: { label: '取做法', running: '正在取这类活的做法' },
// ── 纯位置标记,不查库不写库(见 assistant.service 的 place_*)──
show_sheet: { label: '摆确认单卡片', running: '正在摆确认单' },
show_guidance: { label: '摆选项按钮', running: '正在摆选项按钮' },
};
export function toolMeta(tool: string) {
return TOOL_META[tool] ?? { label: tool, running: `正在调用 ${tool}` };
}
/** 展示用的长度上限 —— ⚠️ 与历史回传那份(`HISTORY_CAP`)**不是同一件事**,别去对齐:
* 那边是省 token,这边是省版面,超出的部分靠滚动看,⛔ 不截断丢失。 */
// ⚠️ `max-h-56 overflow-auto` **不许动**:超出的部分靠滚动看,⛔ 不截断丢失。
// 2026-08-15 只改了外观:细边 + 更淡的底(对齐 Claude Code 那种"有框的浅盒")。
const BOX =
'max-h-56 overflow-auto rounded-md border border-slate-200/80 bg-slate-50/60 px-2.5 py-2';
const MONO = 'whitespace-pre-wrap break-all font-mono text-[10.5px] leading-[1.6] text-slate-600';
/** 参数短就一行、长就换行铺开 —— ⛔ 别一律 pretty-print,`{"编号":3}` 占三行纯属浪费。 */
function fmt(v: unknown): string {
if (v == null) return '';
const one = typeof v === 'string' ? v : JSON.stringify(v);
if (one.length <= 80) return one;
return typeof v === 'string' ? v : JSON.stringify(v, null, 2);
}
/**
* ⭐ **一条可折叠的轨迹行** —— 思考和工具调用共用的唯一原语。
*
* 为什么合成一个:它们在消息里**交错出现**,视觉必须同源,否则同一串时序读起来
* 像两套东西。⇒ 一行标签 + 可展开的正文;差别只在标签和展开的内容。
* ⚠️ 一律**默认收起**:这是脚注不是内容,主管要读的是结论。
* ⛔ 别给其中一种单独加边框/底色 —— 那会把"谁更重要"的暗示塞进纯记录里。
*/
function TraceRow({
label,
tag,
status,
children,
}: {
label: string;
/** 灰色小字:工具原始名 / 思考字数 —— 出问题时能拿去 grep 的那个词 */
tag?: string;
status?: 'error' | 'running' | 'aborted';
children?: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
// ⭐ **无分隔线**(2026-08-15):一串轨迹行本来就是一串,横线把它切成了好几块小卡片。
// ⚠️ 行与行靠**留白**分开,⛔ 别再加回 border-t。
<div>
<button
type="button"
onClick={() => setOpen((v) => !v)}
/**
* ⭐ **文字顶到最左,箭头跟在后面**(2026-08-15)。
*
* 箭头原来在最前面,把这一行整体往右推了 18px —— 于是每一行轨迹都比它下面的
* 正文缩进一截,读起来像是正文的子项,而它们本来是同一级的。
* ⚠️ `-mx-2 px-2`:视觉左沿落在 0(与正文对齐),而悬停底色仍向两侧各溢出 8px,
* 点击热区不至于紧贴文字。⛔ 别改成 `px-0`:那样悬停块会贴着字边,很局促。
*/
className="-mx-2 flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left transition-colors hover:bg-slate-50"
>
<span className="text-[12px] text-slate-500">{label}</span>
{tag && <span className="truncate font-mono text-[10.5px] text-slate-400">{tag}</span>}
{open ? (
<ChevronDown className="size-3 shrink-0 text-slate-400" />
) : (
<ChevronRight className="size-3 shrink-0 text-slate-400" />
)}
{status === 'error' && (
<span className="ml-auto shrink-0 text-[10.5px] text-rose-600">失败</span>
)}
{status === 'running' && (
<span className="ml-auto shrink-0 text-[10.5px] text-slate-400">进行中…</span>
)}
{/* ⚠️ 中性色,⛔ 不是红:这一步**没有失败**,只是这一轮不听了(见 ToolStep 上那段) */}
{status === 'aborted' && (
<span className="ml-auto shrink-0 text-[10.5px] text-slate-400">已中断</span>
)}
</button>
{/* ⚠️ 箭头挪到文字后面之后,展开内容与 label 左沿**天然对齐**(都落在 0)——
⛔ 别再加 `pl-*` 去"缩进一下":那正是刚拆掉的那种错位。 */}
{open && children && <div className="space-y-1 pb-2">{children}</div>}
</div>
);
}
/** 一次工具调用。⚠️ 参数/返回里会出现取值码,所以**默认收起**(同 StepTrace 顶部那段边界)。 */
export function StepRow({ step }: { step: ToolStep }) {
const meta = toolMeta(step.tool);
const args = fmt(step.args);
const result = step.status === 'error' ? fmt(step.error) : fmt(step.result);
return (
<TraceRow
label={meta.label}
tag={step.tool}
{...(step.status === 'done' ? {} : { status: step.status })}
>
{args && (
<div className={BOX}>
<div className="mb-0.5 text-[10px] text-slate-400">参数</div>
<div className={MONO}>{args}</div>
</div>
)}
{result && (
<div className={BOX}>
<div className="mb-0.5 text-[10px] text-slate-400">
{step.status === 'error' ? '错因' : '返回'}
</div>
<div className={cn(MONO, step.status === 'error' && 'text-rose-700')}>{result}</div>
</div>
)}
</TraceRow>
);
}
/**
* 模型的思考。⚠️ 标签上带**字数**:流式期间它在长,字数是唯一能看出"还在想"的信号。
* ⛔ 别自动展开 —— 实测单轮能到三千多字,摊开会把正文淹掉。
*/
export function ReasoningRow({ text }: { text: string }) {
return (
<TraceRow label="思考" tag={`${text.length} 字`}>
<div className={BOX}>
<div className={MONO}>{text}</div>
</div>
</TraceRow>
);
}
/**
* 执行过程 —— **只剩一行小结**(2026-08-14 改)。
*
* 🔴 明细现在**按时序穿插在正文里**(`StepRow` / `ReasoningRow` 由 BlockView 就地渲染),
* 这里再展开一遍就是同一份东西的两个副本 —— 改一处漏一处。
* ⭐ 但这一行要留:**「这轮跑了几步」永远在同一个地方**,扫一眼就知道走了什么路,
* ⛔ 不用顺着正文一路数绿框和灰行。
* ⚠️ 计数**含**位置标记(`show_*`),而它们在正文里不出灰行(见 BlockView 那段),
* 所以这个数会比正文里的行数多 —— 差几个就是摆了几次。
*/
export function StepTrace({ steps }: { steps: ToolStep[] }) {
// ⛔ 一步都没有就整块不渲染(产品定)—— 空壳对主管是噪音
if (steps.length === 0) return null;
const failed = steps.filter((s) => s.status === 'error').length;
return (
<p className="mt-2 flex items-center gap-2 px-0.5 text-[11px] text-slate-400">
{/* 🔴 `shrink-0 whitespace-nowrap` **不许去掉**(2026-08-15 对抗审查实测):
同排那条工具链带 `truncate`(overflow:hidden ⇒ 最小尺寸为 0),但 flex 收缩是
**按比例分摊**的,不会优先榨可截断的那个 —— 首段跟着被压窄,压不下就逐字换行。
480px 浮窗实测:6 步(正好是出一版方案的正常步数)就折成两行、12 步三行,
而「这轮跑了几步」这个唯一固定锚点被劈开时,看起来像渲染坏了。 */}
<span className="shrink-0 whitespace-nowrap">执行过程 · {steps.length} 步</span>
<span className="truncate">{steps.map((s) => toolMeta(s.tool).label).join(' → ')}</span>
{/* ⭐ 失败数一直摆在外面 —— 不展开也得看得见有东西挂了 */}
{failed > 0 && <span className="ml-auto shrink-0 text-rose-600">{failed} 失败</span>}
</p>
);
}
'use client'; 'use client';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import type { AssignmentProposal, SheetEditOp } from '@pac/types'; import type { AssignmentProposal, SheetEditOp, Signal } 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';
import { useAssistantStore } from '@/stores/assistant-store';
// ⚠️ 「换无主患者补上」要重跑一版 —— 那是换人群,⛔ 不是局部改单
import { assignmentsApi } from '@/components/plans/assignments-api';
// ⭐ 数据模型 + 纯函数操作已抽到 chat-blocks(草稿状态机是最该有回归的一段逻辑, // ⭐ 数据模型 + 纯函数操作已抽到 chat-blocks(草稿状态机是最该有回归的一段逻辑,
// 内联在 hook 里只能端到端点一遍才能验)。这里 re-export 保持下游 import 不变。 // 内联在 hook 里只能端到端点一遍才能验)。这里 re-export 保持下游 import 不变。
...@@ -14,7 +17,7 @@ import { ...@@ -14,7 +17,7 @@ import {
findToolIdx, findToolIdx,
intentToPrompt, intentToPrompt,
intentToSheetOps, intentToSheetOps,
toApiMessage, toApiMessages,
upsertArtifact, upsertArtifact,
type Block, type Block,
type ChatMessage, type ChatMessage,
...@@ -67,8 +70,62 @@ export async function transcribeAudio(blob: Blob): Promise<string> { ...@@ -67,8 +70,62 @@ export async function transcribeAudio(blob: Blob): Promise<string> {
export function useAssistantChat() { export function useAssistantChat() {
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [status, setStatus] = useState<ChatStatus>('idle'); const [status, setStatus] = useState<ChatStatus>('idle');
const [model, setModel] = useState('qwen'); /**
* 正在执行的引导节点 intent —— 按钮据此转圈并整排禁用。
*
* 🔴 必须在**这一层**(2026-08-13):会花时间的两类动作都已经不在卡片里了 ——
* `pending.refill` 走 HTTP 重排(几秒),`basis.set` / `cohort.*` 走
* `send()` 让模型重出一版(几十秒)。⛔ 局部改单(to_owner / spread / remove /
* expiry.set)是同步的,⛔ 不要给它们转圈:闪一下反而像出错了。
*/
const [busyIntent, setBusyIntent] = useState<string | null>(null);
/**
* 默认模型 —— 🔴 2026-08-14 由 `qwen` 改成 `deepseek`,**这是能力判断,不是偏好**。
*
* 助手的排版靠模型在写字的**同一条消息里**顺手调 `show_sheet` / `show_guidance`
* (content 与 tool_calls 并存),前端按到达顺序追加块,位置由此确定。
* · DeepSeek:官方文档的多轮拼接示例里 content / reasoning_content / tool_calls 三者并存 ⇒ 做得到。
* · 百炼 qwen:文档示例里出 tool_calls 那一轮 `"content": ""` ⇒ 那一步写不了字;
* 而一旦某步没有工具调用,多步循环就结束(finishReason='stop')——
* **所以它只能"先把工具全调完、再一口气写完",是结构上被逼的,⛔ 不是提示词能改的。**
*
* ⚠️ 换默认模型**不是**给 qwen 打补丁:qwen 仍可在下拉里选,只是它那条链路上
* 位置永远是退化态(无根 → 挪到正文之后 + 补小标题)。
* ⛔ 别为了"两个模型看起来一样"再往 `assistant-chat` 的排版里加分支 ——
* 2026-08-14 已经为此翻过一次车(历史里留位置空壳,模型把标记当正文写了出来)。
*/
const [model, setModel] = useState('deepseek');
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
/**
* 消息流的**同步镜像** —— 给那些"要先读当前状态、再发请求"的回调用。
* ⛔ 别把这类读取塞进 `setMessages` 的 updater:StrictMode 会把 updater 跑两次,
* 写在里面的副作用(如重排接口)就会**发两遍**。
*/
const messagesRef = useRef<ChatMessage[]>([]);
messagesRef.current = messages;
/**
* ⭐ **这一轮结束了,把还在跑的那几步落定**(2026-08-15 对抗审查抓到)。
*
* `running` 只有 `tool_result` / `tool_error` 两个事件能收尾。点「停止」或连接断掉时
* 这两个事件都不会再来,于是那一行**永远写着「进行中…」**,而这一轮早已结束
* (GeneratingHint 跟着 streaming 一起消失)。主管看到的是「答完了,但它好像还在查」。
* ⚠️ 落成 `aborted` **不是** `error`:服务端那次调用可能已经跑完了,我们只是不再听 ——
* 替它宣布失败,主管会以为那批没出,转身再发一遍(见 ToolStep 上那段)。
* ⚠️ 在 `finally` 里调,正常收尾也走一次:那时没有 running,是个空操作。
*/
const settleRunningSteps = useCallback(() => {
setMessages((prev) =>
prev.map((m) => ({
...m,
blocks: m.blocks.map((b) =>
b.kind === 'tool' && b.step.status === 'running'
? { ...b, step: { ...b.step, status: 'aborted' as const } }
: b,
),
})),
);
}, []);
const stop = useCallback(() => { const stop = useCallback(() => {
abortRef.current?.abort(); abortRef.current?.abort();
...@@ -89,10 +146,11 @@ export function useAssistantChat() { ...@@ -89,10 +146,11 @@ export function useAssistantChat() {
const assistantId = nextId(); const assistantId = nextId();
const assistantMsg: ChatMessage = { id: assistantId, role: 'assistant', blocks: [] }; const assistantMsg: ChatMessage = { id: assistantId, role: 'assistant', blocks: [] };
// 历史(含新 user)→ apiMessages /**
const apiMessages = [...messages, userMsg] * 历史(含新 user)→ apiMessages:按**结构**回传(真的 tool-call / tool-result 消息)。
.map(toApiMessage) * ⛔ 不压平成文本 —— 沿革与三次事故见 `toApiMessages` 上面那段。
.filter((x): x is { role: 'user' | 'assistant'; content: string } => x !== null); */
const apiMessages = [...messages, userMsg].flatMap(toApiMessages);
setMessages((prev) => [...prev, userMsg, assistantMsg]); setMessages((prev) => [...prev, userMsg, assistantMsg]);
setStatus('streaming'); setStatus('streaming');
...@@ -104,20 +162,30 @@ export function useAssistantChat() { ...@@ -104,20 +162,30 @@ export function useAssistantChat() {
prev.map((m) => (m.id === assistantId ? { ...m, blocks: fn(m.blocks) } : m)), prev.map((m) => (m.id === assistantId ? { ...m, blocks: fn(m.blocks) } : m)),
); );
const appendText = (t: string) => /**
* 文本 / 思考的增量追加 —— **追加到最后一个同类块**,被别的东西打断就新起一段。
*
* ⚠️ 这就是"时序"的全部实现:块的先后 = 事件到达的先后,⛔ 前端不重排。
* 所以「思考 → 调工具 → 写字 → 再调」在消息里长什么样,就是它真实发生的顺序。
*/
const appendStream = (kind: 'text' | 'reasoning', t: string) =>
patch((blocks) => { patch((blocks) => {
const last = blocks[blocks.length - 1]; const last = blocks[blocks.length - 1];
if (last && last.kind === 'text') { if (last && last.kind === kind) {
return [...blocks.slice(0, -1), { kind: 'text', text: last.text + t }]; return [...blocks.slice(0, -1), { kind, text: last.text + t }];
} }
return [...blocks, { kind: 'text', text: t }]; return [...blocks, { kind, text: t }];
}); });
const appendText = (t: string) => appendStream('text', t);
const onEvent = (evt: Record<string, unknown>) => { const onEvent = (evt: Record<string, unknown>) => {
switch (evt.type) { switch (evt.type) {
case 'text': case 'text':
appendText((evt.text as string) ?? ''); appendText((evt.text as string) ?? '');
break; break;
case 'reasoning':
appendStream('reasoning', (evt.text as string) ?? '');
break;
case 'assignment_sheet': case 'assignment_sheet':
// ⭐ 侧信道推来的确认单:upsert 它 + 把此前所有 active 的作废。 // ⭐ 侧信道推来的确认单:upsert 它 + 把此前所有 active 的作废。
// 逻辑在 chat-blocks.applyIncomingSheet(纯函数,有回归测试)。 // 逻辑在 chat-blocks.applyIncomingSheet(纯函数,有回归测试)。
...@@ -155,6 +223,40 @@ export function useAssistantChat() { ...@@ -155,6 +223,40 @@ export function useAssistantChat() {
); );
}); });
break; break;
case 'assignment_sheet_anchor':
// ⭐ 模型说「卡片摆这」—— 追加一个位置标记到当前这条消息。
// ⚠️ 一条消息只留一个:模型重复调用不该把卡片渲染两遍。
patch((blocks) =>
blocks.some((b) => b.kind === 'sheet_anchor')
? blocks
: [...blocks, { kind: 'sheet_anchor' as const }],
);
break;
case 'assignment_guidance': {
/**
* ⭐ 模型说「这条的按钮插在这」—— `patch` 追加到**当前正在写的**这条消息末尾,
* 也就是它刚写完的那句话后面。位置由此确定,⛔ 前端不去猜。
*
* 🔴 2026-08-14:`signalKey` 现在由**服务端解析好**再推过来。
* 在此之前模型传的是序号,前端要按 `filter(tier==='action')[idx-1]` 还原 ——
* 两边各解析一次、过滤顺序必须永远一致,错了还**不报错**(按钮插到别人下面)。
* ⇒ 一边解析,一边用。⚠️ key 仍然不进模型上下文(侧信道不经过模型)。
* ⚠️ 拿不到就**丢弃**:那几条会照旧显示在卡片上,⛔ 不会消失。
*/
const key = String(evt.signalKey ?? '');
setMessages((prev) => {
if (!key) return prev;
return prev.map((m) =>
m.id === assistantId
? // 同一条只插一次 —— 模型重复调用不该长出两排按钮
m.blocks.some((b) => b.kind === 'guidance' && b.signalKey === key)
? m
: { ...m, blocks: [...m.blocks, { kind: 'guidance' as const, signalKey: key }] }
: m,
);
});
break;
}
case 'artifact_html': case 'artifact_html':
// 流式:render_artifact 的 html 边生成边到(按 callId upsert,实时"长出"卡片)。 // 流式:render_artifact 的 html 边生成边到(按 callId upsert,实时"长出"卡片)。
patch((blocks) => patch((blocks) =>
...@@ -322,7 +424,23 @@ export function useAssistantChat() { ...@@ -322,7 +424,23 @@ export function useAssistantChat() {
Accept: 'text/event-stream', Accept: 'text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}, },
body: JSON.stringify({ messages: apiMessages, model }), // ⭐ activeClinicId 走**请求体**不进模型上下文 —— 它是"主管现在看哪家"的事实,
// 不是对话内容;让模型转译一次就多一次错的机会(见 assistant-store 的注释)。
body: JSON.stringify({
messages: apiMessages,
model,
...(useAssistantStore.getState().activeClinicId
? { activeClinicId: useAssistantStore.getState().activeClinicId }
: {}),
/**
* ⭐ 眼前那张确认单**此刻**的样子 —— 同样走请求体,不进模型上下文;
* 模型要用 `get_current_sheet` 取。
* ⚠️ **现取现发**:主管拖一下、点个 × 它就变了,⛔ 别缓存、⛔ 别在别处存一份。
*/
...(useAssistantStore.getState().sheetSnapshot
? { sheetState: useAssistantStore.getState().sheetSnapshot }
: {}),
}),
signal: controller.signal, signal: controller.signal,
}); });
if (!res.ok || !res.body) { if (!res.ok || !res.body) {
...@@ -361,10 +479,11 @@ export function useAssistantChat() { ...@@ -361,10 +479,11 @@ export function useAssistantChat() {
} }
} finally { } finally {
abortRef.current = null; abortRef.current = null;
settleRunningSteps();
emitPetEvent({ type: 'ai_thinking_end' }); emitPetEvent({ type: 'ai_thinking_end' });
} }
}, },
[messages, model, status], [messages, model, status, settleRunningSteps],
); );
/** /**
...@@ -431,6 +550,49 @@ export function useAssistantChat() { ...@@ -431,6 +550,49 @@ export function useAssistantChat() {
*/ */
const applySignalIntent = useCallback( const applySignalIntent = useCallback(
(intent: string, args?: Record<string, unknown>) => { (intent: string, args?: Record<string, unknown>) => {
/**
* 「换无主患者补上」= 重跑一版(换人群),⛔ 不是局部改单。
*
* ⚠️ 2026-08-12 从确认单组件搬到这里:待分配的按钮已经不在卡片上了
* (它跟着模型的文案走),卡片再留一份实现就是**没人走的死代码**。
* ⚠️ 条件必须**原样带回** —— 少带一个(口径、治疗项、时间档),重排就换了人群,
* 而且不报错。
*/
if (intent === 'pending.refill') {
const at = findActiveSheet(messagesRef.current);
if (!at) return;
const b = messagesRef.current[at.messageIndex]!.blocks[at.blockIndex] as Extract<
Block,
{ kind: 'assignment_sheet' }
>;
const s = b.sheet;
setBusyIntent(intent);
void assignmentsApi
.refill({
clinicId: s.clinicId,
potentialTreatment: s.potentialTreatment,
temperature: s.temperature,
/**
* 🔴 **他自己收窄的那几刀也要带回**(2026-08-15 补——此前整个漏了)。
*
* 上面那句「条件必须原样带回」写着,代码却只带了矩阵那两轴。
* 后果:主管先「只选消费高于 ¥721 的 314 人」,再点「换无专属客服的患者补上」,
* 服务端拿不到这一刀 → **退回整格 2,673 人重取** —— 他刚圈掉的人又回来了,
* 而界面只显示"重新排了一版",⛔ 不报错、⛔ 无提示。
* ⚠️ 真源是 `narrowedBy`(提案里专门记"他追加了什么"的那个字段),
* ⛔ 别去 `criteria` 里翻:那里混着矩阵两轴,分不出哪些是他加的。
*/
...(s.narrowedBy?.personaTags ? { personaTags: s.narrowedBy.personaTags } : {}),
...(s.narrowedBy?.minSpendYuan ? { minSpendYuan: s.narrowedBy.minSpendYuan } : {}),
targetCount: s.batchSize,
expiresInDays: s.expiresInDays,
})
.then((next) => replaceSheet(b.requestId, next))
.catch(() => appendAssistantNote('重新排版失败,这批没有改动。'))
// ⚠️ `finally` 不能省:失败时也要把转圈停掉,否则整排按钮永久禁用
.finally(() => setBusyIntent(null));
return;
}
const ops = intentToSheetOps(intent, args); const ops = intentToSheetOps(intent, args);
if (ops) { if (ops) {
setMessages((prev) => { setMessages((prev) => {
...@@ -449,24 +611,50 @@ export function useAssistantChat() { ...@@ -449,24 +611,50 @@ export function useAssistantChat() {
return; return;
} }
const text = intentToPrompt(intent, args); const text = intentToPrompt(intent, args);
if (text) void send(text); if (text) {
// ⚠️ 这条要模型重出一版(几十秒)—— 转圈一直挂到这一轮说完
setBusyIntent(intent);
void send(text).finally(() => setBusyIntent(null));
}
}, },
[send], [send, replaceSheet, appendAssistantNote],
); );
/** 确认单落库后把卡片切到终态(按钮禁用,显示批次号) */ /**
const settleSheet = useCallback((requestId: string, assignmentId: string) => { * 确认单落库后把卡片切到终态(按钮禁用,显示批次号)。
*
* ⭐ **确认之后还能做的事**(`postConfirm`)一并挂进这张单的 signals ——
* 挂进去而不是直接塞进消息,是为了让它能**自己消失**:
* 卡片的 `liveSignals` 会按当前状态复算(福利补上了 → 这条没主语了),
* 而正文里那个引导块是按 key 去查这张单的,查不到就不渲染。
* ⛔ 别把 signal 整个塞进引导块里图省事:那样福利补上之后,
* 「这批还没带福利」会一直挂在那儿说一句**已经不成立的话**。
*/
/**
* 🔴 曾有第三个参数 `postConfirm` —— 确认成功时把「这批还没带福利」那条引导
* 合进这张单的 signals,卡片于是长出一块黄色引导。**删于 2026-08-15**:
* 产品定确认单里不做福利引导,确认之后要说什么归模型(提示词 ④)。
* ⛔ 别加回来。
*/
const settleSheet = useCallback(
(requestId: string, assignmentId: string) => {
setMessages((prev) => setMessages((prev) =>
prev.map((m) => ({ prev.map((m) => ({
...m, ...m,
blocks: m.blocks.map((b) => blocks: m.blocks.map((b) =>
b.kind === 'assignment_sheet' && b.requestId === requestId b.kind === 'assignment_sheet' && b.requestId === requestId
? { ...b, state: 'confirmed' as const, assignmentId } ? {
...b,
state: 'confirmed' as const,
assignmentId,
}
: b, : b,
), ),
})), })),
); );
}, []); },
[],
);
return { return {
messages, messages,
...@@ -479,5 +667,6 @@ export function useAssistantChat() { ...@@ -479,5 +667,6 @@ export function useAssistantChat() {
settleSheet, settleSheet,
replaceSheet, replaceSheet,
applySignalIntent, applySignalIntent,
busyIntent,
}; };
} }
...@@ -89,7 +89,7 @@ import { useSummaryStream } from './use-summary-stream'; ...@@ -89,7 +89,7 @@ import { useSummaryStream } from './use-summary-stream';
import { submitExecution } from './execution-api'; import { submitExecution } from './execution-api';
/// 话术生成模型(具体型号,直传后端 AiProviderService.resolve) /// 话术生成模型(具体型号,直传后端 AiProviderService.resolve)
export type ScriptModel = 'deepseek-v4-pro' | 'deepseek-v4-flash' | 'gemini-3.5-flash' | 'qwen3.7-max'; export type ScriptModel = 'deepseek-v4-pro' | 'deepseek-v4-flash' | 'gemini-3.5-flash' | 'qwen3.8-max';
/// 投入档(话术生成档位):稳健=4段模板填空 / 标准=去模板自由编排 / 深度=多步多段+对抗校验。 /// 投入档(话术生成档位):稳健=4段模板填空 / 标准=去模板自由编排 / 深度=多步多段+对抗校验。
export type ScriptTier = 'stable' | 'standard' | 'deep'; export type ScriptTier = 'stable' | 'standard' | 'deep';
...@@ -216,8 +216,18 @@ export function PlanDetailApp({ ...@@ -216,8 +216,18 @@ export function PlanDetailApp({
const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown'); const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
/** 话术渠道 tab:电话(分段稿,固定「原文」渲染)/ 企微(单块,可复制) */ /** 话术渠道 tab:电话(分段稿,固定「原文」渲染)/ 企微(单块,可复制) */
const [scriptChannel, setScriptChannel] = useState<ScriptChannel>('phone'); const [scriptChannel, setScriptChannel] = useState<ScriptChannel>('phone');
// 话术生成模型(具体型号);默认 qwen3.7-max(极快 · 简洁) /**
const [scriptModel, setScriptModel] = useState<ScriptModel>('qwen3.7-max'); * 话术生成模型 —— **具体型号,不是裸键**;默认 qwen3.8-max(极快 · 简洁)。
*
* ⚠️ 这个值每次 `regenerate` 都作 `model` 参数发出去 → 服务端的 `modelIdOverride`,
* **压过** call 上写的 `defaultModelId`。所以那几个 call 里的
* `defaultModelId = 'deepseek-v4-flash'` 在这条路上是够不着的死值 ——
* ⛔ 想知道话术真跑哪个模型,看这里,别去看 call(2026-08-15 我在这上面读错过一次)。
* ⚠️ 这里**故意**钉具体版本而不是裸键 `'qwen'`:这是个**型号选择器**,
* 下拉里将来要能同时摆 pro 和 flash —— 裸键表达不了"具体哪一个"。
* 代价是换版本要改这里,⛔ 改 `QWEN_DEFAULT_MODEL` 移不动它(那只管裸键 `'qwen'` 的三个摘要)。
*/
const [scriptModel, setScriptModel] = useState<ScriptModel>('qwen3.8-max');
// 投入档(默认稳健);跟模型并列,客服在重新生成处选 // 投入档(默认稳健);跟模型并列,客服在重新生成处选
const [scriptTier, setScriptTier] = useState<ScriptTier>('stable'); const [scriptTier, setScriptTier] = useState<ScriptTier>('stable');
const { state: streamState, regenerate, abort } = useScriptStream(); const { state: streamState, regenerate, abort } = useScriptStream();
...@@ -287,16 +297,18 @@ export function PlanDetailApp({ ...@@ -287,16 +297,18 @@ export function PlanDetailApp({
// 流式 done / error 时弹 toast // 流式 done / error 时弹 toast
useEffect(() => { useEffect(() => {
if (streamState.status === 'done') { if (streamState.status === 'done') {
// 🔴 `costYuan` 可能缺省 —— 企微的 done 事件不带成本/token 字段(它只推步骤 + 正文), /**
// 直接 `.toFixed()` 会在生成完成那一刻整页崩(实测)。⛔ 别假设两条流的 done 形状一样。 * ⛔ **别把调用费用写进 toast**(2026-08-13 产品定):
const cost = streamState.costYuan?.toFixed(4); * 这是给一线用的界面,`¥0.0123` 对他没有任何可操作性 —— 他既不为此付钱、
* 也不会因为这个数改做法,只是每生成一次就被塞一串数字。
* ⚠️ 成本本来就有专门的去处:AI 调用列表页(含 24h 成本、按调用逐条 ¥)。
* ⛔ 别以"顺手就有"为由加回来。
*/
const label = const label =
streamState.source === 'failed' streamState.source === 'failed'
? '生成失败,请重试或自己写一条' ? '生成失败,请重试或自己写一条'
: streamState.source === 'template_fallback' : streamState.source === 'template_fallback'
? '模板兜底' ? '模板兜底'
: cost
? `AI 生成 · ¥${cost}`
: 'AI 生成'; : 'AI 生成';
showToast( showToast(
streamState.source === 'failed' ? 'rose' : 'emerald', streamState.source === 'failed' ? 'rose' : 'emerald',
...@@ -312,9 +324,8 @@ export function PlanDetailApp({ ...@@ -312,9 +324,8 @@ export function PlanDetailApp({
// 摘要 done / error 时弹 toast // 摘要 done / error 时弹 toast
useEffect(() => { useEffect(() => {
if (summaryState.status === 'done') { if (summaryState.status === 'done') {
const cost = summaryState.costYuan.toFixed(4); // ⛔ 同上:费用不进 toast,成本看 AI 调用列表页
const label = const label = summaryState.source === 'template_fallback' ? '模板兜底' : 'AI 生成';
summaryState.source === 'template_fallback' ? '模板兜底' : `AI 生成 · ¥${cost}`;
showToast('emerald', '摘要已重新生成(3 段)', label); showToast('emerald', '摘要已重新生成(3 段)', label);
} else if (summaryState.status === 'error') { } else if (summaryState.status === 'error') {
showToast('rose', '摘要生成失败', summaryState.message.slice(0, 80)); showToast('rose', '摘要生成失败', summaryState.message.slice(0, 80));
...@@ -2805,7 +2816,7 @@ function PersonaTagCloud({ ...@@ -2805,7 +2816,7 @@ function PersonaTagCloud({
// 话术生成模型选项(具体型号,直传后端) // 话术生成模型选项(具体型号,直传后端)
const SCRIPT_MODELS: { key: ScriptModel; label: string }[] = [ const SCRIPT_MODELS: { key: ScriptModel; label: string }[] = [
{ key: 'deepseek-v4-flash', label: '快 · 均衡' }, { key: 'deepseek-v4-flash', label: '快 · 均衡' },
{ key: 'qwen3.7-max', label: '极快 · 简洁' }, { key: 'qwen3.8-max', label: '极快 · 简洁' },
]; ];
// 投入档选项(跟模型并列,直传后端 tier) // 投入档选项(跟模型并列,直传后端 tier)
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
import type { import type {
AgentWorkloadResponse, AgentWorkloadResponse,
AnchorModeValue,
CreateAssignmentRequest, CreateAssignmentRequest,
CreateAssignmentResponse, CreateAssignmentResponse,
ListAgentsResponse, ListAgentsResponse,
...@@ -140,7 +139,6 @@ export const assignmentsApi = { ...@@ -140,7 +139,6 @@ export const assignmentsApi = {
clinicId: string; clinicId: string;
potentialTreatment?: string; potentialTreatment?: string;
temperature?: string; temperature?: string;
anchor?: AnchorModeValue;
personaTags?: string; personaTags?: string;
/// 一个框搜五样(姓名/病历号/主治/上次接诊/专属客服):只收窄这张表, /// 一个框搜五样(姓名/病历号/主治/上次接诊/专属客服):只收窄这张表,
/// ⛔ 不改变交给助手的人群 /// ⛔ 不改变交给助手的人群
...@@ -153,7 +151,6 @@ export const assignmentsApi = { ...@@ -153,7 +151,6 @@ export const assignmentsApi = {
clinicId: q.clinicId, clinicId: q.clinicId,
potentialTreatment: q.potentialTreatment, potentialTreatment: q.potentialTreatment,
temperature: q.temperature, temperature: q.temperature,
anchor: q.anchor,
personaTags: q.personaTags, personaTags: q.personaTags,
keyword: q.keyword, keyword: q.keyword,
page: q.page == null ? undefined : String(q.page), page: q.page == null ? undefined : String(q.page),
......
'use client'; 'use client';
import type { import type {
AnchorModeValue,
TemperatureValue, TemperatureValue,
ListPlansQuery, ListPlansQuery,
ListPlansResponse, ListPlansResponse,
...@@ -33,9 +32,6 @@ export interface PoolMatrix { ...@@ -33,9 +32,6 @@ export interface PoolMatrix {
unknownTotal: number; unknownTotal: number;
/** 有待重算的人时才非空;成品句子,直接展示 */ /** 有待重算的人时才非空;成品句子,直接展示 */
note: string; note: string;
/** ⭐ 这份数据是按哪个锚点算的 —— 由**服务端**回声,⛔ 前端别自己记(刷新就漂) */
anchorMode?: AnchorModeValue;
anchorModeZh?: string;
} }
export const plansApi = { export const plansApi = {
...@@ -67,8 +63,8 @@ export const plansApi = { ...@@ -67,8 +63,8 @@ export const plansApi = {
* 初选矩阵(8 潜在治疗 × 3 窗口温度)。 * 初选矩阵(8 潜在治疗 × 3 窗口温度)。
* ⚠️ 数字是**去重患者数**,不是 plan 条数 —— 列表按 plan 分页,两者本就不该相等。 * ⚠️ 数字是**去重患者数**,不是 plan 条数 —— 列表按 plan 分页,两者本就不该相等。
*/ */
matrix: (clinicId?: string, anchor?: AnchorModeValue) => matrix: (clinicId?: string) =>
api.get<PoolMatrix>('/pac/v1/plans/matrix', { query: { clinicId, anchor } }), api.get<PoolMatrix>('/pac/v1/plans/matrix', { query: { clinicId } }),
/** 「上次医生 / 偏好医生」筛选的候选名单(后端缓存 6h;前端在它上面做客户端模糊匹配)*/ /** 「上次医生 / 偏好医生」筛选的候选名单(后端缓存 6h;前端在它上面做客户端模糊匹配)*/
doctors: () => api.get<{ doctors: string[] }>('/pac/v1/plans/doctors'), doctors: () => api.get<{ doctors: string[] }>('/pac/v1/plans/doctors'),
......
'use client'; 'use client';
import { import {
ANCHOR_MODE_META, TEMPERATURE_AXIS_ZH,
AnchorMode,
TEMPERATURE_META, TEMPERATURE_META,
TEMPERATURE_ORDER, TEMPERATURE_ORDER,
potentialTreatmentItemName, potentialTreatmentItemName,
temperatureWindowHint, temperatureWindowHint,
type AnchorModeValue,
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
...@@ -95,13 +93,6 @@ export function PoolMatrix({ ...@@ -95,13 +93,6 @@ export function PoolMatrix({
* ⛔ 别把默认宽度删掉改成永远 w-full —— popover 会跟着变成整屏宽。 * ⛔ 别把默认宽度删掉改成永远 w-full —— popover 会跟着变成整屏宽。
*/ */
className, className,
/**
* 档位口径(按诊断 / 按末诊)—— 只影响**格子 hover 的那句话**与底部说明的措辞;
* 数字本身是服务端按同一个 mode 算好的。
* ⚠️ 默认 `diagnosis` 是为了让老调用点行为不变,⛔ 别把它当"聪明的默认"——
* 调用方拿到的数据是哪版,就必须传哪版(见 new-batch 的 dataMode)。
*/
anchorMode = AnchorMode.DIAGNOSIS,
}: { }: {
/** /**
* 矩阵数据 —— **由调用方取好再传进来**,本组件不自己发请求。 * 矩阵数据 —— **由调用方取好再传进来**,本组件不自己发请求。
...@@ -111,7 +102,6 @@ export function PoolMatrix({ ...@@ -111,7 +102,6 @@ export function PoolMatrix({
* 数据到了再重排,浮层的抖动比列表刺眼得多。 * 数据到了再重排,浮层的抖动比列表刺眼得多。
*/ */
data: PoolMatrixData; data: PoolMatrixData;
anchorMode?: AnchorModeValue;
/** 当前选中的格子(回显用) */ /** 当前选中的格子(回显用) */
selected?: { treatment: string; temperature: TempKey } | null; selected?: { treatment: string; temperature: TempKey } | null;
className?: string; className?: string;
...@@ -180,13 +170,7 @@ export function PoolMatrix({ ...@@ -180,13 +170,7 @@ export function PoolMatrix({
{/* ⭐ 1b 的主体:**一整张**渐变面,8×3 个格子透明浮在上面 */} {/* ⭐ 1b 的主体:**一整张**渐变面,8×3 个格子透明浮在上面 */}
<div className={cn('flex-1 overflow-hidden rounded', PLANE)}> <div className={cn('flex-1 overflow-hidden rounded', PLANE)}>
{data.rows.map((row) => ( {data.rows.map((row) => (
<MatrixRow <MatrixRow key={row.key} row={row} selected={selected} onPick={onPick} />
key={row.key}
row={row}
selected={selected}
onPick={onPick}
anchorMode={anchorMode}
/>
))} ))}
</div> </div>
...@@ -195,9 +179,7 @@ export function PoolMatrix({ ...@@ -195,9 +179,7 @@ export function PoolMatrix({
{data.rows.map((row) => ( {data.rows.map((row) => (
<div <div
key={row.key} key={row.key}
title={`算不出档位 —— ${ title="算不出档位 —— 这些人没有末诊记录"
anchorMode === AnchorMode.LAST_VISIT ? '这些人没有末诊记录' : '召回证据里没有可用的诊断日'
}`}
className={cn( className={cn(
ROW_H, ROW_H,
'flex items-center justify-center text-[11px] text-muted-foreground nums', 'flex items-center justify-center text-[11px] text-muted-foreground nums',
...@@ -212,17 +194,13 @@ export function PoolMatrix({ ...@@ -212,17 +194,13 @@ export function PoolMatrix({
{/* {/*
⚠️ 2026-08-11 大幅缩短:原来这里要解释"前两档按该治疗自己的周期、后四档按年数"—— ⚠️ 2026-08-11 大幅缩短:原来这里要解释"前两档按该治疗自己的周期、后四档按年数"——
六档改成同一把尺子(都按诊断距今多久)之后,那半句话没有了,⛔ 别再写回去。 六档改成同一把尺子之后,那半句话没有了,⛔ 别再写回去。
⭐ 剩下这一句是**必须留的**:一个人有几个潜在治疗就占几行, ⭐ 剩下那半句是**必须留的**:末诊是**患者级**的,同一个人的几个潜在治疗必然落同一档。
否则主管把各行相加会发现比池子总数大,当场以为系统在骗他(实测 2,526 vs 2,129)。 不说清楚,主管会以为"这人三个机会都刚诊断"。⛔ 别省。
*/} */}
<p className="mt-2 text-[10.5px] leading-relaxed text-muted-foreground"> <p className="mt-2 text-[10.5px] leading-relaxed text-muted-foreground">
按<span className="font-medium">{ANCHOR_MODE_META[anchorMode].axisZh}距今多久</span>分档。 按<span className="font-medium">{TEMPERATURE_AXIS_ZH}距今多久</span>分档。
{anchorMode === AnchorMode.LAST_VISIT 末诊按人算,同一个人的几个治疗项都在同一档。
? // ⚠️ 末诊是**患者级**的:同一个人的几个潜在治疗必然落同一档 —— 不说清楚,
// 主管会以为"这人三个机会都刚诊断"。⛔ 别省这半句。
'末诊按人算,同一个人的几个治疗项都在同一档。'
: '一个人有几个潜在治疗就出现在几行。'}
</p> </p>
{data.note && ( {data.note && (
...@@ -239,10 +217,8 @@ function MatrixRow({ ...@@ -239,10 +217,8 @@ function MatrixRow({
row, row,
selected, selected,
onPick, onPick,
anchorMode,
}: { }: {
row: PoolMatrixRow; row: PoolMatrixRow;
anchorMode: AnchorModeValue;
selected?: { treatment: string; temperature: TempKey } | null; selected?: { treatment: string; temperature: TempKey } | null;
onPick: (cell: { onPick: (cell: {
treatment: string; treatment: string;
...@@ -279,7 +255,7 @@ function MatrixRow({ ...@@ -279,7 +255,7 @@ function MatrixRow({
// ⭐ hover 文案保持原样:只给**天数区间**,不给解释。 // ⭐ hover 文案保持原样:只给**天数区间**,不给解释。
// 格子里已经有数字、列头已经写了档位名,再叠一段说明就是噪声; // 格子里已经有数字、列头已经写了档位名,再叠一段说明就是噪声;
// 区间是主管唯一看不出来的那个信息。 // 区间是主管唯一看不出来的那个信息。
title={temperatureWindowHint(row.key, c, anchorMode)} title={temperatureWindowHint(row.key, c)}
className={cn( className={cn(
ROW_H, ROW_H,
'flex-1 text-[13px] text-slate-900 nums', 'flex-1 text-[13px] text-slate-900 nums',
......
...@@ -2,12 +2,7 @@ ...@@ -2,12 +2,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Search, X } from 'lucide-react'; import { Search, X } from 'lucide-react';
import { import { TEMPERATURE_META, type TemperatureValue } from '@pac/types';
ANCHOR_MODE_META,
TEMPERATURE_META,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
import { assignmentsApi, type CohortPatientRow } from '@/components/plans/assignments-api'; import { assignmentsApi, type CohortPatientRow } from '@/components/plans/assignments-api';
import { cn, formatGender } from '@/lib/utils'; import { cn, formatGender } from '@/lib/utils';
...@@ -31,13 +26,11 @@ import { cn, formatGender } from '@/lib/utils'; ...@@ -31,13 +26,11 @@ import { cn, formatGender } from '@/lib/utils';
export function CohortPreview({ export function CohortPreview({
clinicId, clinicId,
cell, cell,
anchorMode,
onCancel, onCancel,
onConfirm, onConfirm,
}: { }: {
clinicId: string; clinicId: string;
cell: { treatment: string; treatmentZh: string; temperature: TemperatureValue; count: number }; cell: { treatment: string; treatmentZh: string; temperature: TemperatureValue; count: number };
anchorMode: AnchorModeValue;
onCancel: () => void; onCancel: () => void;
/// 确定 —— 带上「确定」按钮的视口矩形,数据流粒子从那里起飞(⛔ 不能事后再查,窗已经关了) /// 确定 —— 带上「确定」按钮的视口矩形,数据流粒子从那里起飞(⛔ 不能事后再查,窗已经关了)
onConfirm: (from: { x: number; y: number; w: number; h: number }) => void; onConfirm: (from: { x: number; y: number; w: number; h: number }) => void;
...@@ -85,7 +78,6 @@ export function CohortPreview({ ...@@ -85,7 +78,6 @@ export function CohortPreview({
clinicId, clinicId,
potentialTreatment: cell.treatment, potentialTreatment: cell.treatment,
temperature: cell.temperature, temperature: cell.temperature,
anchor: anchorMode,
...(keyword ? { keyword } : {}), ...(keyword ? { keyword } : {}),
page: p, page: p,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
...@@ -107,7 +99,7 @@ export function CohortPreview({ ...@@ -107,7 +99,7 @@ export function CohortPreview({
setLoading(false); setLoading(false);
} }
}, },
[clinicId, cell.treatment, cell.temperature, anchorMode, keyword], [clinicId, cell.treatment, cell.temperature, keyword],
); );
/// 条件一变就回到第一页并清空 —— ⛔ 不能只追加:那会把新条件的结果接在旧结果后面 /// 条件一变就回到第一页并清空 —— ⛔ 不能只追加:那会把新条件的结果接在旧结果后面
...@@ -151,7 +143,6 @@ export function CohortPreview({ ...@@ -151,7 +143,6 @@ export function CohortPreview({
const cellTotal = total ?? cell.count; const cellTotal = total ?? cell.count;
const hasFilter = Boolean(keyword); const hasFilter = Boolean(keyword);
const shown = filtered ?? cellTotal; const shown = filtered ?? cellTotal;
const tag = ANCHOR_MODE_META[anchorMode].batchTag;
return ( return (
<div <div
...@@ -165,7 +156,6 @@ export function CohortPreview({ ...@@ -165,7 +156,6 @@ export function CohortPreview({
<div className="flex flex-none items-baseline gap-2 border-b px-4 py-3"> <div className="flex flex-none items-baseline gap-2 border-b px-4 py-3">
<span className="text-[13.5px] font-semibold text-slate-900"> <span className="text-[13.5px] font-semibold text-slate-900">
{cell.treatmentZh} · {TEMPERATURE_META[cell.temperature].zh} {cell.treatmentZh} · {TEMPERATURE_META[cell.temperature].zh}
{tag ? `(${tag})` : ''}
</span> </span>
{/* ⚠️ 总数取**服务端**的 total,⛔ 不用格子上那个数:格子可能是几秒前的快照 */} {/* ⚠️ 总数取**服务端**的 total,⛔ 不用格子上那个数:格子可能是几秒前的快照 */}
<span className="nums text-[12px] text-slate-500">共 {cellTotal} 人</span> <span className="nums text-[12px] text-slate-500">共 {cellTotal} 人</span>
......
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import { TEMPERATURE_AXIS_ZH, TEMPERATURE_META, type TemperatureValue } from '@pac/types';
AnchorMode,
ANCHOR_MODE_META,
TEMPERATURE_META,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
import { plansApi, type PoolMatrix as PoolMatrixData } from '@/components/plans/plans-api'; import { plansApi, type PoolMatrix as PoolMatrixData } from '@/components/plans/plans-api';
import { PoolMatrix } from '@/components/plans/pool-matrix'; import { PoolMatrix } from '@/components/plans/pool-matrix';
import { useAssignmentSyncStore } from '@/stores/assignment-sync-store'; import { useAssignmentSyncStore } from '@/stores/assignment-sync-store';
...@@ -43,17 +37,14 @@ type Picked = { ...@@ -43,17 +37,14 @@ type Picked = {
}; };
/** /**
* 🔴 口径要**写进交给助手的那句话里**。 * 移交只有一条自然语言通道(模型看不到界面),所以这句话必须**自己说清是哪一格**。
* *
* 移交只有一条自然语言通道(模型看不到界面开关),所以「按末诊」这三个字就是 * ⚠️ 2026-08-13 之前这里还要补一个「(按末诊)」标记,因为档位有两版口径 ——
* 主管选择的**唯一载体** —— `ANCHOR_MODE_TOOL_DESC` 那边的判据也正是"话里带没带这三个字"。 * 现在只剩一版(距上次到诊多久),标记跟着 `AnchorMode` 一起删了。⛔ 别加回来。
* ⛔ 别改这句的措辞而不同步改工具说明:改了模型就不传 `anchorMode`,
* 主管在按末诊那版上点的格子,确认单会按诊断口径捞另一批人 —— 而且不报错(T14)。
*/ */
function handoff(c: Picked, mode: AnchorModeValue) { function handoff(c: Picked) {
const tempZh = TEMPERATURE_META[c.temperature].zh; const tempZh = TEMPERATURE_META[c.temperature].zh;
const tag = ANCHOR_MODE_META[mode].batchTag; const cell = `${c.treatmentZh} · ${tempZh}`;
const cell = `${c.treatmentZh} · ${tempZh}${tag ? `(${tag})` : ''}`;
useAssistantStore.getState().handoff({ useAssistantStore.getState().handoff({
text: `帮我给「${cell}」这批患者出一份分配方案`, text: `帮我给「${cell}」这批患者出一份分配方案`,
from: c.rect, from: c.rect,
...@@ -71,31 +62,14 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) { ...@@ -71,31 +62,14 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
/** /**
* 🔴 档位口径 —— **这是口径开关,不是样式开关**(⛔ 别拿它跟当年那个「样式1/样式2」类比)。 * 换诊所先清空 —— ⛔ 不能让上一家的人数在新标题下面多留一帧
* 它决定分到的是哪批人:同一个「三个月内」,按诊断和按末诊是两批完全不同的人。
*
* ⚠️ 记进 localStorage 是为了**别让他每次进来都要重切**(主管多半长期用同一版);
* 但切换那一刻必须**看得见**(下面开关旁边那句说明),⛔ 不能像样式开关那样悄悄记住。
*/
const [mode, setMode] = useState<AnchorModeValue>(AnchorMode.DIAGNOSIS);
useEffect(() => {
const v = localStorage.getItem('pac.anchorMode');
if (v === AnchorMode.LAST_VISIT || v === AnchorMode.DIAGNOSIS) setMode(v);
}, []);
const pickMode = (m: AnchorModeValue) => {
setMode(m);
localStorage.setItem('pac.anchorMode', m);
};
/**
* 换诊所 / 换口径先清空 —— ⛔ 不能让上一版的人数在新标题下面多留一帧
* (那一帧里主管点下去,拿到的就是另一批人)。 * (那一帧里主管点下去,拿到的就是另一批人)。
* ⚠️ 只挂 `clinicId` / `mode`:下面那次「分完重拉」不走这里,否则每分一批矩阵都要闪一下骨架。 * ⚠️ 只挂 `clinicId`:下面那次「分完重拉」不走这里,否则每分一批矩阵都要闪一下骨架。
*/ */
useEffect(() => { useEffect(() => {
setMatrix(null); setMatrix(null);
setError(null); setError(null);
}, [clinicId, mode]); }, [clinicId]);
/** /**
* 取矩阵。**助手那边确认/撤销之后也要重拉**(`syncSeq`): * 取矩阵。**助手那边确认/撤销之后也要重拉**(`syncSeq`):
...@@ -106,20 +80,13 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) { ...@@ -106,20 +80,13 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
if (!clinicId) return; if (!clinicId) return;
let alive = true; let alive = true;
plansApi plansApi
.matrix(clinicId, mode) .matrix(clinicId)
.then((r) => alive && setMatrix(r)) .then((r) => alive && setMatrix(r))
.catch((e) => alive && setError(e instanceof Error ? e.message : '取矩阵失败')); .catch((e) => alive && setError(e instanceof Error ? e.message : '取矩阵失败'));
return () => { return () => {
alive = false; alive = false;
}; };
}, [clinicId, syncSeq, mode]); }, [clinicId, syncSeq]);
/**
* ⚠️ 口径取**服务端回声**(`matrix.anchorMode`)而不是本地 state:
* 两者理论上一致,但万一请求慢/失败留着旧数据,回声能保证"这批数是按哪版算的"跟着数据走,
* ⛔ 不会出现"开关已经切了、格子还是旧那版、点下去按新那版圈人"这种错位。
*/
const dataMode = matrix?.anchorMode ?? mode;
/** /**
* 🔴 点格子**不再直接移交**(2026-08-11 产品定):先弹中间表,让主管看清这批人是谁, * 🔴 点格子**不再直接移交**(2026-08-11 产品定):先弹中间表,让主管看清这批人是谁,
...@@ -142,31 +109,10 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) { ...@@ -142,31 +109,10 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
<span className="text-[11px] text-slate-400"> <span className="text-[11px] text-slate-400">
点一格 = 把这批人交给助手,助手出确认单 点一格 = 把这批人交给助手,助手出确认单
</span> </span>
{/* ⭐ 口径开关 —— 挨着列头放,主管点格子之前就知道这些数是按什么算的。 {/* ⚠️ 这句不是装饰:列头写的是「三个月内」,而"什么的三个月"只有这一处说得清。
⚠️ 旁边那句 axisZh 不是装饰:列头写的是「三个月内」,而"什么的三个月" ⛔ 别为了省空间把它收进 tooltip。 */}
只有这一处说得清;⛔ 别为了省空间把它收进 tooltip。 */} <span className="ml-auto hidden text-[11px] text-slate-400 sm:inline">
<span className="ml-auto flex items-center gap-2"> 按{TEMPERATURE_AXIS_ZH}距今多久分档
<span className="hidden text-[11px] text-slate-400 sm:inline">
{ANCHOR_MODE_META[dataMode].axisZh}
</span>
<span className="inline-flex rounded-lg bg-slate-100 p-0.5">
{([AnchorMode.DIAGNOSIS, AnchorMode.LAST_VISIT] as const).map((m) => (
<button
key={m}
type="button"
title={ANCHOR_MODE_META[m].hint}
onClick={() => pickMode(m)}
className={cn(
'rounded-md px-2.5 py-1 text-[11.5px] transition-colors',
mode === m
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700',
)}
>
{ANCHOR_MODE_META[m].zh}
</button>
))}
</span>
</span> </span>
</div> </div>
{/* ⚠️ 小屏兜底:矩阵有六列,窄了就横向滚,⛔ 别让它压缩到看不清数字 */} {/* ⚠️ 小屏兜底:矩阵有六列,窄了就横向滚,⛔ 别让它压缩到看不清数字 */}
...@@ -183,7 +129,6 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) { ...@@ -183,7 +129,6 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
data={matrix} data={matrix}
selected={null} selected={null}
onPick={pick} onPick={pick}
anchorMode={dataMode}
className="w-full min-w-[34rem]" className="w-full min-w-[34rem]"
/> />
) : ( ) : (
...@@ -199,11 +144,10 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) { ...@@ -199,11 +144,10 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
<CohortPreview <CohortPreview
clinicId={clinicId} clinicId={clinicId}
cell={preview} cell={preview}
anchorMode={dataMode}
onCancel={() => setPreview(null)} onCancel={() => setPreview(null)}
onConfirm={(from) => { onConfirm={(from) => {
setPreview(null); setPreview(null);
handoff({ ...preview, rect: from }, dataMode); handoff({ ...preview, rect: from });
}} }}
/> />
)} )}
......
...@@ -7,6 +7,7 @@ import { SupervisorHeader } from './supervisor-header'; ...@@ -7,6 +7,7 @@ import { SupervisorHeader } from './supervisor-header';
import { BatchTracking } from './batch-tracking'; import { BatchTracking } from './batch-tracking';
import { TeamStatus } from './team-status'; import { TeamStatus } from './team-status';
import { NewBatchPanel } from './new-batch'; import { NewBatchPanel } from './new-batch';
import { useAssistantStore } from '@/stores/assistant-store';
/** /**
* 主管工作台 —— 左「分一批新的 + 我分的批次」/ 右「团队现在什么状态」。 * 主管工作台 —— 左「分一批新的 + 我分的批次」/ 右「团队现在什么状态」。
...@@ -23,6 +24,14 @@ export function SupervisorWorkbench() { ...@@ -23,6 +24,14 @@ export function SupervisorWorkbench() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const clinics = visibleClinics(user); const clinics = visibleClinics(user);
const [clinicId, setClinicId] = useState<string | null>(null); const [clinicId, setClinicId] = useState<string | null>(null);
/**
* ⭐ 把"当前看的是哪家诊所"同步给助手 —— 它随每次 chat 请求发给服务端,
* 当作 `propose_assignment` 的诊所兜底。⛔ 别指望模型从对话里推出来:
* 移交那句话里根本没有诊所(见 assistant-store 的 activeClinicId 注释)。
*/
useEffect(() => {
useAssistantStore.getState().setActiveClinicId(clinicId);
}, [clinicId]);
/** /**
* 默认落到第一个可见诊所。 * 默认落到第一个可见诊所。
......
'use client'; 'use client';
import { create } from 'zustand'; import { create } from 'zustand';
import type { SheetSnapshot } from '@pac/types';
import { emitPetEvent } from '@/lib/pet-events'; import { emitPetEvent } from '@/lib/pet-events';
import { STREAM_TOTAL_MS } from '@/components/assistant/cohort-stream'; import { STREAM_TOTAL_MS } from '@/components/assistant/cohort-stream';
...@@ -55,6 +56,32 @@ interface AssistantState { ...@@ -55,6 +56,32 @@ interface AssistantState {
treatment?: string; treatment?: string;
temperature?: string; temperature?: string;
}) => void; }) => void;
/**
* 主管当前正看着哪家诊所 —— **跟着每次 chat 请求一起发给服务端**。
*
* 🔴 为什么必须走 store 而不是让模型问出来:移交那句话只有
* 「帮我给「拔牙 · 3 年以上」这批患者出一份分配方案」,**里面没有诊所**。
* 模型没得传 → 服务端回落 `scope.clinicIds[0]` → 多诊所主管在第二家的矩阵上点一格,
* 确认单按**第一家**出,人数跟他刚看到的格子对不上**而且不报错**。
* ⚠️ `get_current_user` 救不了这个:它返回的是诊所**列表**,
* 模型照样不知道主管刚才在看哪一家。⇒ 上下文走旁路,⛔ 别让模型转译。
* ⚠️ 客服侧(患者详情里的助手)没有诊所选择器 → 一直是 null → 服务端行为与从前一致。
*/
activeClinicId: string | null;
setActiveClinicId: (clinicId: string | null) => void;
/**
* 🔴 **眼前那张还没确认的确认单,此刻的样子** —— 与 `activeClinicId` 同一条旁路,
* 跟着每次 chat 请求发给服务端,模型用 `get_current_sheet` 取。
*
* ⚠️ 为什么非走这条路不可:确认之前,改派/移出/时效/福利**只存在于卡片组件里**,
* 服务端没有可查的东西(见 `SheetSnapshotSchema` 上那段)。
* ⚠️ 只有**现役**那张挂在这里 —— 卡片自己在 effect 里挂、卸载时只清自己那份
* (按 requestId 比对),否则重出一版后旧卡片卸载会把新的一起清掉。
* ⚠️ 它是**照片不是账本**:主管下一秒拖一下就过期,所以每次请求现取现发,
* ⛔ 服务端不许缓存。
*/
sheetSnapshot: SheetSnapshot | null;
setSheetSnapshot: (snapshot: SheetSnapshot | null) => void;
/** 消费方(AssistantChat)发完后调,避免重复发送 */ /** 消费方(AssistantChat)发完后调,避免重复发送 */
consume: (seq: number) => void; consume: (seq: number) => void;
} }
...@@ -83,6 +110,10 @@ export const useAssistantStore = create<AssistantState>((set, get) => ({ ...@@ -83,6 +110,10 @@ export const useAssistantStore = create<AssistantState>((set, get) => ({
*/ */
window.setTimeout(() => get().ask(text), STREAM_TOTAL_MS); window.setTimeout(() => get().ask(text), STREAM_TOTAL_MS);
}, },
activeClinicId: null,
setActiveClinicId: (activeClinicId) => set({ activeClinicId }),
sheetSnapshot: null,
setSheetSnapshot: (sheetSnapshot) => set({ sheetSnapshot }),
consume: (seq) => { consume: (seq) => {
// 只清掉自己那一条 —— 期间若又来了新请求(seq 更大),不能连它一起清 // 只清掉自己那一条 —— 期间若又来了新请求(seq 更大),不能连它一起清
if (get().pending?.seq === seq) set({ pending: null }); if (get().pending?.seq === seq) set({ pending: null });
......
...@@ -45,7 +45,7 @@ ...@@ -45,7 +45,7 @@
| 5 | `Signal` 类型 + 6 个判定器 + 需处置/提示分层 | **新建** `plan/assignment-signals.ts` | | 5 | `Signal` 类型 + 6 个判定器 + 需处置/提示分层 | **新建** `plan/assignment-signals.ts` |
| 6 | **基数改造**:每人每日 15 × 在岗 × D,⛔ 不再沿用上次 | `assignment-proposal.service.ts` | | 6 | **基数改造**:每人每日 15 × 在岗 × D,⛔ 不再沿用上次 | `assignment-proposal.service.ts` |
| 7 | `AssignmentProposal` 加 `signals[]` | `packages/types` | | 7 | `AssignmentProposal` 加 `signals[]` | `packages/types` |
| 8 | 画像亮点基线(同治疗项池子的分布) | 复用 `cohort-attributes.service` | | 8 | ~~画像亮点基线(同治疗项池子的分布)~~ → **`narrowFacts` 一次查完,不比基线**(2026-08-13) | `cohort-attributes.service` |
| 9 | **intent 契约**:节点选项 → intent;`edit_assignment_sheet` 拆成意图级工具,与按钮共用同一组 intent | **新建** `plan/assignment-intents.ts` | | 9 | **intent 契约**:节点选项 → intent;`edit_assignment_sheet` 拆成意图级工具,与按钮共用同一组 intent | **新建** `plan/assignment-intents.ts` |
| 10 | 确认单卡片渲染节点区 + 选项按钮 | `assignment-confirm-sheet.tsx` | | 10 | 确认单卡片渲染节点区 + 选项按钮 | `assignment-confirm-sheet.tsx` |
......
...@@ -68,7 +68,7 @@ ...@@ -68,7 +68,7 @@
按顺序,全部确定性,一次返回: 按顺序,全部确定性,一次返回:
``` ```
1. 候选集 口径(治疗项 · 时间档 · 锚点)→ total · 可切维度 1. 候选集 口径(治疗项 · 时间档)→ total · 可切维度
排序:没被分过的优先 排序:没被分过的优先
2. 基数 每人每日 15 条 × 在岗人数 × D → N(与候选总数取小) 2. 基数 每人每日 15 条 × 在岗人数 × D → N(与候选总数取小)
...@@ -116,7 +116,7 @@ ...@@ -116,7 +116,7 @@
| | 动的是 | 走 | 例 | | | 动的是 | 走 | 例 |
|---|---|---|---| |---|---|---|---|
| **派法** | 这批人**怎么分** | 局部改,不重跑 | 「王强移出这批」「待分配各自归专属」「给 5 天」「带个福利」 | | **派法** | 这批人**怎么分** | 局部改,不重跑 | 「王强移出这批」「待分配各自归专属」「给 5 天」「带个福利」 |
| **人群** | 这批人**是谁** | 重跑,出新版 | 「只要商保直付的」「改成 200 人」「换成 1–2 年那档」「按末诊算」 | | **人群** | 这批人**是谁** | 重跑,出新版 | 「只要商保直付的」「改成 200 人」「换成 1–2 年那档」 |
**代价不对称**:把「人群」误判成「派法」→ 主管以为条件生效了、其实没有(**静默错**);反过来只是多算一次。 **代价不对称**:把「人群」误判成「派法」→ 主管以为条件生效了、其实没有(**静默错**);反过来只是多算一次。
⇒ **拿不准时一律走重跑。** ⇒ **拿不准时一律走重跑。**
...@@ -143,7 +143,7 @@ ...@@ -143,7 +143,7 @@
### 事实① 怎么选的(**选谁**) ### 事实① 怎么选的(**选谁**)
- 口径:治疗项 · 时间档 · 锚点模式(诊断 / 末诊) - 口径:治疗项 · 时间档(锚点只有末诊一版,见教条 T6″)
- 排序:没被分过的优先 - 排序:没被分过的优先
- 候选总数 → 取本批 N - 候选总数 → 取本批 N
...@@ -174,7 +174,7 @@ ...@@ -174,7 +174,7 @@
(T:关系层面的决定只有主管能做)。 (T:关系层面的决定只有主管能做)。
⚠️ 有待分配就**必须说出来**,⛔ 不许省、不许弱化成「另有若干」。 ⚠️ 有待分配就**必须说出来**,⛔ 不许省、不许弱化成「另有若干」。
⚠️ **但「还没定的」不只是待分配** —— 它是所有未处置的引导节点(另有每日超阈值、候选不足、画像亮点)。待分配只是 severity 最高的那一个。 ⚠️ **但「还没定的」不只是待分配** —— 它是所有未处置的引导节点(另有每日超阈值、候选不足、还能再筛一刀)。待分配只是 severity 最高的那一个。
--- ---
...@@ -227,56 +227,74 @@ N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在 ...@@ -227,56 +227,74 @@ N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在
待分配的人不发出去,这批**实发量就少了**。从池子里取**等量的无主患者**补进来: 待分配的人不发出去,这批**实发量就少了**。从池子里取**等量的无主患者**补进来:
既不动任何专属关系(T:不许替主管把患者从专属客服手里挪走),又把产能填满。 既不动任何专属关系(T:不许替主管把患者从专属客服手里挪走),又把产能填满。
#### `highlight` 展开 #### `narrow` 展开:还能再筛一刀
**基线取同治疗项的池子**,⛔ 不取全池(种植的重要价值天然高于补牙,用全池会让种植永远命中)。 🔴 **2026-08-13 换掉了原来的 `highlight`(画像亮点)。** 旧版比两份分布(这一格 vs 同治疗项池子),
用「倍数 ≥2 或高出 ≥12pp」判断哪一项"值得注意",命中的每一项各出一个节点。
命中条件是**两条取其一**(外加本批命中 ≥10 人、基线 ≥50 人): 删的理由**不是它算错了** —— 40 格扫下来 29 格命中,结果也有临床意义。是它**在替主管判断**
「这值不值得注意」,而他要的只是「还能怎么切」。⇒ 不比基线、不设显著性闸,把几刀直接摆出来。
| 口径 | 阈值 | 抓什么 | 顺带去掉的两样:那次**全治疗项池子扫描**(每出一次确认单都要跑),以及一个格子最多三条亮点
各带一个标题的排版(四个标题说的其实是同一件事)。
**一个节点、几个按钮**,挂在「这批人怎么来的」下面(`stage: 'cohort'`):
> 🔴 **措辞的要害:这是换个条件重新圈初选人群,⛔ 不是在已排好的那 N 个里再挑。**
> 原来标题写「这批 2663 人还能再筛一刀」,产品走查当场读成「在本批 495 人之后的进一步筛选」——
> 「再筛一刀」自带先后顺序的暗示,而「这批」在这条链路里同时指过候选集和本批。
> ⇒ 一律说**「这一格」**(他就是从矩阵格子点进来的),动词用**「圈」**不用「筛」,
> `why` 里明写「换的是这一格圈进来多少人,本批人数跟着重新估」。
| 刀 | 判据 | 实测(拔牙 · 3 年以上,候选 2,509) |
|---|---|---| |---|---|---|
| 倍数 | 本批占比 ÷ 基线 ≥ **2** | **小基数**维度的异常聚集 | | 消费 | 高于**这一批自己的**平均 | ¥720 → 312 人(12%) |
| 百分点 | 本批占比 − 基线 ≥ **12pp** | **大基数**维度的偏移 | | 转介绍达人 | 家庭型 ∪ 社交型 | 2 人 → ⛔ 不给(<10) |
| 权益身份 | 这批里**最多的那一项** | 儿牙会员 76 人(3%) |
| 获客渠道 | 同上 | 走入 804 人(32%) |
| 兜底 | —— | 「按别的条件圈」→ 模型去调 `get_cohort_attributes` |
> 🔴 2026-08-12 本地实测补的第二条。只用倍数时,**结构上打不着大类**: 闸只剩两条:**候选集 ≥ 50**、**一刀切完 ≥ 10 人**。
> 朝阳公园的获客渠道是 口碑 33% / 集团营销 23% / 走入 22%,
> 口碑要够 2 倍得占到 66%(现实里不可能);而能翻倍的小类(电商 2.3%)
> 在一格 71 人里连 10 个人都凑不出 ⇒ **整个维度永远不会命中**。
⚠️ 措辞跟着口径走:大基数说「高出 N 个百分点」,小基数说「约 N 倍」。 > 🔴 **⛔ 不给「最多的那一项」加占比闸。** 权益那种最高项只占 3% 的维度,一刀下去批次会从
> 495 人缩到 76 人 —— 但**人数就写在按钮上**,他点之前看得见(②可见可撤),
> 重排后 `short_supply` 还会再兜一次。加闸等于又替他判断了一次。
#### 实测:判据打得着,且结果有临床意义 > 🔴 **消费的门槛是批内相对的**,文案必须写「这批的平均」。实测全库平均 ¥4,442,
> 而「拔牙 · 3 年以上」这一格只有 ¥720,差六倍;写成「高消费客户」他就会拿去跨格子比。
> ⚠️ 消费分布右偏(这一格中位数是 **¥0**),所以"高于平均"选出的是一成多而不是一半 —— 正合适。
> ⚠️ 这一刀走 `minSpendYuan`(元),**⛔ 不走 `personaTags`**:那是个等值字典,表达不了「大于」。
2026-08-12 在北京朝阳公园(2,436 条活跃计划)扫了全部 40 格,**29 格命中**,两条口径各司其职: > ⛔ **`rfm.segment` 不参与。** 档位锚点改成末诊之后(T6″),时间档与 RFM 的 R 分完全同尺
> ⇒ 冷档里「低活跃 / 重要挽留」占比高是**恒等式不是发现**。R 被格子钉死,这个维度上
> 还能动的只有 M(消费)与 F(频次),所以只取消费。
| 口径 | 实例 | > ⚠️ **「转介绍达人」⛔ 不能写成「有过转介绍的」**:这个维度的门槛是**推荐 ≥3 人且带来成交**。
|---|---| > 要"推荐过 1 人也算",那是另一个判据,现有画像里没有 —— 得新做特征。
| 百分点 | 充填 · 三个月到半年:「一般发展」69% vs 整体 48%,高出 **21pp** |
| 倍数 | 牙周 · 2–3 年:「低活跃」49% vs 整体 11%,约 **4.6 倍** |
⭐ **只留倍数那一条的话,上面第一类全部打不着** —— 这就是加百分点判据的实证。 ⚠️ 折扣锚点仍未纳入:它的值是「最低折扣率 + 日期/项目」,是连续量、没有闭集取值,
要用它得先定分桶(≥9折 / 8–9折 / <8折),那是产品判断。
> 💡 它更适合做成**统计事实**(「这批人历史最深折扣中位数 X 折」,供福利定价参考)。
结果不是噪音,有明显规律(可作为判据可信的旁证): #### 引导节点分三组:改人群 / 改人数 / 改怎么派
- **`2–3 年` 那一列几乎全是「低活跃」**(牙周 4.6× / 修复 6.2× / 充填 5.0×)—— 时间档与活跃度强相关 每条节点带 `stage`,**由程序定**,⛔ 不靠提示词让模型自己判断该讲在哪一段:
- **`3 年以上` 几乎全是「重要价值」+「口碑客户」**(种植 60%、牙周 83%)——
⭐ **沉睡的恰恰是高价值的那批**,这是主管真会据此改打法的结论
- **早矫 / 正畸的 `3 年以上` 是「儿牙会员」**(早矫 81%)—— 买了年卡然后没再来
⚠️ 29/40 的命中率**不算高**:卡片上 `action` 层最多同时出现三条(含待分配),不会淹没真正要处置的那条。 | stage | 改的是 | 节点 | 出现在 |
⛔ 不要因为"命中太多"去调高阈值 —— 先确认它淹没了什么。 |---|---|---|---|
| `cohort` | 圈进来哪些人 | `narrow` · `short_supply` | 第 1 段,紧接正文 |
| `size` | 这一批发多少 | `batch_size_basis` | 第 1 段,`cohort` **之后** |
| `dispatch` | 怎么派 | `pending` · `daily_overload` | 第 3 段「要您定的」 |
| 亮点 | 选项 | > 🔴 **`cohort` 与 `size` 必须是两个键**,虽然都讲在第 1 段。合成一个键时模型会拿
|---|---| > 一个标题罩住两组按钮 —— 走查抓到「还能怎么圈:」下面挂着「改时效 / 改每天几通」,
| 重要价值 | 只做这些 / 优先给专属 | > 而改时效根本不是圈人。⇒ **一个键只说一件事**。
| 转介绍 | 话术带上介绍人 |
| 权益身份 | 权益到期未用当由头 | > ⚠️ 顺序 `cohort → size`,⛔ 不许反:换人群会让 N 重新估一遍
| 折扣锚点 | 福利定价参考上次折扣 | > (实测候选 2509→312 后,本批 495 被压成 312),先调 N 那下就白调了。
| 获客渠道集中 | 统一话术口径 |
⚠️ `编号`(`place_guidance` 的位置句柄)在三组之间**连着排** —— 各从 1 数起的话,
⚠️ **折扣锚点暂未纳入**:它的值是「最低折扣率 + 日期/项目」,是**连续量**,没有闭集取值 —— 几个「1 号」指向不同的东西,模型调 `place_guidance 1` 会把按钮插到另一条的解释下面,且不报错。
「占比是基线的几倍」无从谈起。要用它得先定分桶(如 ≥9折 / 8–9折 / <8折),那是产品判断。
> 💡 它更适合做成**统计事实**(「这批人历史最深折扣中位数 X 折」,供福利定价参考)而不是亮点。
### 交互形态:点击为主,自由输入兜底 ### 交互形态:点击为主,自由输入兜底
......
...@@ -326,11 +326,11 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾 ...@@ -326,11 +326,11 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
> 两轴天然互补:X 轴(潜在治疗)已去时间门,Y 轴(窗口温度)正好把时间维度补回来。 > 两轴天然互补:X 轴(潜在治疗)已去时间门,Y 轴(窗口温度)正好把时间维度补回来。
### T6 · 档位 = 诊断距今多久(一条绝对时间轴) ### T6 · 档位 = 距上次到诊多久(一条绝对时间轴)
不是客户价值、不是意愿、不是末诊天数,而是: 不是客户价值、不是意愿、不是诊断距今,而是:
| 档 | 定义(距锚点 = 该标签最新的未治疗诊断) | | 档 | 定义(距锚点 = 患者末诊 `patient_profiles.last_visit_at`) |
|---|---| |---|---|
| 三个月内 | `≤ 90 天` | | 三个月内 | `≤ 90 天` |
| 三个月到半年 | `90–180 天` | | 三个月到半年 | `90–180 天` |
...@@ -347,42 +347,51 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾 ...@@ -347,42 +347,51 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
> (cooldown)与打分衰减,**只是不再管矩阵档位** —— 两者已脱钩,⛔ 别顺手统一。 > (cooldown)与打分衰减,**只是不再管矩阵档位** —— 两者已脱钩,⛔ 别顺手统一。
> 天数常量的单一真理源:`HOT_BUCKET_DAYS` / `WARM_BUCKET_DAYS`(`packages/types/src/temperature.ts`)。 > 天数常量的单一真理源:`HOT_BUCKET_DAYS` / `WARM_BUCKET_DAYS`(`packages/types/src/temperature.ts`)。
#### T6″ · 锚点两版(2026-08-11)—— 距**哪个**时刻多久 #### T6″ · 锚点 = 末诊,只此一版(2026-08-13)
「机会有多老」**不可观测**:`onset_date` 在标准里但宿主 0 填充。手上只有两个观测时间, 「机会有多老」**不可观测**:`onset_date` 在标准里但宿主 0 填充。手上只有两个观测时间,
各自诚实,主管在矩阵上现切(`AnchorMode`): 2026-08-11 曾把两版都做出来让主管在矩阵上现切(`AnchorMode`),**两天后删掉了按诊断那版**。
现在锚点只有一个:患者末诊。
| mode | 锚点 | 回答的问题 | > 🔴 **⛔ 别把「按诊断」加回来。** 它有一个已量化的系统偏差:复诊时医生常不重复写同一诊断 ——
|---|---|---|
| `diagnosis`(默认) | 医生最后一次写下该诊断 | 患者最后一次**被提醒**是多久前(话术) |
| `last_visit` | 患者末诊(`patient_profiles.last_visit_at`) | 这人多久**没露面**(接通率) |
> 🔴 `diagnosis` 有一个已量化的偏差:复诊时医生常不重复写同一诊断 ——
> **测试服朝阳全量实测**(45,190 个机会):诊断后患者又来过的 19,605 个里, > **测试服朝阳全量实测**(45,190 个机会):诊断后患者又来过的 19,605 个里,
> **49.1%** 一次都没再写(期间平均又来 4.7 次),诊断日与末诊日**中位差 490 天** > **49.1%** 一次都没再写(期间平均又来 4.7 次),诊断日与末诊日**中位差 490 天**
> (矩阵上差 1–2 档);各医生重复记录率差约一倍。 > (矩阵上差 1–2 档);各医生重复记录率差约一倍。
> ⚠️ 所以 `diagnosis` 档位里含着「这个医生爱不爱重复写」的成分,⛔ 别读成临床新鲜度。 > ⇒ 那个档位里含着「这个医生爱不爱重复写」的成分,读不成临床新鲜度。
> 而召回问的是「这人多久没露面」,末诊直接回答它。
> >
> ⚠️⚠️ **量这类比例必须按「日期」比,⛔ 不能按时间戳**:同一次就诊里 emr 与 diagnosis 的 > ⚠️⚠️ **复量这类比例必须按「日期」比,⛔ 不能按时间戳**:同一次就诊里 emr 与 diagnosis 的
> `occurred_at` 可能差几毫秒,用 `>` 会把"本次就诊"误算成"后来又来过一次"—— > `occurred_at` 可能差几毫秒,用 `>` 会把"本次就诊"误算成"后来又来过一次"——
> 测试服上按时间戳算出来的"又来过"是 99.98%(真值 43%),而这个错**不会报错**。 > 测试服上按时间戳算出来的"又来过"是 99.98%(真值 43%),而这个错**不会报错**。
**🔴 这是口径开关,不是样式开关**(⛔ 别拿它跟当年那个「样式1/样式2」类比)。它决定分到的是哪批人,所以三处必须同时到位,缺一处就是 T14 事故: 删掉的不只是一个枚举 —— 一并去掉的是**同步三处口径**这件事本身。两版并存时,
「看」(`/plans/matrix?anchor=`)、「圈人」(`criteria.anchorMode`)、「批次名标记」
必须逐字一致,缺一处就是 T14 事故(主管看到 87 人、确认单捞出另一批,全程不报错)。
一版口径时这个事故**结构上不成立**。
> ⭐ 这是「①结构上不可能 ≫ ②可见可撤 ≫ ③校验/提示词」的一次应用:
> 口径同源最可靠的办法不是校验两边一致,而是**没有第二个选项**。
1. **看**(`/plans/matrix?anchor=`)与**圈人**(`cohort-filter` → `criteria.anchorMode`)必须同 mode; > ⚠️ 2026-08-11~13 之间建的批次,名字里可能带着「(按末诊)」标记 —— ⛔ 别去洗,
2. 随确认单进 `plan_assignments.criteria` 快照; > 批次名是主管指认一批的唯一抓手。新批次不再标(只剩一版,标了是废字)。
3. 批次名标出来(`ANCHOR_MODE_META[*].batchTag`,只标非默认那版 → 老批次不用改名)。
助手侧的判据只有一条:主管那句话里**带「按末诊」三个字**才传 `last_visit` > ⚠️ **拿本地库下结论前先看一眼末诊的跨度**(2026-08-11 踩过并当场被产品指出来):
(`ANCHOR_MODE_TOOL_DESC`)——⛔ 改矩阵那句移交措辞就必须同步改工具说明。 > 当时本地只有 5,825 个患者、**最早末诊 2024-06**,「3 年以上」恒为空,
> 差点被读成"沉睡患者其实一直在来"—— 其实是那批人**根本不在库里**。
> 补摄之后本地已是 30,000 人、末诊 `2020-01 ~ 2026-06`、其中 **9,559 人超 3 年**
> (2026-08-13 实测),这一档才有东西。⛔ 空列先怀疑数据,别怀疑口径。
> 🔴 **⛔ 别拿本地库量这两版的差异**(2026-08-11 踩过并当场被产品指出来): > ⚠️ 「三个月内」这一列**本来就常年是空的**,⛔ 别当成锚点改坏了:入池下界
> 本地只有 5,825 个患者、**最早末诊 2024-06**(全是近两年来过的人), > (`cooldownDays` 考虑期)已经把刚来过的人挡在召回池外 —— 换锚点前后都是 0(实测)。
> 于是「按末诊·3 年以上」在本地恒为空 —— 看着像"诊断三年前没做的人其实一直在来",
> 其实是那批沉睡患者**根本不在本地库里**。 > ⚠️ 末诊是**患者级**的:同一个人的所有治疗项必然落同一档,所以矩阵一行里
> 测试服全量(朝阳池子 7,168 格位)的真相相反:**「3 年以上」按末诊仍有 3,592 格**, > 一个患者只会出现在一列。⛔ 别把这当成取数漏了。
> 占池子一半以上;诊断 3 年+ 的格位里 **89% 末诊也是 3 年+**(真沉睡),只有 11% 是"人一直在来"。 >
> ⇒ 两版在真实数据上**比想象的接近**(粗档同档率 90%),⛔ 别照本地那份 71% 做判断。 > ⭐ 这顺带修掉了诊断锚点的一个毛病:那时同一个人的两条 plan 可能落在**两列**,
> 各列去重人数相加会大于池子人数(2026-08-13 实测拔牙:19,269 → 18,872)。
>
> ⚠️ 副作用:时间档与画像 `rfm` 的 R 分**完全同尺**(R 档界 730/1095 与档位边界重合)
> ⇒ 冷档里「低活跃 / 重要挽留」占比高是**恒等式**,不是发现 —— 画像亮点别拿 rfm 点灯。
#### T6′ · 落地口径(2026-08-02 定稿,实现见 `packages/types/src/temperature.ts`) #### T6′ · 落地口径(2026-08-02 定稿,实现见 `packages/types/src/temperature.ts`)
......
...@@ -614,6 +614,23 @@ export const SignalOptionSchema = z.object({ ...@@ -614,6 +614,23 @@ export const SignalOptionSchema = z.object({
label: z.string().describe('按钮文案,给主管看'), label: z.string().describe('按钮文案,给主管看'),
intent: z.string().describe('动作契约 id'), intent: z.string().describe('动作契约 id'),
args: z.record(z.string(), z.unknown()).optional(), args: z.record(z.string(), z.unknown()).optional(),
/**
* 这个选项要主管**填一个数**(时效几天 / 每天几通)—— 界面渲染成内联数字输入框,
* 提交时把值并进 `args[argKey]`。
*
* ⭐ 为什么不做成几个固定档的按钮:天数和通数是**连续量**,列 3/5/7 天必然漏掉他要的那个,
* 而漏掉的那次他只能去输入框打字 → 走模型理解 → 慢且可能会错。填数是确定性的。
* ⚠️ `min/max` 是**护栏**,⛔ 不是建议值:越界的数直接不给提交,别让它走到服务端才报错。
*/
input: z
.object({
argKey: z.string().describe('填的数并进 args 的哪个键'),
unit: z.string().describe('单位,显示在输入框后面(天 / 通)'),
min: z.number().int().positive(),
max: z.number().int().positive(),
default: z.number().int().positive().describe('预填值 —— 一般就是当前值'),
})
.optional(),
}); });
export type SignalOption = z.infer<typeof SignalOptionSchema>; export type SignalOption = z.infer<typeof SignalOptionSchema>;
...@@ -630,6 +647,31 @@ export const SignalSchema = z.object({ ...@@ -630,6 +647,31 @@ export const SignalSchema = z.object({
key: z.string(), key: z.string(),
severity: z.number().int().describe('越小越靠前;排序规则必须确定性'), severity: z.number().int().describe('越小越靠前;排序规则必须确定性'),
tier: SignalTierSchema, tier: SignalTierSchema,
/**
* 这一条**改的是什么** —— 决定它在对话里出现在哪儿(2026-08-13 产品定)。
*
* · `cohort` 改**圈进来哪些人**(换个条件圈 / 这一格人不够,往后放一档)
* · `size` 改**这一批发多少**(时效几天 × 每天几通 → N)
* · `dispatch` 改**怎么派**(专属客服排满怎么办 / 最忙的那位扛不扛得住)
* · `post_confirm` **已经分下去之后**还能做的事(补挂福利)——
* ⚠️ 它与前三个的区别不只是时间:前三个由 `computeSignals` 从提案算出来,
* 这一类**只有界面知道**(确认这个动作发生在模型发言之外,服务端也不在场)。
* ⛔ 别指望模型来摆它:确认不触发新一轮发言,等它开口 = 主管什么也看不到。
* ⛔ 也别把它加进 `GUIDANCE_KIND`(show_guidance 的取值域)——
* 那个工具只在出方案那一轮能调,给它一个永远调不成的值只是多一格填错的地方。
*
* 🔴 `cohort` 与 `size` **必须分开**,虽然两者都讲在第 1 段。
* 合成一个键时模型会拿一个标题罩住两组按钮(实测:「还能怎么圈:」下面挂着
* 「改时效 / 改每天几通」)—— 而改时效根本不是圈人。⇒ **一个键只说一件事**。
* ⚠️ 顺序是 `cohort` → `size`,⛔ 不许反:换人群会让 N 重新估一遍
* (实测候选 2509→312 之后,本批 495 直接被压成 312),先调 N 那下就白调了。
*
* 🔴 归属**由程序定**,⛔ 不靠提示词让模型自己判断该讲在哪一段 ——
* 那是每轮掷一次骰子,而且掷错了不报错(它只会把按钮摆到一段无关的话下面)。
* ⚠️ ⛔ 别拿它当排序键:排序仍然只看 `severity`(见 computeSignals 末尾)。
* 两个概念混在一起的话,以后想调分段就会连带把顺序也搅了。
*/
stage: z.enum(['cohort', 'size', 'dispatch', 'post_confirm']),
title: z.string().describe('一句话说清是什么'), title: z.string().describe('一句话说清是什么'),
why: z.string().describe('一句事实,⛔ 不是建议'), why: z.string().describe('一句事实,⛔ 不是建议'),
defaultLabel: z.string().describe('不动会怎样(no-op)'), defaultLabel: z.string().describe('不动会怎样(no-op)'),
...@@ -662,14 +704,23 @@ export const AssignmentProposalSchema = z.object({ ...@@ -662,14 +704,23 @@ export const AssignmentProposalSchema = z.object({
*/ */
temperature: z.string().nullable(), temperature: z.string().nullable(),
/** /**
* ⭐ 档位口径(`diagnosis` 按诊断 / `last_visit` 按末诊)—— 与 temperature 同等重要。 * ⭐ **主管在初选之上自己追加的条件** —— 引导节点靠它认出「这一版是他收窄来的」。
* *
* 🔴 同一个「三个月内」,两版圈出来是**两批不同的人**。它必须 * 🔴 2026-08-13 加。在此之前三条节点都只看**这一版的绝对状态**,认不出主管刚做了什么,
* ① 随确认单下发 → 前端写进 `criteria` 快照 → 批次名标「按末诊」; * 于是他按消费切完之后:
* ② 与主管当时在矩阵上切的那版**逐字一致**(T14)。 * · `short_supply` 把他自己造成的"人少"报成故障,还递上两个会**丢掉他刚加的条件**
* ⚠️ 老确认单没有这个字段 → 消费方按 `diagnosis` 兜底(那就是当时的唯一口径)。 * 的按钮(往后放一档 / 换个治疗项);
* · `narrow` 又递上「只圈消费高于这一格平均」—— 而那个阈值正是他那一刀造出来的
* (砍掉低端,均值从 ¥720 跳到 ¥5,246),点下去再跳一次,是个**自我收窄的螺旋**。
* ⚠️ 只记**主管追加的**,⛔ 不记矩阵那两轴(治疗项/时间档在上面单独有字段)——
* 格子本身小和他自己切小,是两件事。
*/ */
anchorMode: z.string().optional(), narrowedBy: z
.object({
personaTags: z.string().optional(),
minSpendYuan: z.number().optional(),
})
.nullable(),
/// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 —— /// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 ——
/// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对 /// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对
candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'), candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'),
...@@ -681,6 +732,22 @@ export const AssignmentProposalSchema = z.object({ ...@@ -681,6 +732,22 @@ export const AssignmentProposalSchema = z.object({
* 而主管完全看不出为什么变小了。 * 而主管完全看不出为什么变小了。
*/ */
batchSize: z.number().int(), batchSize: z.number().int(),
/**
* 🔴 **算 `batchSize` 时乘进去的那个在岗人数** —— 存在的唯一理由是让
* 「在岗 N 人 × 每天几通 × 时效」这句话里的 N **就是乘法用的那个变量**。
*
* ⚠️ 2026-08-15 实测事故:两处显示(`modelFacts`、`batch_size_basis` 卡片标题)
* 都拿 `byAgent.length` 当"在岗人数"印 —— 那是**本批分到人的客服数**,不是名册。
* 上海世纪公园实测印出「在岗 **9** 人 × 每天 15 通 × 1 天」,而主管同屏
* 「团队现在什么状态」写着 **33 位在岗**,且 9×15×1=135 连它自己那行的
* 「本批人数 214」都算不出来(真实是 33×15×1=495,再被候选 214 压下来)。
* 9 是水位法的结果:214 人里 211 个有专属、只落在 6 位客服头上(一人独占 146),
* 每人封顶水位 7 → 6 位;剩 3 个无主的铺给最空的 3 位 → 6+3=9。
* ⇒ ⛔ 别再从 `byAgent`/`skippedAgents` 反推名册大小(加法能算对,但下一个
* 读代码的人仍要自己想明白"为什么是这两个数相加"),⭐ 由算 `batchSize` 的
* **同一行**赋值,让句子和乘法共用一个真源。
*/
rosterCount: z.number().int().describe('在岗名册人数(= 估算基数时乘进去的那个数)'),
/// ── 两个基数,连同它们的**出处**一起下发 ──────────────────────── /// ── 两个基数,连同它们的**出处**一起下发 ────────────────────────
/// ⚠️ 出处必须跟着值走:主管看到「容量 20」时,「这 20 是哪来的」和这个数本身一样重要 —— /// ⚠️ 出处必须跟着值走:主管看到「容量 20」时,「这 20 是哪来的」和这个数本身一样重要 ——
/// 沿用上次 / 首次默认 / 他自己刚说的,三者对应完全不同的信任度(T14:界面元素也算证据)。 /// 沿用上次 / 首次默认 / 他自己刚说的,三者对应完全不同的信任度(T14:界面元素也算证据)。
...@@ -731,10 +798,18 @@ export const AssignmentProposalSchema = z.object({ ...@@ -731,10 +798,18 @@ export const AssignmentProposalSchema = z.object({
z.object({ userId: z.string(), name: z.string().nullable(), inHand: z.number().int() }), z.object({ userId: z.string(), name: z.string().nullable(), inHand: z.number().int() }),
), ),
rosterNote: z.string(), rosterNote: z.string(),
/// 基数说明:两个基数的值 + **出处**(沿用哪次/首次默认/本次指定)+ 分配后的水位 /**
basisNote: z.string(), * 🔴 `basisNote` **已于 2026-08-14 删除** —— ⛔ 别加回来。
*
* 它是"成品句子给助手照抄"那一代的产物,而 P2 之后模型拿的是**结构化事实**
* (`modelFacts`)。走查发现它**只写不读**:服务端每次算一遍、随确认单发到浏览器、
* 然后被丢掉 —— 前端只渲染 `refillNote`,`modelFacts` 不带它,企微那条道也不碰。
* ⚠️ 里面还留着两处过期内容:「沿用您 X 月 X 日那次」(沿用 2026-08-12 就断开了)、
* 以及 `basis='default'` 时拼出「都是**系统默认**(…**您设的** 405 没变)」自相矛盾。
* ⇒ 同一批数现在的出口:模型正文(modelFacts)+ 引导节点,⛔ 不要再开第三个。
*/
selectionNote: z.string(), selectionNote: z.string(),
/// 「待分配」那段(含分布说明 + 顶替提示);没有待分配时为空串。⚠️ 排在 basisNote **之后**说 /// 「待分配」那段(含分布说明 + 顶替提示);没有待分配时为空串
pendingNote: z.string(), pendingNote: z.string(),
/** /**
* 🔴 **「重新排一版」到底做了什么** —— 由**卡片自己**渲染,⛔ 不走助手。空串 = 没什么可说的。 * 🔴 **「重新排一版」到底做了什么** —— 由**卡片自己**渲染,⛔ 不走助手。空串 = 没什么可说的。
...@@ -749,22 +824,6 @@ export const AssignmentProposalSchema = z.object({ ...@@ -749,22 +824,6 @@ export const AssignmentProposalSchema = z.object({
*/ */
refillNote: z.string(), refillNote: z.string(),
/** /**
* 🔴 **运营结论** —— 主管扫一眼就该知道的三件事,⛔ 不是分配明细的复述。
*
* 由来(2026-08-04 产品评审):主管当场说他没法审这张单 ——
* 「我不可能挨个去看他分的对不对,你在这儿应该给的是结论性的东西…
* **如果你不能告诉他哪不对、哪里需要主管判断,那就默认他必须一条一条看过去才能确认**」
*
* 于是这段只回答三个问题,每个都是"看完就能决定按不按确认"的:
* ① **这是多大的活** —— 分完之后每人手上多少条 ≈ 几天的量(见 DAILY_CALLS_PER_AGENT)
* ② **这批给谁加了担子** —— 本批给他几条(⚠️ 与"手上总量"是两个数,必须分开说)
* ③ **谁的这批全是自己的老客户** —— 那种批次主管基本不用看,可以直接过
*
* ⚠️ ⛔ 别把「待分配」的解释塞进来:那件事有 pendingNote,而且它问的是"要你拍板什么",
* 和这段问的"这活多大"不是一回事,混在一起两边都读不清。
*/
opsNote: z.string(),
/**
* 🔴 **引导节点** —— 主管这一版还有什么要定的(见 SignalSchema)。 * 🔴 **引导节点** —— 主管这一版还有什么要定的(见 SignalSchema)。
* *
* ⚠️ 与三段 `*Note` 的分工:Note 说的是**已经确定的事实**(怎么选的 / 怎么派的), * ⚠️ 与三段 `*Note` 的分工:Note 说的是**已经确定的事实**(怎么选的 / 怎么派的),
...@@ -777,6 +836,63 @@ export const AssignmentProposalSchema = z.object({ ...@@ -777,6 +836,63 @@ export const AssignmentProposalSchema = z.object({
export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>; export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;
/** /**
* 🔴 **眼前那张确认单「此刻」的样子** —— 主管在卡片上改过之后的**草稿态**。
*
* ═══ 为什么它必须由浏览器产 ═══════════════════════════════════════
* 确认之前,改动**全部只存在于卡片的组件状态里**(改派 / 移出 / 时效 / 福利)——
* 服务端手上只有最初生成的那一版提案,还是个请求内的闭包,没落库也没缓存。
* ⇒ ⛔ 别指望服务端"查"得到它:`get_current_sheet` 查的就是这份随请求捎来的快照,
* ⛔ 也别为它去开一条"草稿回传"的写路径(多一份要维护的状态,只为省模型一次加法)。
*
* ⚠️ 它是**这一刻**的照片,不是账本:主管下一秒拖一下它就过期了。
* 所以它随**每次** chat 请求重新捎一份(与 `activeClinicId` 同一条旁路),
* ⛔ 别在服务端缓存它。
* ⚠️ 键名用英文:它是**线上契约**。给模型看的中文事实由服务端投影
* (`sheetSnapshotFacts`)—— 与 `modelFacts` 同一条纪律:事实投影只在服务端一处。
*/
export const SheetSnapshotSchema = z.object({
/// 哪一张单(服务端铸造的幂等键)——多轮之后能认出"说的是不是同一张"
requestId: z.string(),
/// 已经点过确认了没有。⚠️ true 时人员/时效已锁死,只有福利还能改
confirmed: z.boolean(),
/// 现在会真正落库的条数(= 卡片上「已排好 N 人」)
placed: z.number().int(),
/// 这些条落在几位客服头上
agents: z.number().int(),
/// 还留在「待分配」里、他一直没处置的 —— ⚠️ 确认时**不会落库**
pending: z.number().int(),
/// 他点 × 移出本批的条数
dropped: z.number().int(),
/// 被改派过的条数(拖动 / 助手改单都算)
moved: z.number().int(),
/// 整批时效
expiresInDays: z.number().int(),
/// 有几条单独改过时效(不跟整批走)
rowExpiryOverrides: z.number().int(),
/// 本批福利原文;null = 还没带
benefit: z.string().nullable(),
/// 逐人:现在谁手上几条
byAgent: z.array(z.object({ name: z.string(), count: z.number().int() })),
});
export type SheetSnapshot = z.infer<typeof SheetSnapshotSchema>;
/**
* 同一份快照压成**一行**,跟在「确认单已更新:…」后面一起进模型上下文。
*
* ⚠️ 只进 `modelText`,⛔ 不显示给主管 —— 这几个数**卡片上就在他眼前**,
* 界面上再写一行是同一件事读两遍;而模型看不见卡片,它只有这一行。
* ⚠️ 放在 `@pac/types` 而不是服务端的 `assignment-facts`:这行字是**浏览器**产的
* (回声发生在点一下的那一刻,服务端不在场)。⛔ 别在服务端另写一份措辞。
*/
export function sheetSnapshotLine(s: SheetSnapshot): string {
return (
`当前这张单:已排好 ${s.placed} 人 · ${s.agents} 位客服 · 待你定的 ${s.pending} 人 · ` +
`整批时效 ${s.expiresInDays} 天 · 福利${s.benefit ? `「${s.benefit}」` : '还没带'}` +
(s.dropped ? ` · 已移出本批 ${s.dropped} 人` : '')
);
}
/**
* 「重新排一版(尽量排满)」的入参 —— 卡片上那个按钮打过来的。 * 「重新排一版(尽量排满)」的入参 —— 卡片上那个按钮打过来的。
* *
* ⚠️ 条件必须**原样带回**:重新排的是**同一批人群**,只是换一种取人方式。 * ⚠️ 条件必须**原样带回**:重新排的是**同一批人群**,只是换一种取人方式。
...@@ -786,9 +902,17 @@ export const RefillProposalRequestSchema = z.object({ ...@@ -786,9 +902,17 @@ export const RefillProposalRequestSchema = z.object({
clinicId: z.string().min(1), clinicId: z.string().min(1),
potentialTreatment: z.string().nullable().optional(), potentialTreatment: z.string().nullable().optional(),
temperature: z.string().nullable().optional(), temperature: z.string().nullable().optional(),
/// ⚠️ 口径也要原样带回 —— 重排的是**同一批人群**,换口径等于换了人群
anchorMode: z.string().nullable().optional(),
personaTags: z.string().nullable().optional(), personaTags: z.string().nullable().optional(),
/**
* 🔴 **主管自己加的那一刀,必须一起带回**(2026-08-15 补 —— 此前整个漏了)。
*
* 他很可能已经收窄过几遍(「只选消费高于 ¥721 的 314 人」)。这两个条件少带一个,
* 「换无专属客服的患者补上」就**悄悄退回整格**去取人 —— 他刚圈掉的人又回来了,
* 而界面上只显示"重新排了一版",⛔ 不报错、⛔ 不提示。
* ⚠️ `personaTags` 当时在 schema 里但**前端一直没传**;`minSpendYuan` 是连 schema 都没有。
* ⇒ 契约齐了不等于传到了 —— 改这里时**顺手看一眼调用方**(use-assistant-chat 的 refill)。
*/
minSpendYuan: z.number().positive().optional(),
/// 基数 N 与时效原样带回,否则重排会退回"沿用上一次",人数悄悄变了 /// 基数 N 与时效原样带回,否则重排会退回"沿用上一次",人数悄悄变了
targetCount: z.number().int().positive().optional(), targetCount: z.number().int().positive().optional(),
expiresInDays: z.number().int().positive().max(90).optional(), expiresInDays: z.number().int().positive().max(90).optional(),
......
...@@ -3,8 +3,8 @@ import { lookupDxTreatment } from './canonical-codes'; ...@@ -3,8 +3,8 @@ import { lookupDxTreatment } from './canonical-codes';
/** /**
* 窗口温度 —— 初选矩阵的 Y 轴(X 轴见 persona-tag-filters 的 potential_treatment 8 类)。 * 窗口温度 —— 初选矩阵的 Y 轴(X 轴见 persona-tag-filters 的 potential_treatment 8 类)。
* *
* ═══ 口径(2026-08-11 产品改口:六档统一成一条**绝对时间轴**)═══════════ * ═══ 口径(2026-08-11 六档统一成一条**绝对时间轴**)═══════════════════
* 档位 = **诊断距今多久**,不是客户价值、不是意愿、不是末诊天数。 * 档位 = **距上次到诊多久**,不是客户价值、不是意愿、不是诊断距今。
* *
* ``` * ```
* [0, 90d] 三个月内 * [0, 90d] 三个月内
...@@ -13,6 +13,29 @@ import { lookupDxTreatment } from './canonical-codes'; ...@@ -13,6 +13,29 @@ import { lookupDxTreatment } from './canonical-codes';
* (1y, 2y] (2y, 3y] (3y, ∞) * (1y, 2y] (2y, 3y] (3y, ∞)
* ``` * ```
* *
* ═══ 锚点 = 末诊,只此一版(2026-08-13 产品定)═══════════════════════
* 此前默认锚在「医生最后一次写下这条诊断」,并存过一个 `AnchorMode` 开关。整套删了。
*
* 🔴 ⛔ **别把「按诊断」加回来。** 它有一个**已量化**的系统偏差:复诊时医生常不重复写
* 同一条诊断。**测试服朝阳全量** 45,190 个机会:诊断后又来过的 19,605 个里
* **49.1% 一次都没再写**,期间平均又来 4.7 次;诊断日与末诊日**中位相差 490 天**
* = 矩阵上差 1–2 档。各医生记录习惯还差一倍 ⇒ 那个档位里含着"这个医生爱不爱重复写"
* 的成分,⛔ 读不成临床新鲜度。而召回问的是「这人多久没露面」,末诊直接回答它。
* ⚠️⚠️ 复量这类比例**必须按日期比,⛔ 不能按时间戳**:同次就诊里 emr 与 diagnosis 的
* `occurred_at` 差几毫秒,用 `>` 会把"本次就诊"算成"又来过一次"(测试服上算出 99.98%,
* 真值 43%),而这个错不报错。
* ⚠️ **拿本地库下结论前先看一眼末诊的跨度**。2026-08-11 那次就栽在这:当时本地只有
* 5,825 人、最早末诊 2024-06,「3 年以上」恒为空,差点被读成"沉睡患者其实一直在来"——
* 其实是那批人**根本不在库里**。补摄之后本地已是 30,000 人、末诊 2020-01~2026-06、
* 其中 9,559 人超 3 年(2026-08-13 实测),这一档才有东西。⛔ 空列先怀疑数据,别怀疑口径。
* ⚠️ 末诊是**患者级**的:同一个人的所有治疗项必然落同一档。所以矩阵一行里
* 一个患者只会出现在一列 —— ⛔ 别把这当成取数漏了。
* ⭐ 顺带修掉了诊断锚点的一个毛病:那时同一个人的两条 plan 可能落在**两列**,
* 各列去重人数相加会大于池子人数(2026-08-13 实测拔牙:19,269 → 18,872)。
* ⚠️ 「三个月内」这一列**本来就常年是空的**,⛔ 别当成锚点改坏了:
* 入池下界(`cooldownDays` 考虑期)已经把刚来过的人挡在召回池外了 —— 两版锚点下都是 0。
* ⚠️ 末诊为 NULL → 落 `unknown`,⛔ 不许默认塞进某一档。
*
* 🔴 **前两档不再按治疗项自己的临床周期算**(2026-08-11 前的旧口径: * 🔴 **前两档不再按治疗项自己的临床周期算**(2026-08-11 前的旧口径:
* `[0, urgencyDayThreshold]` = 黄金期、`(urgencyDayThreshold, windowDays]` = 窗口内, * `[0, urgencyDayThreshold]` = 黄金期、`(urgencyDayThreshold, windowDays]` = 窗口内,
* 每个 K 码一套天数)。改成固定 90/180 天,理由是主管那一侧的可读性: * 每个 K 码一套天数)。改成固定 90/180 天,理由是主管那一侧的可读性:
...@@ -100,68 +123,25 @@ export const COLD_TEMPERATURES: readonly TemperatureValue[] = [ ...@@ -100,68 +123,25 @@ export const COLD_TEMPERATURES: readonly TemperatureValue[] = [
]; ];
/** /**
* 六档量的是「距**哪个**锚点多久」—— 两版并存,主管在矩阵上现切(2026-08-11 产品定)。 * 矩阵 Y 轴的**轴名** —— 列头上方那句「按 X 距今多久分档」用它。
* *
* ═══ 为什么要两版 ═══════════════════════════════════════════════════ * ⚠️ 这不是装饰:列头写的是「三个月内」,而"什么的三个月"决定了它是哪批人。
* 「这个机会有多老」**不可观测**:`onset_date`(起病日)在 PAC 标准里有,宿主 0 填充; * ⛔ 别把这句话从界面上拿掉。
* 龋洞在被发现之前已经长了多久,数据里没有、也不会有。手上只有两个观测时间,
* 各自诚实、各答一个问题:
* · `diagnosis` = 医生**最后一次写下**这条诊断 → 「患者最后一次被提醒」是多久前(话术)
* · `last_visit` = 患者**最后一次到诊** → 「这人多久没露面」(接通率 / 关系温度)
*
* 🔴 `diagnosis` 有一个**已量化**的系统偏差:复诊时医生常不重复写同一诊断
* (**测试服朝阳全量** 45,190 个机会:诊断后又来过的 19,605 个里 **49.1%** 一次都没再写,
* 期间平均又来 4.7 次;诊断日与末诊日**中位相差 490 天** = 矩阵上差 1–2 档)。
* 而且各医生记录习惯差一倍 ——
* ⚠️ 所以 `diagnosis` 档位里含着"这个医生爱不爱重复写"的成分,⛔ 别把它读成临床新鲜度。
* ⚠️⚠️ 量这类比例**必须按日期比,⛔ 不能按时间戳**:同次就诊里 emr 与 diagnosis 的
* `occurred_at` 差几毫秒,用 `>` 会把"本次就诊"算成"又来过一次"(测试服上算出 99.98%,
* 真值 43%),而这个错不报错。
* ⚠️ **⛔ 别拿本地库量两版差异**:本地只有 5,825 人、最早末诊 2024-06(全是近两年来过的),
* 「按末诊·3 年以上」在本地恒空 —— 那是采样偏差,不是业务发现。测试服上那一档仍有
* 3,592 格(占池子一半以上),诊断 3 年+ 的格位里 89% 末诊也是 3 年+。
*
* ⛔ **不要改用「最早那条诊断」**去求所谓真实年龄:那会重新引入反单调
* (上周刚查出的龋齿,因为身上有颗两年前的旧龋,整个人被判成冷)—— 见文件头 ①。
*
* 🔴 **这是口径开关,不是样式开关**(⛔ 别拿它跟当年那个「样式1/样式2」类比)。
* 它决定**分到的是哪批人**,所以:
* ① 圈人侧(`cohort-filter`)必须跟着同一个 mode —— 否则矩阵 87 人、确认单另一批人,
* 而且全程不报错(T14 口径对数,矩阵 67→324 那次事故的同构版本);
* ② 必须随确认单进 `plan_assignments.criteria` 快照,并在批次名里标出来 ——
* 否则事后没人能回答「这批当时按什么圈的」。
*/ */
export const AnchorMode = { export const TEMPERATURE_AXIS_ZH = '患者最后一次到诊';
/** 医生最后一次写下这条诊断(默认,历史行为) */
DIAGNOSIS: 'diagnosis',
/** 患者最后一次到诊(患者级,同一人所有行必然同档) */
LAST_VISIT: 'last_visit',
} as const;
export type AnchorModeValue = (typeof AnchorMode)[keyof typeof AnchorMode];
export const ANCHOR_MODE_META: Record< /**
AnchorModeValue, * 档位标签的**前缀** —— 「3 年以上」补全成「距上次到诊 3 年以上」。
{ zh: string; axisZh: string; hint: string; batchTag: string } *
> = { * 🔴 2026-08-14 产品走查:`modelFacts` 给模型的是 `"时间档": "3 年以上"`,
[AnchorMode.DIAGNOSIS]: { * 而**「3 年以上」自己说不清是什么的 3 年**。界面上不缺这半句(列头上方那行
zh: '按诊断', * `TEMPERATURE_AXIS_ZH` 就在旁边,位置本身提供了上下文);JSON 里没有位置,
axisZh: '医生最后一次提到', * 模型拿到的是一个孤零零的字符串 —— 同一个标签在界面上够用、在 JSON 里有歧义。
hint: '档位 = 医生最后一次写下这条诊断距今多久。复诊没再写就不会刷新,读作「患者最后一次被提醒」', * ⚠️ 修法是把**值补全**,⛔ 不是另加一个「口径」键:只剩末诊一版锚点,
/// 默认口径,批次名里**不标** —— 标了等于每个老批次都要改名 * 单独一个口径字段读起来像"还有别的口径",那正是 2026-08-13 删掉的东西。
batchTag: '', * ⛔ 别在别处再手写「距上次到诊」——`temperatureWindowHint` 和 facts 都取这一份。
}, */
[AnchorMode.LAST_VISIT]: { export const TEMPERATURE_SINCE_ZH = '距上次到诊';
zh: '按末诊',
axisZh: '患者最后一次到诊',
hint: '档位 = 这个人最后一次到诊距今多久。患者级 —— 同一个人的所有治疗项必然落同一档',
batchTag: '按末诊',
},
};
/** 入参解析:非法值一律回落默认(⛔ 别抛错 —— 查询串是用户可改的) */
export function parseAnchorMode(v: unknown): AnchorModeValue {
return v === AnchorMode.LAST_VISIT ? AnchorMode.LAST_VISIT : AnchorMode.DIAGNOSIS;
}
/** /**
* 前两档的**天数上界**(距锚点)—— 2026-08-11 产品定,固定值,与治疗项无关。 * 前两档的**天数上界**(距锚点)—— 2026-08-11 产品定,固定值,与治疗项无关。
...@@ -209,27 +189,27 @@ export function expandTemperatureFilter(v: string): readonly TemperatureValue[] ...@@ -209,27 +189,27 @@ export function expandTemperatureFilter(v: string): readonly TemperatureValue[]
export const TEMPERATURE_META: Record<TemperatureValue, { zh: string; hint: string }> = { export const TEMPERATURE_META: Record<TemperatureValue, { zh: string; hint: string }> = {
[Temperature.HOT]: { [Temperature.HOT]: {
zh: '三个月内', zh: '三个月内',
hint: '诊断距今不到三个月 —— 医生刚说过,患者还记得', hint: '上次到诊不到三个月 —— 人还热乎,当年那句话大概率还记得',
}, },
[Temperature.WARM]: { [Temperature.WARM]: {
zh: '三个月到半年', zh: '三个月到半年',
hint: '诊断距今三到六个月 —— 还来得及,话术要给个理由', hint: '上次到诊三到六个月 —— 还来得及,话术要给个理由',
}, },
[Temperature.COLD_1Y]: { [Temperature.COLD_1Y]: {
zh: '半年到 1 年', zh: '半年到 1 年',
hint: '诊断距今半年到 1 年 —— 情况变化不大,先问近况再谈方案', hint: '上次到诊半年到 1 年 —— 情况变化不大,先问近况再谈方案',
}, },
[Temperature.COLD_2Y]: { [Temperature.COLD_2Y]: {
zh: '1–2 年', zh: '1–2 年',
hint: '诊断距今 1-2 年 —— 当年的方案多半要重评,别照着老诊断讲', hint: '上次到诊 1-2 年 —— 当年的方案多半要重评,别照着老诊断讲',
}, },
[Temperature.COLD_3Y]: { [Temperature.COLD_3Y]: {
zh: '2–3 年', zh: '2–3 年',
hint: '诊断距今 2-3 年 —— 按"重新了解情况"开口,不要假设需求还在', hint: '上次到诊 2-3 年 —— 按"重新了解情况"开口,不要假设需求还在',
}, },
[Temperature.COLD_OVER]: { [Temperature.COLD_OVER]: {
zh: '3 年以上', zh: '3 年以上',
hint: '诊断距今 3 年以上 —— 长尾,期望值放低;⛔ 别用紧迫话术', hint: '上次到诊 3 年以上 —— 长尾,期望值放低;⛔ 别用紧迫话术',
}, },
}; };
...@@ -263,7 +243,20 @@ export const TEMPERATURE_TOOL_DESC: string = ...@@ -263,7 +243,20 @@ export const TEMPERATURE_TOOL_DESC: string =
'矩阵的一档(界面上就是列头那几个中文,主管说的也是它们)。⚠️ 必须与 potentialTreatment 同时给。' + '矩阵的一档(界面上就是列头那几个中文,主管说的也是它们)。⚠️ 必须与 potentialTreatment 同时给。' +
'按此表把主管的话翻成取值:' + '按此表把主管的话翻成取值:' +
TEMPERATURE_ORDER.map((t) => `${TEMPERATURE_META[t].zh}=${t}`).join(' · ') + TEMPERATURE_ORDER.map((t) => `${TEMPERATURE_META[t].zh}=${t}`).join(' · ') +
'。⚠️ `cold` = 后四档**合计**(诊断距今半年以上全体),' + /**
* 🔴 **命名陷阱**:`cold_Ny` 是「N−1 到 N 年」,⛔ 不是「N 年以上」。
*
* 2026-08-14 实测(DeepSeek,思维链原话):主管说「拔牙 · 3 年以上」,
* 模型照数字把 `3` 匹配成 `cold_3y` 出了一版(947 人),拿到返回值里的
* 「距上次到诊 **2−3 年**」才发现对不上,重调 `cold_over` 又出了一版 ——
* 白跑一次取数。
* ⚠️ 对照表本来就在下面且是对的,它**没查表、凭数字直觉填的** ⇒ 只给表不够,
* 要把**最容易撞的那一对**单独点出来(同一份描述里已有的两条硬约束也是这么写的)。
* ⛔ 枚举值本身不能改(API 参数 / SQL 谓词 / 已落库 JSON 路径都用它,见本文件头)。
*/
'\n🔴 ⛔ **别照数字直接匹配**:每个 `cold_Ny` 表示「N−1 到 N 年」——' +
'`cold_3y` 是「2–3 年」,主管说「3 年以上」要用 `cold_over`。' +
'\n⚠️ `cold` = 后四档**合计**(上次到诊半年以上全体),' +
'⛔ 只有主管明确说「半年以上 / 老单子」整体时才用 —— ' + '⛔ 只有主管明确说「半年以上 / 老单子」整体时才用 —— ' +
'他点的是矩阵里某一格时一律给**具体档位**,给成 cold 会把人数放大好几倍。' + '他点的是矩阵里某一格时一律给**具体档位**,给成 cold 会把人数放大好几倍。' +
'\n🔴 ⛔ **这些取值码只用于调工具,一个字都不许说给主管**(他界面上没见过)。' + '\n🔴 ⛔ **这些取值码只用于调工具,一个字都不许说给主管**(他界面上没见过)。' +
...@@ -272,22 +265,6 @@ export const TEMPERATURE_TOOL_DESC: string = ...@@ -272,22 +265,6 @@ export const TEMPERATURE_TOOL_DESC: string =
'要说「往后放一档」而不是「放宽温度」。'; '要说「往后放一档」而不是「放宽温度」。';
/** /**
* 档位口径的**工具参数说明** —— 助手 / MCP 的 `anchorMode` 一律取这里。
*
* 🔴 主管点矩阵格子时,那句话里会带「(按末诊)」——**只有带了这四个字才传 last_visit**。
* ⛔ 别自己推断:两版圈出来是两批不同的人,猜错了主管拿到的名单就是错的,而且不报错。
*/
export const ANCHOR_MODE_TOOL_DESC: string =
'档位按哪个时间算。' +
`${AnchorMode.DIAGNOSIS}=按诊断(默认,医生最后一次提到这件事多久了)· ` +
`${AnchorMode.LAST_VISIT}=按末诊(这个人最后一次到诊多久了)。` +
'\n🔴 判据只有一条:主管那句话里**带「按末诊」三个字**就传 `last_visit`,' +
'否则**一律不传**(= 按诊断)。⛔ 不许自己推断、不许问主管"要按哪个口径" —— ' +
'他在矩阵上已经切过了,那句话就是他的选择。' +
'\n⚠️ 回话时不要说这两个取值码,也不要说「锚点/口径」——' +
'要提就说「按末诊那版」,不提最好。';
/**
* 一条 gap 的温度边界。存 ISO 串(persona_features.data 是 JSON)。 * 一条 gap 的温度边界。存 ISO 串(persona_features.data 是 JSON)。
* *
* ⚠️ **必须是 `toISOString()` 的 UTC 形态** —— 全仓统一后 ISO 串的字典序 == 时间序, * ⚠️ **必须是 `toISOString()` 的 UTC 形态** —— 全仓统一后 ISO 串的字典序 == 时间序,
...@@ -397,14 +374,9 @@ export const POTENTIAL_TREATMENT_SOURCE_CODES: Record<string, readonly string[]> ...@@ -397,14 +374,9 @@ export const POTENTIAL_TREATMENT_SOURCE_CODES: Record<string, readonly string[]>
* ⛔ 参数没删是为了调用点稳定(而且真要按项目分档时,这里是唯一入口)。 * ⛔ 参数没删是为了调用点稳定(而且真要按项目分档时,这里是唯一入口)。
* 旧口径下这里要拼各码窗口的并集(拔牙 = K01 ∪ K03),现在整段没有了。 * 旧口径下这里要拼各码窗口的并集(拔牙 = K01 ∪ K03),现在整段没有了。
*/ */
export function temperatureWindowHint( export function temperatureWindowHint(label: string, temp: TemperatureValue): string {
label: string,
temp: TemperatureValue,
/// ⚠️ 口径决定这句话的**主语**:同一个「≤90 天」,按诊断是"医生提过",按末诊是"人来过"
mode: AnchorModeValue = AnchorMode.DIAGNOSIS,
): string {
void label; void label;
const from = mode === AnchorMode.LAST_VISIT ? '距上次到诊' : '医生最后一次提到'; const from = TEMPERATURE_SINCE_ZH;
const years = COLD_BUCKET_YEARS[temp]; const years = COLD_BUCKET_YEARS[temp];
if (years !== undefined) { if (years !== undefined) {
if (years === null) return `${from} > 3 年`; if (years === null) return `${from} > 3 年`;
......
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