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
-- ============================================================
-- 召回正确性扫描器(treatment_initiation_recall · 只读回归)
--
-- 用途:独立于 scenario 代码,用 plan_reasons.signals 反查 patient_facts 交叉验证
-- "该召没召 / 不该召却召"。每次摄入/改算法后跑,看 FP/FN 体量。
-- 独立性:下方 codes VALUES 重述一份 code→{resolver/cooldown/⑤d科目/全口/乳牙},
-- 跟 scenario 代码各写一份 → 不一致即暴露(差分)。
-- 运行:
-- 本地 docker exec -i pac-postgres psql -U pac -d pac -f - < apps/pac-service/sql/verify-recall.sql
-- 服务器 docker exec -i pac-postgres-1 psql -U pac -d pac -f - < apps/pac-service/sql/verify-recall.sql
-- 章节:§A 规模 / §B FP 硬闸(应0) / §C 牙位交叉表+FP / §C3-C4 FN 逐层解释 /
-- §D 全口码治疗后复发被压 / §E K08 正畸语境 FP
-- 注:§C/§D 的"残留"需再排 cooldown / 已指派锁 / ⑤d 才是真问题(见 docs 讨论)。
-- 只读;tx_final 用 LATEST 诊断做时间基准(修复发误判),并标全部合法排除层。
-- ============================================================
\pset pager off
\set ON_ERROR_STOP on
CREATE TEMP TABLE tx_final AS
WITH
-- code 配置:resolver(已治家族,宽)+ cooldown + ⑤d 相关预约科目文本 + 是否全口/乳牙剔除
codes(code,sub,cooldown,resolver,complaints,whole,drop_decid) AS (VALUES
('K08','missing_tooth',30, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], ARRAY['种植','修复'], false, true),
('K02','caries_no_filling',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], ARRAY[]::text[], false, false),
('K03','hard_tissue_damage',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], ARRAY['修复','拔牙'], false, false),
('K04','endo_no_rct',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], ARRAY[]::text[], false, false),
('K01','impacted_tooth',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], ARRAY['拔牙'], false, false),
('K09','jaw_cyst',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], ARRAY['拔牙'], false, false),
('K00','development_eruption',30, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric','orthodontic'], ARRAY['拔牙','修复','种植','正畸','早矫'], false, false),
('K06','gum_alveolar_lesion',14, ARRAY['periodontic','surgical'], ARRAY['牙周','拔牙'], false, false)),
diag AS (
SELECT d.patient_id,c.code,c.sub,c.resolver,c.complaints,c.drop_decid,c.cooldown,tk AS tooth,d.occurred_at AS dx_at,d.content->>'name_zh' AS name_zh
FROM patient_facts d JOIN codes c ON c.code=d.content->>'code'
JOIN patients p ON p.id=d.patient_id JOIN patient_profiles pp ON pp.patient_id=d.patient_id
CROSS JOIN LATERAL unnest(string_to_array(regexp_replace(coalesce(d.content->>'tooth_position',''),'[^0-9;]+',';','g'),';')) tk
WHERE d.type='diagnosis_record' AND d.status='active' AND p.active AND NOT pp.do_not_contact AND NOT pp.deceased AND tk ~ '^[0-9]{2}$'),
g AS ( -- 每(患者×code×牙):取 LATEST 诊断(修复发),过 cooldown
SELECT patient_id,code,sub,tooth,min(resolver) resolver,min(complaints) complaints,bool_or(drop_decid) drop_decid,
max(dx_at) dx_at, string_agg(DISTINCT name_zh,'/') name_zh, min(cooldown) cooldown
FROM diag GROUP BY patient_id,code,sub,tooth
HAVING max(dx_at) <= now()-(min(cooldown)||' days')::interval)
SELECT g.patient_id,g.code,g.sub,g.tooth,g.dx_at,g.name_zh,
EXISTS(SELECT 1 FROM patient_facts t CROSS JOIN LATERAL unnest(string_to_array(regexp_replace(coalesce(t.content->>'tooth_position',''),'[^0-9;]+',';','g'),';')) tt
WHERE t.patient_id=g.patient_id AND t.type='treatment_record' AND t.kind='actual' AND t.status IN('active','fulfilled')
AND t.content->>'category'=ANY(g.resolver) AND tt=g.tooth AND t.occurred_at>=g.dx_at) AS resolved,
EXISTS(SELECT 1 FROM plan_reasons pr JOIN followup_plans fp ON fp.id=pr.plan_id
CROSS JOIN LATERAL unnest(string_to_array(regexp_replace(coalesce(pr.signals->>'toothPosition',''),'[^0-9;]+',';','g'),';')) rt
WHERE fp.patient_id=g.patient_id AND fp.status IN('active','assigned') AND pr.signals->>'subKey'=g.sub AND rt=g.tooth) AS recalled,
-- 合法排除层
EXISTS(SELECT 1 FROM patient_facts a WHERE a.patient_id=g.patient_id AND a.type='appointment_record' AND a.status='active' AND COALESCE(a.planned_for,a.occurred_at)>now()) AS x_future_appt,
EXISTS(SELECT 1 FROM patient_facts v WHERE v.patient_id=g.patient_id AND v.type IN('encounter_record','emr_record') AND v.occurred_at>now()-interval '14 days') AS x_recent_visit,
(cardinality(g.complaints)>0 AND EXISTS(SELECT 1 FROM patient_facts a WHERE a.patient_id=g.patient_id AND a.type='appointment_record'
AND a.status IN('active','fulfilled') AND COALESCE(a.planned_for,a.occurred_at)>=g.dx_at
AND EXISTS(SELECT 1 FROM unnest(string_to_array(coalesce(a.content->>'complaint_category',''),',')) c WHERE trim(c)=ANY(g.complaints)))) AS x_appt_complaint,
(g.name_zh IN ('废用牙','无功能牙')) AS x_ineligible_name,
(g.drop_decid AND g.tooth ~ '^[5-8][1-5]$') AS x_deciduous
FROM g;
\echo '════════ §A 规模 ════════'
SELECT count(*) AS teeth, count(DISTINCT patient_id) AS patients,
count(*) FILTER (WHERE recalled) AS recalled_teeth,
count(*) FILTER (WHERE resolved) AS resolved_teeth FROM tx_final;
\echo '════════ §B FP 硬闸(应=0)════════'
SELECT
(SELECT count(*) FROM plan_reasons pr JOIN followup_plans fp ON fp.id=pr.plan_id JOIN patients p ON p.id=fp.patient_id JOIN patient_profiles pp ON pp.patient_id=fp.patient_id
WHERE fp.status IN('active','assigned') AND (p.active=false OR pp.do_not_contact OR pp.deceased)) AS fp_compliance,
(SELECT count(*) FROM plan_reasons pr JOIN followup_plans fp ON fp.id=pr.plan_id
WHERE fp.status IN('active','assigned') AND pr.scenario='treatment_initiation_recall'
AND EXISTS(SELECT 1 FROM patient_facts a WHERE a.patient_id=fp.patient_id AND a.type='appointment_record' AND a.status='active' AND COALESCE(a.planned_for,a.occurred_at)>now())) AS fp_future_appt;
\echo '════════ §C 牙位交叉表(LATEST 诊断基准)════════'
SELECT resolved, recalled, count(*) AS teeth,
CASE WHEN NOT resolved AND recalled THEN '✓ 未治→召'
WHEN resolved AND NOT recalled THEN '✓ 已治→不召'
WHEN resolved AND recalled THEN '✗ FP 已治却召'
ELSE '? FN 未治却不召' END AS verdict
FROM tx_final GROUP BY 1,2 ORDER BY 1,2;
\echo '════════ §C2 FP(已治却召)真量 + 样例 ════════'
SELECT count(*) AS fp_teeth FROM tx_final WHERE resolved AND recalled;
SELECT substr(patient_id::text,1,8) pid, code, tooth, dx_at::date, name_zh FROM tx_final WHERE resolved AND recalled LIMIT 12;
\echo '════════ §C3 FN 逐层解释 → 真·无法解释 ════════'
SELECT count(*) AS fn_total,
count(*) FILTER (WHERE x_future_appt) AS e_future_appt,
count(*) FILTER (WHERE NOT x_future_appt AND x_recent_visit) AS e_recent_visit,
count(*) FILTER (WHERE NOT x_future_appt AND NOT x_recent_visit AND x_appt_complaint) AS e_appt_complaint,
count(*) FILTER (WHERE NOT x_future_appt AND NOT x_recent_visit AND NOT x_appt_complaint AND (x_ineligible_name OR x_deciduous)) AS e_ineligible_decid,
count(*) FILTER (WHERE NOT x_future_appt AND NOT x_recent_visit AND NOT x_appt_complaint AND NOT x_ineligible_name AND NOT x_deciduous) AS truly_unexplained
FROM tx_final WHERE NOT resolved AND NOT recalled;
\echo '---- 真·无法解释 FN 样例(按 code)----'
SELECT code, substr(patient_id::text,1,8) pid, tooth, dx_at::date, name_zh FROM tx_final
WHERE NOT resolved AND NOT recalled AND NOT x_future_appt AND NOT x_recent_visit AND NOT x_appt_complaint AND NOT x_ineligible_name AND NOT x_deciduous
ORDER BY code LIMIT 30;
\echo '════════ §C4 真·无法解释 FN 里被"同牙更晚结构诊断/建议取代"(branch b/c,我未建模)解释的 ════════'
SELECT count(*) AS superseded
FROM tx_final f
WHERE NOT f.resolved AND NOT f.recalled AND NOT f.x_future_appt AND NOT f.x_recent_visit AND NOT f.x_appt_complaint AND NOT f.x_ineligible_name AND NOT f.x_deciduous
AND EXISTS (SELECT 1 FROM patient_facts ld
CROSS JOIN LATERAL unnest(string_to_array(regexp_replace(coalesce(ld.content->>'tooth_position',''),'[^0-9;]+',';','g'),';')) lt
WHERE ld.patient_id=f.patient_id AND ld.status='active' AND ld.type IN('diagnosis_record','recommendation_record')
AND ld.content->>'code' IN ('K00','K01','K02','K03','K04','K08','K09','IMPLANT_RECOMMENDED','CROWN_RECOMMENDED','FILLING_RECOMMENDED','EXTRACTION_RECOMMENDED','RCT_RECOMMENDED','HARD_TISSUE_REPAIR_RECOMMENDED','ERUPTION_INTERVENTION_RECOMMENDED','JAW_CYST_REMOVAL_RECOMMENDED')
AND ld.content->>'code' <> f.code AND lt=f.tooth AND COALESCE(ld.occurred_at,ld.planned_for) >= f.dx_at);
\echo '════════ §D ⭐ 全口码 治疗后又被诊断 却被压(excludeIfEverTreated 缺陷)════════'
WITH wm(code,sub,tx_cat) AS (VALUES ('K05','perio_no_srp','periodontic'),('K07','ortho_no_consult','orthodontic')),
pt AS (
SELECT wm.code,wm.sub,d.patient_id, max(d.occurred_at) AS latest_dx,
(SELECT max(t.occurred_at) FROM patient_facts t WHERE t.patient_id=d.patient_id AND t.type='treatment_record'
AND t.kind='actual' AND t.status IN('active','fulfilled') AND t.content->>'category'=wm.tx_cat) AS latest_tx
FROM patient_facts d JOIN wm ON wm.code=d.content->>'code'
JOIN patients p ON p.id=d.patient_id JOIN patient_profiles pp ON pp.patient_id=d.patient_id
WHERE d.type='diagnosis_record' AND d.status='active' AND p.active AND NOT pp.do_not_contact AND NOT pp.deceased
GROUP BY wm.code,wm.sub,wm.tx_cat,d.patient_id)
SELECT code,
count(*) FILTER (WHERE latest_tx IS NOT NULL) AS treated_pts,
count(*) FILTER (WHERE latest_tx IS NOT NULL AND latest_dx>latest_tx) AS new_dx_after_tx,
count(*) FILTER (WHERE latest_tx IS NOT NULL AND latest_dx>latest_tx
AND NOT EXISTS(SELECT 1 FROM plan_reasons pr JOIN followup_plans fp ON fp.id=pr.plan_id
WHERE fp.patient_id=pt.patient_id AND fp.status IN('active','assigned') AND pr.signals->>'subKey'=pt.sub)) AS suppressed_not_recalled,
count(*) FILTER (WHERE latest_tx IS NOT NULL AND latest_dx>latest_tx
AND NOT EXISTS(SELECT 1 FROM plan_reasons pr JOIN followup_plans fp ON fp.id=pr.plan_id
WHERE fp.patient_id=pt.patient_id AND fp.status IN('active','assigned') AND pr.signals->>'subKey'=pt.sub)
AND NOT EXISTS(SELECT 1 FROM patient_facts a WHERE a.patient_id=pt.patient_id AND a.type='appointment_record' AND a.status='active' AND COALESCE(a.planned_for,a.occurred_at)>now())
AND NOT EXISTS(SELECT 1 FROM patient_facts v WHERE v.patient_id=pt.patient_id AND v.type IN('encounter_record','emr_record') AND v.occurred_at>now()-interval '14 days')) AS suppressed_clean
FROM pt GROUP BY code;
\echo '════════ §E K08 正畸语境 FP(缺牙被召种植,但患者在做/做过正畸)════════'
SELECT count(DISTINCT t.patient_id) AS k08_ortho_ctx_patients FROM tx_final t
WHERE t.code='K08' AND t.recalled
AND (EXISTS(SELECT 1 FROM patient_facts d WHERE d.patient_id=t.patient_id AND d.type='diagnosis_record' AND d.status='active' AND d.content->>'code'='K07')
OR EXISTS(SELECT 1 FROM patient_facts x WHERE x.patient_id=t.patient_id AND x.type='treatment_record' AND x.content->>'category'='orthodontic'));
......@@ -258,11 +258,22 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
? Prisma.sql`AND p.id = ${scope.patientId}::uuid`
: 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
? Prisma.empty
? Prisma.sql`AND tx.occurred_at >= ${latestDxOfCode}`
: Prisma.sql`AND tx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`;
// ⭐ 缺口2 修复:wholeMouth 码(全口/全牙弓病:牙周 K05 / 正畸 K07)忽略 dx 自带牙位。
......@@ -315,9 +326,10 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 刻意不含 periodontic/orthodontic/preventive/review → 不被洗牙/刮治/流程跨病误销。
// 牙周/正畸码 resolverCats = rule.categories(牙周只认牙周/外科,不被结构治疗误销)。
// ⚠️ afterDx(治疗 ≥ 诊断 才终结):拔除只终结它之前/同时的病;诊断在拔除【之后】=新信号(不压)。
// excludeIfEverTreated 码(K05/K07 全口长疗程)忽略时间方向 → 曾做过即解决。
// excludeIfEverTreated 码(K05/K07):治疗须晚于该病【最新诊断】才算解决(同 afterDxFrag,治疗后复发重召)。
// 注:K05/K07 均 wholeMouth(走 ⑤a 全口分支),此 Rtx 路径对它们实际不生效,这里保持口径一致。
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)`;
const resolvedTeethSql = Prisma.sql`
(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