Commit 05cdd46d by luoqi

fix(recall): 终态抑制加时间锚 — 结案后新发同类信号放行复活,'永久'不再误杀

问题:信号级抑制集只按 (scenario|subKey) 一刀切,外院/无效的永久 snooze(36500d)会把
结案后新发的同类诊断也永久压死 —— 同牙位复发(外院种植失败回院再诊断)和 @whole 病种
(外院牙周,多年后新发)整类终身沉默。且 cluster lead 锚最早诊断,老 fact 永远 active
(外院治疗不进本院数据)→ 新旧必聚同 cluster,单看 lead 时间无法区分新发。

修法:
- scenario:cluster 注入 cluster_latest_occurred_at(成员 max),hit 透传
  latestSignalOccurredAt(lead/daysSince 仍锚最早,紧迫度口径不变)
- engine:抑制集 Set<key> → Map<key, 结案锚点>;锚=结案 execution.createdAt(不可变,
  不用 updatedAt——会被召回反馈等后续写顶后);同 key 多次结案取最新锚
- 过滤:key 命中且 latest > anchor → 放行复活;latest 缺省(旧 scenario)/锚不可解析
  (远未来哨兵)→ 维持旧行为全压,宁可多压不误放

测试:tests/plan-engine-snooze-anchor.spec.ts(锚点构建 5 例 + 逃逸判定端到端);
存量 plan-engine-batch 通过(mock 无 executions/updatedAt 走哨兵路径)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent f7cdf226
Pipeline #3397 failed in 0 seconds
......@@ -195,7 +195,7 @@ export class PlanEngineService {
patientIds,
now,
);
const EMPTY_SNOOZE = new Set<string>();
const EMPTY_SNOOZE = new Map<string, Date>();
const logRows: Prisma.PlanGenerationLogCreateManyInput[] = [];
const concurrency = Math.max(1, Number(process.env.PAC_PLAN_BATCH_CONCURRENCY) || 8);
const entries = [...hitsByPatient.entries()];
......@@ -209,7 +209,7 @@ export class PlanEngineService {
hits,
prefetched: {
latest: latestByPatient.get(patientId) ?? null,
snoozedKeys: snoozedByPatient.get(patientId) ?? EMPTY_SNOOZE,
snoozedAnchors: snoozedByPatient.get(patientId) ?? EMPTY_SNOOZE,
personaId: personaByPatient.get(patientId) ?? null,
},
});
......@@ -301,7 +301,7 @@ export class PlanEngineService {
hits: ScenarioHitWithKey[];
prefetched?: {
latest: PlanWithReasons | null;
snoozedKeys: Set<string>;
snoozedAnchors: Map<string, Date>;
personaId: string | null;
};
}): Promise<'created' | 'superseded' | 'unchanged' | 'suppressed'> {
......@@ -327,15 +327,23 @@ export class PlanEngineService {
// 并集作抑制集,逐 hit 过滤掉命中抑制集的信号;剩下的(全新诊断)继续走下面生成逻辑。
// 修复的 bug:旧版"整患者抑制" → 成功转化 K08 后,snooze 期内新长的 K02 也被压住不召(误伤新缺口)。
// 多次结案也正确:查的是全部未到期终态 plan 的并集(不止 latest 一条)。
const snoozedKeys = prefetched
? prefetched.snoozedKeys
const snoozedAnchors = prefetched
? prefetched.snoozedAnchors
: await this.fetchSnoozedSignalKeys(scope, patientId, scope.now);
const usableHits =
snoozedKeys.size === 0
snoozedAnchors.size === 0
? hits
: hits.filter((h) => !snoozedKeys.has(`${h.scenarioKey}|${h.subKey ?? ''}`));
: hits.filter((h) => {
const anchor = snoozedAnchors.get(`${h.scenarioKey}|${h.subKey ?? ''}`);
if (!anchor) return true; // 不在抑制集 → 放行
// ⭐ 时间锚逃逸:cluster 内存在"结案之后新发"的同类信号 → 视为新机会,放行复活。
// ("永久"抑制只对结案时已知的问题永久 —— 外院种植失败回院再诊断/多年后新发同类
// @whole 病种,不该被老 snooze 压死。)latest 缺省(旧 scenario 未提供)→ 维持全压。
const latest = h.latestSignalOccurredAt;
return latest != null && latest > anchor;
});
if (usableHits.length === 0) {
// 当前所有活信号都在冷静期内(= 刚结案那批)→ 不生成新 plan
// 当前所有活信号都在冷静期内(= 刚结案那批,且无结案后新发)→ 不生成新 plan
return 'suppressed';
}
......@@ -448,19 +456,22 @@ export class PlanEngineService {
}
/**
* 信号级抑制集 —— 该患者所有"终态(completed/abandoned)+ 冷静期未到期(snoozedUntil>now)"plan
* 覆盖的召回理由 (scenario, subKey) 并集
* 信号级抑制集(带时间锚)—— 该患者所有"终态(completed/abandoned)+ 冷静期未到期
* (snoozedUntil>now)"plan 覆盖的召回理由 (scenario, subKey) → **结案锚点** Map
*
* 语义:这些信号最近被召回处理过(成功转化/拒绝/外院/熔断…),在冷静期内不重复召回;
* 但**不在此集合里的全新诊断不受影响**,照常生成召回(这是信号级抑制的核心,跟召回算法同粒度)。
* 多次结案累积正确:取全部未到期终态 plan 的并集,而非只看最新一条。
* 返回 key 格式 `${scenario}|${subKey ?? ''}`,跟 upsertPlan 过滤口径一致。
* ⭐ 时间锚(2026-07 修):同 key 也不一刀切 —— 结案锚点之后**新发**的同类信号(hit 的
* latestSignalOccurredAt > anchor)放行复活。修复的坑:外院/无效的"永久"snooze 会把
* 结案后多年新发的同牙位/@whole 同类诊断也永久压死(粒度只到 key,无时间维度)。
* 多次结案累积正确:取全部未到期终态 plan 的并集;同 key 多次结案取**最新**锚点。
* key 格式 `${scenario}|${subKey ?? ''}`,跟 upsertPlan 过滤口径一致。
*/
private async fetchSnoozedSignalKeys(
scope: ScenarioScope,
patientId: string,
now: Date,
): Promise<Set<string>> {
): Promise<Map<string, Date>> {
const terminalPlans = await this.prisma.followupPlan.findMany({
where: {
hostId: scope.hostId,
......@@ -469,15 +480,20 @@ export class PlanEngineService {
status: { in: ['completed', 'abandoned'] },
snoozedUntil: { gt: now },
},
select: { reasons: { select: { scenario: true, subKey: true } } },
select: {
updatedAt: true,
reasons: { select: { scenario: true, subKey: true } },
// 结案锚点 = 结案那次 execution 的 createdAt(事件表,不可变)。
// ⚠️ 不能用 plan.updatedAt 当首选锚:召回反馈(👍/👎)等后续写操作会顶后 updatedAt,
// 把"结案后、反馈前"新发的信号误压。updatedAt 仅作无 execution 时的兜底(理论不发生)。
executions: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { createdAt: true },
},
},
});
const keys = new Set<string>();
for (const plan of terminalPlans) {
for (const r of plan.reasons) {
keys.add(`${r.scenario}|${r.subKey ?? ''}`);
}
}
return keys;
return buildSnoozeAnchors(terminalPlans);
}
/**
......@@ -492,11 +508,11 @@ export class PlanEngineService {
now: Date,
): Promise<{
latestByPatient: Map<string, PlanWithReasons>;
snoozedByPatient: Map<string, Set<string>>;
snoozedByPatient: Map<string, Map<string, Date>>;
personaByPatient: Map<string, string>;
}> {
const latestByPatient = new Map<string, PlanWithReasons>();
const snoozedByPatient = new Map<string, Set<string>>();
const snoozedByPatient = new Map<string, Map<string, Date>>();
const personaByPatient = new Map<string, string>();
const CHUNK = 2000;
for (let i = 0; i < patientIds.length; i += CHUNK) {
......@@ -510,7 +526,7 @@ export class PlanEngineService {
for (const p of plans) {
if (p.patientId && !latestByPatient.has(p.patientId)) latestByPatient.set(p.patientId, p);
}
// snooze 抑制集(对齐 fetchSnoozedSignalKeys:终态 + 冷静期未到期 plan 的 reason 并集)
// snooze 抑制集(对齐 fetchSnoozedSignalKeys:终态 + 冷静期未到期 plan 的 reason → 结案锚点)
const terminal = await this.prisma.followupPlan.findMany({
where: {
hostId: scope.hostId,
......@@ -519,13 +535,25 @@ export class PlanEngineService {
status: { in: ['completed', 'abandoned'] },
snoozedUntil: { gt: now },
},
select: { patientId: true, reasons: { select: { scenario: true, subKey: true } } },
select: {
patientId: true,
updatedAt: true,
reasons: { select: { scenario: true, subKey: true } },
// 结案锚点 = 结案 execution.createdAt(不可变;updatedAt 会被召回反馈等后续写顶后,仅兜底)
executions: { orderBy: { createdAt: 'desc' }, take: 1, select: { createdAt: true } },
},
});
{
const plansByPatient = new Map<string, typeof terminal>();
for (const t of terminal) {
if (!t.patientId) continue;
const set = snoozedByPatient.get(t.patientId) ?? new Set<string>();
for (const r of t.reasons) set.add(`${r.scenario}|${r.subKey ?? ''}`);
snoozedByPatient.set(t.patientId, set);
const arr = plansByPatient.get(t.patientId) ?? [];
arr.push(t);
plansByPatient.set(t.patientId, arr);
}
for (const [pid, plans] of plansByPatient) {
snoozedByPatient.set(pid, buildSnoozeAnchors(plans));
}
}
// active persona id(每患最新 active 版本)
const personas = await this.prisma.persona.findMany({
......@@ -565,3 +593,30 @@ export interface EngineRunResult {
plansClosed: number;
durationMs: number;
}
/**
* 终态 plan → (scenario|subKey) → 结案锚点 Map(纯函数,fetchSnoozedSignalKeys / prefetchForBatch 共用)。
* 锚点 = 结案 execution.createdAt(事件表,不可变);无 execution(理论不发生)兜底 plan.updatedAt。
* 同 key 多次结案取**最新**锚 —— 最近一次人为处置才是"此后新发才算新机会"的分界线。
*/
export function buildSnoozeAnchors(
terminalPlans: Array<{
updatedAt?: Date;
reasons: Array<{ scenario: string; subKey: string | null }>;
executions?: Array<{ createdAt: Date }>;
}>,
): Map<string, Date> {
// 锚点解析不到(旧数据/未 select 关联)→ 远未来哨兵:latest > anchor 永不成立 = 永不逃逸,
// 严格等价旧的"key 命中即压"行为(宁可多压,不误放)。
const FAR_FUTURE = new Date(8640000000000000);
const anchors = new Map<string, Date>();
for (const plan of terminalPlans) {
const anchor = plan.executions?.[0]?.createdAt ?? plan.updatedAt ?? FAR_FUTURE;
for (const r of plan.reasons) {
const key = `${r.scenario}|${r.subKey ?? ''}`;
const prev = anchors.get(key);
if (!prev || anchor > prev) anchors.set(key, anchor);
}
}
return anchors;
}
......@@ -52,6 +52,11 @@ export interface ScenarioHit {
/// 子场景标识(reason 文本内已含,落 plan_reasons.breakdown.subKey 便于 metrics 检索)
subKey?: string;
/// cluster 内**最新**信号发生时间(时间锚抑制逃逸用,与 signals.signalOccurredAt 的"最早"锚相反):
/// 终态抑制(外院/拒绝的"永久" snooze)只压"结案时已知的问题" —— 结案后新发的同类诊断
/// (同 subKey 新 fact)应放行复活。引擎按 latest > 结案锚点 判断逃逸;缺省(旧 scenario)= 不逃逸。
latestSignalOccurredAt?: Date | null;
/// **W3 末新增**:结构化召回信号(plan_reasons.signals JSON 落库)。
/// DB 存原始 enum / canonical code(不语义化),前端用 @pac/types 字典翻译富文本渲染。
/// 详见 packages/types/src/schemas/reason-signals.ts
......
......@@ -481,6 +481,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 同 sub_scenario 多牙位各 1 行(36/46 都需充填都进库)。
// 全口诊断(K05 等无牙位)→ '@whole';前端 / plan-aggregate 用 signals.toothPosition 区分语义。
subKey: `${subKey}@${(r.tooth ?? '').trim() || 'whole'}`,
// 时间锚抑制逃逸:cluster 内最新信号时间(单 sig cluster = 自身时间)
latestSignalOccurredAt: r.cluster_latest_occurred_at ?? r.signal_occurred_at,
// 结构化召回信号(DB 存 raw enum / canonical code,前端字典翻译富文本)
// triggers 是 cluster 内全量(去重),前端按 type set 大小渲染 (诊断) / (医生建议) / (诊断+医生建议)
signals: {
......@@ -581,6 +583,8 @@ interface HitRow {
cluster_has_recommendation?: boolean; // cluster 含至少 1 个 recommendation_record
cluster_confidence?: number; // cluster 最优来源置信度(诊断1.0/建议0.8/影像0.5,取 max)
cluster_triggers?: Array<{ type: string; code: string }>; // cluster 内 unique (type, code),给 signals.triggers
/// cluster 内**最新**信号时间(lead 锚最早=daysSince 口径;此字段锚最新=终态抑制时间锚逃逸口径)
cluster_latest_occurred_at?: Date;
}
/// 单 sig 来源置信度(v3.1 三源标定):诊断 1.0 / 医生建议 0.8 / 影像AI 0.5。
......@@ -634,6 +638,7 @@ function mergeRowsByToothOverlap(rows: HitRow[]): HitRow[] {
lead.cluster_has_recommendation = wholeMouth.some((x) => x.signal_type === 'recommendation_record');
lead.cluster_confidence = Math.max(...wholeMouth.map(sourceConfidence));
lead.cluster_triggers = uniqueTriggers(wholeMouth);
lead.cluster_latest_occurred_at = latestOccurredAt(wholeMouth);
merged.push(lead);
}
......@@ -673,6 +678,7 @@ function mergeRowsByToothOverlap(rows: HitRow[]): HitRow[] {
lead.cluster_has_recommendation = c.rows.some((x) => x.signal_type === 'recommendation_record');
lead.cluster_confidence = Math.max(...c.rows.map(sourceConfidence));
lead.cluster_triggers = uniqueTriggers(c.rows);
lead.cluster_latest_occurred_at = latestOccurredAt(c.rows);
merged.push(lead);
}
}
......@@ -690,6 +696,17 @@ function triggerTypeOf(row: { signal_type: string; code_source: string | null })
: 'diagnosis';
}
/// cluster 内最新信号时间 —— 时间锚抑制逃逸用(lead 锚最早,此处锚最新,两口径并存各司其职)。
/// pg timestamptz 经 $queryRaw 已是 Date;防御性过滤无效值(理论上 ④ 闸已保证非空)。
function latestOccurredAt(rows: HitRow[]): Date | undefined {
let max: Date | undefined;
for (const r of rows) {
const d = r.signal_occurred_at;
if (d instanceof Date && !Number.isNaN(d.getTime()) && (!max || d > max)) max = d;
}
return max;
}
function uniqueTriggers(rows: HitRow[]): Array<{ type: string; code: string }> {
const seen = new Set<string>();
const out: Array<{ type: string; code: string }> = [];
......
import { buildSnoozeAnchors } from '../src/modules/plan/engine/plan-engine.service';
/**
* 时间锚抑制(2026-07 修)单测 —— 纯函数层。
* 背景:终态 plan 的"永久" snooze 原来按 (scenario|subKey) 一刀切,结案后**新发**的同类
* 诊断(外院种植失败回院 / 多年后新发 @whole 病种)也被永久压死。修复 = 抑制集带结案锚点,
* 引擎按 hit.latestSignalOccurredAt > anchor 放行。本文件测锚点构建;逃逸比较是一行大小关系,
* 语义由锚点正确性保证。
*/
describe('buildSnoozeAnchors', () => {
const d = (s: string) => new Date(s);
const plan = (
anchorExec: string | null,
updatedAt: string,
keys: Array<[string, string | null]>,
) => ({
updatedAt: d(updatedAt),
reasons: keys.map(([scenario, subKey]) => ({ scenario, subKey })),
executions: anchorExec ? [{ createdAt: d(anchorExec) }] : [],
});
it('锚点取结案 execution.createdAt(不取 plan.updatedAt)', () => {
// updatedAt 被召回反馈等后续写操作顶后(2026-07-10),真结案在 2026-07-01
const anchors = buildSnoozeAnchors([
plan('2026-07-01T10:00:00Z', '2026-07-10T00:00:00Z', [['recall', 'missing_tooth@36']]),
]);
expect(anchors.get('recall|missing_tooth@36')).toEqual(d('2026-07-01T10:00:00Z'));
});
it('无 execution 时兜底 plan.updatedAt', () => {
const anchors = buildSnoozeAnchors([
plan(null, '2026-07-10T00:00:00Z', [['recall', 'perio@whole']]),
]);
expect(anchors.get('recall|perio@whole')).toEqual(d('2026-07-10T00:00:00Z'));
});
it('同 key 多次结案取最新锚(最近一次处置才是新旧分界)', () => {
const anchors = buildSnoozeAnchors([
plan('2025-01-01T00:00:00Z', '2025-01-01T00:00:00Z', [['recall', 'missing_tooth@36']]),
plan('2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', [['recall', 'missing_tooth@36']]),
]);
expect(anchors.get('recall|missing_tooth@36')).toEqual(d('2026-06-01T00:00:00Z'));
});
it('subKey=null 归一为空串 key(与引擎过滤口径一致)', () => {
const anchors = buildSnoozeAnchors([
plan('2026-07-01T00:00:00Z', '2026-07-01T00:00:00Z', [['recall', null]]),
]);
expect(anchors.get('recall|')).toEqual(d('2026-07-01T00:00:00Z'));
});
it('多 plan 多 key 并集(多次结案累积)', () => {
const anchors = buildSnoozeAnchors([
plan('2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', [['recall', 'caries@45']]),
plan('2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', [['recall', 'perio@whole']]),
]);
expect(anchors.size).toBe(2);
expect(anchors.get('recall|caries@45')).toEqual(d('2026-05-01T00:00:00Z'));
expect(anchors.get('recall|perio@whole')).toEqual(d('2026-06-01T00:00:00Z'));
});
// 逃逸语义端到端(用与引擎 336 行同款比较式,防口径漂移)
it('逃逸判定:结案后新发信号(latest > anchor)放行;老信号照压', () => {
const anchors = buildSnoozeAnchors([
plan('2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', [['recall', 'missing_tooth@36']]),
]);
const escapes = (key: string, latest: Date | null) => {
const anchor = anchors.get(key);
if (!anchor) return true;
return latest != null && latest > anchor;
};
// 老 fact(结案前 2024)聚类 → latest=2024 → 仍压
expect(escapes('recall|missing_tooth@36', d('2024-03-01T00:00:00Z'))).toBe(false);
// 结案后新发(2027 复诊再诊断)→ latest=2027 → 放行复活
expect(escapes('recall|missing_tooth@36', d('2027-01-15T00:00:00Z'))).toBe(true);
// 旧 scenario 未提供 latest → 维持旧行为全压
expect(escapes('recall|missing_tooth@36', null)).toBe(false);
// 不在抑制集 → 放行
expect(escapes('recall|caries@45', d('2024-01-01T00:00:00Z'))).toBe(true);
});
});
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