Commit 76516429 by luoqi

refactor(recall): 抽 gap 核心到共享模块 clinical-gap(为潜在治疗画像单一源)

- 新建 modules/clinical-gap/potential-treatment-gap.sql.ts:toothArrSql + buildGapCore
  (sig 牙位/resolved/remaining 相减 + ⑤a 判定 + 废用牙/先天剔除 + §E flag),Layer 1 facts 消费层(中立)。
- §E gap flag 单一真理源 GAP_FLAGS_BY_PRIMARY(召回 SUB_SCENARIOS 不再内联)。
- 召回 scenario 改 import 共享片段,SQL 逻辑零改动只搬家。
-  字节等价验证:新旧码召回集合 EXCEPT 双向 0 diff(本地 928,1450 条全等)。
- 目的:Layer 2 潜在治疗画像将复用同一 gap 核心(避免 Layer 2→3 倒置 + 口径漂移)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent bfaae12d
import { Prisma } from '@prisma/client';
import {
RESTORATION_INELIGIBLE_DX_NAMES,
STRUCTURAL_DX_CODE_LIST,
type DxTreatmentRule,
} from '@pac/types';
/**
* 潜在治疗 gap 核心 SQL —— 单一真理源(Layer 1 facts 消费层,中立)
*
* "诊断了/建议了某治疗,但没启动对应治疗" 的牙位级 gap 计算。**两个消费方共用**:
* - Layer 3 召回(treatment-initiation-recall.scenario):gap + 时间门 + 6 因子打分 + 建 plan
* - Layer 2 画像(potential-treatment.feature):gap + 年龄/name 拆分 → 8 业务标签
* 都 import 本模块,口径 byte 级一致(圈人群 = 召回候选)。
*
* ⚠️ 本文件 = 原 scenario 里 toothArrSql / resolvedTeethSql / lateral / gapWhere 的【原样搬家】,
* SQL 逻辑零改动(W7 抽取,verify-recall.sql 验 FP/FN 不变)。改 gap 口径只改这一处。
*
* 别名契约:消费方主查询必须用 `p`(patients)/ `sig`(触发 fact)别名,本模块片段按此关联。
*/
export interface GapCfgFlags {
excludeDeciduous?: boolean; // 乳牙不进种植/修复(FDI 51-85 + 象限 1A-4E)
excludeThirdMolar?: boolean; // §E 智齿位 18/28/38/48 不召种植
excludeOrthoExtractionSites?: boolean; // §E 正畸减数位(外科拔除 + 正畸语境)折进 resolved
excludeCongenitalName?: boolean; // §E name 含"先天" → 正畸统筹,不自动召修复
}
/// §E gap 修正 flag 的单一真理源(按 primaryCode 查)。召回 SUB_SCENARIOS 与画像标签都引此。
/// 仅 K08(缺失牙→种植)需要:影像AI/name_map 常把废用牙/智齿/正畸减数位/先天缺失误当缺牙。
export const GAP_FLAGS_BY_PRIMARY: Record<string, GapCfgFlags> = {
K08: {
excludeDeciduous: true,
excludeThirdMolar: true,
excludeOrthoExtractionSites: true,
excludeCongenitalName: true,
},
};
const RESTORATION_INELIGIBLE_NAMES = [...RESTORATION_INELIGIBLE_DX_NAMES];
/// 牙位字符串 → 牙位数组(只剥"牙位base+空格+牙面字母"后缀,保 FDI 数字 & Palmer 乳牙字母)。
/// 口径必须跟 tooth-position.util.ts 的 toothSet 严格一致。详见原 scenario 注释。
export function toothArrSql(
expr: Prisma.Sql,
opts: { dropDeciduous?: boolean; dropThirdMolar?: boolean } = {},
): Prisma.Sql {
const deciduousFilter = opts.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 = opts.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} ${thirdMolarFilter})`;
}
export interface GapCoreInput {
rule: DxTreatmentRule;
cfgFlags: GapCfgFlags;
allCodes: readonly string[]; // dxCodes ∪ recCodes
resolverCats: readonly string[]; // resolverCategoriesFor(primaryCode)
}
export interface GapCorePieces {
/// 信号牙位表达式(全口码 = NULL)
sigToothExpr: Prisma.Sql;
/// 是否全口码(K05/K07)
wholeMouthFlag: Prisma.Sql;
/// ④' 废用牙/无功能牙剔除(WHERE add-on,作用于所有 sig;只 K08 有这些 name)
restorationIneligibleFrag: Prisma.Sql;
/// ④' §E 先天缺失剔除(WHERE add-on;仅 excludeCongenitalName 时非空)
congenitalFrag: Prisma.Sql;
/// LEFT JOIN LATERAL (...) lat ON true —— 算 sig_teeth / resolved_teeth / remaining_teeth
lateralJoin: Prisma.Sql;
/// tooth 输出 CASE(全口 → sigToothExpr;有牙位 → 剩余未治牙位字符串)
toothOutput: Prisma.Sql;
/// ⑤a gap 判定(WHERE add-on):全口 → NOT EXISTS 同类治疗;有牙位 → 剩余非空
gapWhere: Prisma.Sql;
}
/**
* 构造 gap 核心 SQL 片段(原样搬自 scenario,逻辑不变)。
* 消费方按需把片段拼进自己的主查询(召回再加时间门 + 打分;画像加年龄 + 标签映射)。
*/
export function buildGapCore(input: GapCoreInput): GapCorePieces {
const { rule, cfgFlags, allCodes, resolverCats } = input;
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.sql`AND tx.occurred_at >= ${latestDxOfCode}`
: Prisma.sql`AND tx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`;
const sigToothExpr = rule.wholeMouth
? Prisma.sql`NULL::text`
: Prisma.sql`sig.content->>'tooth_position'`;
const wholeMouthFlag = rule.wholeMouth ? Prisma.sql`TRUE` : Prisma.sql`FALSE`;
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):该牙有外科拔除 + 患者有正畸语境 → 折进 resolved 减掉。
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;
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 家族里任一治疗
SELECT rtt AS t
FROM patient_facts rtx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rtx.content->>'tooth_position'`)}) AS rtt
WHERE rtx.patient_id = p.id
AND rtx.type = 'treatment_record' AND rtx.kind = 'actual'
AND rtx.status IN ('active', 'fulfilled')
AND COALESCE(NULLIF(trim(rtx.content->>'tooth_position'), ''), '') != ''
AND rtx.content->>'category' = ANY(${resolverCats}::text[])
${afterDxFragRtx}
UNION
-- (b) 同牙位以【最新诊断】为准:更晚的真实结构诊断 → 旧诊断对该牙失效
SELECT ldt AS t
FROM patient_facts ldx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`ldx.content->>'tooth_position'`)}) AS ldt
WHERE ldx.patient_id = p.id
AND ldx.type = 'diagnosis_record' AND ldx.status = 'active'
AND ldx.content->>'code' = ANY(${[...STRUCTURAL_DX_CODE_LIST]}::text[])
AND ldx.occurred_at > COALESCE(sig.occurred_at, sig.planned_for)
UNION
-- (c) 诊断 vs 建议冲突,以【建议】为准(医生治疗决定 > 诊断)
SELECT rdt AS t
FROM patient_facts rdx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rdx.content->>'tooth_position'`)}) AS rdt
WHERE rdx.patient_id = p.id
AND rdx.type = 'recommendation_record' AND rdx.status = 'active'
AND rdx.content->>'code' = ANY(${[...STRUCTURAL_DX_CODE_LIST]}::text[])
AND COALESCE(rdx.occurred_at, rdx.planned_for) >= COALESCE(sig.occurred_at, sig.planned_for)
AND sig.type = 'diagnosis_record'
${orthoExtractBranch}
) u)`;
const lateralJoin = Prisma.sql`
LEFT JOIN LATERAL (
SELECT st AS sig_teeth, rt AS resolved_teeth,
(SELECT COALESCE(array_agg(x), ARRAY[]::text[])
FROM unnest(st) AS x WHERE x <> ALL(rt)) AS remaining_teeth
FROM (
SELECT COALESCE(${toothArrSql(sigToothExpr, {
dropDeciduous: cfgFlags.excludeDeciduous === true,
dropThirdMolar: cfgFlags.excludeThirdMolar === true,
})}, ARRAY[]::text[]) AS st,
${resolvedTeethSql} AS rt
) base
) lat ON true`;
const toothOutput = Prisma.sql`CASE WHEN ${wholeMouthFlag}
THEN ${sigToothExpr}
ELSE array_to_string(lat.remaining_teeth, ';') END`;
const restorationIneligibleFrag = Prisma.sql`AND COALESCE(sig.content->>'name_zh', '') <> ALL(${RESTORATION_INELIGIBLE_NAMES}::text[])`;
const gapWhere = Prisma.sql`AND (
CASE WHEN ${wholeMouthFlag} THEN
NOT EXISTS (
SELECT 1 FROM patient_facts tx
WHERE tx.patient_id = p.id
AND tx.type = 'treatment_record'
AND tx.kind = 'actual'
AND tx.status IN ('active', 'fulfilled')
AND tx.content->>'category' = ANY(${resolverCats}::text[])
${afterDxFrag}
AND COALESCE(NULLIF(trim(${sigToothExpr}), ''), '') = ''
)
ELSE
cardinality(lat.remaining_teeth) > 0
END
)`;
return {
sigToothExpr,
wholeMouthFlag,
restorationIneligibleFrag,
congenitalFrag,
lateralJoin,
toothOutput,
gapWhere,
};
}
......@@ -2,12 +2,9 @@ import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
PlanScenario,
DiagnosisTreatmentMap,
APPT_COMPLAINT_TO_CATEGORY,
RESTORATION_INELIGIBLE_DX_NAMES,
lookupDxTreatment,
resolverCategoriesFor,
STRUCTURAL_DX_CODE_LIST,
diagnosisCodeNameZh,
treatmentCategoryNameZh,
treatmentCategoryNameZhForTeeth,
......@@ -20,6 +17,8 @@ import type {
} from '../scenario.interface';
import { calcPriority } from '../priority-scorer';
import { toothSet } from '../../../sync/pipeline/parsers/tooth-position.util';
// ⭐ gap 核心单一真理源(召回 + 潜在治疗画像共用;SQL 逻辑搬此,本文件只组装)
import { buildGapCore, GAP_FLAGS_BY_PRIMARY } from '../../../clinical-gap/potential-treatment-gap.sql';
/**
* 潜在治疗新链召回(treatment_initiation_recall)— v2.1 重写
......@@ -69,14 +68,6 @@ import { toothSet } from '../../../sync/pipeline/parsers/tooth-position.util';
* active + 非 DNC + 非 deceased(phone 缺失暂不强制,DW 不提供)
*/
/// 临床语义剔除:这些 name_zh 被 host 映射到 K08(yaml 标注"临床功能性缺失"),但牙其实
/// 还在、只是无咬合功能(对颌缺失/过长)→ 临床该拔除或观察,**不是修复(种植/桥/义齿)对象**
/// → 不进 K08 种植召回。host 原文 name_zh 在 diagnosis_record.content 留底,据此精确剔除。
/// 案例:韩雷 38;48 / 826790 18;28;38;48 都是"废用牙"被误当缺牙召回种植(91 分误召)。
/// 注:这跟"真缺失"(缺失/牙列缺损 → 该修复)区分开;拔牙本身不做泛召回(详见 docs 讨论)。
/// ⭐ 单一真理源(canonical-codes):chain-composer 同表 → 不立"种植修复·发现机会"潜在链,口径一致。
const RESTORATION_INELIGIBLE_NAMES = [...RESTORATION_INELIGIBLE_DX_NAMES];
/// 就诊冷静(防打扰,⑤f):患者近 N 天到过诊(encounter/emr)→ 本轮不召,别催刚来过的人。
/// 跟诊断 cooldown 不同锚点 — cooldown 锚"诊断日"按 K 码定长;本项锚"最近到诊"统一长度。
/// 二者并存,各补各的洞:cooldown 给新诊断缓冲;本项拦"旧诊断 + 近期又来过"。
......@@ -112,14 +103,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
recCodes: ['IMPLANT_RECOMMENDED'],
label: '缺失牙未启动修复',
goal: '邀约启动缺失牙修复(种植/桥/义齿),避免邻牙倾斜 / 对颌伸长',
// ⭐ 乳牙不进种植/修复召回:乳牙缺失/晚萌是生理现象,恒牙会替换,不可能种植/做冠桥。
// 只对本子场景(缺牙→成人修复)生效;龋齿(乳牙也补)/正畸/萌出 不受影响。
// 乳牙判定: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 含"先天"(先天缺失等)→ 正畸统筹决定开/关隙,不自动召修复
// ⭐ §E gap 修正 flag(乳牙/智齿/正畸减数位/先天缺失剔除)→ 单一真理源 GAP_FLAGS_BY_PRIMARY['K08']
// (potential-treatment-gap.sql),召回与潜在治疗画像共用,口径一致。
},
ortho_no_consult: {
base: 55,
......@@ -240,13 +225,8 @@ 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;
};
// §E gap 修正 flag → 单一真理源 GAP_FLAGS_BY_PRIMARY(召回 + 潜在治疗画像共用)
const cfgFlags = GAP_FLAGS_BY_PRIMARY[cfg.primaryCode] ?? {};
// ⭐ 两个口径分开(单一真理源 canonical-codes):
// expectedCats = rule.categories(窄,主治疗)→ 展示"未启动 X" + 触发预期 + ⑤d 主诉匹配
// resolverCats = resolverCategoriesFor(宽,治疗家族)→ ⑤a "已解决" 判定
......@@ -269,141 +249,10 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
? Prisma.sql`AND p.id = ${scope.patientId}::uuid`
: Prisma.empty;
// ⑤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.sql`AND tx.occurred_at >= ${latestDxOfCode}`
: Prisma.sql`AND tx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`;
// ⭐ 缺口2 修复:wholeMouth 码(全口/全牙弓病:牙周 K05 / 正畸 K07)忽略 dx 自带牙位。
// 把"信号的牙位"统一替换成 NULL → 下游一律按"无牙位"处理:
// - 主 SELECT:tooth=null → 聚类归 'whole' cluster(sub_key=...@whole),展示不带单颗牙
// - ⑤a:第一分支"信号无牙位"恒真 → category 级排除(只要做过同类治疗即排,不比牙位)
// - ⑤c / ⑤e:"信号有牙位"守卫恒假 → 不按单牙误排(单颗拔除/单颗冠桥不终结全口病)
// 原因:全口病诊断偶带某颗牙(录入习惯),治疗可能在别的牙,单牙重叠判断会误召
// (709686:K05@38 牙周治疗在 36 → 单牙不重叠 → 误召 perio_no_srp@38)。
// 非 wholeMouth 码(K02 龋/K04 根管/K08 缺牙等)保持按牙位精确匹配,不受影响。
const sigToothExpr = rule.wholeMouth
? Prisma.sql`NULL::text`
: Prisma.sql`sig.content->>'tooth_position'`;
// 是否全口码(K05/K07)— 编译期已知,显式传入。
// ⚠️ 不能再用 `cardinality(sig_teeth)=0` 推断全口:非全口码若牙位全是非法(裸象限数字
// "1;2" 笔误,被 toothArrSql 过滤成空)也会 cardinality=0 → 误入全口分支 → 输出原始裸数字
// (phantom 召回 endo_no_rct@1;)。改用 rule.wholeMouth:非全口码牙位全非法 → remaining 空 → 不召。
const wholeMouthFlag = rule.wholeMouth ? Prisma.sql`TRUE` : Prisma.sql`FALSE`;
// ⭐ 牙位级"按牙相减"(W5:修多牙诊断被部分治疗整体误抑制)
// 牙位字符串 → 牙位数组,**只剥"空格+牙面字母"后缀,保留 FDI 数字 & Palmer 乳牙字母**。
// 口径必须跟单一真理源 toothSet(tooth-position.util.ts)严格一致 —— 否则乳牙塌缩。
// ⚠️ 旧实现 `regexp_replace(expr,'[^0-9;]+',';')` 把所有字母无差别剥掉,
// 乳牙 "1D;1E" 全塌成 "1"(象限)→ 错标 + 整象限过度相减漏召(差分测试 26 患者命中)。
// 现:① 剥 "牙位base + 空格 + 牙面字母"(同 normalizeToothPosition 的 SURFACE_SUFFIX_RE)
// ② 去残留空格 ③ 按 ';' 拆。例:
// "1D;1E;2D" → {1D,1E,2D}(乳牙保留) "1D OD;17 D" → {1D,17}(剥面) "16;46" → {16,46}
// 注:JS 模板里反向引用要写 '\\1'(传到 SQL 为 '\1');用 POSIX [[:space:]] 避开 \s 转义坑。
// ⭐ 合法牙过滤(口径同 util.isValidToothToken):只留【数字开头 + ≥2 字符】的 token。
// 裸单数字 1-8(象限号,医生把 "16" 笔误成 "1")非法 → 丢,否则造幽灵召回 caries@1。
// `x ~ '^[0-9].'`:数字开头且其后还有字符(裸 "1" 仅 1 字符 → 不匹配 → 滤掉)。
// ⭐ 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, 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} ${thirdMolarFilter})`;
};
// 该信号"已被解决"的牙位集合 = 诊断后同牙做了 resolverCats 家族里任一治疗(afterDx)。
// ⭐ 治疗家族 resolver(单一真理源 canonical-codes.resolverCategoriesFor):
// 结构码 resolverCats = 局部结构治疗全集(充填/嵌体/冠桥/种植/牙髓/外科/美学/儿牙)—
// 已涵盖旧 ⑤a同类 + ⑤c拔除(surgical)+ ⑤e替代(implant/prostho),三分支收成一条。
// 新增:贴面(cosmetic)/ 根管(endodontic)/ 充填(restorative)治龋等过去漏判的也算已治(李梦维 1B)。
// 刻意不含 periodontic/orthodontic/preventive/review → 不被洗牙/刮治/流程跨病误销。
// 牙周/正畸码 resolverCats = rule.categories(牙周只认牙周/外科,不被结构治疗误销)。
// ⚠️ afterDx(治疗 ≥ 诊断 才终结):拔除只终结它之前/同时的病;诊断在拔除【之后】=新信号(不压)。
// excludeIfEverTreated 码(K05/K07):治疗须晚于该病【最新诊断】才算解决(同 afterDxFrag,治疗后复发重召)。
// 注:K05/K07 均 wholeMouth(走 ⑤a 全口分支),此 Rtx 路径对它们实际不生效,这里保持口径一致。
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 家族里任一治疗
SELECT rtt AS t
FROM patient_facts rtx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rtx.content->>'tooth_position'`)}) AS rtt
WHERE rtx.patient_id = p.id
AND rtx.type = 'treatment_record' AND rtx.kind = 'actual'
AND rtx.status IN ('active', 'fulfilled')
AND COALESCE(NULLIF(trim(rtx.content->>'tooth_position'), ''), '') != ''
AND rtx.content->>'category' = ANY(${resolverCats}::text[])
${afterDxFragRtx}
UNION
-- (b) ⭐ 同牙位以【最新诊断】为准:某牙存在比本信号更晚的真实诊断 → 旧诊断对该牙失效。
-- 解:深龋(2023)→ 缺失(2024)缺失取代龋(牙没了,补龋/拔牙都 moot);龋→牙髓炎(进展)只召根管;
-- 复发同病只认最新那次(daysSince 从最新算)。证据是【诊断】本身,无需治疗记录。
-- 只算"真实码"(非空码,排除深窝沟等 null 噪音);严格更晚(> 不含同刻,避免自我取代)。
-- wholeMouth 码(K05/K07)走 category 级、sig_teeth 空 → 不经此路径,不受影响。
SELECT ldt AS t
FROM patient_facts ldx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`ldx.content->>'tooth_position'`)}) AS ldt
WHERE ldx.patient_id = p.id
AND ldx.type = 'diagnosis_record' AND ldx.status = 'active'
AND ldx.content->>'code' = ANY(${[...STRUCTURAL_DX_CODE_LIST]}::text[]) -- 仅结构码取代(牙周/正畸不 moot 结构病)
AND ldx.occurred_at > COALESCE(sig.occurred_at, sig.planned_for)
UNION
-- (c) ⭐ 诊断 vs 建议冲突,以【建议】为准(医生治疗决定 > 诊断):
-- 某牙存在 ≥ 本诊断时间的真实建议(建议拔除/种植…)→ 该诊断对该牙失效,听医生的决定。
-- 18:深龋(诊断)+ 建议拔除(同日)→ 龋失效,只留拔除。只作用于本 sig 是【诊断】时
-- (建议本身不被同牙建议取代;建议被更晚诊断取代已由 (b) 覆盖)。
SELECT rdt AS t
FROM patient_facts rdx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rdx.content->>'tooth_position'`)}) AS rdt
WHERE rdx.patient_id = p.id
AND rdx.type = 'recommendation_record' AND rdx.status = 'active'
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)`;
// ⭐ gap 核心(sig 牙位 / resolved / remaining + ⑤a 判定 + 废用牙/先天剔除)抽到共享模块
// potential-treatment-gap.sql —— 召回与潜在治疗画像【单一真理源】,SQL 逻辑零改动只搬家。
// 召回在此基础上再加时间门(④ cooldown / ⑤b 预约 / ⑤d entered / ⑤f 到诊)+ 6 因子打分。
const gap = buildGapCore({ rule, cfgFlags, allCodes, resolverCats });
// ╔═════════════════════════════════════════════════════════════════════╗
// ║ 召回 SQL 完整解读(initiation = 潜在治疗新链召回) ║
......@@ -473,11 +322,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
sig.id AS signal_fact_id,
sig.type AS signal_type,
sig.content->>'code' AS signal_code,
-- ⭐ 牙位级相减:有牙位信号 → 输出"剩余未治牙位"(诊断牙位 − 已治牙位);
-- 全口信号(sig_teeth 空)→ 原样(NULL,下游归 whole cluster)
CASE WHEN ${wholeMouthFlag}
THEN ${sigToothExpr}
ELSE array_to_string(lat.remaining_teeth, ';') END AS tooth,
-- ⭐ 牙位级相减:有牙位信号 → 剩余未治牙位;全口信号 → 原样 NULL(gap 核心,共享模块)
${gap.toothOutput} AS tooth,
sig.content->>'extracted_by' AS extracted_by,
sig.content->>'confidence' AS confidence,
sig.clinic_id AS clinic_id,
......@@ -486,16 +332,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
FROM patients p
JOIN patient_profiles pp ON pp.patient_id = p.id
JOIN patient_facts sig ON sig.patient_id = p.id
-- ⭐ 按牙相减:算 sig 牙位 / 已解决牙位 / 剩余未治牙位(供 tooth 输出 + ⑤a 闸)
LEFT JOIN LATERAL (
SELECT st AS sig_teeth, rt AS resolved_teeth,
(SELECT COALESCE(array_agg(x), ARRAY[]::text[])
FROM unnest(st) AS x WHERE x <> ALL(rt)) AS remaining_teeth
FROM (
SELECT COALESCE(${toothArrSql(sigToothExpr, cfgFlags.excludeDeciduous === true, cfgFlags.excludeThirdMolar === true)}, ARRAY[]::text[]) AS st,
${resolvedTeethSql} AS rt
) base
) lat ON true
-- ⭐ 按牙相减(sig 牙位 / 已解决 / 剩余未治)— gap 核心,共享模块单一真理源
${gap.lateralJoin}
WHERE p.host_id = ${scope.hostId}::uuid -- ① 隔离闸
AND p.tenant_id = ${scope.tenantId} -- ① 隔离闸
${patientFilter} -- 单刷收窄(可空)
......@@ -507,32 +345,12 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
AND sig.content->>'code' = ANY(${allCodes}::text[]) -- ③ 信号 code 命中
AND COALESCE(sig.occurred_at, sig.planned_for) IS NOT NULL -- ④ 时间不为空
AND COALESCE(sig.occurred_at, sig.planned_for) <= ${this.daysAgo(scope.now, start)}::timestamptz -- ④ 过 cooldown
-- ④' 临床语义剔除:废用牙/无功能牙(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 按牙相减)
-- 例 龚靖舜 浅龋@16;26;46;36 只补了 16 → remaining={26,36,46} 非空 → 仍召这三颗
AND (
CASE WHEN ${wholeMouthFlag} THEN
NOT EXISTS (
SELECT 1 FROM patient_facts tx
WHERE tx.patient_id = p.id
AND tx.type = 'treatment_record'
AND tx.kind = 'actual'
AND tx.status IN ('active', 'fulfilled')
AND tx.content->>'category' = ANY(${resolverCats}::text[]) -- 治疗家族 resolver(全口码 = rule.categories)
${afterDxFrag}
-- 全口信号 sigToothExpr=NULL → 下条恒真 → category 级排除(忽略 dx 自带牙位)
AND COALESCE(NULLIF(trim(${sigToothExpr}), ''), '') = ''
)
ELSE
cardinality(lat.remaining_teeth) > 0
END
)
-- (⑤c 同牙位拔除 已折进上面 resolved_teeth 的 surgical 分支 — 拔了的牙从 remaining 减掉)
-- ④' 废用牙/无功能牙剔除 + ④' §E 先天缺失剔除 + ⑤a 牙位级 gap 判定(全口 NOT EXISTS / 有牙位剩余非空)
-- —— 全部抽到 gap 核心(共享模块),召回与潜在治疗画像口径一致
${gap.restorationIneligibleFrag}
${gap.congenitalFrag}
${gap.gapWhere}
-- (⑤c 同牙位拔除 已折进 resolved_teeth 的 surgical 分支 — 拔了的牙从 remaining 减掉)
AND NOT EXISTS ( -- ⑤b 排除:患者已有未来预约
-- 召回目的 = 让客服建预约。患者已经有未来预约 → 客服不需要再 push,医生到诊现场处理即可
-- 不按 complaint_category 匹配(陆伟根:K07 诊断 + 修复预约 → 反正会来诊)
......
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