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 {
qwenDefaultModel: string;
/// 单次 LLM 调用上限(秒),防卡死(安全网,默认 180;深度档每步各自计时)
requestTimeoutSec: number;
/**
* 助手的**人设语气**(整块换掉系统提示词的 ③ 层,见 assistant-prompts 的 VOICE_DEFAULT)。
* 空 = 用默认那套。宿主 / 品牌要自己的说话方式时配这个,⛔ 不要去改代码里的默认块。
* ⚠️ 它只管**怎么说**:⛔ 不许写事实、工具名、界面部件名 —— 那些换语气的人不该有能力碰。
*/
assistantVoice: string;
/// 价格表(¥/M tokens)— 从 AI_PRICE_TABLE_JSON env 读;调价时改 env 重启即可
priceTable: Record<string, { inHit: number; inMiss: number; out: number }>;
};
......@@ -72,10 +78,11 @@ export function loadConfig(): AppConfig {
geminiLiveModel: process.env.GEMINI_LIVE_MODEL ?? 'gemini-3.1-flash-live-preview',
qwenApiKey: process.env.QWEN_API_KEY ?? process.env.DASHSCOPE_API_KEY ?? '',
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 调用上限(秒)— 仅作"防永久挂起"安全网,不该卡正常请求。
// 取 180s:给 pro(慢·精细)+ 深度档单步留足余量;真挂起 3 分钟后失败 → 走模板兜底。
requestTimeoutSec: Number(process.env.AI_REQUEST_TIMEOUT_SEC ?? 180),
assistantVoice: process.env.PAC_ASSISTANT_VOICE ?? '',
priceTable: parsePriceTable(process.env.AI_PRICE_TABLE_JSON),
},
alert: {
......@@ -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 },
// Qwen3.7-Max 是旗舰最贵档(不是便宜模型):标准价 ¥/M 直接填
'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;
try {
......
......@@ -460,11 +460,26 @@ export class AiCallRunnerService {
cachedInputTokens: number = 0,
): number {
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
const hit = Math.min(cachedInputTokens, promptTokens);
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);
}
}
......
......@@ -37,7 +37,7 @@ export class DraftPersonaSummaryCall
readonly kind = 'summary' as const;
readonly callKey = 'draft_persona_summary';
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 safetyRules = safetyRules;
......
......@@ -76,7 +76,18 @@ export class DraftPlanSummaryCall
readonly kind = 'summary' as const;
readonly callKey = 'draft_plan_summary';
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 safetyRules = safetyRules;
......
......@@ -3,28 +3,37 @@ import { z } from 'zod';
/**
* DraftPlanSummary 输出 schema。
* 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({
onePage: z
.string()
.min(50)
.max(600)
.min(2)
.describe(
'一页快读 — 中文 200 字内 Markdown。客服打电话前 30 秒扫一眼,必含:称呼/价值/上次到店/本次主要任务/风险提示。**禁止 bullet list**,用 2-3 个短段落。',
),
medicalRecord: z
.string()
.min(80)
.max(1500)
.min(2)
.describe(
'病历摘要 — 中文 500 字内 Markdown。按时间倒序整理就诊/治疗/付费/影像 4 大类事件。可以用 bullet list 或时间轴。重点突出"待做治疗"和"未闭环的随访"。',
),
treatmentChain: z
.string()
.min(80)
.max(1500)
.min(2)
.describe(
'治疗链摘要 — 中文 500 字内 Markdown。按治疗链分节(每条链一个 `### 链名` 标题),讲清楚:当前阶段、下一步建议、若已闭环则一句话总结。**必须**引用具体牙位号 / 治疗节点。',
),
......
......@@ -68,7 +68,7 @@ export class DraftRecallBriefCall
readonly kind = 'summary' as const;
readonly callKey = 'draft_recall_brief';
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 safetyRules = safetyRules;
......
......@@ -47,7 +47,7 @@ export class DraftRecallSummaryCall
readonly kind = 'summary' as const;
readonly callKey = 'draft_recall_summary';
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 safetyRules = safetyRules;
......
......@@ -72,11 +72,23 @@ export class AiProviderService {
if (options?.body && typeof options.body === 'string') {
try {
const b = JSON.parse(options.body) as Record<string, unknown>;
b.enable_thinking = false;
// ⚠️ 仅"非工具调用"才强制 json_object(结构化输出框架 generateObject 用 json mode、无 tools)。
// tool-calling / agent(streamText + tools)不能用 JSON mode:① 与工具调用互斥
// ② DashScope 还会硬性要求 messages 含 "json" 字样 → 400。带 tools 时跳过注入。
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) {
b.response_format = { type: 'json_object' }; // DashScope 认 json_object(json_schema 不强制)
}
......@@ -107,7 +119,7 @@ export class AiProviderService {
const canonical = modelId === 'gemini' ? this.geminiDefaultModel : modelId;
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')) {
const canonical = modelId === 'qwen' ? this.qwenDefaultModel : modelId;
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(';');
......@@ -3,12 +3,13 @@ import { FileInterceptor } from '@nestjs/platform-express';
import type { Request, Response } from 'express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { ModelMessage } from 'ai';
import { SheetSnapshotSchema } from '@pac/types';
import { AssistantService, MAX_TOOL_STEPS } from './assistant.service';
import { TranscribeService } from './transcribe.service';
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';
import { buildSystemExtra } from './assistant-prompts';
/// multer 内存模式的最小文件形状(不引 @types/multer)
interface UploadedAudio {
......@@ -20,6 +21,26 @@ interface UploadedAudio {
interface ChatBody {
messages: ModelMessage[];
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 {
// ⭐ 侧信道:本地工具把确认单肥载荷直接推前端,不经模型上下文(见 AssistantChatInput)
onSideEvent: send,
scope,
// ⭐ 按能力切工作流约束(主管 / 客服两套)。
// ⚠️ 在此之前这个参数**从来没被 /assistant/chat 传过** ——
// 「一个助手,按角色切换工作流约束」这条既定取舍此前是零实现。
systemExtra: buildSystemExtra({
permissions: user.permissions,
userName: user.dictionary?.users?.[user.sub] ?? null,
}),
// ⭐ 角色层与现场层由服务端按权限装配(见 assistant-prompts 的分层)——
// ⚠️ 控制器只**供料**,⛔ 不在这里拼提示词:企微那条路也走同一个装配。
// ⭐ 主管当前看的诊所 —— propose_assignment 的诊所兜底(见 DTO 上那段)
...(body.activeClinicId ? { activeClinicId: body.activeClinicId } : {}),
// ⚠️ 解不出来就当没有 —— ⛔ 别把半份快照喂给模型(缺的字段会被读成 0)
...(SheetSnapshotSchema.safeParse(body.sheetState).success
? { sheetState: SheetSnapshotSchema.parse(body.sheetState) }
: {}),
abortSignal: ac.signal,
});
......@@ -180,6 +202,19 @@ export class AssistantController {
case 'text':
send({ type: 'text', text: (p.text as string) ?? (p.delta as string) ?? '' });
break;
/**
* ⭐ 思考也转发 —— 它跟正文走**同一条流**,按到达顺序穿插到消息里。
*
* 🔴 为什么值得摆出来:2026-08-14 裸台实测,模型"为什么这么调工具"全写在这儿 ——
* 「注意:show_guidance **一次只开放一件**」这句话直接指出了当轮只调一次的原因,
* 而它是**读了我们的工具描述之后**的结论,不是模型的毛病。
* ⇒ 没有它,归因只能靠猜,而猜的方向大概率是"模型不行"。
* ⚠️ 它是模型输出的一部分,最后随 messages 原样回传(DeepSeek 文档明确要求
* 思考内容参与后续轮次的上下文拼接)—— ⛔ 别在这里过滤掉。
*/
case 'reasoning-delta':
send({ type: 'reasoning', text: (p.text as string) ?? '' });
break;
case 'tool-input-start':
if (p.toolName === 'render_artifact') artToolIds.add(String(p.id));
break;
......
......@@ -3,6 +3,7 @@ import { AiModule } from '../ai/ai.module';
import { AuthModule } from '../auth/auth.module';
import { PlanModule } from '../plan/plan.module';
import { AssistantController } from './assistant.controller';
import { AssistantLabController } from './lab.controller';
import { AssistantService } from './assistant.service';
import { McpClientService } from './mcp-client.service';
import { TranscribeService } from './transcribe.service';
......@@ -16,7 +17,8 @@ import { DictationGateway } from './dictation.gateway';
@Module({
// PlanModule:本地工具 propose_assignment 用 AssignmentProposalService 取数
imports: [AiModule, AuthModule, PlanModule],
controllers: [AssistantController],
// 🔬 AssistantLabController = 裸 agent 试验台,⛔ 与产品助手无代码关联(见该文件头)
controllers: [AssistantController, AssistantLabController],
providers: [AssistantService, McpClientService, TranscribeService, DictationGateway],
exports: [AssistantService], // 供 WeixinAibotModule 等其它入口复用同一个助手大脑
})
......
import {
ANCHOR_MODE_META,
TEMPERATURE_META,
planScenarioLabel,
potentialTreatmentCardLabel,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
......@@ -49,12 +47,6 @@ export function zhScenario(code: string | null | undefined): string | null {
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 {
export function criteriaZh(input: {
potentialTreatment?: string | null;
temperature?: string | null;
anchorMode?: string | null;
}): Record<string, string> {
const out: Record<string, string> = {};
const t = zhTreatment(input.potentialTreatment);
const temp = zhTemperature(input.temperature);
const anchor = zhAnchorMode(input.anchorMode);
if (t) out['治疗项'] = t;
if (temp) out['时间档'] = temp;
if (anchor) out['口径'] = anchor;
return out;
}
......
......@@ -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(' / ')}`,
).join('\n');
/**
* 🔴 2026-08-14 补上「码不许说出口」那一句 —— 此前它**只在系统提示词的公共层**里
* (以 `personaTags` 为例被点名)。公共层是主管和客服共用的,而这些码只有圈人这条线才有,
* 把它们摆进公共层等于让另一条线的人也读一遍与自己无关的词。
* ⇒ 约束挪到**产生这些码的那一格参数上**:模型读到码的同一眼就读到怎么说。
* `POTENTIAL_TREATMENT_DESC` / `TEMPERATURE_TOOL_DESC` 早就是这么写的,这里是漏的那一份。
*/
export const PERSONA_TAGS_DESC =
'画像圈人(可选):"key:value" 逗号串,同维多选 OR、跨维 AND。' +
'⛔ **只能用下表里的 key 和 value**,写别的会被静默忽略(筛选等于没加,人数却照样返回)。' +
'可用维度与取值:\n' +
'\n🔴 ⛔ **这些 key 和 value 只用于调工具,一个字都不许说给主管** —— ' +
'回话一律用下表右边的中文说法。' +
'\n可用维度与取值:\n' +
PERSONA_TAGS_HELP;
/**
......
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod';
import { Permission, parseAnchorMode } from '@pac/types';
import { Permission } from '@pac/types';
import { RequirePermission } from '../../common/decorators/permissions.decorator';
import { TenantScope } from '../../common/decorators/tenant-scope.decorator';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
......@@ -191,7 +191,6 @@ export class AssignmentController {
@Query('clinicId') clinicId: string,
@Query('potentialTreatment') potentialTreatment?: string,
@Query('temperature') temperature?: string,
@Query('anchor') anchor?: string,
@Query('personaTags') personaTags?: string,
/// ↓ 一个搜索框打通五个字段(姓名/病历号/主治/上次接诊/专属客服):
/// 只收窄这张表,⛔ 不改变交给助手的人群(见 service 注释)
......@@ -207,7 +206,6 @@ export class AssignmentController {
clinicId: resolveClinicId(scope, clinicId),
...(potentialTreatment ? { potentialTreatment } : {}),
...(temperature ? { temperature: temperature as never } : {}),
anchorMode: parseAnchorMode(anchor),
...(personaTags ? { personaTags } : {}),
...(keyword ? { keyword } : {}),
...(page ? { page: Number(page) } : {}),
......@@ -232,9 +230,9 @@ export class AssignmentController {
clinicId: body.clinicId,
...(body.potentialTreatment ? { potentialTreatment: body.potentialTreatment } : {}),
...(body.temperature ? { temperature: body.temperature as never } : {}),
// ⚠️ 口径原样带回 —— 少了它,「重新排一版」会静默换成另一批人
anchorMode: parseAnchorMode(body.anchorMode),
...(body.personaTags ? { personaTags: body.personaTags } : {}),
// 🔴 他自己加的那一刀 —— 漏了就是**悄悄退回整格**取人(见 schema 上那段)
...(body.minSpendYuan ? { minSpendYuan: body.minSpendYuan } : {}),
...(body.targetCount ? { targetCount: body.targetCount } : {}),
...(body.expiresInDays ? { expiresInDays: body.expiresInDays } : {}),
preferPlaceable: true,
......
import { Prisma } from '@prisma/client';
import {
AnchorMode,
PERSONA_TAG_FILTER_DIMS,
expandTemperatureFilter,
Temperature,
parsePersonaTags,
personaTagDimId,
visitRecencyRange,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
......@@ -34,12 +32,18 @@ export interface CohortCriteria {
/** 矩阵 Y 轴:窗口温度。⚠️ 必须与 potentialTreatment 同时给,见下方 assert */
temperature?: TemperatureValue;
/**
* 🔴 档位口径(按诊断 / 按末诊)—— **必须跟着 temperature 一起传**。
* 同一个「三个月内」,两个 mode 圈出来的是**两批不同的人**;这里少传一个字段,
* 主管在矩阵上按末诊看了 87 人、确认单却按诊断捞了另一批,而且不报错(T14)。
* ⚠️ 缺省 = `diagnosis`(历史行为),老批次 criteria 里没这个键时也走这条。
* 只要**累计净消费高于这个数(元)**的人 —— 「还能再筛一刀」里那条消费选项。
*
* 🔴 为什么不走 `personaTags`:那是个**等值字典**(`data->>path = value`),
* 表达不了「大于」。⛔ 别为了复用它去硬造一个 `rfm:gt_720` 之类的伪取值 ——
* 闭集校验会把它静默丢掉(`personaTagsSql` 的 `if (!dim) continue`),筛选等于没加。
* ⚠️ 单位是**元**,一路到这里都是元,只在 SQL 里 ×100 对上 `monetaryCents`。
* ⛔ 别在中途换单位:门槛这个数会原样出现在按钮文案和主管的话里(「消费高于 720 的」),
* 两处单位不一致 = 差 100 倍且不报错。
* ⚠️ 没有 rfm 画像的人**筛不出来**(EXISTS 不成立)—— 那是对的:
* "不知道他花了多少"不等于"他花得多"。
*/
anchorMode?: AnchorModeValue;
minSpendYuan?: number;
/** 主管在调整阶段追加的画像条件(`key:value` 逗号串,与列表页同格式) */
personaTags?: string;
}
......@@ -97,11 +101,10 @@ function temperatureSql(
potentialTreatment: string,
temperature: TemperatureValue,
now: Date,
mode: AnchorModeValue,
): Prisma.Sql {
void now; // 判档一律用 SQL 的 NOW() —— 与矩阵同一个时钟,⛔ 别掺 JS 时刻(边界人群会漂)
// ⚠️ 旧取值 `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 {
}
/**
* 累计净消费下界 —— 与 `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 …` 之后。
*/
export function cohortWhereSql(
......@@ -178,9 +198,10 @@ export function cohortWhereSql(
${c.potentialTreatment ? treatmentSql(c.potentialTreatment) : Prisma.empty}
${
c.temperature && c.potentialTreatment
? temperatureSql(c.potentialTreatment, c.temperature, now, c.anchorMode ?? AnchorMode.DIAGNOSIS)
? temperatureSql(c.potentialTreatment, c.temperature, 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 {
type AgentWorkloadResponse,
RELEASE_REASON_META,
REVOKE_WINDOW_MINUTES,
ANCHOR_MODE_META,
parseAnchorMode,
TEMPERATURE_META,
potentialTreatmentCardLabel,
type TemperatureValue,
......@@ -720,7 +718,6 @@ export class PlanAssignmentService {
const c = (head.criteria ?? {}) as {
potentialTreatment?: string;
temperature?: string;
anchorMode?: string;
};
const parts: string[] = [formatBatchTime(head.createdAt, tz)];
if (c.potentialTreatment) parts.push(potentialTreatmentCardLabel(c.potentialTreatment));
......@@ -736,17 +733,11 @@ export class PlanAssignmentService {
*/
const t = c.temperature;
/**
* 🔴 **口径要跟着档名一起说**(2026-08-11 两版并存之后)。
* 「种植 · 三个月内」按诊断和按末诊是**两批完全不同的人**;名字里不标,
* 主管过两周回看这批就分不出当时点的是哪一版 —— 而 criteria 存了、名字不说,
* 等于把证据锁在库里(批次名是他指认一批的唯一抓手)。
* ⚠️ 只标非默认那版:`diagnosis` 的 batchTag 是空串,老批次不受影响、不用改名。
* ⚠️ 2026-08-11~13 之间建的批次,名字里可能带着「(按末诊)」——
* 那是两版口径并存那两天的标记,⛔ 别去洗掉:批次名是主管指认一批的唯一抓手,
* 改名等于把他记得的那个东西弄没了。新批次不再标(只剩一版,标了是废字)。
*/
const tag = ANCHOR_MODE_META[parseAnchorMode(c.anchorMode)].batchTag;
parts.push(
(TEMPERATURE_META[t as TemperatureValue]?.zh ?? (t === 'cold' ? '窗口外' : t)) +
(tag ? `(${tag})` : ''),
);
parts.push(TEMPERATURE_META[t as TemperatureValue]?.zh ?? (t === 'cold' ? '窗口外' : t));
}
parts.push(`${planned} 人`);
if (agents > 0) parts.push(`${agents} 位客服`);
......
......@@ -9,7 +9,7 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod';
import { Permission, parseAnchorMode } from '@pac/types';
import { Permission } from '@pac/types';
import {
CurrentUser,
AuthenticatedUser,
......@@ -115,21 +115,13 @@ export class PlanController {
matrix(
@TenantScope() scope: TenantScopeContext,
@Query('clinicId') clinicId?: string,
@Query('anchor') anchor?: string,
) {
/**
* 不传 clinicId 用登录人的第一个诊所 —— 批次不跨诊所,矩阵天然是诊所维度的。
* 🔴 传了就**必须在范围里**:矩阵是那家诊所的**患者量分布**,
* 2026-08-10 实测这里也是敞的(带别家 id 能拿到完整矩阵)。
* ⚠️ `anchor` 走 `parseAnchorMode` 兜非法值(查询串是用户可改的)——
* ⛔ 别在这里抛 400:切错一个字母就整屏报错,而回落默认口径是安全的。
*/
return this.cohorts.matrix(
scope,
resolveClinicId(scope, clinicId),
new Date(),
parseAnchorMode(anchor),
);
return this.cohorts.matrix(scope, resolveClinicId(scope, clinicId), new Date());
}
@Get(':id')
......
import { Prisma } from '@prisma/client';
import {
AnchorMode,
COLD_BUCKET_YEARS,
COLD_TEMPERATURES,
HOT_BUCKET_DAYS,
POTENTIAL_LABEL_RULES,
Temperature,
WARM_BUCKET_DAYS,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
......@@ -85,40 +83,32 @@ export function labelCaseSql(factAlias: string, ageSql: Prisma.Sql): Prisma.Sql
* (2026-08-11 实测,矩阵整片打不开)。⛔ 别顺手删掉这个 cast。
*/
/**
* 锚点表达式 —— **两版口径**(见 @pac/types 的 `AnchorMode`)
* 锚点 = **患者末诊**(`lvp.last_visit_at`),只此一版
*
* · diagnosis `anc.at` = 该标签最新的未治疗诊断(默认,历史行为)
* · last_visit `lvp.last_visit_at` = 患者末诊(**患者级**,同一人所有标签同值)
* 🔴 2026-08-13 删掉了「按诊断」那版(`anc.at` 当锚点)和整个 `AnchorMode` 开关 ——
* 理由(医生复诊不重复写诊断,中位差 490 天)见 `@pac/types` 的 temperature.ts 文件头。
* ⛔ 别加回来,也⛔别在这里留"可切换"的口子:两版并存时圈人侧与矩阵侧一旦不同步,
* 就是主管看到 87 人、确认单捞出另一批,而且全程不报错(T14)。
*
* ⚠️ 两版都套**同一个** `temperatureBucketCaseSql`(90/180 天 + 自然年)——
* 换的只是"距哪个时刻",档位切分本身一个字都不动。
* ⚠️ `last_visit_at` 取 `patient_profiles`(本地实测 5,825 人 100% 有值、100% 与
* encounter/emr 事实的最大 occurred_at 同日),⛔ 别为它再去 join 一遍 patient_facts。
* ⚠️ 末诊为 NULL → 三个值全 NULL → 落 `unknown` 档(与"边界缺失"同路),
* ⛔ 不许默认塞进某一档。
* ⚠️ 末诊是**患者级**的 ⇒ 同一 GROUP 内每行值都一样,`max()` 只是为了配合 GROUP BY,
* ⛔ 别读成"多条证据取最热"(那是诊断锚点时代的语义)。
*/
function anchorExpr(mode: AnchorModeValue): Prisma.Sql {
return mode === AnchorMode.LAST_VISIT ? Prisma.sql`lvp.last_visit_at` : Prisma.sql`anc.at`;
}
function boundsSql(mode: AnchorModeValue) {
const a = anchorExpr(mode);
return {
hot: Prisma.sql`max(${a} + make_interval(days => ${HOT_BUCKET_DAYS}::int))`,
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;
}
const ANCHOR_SQL = Prisma.sql`lvp.last_visit_at`;
const BOUNDS = {
hot: Prisma.sql`max(${ANCHOR_SQL} + make_interval(days => ${HOT_BUCKET_DAYS}::int))`,
warm: Prisma.sql`max(${ANCHOR_SQL} + make_interval(days => ${WARM_BUCKET_DAYS}::int))`,
anchor: Prisma.sql`max(${ANCHOR_SQL})`,
};
const LAST_VISIT_JOIN = Prisma.sql`LEFT JOIN patient_profiles lvp ON lvp.patient_id = fp.patient_id`;
/**
* 六档判定的 CASE。
*
* ⭐ **一个锚点走到底**:`anchor` = 该标签最新的未治疗诊断
* ⭐ **一个锚点走到底**:`anchor` = 患者末诊
* 前两档按固定 90/180 天、后四档按绝对年数,量的都是「距同一个锚点多久」。
* ⚠️ 冷端用 `anchor + interval 'N years'` 而不是 `N*365 天` —— 与 TS 侧 `setFullYear` 同语义,
* 365 天算法在闰年会漂,两边就对不上数。
......@@ -151,22 +141,19 @@ export function temperatureBucketCaseSql(
* ⚠️ 关联子查询场景**不要用本函数**(内部的 `fp` 会遮蔽外层同名别名,
* 关联条件变成恒真且 SQL 不报错)—— 那种场景用下方的 `labelExistsSql` / `labelTemperatureExistsSql`。
*
* ⚠️ `f.status = 'active'`:治完的诊断是 `fulfilled` 不是删除。拿它当 =
* ⚠️ `f.status = 'active'`:治完的诊断是 `fulfilled` 不是删除。拿它当证据 =
* 对着一个已经做完的诊断说"您还没做"。当前数据 5,034 条证据全是 active,
* 这条过滤是**不变量守卫** —— 治疗落库后引擎还没重算的窗口期里,它就是唯一防线。
* ⚠️ 三个聚合都用 max(取最热 / 取最新)—— 见 temperature.ts:统一成"最早"会让
* hot/warm 反单调(多一条旧需求反而更冷)。
* 🔴 `anc.at IS NOT NULL` **必须留着**,即便锚点已经换成末诊、不再用它定档:
* 它是「这条证据有日期」的守卫,决定**哪些 plan 进得来**。删掉行集就变了 ——
* 而多出来的那些人不会报错,只会悄悄出现在格子里。
*/
export function planLabelAnchorsSql(
planFilter: Prisma.Sql,
mode: AnchorModeValue = AnchorMode.DIAGNOSIS,
): Prisma.Sql {
const b = boundsSql(mode);
export function planLabelAnchorsSql(planFilter: Prisma.Sql): Prisma.Sql {
return Prisma.sql`
SELECT fp.id AS plan_id, fp.patient_id, lab.lbl AS label,
${b.hot} AS hot_until,
${b.warm} AS warm_until,
${b.anchor} AS anchor_at
${BOUNDS.hot} AS hot_until,
${BOUNDS.warm} AS warm_until,
${BOUNDS.anchor} AS anchor_at
FROM followup_plans fp
JOIN patients p ON p.id = fp.patient_id
JOIN plan_reasons pr ON pr.plan_id = fp.id
......@@ -174,7 +161,7 @@ export function planLabelAnchorsSql(
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 ${labelCaseSql('f', AGE_YEARS_SQL)} AS lbl) lab
${lastVisitJoin(mode)}
${LAST_VISIT_JOIN}
WHERE ${planFilter}
AND lab.lbl IS NOT NULL
AND anc.at IS NOT NULL
......@@ -202,20 +189,16 @@ export function labelExistsSql(label: string): Prisma.Sql {
/**
* 关联片段:该 plan 在 `label` 这一格上的温度落在 `buckets` 之内。
*
* ⚠️ 必须先聚合再判档 —— 温度是「该标签所有未治疗证据**取最热**」后的结果,
* 逐条判再 OR 会把"有一条还热"错读成整格热(反过来也一样)。所以走 GROUP BY + HAVING。
* 🔴 这里是**圈人**侧,`planLabelAnchorsSql` 是**看**侧 —— 两边必须是同一个锚点。
* 曾经它们各自带一个 `mode` 参数,一旦调用方漏传就是主管看到 87 人、确认单捞出另一批,
* 而且全程不报错(T14)。⛔ 别再把口径做成参数:同源的唯一可靠办法是**没有选项**。
* ⚠️ 走 GROUP BY + HAVING 而不是逐条判 —— 判档表达式里有聚合(`max`),
* 逐条判再 OR 的语义不一样。⛔ 别为了"看着简单"改成 WHERE。
*/
export function labelTemperatureExistsSql(
label: string,
buckets: readonly TemperatureValue[],
/**
* 🔴 **必须与矩阵那次查询用同一个 mode**。这里是**圈人**侧,矩阵是**看**侧 ——
* 两边 mode 不一致 = 主管看到 87 人、确认单捞出另一批,而且全程不报错(T14)。
* 调用方(`cohort-filter`)从 `criteria.anchorMode` 取,⛔ 别在这里给"聪明的默认"。
*/
mode: AnchorModeValue = AnchorMode.DIAGNOSIS,
): Prisma.Sql {
const b = boundsSql(mode);
return Prisma.sql`
AND EXISTS (
SELECT 1
......@@ -223,11 +206,11 @@ export function labelTemperatureExistsSql(
CROSS JOIN LATERAL jsonb_array_elements_text(pr.evidence->'factIds') fid
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
${lastVisitJoin(mode)}
${LAST_VISIT_JOIN}
WHERE pr.plan_id = fp.id
AND ${labelCaseSql('f', AGE_YEARS_SQL_P)} = ${label}
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}`),
', ',
)})
......
......@@ -137,7 +137,7 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy {
const result = await this.assistant.chat({
userToken: token,
messages: history,
systemExtra: this.wxSystemExtra,
channelExtra: this.wxSystemExtra,
// ⚠️ 刻意不传 permissions → 走 'basic' 桶。企微这条路用的是 mintToken 造的**合成身份**
// (见 mintToken),不是真人登录态;给它主管工具清单等于把分配能力挂到一个
// 谁都能在群里 @ 出来的机器人上。真身份映射做完之前,这里就该是最小权限。
......
......@@ -5,10 +5,12 @@ import { AssignStrategy, type AgentInfo, type TemperatureValue } from '@pac/type
// 又正好被业务侧的 try/catch 吞掉 ⇒ 测试全绿而被测路径根本没跑。
import {
AssignmentProposalService,
mergeOverrides,
placeAgents,
repickPlaceable,
} from '../src/modules/plan/assignment-proposal.service';
import type { CohortAttributesService } from '../src/modules/plan/cohort-attributes.service';
import { modelFacts } from '../src/modules/plan/assignment-facts';
/**
* 落人算法回归 —— **专属优先 → 无主补空 → 待分配**。
......@@ -585,11 +587,20 @@ describe('基数沿用 —— 本批人数与时效', () => {
expect(r.target).toBe(135);
expect(r.expiresInDays).toBe(1);
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 的第一反应是"怎么这么多"
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,能承接的量本来就该跟着变。 */
......@@ -607,7 +618,15 @@ describe('基数沿用 —— 本批人数与时效', () => {
const r = await mk(44, 9).propose(SCOPE, { clinicId: 'c1' });
expect(r.target).toBe(44);
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 () => {
......@@ -631,7 +650,13 @@ describe('基数沿用 —— 本批人数与时效', () => {
expect(lily.overridden).toBe(true);
// ⚠️ 名额上限**不改批次规模** —— 少的那部分由别人接走
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 () => {
......@@ -663,37 +688,66 @@ describe('基数沿用 —— 本批人数与时效', () => {
});
/**
* 🔴 **运营结论**(opsNote)—— 2026-08-04 产品评审提的那条:
* 「我不可能挨个去看他分的对不对,你在这儿应该给的是**结论性的东西**…
* 如果你不能告诉他哪不对、哪里需要主管判断,那就默认他必须一条一条看过去才能确认。」
* 🔴 **运营结论(opsNote)已于 2026-08-13 整块删除。**
*
* ⚠️ 最要命的是**增量与总量分不开**:卡片原来只写「分完每人 57~62 条」(在手总量),
* 而那一批实际只给每人 1~4 条 —— 主管一眼读成"这批好大一坨"。
* 2026-08-04 产品评审要的是"结论性的东西"(本批拿到几条 / 分完手上几条 / 要打几天)。
* 那个诉求没变,只是**出口换了**——同一批数现在有三处,卡片那份是第三遍:
* ① 模型正文(modelFacts 的「本批每人拿到 / 分完之后每人手上」);
* ② 引导节点 `batch_size_basis`(这批多大 = 在岗 × 每天几通 × 时效,可改);
* ③ 引导节点 `daily_overload`(最忙那位手上共几条、约几天的量)。
* ⇒ 下面只留 basisNote 那半条不变式(增量与总量**仍然不许混**)。
*/
test('⭐⭐ 运营结论:本批拿到几条 / 分完手上几条 / 要打几天,三个数都要有', async () => {
test('🔴 ⛔ basisNote 不许复述「分完每人多少条」—— 那是在手总量,不是这批给他的量', async () => {
const r = await mk(1000, 3, { inHand: 0, lastCriteria: { expiresInDays: 3 } })
.propose(SCOPE, { clinicId: 'c1', targetCount: 9 });
expect(r.byAgent.every((x: { loadAfter: number }) => x.loadAfter === 3)).toBe(true);
expect(r.opsNote).toContain('本批每人拿到 3 条');
expect(r.opsNote).toContain('分完之后手上共 3 条');
// ⚠️ 天数必须带上「按每天 N 通算」的前提 —— 那个 15 是口头经验值不是实测,
// ⛔ 不许写成"需要 X 天"这种像承诺的说法
expect(r.opsNote).toMatch(/按每人每天 \d+ 通算/);
/**
* 🔴 增量与总量**不许混**(2026-08-04 产品点名过的误读:实际每人 1~4 条,却写着 57~62)。
* ⚠️ 2026-08-14 从 basisNote 迁到 modelFacts:那边把两个数**分成两个键**给,
* 结构上就混不了 —— 这比"别写成一句话"更硬。
*/
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 讲的是"怎么分的",
// 把在手总量摆进去会被读成"这批给他这么多"
expect(r.basisNote).not.toContain('分完每人');
expect(r.basisNote).toContain('有专属的先回自己人手上');
/**
* 🔴 **精调怎么撤** —— 唯一的表达是**传空对象**。
*
* 精调(「某人这批最多 5 条」)会被记住,**省略等于沿用**:不传他,上次那条原封不动跟过来。
* 于是「他恢复正常」这句话没有对应的表达 —— 除非传 `{ "u1": {} }`。
*
* ⚠️ 这个能力靠的是 `mergeOverrides` 里**先合并、后 sanitize** 的顺序,
* 而那看起来像是可以"顺手整理"的两行。整理掉的后果是:主管说了「某某恢复正常」,
* 下一批他照旧被卡在 5 条,而**没有任何报错** ⇒ 用测试钉住。
*/
describe('按客服精调的合并与撤销', () => {
test('⭐ 本次传的压过沿用的(同一个人两处都有 → 本次赢)', () => {
expect(mergeOverrides({ u1: { maxThisBatch: 5 } }, { u1: { maxThisBatch: 2 } })).toEqual({
u1: { maxThisBatch: 2 },
});
});
test('⭐ 拿到的全是自己老客户的客服要点名 —— 那种批次主管可以直接过', async () => {
const r = await mk(1000, 3, { inHand: 0, lastCriteria: { expiresInDays: 3 } })
.propose(SCOPE, { clinicId: 'c1', targetCount: 9 });
// 这个 fixture 里没有专属关系 → 一个都不该点名(⛔ 别把"0 位"也写出来)
expect(r.opsNote).not.toContain('全是自己的老客户');
test('⭐ 没传的原样沿用 —— 「省略」不是「撤销」', () => {
expect(mergeOverrides({ u1: { maxThisBatch: 5 } }, { u2: { expiresInDays: 7 } })).toEqual({
u1: { maxThisBatch: 5 },
u2: { expiresInDays: 7 },
});
});
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 },
});
});
});
......@@ -110,7 +110,9 @@ describe('画像圈人 —— 多标签维度不能拿来算百分比', () => {
});
describe('画像圈人 —— 维度点名', () => {
test('⭐⭐ 隐藏维度可以点名:教条举的「排掉怕疼的」正落在 treatment_sensitivity 上', async () => {
// ⚠️ 2026-08-14:工具描述里那句举例(「排掉怕疼的」)已删 —— 举例会被模型念给主管听。
// 但**能力本身要留**:hidden 的语义是"前端面板不展示",⛔ 不是"不能点名"。
test('⭐⭐ 隐藏维度可以点名:treatment_sensitivity 不在面板上,但 keys 点得到', async () => {
// hidden 的语义是"前端面板不展示",不是"不能用"(见 persona-tag-filters 的 hidden 注释)。
const prisma = makePrisma({
patientIds: ['p0', 'p1'],
......@@ -168,8 +170,8 @@ describe('初选矩阵', () => {
expect(implant.counts.cold_1y).toBe(5); // ⛔ 不是 8
expect(implant.unknown).toBe(3);
expect(m.unknownTotal).toBe(3);
// ⚠️ 2026-08-11 措辞从「算不出温度」改成「算不出档位」(界面上早就没有"温度"这个词了),
// 而且原因随口径变(按诊断=没诊断日 / 按末诊=没末诊记录)
// ⚠️ 2026-08-11 措辞从「算不出温度」改成「算不出档位」(界面上早就没有"温度"这个词了);
// 2026-08-13 锚点定为末诊后,原因只有一个:没有末诊记录
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';
/**
......@@ -131,44 +131,36 @@ describe('人群取数 —— 两根轴走召回单证据(2026-08 换源)', () =
});
/**
* 🔴 **两版口径的圈人侧** —— 这一层错了,后果是「主管看到 87 人、确认单给另一批」,
* 🔴 **圈人侧的锚点** —— 这一层错了,后果是「主管看到 87 人、确认单给另一批」,
* 而且两个数字都"对"、全程不报错(T14 口径对数;矩阵 67→324 那次事故的同构版本)。
* ⚠️ 2026-08-13 起只剩末诊一版(`AnchorMode` 开关已删),所以这里锁的是**唯一那一版**。
*/
describe('人群取数 —— 档位口径(按诊断 / 按末诊)', () => {
const sql = (mode: (typeof AnchorMode)[keyof typeof AnchorMode]) =>
describe('人群取数 —— 锚点 = 末诊,只此一版', () => {
const sql = () =>
cohortWhereSql(
SCOPE,
{ clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT, anchorMode: mode },
{ clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT },
NOW,
);
test('⭐⭐ 按末诊时**必须**join patient_profiles 并用 last_visit_at 当锚点', () => {
const t = sql(AnchorMode.LAST_VISIT).strings.join('?');
test('⭐⭐ **必须** join patient_profiles 并用 last_visit_at 当锚点', () => {
const t = sql().strings.join('?');
expect(t).toContain('patient_profiles');
expect(t).toContain('last_visit_at');
});
test('⭐⭐ 按诊断时**不许**碰末诊 —— 多 join 一张表就是另一个口径', () => {
const t = sql(AnchorMode.DIAGNOSIS).strings.join('?');
expect(t).not.toContain('last_visit_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('⭐⭐ ⛔ 不许再拿诊断日当锚点 —— 但 anc.at 的非空守卫要留着(它决定行集)', () => {
const t = sql().strings.join('?');
expect(t).toContain('anc.at IS NOT NULL');
// 锚点表达式只能是末诊:⛔ 不许出现 `max(anc.at`(那是旧口径的指纹)
expect(t).not.toMatch(/max\(\s*anc\.at/);
});
test('⭐⭐ 两版的**档位切分**必须完全一样 —— 换的只是锚点', () => {
for (const mode of [AnchorMode.DIAGNOSIS, AnchorMode.LAST_VISIT]) {
const vals = sql(mode).values.flat();
test('⭐⭐ 档位切分仍由 @pac/types 的常量生成 —— ⛔ 不许手写天数', () => {
const vals = sql().values.flat();
expect(vals).toContain(HOT_BUCKET_DAYS);
expect(vals).toContain(WARM_BUCKET_DAYS);
// 自然年推法两版共用(⛔ 不许一版 365 天一版 interval year)
expect(sql(mode).strings.join('?')).toMatch(/interval '\d+ years'/);
}
// 自然年推法(⛔ 不许换成 365 天)
expect(sql().strings.join('?')).toMatch(/interval '\d+ years'/);
});
});
import * as types from '@pac/types';
import {
AnchorMode,
ANCHOR_MODE_META,
HOT_BUCKET_DAYS,
parseAnchorMode,
TEMPERATURE_AXIS_ZH,
Temperature,
TEMPERATURE_META,
TEMPERATURE_ORDER,
......@@ -186,19 +185,11 @@ describe('标签 ← 诊断码的投影必须与 classifyGapToLabel 一致', ()
describe('窗口期 hover 文案 —— 只给区间,不给解释', () => {
test('⭐ 六档区间与治疗项无关,同一档在哪一行都是同一句', () => {
// 默认口径(按诊断)—— 主语是"医生最后一次提到",⛔ 不是"诊断距今"
// (2026-08-11 两版并存后改的措辞:复诊漏写时,它表达的确实是"最后一次被提及")
expect(temperatureWindowHint('implant', Temperature.HOT)).toBe('医生最后一次提到 ≤ 90 天');
expect(temperatureWindowHint('implant', Temperature.WARM)).toBe('医生最后一次提到 90–180 天');
expect(temperatureWindowHint('implant', Temperature.COLD_1Y)).toBe('医生最后一次提到 180 天–1 年');
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 年',
);
// 🔴 主语是"距上次到诊"(2026-08-13 锚点定为末诊,「医生最后一次提到」那版已删)
expect(temperatureWindowHint('implant', Temperature.HOT)).toBe('距上次到诊 ≤ 90 天');
expect(temperatureWindowHint('implant', Temperature.WARM)).toBe('距上次到诊 90–180 天');
expect(temperatureWindowHint('implant', Temperature.COLD_1Y)).toBe('距上次到诊 180 天–1 年');
expect(temperatureWindowHint('implant', Temperature.COLD_OVER)).toBe('距上次到诊 > 3 年');
});
test('⭐⭐ 多码标签不再有"并集"这回事 —— 拔牙(K01+K03)与单码标签逐字相同', () => {
......@@ -237,7 +228,7 @@ describe('窗口期 hover 文案 —— 只给区间,不给解释', () => {
test('标签不参与计算 —— 传个不存在的标签也给同一句(⛔ 别再回到"未知标签返空串")', () => {
// 旧口径要查该标签对应的码才能算天数,查不到只能返空串。现在天数是固定的,
// 空串反而是错的(格子会失去 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 工具的温度取值 —— 必须与矩阵同源', () => {
});
/**
* ⭐ 两版口径(按诊断 / 按末诊)—— 2026-08-11 并存后的契约
* ⭐ 锚点 = 末诊,**只此一版**(2026-08-13;此前有个 `AnchorMode` 开关,整套已删)
*
* 🔴 这里锁的是**口径分叉**那一类事故:切换只能改"距哪个时刻",
* ⛔ 不许顺手改档位切分(90/180/自然年)—— 那会让两版连人数量级都不可比
* 🔴 这里锁的是「⛔ 别把第二版口径加回来」:两版并存时圈人侧与矩阵侧一旦不同步,
* 就是主管看到 87 人、确认单捞出另一批,而且全程不报错(T14)
*/
describe('档位口径 —— 两版并存', () => {
test('⭐ 非法 / 缺省一律回落按诊断(查询串是用户可改的,⛔ 不抛错)', () => {
expect(parseAnchorMode(undefined)).toBe(AnchorMode.DIAGNOSIS);
expect(parseAnchorMode('')).toBe(AnchorMode.DIAGNOSIS);
expect(parseAnchorMode('lastvisit')).toBe(AnchorMode.DIAGNOSIS); // 少个下划线也不许猜
expect(parseAnchorMode(AnchorMode.LAST_VISIT)).toBe(AnchorMode.LAST_VISIT);
describe('档位口径 —— 只剩一版', () => {
test('⛔ AnchorMode / parseAnchorMode 不许复活', () => {
const mod = types as Record<string, unknown>;
for (const k of ['AnchorMode', 'ANCHOR_MODE_META', 'parseAnchorMode', 'ANCHOR_MODE_TOOL_DESC'])
expect(mod[k]).toBeUndefined();
});
test('⭐⭐ 只有非默认那版带批次名标记 —— 老批次不受影响、不用改名', () => {
expect(ANCHOR_MODE_META[AnchorMode.DIAGNOSIS].batchTag).toBe('');
expect(ANCHOR_MODE_META[AnchorMode.LAST_VISIT].batchTag).toBe('按末诊');
test('⭐ 六档的主语统一是「距上次到诊」—— ⛔ 没有第二种措辞', () => {
for (const t of TEMPERATURE_ORDER)
expect(temperatureWindowHint('implant', t)).toMatch(/^距上次到诊 /);
});
test('⭐⭐ 两版的**档位切分**必须逐字相同 —— 换的只是主语', () => {
for (const t of TEMPERATURE_ORDER) {
const a = temperatureWindowHint('implant', t, AnchorMode.DIAGNOSIS);
const b = temperatureWindowHint('implant', t, AnchorMode.LAST_VISIT);
// 去掉主语后必须一模一样(天数/年数一个字都不能差)
expect(a.replace('医生最后一次提到', '')).toBe(b.replace('距上次到诊', ''));
}
test('⭐ 列头说明的轴名与 hover 主语同源 —— ⛔ 别各写各的', () => {
expect(TEMPERATURE_AXIS_ZH).toBe('患者最后一次到诊');
for (const t of TEMPERATURE_ORDER) expect(TEMPERATURE_META[t].hint).toContain('上次到诊');
});
});
/// <reference types="next" />
/// <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
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
......@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
import { useHasPermission } from '@/hooks/use-permission';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { useAssistantStore } from '@/stores/assistant-store';
import { EXAMPLES_LEADER } from './assistant-chat';
import { AssistantFab } from './assistant-fab';
import { CohortStream } from './cohort-stream';
import { AssistantChat } from './assistant-chat';
......@@ -73,11 +74,9 @@ export function AssistantWidget() {
* 换个说法会把口径带偏(「已处理」≠ 谈成了,这条 note 服务端每次都在喊)。
*/
const canDispatch = useHasPermission(Permission.PLAN_DISPATCH);
const leaderExamples = [
'帮我给「种植 · 窗口内」这批患者出一份分配方案',
'帮我跟踪下我分配的任务有没什么问题',
'最近一批分配出份报表:分配数、未动过、已处理、成功、超期,退回和不成功要带原因',
];
// ⭐ 唯一一份在 assistant-chat(2026-08-15 合并)—— 此前这里抄了一份逐字相同的,
// 而其中「窗口内」「成功/不成功」两处都是错的,两边各错一遍。
const leaderExamples = EXAMPLES_LEADER;
const examples = current?.patientName
? [
`${current.patientName}为什么被召回?帮我捋一下关键事实`,
......@@ -85,8 +84,12 @@ export function AssistantWidget() {
...(canDispatch ? leaderExamples : ['这通电话怎么开口比较好?给我两句开场']),
'今日推荐:挑几个该优先跟进的患者,并说明理由',
]
: canDispatch
? // ⚠️ 主管空态**只给主管的活**(2026-08-15):原来这里还挂着「今日推荐」
// 「现在召回池里优先级最高的是谁」两条 —— 那是客服打单的动线,
// 而「召回池」这个词他这一屏上根本没有(他看到的是「分一批新的」「我分的批次」)。
leaderExamples
: [
...(canDispatch ? leaderExamples : []),
'今日推荐:挑几个该优先跟进的患者,并说明理由',
'现在召回池里优先级最高的是谁?',
];
......
......@@ -25,12 +25,18 @@ export function ConfirmSheetSignals({
readOnly,
busyIntent,
onIntent,
onCompose,
}: {
signals: Signal[];
readOnly: boolean;
/** 正在执行的 intent(按钮转圈用);null = 空闲 */
busyIntent?: string | null;
onIntent: (intent: string, args?: Record<string, unknown>) => void;
/**
* 兜底 —— 把一句**开头**填进输入框并聚焦,⛔ 不直接发送。
* ⚠️ `post_confirm` 那类节点唯一的动作就是它(福利原文只能是主管的话)。
*/
onCompose?: (draft: string) => void;
}) {
const [infoOpen, setInfoOpen] = useState(false);
if (signals.length === 0) return null;
......@@ -41,7 +47,14 @@ export function ConfirmSheetSignals({
return (
<div className="border-b bg-amber-50/40">
{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({
readOnly={readOnly}
busyIntent={busyIntent}
onIntent={onIntent}
onCompose={onCompose}
muted
/>
))}
......@@ -80,14 +94,23 @@ function Row({
readOnly,
busyIntent,
onIntent,
onCompose,
muted,
}: {
signal: Signal;
readOnly: boolean;
busyIntent?: string | null;
onIntent: (intent: string, args?: Record<string, unknown>) => void;
onCompose?: (draft: string) => void;
muted?: boolean;
}) {
/**
* ⭐ **`post_confirm` 那类在只读态下照样可点** —— 只读说的是"这批人和时效定死了",
* 而这类节点恰恰是**分下去之后仍然能做**的那几件(补挂福利只影响此后生成的话术)。
* ⛔ 跟着 readOnly 一起禁掉,就等于摆一句「这批还没带福利」然后什么也不让他做。
*/
const postConfirm = s.stage === 'post_confirm';
const canAct = !readOnly || postConfirm;
return (
<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({
<p className={muted ? 'text-[11.5px] text-slate-600' : 'text-[12px] font-medium text-slate-800'}>
{s.title}
</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">
{s.options.map((o) => {
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>
);
}
......@@ -89,7 +89,7 @@ import { useSummaryStream } from './use-summary-stream';
import { submitExecution } from './execution-api';
/// 话术生成模型(具体型号,直传后端 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段模板填空 / 标准=去模板自由编排 / 深度=多步多段+对抗校验。
export type ScriptTier = 'stable' | 'standard' | 'deep';
......@@ -216,8 +216,18 @@ export function PlanDetailApp({
const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
/** 话术渠道 tab:电话(分段稿,固定「原文」渲染)/ 企微(单块,可复制) */
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 { state: streamState, regenerate, abort } = useScriptStream();
......@@ -287,16 +297,18 @@ export function PlanDetailApp({
// 流式 done / error 时弹 toast
useEffect(() => {
if (streamState.status === 'done') {
// 🔴 `costYuan` 可能缺省 —— 企微的 done 事件不带成本/token 字段(它只推步骤 + 正文),
// 直接 `.toFixed()` 会在生成完成那一刻整页崩(实测)。⛔ 别假设两条流的 done 形状一样。
const cost = streamState.costYuan?.toFixed(4);
/**
* ⛔ **别把调用费用写进 toast**(2026-08-13 产品定):
* 这是给一线用的界面,`¥0.0123` 对他没有任何可操作性 —— 他既不为此付钱、
* 也不会因为这个数改做法,只是每生成一次就被塞一串数字。
* ⚠️ 成本本来就有专门的去处:AI 调用列表页(含 24h 成本、按调用逐条 ¥)。
* ⛔ 别以"顺手就有"为由加回来。
*/
const label =
streamState.source === 'failed'
? '生成失败,请重试或自己写一条'
: streamState.source === 'template_fallback'
? '模板兜底'
: cost
? `AI 生成 · ¥${cost}`
: 'AI 生成';
showToast(
streamState.source === 'failed' ? 'rose' : 'emerald',
......@@ -312,9 +324,8 @@ export function PlanDetailApp({
// 摘要 done / error 时弹 toast
useEffect(() => {
if (summaryState.status === 'done') {
const cost = summaryState.costYuan.toFixed(4);
const label =
summaryState.source === 'template_fallback' ? '模板兜底' : `AI 生成 · ¥${cost}`;
// ⛔ 同上:费用不进 toast,成本看 AI 调用列表页
const label = summaryState.source === 'template_fallback' ? '模板兜底' : 'AI 生成';
showToast('emerald', '摘要已重新生成(3 段)', label);
} else if (summaryState.status === 'error') {
showToast('rose', '摘要生成失败', summaryState.message.slice(0, 80));
......@@ -2805,7 +2816,7 @@ function PersonaTagCloud({
// 话术生成模型选项(具体型号,直传后端)
const SCRIPT_MODELS: { key: ScriptModel; label: string }[] = [
{ key: 'deepseek-v4-flash', label: '快 · 均衡' },
{ key: 'qwen3.7-max', label: '极快 · 简洁' },
{ key: 'qwen3.8-max', label: '极快 · 简洁' },
];
// 投入档选项(跟模型并列,直传后端 tier)
......
......@@ -2,7 +2,6 @@
import type {
AgentWorkloadResponse,
AnchorModeValue,
CreateAssignmentRequest,
CreateAssignmentResponse,
ListAgentsResponse,
......@@ -140,7 +139,6 @@ export const assignmentsApi = {
clinicId: string;
potentialTreatment?: string;
temperature?: string;
anchor?: AnchorModeValue;
personaTags?: string;
/// 一个框搜五样(姓名/病历号/主治/上次接诊/专属客服):只收窄这张表,
/// ⛔ 不改变交给助手的人群
......@@ -153,7 +151,6 @@ export const assignmentsApi = {
clinicId: q.clinicId,
potentialTreatment: q.potentialTreatment,
temperature: q.temperature,
anchor: q.anchor,
personaTags: q.personaTags,
keyword: q.keyword,
page: q.page == null ? undefined : String(q.page),
......
'use client';
import type {
AnchorModeValue,
TemperatureValue,
ListPlansQuery,
ListPlansResponse,
......@@ -33,9 +32,6 @@ export interface PoolMatrix {
unknownTotal: number;
/** 有待重算的人时才非空;成品句子,直接展示 */
note: string;
/** ⭐ 这份数据是按哪个锚点算的 —— 由**服务端**回声,⛔ 前端别自己记(刷新就漂) */
anchorMode?: AnchorModeValue;
anchorModeZh?: string;
}
export const plansApi = {
......@@ -67,8 +63,8 @@ export const plansApi = {
* 初选矩阵(8 潜在治疗 × 3 窗口温度)。
* ⚠️ 数字是**去重患者数**,不是 plan 条数 —— 列表按 plan 分页,两者本就不该相等。
*/
matrix: (clinicId?: string, anchor?: AnchorModeValue) =>
api.get<PoolMatrix>('/pac/v1/plans/matrix', { query: { clinicId, anchor } }),
matrix: (clinicId?: string) =>
api.get<PoolMatrix>('/pac/v1/plans/matrix', { query: { clinicId } }),
/** 「上次医生 / 偏好医生」筛选的候选名单(后端缓存 6h;前端在它上面做客户端模糊匹配)*/
doctors: () => api.get<{ doctors: string[] }>('/pac/v1/plans/doctors'),
......
'use client';
import {
ANCHOR_MODE_META,
AnchorMode,
TEMPERATURE_AXIS_ZH,
TEMPERATURE_META,
TEMPERATURE_ORDER,
potentialTreatmentItemName,
temperatureWindowHint,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
import { cn } from '@/lib/utils';
......@@ -95,13 +93,6 @@ export function PoolMatrix({
* ⛔ 别把默认宽度删掉改成永远 w-full —— popover 会跟着变成整屏宽。
*/
className,
/**
* 档位口径(按诊断 / 按末诊)—— 只影响**格子 hover 的那句话**与底部说明的措辞;
* 数字本身是服务端按同一个 mode 算好的。
* ⚠️ 默认 `diagnosis` 是为了让老调用点行为不变,⛔ 别把它当"聪明的默认"——
* 调用方拿到的数据是哪版,就必须传哪版(见 new-batch 的 dataMode)。
*/
anchorMode = AnchorMode.DIAGNOSIS,
}: {
/**
* 矩阵数据 —— **由调用方取好再传进来**,本组件不自己发请求。
......@@ -111,7 +102,6 @@ export function PoolMatrix({
* 数据到了再重排,浮层的抖动比列表刺眼得多。
*/
data: PoolMatrixData;
anchorMode?: AnchorModeValue;
/** 当前选中的格子(回显用) */
selected?: { treatment: string; temperature: TempKey } | null;
className?: string;
......@@ -180,13 +170,7 @@ export function PoolMatrix({
{/* ⭐ 1b 的主体:**一整张**渐变面,8×3 个格子透明浮在上面 */}
<div className={cn('flex-1 overflow-hidden rounded', PLANE)}>
{data.rows.map((row) => (
<MatrixRow
key={row.key}
row={row}
selected={selected}
onPick={onPick}
anchorMode={anchorMode}
/>
<MatrixRow key={row.key} row={row} selected={selected} onPick={onPick} />
))}
</div>
......@@ -195,9 +179,7 @@ export function PoolMatrix({
{data.rows.map((row) => (
<div
key={row.key}
title={`算不出档位 —— ${
anchorMode === AnchorMode.LAST_VISIT ? '这些人没有末诊记录' : '召回证据里没有可用的诊断日'
}`}
title="算不出档位 —— 这些人没有末诊记录"
className={cn(
ROW_H,
'flex items-center justify-center text-[11px] text-muted-foreground nums',
......@@ -212,17 +194,13 @@ export function PoolMatrix({
{/*
⚠️ 2026-08-11 大幅缩短:原来这里要解释"前两档按该治疗自己的周期、后四档按年数"——
六档改成同一把尺子(都按诊断距今多久)之后,那半句话没有了,⛔ 别再写回去。
⭐ 剩下这一句是**必须留的**:一个人有几个潜在治疗就占几行,
否则主管把各行相加会发现比池子总数大,当场以为系统在骗他(实测 2,526 vs 2,129)
六档改成同一把尺子之后,那半句话没有了,⛔ 别再写回去。
⭐ 剩下那半句是**必须留的**:末诊是**患者级**的,同一个人的几个潜在治疗必然落同一档。
不说清楚,主管会以为"这人三个机会都刚诊断"。⛔ 别省
*/}
<p className="mt-2 text-[10.5px] leading-relaxed text-muted-foreground">
<span className="font-medium">{ANCHOR_MODE_META[anchorMode].axisZh}距今多久</span>分档。
{anchorMode === AnchorMode.LAST_VISIT
? // ⚠️ 末诊是**患者级**的:同一个人的几个潜在治疗必然落同一档 —— 不说清楚,
// 主管会以为"这人三个机会都刚诊断"。⛔ 别省这半句。
'末诊按人算,同一个人的几个治疗项都在同一档。'
: '一个人有几个潜在治疗就出现在几行。'}
<span className="font-medium">{TEMPERATURE_AXIS_ZH}距今多久</span>分档。
末诊按人算,同一个人的几个治疗项都在同一档。
</p>
{data.note && (
......@@ -239,10 +217,8 @@ function MatrixRow({
row,
selected,
onPick,
anchorMode,
}: {
row: PoolMatrixRow;
anchorMode: AnchorModeValue;
selected?: { treatment: string; temperature: TempKey } | null;
onPick: (cell: {
treatment: string;
......@@ -279,7 +255,7 @@ function MatrixRow({
// ⭐ hover 文案保持原样:只给**天数区间**,不给解释。
// 格子里已经有数字、列头已经写了档位名,再叠一段说明就是噪声;
// 区间是主管唯一看不出来的那个信息。
title={temperatureWindowHint(row.key, c, anchorMode)}
title={temperatureWindowHint(row.key, c)}
className={cn(
ROW_H,
'flex-1 text-[13px] text-slate-900 nums',
......
......@@ -2,12 +2,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Search, X } from 'lucide-react';
import {
ANCHOR_MODE_META,
TEMPERATURE_META,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
import { TEMPERATURE_META, type TemperatureValue } from '@pac/types';
import { assignmentsApi, type CohortPatientRow } from '@/components/plans/assignments-api';
import { cn, formatGender } from '@/lib/utils';
......@@ -31,13 +26,11 @@ import { cn, formatGender } from '@/lib/utils';
export function CohortPreview({
clinicId,
cell,
anchorMode,
onCancel,
onConfirm,
}: {
clinicId: string;
cell: { treatment: string; treatmentZh: string; temperature: TemperatureValue; count: number };
anchorMode: AnchorModeValue;
onCancel: () => void;
/// 确定 —— 带上「确定」按钮的视口矩形,数据流粒子从那里起飞(⛔ 不能事后再查,窗已经关了)
onConfirm: (from: { x: number; y: number; w: number; h: number }) => void;
......@@ -85,7 +78,6 @@ export function CohortPreview({
clinicId,
potentialTreatment: cell.treatment,
temperature: cell.temperature,
anchor: anchorMode,
...(keyword ? { keyword } : {}),
page: p,
pageSize: PAGE_SIZE,
......@@ -107,7 +99,7 @@ export function CohortPreview({
setLoading(false);
}
},
[clinicId, cell.treatment, cell.temperature, anchorMode, keyword],
[clinicId, cell.treatment, cell.temperature, keyword],
);
/// 条件一变就回到第一页并清空 —— ⛔ 不能只追加:那会把新条件的结果接在旧结果后面
......@@ -151,7 +143,6 @@ export function CohortPreview({
const cellTotal = total ?? cell.count;
const hasFilter = Boolean(keyword);
const shown = filtered ?? cellTotal;
const tag = ANCHOR_MODE_META[anchorMode].batchTag;
return (
<div
......@@ -165,7 +156,6 @@ export function CohortPreview({
<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">
{cell.treatmentZh} · {TEMPERATURE_META[cell.temperature].zh}
{tag ? `(${tag})` : ''}
</span>
{/* ⚠️ 总数取**服务端**的 total,⛔ 不用格子上那个数:格子可能是几秒前的快照 */}
<span className="nums text-[12px] text-slate-500">{cellTotal}</span>
......
'use client';
import { useCallback, useEffect, useState } from 'react';
import {
AnchorMode,
ANCHOR_MODE_META,
TEMPERATURE_META,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
import { TEMPERATURE_AXIS_ZH, TEMPERATURE_META, type TemperatureValue } from '@pac/types';
import { plansApi, type PoolMatrix as PoolMatrixData } from '@/components/plans/plans-api';
import { PoolMatrix } from '@/components/plans/pool-matrix';
import { useAssignmentSyncStore } from '@/stores/assignment-sync-store';
......@@ -43,17 +37,14 @@ type Picked = {
};
/**
* 🔴 口径要**写进交给助手的那句话里**。
* 移交只有一条自然语言通道(模型看不到界面),所以这句话必须**自己说清是哪一格**。
*
* 移交只有一条自然语言通道(模型看不到界面开关),所以「按末诊」这三个字就是
* 主管选择的**唯一载体** —— `ANCHOR_MODE_TOOL_DESC` 那边的判据也正是"话里带没带这三个字"。
* ⛔ 别改这句的措辞而不同步改工具说明:改了模型就不传 `anchorMode`,
* 主管在按末诊那版上点的格子,确认单会按诊断口径捞另一批人 —— 而且不报错(T14)。
* ⚠️ 2026-08-13 之前这里还要补一个「(按末诊)」标记,因为档位有两版口径 ——
* 现在只剩一版(距上次到诊多久),标记跟着 `AnchorMode` 一起删了。⛔ 别加回来。
*/
function handoff(c: Picked, mode: AnchorModeValue) {
function handoff(c: Picked) {
const tempZh = TEMPERATURE_META[c.temperature].zh;
const tag = ANCHOR_MODE_META[mode].batchTag;
const cell = `${c.treatmentZh} · ${tempZh}${tag ? `(${tag})` : ''}`;
const cell = `${c.treatmentZh} · ${tempZh}`;
useAssistantStore.getState().handoff({
text: `帮我给「${cell}」这批患者出一份分配方案`,
from: c.rect,
......@@ -71,31 +62,14 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | 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(() => {
setMatrix(null);
setError(null);
}, [clinicId, mode]);
}, [clinicId]);
/**
* 取矩阵。**助手那边确认/撤销之后也要重拉**(`syncSeq`):
......@@ -106,20 +80,13 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
if (!clinicId) return;
let alive = true;
plansApi
.matrix(clinicId, mode)
.matrix(clinicId)
.then((r) => alive && setMatrix(r))
.catch((e) => alive && setError(e instanceof Error ? e.message : '取矩阵失败'));
return () => {
alive = false;
};
}, [clinicId, syncSeq, mode]);
/**
* ⚠️ 口径取**服务端回声**(`matrix.anchorMode`)而不是本地 state:
* 两者理论上一致,但万一请求慢/失败留着旧数据,回声能保证"这批数是按哪版算的"跟着数据走,
* ⛔ 不会出现"开关已经切了、格子还是旧那版、点下去按新那版圈人"这种错位。
*/
const dataMode = matrix?.anchorMode ?? mode;
}, [clinicId, syncSeq]);
/**
* 🔴 点格子**不再直接移交**(2026-08-11 产品定):先弹中间表,让主管看清这批人是谁,
......@@ -142,31 +109,10 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
<span className="text-[11px] text-slate-400">
点一格 = 把这批人交给助手,助手出确认单
</span>
{/* ⭐ 口径开关 —— 挨着列头放,主管点格子之前就知道这些数是按什么算的。
⚠️ 旁边那句 axisZh 不是装饰:列头写的是「三个月内」,而"什么的三个月"
只有这一处说得清;⛔ 别为了省空间把它收进 tooltip。 */}
<span className="ml-auto flex items-center gap-2">
<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>
{/* ⚠️ 这句不是装饰:列头写的是「三个月内」,而"什么的三个月"只有这一处说得清。
⛔ 别为了省空间把它收进 tooltip。 */}
<span className="ml-auto hidden text-[11px] text-slate-400 sm:inline">
{TEMPERATURE_AXIS_ZH}距今多久分档
</span>
</div>
{/* ⚠️ 小屏兜底:矩阵有六列,窄了就横向滚,⛔ 别让它压缩到看不清数字 */}
......@@ -183,7 +129,6 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
data={matrix}
selected={null}
onPick={pick}
anchorMode={dataMode}
className="w-full min-w-[34rem]"
/>
) : (
......@@ -199,11 +144,10 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
<CohortPreview
clinicId={clinicId}
cell={preview}
anchorMode={dataMode}
onCancel={() => setPreview(null)}
onConfirm={(from) => {
setPreview(null);
handoff({ ...preview, rect: from }, dataMode);
handoff({ ...preview, rect: from });
}}
/>
)}
......
......@@ -7,6 +7,7 @@ import { SupervisorHeader } from './supervisor-header';
import { BatchTracking } from './batch-tracking';
import { TeamStatus } from './team-status';
import { NewBatchPanel } from './new-batch';
import { useAssistantStore } from '@/stores/assistant-store';
/**
* 主管工作台 —— 左「分一批新的 + 我分的批次」/ 右「团队现在什么状态」。
......@@ -23,6 +24,14 @@ export function SupervisorWorkbench() {
const user = useAuthStore((s) => s.user);
const clinics = visibleClinics(user);
const [clinicId, setClinicId] = useState<string | null>(null);
/**
* ⭐ 把"当前看的是哪家诊所"同步给助手 —— 它随每次 chat 请求发给服务端,
* 当作 `propose_assignment` 的诊所兜底。⛔ 别指望模型从对话里推出来:
* 移交那句话里根本没有诊所(见 assistant-store 的 activeClinicId 注释)。
*/
useEffect(() => {
useAssistantStore.getState().setActiveClinicId(clinicId);
}, [clinicId]);
/**
* 默认落到第一个可见诊所。
......
'use client';
import { create } from 'zustand';
import type { SheetSnapshot } from '@pac/types';
import { emitPetEvent } from '@/lib/pet-events';
import { STREAM_TOTAL_MS } from '@/components/assistant/cohort-stream';
......@@ -55,6 +56,32 @@ interface AssistantState {
treatment?: string;
temperature?: string;
}) => 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)发完后调,避免重复发送 */
consume: (seq: number) => void;
}
......@@ -83,6 +110,10 @@ export const useAssistantStore = create<AssistantState>((set, get) => ({
*/
window.setTimeout(() => get().ask(text), STREAM_TOTAL_MS);
},
activeClinicId: null,
setActiveClinicId: (activeClinicId) => set({ activeClinicId }),
sheetSnapshot: null,
setSheetSnapshot: (sheetSnapshot) => set({ sheetSnapshot }),
consume: (seq) => {
// 只清掉自己那一条 —— 期间若又来了新请求(seq 更大),不能连它一起清
if (get().pending?.seq === seq) set({ pending: null });
......
......@@ -45,7 +45,7 @@
| 5 | `Signal` 类型 + 6 个判定器 + 需处置/提示分层 | **新建** `plan/assignment-signals.ts` |
| 6 | **基数改造**:每人每日 15 × 在岗 × D,⛔ 不再沿用上次 | `assignment-proposal.service.ts` |
| 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` |
| 10 | 确认单卡片渲染节点区 + 选项按钮 | `assignment-confirm-sheet.tsx` |
......
......@@ -326,11 +326,11 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
> 两轴天然互补:X 轴(潜在治疗)已去时间门,Y 轴(窗口温度)正好把时间维度补回来。
### T6 · 档位 = 诊断距今多久(一条绝对时间轴)
### T6 · 档位 = 距上次到诊多久(一条绝对时间轴)
不是客户价值、不是意愿、不是末诊天数,而是:
不是客户价值、不是意愿、不是诊断距今,而是:
| 档 | 定义(距锚点 = 该标签最新的未治疗诊断) |
| 档 | 定义(距锚点 = 患者末诊 `patient_profiles.last_visit_at`) |
|---|---|
| 三个月内 | `≤ 90 天` |
| 三个月到半年 | `90–180 天` |
......@@ -347,42 +347,51 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
> (cooldown)与打分衰减,**只是不再管矩阵档位** —— 两者已脱钩,⛔ 别顺手统一。
> 天数常量的单一真理源:`HOT_BUCKET_DAYS` / `WARM_BUCKET_DAYS`(`packages/types/src/temperature.ts`)。
#### T6″ · 锚点两版(2026-08-11)—— 距**哪个**时刻多久
#### T6″ · 锚点 = 末诊,只此一版(2026-08-13)
「机会有多老」**不可观测**`onset_date` 在标准里但宿主 0 填充。手上只有两个观测时间,
各自诚实,主管在矩阵上现切(`AnchorMode`):
2026-08-11 曾把两版都做出来让主管在矩阵上现切(`AnchorMode`),**两天后删掉了按诊断那版**
现在锚点只有一个:患者末诊。
| mode | 锚点 | 回答的问题 |
|---|---|---|
| `diagnosis`(默认) | 医生最后一次写下该诊断 | 患者最后一次**被提醒**是多久前(话术) |
| `last_visit` | 患者末诊(`patient_profiles.last_visit_at`) | 这人多久**没露面**(接通率) |
> 🔴 `diagnosis` 有一个已量化的偏差:复诊时医生常不重复写同一诊断 ——
> 🔴 **⛔ 别把「按诊断」加回来。** 它有一个已量化的系统偏差:复诊时医生常不重复写同一诊断 ——
> **测试服朝阳全量实测**(45,190 个机会):诊断后患者又来过的 19,605 个里,
> **49.1%** 一次都没再写(期间平均又来 4.7 次),诊断日与末诊日**中位差 490 天**
> (矩阵上差 1–2 档);各医生重复记录率差约一倍。
> ⚠️ 所以 `diagnosis` 档位里含着「这个医生爱不爱重复写」的成分,⛔ 别读成临床新鲜度。
> ⇒ 那个档位里含着「这个医生爱不爱重复写」的成分,读不成临床新鲜度。
> 而召回问的是「这人多久没露面」,末诊直接回答它。
>
> ⚠️⚠️ **量这类比例必须按「日期」比,⛔ 不能按时间戳**:同一次就诊里 emr 与 diagnosis 的
> ⚠️⚠️ **量这类比例必须按「日期」比,⛔ 不能按时间戳**:同一次就诊里 emr 与 diagnosis 的
> `occurred_at` 可能差几毫秒,用 `>` 会把"本次就诊"误算成"后来又来过一次"——
> 测试服上按时间戳算出来的"又来过"是 99.98%(真值 43%),而这个错**不会报错**。
**🔴 这是口径开关,不是样式开关**(⛔ 别拿它跟当年那个「样式1/样式2」类比)。它决定分到的是哪批人,所以三处必须同时到位,缺一处就是 T14 事故:
删掉的不只是一个枚举 —— 一并去掉的是**同步三处口径**这件事本身。两版并存时,
「看」(`/plans/matrix?anchor=`)、「圈人」(`criteria.anchorMode`)、「批次名标记」
必须逐字一致,缺一处就是 T14 事故(主管看到 87 人、确认单捞出另一批,全程不报错)。
一版口径时这个事故**结构上不成立**
> ⭐ 这是「①结构上不可能 ≫ ②可见可撤 ≫ ③校验/提示词」的一次应用:
> 口径同源最可靠的办法不是校验两边一致,而是**没有第二个选项**。
1. **看**`/plans/matrix?anchor=`)与**圈人**`cohort-filter``criteria.anchorMode`)必须同 mode;
2. 随确认单进 `plan_assignments.criteria` 快照;
3. 批次名标出来(`ANCHOR_MODE_META[*].batchTag`,只标非默认那版 → 老批次不用改名)。
> ⚠️ 2026-08-11~13 之间建的批次,名字里可能带着「(按末诊)」标记 —— ⛔ 别去洗,
> 批次名是主管指认一批的唯一抓手。新批次不再标(只剩一版,标了是废字)。
助手侧的判据只有一条:主管那句话里**带「按末诊」三个字**才传 `last_visit`
`ANCHOR_MODE_TOOL_DESC`)——⛔ 改矩阵那句移交措辞就必须同步改工具说明。
> ⚠️ **拿本地库下结论前先看一眼末诊的跨度**(2026-08-11 踩过并当场被产品指出来):
> 当时本地只有 5,825 个患者、**最早末诊 2024-06**,「3 年以上」恒为空,
> 差点被读成"沉睡患者其实一直在来"—— 其实是那批人**根本不在库里**。
> 补摄之后本地已是 30,000 人、末诊 `2020-01 ~ 2026-06`、其中 **9,559 人超 3 年**
> (2026-08-13 实测),这一档才有东西。⛔ 空列先怀疑数据,别怀疑口径。
> 🔴 **⛔ 别拿本地库量这两版的差异**(2026-08-11 踩过并当场被产品指出来):
> 本地只有 5,825 个患者、**最早末诊 2024-06**(全是近两年来过的人),
> 于是「按末诊·3 年以上」在本地恒为空 —— 看着像"诊断三年前没做的人其实一直在来",
> 其实是那批沉睡患者**根本不在本地库里**。
> 测试服全量(朝阳池子 7,168 格位)的真相相反:**「3 年以上」按末诊仍有 3,592 格**,
> 占池子一半以上;诊断 3 年+ 的格位里 **89% 末诊也是 3 年+**(真沉睡),只有 11% 是"人一直在来"。
> ⇒ 两版在真实数据上**比想象的接近**(粗档同档率 90%),⛔ 别照本地那份 71% 做判断。
> ⚠️ 「三个月内」这一列**本来就常年是空的**,⛔ 别当成锚点改坏了:入池下界
> (`cooldownDays` 考虑期)已经把刚来过的人挡在召回池外 —— 换锚点前后都是 0(实测)。
> ⚠️ 末诊是**患者级**的:同一个人的所有治疗项必然落同一档,所以矩阵一行里
> 一个患者只会出现在一列。⛔ 别把这当成取数漏了。
>
> ⭐ 这顺带修掉了诊断锚点的一个毛病:那时同一个人的两条 plan 可能落在**两列**,
> 各列去重人数相加会大于池子人数(2026-08-13 实测拔牙:19,269 → 18,872)。
>
> ⚠️ 副作用:时间档与画像 `rfm` 的 R 分**完全同尺**(R 档界 730/1095 与档位边界重合)
> ⇒ 冷档里「低活跃 / 重要挽留」占比高是**恒等式**,不是发现 —— 画像亮点别拿 rfm 点灯。
#### T6′ · 落地口径(2026-08-02 定稿,实现见 `packages/types/src/temperature.ts`)
......
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