Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
736d95fb
Commit
736d95fb
authored
Aug 29, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: plan 三层耗时日志 + 义齿按颌预过滤 → test
parents
8e480673
4ac764b6
Pipeline
#3612
failed in 0 seconds
Changes
5
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
106 additions
and
0 deletions
+106
-0
apps/pac-service/src/modules/clinical-gap/potential-treatment-gap.sql.ts
+9
-0
apps/pac-service/src/modules/plan/engine/plan-engine.service.ts
+13
-0
apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts
+14
-0
apps/pac-service/tests/arch-denture-false-missing.spec.ts
+41
-0
packages/types/src/canonical-codes.ts
+29
-0
No files found.
apps/pac-service/src/modules/clinical-gap/potential-treatment-gap.sql.ts
View file @
736d95fb
...
...
@@ -25,6 +25,7 @@ import {
ARCH_DENTURE_UPPER_RE
,
ARCH_DENTURE_LOWER_RE
,
ARCH_DENTURE_INTENT_EXCLUDE_RE
,
ARCH_DENTURE_PREFILTER_RE
,
UPPER_ARCH_FIRST_DIGITS_RE
,
LOWER_ARCH_FIRST_DIGITS_RE
,
}
from
'@pac/types'
;
...
...
@@ -387,6 +388,14 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
WHERE adx.patient_id = p.id
AND adx.type = 'emr_record' AND adx.status IN ('active', 'fulfilled')
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'
...
...
apps/pac-service/src/modules/plan/engine/plan-engine.service.ts
View file @
736d95fb
...
...
@@ -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
const
hitsByPatient
=
new
Map
<
string
,
ScenarioHitWithKey
[]
>
();
for
(
const
sc
of
this
.
scenarios
)
{
...
...
@@ -265,9 +270,13 @@ export class PlanEngineService {
// → 逐患复用**同一** upsert 判定(结果等价)→ 写操作**分块并发** → PlanGenerationLog 收集后
// 一次性 createMany(每患仍一行,审计/监控口径不变,只是写法从 12.5万次 → 几次)。
// 注:selectHits 的全表扫不在本优化内(时间规则随时可改,每轮必须全量重选才正确)。
const
selectMs
=
Date
.
now
()
-
tSelect
;
const
patientIds
=
[...
hitsByPatient
.
keys
()];
const
tPrefetch
=
Date
.
now
();
const
{
latestByPatient
,
snoozedByPatient
,
personaByPatient
,
lastVisitClinicByPatient
,
touchedPlanIds
}
=
await
this
.
prefetchForBatch
(
scope
,
patientIds
,
now
);
const
prefetchMs
=
Date
.
now
()
-
tPrefetch
;
const
tWrite
=
Date
.
now
();
const
EMPTY_SNOOZE
=
new
Map
<
string
,
Date
>
();
const
logRows
:
Prisma
.
PlanGenerationLogCreateManyInput
[]
=
[];
// ⭐ 2026-07-26:写操作从「每 N 个一批 await Promise.all」的**栅栏**改成连续调度的
...
...
@@ -413,6 +422,10 @@ export class PlanEngineService {
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
{
scenariosRun
:
this
.
scenarios
.
length
,
patientsHit
:
hitsByPatient
.
size
,
...
...
apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts
View file @
736d95fb
...
...
@@ -513,7 +513,13 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
`[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
sqlMs
=
Date
.
now
()
-
sqlStart
;
const
postStart
=
Date
.
now
();
// ⭐ 同 patient 同 sub_scenario 的多 sig 按 tooth-overlap 合并(union-find)
// 跟 chain-composer 的 bucket 合并口径一致 → reason 与 chain 1:1 对齐
...
...
@@ -637,6 +643,14 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
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;
}
...
...
apps/pac-service/tests/arch-denture-false-missing.spec.ts
View file @
736d95fb
...
...
@@ -15,6 +15,7 @@ import {
ARCH_DENTURE_UPPER_RE
,
ARCH_DENTURE_LOWER_RE
,
ARCH_DENTURE_INTENT_EXCLUDE_RE
,
ARCH_DENTURE_PREFILTER_RE
,
UPPER_ARCH_FIRST_DIGITS_RE
,
LOWER_ARCH_FIRST_DIGITS_RE
,
}
from
'@pac/types'
;
...
...
@@ -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
LOW
=
new
RegExp
(
ARCH_DENTURE_LOWER_RE
);
const
INTENT
=
new
RegExp
(
ARCH_DENTURE_INTENT_EXCLUDE_RE
);
const
PRE
=
new
RegExp
(
ARCH_DENTURE_PREFILTER_RE
);
/** 模拟:该句判出哪几颌有义齿(未发生词一票否决) */
const
arches
=
(
msg
:
string
):
string
[]
=>
{
...
...
@@ -125,3 +127,42 @@ describe('表自身自洽', () => {
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
);
}
});
});
packages/types/src/canonical-codes.ts
View file @
736d95fb
...
...
@@ -730,6 +730,35 @@ export const ARCH_DENTURE_UPPER_RE =
export
const
ARCH_DENTURE_LOWER_RE
=
'(下颌|下半口)[^。;;]{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
=
'建议|推荐|拟|打算|要求|考虑|择期|待|计划做|未行|未做'
;
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment