Commit 99032119 by luoqi

fix(矩阵): 就地刷新 reason 时把标签标记重算 —— 补上一版漏掉的那条路

上一版(1293108a)只在**新建** reason 时补标签,漏了 `plan-engine` 的**就地刷新**路径
(plan-engine.service.ts:569):signals/evidence 语义变了但不升版本时,
它直接 `planReason.update({ evidence: { factIds } })` 改写证据。

🔴 那条路上 `potential_labels` 既不是 NULL(收尾的 backfillMissing 会跳过它)、
  又已经对不上新证据 ⇒ **初选矩阵按旧标签把人算进错的格子**,
  要到夜间全量重算才自愈,中间一整天不报错。

⇒ 同事务里把这些 reason 的 `potential_labels` 置回 NULL(=「没算过」),
  本轮收尾的 `backfillMissing()` 用同一份 SQL 填上。
   不在这里用 TS 顺手算一个:规则表是产品配置,第二份实现迟早和矩阵那份漂开。

️ 走 raw:Prisma 的**标量数组字段不能用类型化 API 置 null**(tsc 会拦);
  而改用 `[]` 会让「没算过」和「算过是空」分不开 —— 那正是这一列设成可空的理由。
️ 第一版 raw 写成 `IN (a,b)::uuid[]` —— **tsc 全绿但 SQL 是错的**
  (cast 套在了布尔结果上,PG 报 `cannot cast type boolean to uuid[]`)。
  这类错类型检查天生看不见,只能真连库跑一次。已改 `= ANY(ARRAY[...]::uuid[])` 并实测。

顺带:单患者重算路径(recomputeForPatient)也补上 backfillMissing ——
   不补的话这位患者在矩阵里**当场消失**,直到夜间重算才回来。
  平时代价接近零:`potential_labels IS NULL` 上有部分索引。

测试:
  · 替身补 `tx.$executeRaw`,并给 engine() 传标签刷新器替身
  · 新断言「证据被就地改写 ⇒ 必须同时标记重算」——
    ️ 做过变异校验(把期望改成 99 立刻变红),确认这条断言真的在跑,不是假绿

验证:tsc 通过;jest 86 套 1342 例全过;矩阵基准 24 格仍与基线逐字相同;
  eslint 无新增错误(plan.service 里 3 条 no-empty-object-type 是既有的)。
