Commit 1284675e by luoqi

fix(召回): 抑制改为「全有或全无」—— 处置是计划级的,抑制就不能逐条

陆雪 TS0K090243:
  08-23 客服点一次「已完成治疗」→ 该 plan 名下 4 条理由(缺牙种植 / 牙周 /
        影像AI缺牙 / 残根)全部被压,treated 的 suppressDays = 36500 天(永久)
  08-26 我们的残根→K03 改判把 subKey 从 missing_tooth@17 换成
        hard_tissue_damage@17,它不在抑制集里 → **只有它一条**复活
结果卡片上:病历三类应治未治、画像标签也写着「种植/牙周/拔牙三类待跟进」,
召回理由却只剩一条 —— 读的人只能以为算法漏了。而那次「已完成治疗」本身是错的
(该患者信号后只做过 OHI),等于把两条正确召回永久埋掉。

── 根因:两个粒度对不上 ──
处置是**计划级**的(schema PlanExecution.inaccurateTreatments 注释写明:客服勾
"只有种植不准"也不会只放过根管那条 —— 业务 2026-07-30 明确),抑制却是**逐 hit**
过滤的。于是产生"部分抑制"这种中间态:一部分理由回来了、另一部分还压着。
而且这一条复活还是**靠巧合** —— 走的是 `if (!anchor) return true`(键查不到就放行),
不是设计好的时间锚逃逸。生产实测:2026-08-01 以来 4 例结案后复活,时间锚逃逸 0 例,
改判逃逸 4 例(王丽芳/孙海燕/吴梅英/陆雪,全部是残根→K03),同一条诊断 subject 未变。

── 新规则 ──
缺口开没开由**事实**说了算,抑制只是人为覆盖,不改写事实:
  · 一条都逃不出去 → 整个 plan 不生成(一起抑制)
  · 有任意一条逃得出去 → 本轮**全部** hit 都进 plan(一起生效)
真治好了的缺口会被 gap 计算自己消掉,轮不到抑制来盖;抑制盖住的从来只是
①数据没摄到 或 ②人标错了 —— 这两种都不该永久。

改动本身只有一处:逃逸判定的结果从"过滤后的 hits"改成"要不要整体放行"的开关,
放行后用全量 hits。旧的时间锚逃逸口径原样保留。

