Commit 0df27893 by luoqi

feat(persona): S2.3 温度口径裁决 —— 在温度层聚合 + 锚点存边界时刻

裁决开发规划 Q-6(原判「8 标签 ← K 码一对多,无解」)。原建议是给 8 个业务标签
另定一张标签级窗口表并明写「这是新口径」;**否掉**,因为真正的解法是换一层聚合:

  逐条 gap 用自己 K 码的窗口判档 → 再取最热

一对多自动消解(extraction 里 K01 那条按 K01 判、K03 那条按 K03 判),
不需要任何新常量,DiagnosisTreatmentMap 是真的被复用了 ——
教条 T6「不新增口径」与 canonical-codes「不允许任一处再硬编码窗口」都不用破例。
原判「无解」源于默认了聚合必须发生在天数层。

同时修掉旧聚合的**反单调**:先 Math.max(daysSince) 再判档,会让「上周刚查出的龋齿」
因为身上还有一颗两年前的旧龋而被判成冷 —— 多一条未满足需求反而更冷。

存边界时刻而非天数/档位(解冻):
  daysSince 在 VOLATILE_DATA_KEYS 里被 stripVolatile 剔掉 → 时间流逝永不触发重算
  → 温度档永久冻结在画像算出来那一刻。改存 hotUntil/warmUntil(= 锚点 + 窗口),
  读时跟 now 比。二者由「事实日期 + 静态配置」推出,不是易变键。
  同一套路本仓已用过两次(visitRecencyRange / applyLiveDays),这是第三次。

「不知道」不等于「冷」:老画像无边界 → classifyTemperature 返回 null 而非 cold,
否则老数据会静默塞满冷格子,主管看到一个假分布还看不出哪里假(T14)。

 顺带抓出并修掉一个**静默归零**缺陷(教条 §4.38):
assignment-proposal 按 `pe.id = fp.persona_id AND pe.superseded_at IS NULL` 关联画像,
而 plan 的 persona_id 不随画像升版本更新 → 重算一次后条件恒为假、筛选变 0 条。
实测:全量重算后池子里只剩 97/2,724 指向活版本,「潜在种植」按 persona_id 得 0 条、
按 patient_id 得 376 条(列表页一直是后者)。已改为按 patient_id + 源码形态护栏。

实测量级(本地 5,825 患者,召回池 3,880 个「患者×标签」格位):
翻档 1.31%(43 变热 / 8 变冷,其中 6 条是压线、2 条是 K06 被当成 K05 的误判改对)。
️ 拿未过 gap 闸的原始诊断事实去量会得到 40-70% 的夸张差值,那是错的人群。

