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
a92bed53
Commit
a92bed53
authored
Jul 19, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
Merge branch 'fix/recall-suppression-time-anchor' into test
parents
22b761ed
05cdd46d
Pipeline
#3396
failed in 0 seconds
Changes
6
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
219 additions
and
31 deletions
+219
-31
apps/pac-service/src/modules/plan/engine/plan-engine.service.ts
+85
-30
apps/pac-service/src/modules/plan/engine/scenario.interface.ts
+5
-0
apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts
+17
-0
apps/pac-service/tests/plan-engine-snooze-anchor.spec.ts
+81
-0
deploy/deploy-prod.sh
+16
-1
docker-compose.managed.yml
+15
-0
No files found.
apps/pac-service/src/modules/plan/engine/plan-engine.service.ts
View file @
a92bed53
...
@@ -195,7 +195,7 @@ export class PlanEngineService {
...
@@ -195,7 +195,7 @@ export class PlanEngineService {
patientIds
,
patientIds
,
now
,
now
,
);
);
const
EMPTY_SNOOZE
=
new
Set
<
string
>
();
const
EMPTY_SNOOZE
=
new
Map
<
string
,
Date
>
();
const
logRows
:
Prisma
.
PlanGenerationLogCreateManyInput
[]
=
[];
const
logRows
:
Prisma
.
PlanGenerationLogCreateManyInput
[]
=
[];
const
concurrency
=
Math
.
max
(
1
,
Number
(
process
.
env
.
PAC_PLAN_BATCH_CONCURRENCY
)
||
8
);
const
concurrency
=
Math
.
max
(
1
,
Number
(
process
.
env
.
PAC_PLAN_BATCH_CONCURRENCY
)
||
8
);
const
entries
=
[...
hitsByPatient
.
entries
()];
const
entries
=
[...
hitsByPatient
.
entries
()];
...
@@ -209,7 +209,7 @@ export class PlanEngineService {
...
@@ -209,7 +209,7 @@ export class PlanEngineService {
hits
,
hits
,
prefetched
:
{
prefetched
:
{
latest
:
latestByPatient
.
get
(
patientId
)
??
null
,
latest
:
latestByPatient
.
get
(
patientId
)
??
null
,
snoozed
Key
s
:
snoozedByPatient
.
get
(
patientId
)
??
EMPTY_SNOOZE
,
snoozed
Anchor
s
:
snoozedByPatient
.
get
(
patientId
)
??
EMPTY_SNOOZE
,
personaId
:
personaByPatient
.
get
(
patientId
)
??
null
,
personaId
:
personaByPatient
.
get
(
patientId
)
??
null
,
},
},
});
});
...
@@ -301,7 +301,7 @@ export class PlanEngineService {
...
@@ -301,7 +301,7 @@ export class PlanEngineService {
hits
:
ScenarioHitWithKey
[];
hits
:
ScenarioHitWithKey
[];
prefetched
?:
{
prefetched
?:
{
latest
:
PlanWithReasons
|
null
;
latest
:
PlanWithReasons
|
null
;
snoozed
Keys
:
Set
<
string
>
;
snoozed
Anchors
:
Map
<
string
,
Date
>
;
personaId
:
string
|
null
;
personaId
:
string
|
null
;
};
};
}):
Promise
<
'created'
|
'superseded'
|
'unchanged'
|
'suppressed'
>
{
}):
Promise
<
'created'
|
'superseded'
|
'unchanged'
|
'suppressed'
>
{
...
@@ -327,15 +327,23 @@ export class PlanEngineService {
...
@@ -327,15 +327,23 @@ export class PlanEngineService {
// 并集作抑制集,逐 hit 过滤掉命中抑制集的信号;剩下的(全新诊断)继续走下面生成逻辑。
// 并集作抑制集,逐 hit 过滤掉命中抑制集的信号;剩下的(全新诊断)继续走下面生成逻辑。
// 修复的 bug:旧版"整患者抑制" → 成功转化 K08 后,snooze 期内新长的 K02 也被压住不召(误伤新缺口)。
// 修复的 bug:旧版"整患者抑制" → 成功转化 K08 后,snooze 期内新长的 K02 也被压住不召(误伤新缺口)。
// 多次结案也正确:查的是全部未到期终态 plan 的并集(不止 latest 一条)。
// 多次结案也正确:查的是全部未到期终态 plan 的并集(不止 latest 一条)。
const
snoozed
Key
s
=
prefetched
const
snoozed
Anchor
s
=
prefetched
?
prefetched
.
snoozed
Key
s
?
prefetched
.
snoozed
Anchor
s
:
await
this
.
fetchSnoozedSignalKeys
(
scope
,
patientId
,
scope
.
now
);
:
await
this
.
fetchSnoozedSignalKeys
(
scope
,
patientId
,
scope
.
now
);
const
usableHits
=
const
usableHits
=
snoozed
Key
s
.
size
===
0
snoozed
Anchor
s
.
size
===
0
?
hits
?
hits
:
hits
.
filter
((
h
)
=>
!
snoozedKeys
.
has
(
`
${
h
.
scenarioKey
}
|
${
h
.
subKey
??
''
}
`));
:
hits
.
filter
((
h
)
=>
{
const
anchor
=
snoozedAnchors
.
get
(
`
${
h
.
scenarioKey
}
|
${
h
.
subKey
??
''
}
`);
if (!anchor) return true; // 不在抑制集 → 放行
// ⭐ 时间锚逃逸:cluster 内存在"结案之后新发"的同类信号 → 视为新机会,放行复活。
// ("永久"抑制只对结案时已知的问题永久 —— 外院种植失败回院再诊断/多年后新发同类
// @whole 病种,不该被老 snooze 压死。)latest 缺省(旧 scenario 未提供)→ 维持全压。
const latest = h.latestSignalOccurredAt;
return latest != null && latest > anchor;
});
if (usableHits.length === 0) {
if (usableHits.length === 0) {
// 当前所有活信号都在冷静期内(= 刚结案那批)→ 不生成新 plan
// 当前所有活信号都在冷静期内(= 刚结案那批
,且无结案后新发
)→ 不生成新 plan
return 'suppressed';
return 'suppressed';
}
}
...
@@ -448,19 +456,22 @@ export class PlanEngineService {
...
@@ -448,19 +456,22 @@ export class PlanEngineService {
}
}
/**
/**
* 信号级抑制集
—— 该患者所有"终态(completed/abandoned)+ 冷静期未到期(snoozedUntil>now)"plan
* 信号级抑制集
(带时间锚)—— 该患者所有"终态(completed/abandoned)+ 冷静期未到期
*
覆盖的召回理由 (scenario, subKey) 并集
。
*
(snoozedUntil>now)"plan 覆盖的召回理由 (scenario, subKey) → **结案锚点** Map
。
*
*
* 语义:这些信号最近被召回处理过(成功转化/拒绝/外院/熔断…),在冷静期内不重复召回;
* 语义:这些信号最近被召回处理过(成功转化/拒绝/外院/熔断…),在冷静期内不重复召回;
* 但**不在此集合里的全新诊断不受影响**,照常生成召回(这是信号级抑制的核心,跟召回算法同粒度)。
* 但**不在此集合里的全新诊断不受影响**,照常生成召回(这是信号级抑制的核心,跟召回算法同粒度)。
* 多次结案累积正确:取全部未到期终态 plan 的并集,而非只看最新一条。
* ⭐ 时间锚(2026-07 修):同 key 也不一刀切 —— 结案锚点之后**新发**的同类信号(hit 的
* 返回 key 格式 `
$
{
scenario
}
|
$
{
subKey
??
''
}
`,跟 upsertPlan 过滤口径一致。
* latestSignalOccurredAt > anchor)放行复活。修复的坑:外院/无效的"永久"snooze 会把
* 结案后多年新发的同牙位/@whole 同类诊断也永久压死(粒度只到 key,无时间维度)。
* 多次结案累积正确:取全部未到期终态 plan 的并集;同 key 多次结案取**最新**锚点。
* key 格式 `
$
{
scenario
}
|
$
{
subKey
??
''
}
`,跟 upsertPlan 过滤口径一致。
*/
*/
private async fetchSnoozedSignalKeys(
private async fetchSnoozedSignalKeys(
scope: ScenarioScope,
scope: ScenarioScope,
patientId: string,
patientId: string,
now: Date,
now: Date,
): Promise<
Set<string
>> {
): Promise<
Map<string, Date
>> {
const terminalPlans = await this.prisma.followupPlan.findMany({
const terminalPlans = await this.prisma.followupPlan.findMany({
where: {
where: {
hostId: scope.hostId,
hostId: scope.hostId,
...
@@ -469,15 +480,20 @@ export class PlanEngineService {
...
@@ -469,15 +480,20 @@ export class PlanEngineService {
status: { in: ['completed', 'abandoned'] },
status: { in: ['completed', 'abandoned'] },
snoozedUntil: { gt: now },
snoozedUntil: { gt: now },
},
},
select: { reasons: { select: { scenario: true, subKey: true } } },
select: {
updatedAt: true,
reasons: { select: { scenario: true, subKey: true } },
// 结案锚点 = 结案那次 execution 的 createdAt(事件表,不可变)。
// ⚠️ 不能用 plan.updatedAt 当首选锚:召回反馈(👍/👎)等后续写操作会顶后 updatedAt,
// 把"结案后、反馈前"新发的信号误压。updatedAt 仅作无 execution 时的兜底(理论不发生)。
executions: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { createdAt: true },
},
},
});
});
const keys = new Set<string>();
return buildSnoozeAnchors(terminalPlans);
for (const plan of terminalPlans) {
for (const r of plan.reasons) {
keys.add(`
$
{
r
.
scenario
}
|
$
{
r
.
subKey
??
''
}
`);
}
}
return keys;
}
}
/**
/**
...
@@ -492,11 +508,11 @@ export class PlanEngineService {
...
@@ -492,11 +508,11 @@ export class PlanEngineService {
now: Date,
now: Date,
): Promise<{
): Promise<{
latestByPatient: Map<string, PlanWithReasons>;
latestByPatient: Map<string, PlanWithReasons>;
snoozedByPatient: Map<string,
Set<string
>>;
snoozedByPatient: Map<string,
Map<string, Date
>>;
personaByPatient: Map<string, string>;
personaByPatient: Map<string, string>;
}> {
}> {
const latestByPatient = new Map<string, PlanWithReasons>();
const latestByPatient = new Map<string, PlanWithReasons>();
const snoozedByPatient = new Map<string,
Set<string
>>();
const snoozedByPatient = new Map<string,
Map<string, Date
>>();
const personaByPatient = new Map<string, string>();
const personaByPatient = new Map<string, string>();
const CHUNK = 2000;
const CHUNK = 2000;
for (let i = 0; i < patientIds.length; i += CHUNK) {
for (let i = 0; i < patientIds.length; i += CHUNK) {
...
@@ -510,7 +526,7 @@ export class PlanEngineService {
...
@@ -510,7 +526,7 @@ export class PlanEngineService {
for (const p of plans) {
for (const p of plans) {
if (p.patientId && !latestByPatient.has(p.patientId)) latestByPatient.set(p.patientId, p);
if (p.patientId && !latestByPatient.has(p.patientId)) latestByPatient.set(p.patientId, p);
}
}
// snooze 抑制集(对齐 fetchSnoozedSignalKeys:终态 + 冷静期未到期 plan 的 reason
并集
)
// snooze 抑制集(对齐 fetchSnoozedSignalKeys:终态 + 冷静期未到期 plan 的 reason
→ 结案锚点
)
const terminal = await this.prisma.followupPlan.findMany({
const terminal = await this.prisma.followupPlan.findMany({
where: {
where: {
hostId: scope.hostId,
hostId: scope.hostId,
...
@@ -519,13 +535,25 @@ export class PlanEngineService {
...
@@ -519,13 +535,25 @@ export class PlanEngineService {
status: { in: ['completed', 'abandoned'] },
status: { in: ['completed', 'abandoned'] },
snoozedUntil: { gt: now },
snoozedUntil: { gt: now },
},
},
select: { patientId: true, reasons: { select: { scenario: true, subKey: true } } },
select: {
patientId: true,
updatedAt: true,
reasons: { select: { scenario: true, subKey: true } },
// 结案锚点 = 结案 execution.createdAt(不可变;updatedAt 会被召回反馈等后续写顶后,仅兜底)
executions: { orderBy: { createdAt: 'desc' }, take: 1, select: { createdAt: true } },
},
});
});
for (const t of terminal) {
{
if (!t.patientId) continue;
const plansByPatient = new Map<string, typeof terminal>();
const set = snoozedByPatient.get(t.patientId) ?? new Set<string>();
for (const t of terminal) {
for (const r of t.reasons) set.add(`
$
{
r
.
scenario
}
|
$
{
r
.
subKey
??
''
}
`);
if (!t.patientId) continue;
snoozedByPatient.set(t.patientId, set);
const arr = plansByPatient.get(t.patientId) ?? [];
arr.push(t);
plansByPatient.set(t.patientId, arr);
}
for (const [pid, plans] of plansByPatient) {
snoozedByPatient.set(pid, buildSnoozeAnchors(plans));
}
}
}
// active persona id(每患最新 active 版本)
// active persona id(每患最新 active 版本)
const personas = await this.prisma.persona.findMany({
const personas = await this.prisma.persona.findMany({
...
@@ -565,3 +593,30 @@ export interface EngineRunResult {
...
@@ -565,3 +593,30 @@ export interface EngineRunResult {
plansClosed: number;
plansClosed: number;
durationMs: number;
durationMs: number;
}
}
/**
* 终态 plan → (scenario|subKey) → 结案锚点 Map(纯函数,fetchSnoozedSignalKeys / prefetchForBatch 共用)。
* 锚点 = 结案 execution.createdAt(事件表,不可变);无 execution(理论不发生)兜底 plan.updatedAt。
* 同 key 多次结案取**最新**锚 —— 最近一次人为处置才是"此后新发才算新机会"的分界线。
*/
export function buildSnoozeAnchors(
terminalPlans: Array<{
updatedAt?: Date;
reasons: Array<{ scenario: string; subKey: string | null }>;
executions?: Array<{ createdAt: Date }>;
}>,
): Map<string, Date> {
// 锚点解析不到(旧数据/未 select 关联)→ 远未来哨兵:latest > anchor 永不成立 = 永不逃逸,
// 严格等价旧的"key 命中即压"行为(宁可多压,不误放)。
const FAR_FUTURE = new Date(8640000000000000);
const anchors = new Map<string, Date>();
for (const plan of terminalPlans) {
const anchor = plan.executions?.[0]?.createdAt ?? plan.updatedAt ?? FAR_FUTURE;
for (const r of plan.reasons) {
const key = `
$
{
r
.
scenario
}
|
$
{
r
.
subKey
??
''
}
`;
const prev = anchors.get(key);
if (!prev || anchor > prev) anchors.set(key, anchor);
}
}
return anchors;
}
apps/pac-service/src/modules/plan/engine/scenario.interface.ts
View file @
a92bed53
...
@@ -52,6 +52,11 @@ export interface ScenarioHit {
...
@@ -52,6 +52,11 @@ export interface ScenarioHit {
/// 子场景标识(reason 文本内已含,落 plan_reasons.breakdown.subKey 便于 metrics 检索)
/// 子场景标识(reason 文本内已含,落 plan_reasons.breakdown.subKey 便于 metrics 检索)
subKey
?:
string
;
subKey
?:
string
;
/// cluster 内**最新**信号发生时间(时间锚抑制逃逸用,与 signals.signalOccurredAt 的"最早"锚相反):
/// 终态抑制(外院/拒绝的"永久" snooze)只压"结案时已知的问题" —— 结案后新发的同类诊断
/// (同 subKey 新 fact)应放行复活。引擎按 latest > 结案锚点 判断逃逸;缺省(旧 scenario)= 不逃逸。
latestSignalOccurredAt
?:
Date
|
null
;
/// **W3 末新增**:结构化召回信号(plan_reasons.signals JSON 落库)。
/// **W3 末新增**:结构化召回信号(plan_reasons.signals JSON 落库)。
/// DB 存原始 enum / canonical code(不语义化),前端用 @pac/types 字典翻译富文本渲染。
/// DB 存原始 enum / canonical code(不语义化),前端用 @pac/types 字典翻译富文本渲染。
/// 详见 packages/types/src/schemas/reason-signals.ts
/// 详见 packages/types/src/schemas/reason-signals.ts
...
...
apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts
View file @
a92bed53
...
@@ -481,6 +481,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
...
@@ -481,6 +481,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 同 sub_scenario 多牙位各 1 行(36/46 都需充填都进库)。
// 同 sub_scenario 多牙位各 1 行(36/46 都需充填都进库)。
// 全口诊断(K05 等无牙位)→ '@whole';前端 / plan-aggregate 用 signals.toothPosition 区分语义。
// 全口诊断(K05 等无牙位)→ '@whole';前端 / plan-aggregate 用 signals.toothPosition 区分语义。
subKey
:
`
${
subKey
}
@
${(
r
.
tooth
??
''
).
trim
()
||
'whole'
}
`,
subKey
:
`
${
subKey
}
@
${(
r
.
tooth
??
''
).
trim
()
||
'whole'
}
`,
// 时间锚抑制逃逸:cluster 内最新信号时间(单 sig cluster = 自身时间)
latestSignalOccurredAt: r.cluster_latest_occurred_at ?? r.signal_occurred_at,
// 结构化召回信号(DB 存 raw enum / canonical code,前端字典翻译富文本)
// 结构化召回信号(DB 存 raw enum / canonical code,前端字典翻译富文本)
// triggers 是 cluster 内全量(去重),前端按 type set 大小渲染 (诊断) / (医生建议) / (诊断+医生建议)
// triggers 是 cluster 内全量(去重),前端按 type set 大小渲染 (诊断) / (医生建议) / (诊断+医生建议)
signals: {
signals: {
...
@@ -581,6 +583,8 @@ interface HitRow {
...
@@ -581,6 +583,8 @@ interface HitRow {
cluster_has_recommendation
?:
boolean
;
// cluster 含至少 1 个 recommendation_record
cluster_has_recommendation
?:
boolean
;
// cluster 含至少 1 个 recommendation_record
cluster_confidence
?:
number
;
// cluster 最优来源置信度(诊断1.0/建议0.8/影像0.5,取 max)
cluster_confidence
?:
number
;
// cluster 最优来源置信度(诊断1.0/建议0.8/影像0.5,取 max)
cluster_triggers
?:
Array
<
{
type
:
string
;
code
:
string
}
>
;
// cluster 内 unique (type, code),给 signals.triggers
cluster_triggers
?:
Array
<
{
type
:
string
;
code
:
string
}
>
;
// cluster 内 unique (type, code),给 signals.triggers
/// cluster 内**最新**信号时间(lead 锚最早=daysSince 口径;此字段锚最新=终态抑制时间锚逃逸口径)
cluster_latest_occurred_at
?:
Date
;
}
}
/// 单 sig 来源置信度(v3.1 三源标定):诊断 1.0 / 医生建议 0.8 / 影像AI 0.5。
/// 单 sig 来源置信度(v3.1 三源标定):诊断 1.0 / 医生建议 0.8 / 影像AI 0.5。
...
@@ -634,6 +638,7 @@ function mergeRowsByToothOverlap(rows: HitRow[]): HitRow[] {
...
@@ -634,6 +638,7 @@ function mergeRowsByToothOverlap(rows: HitRow[]): HitRow[] {
lead
.
cluster_has_recommendation
=
wholeMouth
.
some
((
x
)
=>
x
.
signal_type
===
'recommendation_record'
);
lead
.
cluster_has_recommendation
=
wholeMouth
.
some
((
x
)
=>
x
.
signal_type
===
'recommendation_record'
);
lead
.
cluster_confidence
=
Math
.
max
(...
wholeMouth
.
map
(
sourceConfidence
));
lead
.
cluster_confidence
=
Math
.
max
(...
wholeMouth
.
map
(
sourceConfidence
));
lead
.
cluster_triggers
=
uniqueTriggers
(
wholeMouth
);
lead
.
cluster_triggers
=
uniqueTriggers
(
wholeMouth
);
lead
.
cluster_latest_occurred_at
=
latestOccurredAt
(
wholeMouth
);
merged
.
push
(
lead
);
merged
.
push
(
lead
);
}
}
...
@@ -673,6 +678,7 @@ function mergeRowsByToothOverlap(rows: HitRow[]): HitRow[] {
...
@@ -673,6 +678,7 @@ function mergeRowsByToothOverlap(rows: HitRow[]): HitRow[] {
lead
.
cluster_has_recommendation
=
c
.
rows
.
some
((
x
)
=>
x
.
signal_type
===
'recommendation_record'
);
lead
.
cluster_has_recommendation
=
c
.
rows
.
some
((
x
)
=>
x
.
signal_type
===
'recommendation_record'
);
lead
.
cluster_confidence
=
Math
.
max
(...
c
.
rows
.
map
(
sourceConfidence
));
lead
.
cluster_confidence
=
Math
.
max
(...
c
.
rows
.
map
(
sourceConfidence
));
lead
.
cluster_triggers
=
uniqueTriggers
(
c
.
rows
);
lead
.
cluster_triggers
=
uniqueTriggers
(
c
.
rows
);
lead
.
cluster_latest_occurred_at
=
latestOccurredAt
(
c
.
rows
);
merged
.
push
(
lead
);
merged
.
push
(
lead
);
}
}
}
}
...
@@ -690,6 +696,17 @@ function triggerTypeOf(row: { signal_type: string; code_source: string | null })
...
@@ -690,6 +696,17 @@ function triggerTypeOf(row: { signal_type: string; code_source: string | null })
:
'diagnosis'
;
:
'diagnosis'
;
}
}
/// cluster 内最新信号时间 —— 时间锚抑制逃逸用(lead 锚最早,此处锚最新,两口径并存各司其职)。
/// pg timestamptz 经 $queryRaw 已是 Date;防御性过滤无效值(理论上 ④ 闸已保证非空)。
function
latestOccurredAt
(
rows
:
HitRow
[]):
Date
|
undefined
{
let
max
:
Date
|
undefined
;
for
(
const
r
of
rows
)
{
const
d
=
r
.
signal_occurred_at
;
if
(
d
instanceof
Date
&&
!
Number
.
isNaN
(
d
.
getTime
())
&&
(
!
max
||
d
>
max
))
max
=
d
;
}
return
max
;
}
function
uniqueTriggers
(
rows
:
HitRow
[]):
Array
<
{
type
:
string
;
code
:
string
}
>
{
function
uniqueTriggers
(
rows
:
HitRow
[]):
Array
<
{
type
:
string
;
code
:
string
}
>
{
const
seen
=
new
Set
<
string
>
();
const
seen
=
new
Set
<
string
>
();
const
out
:
Array
<
{
type
:
string
;
code
:
string
}
>
=
[];
const
out
:
Array
<
{
type
:
string
;
code
:
string
}
>
=
[];
...
...
apps/pac-service/tests/plan-engine-snooze-anchor.spec.ts
0 → 100644
View file @
a92bed53
import
{
buildSnoozeAnchors
}
from
'../src/modules/plan/engine/plan-engine.service'
;
/**
* 时间锚抑制(2026-07 修)单测 —— 纯函数层。
* 背景:终态 plan 的"永久" snooze 原来按 (scenario|subKey) 一刀切,结案后**新发**的同类
* 诊断(外院种植失败回院 / 多年后新发 @whole 病种)也被永久压死。修复 = 抑制集带结案锚点,
* 引擎按 hit.latestSignalOccurredAt > anchor 放行。本文件测锚点构建;逃逸比较是一行大小关系,
* 语义由锚点正确性保证。
*/
describe
(
'buildSnoozeAnchors'
,
()
=>
{
const
d
=
(
s
:
string
)
=>
new
Date
(
s
);
const
plan
=
(
anchorExec
:
string
|
null
,
updatedAt
:
string
,
keys
:
Array
<
[
string
,
string
|
null
]
>
,
)
=>
({
updatedAt
:
d
(
updatedAt
),
reasons
:
keys
.
map
(([
scenario
,
subKey
])
=>
({
scenario
,
subKey
})),
executions
:
anchorExec
?
[{
createdAt
:
d
(
anchorExec
)
}]
:
[],
});
it
(
'锚点取结案 execution.createdAt(不取 plan.updatedAt)'
,
()
=>
{
// updatedAt 被召回反馈等后续写操作顶后(2026-07-10),真结案在 2026-07-01
const
anchors
=
buildSnoozeAnchors
([
plan
(
'2026-07-01T10:00:00Z'
,
'2026-07-10T00:00:00Z'
,
[[
'recall'
,
'missing_tooth@36'
]]),
]);
expect
(
anchors
.
get
(
'recall|missing_tooth@36'
)).
toEqual
(
d
(
'2026-07-01T10:00:00Z'
));
});
it
(
'无 execution 时兜底 plan.updatedAt'
,
()
=>
{
const
anchors
=
buildSnoozeAnchors
([
plan
(
null
,
'2026-07-10T00:00:00Z'
,
[[
'recall'
,
'perio@whole'
]]),
]);
expect
(
anchors
.
get
(
'recall|perio@whole'
)).
toEqual
(
d
(
'2026-07-10T00:00:00Z'
));
});
it
(
'同 key 多次结案取最新锚(最近一次处置才是新旧分界)'
,
()
=>
{
const
anchors
=
buildSnoozeAnchors
([
plan
(
'2025-01-01T00:00:00Z'
,
'2025-01-01T00:00:00Z'
,
[[
'recall'
,
'missing_tooth@36'
]]),
plan
(
'2026-06-01T00:00:00Z'
,
'2026-06-01T00:00:00Z'
,
[[
'recall'
,
'missing_tooth@36'
]]),
]);
expect
(
anchors
.
get
(
'recall|missing_tooth@36'
)).
toEqual
(
d
(
'2026-06-01T00:00:00Z'
));
});
it
(
'subKey=null 归一为空串 key(与引擎过滤口径一致)'
,
()
=>
{
const
anchors
=
buildSnoozeAnchors
([
plan
(
'2026-07-01T00:00:00Z'
,
'2026-07-01T00:00:00Z'
,
[[
'recall'
,
null
]]),
]);
expect
(
anchors
.
get
(
'recall|'
)).
toEqual
(
d
(
'2026-07-01T00:00:00Z'
));
});
it
(
'多 plan 多 key 并集(多次结案累积)'
,
()
=>
{
const
anchors
=
buildSnoozeAnchors
([
plan
(
'2026-05-01T00:00:00Z'
,
'2026-05-01T00:00:00Z'
,
[[
'recall'
,
'caries@45'
]]),
plan
(
'2026-06-01T00:00:00Z'
,
'2026-06-01T00:00:00Z'
,
[[
'recall'
,
'perio@whole'
]]),
]);
expect
(
anchors
.
size
).
toBe
(
2
);
expect
(
anchors
.
get
(
'recall|caries@45'
)).
toEqual
(
d
(
'2026-05-01T00:00:00Z'
));
expect
(
anchors
.
get
(
'recall|perio@whole'
)).
toEqual
(
d
(
'2026-06-01T00:00:00Z'
));
});
// 逃逸语义端到端(用与引擎 336 行同款比较式,防口径漂移)
it
(
'逃逸判定:结案后新发信号(latest > anchor)放行;老信号照压'
,
()
=>
{
const
anchors
=
buildSnoozeAnchors
([
plan
(
'2026-06-01T00:00:00Z'
,
'2026-06-01T00:00:00Z'
,
[[
'recall'
,
'missing_tooth@36'
]]),
]);
const
escapes
=
(
key
:
string
,
latest
:
Date
|
null
)
=>
{
const
anchor
=
anchors
.
get
(
key
);
if
(
!
anchor
)
return
true
;
return
latest
!=
null
&&
latest
>
anchor
;
};
// 老 fact(结案前 2024)聚类 → latest=2024 → 仍压
expect
(
escapes
(
'recall|missing_tooth@36'
,
d
(
'2024-03-01T00:00:00Z'
))).
toBe
(
false
);
// 结案后新发(2027 复诊再诊断)→ latest=2027 → 放行复活
expect
(
escapes
(
'recall|missing_tooth@36'
,
d
(
'2027-01-15T00:00:00Z'
))).
toBe
(
true
);
// 旧 scenario 未提供 latest → 维持旧行为全压
expect
(
escapes
(
'recall|missing_tooth@36'
,
null
)).
toBe
(
false
);
// 不在抑制集 → 放行
expect
(
escapes
(
'recall|caries@45'
,
d
(
'2024-01-01T00:00:00Z'
))).
toBe
(
true
);
});
});
deploy/deploy-prod.sh
View file @
a92bed53
...
@@ -92,7 +92,22 @@ main() {
...
@@ -92,7 +92,22 @@ main() {
# / 现为 307→/plans(next.config redirects),直接验 /plans 的 200
# / 现为 307→/plans(next.config redirects),直接验 /plans 的 200
webcode
=
$(
curl
-s
-m
30
-o
/dev/null
-w
'%{http_code}'
http://127.0.0.1:3100/plans
||
true
)
webcode
=
$(
curl
-s
-m
30
-o
/dev/null
-w
'%{http_code}'
http://127.0.0.1:3100/plans
||
true
)
[[
"
$webcode
"
==
"200"
]]
||
die
"web http=
$webcode
"
[[
"
$webcode
"
==
"200"
]]
||
die
"web http=
$webcode
"
echo
" health 200 / web 200"
echo
" health 200 / web 200(loopback)"
# 托管环境(COMPOSE_MANAGED=1)= 远端网关(如 friday 的 lb1)经【ECS 私网 IP】到达应用。
# loopback 验证过不代表网关能到:若应用绑成 127.0.0.1,私网 IP 会 refused → 公网 502。
# 这里额外验私网 IP 可达,把"loopback 通、网关不通"的漂移在部署当场拦下(2026-07-18 踩过)。
if
[[
"
${
COMPOSE_MANAGED
:-
0
}
"
==
"1"
]]
;
then
log
"验证 3b:私网 IP 可达(网关路径,防 loopback-only 绑定)"
local
priv phealth pweb
priv
=
$(
ip route get 1 2>/dev/null | awk
'{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1);exit}}'
)
[[
-n
"
$priv
"
]]
||
die
"拿不到私网 IP(ip route get 1 解析失败)"
phealth
=
$(
curl
-s
-m
5
-o
/dev/null
-w
'%{http_code}'
"http://
$priv
:3101/pac/v1/health"
||
true
)
[[
"
$phealth
"
==
"200"
]]
||
die
"私网
$priv
:3101 health=
$phealth
— 应用可能绑成 loopback,网关够不到(会 502)。托管环境需 web/service 绑 0.0.0.0(见 docker-compose.managed.yml)"
pweb
=
$(
curl
-s
-m
30
-o
/dev/null
-w
'%{http_code}'
"http://
$priv
:3100/plans"
||
true
)
[[
"
$pweb
"
==
"200"
]]
||
die
"私网
$priv
:3100 web=
$pweb
— 同上(loopback 绑定 → 网关 502)"
echo
" 私网
$priv
health 200 / web 200(网关路径 OK)"
fi
log
"部署成功 ✅
$(
git log
--oneline
-1
)
"
log
"部署成功 ✅
$(
git log
--oneline
-1
)
"
}
}
...
...
docker-compose.managed.yml
View file @
a92bed53
...
@@ -19,6 +19,19 @@ services:
...
@@ -19,6 +19,19 @@ services:
redis
:
redis
:
profiles
:
[
"
bundled-db-off"
]
profiles
:
[
"
bundled-db-off"
]
# 端口绑定:托管环境经【远端网关】(如 friday 的 lb1.friday.tech,独立主机)转发,
# 网关走 ECS 私网 IP 到达应用 → 必须绑 0.0.0.0(非 loopback),否则网关连不上 → 502。
# base compose 绑 127.0.0.1 是给【本机代理型】部署(老/测试机自带 nginx→loopback)用的;
# 那类机器不叠加本 override,仍 127.0.0.1。安全由云安全组保证(只放行网关/VPC,不对公网开)。
pac-web
:
ports
:
!override
-
"
3100:3100"
# docs 同理(经网关到达);base 绑 127.0.0.1,托管环境需 0.0.0.0,免同样漂移。
pac-docs
:
ports
:
!override
-
"
3102:3102"
# 迁移容器:去掉对自带 postgres 的依赖,DB 指向托管
# 迁移容器:去掉对自带 postgres 的依赖,DB 指向托管
pac-migrate
:
pac-migrate
:
depends_on
:
!reset
null
depends_on
:
!reset
null
...
@@ -30,6 +43,8 @@ services:
...
@@ -30,6 +43,8 @@ services:
depends_on
:
!override
depends_on
:
!override
pac-migrate
:
pac-migrate
:
condition
:
service_completed_successfully
condition
:
service_completed_successfully
ports
:
!override
# 同 pac-web:网关经私网 IP 到达,需绑 0.0.0.0
-
"
3101:3101"
environment
:
environment
:
DATABASE_URL
:
${DATABASE_URL:?托管模式需在 .env 设 DATABASE_URL(指向 RDS)}
DATABASE_URL
:
${DATABASE_URL:?托管模式需在 .env 设 DATABASE_URL(指向 RDS)}
REDIS_URL
:
${REDIS_URL:?托管模式需在 .env 设 REDIS_URL(指向托管 Redis)}
REDIS_URL
:
${REDIS_URL:?托管模式需在 .env 设 REDIS_URL(指向托管 Redis)}
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