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,
};
}
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