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
6d3ba7a1
Commit
6d3ba7a1
authored
Aug 30, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: gap 集合式形态 + 对拍工具(默认 legacy,开关未开)→ main
parents
61ba24de
1bde4764
Pipeline
#3636
failed in 0 seconds
Changes
7
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
1608 additions
and
39 deletions
+1608
-39
apps/pac-service/package.json
+2
-0
apps/pac-service/src/cli/verify-gap-equivalence.cli.ts
+396
-0
apps/pac-service/src/modules/clinical-gap/potential-treatment-gap.sql.ts
+450
-0
apps/pac-service/src/modules/clinical-gap/potential-treatment.selector.ts
+34
-8
apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts
+97
-31
apps/pac-service/tests/gap-setbased-parity.spec.ts
+143
-0
docs/design/gap-set-based-rewrite-plan.md
+486
-0
No files found.
apps/pac-service/package.json
View file @
6d3ba7a1
...
@@ -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"
,
...
...
apps/pac-service/src/cli/verify-gap-equivalence.cli.ts
0 → 100644
View file @
6d3ba7a1
/**
* verify-gap-equivalence — gap 计算形态对拍(legacy ↔ setbased)
*
* 为什么必须有这个工具:
* `buildGapCore` 是【召回】与【画像】共用的单一真理源,改错的后果是**静默少召** ——
* 不报错、不炸测试,几个月后才由一线反馈冒出来。而现有 spec(arch-denture /
* polish-implies / restoration-in-place / treated-evidence / review-implies)
* 全是纯 JS 常量与正则断言,**不碰 SQL 行为**,重写后照样全绿 → 对这次改动的保护 ≈ 0。
* 所以正确性只能靠"两版跑同一份数据、逐 (患者×信号×牙位) 差分"来证。
*
* 怎么保证可信:
* ① 两版在**同一个 REPEATABLE READ 事务**里跑 —— 否则并发增量摄入会造出假差异。
* ② SQL 来自 scenario 自己的 `buildScenarioSql()`,不是这里另抄一份 ——
* 另抄就变成"验证我抄得对不对",而不是验证线上行为。
* ③ `--self` 模式让 legacy 跟自己对拍,先证明工具本身可信(必须零差异)。
*
* Usage:
* pnpm verify-gap-equivalence -- --host=jvs-dw # 全部 11 个子场景
* pnpm verify-gap-equivalence -- --host=jvs-dw --sub=impacted_tooth
* pnpm verify-gap-equivalence -- --host=jvs-dw --self # 自对拍(工具自检)
* pnpm verify-gap-equivalence -- --host=jvs-dw --bench # 只测耗时,不差分
*
* 退出码:0 = 零差异;1 = 有差异或出错(可直接进 CI / 部署脚本)。
*/
import
{
NestFactory
}
from
'@nestjs/core'
;
import
{
Prisma
}
from
'@prisma/client'
;
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'
;
import
{
disableSchedulersForCli
}
from
'./bootstrap-flags'
;
interface
Args
{
host
:
string
;
sub
?:
string
;
self
:
boolean
;
bench
:
boolean
;
samples
:
number
;
/// >0 时改跑画像消费方对拍:抽 N 位患者,逐个跑两版 selectForPatient 比 gap 列表
persona
:
number
;
/// 并发标定:批量路径的子场景并发度(--conc=N),配 --subset=M 只读跑一轮
conc
:
number
;
/// 并发标定的患者子集规模(0=全量)
subset
:
number
;
/// >0 时改跑**交互路径**对拍:抽 N 位患者,按 scope.patientId 单患者跑召回 SQL 两版
/// —— 详情页「刷新」(plan.controller recomputeForPatient)走的就是这条,直接面向用户,
/// 批量快不快是运维的事,这条慢了是用户当场感受得到的。
single
:
number
;
}
function
parseArgs
(
argv
:
string
[]):
Args
{
const
a
:
Args
=
{
host
:
'demo'
,
self
:
false
,
bench
:
false
,
samples
:
20
,
persona
:
0
,
single
:
0
,
conc
:
0
,
subset
:
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
;
else
if
(
s
.
startsWith
(
'--single='
))
a
.
single
=
Number
(
s
.
slice
(
'--single='
.
length
))
||
0
;
else
if
(
s
.
startsWith
(
'--conc='
))
a
.
conc
=
Number
(
s
.
slice
(
'--conc='
.
length
))
||
0
;
else
if
(
s
.
startsWith
(
'--subset='
))
a
.
subset
=
Number
(
s
.
slice
(
'--subset='
.
length
))
||
0
;
}
return
a
;
}
interface
DiffRow
{
side
:
string
;
patient_id
:
string
;
signal_fact_id
:
string
;
tooth
:
string
|
null
;
}
async
function
bootstrap
():
Promise
<
number
>
{
const
args
=
parseArgs
(
process
.
argv
.
slice
(
2
));
// 报告一律走 console:Nest 的 logger 级别会被 createApplicationContext 全局压掉,
// 而这个 CLI 的输出就是它的全部产物 —— 不能被日志级别吃掉。
const
out
=
(
m
:
string
):
void
=>
console
.
log
(
m
);
const
bad_
=
(
m
:
string
):
void
=>
console
.
error
(
m
);
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli
();
const
app
=
await
NestFactory
.
createApplicationContext
(
AppModule
,
{
logger
:
[
'warn'
,
'error'
],
});
let
bad
=
0
;
try
{
const
prisma
=
app
.
get
(
PrismaService
);
const
scenario
=
app
.
get
(
TreatmentInitiationRecallScenario
);
const
host
=
await
prisma
.
host
.
findUnique
({
where
:
{
name
:
args
.
host
}
});
if
(
!
host
)
throw
new
Error
(
`Host '
${
args
.
host
}
' not found`
);
const
tenants
=
await
prisma
.
patient
.
findMany
({
where
:
{
hostId
:
host
.
id
},
select
:
{
tenantId
:
true
},
distinct
:
[
'tenantId'
],
});
if
(
!
tenants
.
length
)
throw
new
Error
(
'No tenants found for host'
);
// 🔴 now 固定一次:两版必须拿同一个时间锚,否则 cooldown 边界上的信号会来回抖。
const
now
=
new
Date
();
// ══ 批量路径并发标定(--conc=N [--subset=M])══
// 只读:每条子场景 SQL 外面套一层 count(*),不写库。
// 目的:量出「子场景并发」在**这台机器**上到底有没有扩展性 ——
// 2026-08-29 曾以「并发3=24.1分 vs 串行23.3分」判其无效并写进代码注释,
// 但 3% 落在 ±25% 的环境噪音里,那次测试什么也没证明(见方案 §11)。
// 判据用**墙钟**,不看各查询耗时之和(并发下单条会被争抢拉长,和不可比)。
if
(
args
.
conc
>
0
)
{
let
subsetIds
:
string
[]
|
undefined
;
if
(
args
.
subset
>
0
)
{
const
rows
=
await
prisma
.
$queryRaw
<
{
id
:
string
}[]
>
(
Prisma
.
sql
`
SELECT p.id FROM patients p
WHERE p.host_id =
${
host
.
id
}
::uuid AND p.active = true
ORDER BY p.id LIMIT
${
args
.
subset
}
`
);
subsetIds
=
rows
.
map
((
r
)
=>
r
.
id
);
}
const
scopeB
:
ScenarioScope
=
{
hostId
:
host
.
id
,
tenantId
:
tenants
[
0
]
!
.
tenantId
,
now
,
...(
subsetIds
?
{
patientIds
:
subsetIds
}
:
{}),
};
const
variant
:
GapVariant
=
args
.
self
?
'setbased'
:
'legacy'
;
const
jobs
=
Object
.
entries
(
TreatmentInitiationRecallScenario
.
SUB_SCENARIOS
).
map
(
([
subKey
,
cfg
])
=>
{
const
rule
=
lookupDxTreatment
(
cfg
.
primaryCode
);
if
(
!
rule
)
throw
new
Error
(
`
${
subKey
}
无 rule`
);
return
{
subKey
,
sql
:
scenario
.
buildScenarioSql
(
scopeB
,
cfg
.
primaryCode
,
rule
,
variant
)
};
},
);
const
runOne
=
async
(
j
:
{
subKey
:
string
;
sql
:
Prisma
.
Sql
}):
Promise
<
string
>
=>
{
const
t
=
Date
.
now
();
const
[,
rows
]
=
await
prisma
.
$transaction
([
prisma
.
$executeRaw
`SET LOCAL work_mem = '256MB'`
,
prisma
.
$queryRaw
<
{
n
:
bigint
}[]
>
(
Prisma
.
sql
`SELECT count(*)::bigint AS n FROM (
${
j
.
sql
}
) q`
),
]);
return
`
${
j
.
subKey
}
=
${
Date
.
now
()
-
t
}
ms/
${
Number
(
rows
[
0
]?.
n
??
0
)}
行
`;
};
out(
`
▶
并发标定
conc
=
$
{
args
.
conc
}
形态
=
$
{
variant
}
患者域
=
$
{
subsetIds
?
`
${
subsetIds
.
length
}
位子集`
:
'全量'
}
`,
);
const t0 = Date.now();
const results: string[] = [];
for (let i = 0; i < jobs.length; i += args.conc) {
const chunk = await Promise.all(jobs.slice(i, i + args.conc).map(runOne));
results.push(...chunk);
}
const wall = Date.now() - t0;
out(`
$
{
results
.
join
(
' '
)}
`);
out(`
⏱
墙钟
=
$
{
wall
}
ms
(
$
{(
wall
/
1000
).
toFixed
(
1
)}
s
)
—
这是唯一可比的数
`);
return 0;
}
// ══ 交互路径对拍(详情页「刷新」:单患者召回)══
// 批量慢是运维问题,这条慢是**用户当场感受得到**的问题 —— 必须单独量。
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 消费方)══
// 画像是**逐患者**调用,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
,
);
if
(
!
entries
.
length
)
throw
new
Error
(
`--sub=
${
args
.
sub
}
不是已知子场景`
);
for
(
const
t
of
tenants
)
{
const
scope
:
ScenarioScope
=
{
hostId
:
host
.
id
,
tenantId
:
t
.
tenantId
,
now
};
out
(
`▶ host=
${
args
.
host
}
tenant=
${
t
.
tenantId
}
子场景=
${
entries
.
length
}
个`
);
for
(
const
[
subKey
,
cfg
]
of
entries
)
{
const
rule
=
lookupDxTreatment
(
cfg
.
primaryCode
);
if
(
!
rule
)
throw
new
Error
(
`
${
subKey
}
.primaryCode=
${
cfg
.
primaryCode
}
不在 DiagnosisTreatmentMap`
);
const
leftVariant
:
GapVariant
=
'legacy'
;
const
rightVariant
:
GapVariant
=
args
.
self
?
'legacy'
:
'setbased'
;
const
left
=
scenario
.
buildScenarioSql
(
scope
,
cfg
.
primaryCode
,
rule
,
leftVariant
);
const
right
=
scenario
.
buildScenarioSql
(
scope
,
cfg
.
primaryCode
,
rule
,
rightVariant
);
// ── 计时:两版各单独跑一次(不在同一事务里,避免第二次白蹭第一次的缓存判断失真;
// 对拍另开一个事务)。work_mem 与线上一致。
const
timeOne
=
async
(
sql
:
Prisma
.
Sql
):
Promise
<
{
ms
:
number
;
rows
:
number
}
>
=>
{
const
t0
=
Date
.
now
();
const
[,
rows
]
=
await
prisma
.
$transaction
([
prisma
.
$executeRaw
`SET LOCAL work_mem = '256MB'`
,
prisma
.
$queryRaw
<
{
n
:
bigint
}[]
>
(
Prisma
.
sql
`SELECT count(*)::bigint AS n FROM (
${
sql
}
) q`
,
),
]);
return
{
ms
:
Date
.
now
()
-
t0
,
rows
:
Number
(
rows
[
0
]?.
n
??
0
)
};
};
const
l
=
await
timeOne
(
left
);
const
r
=
await
timeOne
(
right
);
const
speed
=
r
.
ms
>
0
?
(
l
.
ms
/
r
.
ms
).
toFixed
(
2
)
:
'n/a'
;
out
(
`
${
subKey
.
padEnd
(
24
)}
${
leftVariant
}
=
${
String
(
l
.
ms
).
padStart
(
7
)}
ms/
${
l
.
rows
}
行 `
+
`
${
rightVariant
}
=
${
String
(
r
.
ms
).
padStart
(
7
)}
ms/
${
r
.
rows
}
行 ×
${
speed
}
`
,
);
if
(
args
.
bench
)
continue
;
// ── 差分:两版同一快照,双向 EXCEPT ALL(ALL 保留重复,行数不一致也会暴露)──
const
key
=
Prisma
.
raw
(
'patient_id, signal_fact_id, tooth'
);
const
diffSql
=
Prisma
.
sql
`
WITH lg AS (
${
left
}
), sb AS (
${
right
}
),
d1 AS (SELECT
${
key
}
FROM lg EXCEPT ALL SELECT
${
key
}
FROM sb),
d2 AS (SELECT
${
key
}
FROM sb EXCEPT ALL SELECT
${
key
}
FROM lg)
SELECT 'only_legacy'::text AS side,
${
key
}
FROM d1
UNION ALL
SELECT 'only_setbased'::text AS side,
${
key
}
FROM d2`
;
const
diffs
=
await
prisma
.
$transaction
(
async
(
tx
)
=>
{
await
tx
.
$executeRaw
`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`
;
await
tx
.
$executeRaw
`SET LOCAL work_mem = '256MB'`
;
return
tx
.
$queryRaw
<
DiffRow
[]
>
(
diffSql
);
},
{
timeout
:
3
*
60
*
60
*
1000
,
maxWait
:
120
_000
},
);
if
(
diffs
.
length
===
0
)
{
out
(
`
${
subKey
.
padEnd
(
24
)}
✅ 零差异`
);
}
else
{
bad
++
;
const
onlyL
=
diffs
.
filter
((
d
)
=>
d
.
side
===
'only_legacy'
).
length
;
const
onlyR
=
diffs
.
filter
((
d
)
=>
d
.
side
===
'only_setbased'
).
length
;
bad_
(
`
${
subKey
.
padEnd
(
24
)}
❌ 差异
${
diffs
.
length
}
条(只在 legacy=
${
onlyL
}
/ 只在 setbased=
${
onlyR
}
)`
,
);
for
(
const
d
of
diffs
.
slice
(
0
,
args
.
samples
))
{
bad_
(
`
${
d
.
side
}
patient=
${
d
.
patient_id
}
sig=
${
d
.
signal_fact_id
}
tooth=
${
d
.
tooth
??
'NULL'
}
`);
}
}
}
}
} catch (e) {
bad_(e instanceof Error ? (e.stack ?? e.message) : String(e));
bad++;
} finally {
await app.close();
}
if (bad === 0) {
console.log('══ 全部零差异 ══');
return 0;
}
console.error(`
══
$
{
bad
}
个子场景不一致
/
出错
══
`);
return 1;
}
bootstrap().then((code) => process.exit(code));
apps/pac-service/src/modules/clinical-gap/potential-treatment-gap.sql.ts
View file @
6d3ba7a1
...
@@ -118,6 +118,58 @@ export interface GapCoreInput {
...
@@ -118,6 +118,58 @@ export interface GapCoreInput {
cfgFlags
:
GapCfgFlags
;
cfgFlags
:
GapCfgFlags
;
allCodes
:
readonly
string
[];
// dxCodes ∪ recCodes
allCodes
:
readonly
string
[];
// dxCodes ∪ recCodes
resolverCats
:
readonly
string
[];
// resolverCategoriesFor(primaryCode)
resolverCats
:
readonly
string
[];
// resolverCategoriesFor(primaryCode)
/**
* 计算形态。**只换形态,不换口径** —— 两者必须逐 (患者×信号×牙位) 完全一致。
* 'legacy' 逐 (患者×信号) 行跑相关子查询(2026-08 之前唯一形态)
* 'setbased' 先按 (患者,牙位) 预聚合全部 resolved 证据,再做反连接
* 默认 legacy。切换与验证见 docs/design/gap-set-based-rewrite-plan.md,
* 对拍工具 src/cli/verify-gap-equivalence.cli.ts —— **改任何一边都要重跑对拍**。
*/
variant
?:
GapVariant
;
}
export
type
GapVariant
=
'legacy'
|
'setbased'
;
/**
* gap 计算形态开关。默认 legacy(逐行相关子查询);PAC_GAP_VARIANT=setbased 切集合式。
*
* ⚠️ 两种形态**必须产出完全相同的行集** —— 切换前后要跑
* `pnpm verify-gap-equivalence --host=<host>`(逐 患者×信号×牙位 差分,必须零差异)。
* 设成环境开关而不是写死,是为了让同一个进程能在同一份数据上跑两版做对拍,
* 也为了生产上万一发现差异能立刻回退,不用重新发版。
*/
export
function
gapVariant
():
GapVariant
{
return
process
.
env
.
PAC_GAP_VARIANT
===
'setbased'
?
'setbased'
:
'legacy'
;
}
/**
* 集合式形态的拼装件。消费方主查询变成:
*
* WITH gap_cand AS MATERIALIZED (
* SELECT <自己的投影>, ${candExtraCols}
* FROM patients p JOIN patient_profiles pp … JOIN patient_facts sig …
* WHERE <自己的闸> ${candWhere}
* )${postCtes}
* SELECT <自己的投影(从 c 取)>, ${toothOutput} AS tooth
* FROM gap_cand c ${remJoin}
* WHERE TRUE ${outerWhere}
*
* 全口码(K05/K07)时 postCtes/remJoin/outerWhere 为空、判定全在 candWhere 里 ——
* 因为全口场景根本不做牙位相减(见方案 §0.1:那条 lateral PG 本来就会自动删掉)。
*/
export
interface
GapSetBasedPieces
{
/// gap_cand 的额外投影列(每项前带逗号)
candExtraCols
:
Prisma
.
Sql
;
/// gap_cand 的 WHERE add-on(外院已治疗闸;全口码另加两个 NOT EXISTS)
candWhere
:
Prisma
.
Sql
;
/// gap_cand 之后的 CTE 链(前带逗号),牙位级非空 / 全口码为空
postCtes
:
Prisma
.
Sql
;
/// 主查询的 gap_rem 连接
remJoin
:
Prisma
.
Sql
;
/// tooth 输出表达式
toothOutput
:
Prisma
.
Sql
;
/// 主查询 WHERE add-on(牙位级:剩余非空)
outerWhere
:
Prisma
.
Sql
;
}
}
export
interface
GapCorePieces
{
export
interface
GapCorePieces
{
...
@@ -135,6 +187,8 @@ export interface GapCorePieces {
...
@@ -135,6 +187,8 @@ export interface GapCorePieces {
toothOutput
:
Prisma
.
Sql
;
toothOutput
:
Prisma
.
Sql
;
/// ⑤a gap 判定(WHERE add-on):全口 → NOT EXISTS 同类治疗;有牙位 → 剩余非空
/// ⑤a gap 判定(WHERE add-on):全口 → NOT EXISTS 同类治疗;有牙位 → 剩余非空
gapWhere
:
Prisma
.
Sql
;
gapWhere
:
Prisma
.
Sql
;
/// 集合式拼装件(variant='setbased' 时非空;legacy 时 undefined)
setBased
?:
GapSetBasedPieces
;
}
}
/**
/**
...
@@ -571,5 +625,401 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
...
@@ -571,5 +625,401 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
lateralJoin
,
lateralJoin
,
toothOutput
,
toothOutput
,
gapWhere
,
gapWhere
,
setBased
:
input
.
variant
===
'setbased'
?
(
buildGapSetBased
(
input
)
??
undefined
)
:
undefined
,
};
}
// ═══════════════════════════════════════════════════════════════════════════
// 集合式形态(setbased)—— 与上面的 legacy 形态**独立重写一份**
//
// ⚠️ 刻意不与 legacy 共用分支片段。共用意味着"重构时写错的地方两边一起错、对拍互相抵消",
// 那样对拍就失去意义。这里是**独立再推导一遍**,靠 verify-gap-equivalence 逐
// (患者×信号×牙位) 差分来证明两份等价 —— 同 sql/verify-recall.sql 里
// 「vt_codes 重述一份、不一致即暴露」的思路。
// legacy 分支在集合式全量验收通过、生产稳定跑过一轮之后再删。
//
// 核心恒等式: ∃x∈G: t(x) ⋛ a ⟺ max{t(x) : x∈G} ⋛ a
// 分组 G = (patient_id, tooth);组内其余谓词(category/status/正则/牙位纯度)全与 sig 无关,
// 所以能先按组聚出 max(t),再跟每个信号的锚点比大小 —— 一次算完全部患者。
//
// 13 个分支对 sig 的相关性只有三类:
// ① 时间门(10 条) → 聚合键 (pid, tooth),gate = max(时间)
// ② 病历号等值(1 条) → 聚合键 (pid, tooth, enc),enc = 该病历的 emr_external_id
// ③ 无相关(2 条) → gate = 'infinity'(恒过)
// 另有 1 条((c) 建议优先)多一个 sig.type 标量谓词 → ndx 标志位
//
// 🔴 gate 列**永不为 NULL**:时间门分支一律加 `IS NOT NULL`(等价 —— NULL 本来就过不了
// `>= 锚点`),无门分支写死 'infinity'。若让 NULL 表示"无门",全 NULL 组会被 max()
// 聚成 NULL 而当成恒过 → 误销 → 静默少召。这是本次重写最危险的一个坑。
// ═══════════════════════════════════════════════════════════════════════════
/// 集合式分支的统一行形状(顺序即列序,13 个分支 UNION ALL 必须对齐)
/// patient_id | tooth | gate | enc | strict | ndx
const
SB_COLS
=
Prisma
.
raw
(
'patient_id, tooth, gate, enc, strict, ndx'
);
/// 无时间门 → 恒过(不能用 NULL,见上面 🔴)
const
SB_NO_GATE
=
Prisma
.
sql
`'infinity'::timestamptz`
;
/// 患者收窄:所有分支都必须挂,否则 CTE 会全表扫 patient_facts(生产 34GB heap)
const
sbScope
=
(
alias
:
string
):
Prisma
.
Sql
=>
Prisma
.
sql
`
${
Prisma
.
raw
(
alias
)}
.patient_id IN (SELECT patient_id FROM gap_scope)`
;
export
function
buildGapSetBased
(
input
:
GapCoreInput
):
GapSetBasedPieces
|
null
{
const
{
rule
,
cfgFlags
,
allCodes
,
resolverCats
}
=
input
;
// 不变式:excludeIfEverTreated ⟺ wholeMouth(当前只有 K05/K07 两者同时为真)。
// 牙位级场景因此永远是 sig 锚点形态,集合式路径不必处理 latestDxOfCode 那一支。
// 谁给某个牙位级 rule 加了 excludeIfEverTreated,这里必须炸,而不是静默算错。
if
(
rule
.
excludeIfEverTreated
&&
!
rule
.
wholeMouth
)
{
throw
new
Error
(
'buildGapSetBased: excludeIfEverTreated 目前只在 wholeMouth 规则上出现;'
+
'牙位级规则要用它,得先给集合式补 latestDxOfCode 分支并重跑对拍。'
,
);
}
// ══ 全口码(K05/K07):**不进集合式,原样走 legacy** ══
// §0.1 已实测:全口场景 sigToothExpr=NULL → st={},toothOutput/gapWhere 都是
// `CASE WHEN TRUE`,常量折叠后 lat 无人引用 → PG 的 useless-left-join removal
// 把整个 LATERAL 摘掉。也就是说**它们本来就没在跑 resolvedTeeth**,集合式零收益。
//
// 2026-08-30 本地实测,曾试着把它们也套进 gap_cand 统一形态,结果**变慢 2~3 倍**:
// ortho_no_consult 1309ms → 4439ms(×0.29)
// perio_no_srp 1388ms → 3065ms(×0.45)
// MATERIALIZED 挡住了规划器对这两条(判定全是患者级 NOT EXISTS)的原有安排。
// ⛔ 别再为了"形态统一好看"把它们并进来 —— 没收益、纯风险、还慢。
if
(
rule
.
wholeMouth
)
return
null
;
const
sigToothExpr
=
Prisma
.
sql
`sig.content->>'tooth_position'`
;
// 全口码已早退,这里必有牙位
// ── gap_cand 的额外列(前缀 gap_ 避免跟消费方自己的投影撞名)──
const
candExtraCols
=
Prisma
.
sql
`,
p.id AS gap_patient_id,
sig.id AS gap_sig_id,
COALESCE(sig.occurred_at, sig.planned_for) AS gap_anchor,
sig.type AS gap_sig_type,
sig.content->>'source_encounter_external_id' AS gap_sig_enc,
COALESCE(
${
toothArrSql
(
sigToothExpr
,
{
dropDeciduous
:
cfgFlags
.
excludeDeciduous
===
true
,
dropThirdMolar
:
cfgFlags
.
excludeThirdMolar
===
true
,
})}
, ARRAY[]::text[]) AS gap_sig_teeth`
;
// ── 外院已治疗(回访 result)患者级排除 —— 与 legacy 同口径,放进 gap_cand ──
const
externalTreatmentGate
=
Prisma
.
sql
`AND NOT EXISTS (
SELECT 1 FROM patient_return_visits rv
WHERE rv.patient_id = p.id
AND rv.result ~
${
EXTERNAL_TREATMENT_VISIT_POS_RE
}
AND rv.result !~
${
EXTERNAL_TREATMENT_VISIT_NEG_RE
}
AND rv.task_date >= COALESCE(sig.occurred_at, sig.planned_for)::date
)`
;
const
refusalRe
=
TREATMENT_REFUSAL_SUBTYPE_PATTERNS
.
join
(
'|'
);
const
refusalImagingRe
=
TREATMENT_REFUSAL_IMAGING_EXCLUDE_RE
;
const
refusalSubtypeMatch
=
(
alias
:
string
):
Prisma
.
Sql
=>
Prisma
.
sql
`regexp_replace(
${
Prisma
.
raw
(
alias
)}
.content->>'subtype',
${
refusalImagingRe
}
, '', 'g') ~
${
refusalRe
}
`
;
// ══ 牙位级:13 个分支预聚合 ══
const
branches
:
Prisma
.
Sql
[]
=
[];
// ① (a) 治疗家族 resolver —— 同牙做了 resolverCats 家族里任一治疗
branches
.
push
(
Prisma
.
sql
`
SELECT rtx.patient_id, rtt AS tooth, rtx.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts rtx
CROSS JOIN unnest(
${
toothArrSql
(
Prisma
.
sql
`rtx.content->>'tooth_position'`
)}
) AS rtt
WHERE
${
sbScope
(
'rtx'
)}
AND rtx.type = 'treatment_record' AND rtx.kind = 'actual'
AND rtx.status IN ('active', 'fulfilled')
AND COALESCE(NULLIF(trim(rtx.content->>'tooth_position'), ''), '') != ''
AND rtx.content->>'category' = ANY(
${
resolverCats
}
::text[])
AND rtx.occurred_at IS NOT NULL`
);
// ② (a'') 桥类修复的牙位盲点 —— 基牙跨度区间内全部牙位计入
branches
.
push
(
Prisma
.
sql
`
SELECT btx.patient_id, cov.arch_tooth AS tooth, btx.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts btx
CROSS JOIN LATERAL (
SELECT min(z.idx) AS mi, max(z.idx) AS ma, z.arch
FROM (
SELECT CASE substr(bt,1,1)
WHEN '1' THEN 9 - substr(bt,2,1)::int
WHEN '2' THEN 8 + substr(bt,2,1)::int
WHEN '4' THEN 9 - substr(bt,2,1)::int
WHEN '3' THEN 8 + substr(bt,2,1)::int END AS idx,
CASE WHEN substr(bt,1,1) IN ('1','2') THEN 'U' ELSE 'L' END AS arch
FROM unnest(
${
toothArrSql
(
Prisma
.
sql
`btx.content->>'tooth_position'`
)}
) AS bt
WHERE bt ~ '^[1-4][1-8]$'
) z GROUP BY z.arch HAVING count(*) >= 2
) span
CROSS JOIN LATERAL (
SELECT CASE WHEN span.arch = 'U'
THEN CASE WHEN gi <= 8 THEN '1' || (9 - gi)::text ELSE '2' || (gi - 8)::text END
ELSE CASE WHEN gi <= 8 THEN '4' || (9 - gi)::text ELSE '3' || (gi - 8)::text END
END AS arch_tooth
FROM generate_series(span.mi, span.ma) AS gi
) cov
WHERE
${
sbScope
(
'btx'
)}
AND btx.type = 'treatment_record' AND btx.kind = 'actual'
AND btx.status IN ('active', 'fulfilled')
AND btx.content->>'category' = 'prosthodontic'
AND btx.occurred_at IS NOT NULL`
);
// ③ (a''') 检查所见"缺牙但间隙关闭/无修复间隙" → 该牙无修复指征
const
noRestorMsgRe
=
NO_RESTORATION_GAP_EXAM_PATTERNS
.
join
(
'|'
);
branches
.
push
(
Prisma
.
sql
`
SELECT src.patient_id, nrt AS tooth, src.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM (
SELECT emrx.patient_id, emrx.content->>'exam_findings' AS ef_text, emrx.occurred_at
FROM patient_facts emrx
WHERE
${
sbScope
(
'emrx'
)}
AND emrx.type = 'emr_record' AND emrx.status IN ('active', 'fulfilled')
AND emrx.content->>'exam_findings' ~ '^\\['
AND emrx.content->>'exam_findings' ~ '缺[牙失]'
AND emrx.content->>'exam_findings' ~
${
noRestorMsgRe
}
AND emrx.occurred_at IS NOT NULL
) src
CROSS JOIN LATERAL jsonb_array_elements(src.ef_text::jsonb) AS ef
CROSS JOIN unnest(
${
toothArrSql
(
Prisma
.
sql
`ef->>'toothPosition'`
)}
) AS nrt
WHERE (ef->>'message') ~ '缺[牙失]'
AND (ef->>'message') ~
${
noRestorMsgRe
}
`
);
// ④ (a'''') 患者无意愿:该 category 治疗被患者拒绝
branches
.
push
(
Prisma
.
sql
`
SELECT rfx.patient_id, rft AS tooth,
COALESCE(rfx.occurred_at, rfx.planned_for) AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts rfx
CROSS JOIN unnest(
${
toothArrSql
(
Prisma
.
sql
`rfx.content->>'tooth_position'`
)}
) AS rft
WHERE
${
sbScope
(
'rfx'
)}
AND rfx.type = 'treatment_record' AND rfx.status IN ('active', 'fulfilled')
AND
${
refusalSubtypeMatch
(
'rfx'
)}
AND rfx.content->>'category' = ANY(
${
resolverCats
}
::text[])
AND COALESCE(rfx.occurred_at, rfx.planned_for) IS NOT NULL`
);
// ⑤ (b) 同牙位以【最新诊断】为准 —— 🔴 严格 >(其余分支都是 >=)
branches
.
push
(
Prisma
.
sql
`
SELECT ldx.patient_id, ldt AS tooth, ldx.occurred_at AS gate,
NULL::text AS enc, TRUE AS strict, FALSE AS ndx
FROM patient_facts ldx
CROSS JOIN unnest(
${
toothArrSql
(
Prisma
.
sql
`ldx.content->>'tooth_position'`
)}
) AS ldt
WHERE
${
sbScope
(
'ldx'
)}
AND ldx.type = 'diagnosis_record' AND ldx.status = 'active'
AND ldx.content->>'code' = ANY(
${[...
STRUCTURAL_DX_CODE_LIST
]}
::text[])
AND ldx.occurred_at IS NOT NULL`
);
// ⑥ (c) 诊断 vs 建议冲突以建议为准 —— 🔴 只对 sig.type='diagnosis_record' 生效(ndx)
branches
.
push
(
Prisma
.
sql
`
SELECT rdx.patient_id, rdt AS tooth,
COALESCE(rdx.occurred_at, rdx.planned_for) AS gate,
NULL::text AS enc, FALSE AS strict, TRUE AS ndx
FROM patient_facts rdx
CROSS JOIN unnest(
${
toothArrSql
(
Prisma
.
sql
`rdx.content->>'tooth_position'`
)}
) AS rdt
WHERE
${
sbScope
(
'rdx'
)}
AND rdx.type = 'recommendation_record' AND rdx.status = 'active'
AND rdx.content->>'code' = ANY(
${[...
STRUCTURAL_DX_CODE_LIST
]}
::text[])
AND COALESCE(rdx.occurred_at, rdx.planned_for) IS NOT NULL`
);
// ⑦ (a''''') 「复查/复诊」= 修复体在位的证据(按类目过滤,不收整类 review)
const
reviewRules
=
REVIEW_IMPLIES_TREATMENT
.
filter
((
r
)
=>
(
resolverCats
as
readonly
string
[]).
includes
(
r
.
category
),
);
if
(
reviewRules
.
length
)
{
branches
.
push
(
Prisma
.
sql
`
SELECT rvx.patient_id, rvt AS tooth, rvx.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts rvx
CROSS JOIN unnest(
${
toothArrSql
(
Prisma
.
sql
`rvx.content->>'tooth_position'`
)}
) AS rvt
WHERE
${
sbScope
(
'rvx'
)}
AND rvx.type = 'treatment_record' AND rvx.kind = 'actual'
AND rvx.status IN ('active', 'fulfilled')
AND rvx.content->>'category' = 'review'
AND rvx.content->>'subtype' ~
${
reviewRules
.
map
((
r
)
=>
r
.
pattern
).
join
(
'|'
)}
AND rvx.occurred_at IS NOT NULL`
);
}
// ⑧ (a'''''') 缺牙位上的裸「抛光」= 修复体在位(仅 K08 开闸)
if
(
cfgFlags
.
polishImpliesRestoration
)
{
branches
.
push
(
Prisma
.
sql
`
SELECT plx.patient_id, plt AS tooth, plx.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts plx
CROSS JOIN unnest(
${
toothArrSql
(
Prisma
.
sql
`plx.content->>'tooth_position'`
)}
) AS plt
WHERE
${
sbScope
(
'plx'
)}
AND plx.type = 'treatment_record' AND plx.kind = 'actual'
AND plx.status IN ('active', 'fulfilled')
AND plx.content->>'category' =
${
MISSING_TOOTH_POLISH_EVIDENCE
.
category
}
AND plx.content->>'subtype' ~
${
MISSING_TOOTH_POLISH_EVIDENCE
.
subtypePattern
}
AND COALESCE(array_length(
${
toothArrSql
(
Prisma
.
sql
`plx.content->>'tooth_position'`
)}
, 1), 0)
BETWEEN 1 AND
${
MISSING_TOOTH_POLISH_EVIDENCE
.
maxTeeth
}
AND plx.occurred_at IS NOT NULL`
);
}
// ⑨ (a''''''') 病历自由文本自证已治疗(治疗记录 ⋈ 同次病历;与 sig 无关)
const
evidenceTerms
=
TREATED_EVIDENCE_RESTORATION_TERMS
.
filter
((
t
)
=>
(
resolverCats
as
readonly
string
[]).
includes
(
t
.
category
),
);
if
(
cfgFlags
.
treatedEvidenceFromEmrText
&&
evidenceTerms
.
length
)
{
const
emrTextSql
=
Prisma
.
raw
(
TREATED_EVIDENCE_EMR_FIELDS
.
map
((
f
)
=>
`COALESCE(emx.content->>'
${
f
}
', '')`
).
join
(
" || ' ' || "
),
);
branches
.
push
(
Prisma
.
sql
`
SELECT tvx.patient_id, tvt AS tooth, tvx.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts tvx
CROSS JOIN unnest(
${
toothArrSql
(
Prisma
.
sql
`tvx.content->>'tooth_position'`
)}
) AS tvt
JOIN patient_facts emx
ON emx.patient_id = tvx.patient_id
AND emx.type = 'emr_record'
AND emx.status IN ('active', 'fulfilled')
AND emx.content->>'emr_external_id' = tvx.content->>'source_encounter_external_id'
WHERE
${
sbScope
(
'tvx'
)}
AND tvx.type = 'treatment_record' AND tvx.kind = 'actual'
AND tvx.status IN ('active', 'fulfilled')
AND COALESCE(NULLIF(trim(tvx.content->>'tooth_position'), ''), '') != ''
AND tvx.content->>'category' = ANY(
${[
...
treatedEvidenceTriggersFor
(
resolverCats
),
]}
::text[])
AND (
tvx.content->>'category' <> ALL(
${[...
TREATED_EVIDENCE_BATCH_CATEGORIES
]}
::text[])
OR COALESCE(array_length(
${
toothArrSql
(
Prisma
.
sql
`tvx.content->>'tooth_position'`
)}
, 1), 0)
<=
${
TREATED_EVIDENCE_BATCH_MAX_TEETH
}
)
AND (
${
emrTextSql
}
) ~
${
evidenceTerms
.
map
((
t
)
=>
t
.
pattern
).
join
(
'|'
)}
AND (
${
emrTextSql
}
) ~
${
TREATED_EVIDENCE_COMPLETION_RE
}
AND (
${
emrTextSql
}
) !~
${
TREATED_EVIDENCE_INTENT_EXCLUDE_RE
}
${
TREATED_EVIDENCE_SINGLE_ARCH_ONLY
?
Prisma
.
sql
`AND NOT (
EXISTS (SELECT 1 FROM unnest(
${
toothArrSql
(
Prisma
.
sql
`tvx.content->>'tooth_position'`
)}
) au WHERE au ~ '^[12]')
AND
EXISTS (SELECT 1 FROM unnest(
${
toothArrSql
(
Prisma
.
sql
`tvx.content->>'tooth_position'`
)}
) al WHERE al ~ '^[34]')
)`
:
Prisma
.
empty
}
AND tvx.occurred_at IS NOT NULL`
);
}
// ⑩ (a'''''''') 检查所见写着修复体在位(同颌闸 + 失效词一票否决)
if
(
cfgFlags
.
restorationInPlaceFromExam
)
{
branches
.
push
(
Prisma
.
sql
`
SELECT rsrc.patient_id, ript AS tooth, rsrc.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM (
SELECT rix.patient_id, rix.content->>'exam_findings' AS ef_text, rix.occurred_at
FROM patient_facts rix
WHERE
${
sbScope
(
'rix'
)}
AND rix.type = 'emr_record' AND rix.status IN ('active', 'fulfilled')
AND rix.content->>'exam_findings' ~ '^\\['
AND rix.content->>'exam_findings' ~
${
RESTORATION_IN_PLACE_TERMS_RE
}
AND rix.occurred_at IS NOT NULL
) rsrc
CROSS JOIN LATERAL jsonb_array_elements(rsrc.ef_text::jsonb) AS rief
CROSS JOIN unnest(
${
toothArrSql
(
Prisma
.
sql
`rief->>'toothPosition'`
)}
) AS ript
WHERE (rief->>'message') ~
${
RESTORATION_IN_PLACE_TERMS_RE
}
AND (rief->>'message') ~
${
RESTORATION_IN_PLACE_STATE_RE
}
AND regexp_replace(rief->>'message',
${
RESTORATION_IN_PLACE_NEG_STRIP_RE
}
, '', 'g')
!~
${
RESTORATION_IN_PLACE_FAIL_RE
}
AND NOT (
EXISTS (SELECT 1 FROM unnest(
${
toothArrSql
(
Prisma
.
sql
`rief->>'toothPosition'`
)}
) ru WHERE ru ~ '^[12]')
AND
EXISTS (SELECT 1 FROM unnest(
${
toothArrSql
(
Prisma
.
sql
`rief->>'toothPosition'`
)}
) rl WHERE rl ~ '^[34]')
)`
);
}
// ⑪ (a''''''''') 整颌活动义齿 —— 🔴 唯一走【病历号等值】相关的分支(enc 列)
// legacy: adx.emr_external_id = sig.source_encounter_external_id(无时间门)
// setbased: enc 进聚合键,gate 恒过
if
(
cfgFlags
.
archDentureIsRestored
)
{
branches
.
push
(
Prisma
.
sql
`
SELECT adx.patient_id, adt AS tooth,
${
SB_NO_GATE
}
AS gate,
adx.content->>'emr_external_id' AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts adx
CROSS JOIN LATERAL jsonb_array_elements((adx.content->>'exam_findings')::jsonb) AS ade
CROSS JOIN unnest(
${
toothArrSql
(
Prisma
.
sql
`ade->>'toothPosition'`
)}
) AS adt
WHERE
${
sbScope
(
'adx'
)}
AND adx.type = 'emr_record' AND adx.status IN ('active', 'fulfilled')
AND adx.content->>'exam_findings' ~ '^\\['
AND adx.content->>'exam_findings' ~
${
ARCH_DENTURE_PREFILTER_RE
}
AND adx.content->>'emr_external_id' IS NOT NULL
AND (ade->>'message') !~
${
ARCH_DENTURE_INTENT_EXCLUDE_RE
}
AND (
((ade->>'message') ~
${
ARCH_DENTURE_UPPER_RE
}
AND adt ~
${
UPPER_ARCH_FIRST_DIGITS_RE
}
)
OR
((ade->>'message') ~
${
ARCH_DENTURE_LOWER_RE
}
AND adt ~
${
LOWER_ARCH_FIRST_DIGITS_RE
}
)
)`
);
}
// ⑫ §E 正畸减数位 —— 与 sig 无关,gate 恒过
if
(
cfgFlags
.
excludeOrthoExtractionSites
)
{
const
exTeeth
=
toothArrSql
(
Prisma
.
sql
`ex.content->>'tooth_position'`
);
branches
.
push
(
Prisma
.
sql
`
SELECT ex.patient_id, eet AS tooth,
${
SB_NO_GATE
}
AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts ex
CROSS JOIN unnest(
${
exTeeth
}
) AS eet
WHERE
${
sbScope
(
'ex'
)}
AND ex.type = 'treatment_record' AND ex.kind = 'actual' AND ex.status IN ('active','fulfilled')
AND ex.content->>'category' = 'surgical'
AND eet ~ '^[1-4][45]$'
AND NOT EXISTS (
SELECT 1 FROM unnest(
${
exTeeth
}
) AS xt WHERE xt !~ '^[1-4][45]$'
)
AND (
${
exTeeth
}
@> ARRAY['14','24']::text[]
OR
${
exTeeth
}
@> ARRAY['34','44']::text[])
AND EXISTS (SELECT 1 FROM patient_facts oc WHERE oc.patient_id = ex.patient_id
AND ((oc.type='diagnosis_record' AND oc.status='active' AND oc.content->>'code'='K07')
OR (oc.type='treatment_record' AND oc.content->>'category'='orthodontic')))`
);
}
// ⑬ 「建议拔除」让位同牙编码病种诊断 —— 与 sig 无关,gate 恒过
if
(
cfgFlags
.
deferToToothDx
)
{
branches
.
push
(
Prisma
.
sql
`
SELECT dxx.patient_id, ddt AS tooth,
${
SB_NO_GATE
}
AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts dxx
CROSS JOIN unnest(
${
toothArrSql
(
Prisma
.
sql
`dxx.content->>'tooth_position'`
)}
) AS ddt
WHERE
${
sbScope
(
'dxx'
)}
AND dxx.type = 'diagnosis_record' AND dxx.status = 'active'
AND dxx.content->>'code' = ANY(ARRAY['K00','K01','K02','K03','K04','K06','K08','K09']::text[])`
);
}
const
branchUnion
=
Prisma
.
join
(
branches
,
'
\
n UNION ALL
\
n'
);
const
postCtes
=
Prisma
.
sql
`,
gap_scope AS MATERIALIZED (
SELECT DISTINCT gap_patient_id AS patient_id FROM gap_cand
),
gap_resolved AS MATERIALIZED (
-- 按 (患者, 牙位, 病历号, 严格性, 需诊断信号) 聚合出每组的 max(时间门)
SELECT patient_id, tooth, max(gate) AS gate, enc, strict, ndx
FROM (
${
branchUnion
}
) sb(
${
SB_COLS
}
)
GROUP BY patient_id, tooth, enc, strict, ndx
),
gap_rem AS MATERIALIZED (
-- 牙位级反连接。WITH ORDINALITY + ORDER BY ord:保持 legacy 的 unnest 自然顺序,
-- 也保留重复牙位 —— tooth 串会落进 plan_reasons 给客服看,顺序变了就是 diff 噪音。
SELECT c.gap_sig_id AS sig_id,
array_agg(u.x ORDER BY u.ord) AS remaining_teeth
FROM gap_cand c
CROSS JOIN LATERAL unnest(c.gap_sig_teeth) WITH ORDINALITY AS u(x, ord)
WHERE NOT EXISTS (
SELECT 1 FROM gap_resolved r
WHERE r.patient_id = c.gap_patient_id
AND r.tooth = u.x
AND (CASE WHEN r.strict THEN r.gate > c.gap_anchor ELSE r.gate >= c.gap_anchor END)
AND (r.enc IS NULL OR r.enc = c.gap_sig_enc)
AND (NOT r.ndx OR c.gap_sig_type = 'diagnosis_record')
)
GROUP BY c.gap_sig_id
)`
;
return
{
candExtraCols
,
candWhere
:
externalTreatmentGate
,
postCtes
,
remJoin
:
Prisma
.
sql
`LEFT JOIN gap_rem ON gap_rem.sig_id = c.gap_sig_id`
,
// 🔴 gap_rem 无行 ≠ 空数组:牙位全被解决的 sig 在 gap_rem 里没有行 → COALESCE 补 {}
toothOutput
:
Prisma
.
sql
`array_to_string(COALESCE(gap_rem.remaining_teeth, ARRAY[]::text[]), ';')`
,
outerWhere
:
Prisma
.
sql
`AND cardinality(COALESCE(gap_rem.remaining_teeth, ARRAY[]::text[])) > 0`
,
};
};
}
}
apps/pac-service/src/modules/clinical-gap/potential-treatment.selector.ts
View file @
6d3ba7a1
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
.
lateralJoi
n
}
${
joinAddo
n
}
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
,
...
...
apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts
View file @
6d3ba7a1
...
@@ -10,6 +10,7 @@ import {
...
@@ -10,6 +10,7 @@ import {
treatmentCategoryNameZhFor
,
treatmentCategoryNameZhFor
,
recommendedCategoriesForAge
,
recommendedCategoriesForAge
,
refineCategoriesForDiagnosis
,
refineCategoriesForDiagnosis
,
type
DxTreatmentRule
,
}
from
'@pac/types'
;
}
from
'@pac/types'
;
import
{
PrismaService
}
from
'../../../../prisma/prisma.service'
;
import
{
PrismaService
}
from
'../../../../prisma/prisma.service'
;
import
type
{
import
type
{
...
@@ -20,7 +21,14 @@ import type {
...
@@ -20,7 +21,14 @@ import type {
import
{
calcPriority
}
from
'../priority-scorer'
;
import
{
calcPriority
}
from
'../priority-scorer'
;
import
{
toothSet
}
from
'../../../sync/pipeline/parsers/tooth-position.util'
;
import
{
toothSet
}
from
'../../../sync/pipeline/parsers/tooth-position.util'
;
// ⭐ gap 核心单一真理源(召回 + 潜在治疗画像共用;SQL 逻辑搬此,本文件只组装)
// ⭐ gap 核心单一真理源(召回 + 潜在治疗画像共用;SQL 逻辑搬此,本文件只组装)
import
{
buildGapCore
,
GAP_FLAGS_BY_PRIMARY
,
GAP_PRIMARY_GROUPS
}
from
'../../../clinical-gap/potential-treatment-gap.sql'
;
import
{
buildGapCore
,
GAP_FLAGS_BY_PRIMARY
,
GAP_PRIMARY_GROUPS
,
gapVariant
,
type
GapVariant
,
}
from
'../../../clinical-gap/potential-treatment-gap.sql'
;
/**
/**
* 潜在治疗新链召回(treatment_initiation_recall)— v2.1 重写
* 潜在治疗新链召回(treatment_initiation_recall)— v2.1 重写
...
@@ -270,35 +278,31 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
...
@@ -270,35 +278,31 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 子场景跑 SQL + 算 6 因子分
// 子场景跑 SQL + 算 6 因子分
// ─────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────
private
async
runSubScenario
(
/**
* 构造单个子场景的召回 SQL。**抽成独立方法是为了让对拍工具能拿到线上跑的那条 SQL 本身** ——
* verify-gap-equivalence 用同一份代码生成 legacy / setbased 两版再逐行差分,
* 不另写一份查询(另写就变成"验证我抄得对不对",而不是验证线上行为)。
*/
buildScenarioSql
(
scope
:
ScenarioScope
,
scope
:
ScenarioScope
,
subKey
:
string
,
primaryCode
:
string
,
cfg
:
(
typeof
TreatmentInitiationRecallScenario
.
SUB_SCENARIOS
)[
keyof
typeof
TreatmentInitiationRecallScenario
.
SUB_SCENARIOS
],
rule
:
DxTreatmentRule
,
):
Promise
<
ScenarioHit
[]
>
{
variant
:
GapVariant
,
// 临床窗口 / 类别 / 紧迫临界从 canonical-codes.DiagnosisTreatmentMap 单一真理源读
):
Prisma
.
Sql
{
const
rule
=
lookupDxTreatment
(
cfg
.
primaryCode
);
if
(
!
rule
)
{
throw
new
Error
(
`SUB_SCENARIOS[
${
subKey
}
].primaryCode=
${
cfg
.
primaryCode
}
在 DiagnosisTreatmentMap 中找不到 — `
+
`检查 canonical-codes.ts(单一真理源)`
,
);
}
const
start
=
rule
.
cooldownDays
;
const
goldenRange
:
[
number
,
number
]
=
[
rule
.
cooldownDays
,
rule
.
windowDays
];
// 码分组 → 单一真理源 GAP_PRIMARY_GROUPS(召回 + 潜在治疗画像共用,不在 SUB_SCENARIOS 内联)
// 码分组 → 单一真理源 GAP_PRIMARY_GROUPS(召回 + 潜在治疗画像共用,不在 SUB_SCENARIOS 内联)
const
grp
=
GAP_PRIMARY_GROUPS
[
cfg
.
primaryCode
]
??
{
dxCodes
:
[],
recCodes
:
[]
};
const
grp
=
GAP_PRIMARY_GROUPS
[
primaryCode
]
??
{
dxCodes
:
[],
recCodes
:
[]
};
const
dxCodes
=
grp
.
dxCodes
as
readonly
string
[];
const
dxCodes
=
grp
.
dxCodes
as
readonly
string
[];
const
recCodes
=
grp
.
recCodes
as
readonly
string
[];
const
recCodes
=
grp
.
recCodes
as
readonly
string
[];
const
allCodes
=
[...
dxCodes
,
...
recCodes
];
const
allCodes
=
[...
dxCodes
,
...
recCodes
];
// §E gap 修正 flag → 单一真理源 GAP_FLAGS_BY_PRIMARY(召回 + 潜在治疗画像共用)
// §E gap 修正 flag → 单一真理源 GAP_FLAGS_BY_PRIMARY(召回 + 潜在治疗画像共用)
const
cfgFlags
=
GAP_FLAGS_BY_PRIMARY
[
cfg
.
primaryCode
]
??
{};
const
cfgFlags
=
GAP_FLAGS_BY_PRIMARY
[
primaryCode
]
??
{};
// ⭐ 两个口径分开(单一真理源 canonical-codes):
// ⭐ 两个口径分开(单一真理源 canonical-codes):
// expectedCats = rule.categories(窄,主治疗)→ 展示"未启动 X" + 触发预期 + ⑤d 主诉匹配
// expectedCats = rule.categories(窄,主治疗)→ 展示"未启动 X" + 触发预期 + ⑤d 主诉匹配
// resolverCats = resolverCategoriesFor(宽,治疗家族)→ ⑤a "已解决" 判定
// resolverCats = resolverCategoriesFor(宽,治疗家族)→ ⑤a "已解决" 判定
// 结构码(K02/K03/K08…)= 任何局部结构治疗都算(充填/根管/冠桥/种植/外科/美学/儿牙);
// 结构码(K02/K03/K08…)= 任何局部结构治疗都算(充填/根管/冠桥/种植/外科/美学/儿牙);
// 牙周/正畸(K05/K06/K07)沿用各自 categories。见 canonical-codes.resolverCategoriesFor。
// 牙周/正畸(K05/K06/K07)沿用各自 categories。见 canonical-codes.resolverCategoriesFor。
const
expectedCats
=
rule
.
categories
as
readonly
string
[];
const
resolverCats
=
resolverCategoriesFor
(
primaryCode
)
as
readonly
string
[];
const
resolverCats
=
resolverCategoriesFor
(
cfg
.
primaryCode
)
as
readonly
string
[];
// 收窄(可空,两种粒度):
// 收窄(可空,两种粒度):
// - scope.patientId 单患者(详情页"刷新"):O(全租户) → O(1)
// - scope.patientId 单患者(详情页"刷新"):O(全租户) → O(1)
...
@@ -322,7 +326,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
...
@@ -322,7 +326,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ⭐ gap 核心(sig 牙位 / resolved / remaining + ⑤a 判定 + 废用牙/先天剔除)抽到共享模块
// ⭐ gap 核心(sig 牙位 / resolved / remaining + ⑤a 判定 + 废用牙/先天剔除)抽到共享模块
// potential-treatment-gap.sql —— 召回与潜在治疗画像【单一真理源】,SQL 逻辑零改动只搬家。
// potential-treatment-gap.sql —— 召回与潜在治疗画像【单一真理源】,SQL 逻辑零改动只搬家。
// 召回在此基础上再加时间门(④ cooldown / ⑤b 预约 / ⑤d entered / ⑤f 到诊)+ 6 因子打分。
// 召回在此基础上再加时间门(④ cooldown / ⑤b 预约 / ⑤d entered / ⑤f 到诊)+ 6 因子打分。
const
gap
=
buildGapCore
({
rule
,
cfgFlags
,
allCodes
,
resolverCats
});
const
gap
=
buildGapCore
({
rule
,
cfgFlags
,
allCodes
,
resolverCats
,
variant
});
// ╔═════════════════════════════════════════════════════════════════════╗
// ╔═════════════════════════════════════════════════════════════════════╗
// ║ 召回 SQL 完整解读(initiation = 潜在治疗新链召回) ║
// ║ 召回 SQL 完整解读(initiation = 潜在治疗新链召回) ║
...
@@ -389,8 +393,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
...
@@ -389,8 +393,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ║ 输出:每个命中 (patient × sig) 一行,后段 byPatient Map 去重 ║
// ║ 输出:每个命中 (patient × sig) 一行,后段 byPatient Map 去重 ║
// ║ 只留 daysSince 最大那条(最早诊断 = 最有召回价值) ║
// ║ 只留 daysSince 最大那条(最早诊断 = 最有召回价值) ║
// ╚═════════════════════════════════════════════════════════════════════╝
// ╚═════════════════════════════════════════════════════════════════════╝
const
scenarioSql
=
Prisma
.
sql
`
// ── 投影列(legacy / setbased 两形态共用;tooth 单列,两边取法不同)──
SELECT
const
projection
=
Prisma
.
sql
`
p.id AS patient_id,
p.id AS patient_id,
p.external_id AS patient_external_id,
p.external_id AS patient_external_id,
-- 建议治疗的年龄适配用(只影响"建议做什么",不参与召回筛选/排除;无生日 → NULL 走默认)
-- 建议治疗的年龄适配用(只影响"建议做什么",不参与召回筛选/排除;无生日 → NULL 走默认)
...
@@ -401,19 +405,39 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
...
@@ -401,19 +405,39 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
-- 诊断原文词(K00 主类目细分用:滞留→外科 / 早失→正畸 / 先天缺→修复…)。
-- 诊断原文词(K00 主类目细分用:滞留→外科 / 早失→正畸 / 先天缺→修复…)。
-- 纯投影,不进任何 WHERE —— 返回行集与加它之前逐行相同。
-- 纯投影,不进任何 WHERE —— 返回行集与加它之前逐行相同。
sig.content->>'name_zh' AS signal_name_zh,
sig.content->>'name_zh' AS signal_name_zh,
-- ⭐ 牙位级相减:有牙位信号 → 剩余未治牙位;全口信号 → 原样 NULL(gap 核心,共享模块)
${
gap
.
toothOutput
}
AS tooth,
sig.content->>'extracted_by' AS extracted_by,
sig.content->>'extracted_by' AS extracted_by,
sig.content->>'confidence' AS confidence,
sig.content->>'confidence' AS confidence,
sig.content->>'code_source' AS code_source, -- 置信度因子:std_code/name_map=医生 / image_ai / null
sig.content->>'code_source' AS code_source, -- 置信度因子:std_code/name_map=医生 / image_ai / null
sig.clinic_id AS clinic_id,
sig.clinic_id AS clinic_id,
COALESCE(sig.occurred_at, sig.planned_for) AS signal_occurred_at,
COALESCE(sig.occurred_at, sig.planned_for) AS signal_occurred_at,
EXTRACT(DAY FROM
${
scope
.
now
}
::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since
EXTRACT(DAY FROM
${
scope
.
now
}
::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since`
;
// setbased 外层从 gap_cand 取同名列(顺序无关,消费方按列名映射)
const
outerProjection
=
Prisma
.
raw
(
[
'patient_id'
,
'patient_external_id'
,
'patient_age'
,
'signal_fact_id'
,
'signal_type'
,
'signal_code'
,
'signal_name_zh'
,
'extracted_by'
,
'confidence'
,
'code_source'
,
'clinic_id'
,
'signal_occurred_at'
,
'days_since'
,
]
.
map
((
c
)
=>
`c.
${
c
}
`
)
.
join
(
', '
),
);
// ── FROM + 全部闸(①隔离 ②合规 ③信号 ④cooldown ⑤b/⑤f/⑤g)——
// joinAddon = legacy 的 gap lateral(setbased 为空);gapAddon = gap 判定
const
queryBody
=
(
joinAddon
:
Prisma
.
Sql
,
gapAddon
:
Prisma
.
Sql
):
Prisma
.
Sql
=>
Prisma
.
sql
`
FROM patients p
FROM patients p
JOIN patient_profiles pp ON pp.patient_id = p.id
JOIN patient_profiles pp ON pp.patient_id = p.id
JOIN patient_facts sig ON sig.patient_id = p.id
JOIN patient_facts sig ON sig.patient_id = p.id
-- ⭐ 按牙相减(sig 牙位 / 已解决 / 剩余未治)— gap 核心,共享模块单一真理源
${
joinAddon
}
${
gap
.
lateralJoin
}
WHERE p.host_id =
${
scope
.
hostId
}
::uuid -- ① 隔离闸
WHERE p.host_id =
${
scope
.
hostId
}
::uuid -- ① 隔离闸
AND p.tenant_id =
${
scope
.
tenantId
}
-- ① 隔离闸
AND p.tenant_id =
${
scope
.
tenantId
}
-- ① 隔离闸
${
patientFilter
}
-- 单刷 / 子集收窄(可空)
${
patientFilter
}
-- 单刷 / 子集收窄(可空)
...
@@ -428,12 +452,12 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
...
@@ -428,12 +452,12 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
AND sig.type IN ('diagnosis_record', 'recommendation_record') -- ③ 信号类型
AND sig.type IN ('diagnosis_record', 'recommendation_record') -- ③ 信号类型
AND sig.content->>'code' = ANY(
${
allCodes
}
::text[]) -- ③ 信号 code 命中
AND sig.content->>'code' = ANY(
${
allCodes
}
::text[]) -- ③ 信号 code 命中
AND COALESCE(sig.occurred_at, sig.planned_for) IS NOT NULL -- ④ 时间不为空
AND COALESCE(sig.occurred_at, sig.planned_for) IS NOT NULL -- ④ 时间不为空
AND COALESCE(sig.occurred_at, sig.planned_for) <=
${
this
.
daysAgo
(
scope
.
now
,
start
)}
::timestamptz -- ④ 过 cooldown
AND COALESCE(sig.occurred_at, sig.planned_for) <=
${
this
.
daysAgo
(
scope
.
now
,
rule
.
cooldownDays
)}
::timestamptz -- ④ 过 cooldown
-- ④' 废用牙/无功能牙剔除 + ④' §E 先天缺失剔除 + ⑤a 牙位级 gap 判定(全口 NOT EXISTS / 有牙位剩余非空)
-- ④' 废用牙/无功能牙剔除 + ④' §E 先天缺失剔除 + ⑤a 牙位级 gap 判定(全口 NOT EXISTS / 有牙位剩余非空)
-- —— 全部抽到 gap 核心(共享模块),召回与潜在治疗画像口径一致
-- —— 全部抽到 gap 核心(共享模块),召回与潜在治疗画像口径一致
${
gap
.
restorationIneligibleFrag
}
${
gap
.
restorationIneligibleFrag
}
${
gap
.
congenitalFrag
}
${
gap
.
congenitalFrag
}
${
gap
.
gapWhere
}
${
gap
Addon
}
-- (⑤c 同牙位拔除 已折进 resolved_teeth 的 surgical 分支 — 拔了的牙从 remaining 减掉)
-- (⑤c 同牙位拔除 已折进 resolved_teeth 的 surgical 分支 — 拔了的牙从 remaining 减掉)
AND NOT EXISTS ( -- ⑤b 排除:患者已有未来预约
AND NOT EXISTS ( -- ⑤b 排除:患者已有未来预约
-- 召回目的 = 让客服建预约。患者已经有未来预约 → 客服不需要再 push,医生到诊现场处理即可
-- 召回目的 = 让客服建预约。患者已经有未来预约 → 客服不需要再 push,医生到诊现场处理即可
...
@@ -478,8 +502,50 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
...
@@ -478,8 +502,50 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
SELECT 1 FROM patient_return_visits rv
SELECT 1 FROM patient_return_visits rv
WHERE rv.patient_id = p.id
WHERE rv.patient_id = p.id
AND rv.task_date >
${
scope
.
now
}
::date
AND rv.task_date >
${
scope
.
now
}
::date
)
)`
;
const
sb
=
gap
.
setBased
;
return
sb
?
// ══ 集合式:候选 → 患者域 → resolved 预聚合 → 牙位反连接 ══
// 见 docs/design/gap-set-based-rewrite-plan.md §4。gap_cand 必须 MATERIALIZED:
// 它被下游扫三次(gap_scope / gap_rem / 主查询),内联会让 ①②③④ 闸重算三遍。
Prisma
.
sql
`
WITH gap_cand AS MATERIALIZED (
SELECT
${
projection
}${
sb
.
candExtraCols
}
${
queryBody
(
Prisma
.
empty
,
sb
.
candWhere
)}
)
${
sb
.
postCtes
}
SELECT
${
outerProjection
}
,
${
sb
.
toothOutput
}
AS tooth
FROM gap_cand c
${
sb
.
remJoin
}
WHERE TRUE
${
sb
.
outerWhere
}
`
:
// ══ 逐行相关子查询(legacy)══
Prisma
.
sql
`
SELECT
${
projection
}
,
-- ⭐ 牙位级相减:有牙位信号 → 剩余未治牙位;全口信号 → 原样 NULL(gap 核心,共享模块)
${
gap
.
toothOutput
}
AS tooth
${
queryBody
(
gap
.
lateralJoin
,
gap
.
gapWhere
)}
`
;
`
;
}
private
async
runSubScenario
(
scope
:
ScenarioScope
,
subKey
:
string
,
cfg
:
(
typeof
TreatmentInitiationRecallScenario
.
SUB_SCENARIOS
)[
keyof
typeof
TreatmentInitiationRecallScenario
.
SUB_SCENARIOS
],
):
Promise
<
ScenarioHit
[]
>
{
// 临床窗口 / 类别 / 紧迫临界从 canonical-codes.DiagnosisTreatmentMap 单一真理源读
const
rule
=
lookupDxTreatment
(
cfg
.
primaryCode
);
if
(
!
rule
)
{
throw
new
Error
(
`SUB_SCENARIOS[
${
subKey
}
].primaryCode=
${
cfg
.
primaryCode
}
在 DiagnosisTreatmentMap 中找不到 — `
+
`检查 canonical-codes.ts(单一真理源)`
,
);
}
const
start
=
rule
.
cooldownDays
;
const
goldenRange
:
[
number
,
number
]
=
[
rule
.
cooldownDays
,
rule
.
windowDays
];
const
expectedCats
=
rule
.
categories
as
readonly
string
[];
const
scenarioSql
=
this
.
buildScenarioSql
(
scope
,
cfg
.
primaryCode
,
rule
,
gapVariant
());
// ══════════════════════════════════════════════════════════════════
// ══════════════════════════════════════════════════════════════════
// 📊 2026-08-29 全量重算耗时归因(测试服 585K 患者 / 87,661 命中,空闲机)
// 📊 2026-08-29 全量重算耗时归因(测试服 585K 患者 / 87,661 命中,空闲机)
...
@@ -696,7 +762,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
...
@@ -696,7 +762,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 也不知道是 SQL 还是后处理慢。2026-08-29 生产 plan 段从 12 分钟涨到 159 分钟
// 也不知道是 SQL 还是后处理慢。2026-08-29 生产 plan 段从 12 分钟涨到 159 分钟
// 仍未跑完,是临时加采样器才定位到子场景粒度的 —— 那种事不该再来一次。
// 仍未跑完,是临时加采样器才定位到子场景粒度的 —— 那种事不该再来一次。
this.logger.log(
this.logger.log(
`
[
recall
]
sub
=
$
{
subKey
}
code
=
$
{
cfg
.
primaryCode
}
` +
`
[
recall
]
sub
=
$
{
subKey
}
code
=
$
{
cfg
.
primaryCode
}
gap
=
$
{
gapVariant
()}
` +
`
sql
=
$
{
sqlMs
}
ms
rows
=
$
{
rows
.
length
}
post
=
$
{
Date
.
now
()
-
postStart
}
ms
hits
=
$
{
hits
.
length
}
`,
`
sql
=
$
{
sqlMs
}
ms
rows
=
$
{
rows
.
length
}
post
=
$
{
Date
.
now
()
-
postStart
}
ms
hits
=
$
{
hits
.
length
}
`,
);
);
return hits;
return hits;
...
...
apps/pac-service/tests/gap-setbased-parity.spec.ts
0 → 100644
View file @
6d3ba7a1
/**
* 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
,
/
\b
UNION
\b(?!\s
+ALL
)
/g
)
+
1
;
const
setBranches
=
count
(
setbased
,
/
\b
UNION 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
(
/
\b
UNION 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
(
/
\b
UNION 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/
);
});
});
docs/design/gap-set-based-rewrite-plan.md
0 → 100644
View file @
6d3ba7a1
# resolvedTeeth 集合式重写 · 方案
> 目标:把 `buildGapCore` 的 `resolvedTeethSql` 从**逐 (患者×信号) 行相关子查询**改成**集合式预聚合 + 连接**,
> 让 plan 全量重算能装进 2 小时增量窗口。
> 现状锚点:生产全轮 场景段 3h11m / 总 3h41m(work_mem=256MB 之后);测试机全轮 23.3 分钟。
---
## 0. 开工前先复核的两件事(2026-08-30 本地实测,直接改变了方案边界)
### 0.1 ⛔ 全口场景(K05/K07)的 lateral **PostgreSQL 已经自动删掉了** —— 集合式重写对它们零收益
`wholeMouth: true`
时
`sigToothExpr = NULL::text`
→
`st = {}`
;
`toothOutput`
是
`CASE WHEN TRUE THEN NULL`
,
`gapWhere`
是
`CASE WHEN TRUE THEN NOT EXISTS(...)`
——
两处常量折叠后
`lat`
无人引用,PG 的 useless-left-join removal 直接摘掉整个 LATERAL。
本地 pac-postgres 用
**贴真形态**
(lateral 内引用
`sig`
、外层挂 ⑤ 闸)EXPLAIN ANALYZE 验证:
```
Nested Loop
-> Parallel Bitmap Heap Scan on patient_facts sig
-> Memoize -> Index Scan on patients p
Filter: (active AND (NOT (SubPlan 1))) ← 只剩患者级 NOT EXISTS
计划里没有任何 lateral / Append 节点
```
**推论**
:测试机 23.3 分钟里
**K05 牙周 9m13s**
花的不是 resolvedTeeth 的钱,是
「1.22M 信号行扫描 + 5 个患者级 NOT EXISTS 闸」的钱。本方案
**帮不上 K05/K07**
,
它们要单独立项(反连接化 / 候选预筛),不在本文范围。
> 收益上限因此要下修:测试机两条慢查询 K05(9m13s,不受益)+ K01(13m,受益),
> 本方案吃掉的是 K01 那一半里的 68%。**全轮预期改善 ≈ 35~40%,不足以单独装进 2 小时。**
> Phase 0 必须先量生产上「牙位级子场景合计耗时占比」,别砍掉 68% 的一个小分母。
### 0.2 本地单分支对拍只快 1.26× —— 收益必须先量,不能先写
30K 患者本地库、K01、只取 (a) 治疗家族一个分支:
| 形态 | 墙钟 |
|---|---|
| A 现状(逐行相关子查询) | 1484 ms |
| B 集合式(cand → scope → res 预聚合 → 反连接) | 1178 ms |
同一分支 UNION 三份 → 1693 ms,
**边际分支代价只有 ~105ms**
,说明本地瓶颈根本不在分支数上。
本地库太小、全热、去重比只有 1.55×(15,195 候选行 / 9,789 患者),
**不能外推到生产**
。
但足以定一条纪律:
**先建对拍与基准,单子场景试点达标再铺开**
——
这条分支上已经有两个「理论漂亮、实测为零」的前科(子场景并发、信号码部分索引)。
---
## 1. 适用面
| 子场景 | primaryCode | wholeMouth | 本方案是否受益 |
|---|---|---|---|
| missing_tooth | K08 | ✗ | ✅(分支最多,12 条) |
| caries_no_filling | K02 | ✗ | ✅ |
| hard_tissue_damage | K03 | ✗ | ✅ |
| endo_no_rct | K04 | ✗ | ✅ |
| impacted_tooth | K01 | ✗ | ✅(生产/测试最慢那条) |
| jaw_cyst | K09 | ✗ | ✅ |
| development_eruption | K00 | ✗ | ✅ |
| gum_alveolar_lesion | K06 | ✗ | ✅ |
| extraction_recommended | EXTRACTION_RECOMMENDED | ✗ | ✅ |
| perio_no_srp | K05 | ✓ | ❌ 见 §0.1 |
| ortho_no_consult | K07 | ✓ | ❌ 见 §0.1 |
**关键不变式(要写成断言)**
:
`excludeIfEverTreated ⟺ wholeMouth`
(当前只有 K05/K07 两者同时为真)。
所以
**9 个牙位级场景里 `afterDxFor` 永远是 sig 锚点形态**
,集合式路径不必处理
`latestDxOfCode`
那一支。
未来谁给某个牙位级 rule 加
`excludeIfEverTreated`
,断言必须炸。
---
## 2. 核心恒等式
时间门全部是
`∃ 行 x : t(x) ⋛ 锚点`
的形状,而
```
∃ x ∈ G : t(x) >= a ⟺ max{ t(x) : x ∈ G } >= a
∃ x ∈ G : t(x) > a ⟺ max{ t(x) : x ∈ G } > a
```
分组 G =
`(patient_id, tooth)`
,组内其余谓词(category / status / 正则 / 牙位纯度…)
**全部与 sig 无关**
,
所以可以先按组聚合
`max(t)`
,再跟每个信号的锚点比大小 —— 一次算完全部患者。
---
## 3. 13 个分支的相关性分类(这是重写的全部依据)
| # | 代码标记 | 别名 | 相关类型 | 聚合键 | 门 |
|---|---|---|---|---|---|
| 1 | (a) 治疗家族 resolver | rtx | 时间 | (pid, tooth) |
`max(occurred_at) >= anchor`
|
| 2 | (a'') 桥区间填洞 | btx | 时间 | (pid, arch_tooth) |
`max(occurred_at) >= anchor`
|
| 3 | (a''') 缺牙间隙关闭 | emrx | 时间 | (pid, tooth) |
`max(occurred_at) >= anchor`
|
| 4 | (a'''') 患者拒绝 | rfx | 时间 | (pid, tooth) |
`max(COALESCE(occ,planned)) >= anchor`
|
| 5 | (b) 更晚结构诊断 | ldx | 时间(
**严格 >**
) | (pid, tooth) |
`max(occurred_at) > anchor`
|
| 6 | (c) 建议优先于诊断 | rdx | 时间
**+ sig.type**
| (pid, tooth) |
`max(COALESCE) >= anchor`
∧
`sig.type='diagnosis_record'`
|
| 7 | (a''''') 复查即治疗 | rvx | 时间 | (pid, tooth) |
`max >= anchor`
|
| 8 | (a'''''') 抛光即修复 | plx | 时间 | (pid, tooth) |
`max >= anchor`
|
| 9 | (a''''''') 病历文本自证 | tvx ⋈ emx | 时间 | (pid, tooth) |
`max >= anchor`
|
| 10 | (a'''''''') 检查所见修复在位 | rix | 时间 | (pid, tooth) |
`max >= anchor`
|
| 11 | (a''''''''') 整颌活动义齿 | adx |
**等值(病历号)**
| (pid, emr_ext_id, tooth) |
`emr_ext_id = sig.source_encounter_external_id`
|
| 12 | §E 正畸减数位 | ex |
**无**
| (pid, tooth) | 恒真 |
| 13 | 建议拔除让位病种 | dxx |
**无**
| (pid, tooth) | 恒真 |
只有三类:
**时间门 / 病历号等值 / 无相关**
。第 6 条多一个
`sig.type`
标量谓词,提到连接条件即可。
分支 9 的
`tvx ⋈ emx`
与分支 11 的
`adx ⋈ sig`
要分清:前者是
**源内部**
的连接(与 sig 无关,照常预聚合),
后者才是
**与 sig 的等值相关**
(进聚合键)。
---
## 4. 目标 SQL 形态
```
sql
WITH
cand
AS
MATERIALIZED
(
-- 候选 (患者×信号),已过 ①隔离 ②合规 ③信号 ④cooldown
SELECT
p
.
id
AS
patient_id
,
sig
.
id
AS
sig_id
,
COALESCE
(
sig
.
occurred_at
,
sig
.
planned_for
)
AS
anchor
,
sig
.
type
AS
sig_type
,
sig
.
content
->>
'source_encounter_external_id'
AS
sig_enc
,
<
toothArrSql
(
sigToothExpr
,
乳牙
/
智齿剔除
)
>
AS
sig_teeth
,
...
投影列
(
code
/
name_zh
/
clinic_id
/
days_since
…
)
FROM
patients
p
JOIN
patient_profiles
pp
ON
…
JOIN
patient_facts
sig
ON
…
WHERE
<
①②③④
+
patientFilter
+
restorationIneligible
+
congenital
>
),
scope
AS
MATERIALIZED
(
SELECT
DISTINCT
patient_id
FROM
cand
),
res
AS
MATERIALIZED
(
-- 13 个分支 UNION ALL,统一四元组
-- (patient_id, tooth, gate, enc, strict, needs_dx_sig)
-- gate = 'infinity'::timestamptz 表示"无时间门"(恒过);时间门分支保证 gate NOT NULL
SELECT
patient_id
,
tooth
,
max
(
gate
)
AS
gate
,
enc
,
strict
,
needs_dx_sig
FROM
(
<
branch1
>
UNION
ALL
<
branch2
>
…
UNION
ALL
<
branch13
>
)
b
GROUP
BY
patient_id
,
tooth
,
enc
,
strict
,
needs_dx_sig
),
rem
AS
(
-- 牙位级反连接
SELECT
c
.
sig_id
,
array_agg
(
u
.
x
ORDER
BY
u
.
ord
)
AS
remaining_teeth
FROM
cand
c
CROSS
JOIN
LATERAL
unnest
(
c
.
sig_teeth
)
WITH
ORDINALITY
AS
u
(
x
,
ord
)
WHERE
NOT
EXISTS
(
SELECT
1
FROM
res
r
WHERE
r
.
patient_id
=
c
.
patient_id
AND
r
.
tooth
=
u
.
x
AND
(
CASE
WHEN
r
.
strict
THEN
r
.
gate
>
c
.
anchor
ELSE
r
.
gate
>=
c
.
anchor
END
)
AND
(
r
.
enc
IS
NULL
OR
r
.
enc
=
c
.
sig_enc
)
AND
(
NOT
r
.
needs_dx_sig
OR
c
.
sig_type
=
'diagnosis_record'
)
)
GROUP
BY
c
.
sig_id
)
SELECT
…
,
array_to_string
(
COALESCE
(
rem
.
remaining_teeth
,
ARRAY
[]::
text
[]),
';'
)
AS
tooth
FROM
cand
c
LEFT
JOIN
rem
ON
rem
.
sig_id
=
c
.
sig_id
WHERE
cardinality
(
COALESCE
(
rem
.
remaining_teeth
,
ARRAY
[]::
text
[]))
>
0
AND
<
⑤
b
未来预约
/
⑤
f
到诊冷静
/
⑤
g
未来回访
——
召回独有
,
画像不加
>
```
要点:
-
每个分支源表
**`JOIN scope s ON s.patient_id = x.patient_id`**
—— 没有这一句,CTE 会全表扫 34GB heap。
-
`toothArrSql`
从「每候选行算一次」降到「每源事实行算一次」,这是省钱的第二处。
-
画像单患者路径
`scope`
= 1 行 → 全部走
`patient_facts_patient_id_type_status_idx`
,形态不退化。
---
## 5. 五个必须踩准的坑(每一个都通向静默少召)
1.
**NULL 时间不能靠 max() 自然消化**
。
`max()`
忽略 NULL,全 NULL 组聚成 NULL;
若拿 NULL 表示「无时间门」,这种组会被当成恒过 → 误销 → 少召。
⇒ 时间门分支内
`WHERE occurred_at IS NOT NULL`
(等价:NULL 本来就过不了
`>= anchor`
),
无时间门分支显式写
`'infinity'::timestamptz`
。
**`gate` 列永不为 NULL。**
2.
**分支 5 是严格 `>`**
,其余是
`>=`
。合并成一列后必须带
`strict`
标志,不能"统一成 >= 反正差不多"。
3.
**`remaining_teeth` 的顺序与重复要原样保留**
。现状是
`unnest(st)`
的自然顺序、不去重;
`tooth`
字符串会落进 plan_reasons 给客服看,顺序变了就是 diff 噪音。⇒
`WITH ORDINALITY`
+
`ORDER BY ord`
。
4.
**`rem` 无行 ≠ `{}`**
。全部牙位被解决的 sig 在
`rem`
里没有行,LEFT JOIN 得 NULL;
`cardinality(NULL) > 0`
是 NULL(等价于假,行为一致),但
`toothOutput`
会从
`''`
变 NULL。⇒ 一律
`COALESCE(…, '{}')`
。
5.
**CTE 没有统计信息**
。
`WITH … AS MATERIALIZED`
的行数估计是瞎猜,可能选错连接方式。
先例:
`apps/pac-service/sql/verify-recall.sql`
用的是
**临时表 + CREATE INDEX + ANALYZE**
,
注释里写明「13 万患者秒级(旧逐行相关子查询版会 O(n²) 卡死)」。
⇒ 若 CTE 形态在测试机上计划走歪,退回临时表形态(代价:一次事务内多两条 DDL)。
---
## 6. 接口与代码量
| 文件 | 改动 | 量 |
|---|---|---|
|
`clinical-gap/potential-treatment-gap.sql.ts`
|
`resolvedTeethSql`
(107 行)→ 13 个分支 CTE 生成器 +
`res`
/
`rem`
拼装;
`GapCorePieces`
新增
`withCtes`
/
`candProjection`
/
`remainingExpr`
;
`buildGapCore`
新增入参
`scope: Prisma.Sql`
、
`variant: 'legacy'|'setbased'`
| +190 / −110 |
|
`plan/engine/scenarios/treatment-initiation-recall.scenario.ts`
| 主查询包
`WITH`
,⑤ 闸留在外层 | +20 |
|
`clinical-gap/potential-treatment.selector.ts`
| 同上,
`scope`
= 单患者 | +18 |
|
`cli/recompute-plans.cli.ts`
|
`--only=<subKey>`
试点开关 | +8 |
|
**新**
`sql/verify-gap-equivalence.sql`
(或 CLI) | 对拍工具 ——
**真正的工作量**
| ~250 |
|
`tests/gap-setbased-shape.spec.ts`
(新) | 锁「13 分支都进了 res」「wholeMouth 不生成 lateral」「gate 列无 NULL 路径」 | ~120 |
`variant`
是
**临时脚手架**
:对拍工具靠它在同一份主查询里跑新旧两版。全量验收通过后连同 legacy 分支一起删。
### 现有测试保护不了这次重写(已核)
`arch-denture-false-missing`
/
`polish-implies-restoration`
/
`restoration-in-place-from-exam`
/
`treated-evidence-from-emr-text`
/
`review-implies-treatment`
五个 spec
**全部是纯 JS 常量与正则断言**
,
不碰 SQL 行为 —— 重写后它们照样全绿。
**101 套 / 1790 个测试对本次改动的保护 ≈ 0。**
这就是为什么对拍工具不是"顺便加的验证",而是本项目的主体。
---
## 7. 分期与验收门
### Phase 0 · 对拍 + 基准(不改任何逻辑,独立可交付)
-
产出
`verify-gap-equivalence`
:对每个 primaryCode 跑两版,落
`(patient_id, sig_id, tooth)`
三元组,FULL OUTER JOIN 差分。
-
**必须同一快照**
:两版在
**同一个事务**
里跑(
`REPEATABLE READ`
),否则并发增量摄入会造出假差异。
-
顺带量清楚:生产 11 个子场景各自
`sql=ms`
占比 → 确认牙位级到底占多少(见 §0.1 的分母问题)。
-
**门:对 legacy 自己跑两遍必须零差异**
(先证明工具本身可信),且拿到生产逐子场景耗时分布。
### Phase 1 · 单子场景试点(K01)
-
只改 K01 路径走 setbased,测试机跑。
-
**门(两条都要过)**
:① 全量对拍
**零差异**
(不是"差异很少");②
`sql=ms`
至少快
**3×**
。
-
**不到 3× 就停**
,只留 Phase 0 的对拍工具。风险与收益不成比例。
### Phase 2 · 9 个牙位级场景全量
-
**门**
:9 个场景生产全量对拍零差异 + 全轮墙钟对比 + 命中患者数逐场景对比(参照上次「−0.43% / −2.5%」那套核法)。
### Phase 3 · 画像消费方
-
画像是
**逐患者**
调用(547K 次),任何单次开销都会被放大。
-
**门**
:单患者 p95 不劣化 >10%;
`recompute-persona --force`
全量墙钟不劣化;
`persona_features`
的
`detail[].teeth`
与旧版逐患者比对零差异。
### Phase 4 · 清理
-
删
`variant`
脚手架与 legacy 分支;更新 scenario 里那段耗时归因注释;
写一条 memory(集合式重写的结论 + 对拍工具位置)。
**工期**
:核心改写 1 天;对拍工具 1 天;试点与全量验证 2~3 天(全量跑批本身 3~6 小时/轮,是墙钟大头)。
---
## 8. 明确不做
-
**不动 `gapWhere` 的 wholeMouth 分支**
(§0.1:没收益,纯风险)。
-
**不动打分 / ⑤b ⑤f ⑤g 闸 / GAP_FLAGS / 词表正则**
—— 本次只换计算形态,口径一个字节不改。
-
**不把 11 条子场景合并成一条 SQL**
。诱人(公共分支只算一次),但对拍粒度会崩,
且
`resolverCats`
逐场景不同。等集合式站稳、对拍工具成熟后再单独评估。
-
**不上 `CREATE INDEX`**
。同分支已实测:EXPLAIN 成本降 13× 而墙钟 24.5 vs 23.3 分钟,无效。
## 9. K05/K07 另立一项(本文不解决)
测试机 9m13s 的钱在「信号扫描 + 患者级 NOT EXISTS」。已知线索:
`NOT EXISTS`
被裹在
`CASE WHEN <wholeMouthFlag> THEN … END`
里 → PG 不做 sublink 上拉,
只能走 per-row SubPlan(本地实测:裸写
`NOT EXISTS`
会变成 Anti Join,
`CASE`
包住则是 SubPlan)。
本地两者差距只有 13%(库小全热),生产是否放大
**未验**
。
在 TS 侧按
`rule.wholeMouth`
分叉生成 SQL(而不是靠 SQL 的 CASE)是零风险的第一步,值得单独试。
---
## 10. 实施记录 · 本地验收(2026-08-30)
分支
`perf/gap-set-based`
。默认仍走 legacy(
`PAC_GAP_VARIANT`
未设),集合式靠环境变量切换。
### 与方案的两处偏离(都已改回)
-
曾把全口码(K05/K07)也统一进
`gap_cand`
形态 ——
**实测慢 2~3 倍**
(ortho 1309→4439ms ×0.29;perio 1388→3065ms ×0.45),已按 §8 改回原样走 legacy。
`buildGapSetBased`
对
`rule.wholeMouth`
直接
`return null`
。
### 本地验收结果(30K 患者库)
| 项 | 结果 |
|---|---|
| 单元测试 | 102 套 / 1859 测试全绿(含新增
`gap-setbased-parity.spec.ts`
69 例) |
| SQL 层对拍 | 11 个子场景
**逐 (患者×信号×牙位) 零差异**
,行数逐个相同 |
| 端到端对拍 |
`plan_reasons`
39,182 条
**行级 diff = 0**
(含 priority_score) |
| 幂等 | 两版互相复跑都
`plansCreated=0 plansClosed=0`
|
| 场景段耗时 | legacy 34.2s → setbased 29.2s(×1.17) |
⚠️ 本地库小且全热、去重比低,
**×1.17 不作为收益判据**
—— 以测试机 585K 患者的实测为准。
### 对拍工具自检
`--self`
(legacy 对 legacy)先跑过,零差异 —— 证明工具本身不会把相同当不同。
## 11. 测试机验收协议(2026-08-30 夜)
脚本
`~/gapverify.sh`
(测试机),产物落
`~/gapbench/`
。
| 步 | 内容 | 门 |
|---|---|---|
| 0 | 工具自检:
`--self --sub=jaw_cyst`
(legacy 对 legacy) | 必须零差异,否则后面的"零差异"不可信 |
| 1 | 画像消费方:
`--persona=2000`
| 零差异 + 单患者 p95 不劣化 >10% |
| 2 | 召回 11 子场景:逐 (患者×信号×牙位) 双向 EXCEPT ALL |
**零差异**
(不是"差异很少") |
| 3 | 交替全量重算 A(legacy) → B(setbased) → C(legacy) → D(setbased) | 行级 diff 全 0;取 C vs D(都是热态第二轮)比墙钟 |
**为什么要交替四轮**
:第一轮把页读进缓存,第二轮才代表稳态。只跑 legacy→setbased
会让 setbased 白蹭前一轮的缓存,把收益量虚高 —— 这条分支上已经有两次"理论漂亮实测为零"
的前科,不能再拿有偏的数字下结论。
### ⛔ 对拍工具打出的 ×N **不能当收益判据**
工具总是先跑 legacy 再跑 setbased,第二条白蹭第一条预热出来的缓存。
2026-08-30 测试机自检实测(
`--self`
,同一条 legacy SQL 跑两遍):
```
jaw_cyst legacy=36,797ms legacy=4,861ms ×7.57
```
**缓存效应 7.6 倍,比任何真实形态差异大一个数量级。**
所以:
-
`verify-gap-equivalence`
的 ×N 只用来看"有没有离谱地变慢",不用来量收益
-
收益只认交替四轮里的
**C(legacy,第二轮) vs D(setbased,第二轮)**
—— 两边都是热态
**噪音源**
:测试机
`PAC_STALE_SCAN_CRON=0 2 * * *`
(画像 stale 扫描)会落在验收中段。
C/D 相邻,长时间后台负载对两者影响相近,不单边偏袒;若数字明显抖动,重跑 C/D 一对。
`PAC_INCREMENTAL_CRON=15 8 * * *`
在窗口之外。
### 分支覆盖自检(本地 30K 库,2026-08-30)
「零差异」只有在
**每条分支都真的产出过行**
时才有意义 —— 一条从没触发的分支两边都返回空,
差分当然为零,但什么也没证明。本地各分支的源行数:
| 分支 | 源行数 | | 分支 | 源行数 |
|---|---|---|---|---|
| ① 治疗家族 | 56,552 | | ⑧ 裸抛光 | 66 |
| ② 桥类修复 | 8,681 | | ⑨ 病历文本自证 | 4,157 |
| ③ 缺牙间隙关闭 | 117 | | ⑩ 检查所见修复在位 | 4,628 |
| ④ 患者拒绝 | 375 | | ⑪ 整颌活动义齿 | 584 |
| ⑤ 更晚结构诊断 | 153,509 | | ⑫ 正畸减数位(外科拔牙) | 8,344 |
| ⑥ 建议记录 | 15,440 | | ⑬ 让位编码病种 | 77,083 |
| ⑦ 复查即治疗 | 1,805 | | | |
13 条全部有料,本地对拍不是空转。测试机数据量 ~20 倍,覆盖只会更足。
### 测试机结果 · 步 0~1(2026-08-30 01:05~)
| 步 | 结果 |
|---|---|
| 0 工具自检 | ✅ 零差异(并测出缓存效应 ×7.57,见上) |
| 1 画像对拍 | ✅
**零差异**
(2000 位,1290 位有 gap);耗时/患者 legacy p50=13ms p95=47ms 合计 34.7s → setbased p50=9ms p95=27ms 合计 22.1s |
画像的门是「p95 不劣化 >10%」—— 通过。
⚠️ 但每位患者也是先 legacy 后 setbased,
**顺序同样偏袒 setbased**
,
所以只能下「不劣化」的结论,
**不能说快 1.57 倍**
。要量画像的真实收益,得改成交替顺序再跑。
### 测试机结果 · 步 2:召回 11 子场景对拍(2026-08-30 01:07~)
**✅ 11/11 零差异,行数逐个相同**
(585K 患者,逐 患者×信号×牙位 双向 EXCEPT ALL)。
| 子场景 | 行数 | legacy | setbased | 比值
*
|
|---|---|---|---|---|
| missing_tooth | 14,970 | 230.4s | 151.7s | ×1.52 |
| caries_no_filling | 45,339 | 183.3s | 71.3s | ×2.57 |
| hard_tissue_damage | 31,758 | 102.8s | 47.3s | ×2.17 |
| endo_no_rct | 10,165 | 84.2s | 39.3s | ×2.14 |
| impacted_tooth | 38,003 | 71.0s | 39.1s | ×1.82 |
| perio_no_srp
*(全口对照)*
| 15,751 | 41.7s | 39.1s | ×1.07 |
| ortho_no_consult
*(全口对照)*
| 21,674 | 29.1s | 13.2s | ×2.20 |
| development_eruption | 2,882 | 19.4s | 13.9s | ×1.39 |
| extraction_recommended | 1,235 | 15.2s | 15.8s | ×0.96 |
| gum_alveolar_lesion | 875 | 7.3s | 9.2s | ×0.79 |
| jaw_cyst | 284 | 5.2s | 6.8s | ×0.77 |
\*
⛔
**这一列不是收益**
。两个全口子场景两边跑的是
**同一条 SQL**
(setbased 对 wholeMouth 早退),
它们的 ×1.07 与 ×2.20 就是纯缓存噪音的量级 —— 噪音跨度比要测的信号还大。
这一列只用来看「有没有离谱地变慢」。收益看步 3 的 C vs D。
**新发现:小场景一致变慢。**
≤3000 行的四条(extraction ×0.96 / gum ×0.79 / jaw_cyst ×0.77,
本地 extraction ×0.60)都在带缓存优势的情况下仍然更慢 —— 集合式的 CTE 物化是
**固定开销**
,
结果集太小就摊不掉。绝对值都在 2 秒以内,对总墙钟无影响,不值得为它加"按规模切形态"的分叉
(那会让单一真理源裂成两条路径,得不偿失)。
### 测试机结果 · 步 3:交替四轮全量重算(2026-08-30 01:45~03:14)
| 轮 | 形态 | 场景段 | 整轮墙钟 | 命中患者 | reason 行数 |
|---|---|---|---|---|---|
| A | legacy | 670s | 1,584s | 87,807 | 323,781 |
| B | setbased |
**1,334s**
| 1,675s | 87,811 | 323,791 |
| C | legacy | 794s | 1,181s | 87,813 | 323,794 |
| D | setbased |
**545s**
| 923s | 87,814 | 323,796 |
#### ⛔ B 轮是离群值 —— 我据它下过一个错误结论,记在这里免得后来人重犯
B 轮场景段 1,334s(比 legacy 慢一倍),两个
**跑同一条 SQL**
的全口子场景也慢了 7~12 倍
(ortho 38→444s / perio 105→797s)。我当场写下「集合式把内存打穿、溢盘、连没改的查询一起拖垮」,
还配了机器状态佐证(14G 内存吃光 + 9G swap +
`temp_bytes=187GB`
)。
**全错。**
D 轮复现不出来 —— 545s,四轮里最快。真相:
-
B 轮跑在
**02:11–02:39**
,整段压在测试机
`PAC_STALE_SCAN_CRON=0 2 * * *`
(画像 stale 扫描)上。
A 轮尾巴也蹭到 11 分钟,所以 A 也偏慢。C/D 在扫描结束后跑,才是干净的。
-
`temp_bytes=187GB`
是
**统计累计值,不是今晚产生的**
。实测每轮增量:
C(legacy)4 个临时文件 / D(setbased)4 个,总增量 62MB ——
**根本没有溢盘**
。
-
那个"内存打穿"的因果链是我照着一个数字编出来的,没有任何一步是量出来的。
**教训**
:交替四轮的设计救了这次。只跑 A→B 就下结论的话,会把一个正确的优化否掉,
而且带着一套听起来很像回事的错误归因。⛔ 一个离群点 + 一套讲得通的机制 ≠ 结论;
**结论要能复现**
。同
[
[verify-before-prohibiting
]
]。
#### 真实收益:C(legacy) vs D(setbased),相邻两轮、同等条件
| 子场景 | C legacy | D setbased | 倍数 |
|---|---|---|---|
| impacted_tooth | 269.6s | 119.6s |
**×2.25**
|
| caries_no_filling | 358.2s | 171.5s |
**×2.09**
|
| hard_tissue_damage | 209.7s | 132.3s | ×1.59 |
| endo_no_rct | 243.2s | 169.7s | ×1.43 |
| development_eruption | 58.9s | 43.9s | ×1.34 |
| ortho_no_consult
*(全口对照,同一条 SQL)*
| 74.7s | 59.1s |
*×1.26*
|
| missing_tooth | 286.6s | 245.5s | ×1.17 |
| extraction_recommended | 51.0s | 45.6s | ×1.12 |
| gum_alveolar_lesion | 40.2s | 38.7s | ×1.04 |
| perio_no_srp
*(全口对照,同一条 SQL)*
| 128.2s | 130.1s |
*×0.99*
|
| jaw_cyst | 16.2s | 18.2s | ×0.89 |
两个对照组(逐字节相同的 SQL)给出 ×1.26 / ×0.99 →
**环境噪音 ±25%**
。
超出噪音的真实收益集中在 impacted / caries / hard / endo 四条。
-
**场景段 794s → 545s(×1.46,−31%)**
-
**整轮 1,181s → 923s(×1.28,−22%)**
#### 端到端正确性
reason 行级 diff(323,796 条):A↔B 10 行 / B↔C 3 行 / C↔D 2 行 / A↔D 15 行。
差异
**全部是新增(`>`),无一删除**
,成簇按患者出现,且包含
`perio_no_srp@whole`
这种全口场景的行(两版跑同一条 SQL)—— 是患者跨过 ⑤f 到诊冷静期重新进池的
**时间漂移**
,
与形态无关。命中患者数 87,807 → 87,811 → 87,813 → 87,814 单调递增也印证这点。
**零删除 = setbased 从未丢掉 legacy 有的任何一行**
,静默少召这个最怕的方向没有发生。
#### 结论
| 门 | 结果 |
|---|---|
| 正确性(SQL 层,固定 now 同一快照) | ✅ 11/11 零差异,行数逐个相同 |
| 正确性(画像消费方) | ✅ 2000 位零差异,p95 不劣化 |
| 正确性(端到端) | ✅ 差异仅时间漂移,零删除 |
| 收益 | ⚠️ 场景段 −31% / 整轮 −22%,
**低于方案 §7 定的 ≥3× 试点门**
|
方案 §0.1 预估「全轮改善 35~40%,不足以单独装进 2 小时」——
**实测 22~31%,预估方向对、幅度偏乐观**
。
生产场景段 3h11m 按此推算 → 约 2h11m,整轮 3h41m → 约 2h50m,
**仍然超 2 小时**
。
要进窗口还得叠加 §9(全口码那条独立线)或别的手段。
## 12. 测试机切到集合式(2026-08-30 03:2x)
`apps/pac-service/.env`
加
`PAC_GAP_VARIANT=setbased`
,重启 pac-service。
**生产未动,仍是 legacy**
(该变量在生产 .env 里不存在 →
`gapVariant()`
返回 legacy)。
回退:把该行改回
`legacy`
(或删掉),
`bash deploy/deploy-prod.sh --no-pull`
。
**不需要改代码、不需要发版。**
### ⛔ 切换时踩的坑:别手搓 docker compose
我为了省一次构建,手动跑了
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-deps --force-recreate pac-service`
,
结果:
1.
**compose 文件组合错了**
—— deploy-prod.sh 用的是
`-f docker-compose.prod.yml`
(+ managed override),
我多带了一个
`docker-compose.yml`
,容器名从
`pac-pac-service-1`
变成
`pac-service`
,端口冲突起不来
2.
删掉重来后又撞上
**已知的 DB 口令坑**
(
[
[pac-prod-compose-db-cred-landmine
]
]):
手动 compose 不 source
`.env`
,
`DATABASE_URL`
里的口令回落 →
**P1000 认证失败 → crash-loop**
3.
测试服务挂了约 5 分钟,靠
`bash deploy/deploy-prod.sh --no-pull`
恢复(三项验证全过,数据完好)
deploy-prod.sh 的文件头注释本来就写着"不信 compose 的 diff 启发式"、整套逻辑就是为了避开这些。
**改环境变量也要走部署脚本**
—— 它多花的那几分钟构建时间,买的是不出这种事。
## 13. 交互路径(详情页「刷新」)—— 用户当场感受得到的那条
`plan.controller`
的
`recomputeForPatient`
是
**HTTP 端点**
,走的就是本套召回 SQL 的
单患者路径(
`scope.patientId`
)。之前只验了批量和画像,漏了它。
批量慢是运维问题,
**这条慢是体验问题**
。
测试机(585K 患者,200 位样本 × 11 子场景 = 2,200 次比对):
| | legacy | setbased |
|---|---|---|
| 单子场景查询 p50 | 5ms | 4ms |
| 单子场景查询 p95 | 20ms | 17ms |
|
**一次「刷新」(11 条合计)**
|
**89ms**
|
**75ms**
|
| 结果 | — | ✅
**零差异**
|
本地(30K)同向:一次刷新 103ms → 71ms,零差异。
结论:
**交互路径不劣化**
。⚠️ 每条查询仍是先 legacy 后 setbased,顺序偏袒后者,
所以只下「不劣化」的结论,不宣称快 15%。
## 14. 生产与测试机的配置差异(推算收益时必须带上)
| | 生产(friday) | 测试机 |
|---|---|---|
|
`PAC_RECALL_SUBSCENARIO_CONCURRENCY`
|
**未设 → 默认 1(串行)**
|
**4**
|
|
`PAC_GAP_VARIANT`
| 未设 → legacy | setbased(2026-08-30 起) |
|
`PAC_PLAN_BATCH_CONCURRENCY`
| 未设 → 默认 8 | 8 |
⚠️
**−31% 是在并发 4 上量的,生产是串行。**
倍数能否原样搬过去
**没有生产实测**
。
两个旁证指向同一区间:按「逐查询耗时求和」(更接近串行形态)算,C→D 是 1736.5s → 1174.2s =
**−32%**
;
整轮墙钟 −22%。所以
**−20~32% 是一个有依据的区间,不是一个点估计**
。
上生产前应先跑
**只读对拍**
(
`verify-gap-equivalence --host=jvs-dw`
,不写库、不改配置),
确认生产数据分布上同样零差异 —— 生产与测试机的数据快照不是同一份。
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