Commit a07b3691 by luoqi

fix(助手): 补齐工具契约缺的那些格子 —— 温度枚举/诊所id/确认单指令

同一个形状一天栽了三次:**工具契约少一格,模型必然填错且静默**,
返回值都是合法的(一个数字、一个 0、一次成功的操作),只有肉眼比对才发现。
追提示词修不好 —— 得补格子。

- 温度枚举只有 hot/warm/cold(缺 cold_2y/cold_3y)→ 主管点「2–3 年」67 人,
  助手报 324。改成从 TEMPERATURE_ORDER 派生 + 中文对照表 +  不许把码说出口
- clinicId 必填且无兜底 → 模型编了 CL001,SQL 正确、返回 0,
  助手转头去解释"这批人为什么是空的"。改成可选 + resolveClinicId 兜底,
  范围外**抛错**并列出真实 id, 绝不当成"这个诊所没人"返回 0
- 确认单指令 8 条并列字面量 → **三个正交的轴**:
  select(patients/agent/pending/batch) × action × to(owner/balance)
  由来:主管说「把待分配的患者各自分给各自的专属客服」,而
  「待分配 × 改派 × 各自的专属」这一格是空的 —— 只有铺平可用,
  18 个人被散给了 17 位别人。而那恰恰是助手唯一不许自作主张干的事。
  正交化不消灭"枚举漏值",但缺的组合变成**表格里的空格**(看得见),
  测试里是一张 test.each 的「说话 → 拼法」对照表,加一行比加一条指令便宜

