Commit d6b0fa9d by luoqi

merge: plan 三层耗时日志 + 义齿按颌预过滤 → main

补的是常态可观测性:2026-08-29 生产 plan 段从 12 分钟涨到 159 分钟仍未跑完,
而引擎中间什么都不打,只能临时起 pg_stat_activity 采样器反推。
现在每个子场景一行(SQL 与 Node 后处理分开计时)+ 每轮阶段耗时一行。

同时带上义齿按颌分支的预过滤(逻辑恒等,单患者路径实测 16.7×)——
它针对的正是本次新增的两条 exam_findings 分支,是生产回归的头号嫌疑。
parents ec5eee72 4ac764b6
Pipeline #3613 failed in 0 seconds
...@@ -25,6 +25,7 @@ import { ...@@ -25,6 +25,7 @@ import {
ARCH_DENTURE_UPPER_RE, ARCH_DENTURE_UPPER_RE,
ARCH_DENTURE_LOWER_RE, ARCH_DENTURE_LOWER_RE,
ARCH_DENTURE_INTENT_EXCLUDE_RE, ARCH_DENTURE_INTENT_EXCLUDE_RE,
ARCH_DENTURE_PREFILTER_RE,
UPPER_ARCH_FIRST_DIGITS_RE, UPPER_ARCH_FIRST_DIGITS_RE,
LOWER_ARCH_FIRST_DIGITS_RE, LOWER_ARCH_FIRST_DIGITS_RE,
} from '@pac/types'; } from '@pac/types';
...@@ -387,6 +388,14 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces { ...@@ -387,6 +388,14 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
WHERE adx.patient_id = p.id WHERE adx.patient_id = p.id
AND adx.type = 'emr_record' AND adx.status IN ('active', 'fulfilled') AND adx.type = 'emr_record' AND adx.status IN ('active', 'fulfilled')
AND adx.content->>'exam_findings' ~ '^\\[' AND adx.content->>'exam_findings' ~ '^\\['
-- ⚡ 预过滤(纯剪枝,逻辑恒等):上下颌两条 RE 都要求句中出现「义齿|假牙」,
-- 整段文本里一个都没有时任何条目都不可能命中 → 先筛掉整份病历,不进 LATERAL 展开。
-- 2026-08-29 测试库实测:exam_findings 是数组的 emr 事实 1,519,829 份,含此二词的
-- 仅 7,396 份(0.49%)。按患者相关子查询形态(本分支的实际形态)300 患者样本:
-- 832.7ms → 49.7ms,**快 16.7 倍**,结果一致。
-- ⛔ 别拿「全表扫」去验证 —— 那个形态两边都 81 秒看不出差别,照着那个数会误删本行。
-- ⛔ 改 ARCH_DENTURE_UPPER_RE / LOWER_RE 时必须同步核对本词仍是它们的必要条件。
AND adx.content->>'exam_findings' ~ ${ARCH_DENTURE_PREFILTER_RE}
-- 🔴 只认**信号诊断那一份病历**:同一份里医生同时写下"缺失"与"有义齿",用后者补前者 -- 🔴 只认**信号诊断那一份病历**:同一份里医生同时写下"缺失"与"有义齿",用后者补前者
-- 没争议;跨次就多一层"这中间会不会变了"的推断。 -- 没争议;跨次就多一层"这中间会不会变了"的推断。
AND adx.content->>'emr_external_id' = sig.content->>'source_encounter_external_id' AND adx.content->>'emr_external_id' = sig.content->>'source_encounter_external_id'
......
...@@ -241,6 +241,11 @@ export class PlanEngineService { ...@@ -241,6 +241,11 @@ export class PlanEngineService {
); );
} }
// ⏱ 三段计时(场景 / 预取 / 写入)—— 常态开启,每轮 1 行。
// ⛔ 别删:2026-08-29 生产 plan 段从 12 分钟涨到 159 分钟仍未跑完,而引擎从
// 「▶ Running engine」到最后的统计块之间**什么都不打**,整轮是个黑盒 ——
// 只能靠临时起 pg_stat_activity 采样器反推在哪一段。有这行就不用再猜。
const tSelect = Date.now();
// 1. 各 scenario 跑 selector,汇总 hits // 1. 各 scenario 跑 selector,汇总 hits
const hitsByPatient = new Map<string, ScenarioHitWithKey[]>(); const hitsByPatient = new Map<string, ScenarioHitWithKey[]>();
for (const sc of this.scenarios) { for (const sc of this.scenarios) {
...@@ -265,9 +270,13 @@ export class PlanEngineService { ...@@ -265,9 +270,13 @@ export class PlanEngineService {
// → 逐患复用**同一** upsert 判定(结果等价)→ 写操作**分块并发** → PlanGenerationLog 收集后 // → 逐患复用**同一** upsert 判定(结果等价)→ 写操作**分块并发** → PlanGenerationLog 收集后
// 一次性 createMany(每患仍一行,审计/监控口径不变,只是写法从 12.5万次 → 几次)。 // 一次性 createMany(每患仍一行,审计/监控口径不变,只是写法从 12.5万次 → 几次)。
// 注:selectHits 的全表扫不在本优化内(时间规则随时可改,每轮必须全量重选才正确)。 // 注:selectHits 的全表扫不在本优化内(时间规则随时可改,每轮必须全量重选才正确)。
const selectMs = Date.now() - tSelect;
const patientIds = [...hitsByPatient.keys()]; const patientIds = [...hitsByPatient.keys()];
const tPrefetch = Date.now();
const { latestByPatient, snoozedByPatient, personaByPatient, lastVisitClinicByPatient, touchedPlanIds } = const { latestByPatient, snoozedByPatient, personaByPatient, lastVisitClinicByPatient, touchedPlanIds } =
await this.prefetchForBatch(scope, patientIds, now); await this.prefetchForBatch(scope, patientIds, now);
const prefetchMs = Date.now() - tPrefetch;
const tWrite = Date.now();
const EMPTY_SNOOZE = new Map<string, Date>(); const EMPTY_SNOOZE = new Map<string, Date>();
const logRows: Prisma.PlanGenerationLogCreateManyInput[] = []; const logRows: Prisma.PlanGenerationLogCreateManyInput[] = [];
// ⭐ 2026-07-26:写操作从「每 N 个一批 await Promise.all」的**栅栏**改成连续调度的 // ⭐ 2026-07-26:写操作从「每 N 个一批 await Promise.all」的**栅栏**改成连续调度的
...@@ -413,6 +422,10 @@ export class PlanEngineService { ...@@ -413,6 +422,10 @@ export class PlanEngineService {
this.logger.warn(`[标签] 补齐失败(不阻断生成,夜间刷新会兜住):${e instanceof Error ? e.message : e}`); this.logger.warn(`[标签] 补齐失败(不阻断生成,夜间刷新会兜住):${e instanceof Error ? e.message : e}`);
} }
this.logger.log(
`[plan] 阶段耗时 场景=${selectMs}ms 预取=${prefetchMs}ms 写入=${Date.now() - tWrite}ms ` +
`命中患者=${patientIds.length} 总计=${Date.now() - startedAt.getTime()}ms`,
);
return { return {
scenariosRun: this.scenarios.length, scenariosRun: this.scenarios.length,
patientsHit: hitsByPatient.size, patientsHit: hitsByPatient.size,
......
...@@ -513,7 +513,13 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -513,7 +513,13 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
`[dump-sql-params] ${JSON.stringify(scenarioSql.values)}`, `[dump-sql-params] ${JSON.stringify(scenarioSql.values)}`,
); );
} }
// ⏱ 分段计时 —— SQL 与 Node 后处理**分开算**,这是本组日志最关键的一格:
// 2026-08-29 排查时最大的困难,就是知道 plan 段慢、却分不清慢在 SQL 还是慢在 Node,
// 只能靠采样 pg_stat_activity 反推。有了这两个数,看一眼日志就知道该往哪查。
const sqlStart = Date.now();
const rows: HitRow[] = await this.prisma.$queryRaw(scenarioSql); const rows: HitRow[] = await this.prisma.$queryRaw(scenarioSql);
const sqlMs = Date.now() - sqlStart;
const postStart = Date.now();
// ⭐ 同 patient 同 sub_scenario 的多 sig 按 tooth-overlap 合并(union-find) // ⭐ 同 patient 同 sub_scenario 的多 sig 按 tooth-overlap 合并(union-find)
// 跟 chain-composer 的 bucket 合并口径一致 → reason 与 chain 1:1 对齐 // 跟 chain-composer 的 bucket 合并口径一致 → reason 与 chain 1:1 对齐
...@@ -637,6 +643,14 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -637,6 +643,14 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
priorityBreakdown: breakdown, priorityBreakdown: breakdown,
}); });
} }
// ⏱ 每个子场景一行 —— 常态开启(每轮 11 行,可忽略)。
// ⛔ 别删:没有它,一轮跑两小时也只知道总体慢,既不知道是哪个子场景、
// 也不知道是 SQL 还是后处理慢。2026-08-29 生产 plan 段从 12 分钟涨到 159 分钟
// 仍未跑完,是临时加采样器才定位到子场景粒度的 —— 那种事不该再来一次。
this.logger.log(
`[recall] sub=${subKey} code=${cfg.primaryCode} ` +
`sql=${sqlMs}ms rows=${rows.length} post=${Date.now() - postStart}ms hits=${hits.length}`,
);
return hits; return hits;
} }
......
...@@ -15,6 +15,7 @@ import { ...@@ -15,6 +15,7 @@ import {
ARCH_DENTURE_UPPER_RE, ARCH_DENTURE_UPPER_RE,
ARCH_DENTURE_LOWER_RE, ARCH_DENTURE_LOWER_RE,
ARCH_DENTURE_INTENT_EXCLUDE_RE, ARCH_DENTURE_INTENT_EXCLUDE_RE,
ARCH_DENTURE_PREFILTER_RE,
UPPER_ARCH_FIRST_DIGITS_RE, UPPER_ARCH_FIRST_DIGITS_RE,
LOWER_ARCH_FIRST_DIGITS_RE, LOWER_ARCH_FIRST_DIGITS_RE,
} from '@pac/types'; } from '@pac/types';
...@@ -23,6 +24,7 @@ import { GAP_FLAGS_BY_PRIMARY } from '../src/modules/clinical-gap/potential-trea ...@@ -23,6 +24,7 @@ import { GAP_FLAGS_BY_PRIMARY } from '../src/modules/clinical-gap/potential-trea
const UP = new RegExp(ARCH_DENTURE_UPPER_RE); const UP = new RegExp(ARCH_DENTURE_UPPER_RE);
const LOW = new RegExp(ARCH_DENTURE_LOWER_RE); const LOW = new RegExp(ARCH_DENTURE_LOWER_RE);
const INTENT = new RegExp(ARCH_DENTURE_INTENT_EXCLUDE_RE); const INTENT = new RegExp(ARCH_DENTURE_INTENT_EXCLUDE_RE);
const PRE = new RegExp(ARCH_DENTURE_PREFILTER_RE);
/** 模拟:该句判出哪几颌有义齿(未发生词一票否决) */ /** 模拟:该句判出哪几颌有义齿(未发生词一票否决) */
const arches = (msg: string): string[] => { const arches = (msg: string): string[] => {
...@@ -125,3 +127,42 @@ describe('表自身自洽', () => { ...@@ -125,3 +127,42 @@ describe('表自身自洽', () => {
expect(on).toEqual(['K08']); expect(on).toEqual(['K08']);
}); });
}); });
/**
* ⚡ SQL 侧的预过滤(potential-treatment-gap.sql:archDentureBranch)在展开 JSON 数组之前
* 先用 ARCH_DENTURE_PREFILTER_RE 把整份 exam_findings 筛一道。那是纯剪枝,**前提是
* 该词必须始终是上下颌两条 RE 的必要条件** —— 一旦不是,预过滤就会静默丢掉真命中
* (少召,不报错)。这组用例就是锁这个蕴含关系的。
*
* 实测依据(2026-08-29 测试库):exam_findings 是数组的 emr 事实 1,519,829 份,
* 含「义齿|假牙」的仅 7,396 份(0.49%),预过滤剪掉 99.5% 的无用展开。
*/
describe('⚡ 预过滤词必须是两条 RE 的必要条件(改 RE 时这组会先炸)', () => {
const 会命中的句子 = [
'上下颌吸附性义齿修复,下颌固位可,上颌固位稍差',
'牙缺失,口内活动义齿修复,上颌义齿卡环紧,不易取戴,下颌义齿无法完全就位',
'上颌活动义齿在位',
'下半口假牙尚可',
'全口义齿修复',
'上下全口假牙使用中',
'上颌见活动义齿,基托边缘密合',
];
it.each(会命中的句子)('命中 RE 的句子必然含预过滤词:%s', (msg) => {
expect(UP.test(msg) || LOW.test(msg)).toBe(true); // 前提:这些确实命中
expect(PRE.test(msg)).toBe(true); // 结论:那就一定含预过滤词
});
it('⛔ 反向:不含预过滤词的文本,两条 RE 都不可能命中(否则预过滤会丢真命中)', () => {
for (const msg of [
'上颌见残根,下颌牙列完整',
'全口牙石(+),牙龈红肿',
'上颌种植体冠在位,边缘密合', // 修复体在位但非义齿 → 归按条目那条分支管
'下颌固定桥完好',
]) {
expect(PRE.test(msg)).toBe(false);
expect(UP.test(msg)).toBe(false);
expect(LOW.test(msg)).toBe(false);
}
});
});
...@@ -730,6 +730,35 @@ export const ARCH_DENTURE_UPPER_RE = ...@@ -730,6 +730,35 @@ export const ARCH_DENTURE_UPPER_RE =
export const ARCH_DENTURE_LOWER_RE = export const ARCH_DENTURE_LOWER_RE =
'(下颌|下半口)[^。;;]{0,10}(义齿|假牙)|(上下颌|全口|上下全口)[^。;;]{0,10}(义齿|假牙)'; '(下颌|下半口)[^。;;]{0,10}(义齿|假牙)|(上下颌|全口|上下全口)[^。;;]{0,10}(义齿|假牙)';
/**
* 预过滤词 —— **纯性能剪枝,逻辑上恒等,不改任何判定结果**。
*
* 上面两条 RE 都要求句子里出现「义齿」或「假牙」(两个分支各自的第二个捕获组都是
* `(义齿|假牙)`),所以整段 exam_findings 文本里若一个都没有,任何条目都不可能命中。
* 于是可以在**展开 JSON 数组之前**先用它把整份病历筛掉。
*
* 🔴 为什么必须加(2026-08-29 测试库实测):
* exam_findings 是数组的 emr 事实 1,519,829 份,其中含「义齿|假牙」的只有 7,396 份
* —— **0.49%**。不加预过滤时这条分支要对 152 万份病历做 `jsonb_array_elements`
* 横向展开,再逐条目 unnest 牙位(全口条目一条炸 28~32 行)。
* ⚡ 按患者相关子查询(**实际使用的形态**,先走 patient_id 索引再过滤),300 患者样本:
* 无预过滤 832.7ms → 有预过滤 49.7ms,**快 16.7 倍**,结果一致(均 5 行)。
*
* ⛔ 别用「全表扫」的写法去验证本优化 —— 那个形态下两边都是 81 秒、看不出差别
* (顺序扫描 + detoast 压倒一切,少展开几百万行无关紧要),照着那个数会误以为它没用
* 而把它删掉。2026-08-29 我自己就先测错了这一次。批量路径(runAllForHost)接近全表扫
* 形态,收益不明显;真正吃到 16.7 倍的是**单患者路径**(详情页刷新 /
* recomputeForPatient / reparse 后的定向重算)。
*
* 兄弟分支 [[RESTORATION_IN_PLACE_TERMS_RE]] 那条本来就先在整段文本上过滤了
* (见 potential-treatment-gap.sql 的 rsrc 子查询),这条是漏了,不是有意为之。
*
* ⛔ 改上面两条 RE 时必须同步检查本词:**本词必须始终是那两条 RE 的必要条件**,
* 否则预过滤会开始丢真命中,而且是静默少召。tests/arch-denture-false-missing.spec.ts
* 有一条用例专门锁这个蕴含关系。
*/
export const ARCH_DENTURE_PREFILTER_RE = '义齿|假牙';
/// 未发生 —— 命中即不作数(「建议上颌活动义齿修复」是还没做) /// 未发生 —— 命中即不作数(「建议上颌活动义齿修复」是还没做)
export const ARCH_DENTURE_INTENT_EXCLUDE_RE = '建议|推荐|拟|打算|要求|考虑|择期|待|计划做|未行|未做'; export const ARCH_DENTURE_INTENT_EXCLUDE_RE = '建议|推荐|拟|打算|要求|考虑|择期|待|计划做|未行|未做';
......
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