Commit d091ed21 by luoqi

test(gap): 对拍工具补画像消费方(--persona=N)

画像是**逐患者**调用(全量 54.7 万次),SQL 形态与召回不同(scope 恒 1 行),
单次开销会被放大 54.7 万倍 —— 必须单独验,而且要比耗时分布不只比结果。
只读不写库:直接调 selectForPatient 两次比 gap 列表。

selectForPatient 加可选 variant 入参(只给对拍用;生产路径不传,走环境开关)。

本地实测(2000 位有 active 信号的患者):
  零差异(其中 1441 位有 gap)
  耗时/患者 legacy p50=7ms p95=18ms 合计=16.1s
            setbased p50=6ms p95=16ms 合计=13.9s  ← 不劣化

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 772d7353
Pipeline #3617 failed in 0 seconds
......@@ -28,6 +28,7 @@ import { lookupDxTreatment } from '@pac/types';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { TreatmentInitiationRecallScenario } from '../modules/plan/engine/scenarios/treatment-initiation-recall.scenario';
import { PotentialTreatmentSelector } from '../modules/clinical-gap/potential-treatment.selector';
import type { ScenarioScope } from '../modules/plan/engine/scenario.interface';
import type { GapVariant } from '../modules/clinical-gap/potential-treatment-gap.sql';
......@@ -37,16 +38,19 @@ interface Args {
self: boolean;
bench: boolean;
samples: number;
/// >0 时改跑画像消费方对拍:抽 N 位患者,逐个跑两版 selectForPatient 比 gap 列表
persona: number;
}
function parseArgs(argv: string[]): Args {
const a: Args = { host: 'demo', self: false, bench: false, samples: 20 };
const a: Args = { host: 'demo', self: false, bench: false, samples: 20, persona: 0 };
for (const s of argv) {
if (s.startsWith('--host=')) a.host = s.slice('--host='.length);
else if (s.startsWith('--sub=')) a.sub = s.slice('--sub='.length);
else if (s.startsWith('--samples=')) a.samples = Number(s.slice('--samples='.length)) || 20;
else if (s === '--self') a.self = true;
else if (s === '--bench') a.bench = true;
else if (s.startsWith('--persona=')) a.persona = Number(s.slice('--persona='.length)) || 0;
}
return a;
}
......@@ -85,6 +89,79 @@ async function bootstrap(): Promise<number> {
// 🔴 now 固定一次:两版必须拿同一个时间锚,否则 cooldown 边界上的信号会来回抖。
const now = new Date();
// ══ 画像消费方对拍(第二个 buildGapCore 消费方)══
// 画像是**逐患者**调用,SQL 形态与召回不同(scope 恒 1 行),必须单独验。
// 只读、不写库:直接调 selectForPatient 两次比返回值。
if (args.persona > 0) {
const selector = app.get(PotentialTreatmentSelector);
// 抽样偏向"有诊断信号的患者",否则大多数抽中的人两版都返回空数组,验了个寂寞。
const sample = await prisma.$queryRaw<{ id: string; tenant_id: string }[]>(Prisma.sql`
SELECT p.id, p.tenant_id
FROM patients p
WHERE p.host_id = ${host.id}::uuid AND p.active = true
AND EXISTS (
SELECT 1 FROM patient_facts f
WHERE f.patient_id = p.id AND f.status = 'active'
AND f.type IN ('diagnosis_record','recommendation_record')
)
ORDER BY p.id
LIMIT ${args.persona}`);
out(`▶ 画像对拍:抽样 ${sample.length} 位患者(有 active 诊断/建议信号)`);
let diffN = 0;
let withGap = 0;
const msLegacy: number[] = [];
const msRight: number[] = [];
for (const p of sample) {
const codes = await prisma.$queryRaw<{ code: string }[]>(Prisma.sql`
SELECT DISTINCT f.content->>'code' AS code FROM patient_facts f
WHERE f.patient_id = ${p.id}::uuid AND f.status = 'active'
AND f.type IN ('diagnosis_record','recommendation_record')
AND f.content->>'code' IS NOT NULL`);
const activeCodes = new Set(codes.map((c) => c.code));
const base = { hostId: host.id, tenantId: p.tenant_id, patientId: p.id, now, activeCodes };
// 🔴 画像是逐患者调用(全量 54.7 万次),单次开销会被放大 54.7 万倍 ——
// 所以这里除了比结果,还必须比**每次调用的耗时分布**(p50/p95)。
const t0 = Date.now();
const l = await selector.selectForPatient({ ...base, variant: 'legacy' });
const t1 = Date.now();
const r = await selector.selectForPatient({
...base,
variant: args.self ? 'legacy' : 'setbased',
});
msLegacy.push(t1 - t0);
msRight.push(Date.now() - t1);
const key = (g: { primaryCode: string; factId: string; tooth: string | null }): string =>
`${g.primaryCode}#${g.factId}#${g.tooth ?? ''}`;
const ls = l.map(key).sort().join('|');
const rs = r.map(key).sort().join('|');
if (l.length) withGap++;
if (ls !== rs) {
diffN++;
if (diffN <= args.samples) {
bad_(` patient=${p.id}`);
bad_(` legacy : ${ls || '(空)'}`);
bad_(` setbased: ${rs || '(空)'}`);
}
}
}
const pct = (a: number[], q: number): number => {
const v = [...a].sort((x, y) => x - y);
return v[Math.min(v.length - 1, Math.floor(v.length * q))] ?? 0;
};
const sum = (a: number[]): number => a.reduce((x, y) => x + y, 0);
out(
` 耗时/患者 legacy p50=${pct(msLegacy, 0.5)}ms p95=${pct(msLegacy, 0.95)}ms 合计=${sum(msLegacy)}ms | ` +
`另一版 p50=${pct(msRight, 0.5)}ms p95=${pct(msRight, 0.95)}ms 合计=${sum(msRight)}ms`,
);
if (diffN === 0) {
out(` ✅ 画像零差异(${sample.length} 位,其中 ${withGap} 位有 gap)`);
} else {
bad++;
bad_(` ❌ 画像 ${diffN}/${sample.length} 位患者不一致`);
}
return bad === 0 ? 0 : 1;
}
const entries = Object.entries(TreatmentInitiationRecallScenario.SUB_SCENARIOS).filter(
([k]) => !args.sub || k === args.sub,
);
......
......@@ -7,6 +7,7 @@ import {
GAP_FLAGS_BY_PRIMARY,
GAP_PRIMARY_GROUPS,
gapVariant,
type GapVariant,
} from './potential-treatment-gap.sql';
/**
......@@ -33,8 +34,11 @@ export class PotentialTreatmentSelector {
patientId: string;
now: Date;
activeCodes: Set<string>;
/// 仅对拍工具用:强制 gap 计算形态。生产路径不传,走 gapVariant() 的环境开关。
variant?: GapVariant;
}): Promise<PotentialGap[]> {
const { hostId, tenantId, patientId, now, activeCodes } = opts;
const variant = opts.variant ?? gapVariant();
const out: PotentialGap[] = [];
for (const [primaryCode, group] of Object.entries(GAP_PRIMARY_GROUPS)) {
......@@ -45,7 +49,7 @@ export class PotentialTreatmentSelector {
if (!rule) continue;
const resolverCats = resolverCategoriesFor(primaryCode) as readonly string[];
const cfgFlags = GAP_FLAGS_BY_PRIMARY[primaryCode] ?? {};
const gap = buildGapCore({ rule, cfgFlags, allCodes, resolverCats, variant: gapVariant() });
const gap = buildGapCore({ rule, cfgFlags, allCodes, resolverCats, variant });
// 投影列(两形态共用;tooth 单列,取法不同)
const projection = Prisma.sql`
......
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