另:助手第 0 条「说人话」—— 禁说取值码/字段名/工具名/「温度」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 2dc6c19f
...@@ -2,7 +2,11 @@ import { Injectable } from '@nestjs/common'; ...@@ -2,7 +2,11 @@ import { Injectable } from '@nestjs/common';
// clean subpath(运行时 exports map 放行);类型见 src/types/mcp-sdk.d.ts ambient 声明 // clean subpath(运行时 exports map 放行);类型见 src/types/mcp-sdk.d.ts ambient 声明
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod'; import { z } from 'zod';
import { PERSONA_TAG_FILTER_DIMS, personaTagDimId, Permission } from '@pac/types'; import {
Permission,
TEMPERATURE_TOOL_DESC,
TEMPERATURE_TOOL_VALUES,
} from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { PatientService } from '../patient/patient.service'; import { PatientService } from '../patient/patient.service';
import { PersonaService } from '../persona/persona.service'; import { PersonaService } from '../persona/persona.service';
...@@ -14,28 +18,65 @@ import { CohortAttributesService, COHORT_DIM_CATALOG } from '../plan/cohort-attr ...@@ -14,28 +18,65 @@ import { CohortAttributesService, COHORT_DIM_CATALOG } from '../plan/cohort-attr
import type { ListPlansQueryDto } from '../plan/dto/plan.dto'; import type { ListPlansQueryDto } from '../plan/dto/plan.dto';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import type { McpAuthContext } from './mcp-auth.service'; import type { McpAuthContext } from './mcp-auth.service';
import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from './persona-tags.desc';
function jsonResult(data: unknown) { function jsonResult(data: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
} }
/** /**
* personaTags 圈人字典(从 PERSONA_TAG_FILTER_DIMS 生成,单一真理源,自动同步)。 * 🔴 诊所 id 的**唯一**入口 —— 不传就用登录人的第一个诊所,传了就**必须在他的范围里**。
* 格式给 LLM 看:`key(中文): code=中文 / code=中文 …`,一维一行。 *
* 入参格式:"key:value" 逗号串;同维多选 OR,跨维 AND(如 "rfm:important_value,urgency_level:urgent")。 * ── 由来(2026-08-06 实测)────────────────────────────────────────
* 主管点了矩阵的「充填 · 2–3 年」(138 人)再说「我只需要男性患者」,助手回
* 「这个格子当前是空的(0 人)」,还顺带编了两条像模像样的原因。
* 真因:`get_cohort_attributes` 的 `clinicId` 是**必填且没有兜底**,模型只好自己编了一个
* `"CL001"` 传进来 —— 查询完全正确,只是 `target_clinic_id = 'CL001'` 一条都匹配不上。
*
* ⚠️ **这个 bug 最坏的地方不是查不到,是查不到看起来像个结论**:
* 0 是一个合法答案,模型拿到 0 就去解释"为什么这批人是空的",越解释越像真的。
* 主管刚在矩阵上看过 138,下一句就成了 0 —— 违 T14「口径对数」。
*
* ✅ 两道闸,缺一不可:
* ① 不传 → 用 `scope.clinicIds[0]`(与 propose_assignment 的兜底完全一致,⛔ 别两套)
* ② 传了但不在范围里 → **抛错并把真实 id 列出来**,⛔ 绝不能当成"这个诊所没人"返回 0
*
* ⚠️ `scope.clinicIds` 为空 = 集团级范围(看全部诊所),此时无从校验,原样放行 ——
* 那种 scope 下 poolBaseSql 仍会按传入 id 过滤,查不到就是真查不到。
*/ */
const PERSONA_TAGS_HELP = PERSONA_TAG_FILTER_DIMS.map((d) => function resolveClinicId(scope: TenantScopeContext, clinicId?: string): string {
// ⚠️ 用 personaTagDimId 不用 d.key:source='patient' 的维度 key 为空串、筛选串用 id, const given = clinicId?.trim();
// 用 key 会给 LLM 一个空维度名,它照着发就永远筛不出人。 if (!given) {
// 开集维度(医生)options 为空 → 提示取值是自由文本,并指向名单接口,免得 LLM 瞎猜姓名。 const fallback = scope.clinicIds[0];
d.dynamic if (!fallback) {
? `${personaTagDimId(d)}(${d.nameZh}): <医生姓名,精确匹配;名单见 GET /pac/v1/plans/doctors>` throw new Error(
: `${personaTagDimId(d)}(${d.nameZh}): ${d.options.map((o) => `${o.value}=${o.zh}`).join(' / ')}`, '没有指定诊所,当前登录人也没有绑定诊所 —— 请先用 get_current_user 确认数据范围,⛔ 不要自己编一个诊所 id。',
).join('\n'); );
}
return fallback;
}
if (scope.clinicIds.length && !scope.clinicIds.includes(given)) {
throw new Error(
`诊所 id「${given}」不在当前登录人的数据范围内(他的诊所是:${scope.clinicIds.join(' / ')})。` +
'⛔ 不要自己编诊所 id —— 用 get_current_user 返回的 clinicIds,或者干脆不传(默认取第一个)。',
);
}
return given;
}
const PERSONA_TAGS_DESC = /**
'画像圈人(可选):"key:value" 逗号串,同维多选 OR、跨维 AND。可用维度与取值:\n' + * 诊所 id 入参 —— **可选**,且明说"不知道就别传"。
PERSONA_TAGS_HELP; *
* ⚠️ 原来是 `z.string()` 必填:模型没有 id 又必须填,只好编一个(实测编出了 `"CL001"`)。
* 把它改成可选不是"放宽",而是**把猜的动机拿掉** —— 服务端本来就知道该用哪个诊所。
*/
const CLINIC_ID_SCHEMA = z
.string()
.optional()
.describe(
'诊所 id(可选)。⛔ **不知道就不要传**,更不要自己编 —— 不传默认取当前登录人的第一个诊所。' +
'要显式指定时,只能用 get_current_user 返回的 clinicIds 里的值。',
);
function maskPhone(phone: string | null): string | null { function maskPhone(phone: string | null): string | null {
if (!phone) return null; if (!phone) return null;
...@@ -243,7 +284,7 @@ export class McpServerFactory { ...@@ -243,7 +284,7 @@ export class McpServerFactory {
sort: sort ?? 'priority_desc', sort: sort ?? 'priority_desc',
page: 1, page: 1,
pageSize: limit ?? 20, pageSize: limit ?? 20,
...(clinicId ? { targetClinicId: clinicId } : {}), ...(clinicId ? { targetClinicId: resolveClinicId(scope, clinicId) } : {}),
...(personaTags ? { personaTags } : {}), ...(personaTags ? { personaTags } : {}),
...(scenario ? { scenario } : {}), ...(scenario ? { scenario } : {}),
...(status ? { status } : {}), ...(status ? { status } : {}),
...@@ -276,7 +317,7 @@ export class McpServerFactory { ...@@ -276,7 +317,7 @@ export class McpServerFactory {
sort: 'priority_desc', sort: 'priority_desc',
page: 1, page: 1,
pageSize: 1, // stats 不取明细行 pageSize: 1, // stats 不取明细行
...(clinicId ? { targetClinicId: clinicId } : {}), ...(clinicId ? { targetClinicId: resolveClinicId(scope, clinicId) } : {}),
...(personaTags ? { personaTags } : {}), ...(personaTags ? { personaTags } : {}),
...(scenario ? { scenario } : {}), ...(scenario ? { scenario } : {}),
}; };
...@@ -305,7 +346,7 @@ export class McpServerFactory { ...@@ -305,7 +346,7 @@ export class McpServerFactory {
'⛔ **不要替他做减法说"还能吃 N 个"**,他知道谁在休假,你不知道。' + '⛔ **不要替他做减法说"还能吃 N 个"**,他知道谁在休假,你不知道。' +
'要看本批每人分多少,那是 propose_assignment 出的确认单的事。', '要看本批每人分多少,那是 propose_assignment 出的确认单的事。',
inputSchema: { inputSchema: {
clinicId: z.string().describe('诊所 id(必填,名册天然是诊所维度的)'), 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())
...@@ -314,7 +355,12 @@ export class McpServerFactory { ...@@ -314,7 +355,12 @@ export class McpServerFactory {
}, },
}, },
async ({ clinicId, months, include }) => async ({ clinicId, months, include }) =>
jsonResult(await this.roster.list(scope, clinicId, { months, extraUserIds: include })), jsonResult(
await this.roster.list(scope, resolveClinicId(scope, clinicId), {
months,
extraUserIds: include,
}),
),
); );
server.registerTool( server.registerTool(
...@@ -329,15 +375,15 @@ export class McpServerFactory { ...@@ -329,15 +375,15 @@ export class McpServerFactory {
'\n⚠️ 专属客服只报有/无,**不判在岗** —— 谁休假谁离职以主管说的为准,别替他挡人。' + '\n⚠️ 专属客服只报有/无,**不判在岗** —— 谁休假谁离职以主管说的为准,别替他挡人。' +
`\n可点名的维度(keys,不传给一组常用的):${COHORT_DIM_CATALOG}`, `\n可点名的维度(keys,不传给一组常用的):${COHORT_DIM_CATALOG}`,
inputSchema: { inputSchema: {
clinicId: z.string(), clinicId: CLINIC_ID_SCHEMA,
potentialTreatment: z potentialTreatment: z
.string() .string()
.optional() .optional()
.describe('矩阵 X 轴:implant / ortho / early_ortho / endo / perio / filling / restoration / extraction'), .describe(POTENTIAL_TREATMENT_DESC),
temperature: z temperature: z
.enum(['hot', 'warm', 'cold']) .enum(TEMPERATURE_TOOL_VALUES as unknown as [string, ...string[]])
.optional() .optional()
.describe('矩阵 Y 轴:窗口温度。⚠️ 必须与 potentialTreatment 同时给'), .describe(TEMPERATURE_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('只看这些维度(见描述里的维度清单)'),
}, },
...@@ -347,7 +393,7 @@ export class McpServerFactory { ...@@ -347,7 +393,7 @@ export class McpServerFactory {
await this.cohorts.describe( await this.cohorts.describe(
scope, scope,
{ {
clinicId, clinicId: resolveClinicId(scope, clinicId),
...(potentialTreatment ? { potentialTreatment } : {}), ...(potentialTreatment ? { potentialTreatment } : {}),
...(temperature ? { temperature } : {}), ...(temperature ? { temperature } : {}),
...(personaTags ? { personaTags } : {}), ...(personaTags ? { personaTags } : {}),
......
import {
PERSONA_TAG_FILTER_DIMS,
personaTagDimId,
potentialTreatmentItemName,
} from '@pac/types';
/**
* personaTags 圈人字典(从 `PERSONA_TAG_FILTER_DIMS` 生成,单一真理源,自动同步)。
*
* 格式给 LLM 看:`key(中文): code=中文 / code=中文 …`,一维一行。
* 入参格式:"key:value" 逗号串;同维多选 OR,跨维 AND(如 "rfm:important_value,urgency_level:urgent")。
*
* ⚠️ **凡是收 personaTags 的工具都必须挂这份字典**(MCP 的 list_recall_queue /
* get_cohort_attributes,以及助手本地的 propose_assignment)——
* 少给一处,模型在那处就得自己猜 key 和 value code。
* 🔴 而猜错的维度会被 `cohort-filter.personaTagsSql` **静默丢掉**(`if (!dim) continue`):
* 筛选条件等于没加,人数一个不少地返回,**不报错也看不出来**。
* 所以这份字典不是"锦上添花的说明",是防静默失败的必需品。
*
* ⛔ 别再在某个工具里内联一份简写版 —— 那就是第二份真理源。
*/
const PERSONA_TAGS_HELP = PERSONA_TAG_FILTER_DIMS.map((d) =>
// ⚠️ 用 personaTagDimId 不用 d.key:source='patient' 的维度 key 为空串、筛选串用 id,
// 用 key 会给 LLM 一个空维度名,它照着发就永远筛不出人。
// 开集维度(医生)options 为空 → 提示取值是自由文本,并指向名单接口,免得 LLM 瞎猜姓名。
d.dynamic
? `${personaTagDimId(d)}(${d.nameZh}): <医生姓名,精确匹配;名单见 GET /pac/v1/plans/doctors>`
: `${personaTagDimId(d)}(${d.nameZh}): ${d.options.map((o) => `${o.value}=${o.zh}`).join(' / ')}`,
).join('\n');
export const PERSONA_TAGS_DESC =
'画像圈人(可选):"key:value" 逗号串,同维多选 OR、跨维 AND。' +
'⛔ **只能用下表里的 key 和 value**,写别的会被静默忽略(筛选等于没加,人数却照样返回)。' +
'可用维度与取值:\n' +
PERSONA_TAGS_HELP;
/**
* 潜在治疗(矩阵那一行)的取值说明 —— 同样从 `PERSONA_TAG_FILTER_DIMS` 生成。
*
* 🔴 与温度同一条规矩:**码给模型调工具用,中文给主管听**。
* 把 code=中文 的对照表给全,模型才有得翻;⛔ 不给对照表它就只能把 `filling` 原样念出来。
*/
const POTENTIAL_TREATMENT_DIM = PERSONA_TAG_FILTER_DIMS.find(
(d) => d.key === 'potential_treatment',
);
export const POTENTIAL_TREATMENT_DESC =
'矩阵的一行:潜在治疗(界面标题就叫「潜在治疗」)。按此表把主管的话翻成取值:' +
// ⚠️ 中文取 `potentialTreatmentItemName` —— 那是**矩阵实际渲染用的**同一个函数。
// ⛔ 别改用 dim.options 的 zh:那份曾漂成「补牙」,而界面上是「充填」。
(POTENTIAL_TREATMENT_DIM?.options ?? [])
.map((o) => `${potentialTreatmentItemName(o.value)}=${o.value}`)
.join(' · ') +
'。\n🔴 ⛔ **这些取值码只用于调工具,一个字都不许说给主管** —— 回话一律用中文' +
'(「充填」「种植」「牙周」…);⛔ 也别自己发明「病种」「科室」这类界面上没有的叫法。';
import { import {
Temperature, Temperature,
TEMPERATURE_META,
TEMPERATURE_ORDER,
TEMPERATURE_TOOL_DESC,
TEMPERATURE_TOOL_VALUES,
classifyTemperature, classifyTemperature,
gapTemperatureBounds, gapTemperatureBounds,
hottestBounds, hottestBounds,
...@@ -56,7 +60,8 @@ describe('温度口径 —— 逐条 gap 用自己的窗', () => { ...@@ -56,7 +60,8 @@ describe('温度口径 —— 逐条 gap 用自己的窗', () => {
expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(60)), NOW)).toBe(Temperature.HOT); // urgency=60 expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(60)), NOW)).toBe(Temperature.HOT); // urgency=60
expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(61)), NOW)).toBe(Temperature.WARM); expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(61)), NOW)).toBe(Temperature.WARM);
expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(90)), NOW)).toBe(Temperature.WARM); // window=90 expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(90)), NOW)).toBe(Temperature.WARM); // window=90
expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(91)), NOW)).toBe(Temperature.COLD); // 91 天前 = 出窗但不到 1 年 → 冷端第一档
expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(91)), NOW)).toBe(Temperature.COLD_1Y);
}); });
test('未知码 / 无锚点 → null,⛔ 不猜一个默认窗口', () => { test('未知码 / 无锚点 → null,⛔ 不猜一个默认窗口', () => {
...@@ -73,17 +78,18 @@ describe('温度口径 —— 反单调性(这是旧口径最贵的那个 bug)', ...@@ -73,17 +78,18 @@ describe('温度口径 —— 反单调性(这是旧口径最贵的那个 bug)',
const fresh = gapTemperatureBounds('K02', daysAgo(5)); const fresh = gapTemperatureBounds('K02', daysAgo(5));
const stale = gapTemperatureBounds('K02', daysAgo(700)); const stale = gapTemperatureBounds('K02', daysAgo(700));
expect(classifyTemperature(fresh, NOW)).toBe(Temperature.HOT); expect(classifyTemperature(fresh, NOW)).toBe(Temperature.HOT);
expect(classifyTemperature(stale, NOW)).toBe(Temperature.COLD); expect(classifyTemperature(stale, NOW)).toBe(Temperature.COLD_2Y); // 700 天 ≈ 1.9 年
expect(classifyTemperature(hottestBounds([fresh, stale]), NOW)).toBe(Temperature.HOT); expect(classifyTemperature(hottestBounds([fresh, stale]), NOW)).toBe(Temperature.HOT);
expect(classifyTemperature(hottestBounds([stale, fresh]), NOW)).toBe(Temperature.HOT); // 与顺序无关 expect(classifyTemperature(hottestBounds([stale, fresh]), NOW)).toBe(Temperature.HOT); // 与顺序无关
}); });
test('⭐ 单调性通则:往集合里加任何一条 gap,档位只可能变热或不变', () => { test('⭐ 单调性通则:往集合里加任何一条 gap,档位只可能变热或不变', () => {
const base = [gapTemperatureBounds('K02', daysAgo(700))]; const base = [gapTemperatureBounds('K02', daysAgo(700))];
const rank = { hot: 0, warm: 1, cold: 2 } as const; // 六档由热到冷的名次 —— ⛔ 别再手写,从 TEMPERATURE_ORDER 派生(加档位时自动跟上)
const before = rank[classifyTemperature(hottestBounds(base), NOW)!]; const rank = Object.fromEntries(TEMPERATURE_ORDER.map((t, i) => [t, i])) as Record<string, number>;
const before = rank[classifyTemperature(hottestBounds(base), NOW)!]!;
for (const d of [1, 30, 59, 60, 61, 89, 90, 91, 365, 3000]) { for (const d of [1, 30, 59, 60, 61, 89, 90, 91, 365, 3000]) {
const after = rank[classifyTemperature(hottestBounds([...base, gapTemperatureBounds('K02', daysAgo(d))]), NOW)!]; const after = rank[classifyTemperature(hottestBounds([...base, gapTemperatureBounds('K02', daysAgo(d))]), NOW)!]!;
expect(after).toBeLessThanOrEqual(before); expect(after).toBeLessThanOrEqual(before);
} }
}); });
...@@ -119,7 +125,7 @@ describe('温度口径 —— 边界时刻是事实,不是时钟', () => { ...@@ -119,7 +125,7 @@ describe('温度口径 —— 边界时刻是事实,不是时钟', () => {
const b = gapTemperatureBounds('K04', new Date('2026-08-02T00:00:00.000Z'))!; // 60/45 const b = gapTemperatureBounds('K04', new Date('2026-08-02T00:00:00.000Z'))!; // 60/45
expect(classifyTemperature(b, new Date('2026-09-10T00:00:00.000Z'))).toBe(Temperature.HOT); // +39d expect(classifyTemperature(b, new Date('2026-09-10T00:00:00.000Z'))).toBe(Temperature.HOT); // +39d
expect(classifyTemperature(b, new Date('2026-09-20T00:00:00.000Z'))).toBe(Temperature.WARM); // +49d expect(classifyTemperature(b, new Date('2026-09-20T00:00:00.000Z'))).toBe(Temperature.WARM); // +49d
expect(classifyTemperature(b, new Date('2026-11-01T00:00:00.000Z'))).toBe(Temperature.COLD); // +91d expect(classifyTemperature(b, new Date('2026-11-01T00:00:00.000Z'))).toBe(Temperature.COLD_1Y); // +91d
}); });
test('⭐ 边界存的是 UTC ISO —— 字典序必须等于时间序(SQL 侧靠这个直接比较)', () => { test('⭐ 边界存的是 UTC ISO —— 字典序必须等于时间序(SQL 侧靠这个直接比较)', () => {
...@@ -177,29 +183,81 @@ describe('窗口期 hover 文案 —— 只给区间,不给解释', () => { ...@@ -177,29 +183,81 @@ describe('窗口期 hover 文案 —— 只给区间,不给解释', () => {
test('⭐ 单码标签给确切区间(种植 黄金 120 / 窗 180)', () => { test('⭐ 单码标签给确切区间(种植 黄金 120 / 窗 180)', () => {
expect(temperatureWindowHint('implant', Temperature.HOT)).toBe('≤ 120 天'); expect(temperatureWindowHint('implant', Temperature.HOT)).toBe('≤ 120 天');
expect(temperatureWindowHint('implant', Temperature.WARM)).toBe('120–180 天'); expect(temperatureWindowHint('implant', Temperature.WARM)).toBe('120–180 天');
expect(temperatureWindowHint('implant', Temperature.COLD)).toBe('> 180 天'); // 冷端四档是**绝对年数**,与治疗项无关 —— 不再是"> 窗口天数"
expect(temperatureWindowHint('implant', Temperature.COLD_1Y)).toBe('出周期后 1 年内');
expect(temperatureWindowHint('implant', Temperature.COLD_OVER)).toBe('诊断距今 > 3 年');
}); });
test('⭐⭐ 多码标签给各码区间的**并集** —— 挑一个码的数字会骗人', () => { test('⭐⭐ 多码标签给各码区间的**并集** —— 挑一个码的数字会骗人', () => {
// 拔牙 = K01(黄金 90 / 窗 180) ∪ K03(黄金 60 / 窗 90) // 拔牙 = K01(黄金 90 / 窗 180) ∪ K03(黄金 60 / 窗 90)
expect(temperatureWindowHint('extraction', Temperature.HOT)).toBe('≤ 90 天'); expect(temperatureWindowHint('extraction', Temperature.HOT)).toBe('≤ 90 天');
expect(temperatureWindowHint('extraction', Temperature.WARM)).toBe('60–180 天'); expect(temperatureWindowHint('extraction', Temperature.WARM)).toBe('60–180 天');
expect(temperatureWindowHint('extraction', Temperature.COLD)).toBe('> 90 天'); // 冷端与码无关,多码标签也是同一套年数
expect(temperatureWindowHint('extraction', Temperature.COLD_2Y)).toBe('诊断距今 1–2 年');
}); });
test('⭐ 三档区间必须**覆盖整条数轴**,不留缝也不写反', () => { test('⭐ 前两档区间必须**首尾相接**,不留缝也不写反', () => {
// 缝 = 有人哪一档都不属于;写反 = 冷的下界跑到热的上界左边,主管一看就知道在乱说 // 缝 = 有人哪一档都不属于;写反 = 温的上界跑到热的上界左边,主管一看就知道在乱说。
// ⚠️ 冷端四档已改成绝对年数(与治疗项无关),不参与这条"按天数接龙"的校验。
for (const label of Object.keys(POTENTIAL_TREATMENT_SOURCE_CODES)) { for (const label of Object.keys(POTENTIAL_TREATMENT_SOURCE_CODES)) {
const hot = Number(temperatureWindowHint(label, Temperature.HOT).match(/\d+/)![0]); const hot = Number(temperatureWindowHint(label, Temperature.HOT).match(/\d+/)![0]);
const [warmLo, warmHi] = temperatureWindowHint(label, Temperature.WARM).match(/\d+/g)!.map(Number); const [warmLo, warmHi] = temperatureWindowHint(label, Temperature.WARM).match(/\d+/g)!.map(Number);
const cold = Number(temperatureWindowHint(label, Temperature.COLD).match(/\d+/)![0]);
// 单码时三档首尾**恰好相接**(温上界 == 冷下界),多码时并集会让温更宽 —— 都不许留缝
expect(warmLo).toBeLessThanOrEqual(hot); expect(warmLo).toBeLessThanOrEqual(hot);
expect(warmHi).toBeGreaterThanOrEqual(cold); expect(warmHi).toBeGreaterThan(hot);
} }
}); });
test('⭐⭐ 六档必须两两互斥且覆盖整条时间轴 —— 任何一天都恰好属于一档', () => {
// 这条比"文案接龙"硬:直接拿判定函数在时间轴上扫,漏一天或重一天都会红。
const b = gapTemperatureBounds('K02', new Date('2020-01-01T00:00:00.000Z'))!; // 60/90
const seen: string[] = [];
for (const d of [0, 60, 61, 90, 91, 364, 365, 366, 730, 731, 1095, 1096, 5000]) {
const t = classifyTemperature(b, new Date(Date.parse(b.anchorAt) + d * 86_400_000));
expect(t).not.toBeNull(); // ⛔ 任何一天都不许落进"未知"
if (!seen.length || seen[seen.length - 1] !== t) seen.push(t!);
}
// 走过的档位序列必须是 TEMPERATURE_ORDER 的**前缀连续子序列**(单调变冷,不回头、不跳档)
const idx = seen.map((t) => TEMPERATURE_ORDER.indexOf(t as never));
expect(idx).toEqual([...idx].sort((a, z) => a - z));
expect(new Set(idx).size).toBe(idx.length);
});
test('未知标签 → 空串(不编)', () => { test('未知标签 → 空串(不编)', () => {
expect(temperatureWindowHint('not_a_label', Temperature.HOT)).toBe(''); expect(temperatureWindowHint('not_a_label', Temperature.HOT)).toBe('');
}); });
}); });
/**
* ⭐ 工具入参的档位清单必须跟着矩阵走 —— 2026-08-05 事故的回归。
*
* 事故经过:冷端从 1 档拆成 4 档,`Temperature` / `TEMPERATURE_ORDER` / 矩阵 / SQL 全改了,
* 唯独两处 LLM 工具定义里的 `enum: ['hot','warm','cold']` 是**手写**的,没人跟着改。
* 主管点了矩阵的「牙周 · 2–3 年」(67 人),模型在三个取值里找不到对应档 → 退而选 `cold`,
* 而 `cold` 展开成四个冷档的并集 → 确认单报出 324 人。
*
* ⚠️ 这个 bug **没有任何报错**:枚举合法、SQL 合法、count 也是对的,
* 只有把矩阵和确认单摆在一起看才会发现口径差了 4.8 倍(违 T14「口径对数」)。
* 所以只能靠这条测试锁 —— tsc 抓不到手写字符串数组和枚举脱节。
*/
describe('LLM 工具的温度取值 —— 必须与矩阵同源', () => {
test('⭐⭐ 每个展示档都能被工具表达(少一个 = 模型只能退而求其次,静默放大人数)', () => {
for (const t of TEMPERATURE_ORDER) {
expect(TEMPERATURE_TOOL_VALUES).toContain(t);
}
});
test('除了兼容值 cold,不许多出矩阵里没有的档(否则模型会给出渲染不出来的格子)', () => {
expect([...TEMPERATURE_TOOL_VALUES].sort()).toEqual([...TEMPERATURE_ORDER, 'cold'].sort());
});
test('说明里必须带**中文档名对照** —— 主管说的是「2–3 年」,模型得自己翻回 cold_3y', () => {
for (const t of TEMPERATURE_ORDER) {
expect(TEMPERATURE_TOOL_DESC).toContain(`${TEMPERATURE_META[t].zh}=${t}`);
}
});
test('cold 必须被标注成"合计",否则模型会拿它当某一档用', () => {
expect(TEMPERATURE_TOOL_DESC).toMatch(/cold/);
expect(TEMPERATURE_TOOL_DESC).toMatch(/合计|并集|全体/);
});
});
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