parent 1293108a
...@@ -115,6 +115,17 @@ export class PlanEngineService { ...@@ -115,6 +115,17 @@ export class PlanEngineService {
orderBy: { version: 'desc' }, orderBy: { version: 'desc' },
select: { id: true }, select: { id: true },
}); });
/**
* ⭐ 单患者重算也要补标签 —— 与批量那条同一个理由:
* 新写的 reason 是 NULL、就地刷新过的也被置回了 NULL,
* ⛔ 不补的话这位患者在初选矩阵里**当场消失**,直到夜间重算才回来,且不报错。
* ⚠️ 平时代价接近零:`potential_labels IS NULL` 上有部分索引,没得补时一次索引查就返回。
*/
try {
await this.planLabels.backfillMissing();
} catch (e) {
this.logger.warn(`[标签] 单患者补齐失败(夜间刷新会兜住):${e instanceof Error ? e.message : e}`);
}
// upsert 路径自身会 supersede 旧版本(版本流),不算"关闭退池";plansClosed 只统计 0 命中关闭。 // upsert 路径自身会 supersede 旧版本(版本流),不算"关闭退池";plansClosed 只统计 0 命中关闭。
return { plansCreated: created, plansClosed: 0, outcome: result, currentPlanId: current?.id ?? null }; return { plansCreated: created, plansClosed: 0, outcome: result, currentPlanId: current?.id ?? null };
} catch (err) { } catch (err) {
...@@ -587,6 +598,25 @@ export class PlanEngineService { ...@@ -587,6 +598,25 @@ export class PlanEngineService {
}, },
}); });
} }
/**
* 🔴 **证据换了,标签必须重算** —— 置回 NULL(=「没算过」),
* 本轮收尾的 `backfillMissing()` 会把它填上。
*
* ⚠️ ⛔ 不能不管:`potential_labels` 是从 `evidence.factIds` 指向的 fact 推出来的。
* 这条路**只改 evidence、不升版本**,那一列于是既不是 NULL(补齐会跳过它)、
* 又已经对不上新证据 ⇒ 矩阵会**按旧标签把人算进错的格子**,
* 要到夜间全量重算才自愈,中间一整天不报错。
* ⚠️ ⛔ 也别在这里用 TS 顺手算一个填进去:规则表是产品配置,
* 第二份实现迟早和矩阵那份漂开(见 `plan-label.sql.ts` 头注)。
* 置 NULL 让它回到同一份 SQL,是唯一不会漂的写法。
* ⚠️ 走 raw:Prisma 的标量数组字段**不能**用类型化 API 置 null,
* 而改用 `[]` 会让「没算过」和「算过是空」分不开(那正是这一列设成可空的理由)。
*/
if (staleRows.length > 0) {
await tx.$executeRaw`
UPDATE plan_reasons SET potential_labels = NULL
WHERE id = ANY(ARRAY[${Prisma.join(staleRows.map((r) => r.id))}]::uuid[])`;
}
}); });
if (staleRows.length > 0) { if (staleRows.length > 0) {
this.logger.log( this.logger.log(
......
...@@ -234,6 +234,7 @@ function makeStore( ...@@ -234,6 +234,7 @@ function makeStore(
}), }),
}; };
const labelsReset = jest.fn(async () => 0);
const prisma = { const prisma = {
followupPlan, followupPlan,
planReason, planReason,
...@@ -251,10 +252,16 @@ function makeStore( ...@@ -251,10 +252,16 @@ function makeStore(
}, },
planReason: { update: planReason.update }, planReason: { update: planReason.update },
planEventLog, planEventLog,
/**
* 就地刷新 reason 时会把 `potential_labels` 置回 NULL(=「重算我」)——
* 那一步走 raw(Prisma 的标量数组字段不能用类型化 API 置 null)。
* 替身把调用记下来,让测试能断言"证据变了就一定标记重算"。
*/
$executeRaw: labelsReset,
}), }),
), ),
}; };
return { prisma, plans, logs, planReason, events }; return { prisma, plans, logs, planReason, events, labelsReset };
} }
function makeScenario(hits: ScenarioHit[]) { function makeScenario(hits: ScenarioHit[]) {
...@@ -271,8 +278,10 @@ function hit(patientId: string, subKey: string, priorityScore = 50, targetClinic ...@@ -271,8 +278,10 @@ function hit(patientId: string, subKey: string, priorityScore = 50, targetClinic
evidence: { factIds: ['f1'] }, evidence: { factIds: ['f1'] },
} as ScenarioHit; } as ScenarioHit;
} }
/** 标签刷新器替身 —— 生成收尾会调它补 `potential_labels`;这里只要不炸即可。 */
const labelSvcStub = { backfillMissing: jest.fn(async () => 0), countMissing: jest.fn(async () => 0) };
function engine(prisma: unknown, scenario: unknown) { function engine(prisma: unknown, scenario: unknown) {
return new PlanEngineService(prisma as never, scenario as never); return new PlanEngineService(prisma as never, scenario as never, labelSvcStub as never);
} }
const NOW = new Date('2026-06-02T00:00:00Z'); const NOW = new Date('2026-06-02T00:00:00Z');
...@@ -317,7 +326,7 @@ describe('runAllForHost 批量路径 — 5 种结局等价', () => { ...@@ -317,7 +326,7 @@ describe('runAllForHost 批量路径 — 5 种结局等价', () => {
// ⭐ 2026-07:(scenario,subKey) 没变、但 signals 语义变了(如缺牙按年龄排治疗新增 // ⭐ 2026-07:(scenario,subKey) 没变、但 signals 语义变了(如缺牙按年龄排治疗新增
// focusCategory / patientAge)—— 原先走 unchanged 分支一个字都不改,存量 plan 永远修不好。 // focusCategory / patientAge)—— 原先走 unchanged 分支一个字都不改,存量 plan 永远修不好。
test('unchanged + signals 语义变化 → reason 就地刷新,**不升版本、不动认领**', async () => { test('unchanged + signals 语义变化 → reason 就地刷新,**不升版本、不动认领**', async () => {
const { prisma, plans, planReason } = makeStore({ const { prisma, plans, planReason, labelsReset } = makeStore({
plans: [ plans: [
{ {
id: 'p-stale', id: 'p-stale',
...@@ -368,6 +377,14 @@ describe('runAllForHost 批量路径 — 5 种结局等价', () => { ...@@ -368,6 +377,14 @@ describe('runAllForHost 批量路径 — 5 种结局等价', () => {
expect(arg.where.id).toBe('r-stale'); expect(arg.where.id).toBe('r-stale');
expect(arg.data.signals.focusCategory).toBe('prosthodontic'); expect(arg.data.signals.focusCategory).toBe('prosthodontic');
expect(arg.data.signals.patientAge).toBe(90); expect(arg.data.signals.patientAge).toBe(90);
/**
* 🔴 **证据被就地改写 ⇒ 必须同时把 `potential_labels` 置回 NULL**。
*
* 标签是从 `evidence.factIds` 指向的 fact 推出来的,而这条路**只改 evidence、不升版本** ——
* 不置空的话那一列既不是 NULL(收尾的 backfillMissing 会跳过它)、又对不上新证据 ⇒
* 初选矩阵会**按旧标签把人算进错的格子**,要到夜间全量重算才自愈,中间一整天不报错。
*/
expect(labelsReset).toHaveBeenCalledTimes(1);
}); });
test('⭐ unchanged + signals 完全一致 → 不写 reason(防每日重算全量重写)', async () => { test('⭐ unchanged + signals 完全一致 → 不写 reason(防每日重算全量重写)', async () => {
......
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