Commit 6d3ba7a1 by luoqi

merge: gap 集合式形态 + 对拍工具(默认 legacy,开关未开)→ main

parents 61ba24de 1bde4764
Pipeline #3636 failed in 0 seconds
...@@ -30,6 +30,8 @@ ...@@ -30,6 +30,8 @@
"recompute-persona": "ts-node --transpile-only src/cli/recompute-persona.cli.ts", "recompute-persona": "ts-node --transpile-only src/cli/recompute-persona.cli.ts",
"backfill-plan-labels": "ts-node --transpile-only src/cli/backfill-plan-labels.cli.ts", "backfill-plan-labels": "ts-node --transpile-only src/cli/backfill-plan-labels.cli.ts",
"recompute-persona:prod": "node --max-old-space-size=8192 dist/cli/recompute-persona.cli.js", "recompute-persona:prod": "node --max-old-space-size=8192 dist/cli/recompute-persona.cli.js",
"verify-gap-equivalence": "ts-node --transpile-only src/cli/verify-gap-equivalence.cli.ts",
"verify-gap-equivalence:prod": "node --max-old-space-size=8192 dist/cli/verify-gap-equivalence.cli.js",
"recompute-plans": "ts-node --transpile-only src/cli/recompute-plans.cli.ts", "recompute-plans": "ts-node --transpile-only src/cli/recompute-plans.cli.ts",
"recompute-plans:prod": "node --max-old-space-size=8192 dist/cli/recompute-plans.cli.js", "recompute-plans:prod": "node --max-old-space-size=8192 dist/cli/recompute-plans.cli.js",
"timeline": "ts-node --transpile-only src/cli/timeline.cli.ts", "timeline": "ts-node --transpile-only src/cli/timeline.cli.ts",
......
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { lookupDxTreatment, resolverCategoriesFor } from '@pac/types'; import { lookupDxTreatment, resolverCategoriesFor } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { import {
buildGapCore, buildGapCore,
GAP_FLAGS_BY_PRIMARY, GAP_FLAGS_BY_PRIMARY,
GAP_PRIMARY_GROUPS, GAP_PRIMARY_GROUPS,
gapVariant,
type GapVariant,
} from './potential-treatment-gap.sql'; } from './potential-treatment-gap.sql';
/** /**
...@@ -31,8 +34,11 @@ export class PotentialTreatmentSelector { ...@@ -31,8 +34,11 @@ export class PotentialTreatmentSelector {
patientId: string; patientId: string;
now: Date; now: Date;
activeCodes: Set<string>; activeCodes: Set<string>;
/// 仅对拍工具用:强制 gap 计算形态。生产路径不传,走 gapVariant() 的环境开关。
variant?: GapVariant;
}): Promise<PotentialGap[]> { }): Promise<PotentialGap[]> {
const { hostId, tenantId, patientId, now, activeCodes } = opts; const { hostId, tenantId, patientId, now, activeCodes } = opts;
const variant = opts.variant ?? gapVariant();
const out: PotentialGap[] = []; const out: PotentialGap[] = [];
for (const [primaryCode, group] of Object.entries(GAP_PRIMARY_GROUPS)) { for (const [primaryCode, group] of Object.entries(GAP_PRIMARY_GROUPS)) {
...@@ -43,21 +49,23 @@ export class PotentialTreatmentSelector { ...@@ -43,21 +49,23 @@ export class PotentialTreatmentSelector {
if (!rule) continue; if (!rule) continue;
const resolverCats = resolverCategoriesFor(primaryCode) as readonly string[]; const resolverCats = resolverCategoriesFor(primaryCode) as readonly string[];
const cfgFlags = GAP_FLAGS_BY_PRIMARY[primaryCode] ?? {}; const cfgFlags = GAP_FLAGS_BY_PRIMARY[primaryCode] ?? {};
const gap = buildGapCore({ rule, cfgFlags, allCodes, resolverCats }); const gap = buildGapCore({ rule, cfgFlags, allCodes, resolverCats, variant });
const rows = await this.prisma.$queryRaw<RawGapRow[]>` // 投影列(两形态共用;tooth 单列,取法不同)
SELECT const projection = Prisma.sql`
sig.id AS fact_id, sig.id AS fact_id,
sig.content->>'code' AS code, sig.content->>'code' AS code,
sig.content->>'name_zh' AS name_zh, sig.content->>'name_zh' AS name_zh,
sig.type AS signal_type, sig.type AS signal_type,
${gap.toothOutput} AS tooth,
sig.content->>'confidence' AS confidence, sig.content->>'confidence' AS confidence,
EXTRACT(DAY FROM ${now}::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since, EXTRACT(DAY FROM ${now}::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since,
COALESCE(sig.occurred_at, sig.planned_for) AS anchor_at COALESCE(sig.occurred_at, sig.planned_for) AS anchor_at`;
// ⚠️ 画像是**逐患者**调用(全量 54.7 万次),这里的 scope 恒为 1 个患者 ——
// gap_scope 只有一行,各分支走 (patient_id, type, status) 索引,形态不会退化成全表扫。
const queryBody = (joinAddon: Prisma.Sql, gapAddon: Prisma.Sql): Prisma.Sql => Prisma.sql`
FROM patients p FROM patients p
JOIN patient_facts sig ON sig.patient_id = p.id JOIN patient_facts sig ON sig.patient_id = p.id
${gap.lateralJoin} ${joinAddon}
WHERE p.host_id = ${hostId}::uuid WHERE p.host_id = ${hostId}::uuid
AND p.tenant_id = ${tenantId} AND p.tenant_id = ${tenantId}
AND p.id = ${patientId}::uuid AND p.id = ${patientId}::uuid
...@@ -68,8 +76,26 @@ export class PotentialTreatmentSelector { ...@@ -68,8 +76,26 @@ export class PotentialTreatmentSelector {
AND COALESCE(sig.occurred_at, sig.planned_for) IS NOT NULL AND COALESCE(sig.occurred_at, sig.planned_for) IS NOT NULL
${gap.restorationIneligibleFrag} ${gap.restorationIneligibleFrag}
${gap.congenitalFrag} ${gap.congenitalFrag}
${gap.gapWhere} ${gapAddon}`;
`;
const sb = gap.setBased;
const sql = sb
? Prisma.sql`
WITH gap_cand AS MATERIALIZED (
SELECT ${projection}${sb.candExtraCols}
${queryBody(Prisma.empty, sb.candWhere)}
)${sb.postCtes}
SELECT c.fact_id, c.code, c.name_zh, c.signal_type, c.confidence, c.days_since, c.anchor_at,
${sb.toothOutput} AS tooth
FROM gap_cand c
${sb.remJoin}
WHERE TRUE ${sb.outerWhere}`
: Prisma.sql`
SELECT ${projection},
${gap.toothOutput} AS tooth
${queryBody(gap.lateralJoin, gap.gapWhere)}`;
const rows = await this.prisma.$queryRaw<RawGapRow[]>(sql);
for (const r of rows) { for (const r of rows) {
out.push({ out.push({
primaryCode, primaryCode,
......
/**
* gap 集合式形态的**结构对拍**(纯 SQL 文本层,不连库)
*
* 定位:数据层的等价性由 `pnpm verify-gap-equivalence`(逐 患者×信号×牙位 差分)证明,
* 本 spec 只守一件单元测试能守住的事 —— **两种形态的分支集合不许走散**。
* 典型事故:后来人给 legacy 加了第 14 条 resolved 分支,忘了同步 setbased →
* 线上悄悄少销一类证据 → 静默多召 / 少召。那种漏法 tsc 和现有 spec 全都发现不了,
* 但分支计数会当场炸。
*
* ⚠️ 本 spec 断言的是"两边都改了",不是"改对了"。改完仍必须跑 verify-gap-equivalence。
*/
import { lookupDxTreatment, resolverCategoriesFor } from '@pac/types';
import {
buildGapCore,
GAP_FLAGS_BY_PRIMARY,
GAP_PRIMARY_GROUPS,
} from '../src/modules/clinical-gap/potential-treatment-gap.sql';
const PRIMARY_CODES = Object.keys(GAP_PRIMARY_GROUPS);
const WHOLE_MOUTH = ['K05', 'K07'];
function core(primaryCode: string, variant: 'legacy' | 'setbased') {
const rule = lookupDxTreatment(primaryCode);
if (!rule) throw new Error(`no rule for ${primaryCode}`);
const grp = GAP_PRIMARY_GROUPS[primaryCode];
return buildGapCore({
rule,
cfgFlags: GAP_FLAGS_BY_PRIMARY[primaryCode] ?? {},
allCodes: [...grp.dxCodes, ...grp.recCodes],
resolverCats: resolverCategoriesFor(primaryCode) as readonly string[],
variant,
});
}
const count = (hay: string, needle: RegExp): number => (hay.match(needle) ?? []).length;
describe('gap 集合式 ↔ 逐行形态:结构对拍', () => {
it('variant 默认 legacy;只有显式 setbased 才产出集合式拼装件', () => {
const g = core('K08', 'legacy');
expect(g.setBased).toBeUndefined();
expect(core('K08', 'setbased').setBased).toBeDefined();
});
describe.each(PRIMARY_CODES)('%s', (code) => {
const isWhole = WHOLE_MOUTH.includes(code);
it('全口码不进集合式(原样走 legacy),牙位码必须有 resolved 预聚合链', () => {
const g = core(code, 'setbased');
if (isWhole) {
// 全口场景 legacy 的 lateral 本来就会被 PG 的 useless-left-join removal 摘掉 →
// 集合式零收益;实测硬套进来还慢 2~3 倍(见 buildGapSetBased 里的早退注释)。
expect(g.setBased).toBeUndefined();
} else {
const sb = g.setBased!;
expect(sb.postCtes.sql).toContain('gap_scope');
expect(sb.postCtes.sql).toContain('gap_resolved');
expect(sb.postCtes.sql).toContain('gap_rem');
expect(sb.remJoin.sql).toContain('LEFT JOIN gap_rem');
}
});
if (!WHOLE_MOUTH.includes(code)) {
it('两种形态的 resolved 分支条数必须一致(加分支只改一边 = 静默错召)', () => {
const legacy = core(code, 'legacy').lateralJoin.sql;
const setbased = core(code, 'setbased').setBased!.postCtes.sql;
// legacy 分支用裸 UNION 分隔;setbased 用 UNION ALL(先聚合后去重,不需要 UNION 的排序去重)
const legacyBranches = count(legacy, /\bUNION\b(?!\s+ALL)/g) + 1;
const setBranches = count(setbased, /\bUNION ALL\b/g) + 1;
expect(setBranches).toBe(legacyBranches);
});
it('每个分支都挂了患者收窄(漏一个就全表扫 34GB patient_facts)', () => {
const sql = core(code, 'setbased').setBased!.postCtes.sql;
const branches = sql
.slice(sql.indexOf('FROM ('), sql.indexOf(') sb('))
.split(/\bUNION ALL\b/);
expect(branches.length).toBeGreaterThan(1);
for (const b of branches) {
expect(b).toContain('IN (SELECT patient_id FROM gap_scope)');
}
});
it('gate 列永不为 NULL —— 时间门分支带 IS NOT NULL,无门分支写死 infinity', () => {
const sql = core(code, 'setbased').setBased!.postCtes.sql;
const branches = sql
.slice(sql.indexOf('FROM ('), sql.indexOf(') sb('))
.split(/\bUNION ALL\b/);
for (const b of branches) {
const ungated = b.includes(`'infinity'::timestamptz AS gate`);
const gated = /IS NOT NULL/.test(b);
// 二者必居其一:否则全 NULL 组会被 max() 聚成 NULL、当成"无门恒过"→ 误销 → 静默少召
expect(ungated || gated).toBe(true);
}
});
it('严格 > 只出现在「更晚结构诊断」一条分支上', () => {
const sql = core(code, 'setbased').setBased!.postCtes.sql;
expect(count(sql, /TRUE AS strict/g)).toBe(1);
expect(sql).toContain('CASE WHEN r.strict THEN r.gate > c.gap_anchor ELSE r.gate >= c.gap_anchor END');
});
it('牙位顺序与重复原样保留(tooth 串会落进 plan_reasons 给客服看)', () => {
const sql = core(code, 'setbased').setBased!.postCtes.sql;
expect(sql).toContain('WITH ORDINALITY');
expect(sql).toContain('array_agg(u.x ORDER BY u.ord)');
});
it('gap_rem 无行要补空数组,不能留 NULL', () => {
const sb = core(code, 'setbased').setBased!;
expect(sb.toothOutput.sql).toContain("COALESCE(gap_rem.remaining_teeth, ARRAY[]::text[])");
expect(sb.outerWhere.sql).toContain("COALESCE(gap_rem.remaining_teeth, ARRAY[]::text[])");
});
}
});
it('病历号等值相关(整颌活动义齿)只在 K08 出现,且走 enc 列而非时间门', () => {
const k08 = core('K08', 'setbased').setBased!.postCtes.sql;
expect(k08).toContain("adx.content->>'emr_external_id' AS enc");
expect(k08).toContain("r.enc IS NULL OR r.enc = c.gap_sig_enc");
const k02 = core('K02', 'setbased').setBased!.postCtes.sql;
expect(k02).not.toContain("AS enc,\n FALSE AS strict");
expect(count(k02, /adx\./g)).toBe(0);
});
it('「建议优先于诊断」分支只对 diagnosis_record 信号生效(ndx 标志)', () => {
const sql = core('K08', 'setbased').setBased!.postCtes.sql;
expect(count(sql, /TRUE AS ndx/g)).toBe(1);
expect(sql).toContain("NOT r.ndx OR c.gap_sig_type = 'diagnosis_record'");
});
it('牙位级规则若被加上 excludeIfEverTreated,集合式必须直接炸而不是静默算错', () => {
const rule = { ...lookupDxTreatment('K08')!, excludeIfEverTreated: true };
expect(() =>
buildGapCore({
rule,
cfgFlags: GAP_FLAGS_BY_PRIMARY.K08,
allCodes: ['K08'],
resolverCats: resolverCategoriesFor('K08') as readonly string[],
variant: 'setbased',
}),
).toThrow(/excludeIfEverTreated/);
});
});
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