Commit 98575181 by luoqi

fix(recall): 全口码治疗后复发重召 + 召回正确性扫描器入库

- treatment-initiation-recall:excludeIfEverTreated(K05牙周/K07正畸)从"曾做过即永久排除"
  改为"治疗须晚于该病【最新诊断】才算已处理"。最新诊断在末次治疗之后(复发未治)→ 重新召回;
  维护中患者(末次治疗≥最新诊断)继续排除,活跃患者仍由 cooldown/⑤b/⑤d/⑤f 兜住。
  仅改两段时间方向 fragment + 加 latestDxOfCode 子查询。
  本地 1000 患者验证:复发漏召 27→6(残留全是 cooldown内/已指派锁/⑤d 合法不召),牙位级 FP 无回归。
- 新增 sql/verify-recall.sql:只读召回正确性扫描器(FP 硬闸 + 牙位交叉表 + FN 逐层解释 +
  全口复发 + K08正畸语境),每次摄入/改算法后跑做回归。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 379a4af8
...@@ -258,11 +258,22 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -258,11 +258,22 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
? Prisma.sql`AND p.id = ${scope.patientId}::uuid` ? Prisma.sql`AND p.id = ${scope.patientId}::uuid`
: Prisma.empty; : Prisma.empty;
// ⑤a 时间方向开关:excludeIfEverTreated 的码(全口长疗程,正畸 K07 / 牙周 K05)忽略时间方向 — // ⑤a 时间方向开关。
// "曾做过同类治疗"即排除(复诊反复重记诊断,不能要求治疗晚于诊断,否则误召"未启动")。 // 非全口码:保留"治疗晚于本信号诊断"(按牙位区分新旧病灶)。
// 按牙位的码保留"治疗晚于诊断",靠牙位区分新旧病灶。 // excludeIfEverTreated 全口长疗程码(牙周 K05 / 正畸 K07):
// 旧实现 = Prisma.empty("曾做过同类治疗即排除"),会把【治疗后复发、又被诊断】的患者永久压掉(漏召)。
// 新实现 = "治疗须晚于【该患者该病的最新诊断/建议】才算已处理" → 最新诊断在末次治疗【之后】
// (= 复发未治)则重新进召回。用"最新诊断"(非最早/非 sig 自身)规避"复诊反复重记诊断"的误召;
// 维护中患者(末次治疗 ≥ 最新诊断)继续排除,且 ⑤b 未来预约 / ⑤f 近期到诊 已兜住活跃患者。
const latestDxOfCode = Prisma.sql`(
SELECT max(COALESCE(dd.occurred_at, dd.planned_for))
FROM patient_facts dd
WHERE dd.patient_id = p.id AND dd.status = 'active'
AND dd.type IN ('diagnosis_record', 'recommendation_record')
AND dd.content->>'code' = ANY(${allCodes}::text[])
)`;
const afterDxFrag = rule.excludeIfEverTreated const afterDxFrag = rule.excludeIfEverTreated
? Prisma.empty ? Prisma.sql`AND tx.occurred_at >= ${latestDxOfCode}`
: Prisma.sql`AND tx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`; : Prisma.sql`AND tx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`;
// ⭐ 缺口2 修复:wholeMouth 码(全口/全牙弓病:牙周 K05 / 正畸 K07)忽略 dx 自带牙位。 // ⭐ 缺口2 修复:wholeMouth 码(全口/全牙弓病:牙周 K05 / 正畸 K07)忽略 dx 自带牙位。
...@@ -315,9 +326,10 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -315,9 +326,10 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 刻意不含 periodontic/orthodontic/preventive/review → 不被洗牙/刮治/流程跨病误销。 // 刻意不含 periodontic/orthodontic/preventive/review → 不被洗牙/刮治/流程跨病误销。
// 牙周/正畸码 resolverCats = rule.categories(牙周只认牙周/外科,不被结构治疗误销)。 // 牙周/正畸码 resolverCats = rule.categories(牙周只认牙周/外科,不被结构治疗误销)。
// ⚠️ afterDx(治疗 ≥ 诊断 才终结):拔除只终结它之前/同时的病;诊断在拔除【之后】=新信号(不压)。 // ⚠️ afterDx(治疗 ≥ 诊断 才终结):拔除只终结它之前/同时的病;诊断在拔除【之后】=新信号(不压)。
// excludeIfEverTreated 码(K05/K07 全口长疗程)忽略时间方向 → 曾做过即解决。 // excludeIfEverTreated 码(K05/K07):治疗须晚于该病【最新诊断】才算解决(同 afterDxFrag,治疗后复发重召)。
// 注:K05/K07 均 wholeMouth(走 ⑤a 全口分支),此 Rtx 路径对它们实际不生效,这里保持口径一致。
const afterDxFragRtx = rule.excludeIfEverTreated const afterDxFragRtx = rule.excludeIfEverTreated
? Prisma.empty ? Prisma.sql`AND rtx.occurred_at >= ${latestDxOfCode}`
: Prisma.sql`AND rtx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`; : Prisma.sql`AND rtx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`;
const resolvedTeethSql = Prisma.sql` const resolvedTeethSql = Prisma.sql`
(SELECT COALESCE(array_agg(DISTINCT t), ARRAY[]::text[]) FROM ( (SELECT COALESCE(array_agg(DISTINCT t), ARRAY[]::text[]) FROM (
......
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