Commit 8a58bb72 by luoqi

test(gap): 对拍工具补交互路径(--single=N)—— 详情页「刷新」直接面向用户

plan.controller recomputeForPatient 是 HTTP 端点,走的就是这套召回 SQL 的
单患者路径(scope.patientId)。批量慢是运维问题,这条慢是用户当场感受得到的问题,
之前只测了批量和画像,漏了它。

按「一位患者跑完 11 个子场景」= 一次刷新的真实代价来计时,同时逐 signal×tooth 比结果。
本地(30K):零差异;单查询 p95 26→17ms;一次刷新 103→71ms。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent aef2d53b
Pipeline #3627 failed in 0 seconds
...@@ -40,10 +40,14 @@ interface Args { ...@@ -40,10 +40,14 @@ interface Args {
samples: number; samples: number;
/// >0 时改跑画像消费方对拍:抽 N 位患者,逐个跑两版 selectForPatient 比 gap 列表 /// >0 时改跑画像消费方对拍:抽 N 位患者,逐个跑两版 selectForPatient 比 gap 列表
persona: number; persona: number;
/// >0 时改跑**交互路径**对拍:抽 N 位患者,按 scope.patientId 单患者跑召回 SQL 两版
/// —— 详情页「刷新」(plan.controller recomputeForPatient)走的就是这条,直接面向用户,
/// 批量快不快是运维的事,这条慢了是用户当场感受得到的。
single: number;
} }
function parseArgs(argv: string[]): Args { function parseArgs(argv: string[]): Args {
const a: Args = { host: 'demo', self: false, bench: false, samples: 20, persona: 0 }; const a: Args = { host: 'demo', self: false, bench: false, samples: 20, persona: 0, single: 0 };
for (const s of argv) { for (const s of argv) {
if (s.startsWith('--host=')) a.host = s.slice('--host='.length); 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('--sub=')) a.sub = s.slice('--sub='.length);
...@@ -51,6 +55,7 @@ function parseArgs(argv: string[]): Args { ...@@ -51,6 +55,7 @@ function parseArgs(argv: string[]): Args {
else if (s === '--self') a.self = true; else if (s === '--self') a.self = true;
else if (s === '--bench') a.bench = true; else if (s === '--bench') a.bench = true;
else if (s.startsWith('--persona=')) a.persona = Number(s.slice('--persona='.length)) || 0; else if (s.startsWith('--persona=')) a.persona = Number(s.slice('--persona='.length)) || 0;
else if (s.startsWith('--single=')) a.single = Number(s.slice('--single='.length)) || 0;
} }
return a; return a;
} }
...@@ -89,6 +94,85 @@ async function bootstrap(): Promise<number> { ...@@ -89,6 +94,85 @@ async function bootstrap(): Promise<number> {
// 🔴 now 固定一次:两版必须拿同一个时间锚,否则 cooldown 边界上的信号会来回抖。 // 🔴 now 固定一次:两版必须拿同一个时间锚,否则 cooldown 边界上的信号会来回抖。
const now = new Date(); const now = new Date();
// ══ 交互路径对拍(详情页「刷新」:单患者召回)══
// 批量慢是运维问题,这条慢是**用户当场感受得到**的问题 —— 必须单独量。
if (args.single > 0) {
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.single}`);
out(`▶ 交互路径对拍(单患者召回):抽样 ${sample.length} 位`);
const entriesAll = Object.entries(TreatmentInitiationRecallScenario.SUB_SCENARIOS);
const msL: number[] = [];
const msR: number[] = [];
let diffN = 0;
for (const p of sample) {
// 一位患者 = 跑完 11 个子场景(详情页刷新的真实代价)
const scope1: ScenarioScope = {
hostId: host.id,
tenantId: p.tenant_id,
now,
patientId: p.id,
};
for (const [, cfg] of entriesAll) {
const rule = lookupDxTreatment(cfg.primaryCode);
if (!rule) continue;
const lSql = scenario.buildScenarioSql(scope1, cfg.primaryCode, rule, 'legacy');
const rSql = scenario.buildScenarioSql(
scope1,
cfg.primaryCode,
rule,
args.self ? 'legacy' : 'setbased',
);
const runOne = async (sql: Prisma.Sql): Promise<{ ms: number; key: string }> => {
const t = Date.now();
const rows = await prisma.$queryRaw<{ patient_id: string; signal_fact_id: string; tooth: string | null }[]>(
Prisma.sql`SELECT patient_id, signal_fact_id, tooth FROM (${sql}) q ORDER BY 2, 3`,
);
return {
ms: Date.now() - t,
key: rows.map((x) => `${x.signal_fact_id}#${x.tooth ?? ''}`).join('|'),
};
};
const a1 = await runOne(lSql);
const b1 = await runOne(rSql);
msL.push(a1.ms);
msR.push(b1.ms);
if (a1.key !== b1.key) {
diffN++;
if (diffN <= args.samples) {
bad_(` patient=${p.id} sub=${cfg.primaryCode}`);
bad_(` legacy : ${a1.key || '(空)'}`);
bad_(` setbased: ${b1.key || '(空)'}`);
}
}
}
}
const pct = (arr: number[], q: number): number => {
const v = [...arr].sort((x, y) => x - y);
return v[Math.min(v.length - 1, Math.floor(v.length * q))] ?? 0;
};
const sum = (arr: number[]): number => arr.reduce((x, y) => x + y, 0);
out(
` 单查询 legacy p50=${pct(msL, 0.5)}ms p95=${pct(msL, 0.95)}ms | ` +
`另一版 p50=${pct(msR, 0.5)}ms p95=${pct(msR, 0.95)}ms`,
);
out(
` 一次「刷新」(11 个子场景合计) legacy≈${Math.round(sum(msL) / sample.length)}ms | ` +
`另一版≈${Math.round(sum(msR) / sample.length)}ms`,
);
if (diffN === 0) out(` ✅ 交互路径零差异(${sample.length} 位 × 11 子场景)`);
else { bad++; bad_(` ❌ 交互路径 ${diffN} 处不一致`); }
return bad === 0 ? 0 : 1;
}
// ══ 画像消费方对拍(第二个 buildGapCore 消费方)══ // ══ 画像消费方对拍(第二个 buildGapCore 消费方)══
// 画像是**逐患者**调用,SQL 形态与召回不同(scope 恒 1 行),必须单独验。 // 画像是**逐患者**调用,SQL 形态与召回不同(scope 恒 1 行),必须单独验。
// 只读、不写库:直接调 selectForPatient 两次比返回值。 // 只读、不写库:直接调 selectForPatient 两次比返回值。
......
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