917 tests passing;本地全量 persona 重算 success=3020 refreshed=2143 unchanged=662 failed=0。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent cebde6b1
......@@ -53,7 +53,8 @@ export class PotentialTreatmentSelector {
sig.type AS signal_type,
${gap.toothOutput} AS tooth,
sig.content->>'confidence' AS confidence,
EXTRACT(DAY FROM ${now}::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since
EXTRACT(DAY FROM ${now}::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since,
COALESCE(sig.occurred_at, sig.planned_for) AS anchor_at
FROM patients p
JOIN patient_facts sig ON sig.patient_id = p.id
${gap.lateralJoin}
......@@ -77,6 +78,7 @@ export class PotentialTreatmentSelector {
nameZh: r.name_zh ?? null,
tooth: r.tooth ?? null,
daysSince: r.days_since,
anchorAt: r.anchor_at,
signalType: r.signal_type === 'recommendation_record' ? 'recommendation' : 'diagnosis',
confidence: r.confidence
? Number(r.confidence)
......@@ -97,6 +99,14 @@ export interface PotentialGap {
nameZh: string | null; // 诊断中文名(K03 拆 拔牙/修复 用)
tooth: string | null; // 剩余未治牙位(';' 分隔;全口码为 null)
daysSince: number;
/**
* 信号发生时刻(诊断 occurred_at / 推荐 planned_for)—— **不可变锚点**。
*
* 与 `daysSince` 的分工同 `ReasonSignals.signalOccurredAt` / `daysSince` 那一对:
* 天数是算出来那一刻的快照(会陈旧),锚点是事实(不会)。窗口温度只认锚点 ——
* 见 `@pac/types` 的 `gapTemperatureBounds`,以及那里「存边界时刻不存天数」的整段理由。
*/
anchorAt: Date;
signalType: 'diagnosis' | 'recommendation';
confidence: number; // 诊断 1.0 / 建议 0.8
}
......@@ -109,4 +119,5 @@ interface RawGapRow {
tooth: string | null;
confidence: string | null;
days_since: number;
anchor_at: Date;
}
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey } from '@pac/types';
import {
PersonaFeatureKey,
gapTemperatureBounds,
hottestBounds,
type TemperatureBounds,
} from '@pac/types';
import type {
FeatureExtractor,
FeatureExtractorContext,
......@@ -23,6 +28,10 @@ import { nextAgeBoundary } from './time-boundary';
* 修复←K03(默认)· 拔牙←K01 + K03(name 含 残根/残冠/无法保留/不能保留)
* K00 发育 / K09 囊肿 不在业务 8 标签 → 不出此标签(召回仍覆盖)。
*
* ⭐ **本特征同时是初选矩阵的两根轴**:X 轴 = `data.types`(8 标签),
* Y 轴 = `data.detail[].hotUntil / warmUntil`(窗口温度边界,读时定档)。
* 温度为什么这么存、为什么逐条 gap 各判各的窗,见 `@pac/types` 的 temperature.ts 文件头。
*
* 业务 spec 对账:
* - 置信度(病历100%/影像AI 70-90%/客服勾选 50-70%)= PAC diagnosis=1.0 / recommendation=0.8(已建模)。
* - Step3 主诉意愿加分 = 排序事,消费方自算(score 弃用原则,不进标签)。
......@@ -89,9 +98,24 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
const age = ageYearsAt(ctx.patient.birthDate, ctx.now);
// 按业务标签聚合:teeth 并集 / daysSince 取最大(最早需求)/ confidence 取最大 / 来源
//
// ⚠️⚠️ **两个方向相反的聚合并存,别把它们统一掉**:
// · `daysSince` 取 **max** = 「这个机会挂了多久」—— 话术勾子用(「您去年查出的龋齿一直没补」)
// · 温度边界取 **最热** = 「现在该不该打」—— 初选矩阵 Y 轴用
// 拿 max(daysSince) 去判温度会得到反单调的结论(多一条旧需求 = 更冷),
// 实测热少报 40-70%。整段推理见 @pac/types 的 temperature.ts 文件头。
const agg = new Map<
string,
{ zh: string; teeth: Set<string>; daysSince: number; confidence: number; hasDx: boolean; hasRec: boolean }
{
zh: string;
teeth: Set<string>;
daysSince: number;
confidence: number;
hasDx: boolean;
hasRec: boolean;
/** 逐条 gap 按**自己 K 码**的窗口算出的边界,聚合时取最热 */
bounds: Array<TemperatureBounds | null>;
}
>();
const factIds = new Set<string>();
for (const g of gaps) {
......@@ -100,10 +124,22 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
factIds.add(g.factId);
const cur =
agg.get(lbl.key) ??
{ zh: lbl.zh, teeth: new Set<string>(), daysSince: 0, confidence: 0, hasDx: false, hasRec: false };
{
zh: lbl.zh,
teeth: new Set<string>(),
daysSince: 0,
confidence: 0,
hasDx: false,
hasRec: false,
bounds: [] as Array<TemperatureBounds | null>,
};
for (const t of (g.tooth ?? '').split(';').map((s) => s.trim()).filter(Boolean)) cur.teeth.add(t);
cur.daysSince = Math.max(cur.daysSince, g.daysSince);
cur.confidence = Math.max(cur.confidence, g.confidence);
// ⭐ 用 g.primaryCode 而不是 g.code:挖 gap 时用的就是 lookupDxTreatment(primaryCode)
// (见 potential-treatment.selector),温度必须跟它同一条规则,否则 K08 与
// IMPLANT_RECOMMENDED 会各判各的窗,同一个标签内部就先自相矛盾了。
cur.bounds.push(gapTemperatureBounds(g.primaryCode, g.anchorAt));
if (g.signalType === 'diagnosis') cur.hasDx = true;
else cur.hasRec = true;
agg.set(lbl.key, cur);
......@@ -117,6 +153,7 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
const detail = keys.map((k) => {
const v = agg.get(k)!;
const teeth = [...v.teeth].sort();
const hot = hottestBounds(v.bounds);
return {
key: k,
label: v.zh,
......@@ -124,6 +161,11 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
daysSince: v.daysSince,
confidence: v.confidence,
source: v.hasDx && v.hasRec ? 'both' : v.hasRec ? 'recommendation' : 'diagnosis',
// ⭐ 窗口温度的两个边界时刻。**不是易变键** —— 同一批事实换个时刻重算值不变,
// 所以 ⛔ 别加进 persona-diff 的 VOLATILE_DATA_KEYS(加了就永远不会因为
// "新来一条诊断把温度顶热了"而升版本,等于把这次修的冻结原样搬到另一层)。
// 档位在读时由 classifyTemperature(detail, now) 现算。
...(hot ?? {}),
};
});
......
......@@ -204,12 +204,22 @@ export class AssignmentProposalService {
? Prisma.sql`AND p.source_unit IN (${Prisma.join(scope.sourceUnits)})`
: Prisma.empty;
// 潜在治疗:画像特征 data->'types' 是字符串数组,用 @> 命中(与 persona-tag-filters 同口径)
//
// 🔴 **必须按 `pe.patient_id = fp.patient_id` 关联,⛔ 不能按 `pe.id = fp.persona_id`**。
// `followup_plans.persona_id` 指向**建 plan 那一刻的画像版本**;画像一升版本,
// 那一行就被 supersede,而 plan 的外键**不会跟着改**。于是
// `pe.id = fp.persona_id AND pe.superseded_at IS NULL` 会在下一次重算后**恒为假**,
// 整个筛选静默变成 0 条 —— 不报错、不告警,主管只看到"这个格子没人了"。
// 2026-08-02 本地实测:一次全量重算后,2,724 条池子里只剩 97 条还指向活着的画像版本;
// 同一个「潜在种植」条件,按 persona_id 关联 **0 条**,按 patient_id 关联 **376 条**。
// 列表页(`plan.service.buildListWhere` 的 `patient.personas.some`)一直是按患者取当前版的,
// 两处不一致正是 T6a「同源保证」要防的「矩阵上有人、点进去列表没有」。
const treatmentFilter = potentialTreatment
? Prisma.sql`
AND EXISTS (
SELECT 1 FROM personas pe
JOIN persona_features pf ON pf.persona_id = pe.id AND pf.key = 'potential_treatment'
WHERE pe.id = fp.persona_id
WHERE pe.patient_id = fp.patient_id
AND pe.superseded_at IS NULL
AND (pf.data #> '{types}') @> ${JSON.stringify([potentialTreatment])}::jsonb
)`
......
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* 圈人取数的关联口径护栏。
*
* 🔴 这条拦的是一个**静默归零**的 bug:按 `pe.id = fp.persona_id AND pe.superseded_at IS NULL`
* 关联画像,看着天经地义,实际上 `followup_plans.persona_id` 指向的是**建 plan 那一刻**的画像版本,
* 画像一升版本那行就被 supersede,而 plan 的外键不跟着走 —— 于是条件恒为假,
* 整个「潜在治疗」筛选变成 0 条,**不报错、不告警**,主管只看到"这个格子没人了"。
*
* 2026-08-02 本地实测(跑完一次全量 persona 重算之后):
* ```
* 池子里还指向"活着的画像版本"的 plan 97 / 2,724
* 同一个「潜在种植」条件 · 按 persona_id 0 条
* 同一个「潜在种植」条件 · 按 patient_id 376 条 ← 列表页一直是这个口径
* ```
* 两处不一致 = T6a「同源保证」明令要防的「矩阵上有人、点进去列表没有」。
*
* 单测拦不住 SQL 语义(要真库才跑得出来),所以这里退而**锁源码形态** ——
* 便宜,且正好拦住"顺手改回去"这一种复发方式。
*/
const SRC = readFileSync(
join(__dirname, '../src/modules/plan/assignment-proposal.service.ts'),
'utf-8',
);
/**
* 只看**代码**,不看注释 —— 那段注释里就写着反面教材 `pe.id = fp.persona_id`,
* 不剥掉的话这个护栏第一个抓住的是它自己。
*/
const CODE = SRC.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
describe('圈人取数 —— 画像必须按患者取当前版', () => {
test('⭐⭐ ⛔ 不许出现 `pe.id = fp.persona_id`(升一次版本就静默归零)', () => {
expect(CODE).not.toMatch(/pe\.id\s*=\s*fp\.persona_id/);
});
test('⭐ 必须按 `pe.patient_id = fp.patient_id` 关联 —— 与列表页 buildListWhere 同口径', () => {
expect(CODE).toMatch(/pe\.patient_id\s*=\s*fp\.patient_id/);
});
test('⭐ 且必须限定当前版(否则历史版本会把同一患者算多次)', () => {
expect(CODE).toContain('pe.superseded_at IS NULL');
});
});
import {
Temperature,
classifyTemperature,
gapTemperatureBounds,
hottestBounds,
lookupDxTreatment,
} from '@pac/types';
import { VOLATILE_DATA_KEYS } from '../src/modules/persona/persona-diff';
/**
* 窗口温度口径回归(教条 T6 / 开发规划 Q-6 的落地)。
*
* 这里锁的是三件**做错了不会报错、只会让主管看到一个假分布**的事:
* ① 逐条 gap 按自己 K 码的窗判档 —— 而不是先 max(daysSince) 再判
* ② 边界时刻是事实不是时钟 —— 换个"现在"重算,值必须一模一样
* ③ 老数据没有边界时 → 「未知」,⛔ 不许当成「冷」
*/
const DAY = 86_400_000;
const NOW = new Date('2026-08-02T00:00:00.000Z');
const daysAgo = (n: number) => new Date(NOW.getTime() - n * DAY);
describe('温度口径 —— 逐条 gap 用自己的窗', () => {
test('⭐ 各码的边界严格来自 DiagnosisTreatmentMap,不是自己拍的常量', () => {
for (const code of ['K01', 'K02', 'K03', 'K04', 'K05', 'K06', 'K07', 'K08']) {
const rule = lookupDxTreatment(code)!;
const b = gapTemperatureBounds(code, daysAgo(0))!;
expect(new Date(b.hotUntil).getTime() - NOW.getTime()).toBe(rule.urgencyDayThreshold * DAY);
expect(new Date(b.warmUntil).getTime() - NOW.getTime()).toBe(rule.windowDays * DAY);
}
});
test('⭐⭐ Q-6「一对多无解」其实有解:extraction ← K01(180/90) + K03(90/60) 各归各的', () => {
// 同一个「潜在拔牙」标签下的两条 gap,天数一样(70 天),但两码的黄金期不同:
// K01 urgency=90 → 70 天仍在黄金期 = 热
// K03 urgency=60 → 70 天已过黄金期 = 温
// 取最热 → 热。⛔ 不需要为「潜在拔牙」另定一套标签级窗口(那才是新增口径)。
const k01 = gapTemperatureBounds('K01', daysAgo(70));
const k03 = gapTemperatureBounds('K03', daysAgo(70));
expect(classifyTemperature(k01, NOW)).toBe(Temperature.HOT);
expect(classifyTemperature(k03, NOW)).toBe(Temperature.WARM);
expect(classifyTemperature(hottestBounds([k01, k03]), NOW)).toBe(Temperature.HOT);
});
test('⭐ perio ← K05(120/90) + K06(120/60) 同理', () => {
const k05 = gapTemperatureBounds('K05', daysAgo(75));
const k06 = gapTemperatureBounds('K06', daysAgo(75));
expect(classifyTemperature(k05, NOW)).toBe(Temperature.HOT); // 75 ≤ 90
expect(classifyTemperature(k06, NOW)).toBe(Temperature.WARM); // 75 > 60
expect(classifyTemperature(hottestBounds([k05, k06]), NOW)).toBe(Temperature.HOT);
});
test('三档边界是闭区间左闭右闭:恰好落在窗上的那天仍算窗内', () => {
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(90)), NOW)).toBe(Temperature.WARM); // window=90
expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(91)), NOW)).toBe(Temperature.COLD);
});
test('未知码 / 无锚点 → null,⛔ 不猜一个默认窗口', () => {
expect(gapTemperatureBounds('K99', daysAgo(1))).toBeNull();
expect(gapTemperatureBounds('K02', null)).toBeNull();
expect(gapTemperatureBounds('K02', 'not-a-date')).toBeNull();
});
});
describe('温度口径 —— 反单调性(这是旧口径最贵的那个 bug)', () => {
test('⭐⭐ 多一条**更旧**的同类需求,绝不能让人变冷', () => {
// 上周刚查出的龋齿(K02,5 天)+ 两年前的旧龋(K02,700 天)。
// 旧口径:max(daysSince)=700 → 冷,这个刚被医生说过的人在矩阵上永远看不见。
const fresh = gapTemperatureBounds('K02', daysAgo(5));
const stale = gapTemperatureBounds('K02', daysAgo(700));
expect(classifyTemperature(fresh, NOW)).toBe(Temperature.HOT);
expect(classifyTemperature(stale, NOW)).toBe(Temperature.COLD);
expect(classifyTemperature(hottestBounds([fresh, stale]), NOW)).toBe(Temperature.HOT);
expect(classifyTemperature(hottestBounds([stale, fresh]), NOW)).toBe(Temperature.HOT); // 与顺序无关
});
test('⭐ 单调性通则:往集合里加任何一条 gap,档位只可能变热或不变', () => {
const base = [gapTemperatureBounds('K02', daysAgo(700))];
const rank = { hot: 0, warm: 1, cold: 2 } as const;
const before = rank[classifyTemperature(hottestBounds(base), NOW)!];
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)!];
expect(after).toBeLessThanOrEqual(before);
}
});
test('⭐ 不变式 hotUntil < warmUntil 在跨码取 max 之后仍成立', () => {
// 两个 max 可以来自不同 gap,这里证明那不会把区间弄反(弄反 = 温档凭空消失)
const mixed = hottestBounds([
gapTemperatureBounds('K01', daysAgo(10)), // hot 到 +80d, warm 到 +170d
gapTemperatureBounds('K04', daysAgo(1)), // hot 到 +44d, warm 到 +59d
])!;
expect(mixed.hotUntil < mixed.warmUntil).toBe(true);
});
});
describe('温度口径 —— 边界时刻是事实,不是时钟', () => {
test('⭐⭐ 换个"现在"重算,边界一字不变(否则每天都白升一个画像版本)', () => {
const anchor = new Date('2026-01-15T03:00:00.000Z');
const a = gapTemperatureBounds('K08', anchor);
const b = gapTemperatureBounds('K08', anchor); // 同样的事实,隔了半年再算
expect(a).toEqual(b);
});
test('⭐⭐ hotUntil / warmUntil ⛔ 不许进 VOLATILE_DATA_KEYS', () => {
// 进了 = stripVolatile 会把它们从语义指纹里剔掉 → 新来一条诊断把温度顶热了也不升版本,
// 等于把这次要修的"冻结"原样搬到另一层,而且更隐蔽。
expect(VOLATILE_DATA_KEYS.has('hotUntil')).toBe(false);
expect(VOLATILE_DATA_KEYS.has('warmUntil')).toBe(false);
// 对照:daysSince 是真·易变键,必须在里面(它是时钟)
expect(VOLATILE_DATA_KEYS.has('daysSince')).toBe(true);
});
test('⭐ 时间推移会自然翻档,不需要任何重算', () => {
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-20T00:00:00.000Z'))).toBe(Temperature.WARM); // +49d
expect(classifyTemperature(b, new Date('2026-11-01T00:00:00.000Z'))).toBe(Temperature.COLD); // +91d
});
test('⭐ 边界存的是 UTC ISO —— 字典序必须等于时间序(SQL 侧靠这个直接比较)', () => {
const early = gapTemperatureBounds('K02', new Date('2026-01-01T00:00:00.000Z'))!;
const late = gapTemperatureBounds('K02', new Date('2026-06-01T00:00:00.000Z'))!;
expect(early.hotUntil < late.hotUntil).toBe(true);
expect(early.hotUntil).toMatch(/Z$/); // 带 +08:00 偏移的写法会破坏字典序
});
});
describe('温度口径 —— 「不知道」不等于「冷」', () => {
test('⭐⭐ 老画像没有边界 → null,⛔ 不许静默判成 cold', () => {
// 判成 cold 会让本次改动之前算出来的画像全部塞进冷格子,
// 主管看到的是一个**假的**分布,而且看不出哪里假 —— 违 T14。
expect(classifyTemperature(null, NOW)).toBeNull();
expect(classifyTemperature(undefined, NOW)).toBeNull();
expect(classifyTemperature({}, NOW)).toBeNull();
expect(classifyTemperature({ hotUntil: '2026-09-01T00:00:00.000Z' }, NOW)).toBeNull(); // 只有一半也不算
});
test('一条 gap 都算不出边界时,聚合结果也是 null', () => {
expect(hottestBounds([])).toBeNull();
expect(hottestBounds([null, undefined])).toBeNull();
});
});
......@@ -618,6 +618,13 @@ POST /pac/v1/plans/assignments/:id/revoke @RequirePermission(PLAN_DISPATCH)
#### P6.1 温度轴 —— **四份方案全部踢皮球的孤儿依赖**
> ✅ **口径部分已于 2026-08-02 落地**(教条 T6′ + `packages/types/src/temperature.ts`):
> Q-6 解掉、锚点 `anchorAt` 进 `PotentialGap`、`hotUntil`/`warmUntil` 进
> `persona_features.data.detail[]`、本地 5,825 患者全量重算通过。
> **剩下的是 P6.2 的矩阵端点与索引**(下方「技术链条」的后两环)。
> ⚠️ 一次性版本波:新增两个语义键 → 首次重算全量升版本(本地实测 success=3,020)。
> ⚠️ 顺带抓出并修掉了「按 `plan.persona_id` 关联画像」的静默归零缺陷,见教条 §4.38。
web 说「后端加 query 参数」、mcp 说「属初选矩阵工作流,不是 MCP 层的活」、data/service 只字未提。**没有 owner。**
而且现有数据**算不出来**(已实测复核):
......@@ -759,7 +766,7 @@ T4 已把落点写死:`shared/fact-block.ts`(标准 + 深度)与 `tiers/st
| **Q-3** 🟠 | **`ReleaseReason` 值域定稿** | D-6 建议取 data 层 8 个(按「主管的一根杠杆」分类,直接服务 T20 反推);service 层给了另一套 6 个 | 8 个见 D-6。⭐ **可以先落结构 + `other` 一个键开工**`hidden?` 字段已预留),值域后补 —— 不要让它卡 P0 | P0.2(可解耦) |
| **Q-4** 🟠 | **MVS 期福利的落地方式降级** | T4 明写福利要进 prompt(`shared/fact-block.ts` + `tiers/stable/prompt.ts`),但撞上 `plan_scripts.planId @unique` 的缓存问题(R8),最坏会**对患者做虚假承诺** | 建议 MVS 期降级为「独立静态字段,客服自己念,不经 LLM」—— 零合规风险、零重生成成本,T4 的归因目的完全达成;P6.6 再做 prompt 融入 + 失效规则 | P5 |
| **Q-5** 🟠 | **批次规模统计的漂移是否可接受** | 一个 plan 在批次 N 被分 → 退回 → 进批次 N+1 → `assignment_id` 翻成 N+1,批次 N 的 COUNT 悄悄少 1 | ① 接受(简单,符合「主表存当前值」的既有模式)② 不接受 → 需要一张 `plan_assignment_members` 关联表(+0.5 人日 + 一列迁移)。⛔ 「写进 `plan_event_logs.details`」的补丁方案已被否(R12) | P1.1 建表 |
| **Q-6** 🟠 | **温度的 8 标签 × 窗口映射** | 教条 T6 说「阈值复用 `DiagnosisTreatmentMap` 现成配置,不新增口径」,但实测**一对多**`extraction ← K01(180/90) + K03(90/60)``perio ← K05(120/90) + K06(120/60)`,且聚合时已 `Math.max` 跨码合并 | 建议按业务标签定义**标签级**窗口表(8 行常量),并明写「这是新口径,不是 `DiagnosisTreatmentMap` 的复用」—— 否则 `canonical-codes.ts:196` 那句「不允许任一处再硬编码窗口」会被违反 | P6.1(第二刀起跑线) |
| ~~**Q-6**~~ ✅ | ~~**温度的 8 标签 × 窗口映射**~~ | **已裁决(2026-08-02),见教条 T6′** | ⭐ 原建议(标签级窗口表 8 行常量)**已否**。真正的解法是**换一层聚合**:逐条 gap 用自己 K 码的窗口判档、再取最热 —— 一对多自动消解,不需要任何新常量,`DiagnosisTreatmentMap` 是真的被复用了。原判「无解」源于默认了聚合必须发生在天数层 | — |
| **Q-7** 🟡 | **分配单到期后的行为**(教条七·待确认) | ① 自动回池 ② 只提醒 ③ 两者皆有。⚠️ ①/③ 会擦 T8 的判据(「任何改变数据库状态的动作只能由主管确认触发」)。可辩护的解释是「主管在确认单上确认了时效 = 预授权」,但**这属于产品解释权,不该由实现认定** | 建议 **MVS 只做 ②**(写入 + 超期标红),①/③ 推到 v2。若选 ①/③,需在确认单文案上把到期行为显式写出来,把预授权变成看得见的;且**必须落 `release_reason='expired'`**(否则 T20 的分母不干净)、**必须照抄 `recycle-scheduler.service.ts:64-68` 的 `snoozedUntil` 守卫**(约好 6/10 回访的单不能被收走) | 不卡 MVS |
| **Q-8** 🟡 | **撤销时限** | 建议可配 env、默认 **30 分钟**,并按 T14 在助手话术里标明是默认值 | 语义是「手滑/分错人」的补救,不是「改主意重新调度」(改主意应走退回 + 重分)。⚠️ 它是 UX 摩擦不是安全边界(R18) | P6.4 |
| **Q-9** 🟡 | **已被认领的单能否强制改派**(教条七·待确认) | 当前后端拦住(`plan.service.ts:589-591`),教条建议保留该摩擦 | 建议保持拦住 → 进 `skipped(reason:'claimed_by_other')`。⭐ 该判据已收口到 `claim-guard.assertAssignable` **一个函数**,将来改只改这一处 | 不卡 |
......
......@@ -97,6 +97,63 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
每个治疗项目**用自己的周期尺度**,归一化后**横向可比**
阈值复用 `DiagnosisTreatmentMap` 现成配置,不新增口径。
#### T6′ · 落地口径(2026-08-02 定稿,实现见 `packages/types/src/temperature.ts`)
上面三行是**语义**,落地时有两处必须写死,否则各人实现各人的:
**① 在「温度」上聚合,不在「天数」上聚合。**
一个患者的同一业务标签常有多条 gap(实测本地库 41% 的 K02 患者有多条诊断,
跨度均值 452 天,而 K02 窗口只有 90 天)。
- ❌ 先 `Math.max(daysSince)` 再判档 —— **反单调**:上周刚查出的龋齿,
因为身上还有一颗两年前的旧龋,整个人被判成「冷」。**多一条未满足需求反而更冷**
在召回池里说不通。
-**逐条 gap 用自己 K 码的窗口判档,再取最热**
> ⭐ 这一条顺带把开发规划 **Q-6「8 标签 ← K 码一对多,无解」解掉了**:
> `extraction ← K01(180/90) + K03(90/60)` 根本不需要为标签另定一套窗口 ——
> K01 那条按 K01 判、K03 那条按 K03 判,各归各的。
> **原判「无解」是因为默认了聚合必须发生在天数层**;换一层聚合,T6 那句
> 「复用 `DiagnosisTreatmentMap`,不新增口径」就真的做到了,
> `canonical-codes.ts` 那句「不允许任一处再硬编码窗口/临界」也不用破例。
**② 存边界时刻,不存天数、更不存档位。**
`persona_features.data.detail[]``hotUntil` / `warmUntil` 两个**时刻**
(= 锚点日期 + 该码的 `urgencyDayThreshold` / `windowDays`),档位在**读时**`now` 比。
- ❌ 存 `daysSince`:它在 `VOLATILE_DATA_KEYS` 里被 `stripVolatile` 剔掉 →
**时间流逝永远不触发重算** → 温度档永久冻结在画像算出来那一刻。
- ❌ 存档位:一样会过期,还得配一整套「到期重算」机械。
- ✅ 存边界时刻:由「事实日期 + 静态配置」推出,**不是易变键**
(⛔ 别加进 `VOLATILE_DATA_KEYS`),语义变了才升版本;读时一比就出档,零维护。
> 同一套路本仓已用过两次:`visitRecencyRange`(存日期读时分档)、
> `applyLiveDays`(存 `signalOccurredAt` 读时算天数)。这是第三次,别再交一次学费。
**③ 「不知道」不等于「冷」。** 本次改动之前算出来的画像没有边界字段 →
`classifyTemperature` 返回 `null`,调用方必须显式呈现「温度未知」或排除出矩阵。
静默判成冷会让老数据塞满冷格子,主管看到一个**假的**分布还看不出哪里假(T14)。
**④ 不在温度层重复 `cooldownDays`(治疗考虑期)闸。**
入池下界是召回引擎的职责,矩阵是在**池子上**切分,池子已经过闸。
两层各判一次 = 两套口径,正是 T6a「同源保证」要防的事。
#### 实测量级(2026-08-02 本地 5,825 患者,**别拿错人群量收益**)
在召回池上(矩阵真正展示的人群,3,880 个「患者 × 标签」格位):
```
翻档 51 / 3,880 = 1.31%
变热 43
变冷 8 ← 6 条是"恰好压线"(按毫秒比 vs 按整天截断)
2 条是 K06(60 天)被标签级近似当成 K05(90 天)误判成热,新口径改对了
```
⚠️ 拿**未过 gap 闸的原始诊断事实**去量会得到 40-70% 的夸张差值 —— **那是错的人群**
这条改动真正的价值在 ②(解冻,在生产会持续累积)和 Q-6 的收口,**不在今天这 1.31%**
### T7 · 客服退回必须说明原因
退回是**正常路径**不是异常。
......@@ -374,6 +431,31 @@ plan_event_logs view 1,024 ✅ 唯一可用 —— 客服打开过详情页
→ 设计要求:名册之外仍须允许主管**显式指定**,并提示「该客服无近期回访记录」。
### 4.38 🔴 圈人关联画像必须按**患者**取当前版,不能按 `plan.persona_id`
2026-08-02 做温度轴时抓到的**静默归零**缺陷(当时已写进 `assignment-proposal.service`):
```sql
-- ❌ 看着天经地义,实际是个定时炸弹
WHERE pe.id = fp.persona_id AND pe.superseded_at IS NULL
```
`followup_plans.persona_id` 指向**建 plan 那一刻**的画像版本;画像一升版本那行就被 supersede,
而 plan 的外键**不跟着走** → 条件恒为假 → 整个「潜在治疗」筛选变成 0 条,
**不报错、不告警**,主管只看到「这个格子没人了」。
**实测**(跑完一次全量 persona 重算之后):
```
池子里还指向"活着的画像版本"的 plan 97 / 2,724
同一个「潜在种植」条件 · 按 persona_id 0 条
同一个「潜在种植」条件 · 按 patient_id 376 条 ← 列表页 buildListWhere 一直是这个口径
```
**一律按 `pe.patient_id = fp.patient_id AND pe.superseded_at IS NULL`**,与列表页同口径。
两处不一致正是 T6a「同源保证」明令要防的「矩阵上有人、点进去列表没有」。
回归护栏见 `tests/assignment-cohort-join.spec.ts`(单测拦不住 SQL 语义,退而锁源码形态)。
### 4.4 现有 MCP 工具的缺口
现有 7 个工具(`find_patient` / `get_patient_overview` / `get_persona` / `get_facts` /
......
......@@ -6,6 +6,7 @@ export * from './canonical-codes';
export * from './persona-feature-specs';
export * from './clinical-signals';
export * from './visit-recency';
export * from './temperature';
export * from './persona-tag-filters';
export * from './host-action-message';
export * from './kin-relationship';
import { lookupDxTreatment } from './canonical-codes';
/**
* 窗口温度 —— 初选矩阵的 Y 轴(X 轴见 persona-tag-filters 的 potential_treatment 8 类)。
*
* ═══ 口径(教条 T6,2026-08-02 定稿)═════════════════════════════════
* 温度 = **该治疗项目自身的临床时间周期**走到哪一段了,不是客户价值、不是意愿、不是末诊天数。
*
* ```
* [0, urgencyDayThreshold] 🔥 热 黄金期内
* (urgencyDayThreshold, windowDays] 🌡 温 周期内
* (windowDays, ∞) ❄️ 冷 超周期
* ```
* 每个治疗项目**用自己的周期尺度**(`DiagnosisTreatmentMap`),归一化后横向可比 ——
* 「潜在根管·热」(45 天内)与「潜在正畸·热」(180 天内)天数差 4 倍,但业务含义同级。
*
* ═══ 两条关键设计,都是被数据打出来的 ═══════════════════════════════
*
* ① **在「温度」上聚合,不在「天数」上聚合** —— 一个患者的同一业务标签常有多条 gap
* (实测本地库:41% 的 K02 患者有多条诊断,时间跨度均值 452 天,而 K02 窗口只有 90 天)。
*
* ⛔ 旧做法是先 `Math.max(daysSince)` 再判档(potential-treatment.feature 的聚合口径)。
* 那是**反单调**的:上周刚查出的龋齿,因为身上还有一颗两年前的旧龋,整个人被判成「冷」。
* **多一条未满足需求,反而更冷** —— 这在召回池里是说不通的。
*
* ✅ 正解:**逐条 gap 用自己 K 码的窗口判档,再取最热**。
*
* ⚠️ **实测量级要说准**(2026-08-02 本地 5,825 患者):在**召回池**上(矩阵真正展示的人群,
* 3,880 个「患者×标签」格位)只有 **1.31% 翻档,43 条变热 / 8 条变冷** —— 不是什么大数目。
* 那 8 条里 6 条是"恰好压线"(ds 正好等于阈值,按毫秒比 vs 按整天截断的差),
* 另 2 条是 K06(60 天黄金期)被标签级近似当成 K05(90 天)误判成热,**新口径改对了**。
* 量级小是因为池子已经过了 gap 闸;拿**未过闸的原始诊断事实**去量会得到 40-70% 的
* 夸张差值 —— 那是错的人群,别拿它当收益。
* 这条改动真正的价值在 ② 的解冻和 Q-6 的收口,不在今天这 1.31%。
* 顺带把开发规划 Q-6 说的「一对多无解」也解掉了 —— `extraction ← K01(180/90) + K03(90/60)`
* 根本不需要为标签另定一套窗口:K01 那条按 K01 判、K03 那条按 K03 判,各归各的。
* 于是 T6「阈值复用 DiagnosisTreatmentMap,不新增口径」真的做到了,
* `canonical-codes.ts` 那句「不允许任一处再硬编码窗口/临界」也没被违反。
*
* ② **存边界时刻,不存天数/不存档位** —— 与 `visitRecencyRange`(存日期读时分档)、
* `applyLiveDays`(存锚点读时算天数)同一套路,本仓已两次为同一个坑付过学费。
*
* ⛔ 存 `daysSince`:它在 `VOLATILE_DATA_KEYS` 里被 `stripVolatile` 剔掉 →
* **时间流逝永远不触发重算** → 温度档永久冻结在画像算出来那一刻。
* 本地库刚重建所以看着正常,生产跑几个月会整体往「冷」漂,且无人察觉。
* ⛔ 存档位('hot'/'warm'/'cold'):同样会过期,还得配一整套「到期重算」机械
* (persona 的 nextBoundaryAt 能做,但那是每人每次翻档都要写一遍行的代价)。
* ✅ 存 `hotUntil` / `warmUntil` 两个**时刻**:它们由「事实日期 + 静态窗口配置」推出,
* 同一批事实换个时刻重算值不变 → **不是易变键**(⛔ 别加进 VOLATILE_DATA_KEYS),
* 语义变了才升版本;而档位在**读时**跟 now 一比就出来,永远准,零维护。
*
* ═══ 刻意不做的事 ═════════════════════════════════════════════════
* ⛔ **不在温度层重复 `cooldownDays`(治疗考虑期)闸**。
* 入池下界是召回引擎的职责(`treatment-initiation-recall.scenario`),矩阵是在**池子上**切分,
* 池子已经过闸。两层各判一次 = 两套口径,正是 T6a「同源保证」要防的
* 「矩阵上有人、点进去列表没有」。要调整"刚诊断的先别打",去改池子那层的单一真理源。
*/
export const Temperature = {
HOT: 'hot',
WARM: 'warm',
COLD: 'cold',
} as const;
export type TemperatureValue = (typeof Temperature)[keyof typeof Temperature];
/// 由热到冷 —— 矩阵列序、以及「取最热」的比较序都用它,别在别处另写一份
export const TEMPERATURE_ORDER: readonly TemperatureValue[] = [
Temperature.HOT,
Temperature.WARM,
Temperature.COLD,
];
export const TEMPERATURE_META: Record<
TemperatureValue,
{ zh: string; icon: string; hint: string }
> = {
[Temperature.HOT]: {
zh: '热',
icon: '🔥',
hint: '还在该治疗自己的黄金期内 —— 医生刚说过,患者还记得',
},
[Temperature.WARM]: {
zh: '温',
icon: '🌡',
hint: '过了黄金期但没出临床周期 —— 还来得及,话术要给个理由',
},
[Temperature.COLD]: {
zh: '冷',
icon: '❄️',
hint: '超出该治疗的临床周期 —— 情况可能已经变了,先问近况再谈方案',
},
};
/**
* 一条 gap 的温度边界。存 ISO 串(persona_features.data 是 JSON)。
*
* ⚠️ **必须是 `toISOString()` 的 UTC 形态** —— 全仓统一后 ISO 串的字典序 == 时间序,
* SQL 侧(jsonb 路径比较)与 JS 侧才能用同一个比较,不需要来回 parse。
* 带偏移量的写法(`+08:00`)会破坏这个性质。
*/
export interface TemperatureBounds {
/** 黄金期终点 = 锚点 + urgencyDayThreshold */
hotUntil: string;
/** 临床周期终点 = 锚点 + windowDays */
warmUntil: string;
}
const DAY_MS = 86_400_000;
/**
* 单条 gap 的温度边界 = 锚点日期 + **该诊断码自己**的窗口配置。
*
* @param primaryCode gap 的主码(K01/K02/…),与挖 gap 时用的 `lookupDxTreatment(primaryCode)` 同一条规则
* @param anchorAt 信号发生时刻(诊断 occurred_at / 推荐 planned_for)—— **不可变锚点**
* @returns 码不在表里 / 锚点非法 → null(调用方丢弃该条,别猜一个默认窗口出来)
*/
export function gapTemperatureBounds(
primaryCode: string,
anchorAt: Date | string | null | undefined,
): TemperatureBounds | null {
if (!anchorAt) return null;
const t = anchorAt instanceof Date ? anchorAt.getTime() : new Date(anchorAt).getTime();
if (Number.isNaN(t)) return null;
const rule = lookupDxTreatment(primaryCode);
if (!rule) return null;
return {
hotUntil: new Date(t + rule.urgencyDayThreshold * DAY_MS).toISOString(),
warmUntil: new Date(t + rule.windowDays * DAY_MS).toISOString(),
};
}
/**
* 多条 gap 合成一个业务标签的边界 —— **取最热**(两个上界各自取 max)。
*
* 两个 max 可以来自不同的 gap,这是对的,不是 bug:
* `now ≤ max(hotUntil)` ⟺ ∃ 某条 gap 还在自己的黄金期 → 该标签就是热
* `now ≤ max(warmUntil)` ⟺ ∃ 某条 gap 还在自己的周期内 → 至少是温
* 恰好等于「逐条判档后取最热」,而且不用先把档位算出来。
*
* 顺带保证了 `hotUntil < warmUntil` 恒成立:设 max(hotUntil) 来自第 j 条,
* 则 max(warmUntil) ≥ warmUntil_j > hotUntil_j = max(hotUntil)(每条都有 urgency < window)。
*/
export function hottestBounds(
list: ReadonlyArray<TemperatureBounds | null | undefined>,
): TemperatureBounds | null {
let hot: string | null = null;
let warm: string | null = null;
for (const b of list) {
if (!b) continue;
if (hot === null || b.hotUntil > hot) hot = b.hotUntil;
if (warm === null || b.warmUntil > warm) warm = b.warmUntil;
}
return hot !== null && warm !== null ? { hotUntil: hot, warmUntil: warm } : null;
}
/**
* 读时定档 —— 这是温度**唯一**的判定入口,前后端/SQL 想自己写一遍的时候请回来看一眼。
*
* @returns 边界缺失(本次改动之前算出来的老画像)→ **null,不是 'cold'**。
* 把"不知道"当成"冷"会让老数据静默塞满冷格子,主管看到的是一个假的分布 —— 违 T14。
* 调用方应把 null 显式呈现为「温度未知」或排除出矩阵,并说明原因。
*/
export function classifyTemperature(
bounds: { hotUntil?: string | null; warmUntil?: string | null } | null | undefined,
now: Date = new Date(),
): TemperatureValue | null {
if (!bounds?.hotUntil || !bounds.warmUntil) return null;
const iso = now.toISOString();
if (iso <= bounds.hotUntil) return Temperature.HOT;
if (iso <= bounds.warmUntil) return Temperature.WARM;
return Temperature.COLD;
}
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