Commit 0d458785 by luoqi

fix(召回): 「种植/正畸/牙周复查」按类目解除对应缺口 —— 复查是修复体在位的证据

宋志宏 TS0M001982 牙46;36;37:三颗种植冠在位、按约复查,却被判"缺失牙未启动
修复"。那次就诊里四处表述都说明修复体存在(诊断「种植术后」/ 本次治疗「种植复查」/
检查所见「种植冠无松动」/ 主诉「种植戴牙术后1年余」),PAC 一处也没接住 ——
唯一带牙位的证据「种植复查」落在 review 类目,而 review 被刻意排除在
STRUCTURAL_RESOLVER_CATEGORIES 之外。

那个排除本身是对的:生产带牙位的 review 共 4.4 万条,「观察」11,165 /
「观察,必要时拔除」928 /「无治疗」623 —— 这些恰恰说明还没治,整类放行会误销。

做法:新增 REVIEW_IMPLIES_TREATMENT(复查词 → 蕴含的治疗类目),
resolvedTeethSql 加一条 UNION 分支,两道闸都不能松:
  ① 词形闸:必须「治疗词 + 复查/复诊」——「正畸复诊」(在做)vs「正畸会诊」(还在谈)
     只差一字,生产各 35 / 103 条;泛指「复查/常规复查/定期复查」刻意不收。
  ② 类目闸:按 resolverCategoriesFor 过滤 —— 生产实测 19 条候选里 9 条是跨类目
     错配(牙周复查去解 K02 龋齿 4 条 / 种植复查去解 K06 牙龈 1 条 /
     正畸复查去解 K01 阻生 1 条),必须挡住。

生产验证前提:带牙位「种植复查」覆盖 3,893 个牙位,3,434(88.2%)在同一颗牙上
找得到 implant/prosthodontic 的 actual 治疗,患者级 95.2% —— 蕴含关系成立。
剩下 11.8% 正是本规则要救的(外院种的 / 摄入窗口之前 / 治疗记录漏牙位)。

影响面:窄口径 19 条候选 → 类目闸后实际约 10 条生效。宽口径(subtype 含「复」)
391 条**未采纳** —— 那 372 条差额混着"一颗从没治过的牙上做了常规复查",
误销的代价不对称(少召不报错)。全口场景(K05/K07)实测无一命中,gapWhere 不动。

