Commit 0940c635 by luoqi

fix(recall §E): K08 缺牙召回剔除空位但非真缺牙(影像AI/name_map 误召)

仅 missing_tooth 子场景加 3 条剔除:
- excludeThirdMolar:智齿位 18/28/38/48 不召种植
- excludeOrthoExtractionSites:该牙有外科拔除 + 患者正畸语境(K07/正畸治疗)= 正畸减数位,
  缝隙靠矫治关闭,不种植(折进 resolvedTeeth 按牙减,忽略时间方向:拔在 K08 诊断前也算)
- excludeCongenitalName:name 含'先天'(先天缺失等)→ 正畸统筹开/关隙,不自动召修复
本地验证:智齿位 active 召回 2→0,3573063 '38;46'→'46'(精准保留真磨牙),无过度抑制(FP 仍1)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent f4f7a212
......@@ -116,6 +116,10 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 只对本子场景(缺牙→成人修复)生效;龋齿(乳牙也补)/正畸/萌出 不受影响。
// 乳牙判定:FDI 51-85 + 宿主象限记法 1A-4E(见 toothArrSql dropDeciduous)。
excludeDeciduous: true,
// ⭐ §E 修复:把"空位但非真缺牙"剔出 K08 种植召回(影像AI/name_map 常误把这些当缺牙)
excludeThirdMolar: true, // 智齿位 18/28/38/48 不召种植
excludeOrthoExtractionSites: true, // 该牙有外科拔除 + 患者有正畸语境 = 正畸减数位,缝隙正畸关,不种植
excludeCongenitalName: true, // name 含"先天"(先天缺失等)→ 正畸统筹决定开/关隙,不自动召修复
},
ortho_no_consult: {
base: 55,
......@@ -236,6 +240,13 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
const dxCodes = cfg.dxCodes as readonly string[];
const recCodes = cfg.recCodes as readonly string[];
const allCodes = [...dxCodes, ...recCodes];
// §E 子场景特例 flag(仅 missing_tooth 设;其余子场景 undefined → 不生效)
const cfgFlags = cfg as {
excludeDeciduous?: boolean;
excludeThirdMolar?: boolean;
excludeOrthoExtractionSites?: boolean;
excludeCongenitalName?: boolean;
};
// ⭐ 两个口径分开(单一真理源 canonical-codes):
// expectedCats = rule.categories(窄,主治疗)→ 展示"未启动 X" + 触发预期 + ⑤d 主诉匹配
// resolverCats = resolverCategoriesFor(宽,治疗家族)→ ⑤a "已解决" 判定
......@@ -308,15 +319,17 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ⭐ dropDeciduous(仅 missing_tooth 信号牙位用):剔除乳牙 —— 乳牙缺失/损伤不进种植/修复召回。
// 乳牙 = FDI 51-85(`^[5-8][1-5]$`)+ 宿主象限记法 1A-4E(`^[1-4][A-Ea-e]$`)。
// 恒牙 FDI 11-48(象限 1-4 + 1-8 数字)不匹配这两条,不误伤。全乳牙 → st 空 → 不召(同空数组闸)。
const toothArrSql = (expr: Prisma.Sql, dropDeciduous = false) => {
const toothArrSql = (expr: Prisma.Sql, dropDeciduous = false, dropThirdMolar = false) => {
const deciduousFilter = dropDeciduous
? Prisma.sql`AND x !~ '^[5-8][1-5]$' AND x !~ '^[1-4][A-Ea-e]$'`
: Prisma.empty;
// ⭐ §E:智齿位 18/28/38/48(^[1-4]8$ = 恒牙第三磨牙;乳牙 51-85 第二位 1-5,不匹配,无误伤)
const thirdMolarFilter = dropThirdMolar ? Prisma.sql`AND x !~ '^[1-4]8$'` : Prisma.empty;
return Prisma.sql`(SELECT COALESCE(array_agg(x), ARRAY[]::text[]) FROM unnest(array_remove(string_to_array(
regexp_replace(
regexp_replace(${expr}, '([0-9]+[A-Ea-e]?)[[:space:]]+[DMOBLPIdmoblpi]+', '\\1', 'g'),
'[[:space:]]+', '', 'g'),
';'), '')) AS x WHERE x ~ '^[0-9].' ${deciduousFilter})`;
';'), '')) AS x WHERE x ~ '^[0-9].' ${deciduousFilter} ${thirdMolarFilter})`;
};
// 该信号"已被解决"的牙位集合 = 诊断后同牙做了 resolverCats 家族里任一治疗(afterDx)。
// ⭐ 治疗家族 resolver(单一真理源 canonical-codes.resolverCategoriesFor):
......@@ -331,6 +344,26 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
const afterDxFragRtx = rule.excludeIfEverTreated
? Prisma.sql`AND rtx.occurred_at >= ${latestDxOfCode}`
: Prisma.sql`AND rtx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`;
// ⭐ §E (d) 正畸减数位(仅 missing_tooth):该牙有外科拔除(任意时间)且患者有正畸语境
// (K07 诊断 / 正畸治疗)→ 这颗"缺牙"是正畸拔的、缝隙靠矫治关闭,不是种植对象 → 折进 resolved 减掉。
// 不限时间方向(拔在 K08 诊断前也算,葛欣恬:2022 拔、2025 才被影像 AI 标 K08)。
const orthoExtractBranch = cfgFlags.excludeOrthoExtractionSites
? Prisma.sql`
UNION
SELECT eet AS t
FROM patient_facts ex
CROSS JOIN unnest(${toothArrSql(Prisma.sql`ex.content->>'tooth_position'`)}) AS eet
WHERE ex.patient_id = p.id
AND ex.type = 'treatment_record' AND ex.kind = 'actual' AND ex.status IN ('active','fulfilled')
AND ex.content->>'category' = 'surgical'
AND EXISTS (SELECT 1 FROM patient_facts oc WHERE oc.patient_id = p.id
AND ((oc.type='diagnosis_record' AND oc.status='active' AND oc.content->>'code'='K07')
OR (oc.type='treatment_record' AND oc.content->>'category'='orthodontic')))`
: Prisma.empty;
// ⭐ §E 先天缺失剔除(仅 missing_tooth):name 含"先天" → 正畸统筹开/关隙,不自动召种植修复。
const congenitalFrag = cfgFlags.excludeCongenitalName
? Prisma.sql`AND COALESCE(sig.content->>'name_zh','') NOT LIKE '%先天%'`
: Prisma.empty;
const resolvedTeethSql = Prisma.sql`
(SELECT COALESCE(array_agg(DISTINCT t), ARRAY[]::text[]) FROM (
-- (a) 治疗家族 resolver(afterDx):同牙诊断后做了 resolverCats 家族里任一治疗
......@@ -369,6 +402,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
AND rdx.content->>'code' = ANY(${[...STRUCTURAL_DX_CODE_LIST]}::text[]) -- 仅结构建议(拔除/种植/充填…)取代;牙周/正畸建议不 moot 结构病
AND COALESCE(rdx.occurred_at, rdx.planned_for) >= COALESCE(sig.occurred_at, sig.planned_for)
AND sig.type = 'diagnosis_record'
${orthoExtractBranch}
) u)`;
// ╔═════════════════════════════════════════════════════════════════════╗
......@@ -458,7 +492,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
(SELECT COALESCE(array_agg(x), ARRAY[]::text[])
FROM unnest(st) AS x WHERE x <> ALL(rt)) AS remaining_teeth
FROM (
SELECT COALESCE(${toothArrSql(sigToothExpr, (cfg as { excludeDeciduous?: boolean }).excludeDeciduous === true)}, ARRAY[]::text[]) AS st,
SELECT COALESCE(${toothArrSql(sigToothExpr, cfgFlags.excludeDeciduous === true, cfgFlags.excludeThirdMolar === true)}, ARRAY[]::text[]) AS st,
${resolvedTeethSql} AS rt
) base
) lat ON true
......@@ -476,6 +510,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
-- ④' 临床语义剔除:废用牙/无功能牙(host 映射到 K08,但牙还在无功能 → 该拔/观察,非修复对象)
-- 不进种植召回。仅这些 name_zh 受影响(它们只在 K08),其余子场景诊断不含此名 → 无副作用。
AND COALESCE(sig.content->>'name_zh', '') <> ALL(${RESTORATION_INELIGIBLE_NAMES}::text[])
${congenitalFrag} -- ④' §E 先天缺失剔除(仅 missing_tooth 启用)
-- ⑤a 牙位级排除(W5 按牙相减,修"多牙诊断被部分治疗整体误抑制"):
-- 全口信号(sig_teeth 空,如 K05/K07)→ 沿用 category 级 NOT EXISTS(做过同类治疗即排)
-- 有牙位信号 → "剩余未治牙位"非空才召(⑤a 同类 / ⑤c 拔除 / ⑤e 替代 已折进 resolved_teeth 按牙相减)
......
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