测试:新增 2 例(部分逃逸→全部复活 / 全不逃逸→整体抑制),
既有 suppressed 用例不变仍绿。96 suites / 1638 tests,tsc 0 error。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 67fe9d19
......@@ -459,16 +459,36 @@ export class PlanEngineService {
// 带到新版)→ 客服不丢单、不被抢,只是召回理由刷新到最新;理由没变则只就地刷分(下面 unchanged 分支)。
// 缺口全消失(0 命中)→ 即使 assigned 也关闭(见 closeStaleActivePlan)。
// ⭐ 信号级抑制(召回闭环核心)— 抑制粒度 = 召回算法粒度 (scenario, subKey)
// 只压"已结案那条召回覆盖的诊断";患者新长的、不在已结案理由里的诊断照常召回(不受冷静期影响)。
// 做法:取该患者所有"终态(completed/abandoned)+ 冷静期未到期"plan 的 reason (scenario, subKey)
// 并集作抑制集,逐 hit 过滤掉命中抑制集的信号;剩下的(全新诊断)继续走下面生成逻辑。
// 修复的 bug:旧版"整患者抑制" → 成功转化 K08 后,snooze 期内新长的 K02 也被压住不召(误伤新缺口)。
// ⭐ 抑制(召回闭环核心)—— 判定粒度 = (scenario, subKey),生效粒度 = **整个 plan**
// 取该患者所有"终态(completed/abandoned)+ 冷静期未到期"plan 的 reason (scenario, subKey)
// 并集作抑制集,再看本轮 hit 里**有没有任何一条**逃得出去(不在集里 / 结案后新发):
// · 一条都逃不出去 → 整个 plan 不生成
// · 有任意一条逃得出去 → 本轮全部 hit 都进 plan
// 多次结案也正确:查的是全部未到期终态 plan 的并集(不止 latest 一条)。
// 历史:旧版是"整患者抑制"(成功转化 K08 后,snooze 期内新长的 K02 也被压住 → 误伤新缺口);
// 中间版改成逐 hit 过滤,治好了误伤,却引入了"部分抑制"(见下方 2026-08-28 注释)。
const snoozedAnchors = prefetched
? prefetched.snoozedAnchors
: await this.fetchSnoozedSignalKeys(scope, patientId, scope.now);
const usableHits =
// ⭐ 2026-08-28 改为**全有或全无**(业务口径:抑制粒度必须跟处置粒度一致)。
// ── 为什么 ──
// 处置是**计划级**的:客服点一次「已完成治疗」,压的是那个 plan 名下全部 reason
// (schema PlanExecution.inaccurateTreatments 注释:客服勾"只有种植不准"也不会只放过
// 根管那条 —— 业务 2026-07-30 明确)。抑制却是**逐 hit** 过滤的,两个粒度对不上,
// 于是出现「部分抑制」:一部分理由回来了、另一部分还压着。
// 陆雪 TS0K090243 就是这么坏的:08-23 客服标「已完成治疗」压掉 4 条(缺牙种植 /
// 牙周 / 影像AI缺牙 / 残根),三天后我们的残根→K03 改判把 subKey 从 missing_tooth@17
// 换成 hard_tissue_damage@17,它不在抑制集里 → 只有它一条复活。结果卡片上:
// 病历三类应治未治、画像标签也写着三类,召回理由却只剩一条 —— 读的人只能以为算法漏了。
// 而那次「已完成治疗」本身还是错的(该患者信号后只做过 OHI),等于把两条正确召回
// 永久埋掉(treated 的 suppressDays = 36500 天)。
// ── 新规则 ──
// 缺口开没开由**事实**说了算,抑制只是人为覆盖,不改写事实:
// · 一条都没逃逸 → 整个 plan 不生成(一起抑制)
// · 有任意一条逃逸 → 该患者本轮**全部** hit 都进 plan(一起生效)
// 如实反映:理由在,它牵涉的那些理由同样在。真治好了的缺口会被 gap 计算自己消掉,
// 根本轮不到抑制来盖 —— 抑制盖住的从来只是①数据没摄到 或②人标错了,这两种都不该永久。
const escapedHits =
snoozedAnchors.size === 0
? hits
: hits.filter((h) => {
......@@ -480,10 +500,13 @@ export class PlanEngineService {
const latest = h.latestSignalOccurredAt;
return latest != null && latest > anchor;
});
if (usableHits.length === 0) {
if (escapedHits.length === 0) {
// 当前所有活信号都在冷静期内(= 刚结案那批,且无结案后新发)→ 不生成新 plan
return 'suppressed';
}
// ⛔ 这里**故意**用 hits 而不是 escapedHits —— 见上方「全有或全无」。
// 改回 escapedHits 就退回部分抑制,而且不会报错,只会让卡片再次跟病历对不上。
const usableHits = hits;
const newPriorityScore = Math.max(...usableHits.map((h) => h.priorityScore));
......
......@@ -460,6 +460,75 @@ describe('runAllForHost 批量路径 — 5 种结局等价', () => {
expect(plans.filter((p) => p.patientId === 'pat-d' && p.status === 'active')).toHaveLength(0);
});
// ═══ 2026-08-28 抑制改为「全有或全无」(陆雪 TS0K090243)═══
// 处置是计划级的(客服点一次「已完成治疗」压掉该 plan 全部 reason),抑制却曾是逐 hit 的,
// 于是出现"部分抑制":一部分理由复活、另一部分还压着 → 卡片跟病历对不上。
test('⭐ 部分逃逸 → 被压的理由**一起复活**(不是只回来逃逸那条)', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-term-partial',
patientId: 'pat-partial',
version: 1,
status: 'abandoned',
snoozedUntil: new Date('2026-12-01T00:00:00Z'),
// 客服一次「已完成治疗」压掉这三条
reasons: [
{ scenario: SCEN, subKey: 'missing_tooth@17' },
{ scenario: SCEN, subKey: 'missing_tooth@36;37;46;47' },
{ scenario: SCEN, subKey: 'perio_no_srp@whole' },
],
},
],
});
const res = await engine(
prisma,
makeScenario([
// 同一条诊断被改判 → subKey 变了,不在抑制集里 → 逃逸
hit('pat-partial', 'hard_tissue_damage@17', 60),
// 这两条仍在抑制集里 —— 旧口径会被过滤掉,新口径必须跟着一起回来
hit('pat-partial', 'missing_tooth@36;37;46;47', 40),
hit('pat-partial', 'perio_no_srp@whole', 30),
]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
expect(res.plansSuppressed).toBe(0);
// 已有 v1(终态)→ 计数走"升版本"而非"新建",这是既有计数口径,不是本次改动引入的
expect(res.plansSuperseded).toBe(1);
const created = plans.find((p) => p.patientId === 'pat-partial' && p.status === 'active');
expect(created!.reasons.map((r) => r.subKey).sort()).toEqual(
['hard_tissue_damage@17', 'missing_tooth@36;37;46;47', 'perio_no_srp@whole'].sort(),
);
// 顶层分数取全部 hit 的最大值(不是只看逃逸那条)
expect(created!.priorityScore).toBe(60);
});
test('⭐ 一条都逃不出去 → 整个 plan 不生成(一起抑制,口径不变)', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-term-all',
patientId: 'pat-all',
version: 1,
status: 'abandoned',
snoozedUntil: new Date('2026-12-01T00:00:00Z'),
reasons: [
{ scenario: SCEN, subKey: 'missing_tooth@17' },
{ scenario: SCEN, subKey: 'perio_no_srp@whole' },
],
},
],
});
const res = await engine(
prisma,
makeScenario([hit('pat-all', 'missing_tooth@17'), hit('pat-all', 'perio_no_srp@whole')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
expect(res.plansSuppressed).toBe(1);
expect(res.plansCreated).toBe(0);
expect(plans.filter((p) => p.patientId === 'pat-all' && p.status === 'active')).toHaveLength(0);
});
test('stale-close:有 active 但本轮 0 命中 → plansClosed=1', async () => {
const { prisma, plans } = makeStore({
plans: [
......
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