测试:新增 39 条(词形闸 24 / 类目闸 7 / 表自洽 3 等),全量 1526 条通过;
type-check 17 个错为 main 既有,本次引入 0 个。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent cccb88e8
......@@ -8,6 +8,7 @@ import {
RESTORATION_INELIGIBLE_DX_NAMES,
STRUCTURAL_DX_CODE_LIST,
type DxTreatmentRule,
REVIEW_IMPLIES_TREATMENT,
} from '@pac/types';
/**
......@@ -207,6 +208,30 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
? Prisma.sql`AND COALESCE(sig.content->>'name_zh','') NOT LIKE '%先天%'`
: Prisma.empty;
// (a''''') 「复查/复诊」= 修复体/矫治器在位的**证据**(不是治疗本身)。
// 宋志宏 TS0M001982 牙46;36;37:种植冠在位、按约复查,却被判"缺失牙未启动修复" ——
// 唯一带牙位的证据「种植复查」落在 review 类目,而 review 被刻意排除在 resolver 之外。
// ⛔ 只收 REVIEW_IMPLIES_TREATMENT(治疗词 + 复查/复诊),且**按类目过滤** ——
// 整类 review 放进来会误销:生产带牙位 4.4 万条里「观察」11,165 / 「无治疗」623,
// 那些恰恰是"还没治"。跨类目也不行(牙周复查 ≠ 补了龋)。
// 时间方向同治疗排除(afterDxFor):复查发生在信号之前不算数。
const reviewRules = REVIEW_IMPLIES_TREATMENT.filter((r) =>
(resolverCats as readonly string[]).includes(r.category),
);
const reviewImpliesBranch = reviewRules.length
? Prisma.sql`
UNION
SELECT rvt AS t
FROM patient_facts rvx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rvx.content->>'tooth_position'`)}) AS rvt
WHERE rvx.patient_id = p.id
AND rvx.type = 'treatment_record' AND rvx.kind = 'actual'
AND rvx.status IN ('active', 'fulfilled')
AND rvx.content->>'category' = 'review'
AND rvx.content->>'subtype' ~ ${reviewRules.map((r) => r.pattern).join('|')}
${afterDxFor('rvx')}`
: Prisma.empty;
const resolvedTeethSql = Prisma.sql`
(SELECT COALESCE(array_agg(DISTINCT t), ARRAY[]::text[]) FROM (
-- (a) 治疗家族 resolver(afterDx):同牙诊断后做了 resolverCats 家族里任一治疗
......@@ -304,6 +329,7 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
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'
${reviewImpliesBranch}
${orthoExtractBranch}
${deferDxBranch}
) u)`;
......
/**
* REVIEW_IMPLIES_TREATMENT —— 「复查/复诊」作为修复体在位的**证据**。
*
* 守两条闸,任何一条松掉都会静默误销召回(少召不报错,一线只会觉得"系统没提醒过"):
* ① 词形闸:必须「治疗词 + 复查/复诊」——「正畸复诊」(在做)vs「正畸会诊」(还在谈)只差一字
* ② 类目闸:必须与 resolverCategoriesFor 交集 ——「牙周复查」不能解 K02 龋齿
*
* 正则用 PG 语义书写,这里用 JS RegExp 近似校验词形(两者对本表用到的语法等价)。
*
* 跑:
* pnpm test -- review-implies-treatment
*/
import { REVIEW_IMPLIES_TREATMENT, resolverCategoriesFor } from '@pac/types';
/** 某个 subtype 命中哪些规则 */
const rulesFor = (subtype: string) =>
REVIEW_IMPLIES_TREATMENT.filter((r) => new RegExp(r.pattern).test(subtype));
/** 模拟消费方:该诊断码下,这个 subtype 会不会解除缺口 */
const resolves = (code: string, subtype: string): boolean => {
const cats = resolverCategoriesFor(code) as readonly string[];
return rulesFor(subtype).some((r) => cats.includes(r.category));
};
describe('REVIEW_IMPLIES_TREATMENT · 词形闸', () => {
it.each([
['种植复查', 'implant'],
['牙周复查', 'periodontic'],
['正畸复诊', 'orthodontic'],
['正畸复查', 'orthodontic'],
['保持器复诊', 'orthodontic'],
])('生产实有词「%s」→ %s', (subtype, cat) => {
expect(rulesFor(subtype).map((r) => r.category)).toContain(cat);
});
// ⛔ 这些是生产带牙位 review 里的大头,收进来就是误销
it.each([
'观察', '暂观', '观察,必要时拔除', '暂观,必要时拔除', '随访观察', '观察随访', '随诊观察',
'无治疗', '初诊检查', '检查', '方案沟通', '沟通治疗方案', '听方案', '取资料', '缴费',
'转诊', '请全科医生会诊', '治疗中',
])('「%s」不命中任何规则', (subtype) => {
expect(rulesFor(subtype)).toHaveLength(0);
});
// ⛔ 泛指复查也不收 —— 它不指明治疗对象(宽口径 391 条里的争议区)
it.each(['复查', '常规复查', '定期复查'])('泛指「%s」不命中', (subtype) => {
expect(rulesFor(subtype)).toHaveLength(0);
});
// ⛔ 一字之差,语义相反
it.each(['正畸会诊', '正畸咨询', '正畸检查'])('「%s」不命中(还在谈,没在做)', (subtype) => {
expect(rulesFor(subtype)).toHaveLength(0);
});
});
describe('REVIEW_IMPLIES_TREATMENT · 类目闸', () => {
it('K08 缺失牙 + 种植复查 → 解除(宋志宏 TS0M001982 牙46;36;37)', () => {
expect(resolves('K08', '种植复查')).toBe(true);
});
it('K04 根尖周炎 + 种植复查 → 解除(种了牙就没牙髓了)', () => {
expect(resolves('K04', '种植复查')).toBe(true);
});
// ⛔ 生产实测的 9 条跨类目错配,逐条锁死
it.each([
['K02', '牙周复查', '洗牙不补龋'],
['K03', '牙周复查', '洗牙不修牙体'],
['K08', '牙周复查', '洗牙不修缺牙'],
['K06', '种植复查', 'K06 只认牙周/外科'],
['K01', '正畸复查', 'K01 走结构家族,不含正畸'],
])('%s + %s → 不解除(%s)', (code, subtype) => {
expect(resolves(code, subtype)).toBe(false);
});
});
describe('REVIEW_IMPLIES_TREATMENT · 表自身自洽', () => {
it('每条 pattern 都能编译成正则', () => {
for (const r of REVIEW_IMPLIES_TREATMENT) {
expect(() => new RegExp(r.pattern)).not.toThrow();
}
});
it('每条都写了 why(改表的人得说明临床依据)', () => {
for (const r of REVIEW_IMPLIES_TREATMENT) {
expect(r.why.length).toBeGreaterThan(4);
}
});
it('每条 pattern 都强制要求出现 复查/复诊', () => {
for (const r of REVIEW_IMPLIES_TREATMENT) {
expect(r.pattern).toMatch(/复查\|复诊/);
}
});
});
......@@ -421,6 +421,46 @@ const STRUCTURAL_DX_CODES = new Set<string>([
*
* 单一真理源:召回 scenario 的 ⑤a 排除闸 + 牙位事实 oracle 对账 共用此函数,口径不漂移。
*/
/**
* 「复查/复诊」→ 它蕴含的治疗类目(**证据,不是治疗**)。
*
* ── 为什么需要 ──
* 宋志宏 TS0M001982 牙46;36;37 三颗种植冠在位、正在按约复查,却被判「缺失牙未启动修复」。
* 他那次就诊里能证明修复体存在的四处表述(诊断「种植术后」/ 本次治疗「种植复查」/
* 检查所见「种植冠无松动」/ 主诉「种植戴牙术后1年余」)PAC 一处也没接住 ——
* `review` 类目被**刻意**排除在 [[STRUCTURAL_RESOLVER_CATEGORIES]] 之外(见其上方注释)。
* 那个排除是对的:review 是杂物抽屉,生产带牙位的 4.4 万条里「观察」11,165、
* 「观察,必要时拔除」928、「无治疗」623 —— 这些恰恰说明**还没治**,整类放进 resolver 会误销。
*
* ── 判据:复查的**对象**必须先存在 ──
* 「种植复查」蕴含种植体在位,「保持器复诊」蕴含正畸做过 —— 这是逻辑蕴含,不是统计推断。
* 生产验证:带牙位的「种植复查」覆盖 3,893 个牙位,其中 3,434(88.2%)在**同一颗牙**上
* 找得到 implant/prosthodontic 的 actual 治疗;患者级 95.2%。剩下 11.8% 正是本规则要救的
* (外院种的 / 摄入窗口之前 / 治疗记录漏牙位)。
*
* ⛔ **必须同时匹配「治疗词 + 复查/复诊」**:「正畸复诊」(在做正畸)与「正畸会诊」(还在谈)
* 只差一个字,语义相反;生产各 35 / 103 条。光匹配「正畸」会把会诊咨询一起网进来。
* ⛔ **必须按类目匹配**,不能一律放行:生产实测 19 条候选里 9 条是跨类目错配 ——
* 「牙周复查」去解 K02 龋齿(洗牙不补龋,4 条)、「种植复查」去解 K06 牙龈疾患(1 条)、
* 「正畸复查」去解 K01 阻生牙(1 条)。消费方须用 [[resolverCategoriesFor]] 过滤本表。
* ⛔ 「观察 / 暂观 / 无治疗 / 初诊检查 / 方案沟通」等**刻意不收** —— 它们是"还没治"的证据。
*
* 消费方:potential-treatment-gap.sql 的 resolvedTeethSql(牙位级)。
* 生产实测无一条落在 K05/K07 全口场景,故全口路径(gapWhere 的 NOT EXISTS)不接本表。
*/
export const REVIEW_IMPLIES_TREATMENT: ReadonlyArray<{
/// PG 正则(用于 `subtype ~ pattern`)
pattern: string;
/// 命中即视为该类目治疗已完成
category: PACTreatmentCategory;
why: string;
}> = [
{ pattern: '种植[^,,;;]*(复查|复诊)', category: 'implant', why: '种植体不存在就没有种植复查' },
{ pattern: '(修复|冠|桥|义齿|戴牙)[^,,;;]*(复查|复诊)', category: 'prosthodontic', why: '修复体在位才谈得上复查' },
{ pattern: '(正畸|保持器)[^,,;;]*(复查|复诊)', category: 'orthodontic', why: '矫治/保持阶段 ≠ 正畸会诊咨询' },
{ pattern: '牙周[^,,;;]*(复查|复诊)', category: 'periodontic', why: '牙周复查蕴含基础治疗做过' },
];
export function resolverCategoriesFor(code: string): readonly PACTreatmentCategory[] {
if (STRUCTURAL_DX_CODES.has(code)) return STRUCTURAL_RESOLVER_CATEGORIES;
const rule = lookupDxTreatment(code);
......
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