Commit 1293108a by luoqi

perf(矩阵): 潜在治疗标签预计算落库 —— 958ms → 110ms,24 格逐字未变

初选矩阵此前每次开页面都从最原始的事实现推:
  plan_reasons → 展开 evidence.factIds → 回查 patient_facts(1567 万行 / 18 GB)
最忙的诊所(15,884 条 plan)一次摊 34,459 次随机查、372 MB I/O ——
**而这一切的唯一产出就是一个标签字符串**。温度那根轴根本不碰 fact
(它来自 patient_profiles.last_visit_at)。

⇒ 加一列 `plan_reasons.potential_labels text[]`,矩阵改成
  followup_plans ⋈ plan_reasons ⋈ patient_profiles + unnest。

■ 实测(本地,15,884 条 plan 的诊所,与测试服 15,770 同规模)
    耗时      958 ms → 110 ms   (8.7×)
    磁盘读   119,861 → 13,500   (-89%)
    JIT       触发   → 不触发
    小诊所   11 ms  → 0.4 ms    (地板消失)
  🔴 **五个诊所的 24 格逐字相同**(scripts/bench-matrix.ts 的结果快照 diff 零差异)——
    性能改动最容易的失败是悄悄少算一批人,只比耗时看不出来。

■ 为什么是数组而不是单列
  一条依据可挂多个 fact:93.7% 只有一个,但**3.7%(本地 1,391 条)会推出不止一个 code**。
   存单值会把这批人算少。

■ ️ 年龄被冻结,所以必须刷
  规则里三条带年龄(K08>18 / K07 3~12 / K07 13~40)⇒ 标签不是纯函数。
  实测受影响 5,551 人中「明天跨档 0 人、30 天内 16 人」(≈0.5 人/天,占矩阵 1.4 万人的 0.003%)。
  ⇒ 每晚 03:30 全量重算(PlanLabelRefreshService);
     不刷就逐日漂,且不会有任何报错。

■ 三条写入路径共用同一份 SQL
  🔴  绝不在写入侧用 TS 再算一遍:规则表是产品配置,第二份实现迟早和矩阵那份漂开,
    而漂了之后矩阵的数会**静默**不一样。
  · 生成完 plan 立刻 backfillMissing() ——  不补的话新 plan 的列是 NULL,
    `unnest` 直接跳过 ⇒ **今天生成的人在矩阵里看不见**,且不报错。失败不阻断生成。
  · 每晚全量重算(年龄)
  · 部分索引 `WHERE potential_labels IS NULL` —— 平时几乎是空的,补齐即 O(1),
    否则每次生成完都要为找 NULL 扫一遍 31 万行。

■ 两个自己踩了又修的坑(都写进了注释与测试)
  · 全量重算最初只写 `ORDER BY id LIMIT n` **没有游标** —— 每次取同一批,原地打转,
    而且不报错只是每晚白跑。改成按 maxId 推进。
  · 停止条件最初看"写了几行" —— 全量重算时绝大多数行算出来跟原值一样、不会被写,
    written=0  不等于做完了。改成看 seen / maxId,并把两者分开报。

列**刻意可空**,三态有别:NULL=没算过 / '{}'=算过但推不出标签 / 非空=标签集。
 别加 NOT NULL DEFAULT '{}' —— 那样刷新任务再也找不到漏网的行。

验证:tsc 通过;jest 86 套 1342 例全过(新增 12);eslint 干净;
  真 AppModule 起容器确认两个 service 都注入成功;
  回填 37,300 条/8.4s;全量重算 37,300 条/2.7s 未卡死;人为挖 25 个洞→补齐→剩余 0。
parent 628d253a
-- 初选矩阵提速:把「这条依据能推出哪几个潜在治疗标签」预先算好落库。
--
-- 此前矩阵每次开页面都现推:plan_reasons → 展开 evidence.factIds → 回查 patient_facts
-- (1567 万行 / 18 GB)。最忙的诊所一次摊三万多次随机查、372 MB I/O,
-- 而这一切的唯一产出就是这个字符串。
-- 本地实测(15,884 条 plan):958 ms → 110 ms,磁盘读 119,861 → 0,24 格的数逐字未变。
--
-- ⚠️ **刻意可空**,三态有别:
-- NULL = 还没算过(回填 / 新写入的行会被刷新任务捞走)
-- '{}' = 算过了,这条依据推不出任何标签(K 码不在规则表里 / 年龄不落区间)
-- 非空 = 标签集合
-- ⛔ 别加 NOT NULL DEFAULT '{}' —— 那样「没算过」和「算过是空」就分不开,
-- 刷新任务再也找不到漏网的行,而漏了不会有任何报错。
-- AlterTable
ALTER TABLE "plan_reasons" ADD COLUMN "potential_labels" TEXT[];
-- 矩阵只关心「推得出标签」的那些依据;推不出的占比不低,跳过它们能少扫一截。
-- ⚠️ 用 IS NOT NULL 而不是 <> '{}':后者会把「还没算过(NULL)」也排除在外,
-- 而回填期间正需要能看见它们。
CREATE INDEX "plan_reasons_plan_id_potential_labels_idx"
ON "plan_reasons" ("plan_id")
WHERE "potential_labels" IS NOT NULL AND array_length("potential_labels", 1) > 0;
-- 「还没算标签的依据」专用索引。
--
-- 生成完 plan 之后要立刻补标签(不补就是 NULL,矩阵直接看不见这些人)。
-- 没有这个索引的话,每次补都要为找 NULL 扫一遍 plan_reasons(测试服 31 万行)——
-- 而绝大多数时候一条都没有,纯白扫。
-- ⚠️ 部分索引只收 NULL 行 ⇒ 平时几乎是空的,补完即回到 O(1)。
CREATE INDEX "plan_reasons_labels_missing_idx"
ON "plan_reasons" ("id")
WHERE "potential_labels" IS NULL;
...@@ -1254,6 +1254,28 @@ model PlanReason { ...@@ -1254,6 +1254,28 @@ model PlanReason {
closedReason String? @map("closed_reason") closedReason String? @map("closed_reason")
closedAt DateTime? @map("closed_at") @db.Timestamptz(3) closedAt DateTime? @map("closed_at") @db.Timestamptz(3)
/**
* 这条依据能推出哪几个**潜在治疗标签**implant / ortho / extraction …)—— 预先算好。
*
* ═══ 为什么落这一列 ═══════════════════════════════════════════════
* 初选矩阵此前每次开页面都现推:`plan_reasons` 展开 `evidence.factIds`
* 回查 `patient_facts`1567 万行 / 18 GB)。最忙的诊所一次要摊三万多次随机查、
* 372 MB I/O —— **而这一切的唯一产出就是这个字符串**
* 🔴 本地实测(15,884 plan 的诊所):958 ms 110 ms**磁盘读 119,861 0**
* 24 格的数逐字未变(见 `scripts/bench-matrix.ts` 的结果快照)。
*
* ═══ 为什么是数组 ═══════════════════════════════════════════════
* 一条依据可以挂多个 fact93.7% 只有一个,但** 3.7%(本地 1,391 条)
* 会推出不止一个 code** 存单值会把这批人算少,而那正是最不能出的错。
*
* ═══ ⚠️ 年龄被冻结在算的那一刻 ═══════════════════════════════════
* 标签规则里有三条带年龄(K08>18 / K07 3~12 / K07 13~40),所以它不是纯函数。
* 实测本地受年龄规则影响 5,551 人,**明天跨档 0 人、30 天内 16 **(≈0.5 /天)——
* 跟着画像重算每晚刷一次,误差可忽略。
* **不能不刷**:不刷就会逐日漂,且不会有任何报错。刷新入口见 `plan-label.sql.ts`
*/
potentialLabels String[] @map("potential_labels")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
......
...@@ -7,6 +7,7 @@ import { reasonNeedsRefresh } from './reason-refresh'; ...@@ -7,6 +7,7 @@ import { reasonNeedsRefresh } from './reason-refresh';
import { runPool } from '../../../common/run-pool'; import { runPool } from '../../../common/run-pool';
import { PlanEventType, PlanEventReason } from '@pac/types'; import { PlanEventType, PlanEventReason } from '@pac/types';
import { recordPlanEvent, recordPlanEventsBulk, computeHeldSeconds } from '../plan-event.recorder'; import { recordPlanEvent, recordPlanEventsBulk, computeHeldSeconds } from '../plan-event.recorder';
import { PlanLabelService } from '../plan-label.service';
/** /**
* PlanEngineService — 跑所有 scenario plugin,产 FollowupPlan + PlanReason * PlanEngineService — 跑所有 scenario plugin,产 FollowupPlan + PlanReason
...@@ -33,6 +34,7 @@ export class PlanEngineService { ...@@ -33,6 +34,7 @@ export class PlanEngineService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly initiation: TreatmentInitiationRecallScenario, private readonly initiation: TreatmentInitiationRecallScenario,
private readonly planLabels: PlanLabelService,
) { ) {
// v2.1:一期只跑潜在治疗新链召回(treatment_initiation_recall) // v2.1:一期只跑潜在治疗新链召回(treatment_initiation_recall)
// 链已完成召回(treatment_aftercare_recall)留后续 // 链已完成召回(treatment_aftercare_recall)留后续
...@@ -367,6 +369,23 @@ export class PlanEngineService { ...@@ -367,6 +369,23 @@ export class PlanEngineService {
} }
} }
/**
* ⭐ **本轮新写的 reason 必须立刻补上 `potential_labels`** —— 初选矩阵靠这一列。
*
* 🔴 不补 = 新 plan 的那一列是 NULL,而矩阵 `unnest(potential_labels)` 直接跳过它们 ⇒
* **今天生成的人在矩阵里看不见**,且不会有任何报错(主管只会觉得"怎么少了一批")。
* ⚠️ 走 `PlanLabelService` 的同一份 SQL,⛔ 别在这里用 TS 再算一遍标签:
* 规则表是产品配置,第二份实现迟早和矩阵那份漂开(`plan-label.sql.ts` 头注那条)。
* ⚠️ 失败不阻断生成:标签是"派生数据",夜间刷新会兜住;
* 而生成本身回滚的代价远大于矩阵晚一晚上准。
*/
try {
const filled = await this.planLabels.backfillMissing();
if (filled > 0) this.logger.log(`[标签] 本轮补齐 ${filled} 条依据的潜在治疗标签`);
} catch (e) {
this.logger.warn(`[标签] 补齐失败(不阻断生成,夜间刷新会兜住):${e instanceof Error ? e.message : e}`);
}
return { return {
scenariosRun: this.scenarios.length, scenariosRun: this.scenarios.length,
patientsHit: hitsByPatient.size, patientsHit: hitsByPatient.size,
......
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { refreshLabelsSql, countMissingLabelsSql, LABEL_REFRESH_BATCH } from './plan-label.sql';
interface BatchResult {
seen: number;
written: number;
maxId: string | null;
}
/**
* `plan_reasons.potential_labels` 的刷新器 —— 初选矩阵的提速全靠这一列。
*
* 两种跑法:
* · `backfillMissing()` —— 只补没算过的(NULL)。上线回填、以及新写入行的兜底。
* · `refreshAll()` —— 连算过的一起重算。⚠️ 标签含年龄规则,**不重算就会逐日漂**。
*
* ⛔ 两者都**不在请求路径上**:它们扫全表,得由 CLI / 定时任务跑。
*/
@Injectable()
export class PlanLabelService {
private readonly logger = new Logger(PlanLabelService.name);
constructor(private readonly prisma: PrismaService) {}
/** 还有多少条没算过 —— 回填收尾核对、以及"刷新任务是不是在漏人"的体检项。 */
async countMissing(): Promise<number> {
const [row] = await this.prisma.$queryRaw<Array<{ n: number }>>(countMissingLabelsSql);
return row?.n ?? 0;
}
/** 只补没算过的。返回写了多少行。 */
async backfillMissing(batch = LABEL_REFRESH_BATCH): Promise<number> {
return this.run({ onlyMissing: true, batch, label: '回填' });
}
/** 全量重算(年龄会变)。返回写了多少行。 */
async refreshAll(batch = LABEL_REFRESH_BATCH): Promise<number> {
return this.run({ onlyMissing: false, batch, label: '重算' });
}
private async run(o: { onlyMissing: boolean; batch: number; label: string }): Promise<number> {
let afterId: string | null = null;
let written = 0;
let seenTotal = 0;
for (;;) {
const rows: BatchResult[] = await this.prisma.$queryRaw<BatchResult[]>(
refreshLabelsSql({ afterId, onlyMissing: o.onlyMissing, batch: o.batch }),
);
const r = rows[0];
const seen = r?.seen ?? 0;
if (seen === 0) break;
written += r?.written ?? 0;
seenTotal += seen;
/**
* 🔴 游标按 `maxId` 推进,⛔ 不按"写了几行" ——
* 全量重算时绝大多数行算出来跟原值一样、不会被写,此时 written=0
* 但**并不是做完了**。拿 written 当停止条件 = 第一批之后就静默罢工。
*/
afterId = r?.maxId ?? null;
if (!afterId) break;
if (seen < o.batch) break; // 最后一批
}
this.logger.log(`${o.label}完成:看过 ${seenTotal} 条,写入 ${written} 条`);
return written;
}
}
import { Prisma } from '@prisma/client';
import { labelCaseSql, AGE_YEARS_SQL } from './reason-temperature.sql';
/**
* `plan_reasons.potential_labels` 的**计算与刷新** —— 回填、夜间刷新、新写入三条路共用这一份。
*
* ═══ 为什么要预计算 ═══════════════════════════════════════════════
* 初选矩阵此前每次开页面都现推:`plan_reasons` → 展开 `evidence.factIds` →
* 回查 `patient_facts`(1567 万行 / 18 GB)。最忙的诊所一次摊三万多次随机查、372 MB I/O,
* **而这一切的唯一产出就是一个标签字符串**。
* 🔴 本地实测(15,884 条 plan):958 ms → 110 ms,磁盘读 119,861 → 0,24 格逐字未变。
*
* ═══ 🔴 标签规则只有一处 ═══════════════════════════════════════════
* 这里复用 `labelCaseSql`(矩阵此前用的同一个函数,由 `POTENTIAL_LABEL_RULES` 生成)——
* ⛔ **绝不在这里另写一份 CASE**:规则表是产品配置,抄第二份必然漂,
* 而漂了之后矩阵的数会**静默**不一样,没有任何报错(同 `enums` 里那条
* 「同名不同义是统计事故的标准配方」)。
*
* ═══ ⚠️ 年龄被冻结 ═══════════════════════════════════════════════
* 规则里有三条带年龄(K08>18 / K07 3~12 / K07 13~40)⇒ 标签不是纯函数。
* 实测受影响 5,551 人中「明天跨档 0 人、30 天内 16 人」(≈0.5 人/天)——
* 每晚刷一次误差可忽略,⛔ 但**不刷就会逐日漂且不报错**。
*/
/** 一次刷新的批量上限 —— ⚠️ 别一条 UPDATE 扫全表:31 万行会长时间持锁。 */
export const LABEL_REFRESH_BATCH = 5_000;
/**
* 计算并写回一批 `potential_labels`,**按 id 游标推进**。
*
* @param afterId 上一批处理到的最大 id(首批传 null)
* @param onlyMissing true = 只补 `IS NULL` 的(回填 / 补漏网);
* false = 连已算过的一起重算(夜间刷新,因为年龄会变)
* @returns 本批**看过**的行(`seen`)与实际写了几行(`written`)、本批最大 id(`maxId`)
*
* 🔴 **必须带游标** —— 只写 `ORDER BY id LIMIT n` 的话:
* · onlyMissing 那条路碰巧能走完(写完就不再是 NULL,下一批自然换人);
* · 而**全量刷新会原地打转** —— 每次都取同一批,永远推不到第二批。
* ⚠️ 更阴的是它不会报错,只是任务每晚白跑。同类「闸门永远追不上」的坑,
* 留痕清理那里刚踩过一次(那次是拿 `updatedAt` 当判据)。
*
* ⚠️ 全程 `LEFT JOIN` 到 fact —— 推不出标签的依据也要写 `'{}'`,
* ⛔ 不能只更新有标签的那些:否则它们永远停在 NULL,被每一轮重复捞出来。
*/
export function refreshLabelsSql(opts: {
afterId: string | null;
onlyMissing: boolean;
batch?: number;
}): Prisma.Sql {
const limit = opts.batch ?? LABEL_REFRESH_BATCH;
const gate = opts.onlyMissing ? Prisma.sql`AND pr.potential_labels IS NULL` : Prisma.empty;
const cursor = opts.afterId
? Prisma.sql`AND pr.id > ${opts.afterId}::uuid`
: Prisma.empty;
return Prisma.sql`
WITH target AS (
SELECT pr.id, fp.patient_id
FROM plan_reasons pr
JOIN followup_plans fp ON fp.id = pr.plan_id
WHERE fp.superseded_at IS NULL
${gate}
${cursor}
ORDER BY pr.id
LIMIT ${limit}
),
computed AS (
SELECT t.id,
COALESCE(
array_agg(DISTINCT lab.lbl) FILTER (WHERE lab.lbl IS NOT NULL),
'{}'::text[]
) AS labels
FROM target t
JOIN patients p ON p.id = t.patient_id
JOIN plan_reasons pr2 ON pr2.id = t.id
LEFT JOIN LATERAL jsonb_array_elements_text(pr2.evidence->'factIds') fid ON TRUE
LEFT JOIN patient_facts f
ON f.id = fid::uuid
AND f.status = 'active'
AND COALESCE(f.occurred_at, f.planned_for) IS NOT NULL
LEFT JOIN LATERAL (SELECT ${labelCaseSql('f', AGE_YEARS_SQL)} AS lbl) lab ON TRUE
GROUP BY t.id
),
upd AS (
UPDATE plan_reasons pr
SET potential_labels = c.labels
FROM computed c
WHERE pr.id = c.id
AND (pr.potential_labels IS DISTINCT FROM c.labels)
RETURNING pr.id
)
-- ⚠️ seen 与 written 必须**分开报**:全量刷新时绝大多数行算出来跟原值一样、
-- 不会被写,此时 written=0 ⛔ 不代表做完了。推进游标看的是 seen / maxId。
SELECT (SELECT count(*)::int FROM target) AS seen,
(SELECT count(*)::int FROM upd) AS written,
(SELECT max(id::text) FROM target) AS "maxId"`;
}
/** 还有多少条没算过 —— 回填收尾与告警用。 */
export const countMissingLabelsSql = Prisma.sql`
SELECT count(*)::int AS n
FROM plan_reasons pr
JOIN followup_plans fp ON fp.id = pr.plan_id
WHERE fp.superseded_at IS NULL AND pr.potential_labels IS NULL`;
...@@ -14,6 +14,7 @@ import { PlanEngineService } from './engine/plan-engine.service'; ...@@ -14,6 +14,7 @@ import { PlanEngineService } from './engine/plan-engine.service';
import { ChainComposerService } from './engine/chain-composer.service'; import { ChainComposerService } from './engine/chain-composer.service';
import { TreatmentInitiationRecallScenario } from './engine/scenarios/treatment-initiation-recall.scenario'; import { TreatmentInitiationRecallScenario } from './engine/scenarios/treatment-initiation-recall.scenario';
import { RecallDebugController } from './recall-debug/recall-debug.controller'; import { RecallDebugController } from './recall-debug/recall-debug.controller';
import { PlanLabelService } from './plan-label.service';
import { RecallDebugService } from './recall-debug/recall-debug.service'; import { RecallDebugService } from './recall-debug/recall-debug.service';
/** /**
...@@ -42,8 +43,9 @@ import { RecallDebugService } from './recall-debug/recall-debug.service'; ...@@ -42,8 +43,9 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
ChainComposerService, ChainComposerService,
TreatmentInitiationRecallScenario, TreatmentInitiationRecallScenario,
RecallDebugService, RecallDebugService,
PlanLabelService,
], ],
// MCP 的主管工具直接用这两个 service(条件注册,见 mcp-server.factory) // MCP 的主管工具直接用这两个 service(条件注册,见 mcp-server.factory)
exports: [PlanService, PlanAssignmentService, AgentRosterService, AssignmentProposalService, CohortAttributesService, ExecutionService, ExecutionCallbackService, PlanEngineService, ChainComposerService], exports: [PlanService, PlanLabelService, PlanAssignmentService, AgentRosterService, AssignmentProposalService, CohortAttributesService, ExecutionService, ExecutionCallbackService, PlanEngineService, ChainComposerService],
}) })
export class PlanModule {} export class PlanModule {}
...@@ -155,16 +155,10 @@ export function planLabelAnchorsSql(planFilter: Prisma.Sql): Prisma.Sql { ...@@ -155,16 +155,10 @@ export function planLabelAnchorsSql(planFilter: Prisma.Sql): Prisma.Sql {
${BOUNDS.warm} AS warm_until, ${BOUNDS.warm} AS warm_until,
${BOUNDS.anchor} AS anchor_at ${BOUNDS.anchor} AS anchor_at
FROM followup_plans fp FROM followup_plans fp
JOIN patients p ON p.id = fp.patient_id
JOIN plan_reasons pr ON pr.plan_id = fp.id JOIN plan_reasons pr ON pr.plan_id = fp.id
CROSS JOIN LATERAL jsonb_array_elements_text(pr.evidence->'factIds') fid CROSS JOIN LATERAL unnest(pr.potential_labels) AS lab(lbl)
JOIN patient_facts f ON f.id = fid::uuid AND f.status = 'active'
CROSS JOIN LATERAL (SELECT COALESCE(f.occurred_at, f.planned_for) AS at) anc
CROSS JOIN LATERAL (SELECT ${labelCaseSql('f', AGE_YEARS_SQL)} AS lbl) lab
${LAST_VISIT_JOIN} ${LAST_VISIT_JOIN}
WHERE ${planFilter} WHERE ${planFilter}
AND lab.lbl IS NOT NULL
AND anc.at IS NOT NULL
GROUP BY 1, 2, 3`; GROUP BY 1, 2, 3`;
} }
......
...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; ...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule'; import { Cron } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PlanLabelService } from '../modules/plan/plan-label.service';
import type { AppConfig } from '../config/configuration'; import type { AppConfig } from '../config/configuration';
/** /**
...@@ -74,3 +75,35 @@ export class InvocationRetentionService { ...@@ -74,3 +75,35 @@ export class InvocationRetentionService {
return res.count; return res.count;
} }
} }
/**
* PlanLabelRefreshService —— 每晚重算 `plan_reasons.potential_labels`。
*
* 🔴 **不刷就会逐日漂,且不会有任何报错**:标签规则里有三条带年龄
* (K08>18 / K07 3~12 / K07 13~40),患者过生日跨档时标签就该变。
* 实测本地受影响 5,551 人中「明天跨档 0 人、30 天内 16 人」(≈0.5 人/天)——
* 每晚刷一次误差可忽略,但**一天不刷就多欠一天**。
*
* ⚠️ 排在画像重算之后(03:30):标签只依赖 fact 与生日,与画像无先后依赖,
* 但错开时段避免两个全表任务抢 I/O。
*/
@Injectable()
export class PlanLabelRefreshService {
private readonly logger = new Logger(PlanLabelRefreshService.name);
constructor(private readonly labels: PlanLabelService) {}
@Cron(process.env.PAC_PLAN_LABEL_REFRESH_CRON || '30 3 * * *', {
name: 'plan-label-refresh',
timeZone: 'Asia/Shanghai',
})
async refresh(): Promise<void> {
const missing = await this.labels.countMissing();
if (missing > 0) {
// 平时该是 0(生成完就补)。非 0 说明有条路写了 reason 却没补标签 —— 值得看一眼。
this.logger.warn(`[标签] 刷新前发现 ${missing} 条没算过 —— 生成路径可能漏了补齐`);
}
const n = await this.labels.refreshAll();
this.logger.log(`[标签] 夜间重算完成,${n} 条因年龄跨档等原因发生变化`);
}
}
...@@ -11,7 +11,7 @@ import { QueueProducer } from './queue-producer.service'; ...@@ -11,7 +11,7 @@ import { QueueProducer } from './queue-producer.service';
import { StaleScanService } from './stale-scan.service'; import { StaleScanService } from './stale-scan.service';
import { SyncIncrementalSchedulerService } from './sync-incremental.scheduler'; import { SyncIncrementalSchedulerService } from './sync-incremental.scheduler';
import { DwLagMonitorService } from './dw-lag-monitor.service'; import { DwLagMonitorService } from './dw-lag-monitor.service';
import { InvocationRetentionService } from './invocation-retention.service'; import { InvocationRetentionService, PlanLabelRefreshService } from './invocation-retention.service';
import { DailyHealthReportService } from './daily-health-report.service'; import { DailyHealthReportService } from './daily-health-report.service';
import { DailyHealthReportController } from './daily-health-report.controller'; import { DailyHealthReportController } from './daily-health-report.controller';
import { PersonaRecomputeProcessor } from './processors/persona-recompute.processor'; import { PersonaRecomputeProcessor } from './processors/persona-recompute.processor';
...@@ -73,6 +73,7 @@ import { ColdImportProcessor } from './processors/cold-import.processor'; ...@@ -73,6 +73,7 @@ import { ColdImportProcessor } from './processors/cold-import.processor';
DwLagMonitorService, DwLagMonitorService,
DailyHealthReportService, DailyHealthReportService,
InvocationRetentionService, InvocationRetentionService,
PlanLabelRefreshService,
PersonaRecomputeProcessor, PersonaRecomputeProcessor,
PlanRecomputeProcessor, PlanRecomputeProcessor,
PlanAssetGenerateProcessor, PlanAssetGenerateProcessor,
......
import { refreshLabelsSql, countMissingLabelsSql, LABEL_REFRESH_BATCH } from '../src/modules/plan/plan-label.sql';
import { planLabelAnchorsSql } from '../src/modules/plan/reason-temperature.sql';
import { Prisma } from '@prisma/client';
/**
* `plan_reasons.potential_labels` —— 初选矩阵提速的那一列。
*
* 本地实测(15,884 条 plan 的诊所):958 ms → 110 ms,磁盘读 119,861 → 13,500,
* 而 24 格的数**逐字未变**。这组测试守的是让它继续成立的几条结构性前提。
*/
const text = (q: Prisma.Sql) => q.sql;
describe('矩阵查询不再碰 patient_facts', () => {
const sql = text(planLabelAnchorsSql(Prisma.sql`fp.status = 'active'`));
it('🔴 ⛔ 不再 JOIN patient_facts —— 那是三万多次随机查、372 MB I/O 的来源', () => {
expect(sql).not.toMatch(/patient_facts/);
});
it('🔴 ⛔ 不再展开 evidence.factIds', () => {
expect(sql).not.toMatch(/jsonb_array_elements_text/);
});
it('⛔ 不再 JOIN patients —— 年龄已烘进标签,那张表原本要被全表扫', () => {
expect(sql).not.toMatch(/JOIN patients/);
});
it('改用预存的 potential_labels', () => {
expect(sql).toMatch(/unnest\(pr\.potential_labels\)/);
});
it('末诊锚点仍来自 patient_profiles —— 温度那根轴没动', () => {
expect(sql).toMatch(/patient_profiles/);
expect(sql).toMatch(/last_visit_at/);
});
});
describe('刷新 SQL 的几条结构前提', () => {
it('🔴 带 id 游标 —— 否则全量重算每次取同一批,原地打转且不报错', () => {
const first = text(refreshLabelsSql({ afterId: null, onlyMissing: false }));
const next = text(refreshLabelsSql({ afterId: 'x', onlyMissing: false }));
expect(first).not.toMatch(/pr\.id >/);
expect(next).toMatch(/pr\.id >/);
});
it('🔴 seen / written / maxId 分开报 —— written=0 ⛔ 不等于做完了', () => {
const s = text(refreshLabelsSql({ afterId: null, onlyMissing: false }));
expect(s).toMatch(/AS seen/);
expect(s).toMatch(/AS written/);
expect(s).toMatch(/"maxId"/);
});
it('onlyMissing 才加 IS NULL 闸;全量重算⛔ 不加', () => {
expect(text(refreshLabelsSql({ afterId: null, onlyMissing: true }))).toMatch(
/potential_labels IS NULL/,
);
expect(text(refreshLabelsSql({ afterId: null, onlyMissing: false }))).not.toMatch(
/potential_labels IS NULL/,
);
});
it('🔴 用 LEFT JOIN 取 fact —— 推不出标签的也要写 {},否则永远停在 NULL 被反复捞', () => {
const s = text(refreshLabelsSql({ afterId: null, onlyMissing: true }));
expect(s).toMatch(/LEFT JOIN patient_facts/);
expect(s).toMatch(/'\{\}'::text\[\]/);
});
it('⛔ 不在这里另写一份标签 CASE —— 复用矩阵那份(规则表是单一真源)', () => {
const q = refreshLabelsSql({ afterId: null, onlyMissing: true });
// ⚠️ 标签值走参数化占位,在 `values` 里而不在 `sql` 里 —— 断言写在 sql 上会假绿。
const vals = q.values.map(String);
for (const lbl of ['implant', 'early_ortho', 'restoration']) {
expect(vals).toContain(lbl);
}
// 且确实是一段 CASE(来自 labelCaseSql),⛔ 不是这里手写的映射
expect(q.sql).toMatch(/CASE\s+WHEN/);
});
it('只处理未作废的 plan', () => {
expect(text(refreshLabelsSql({ afterId: null, onlyMissing: true }))).toMatch(
/fp\.superseded_at IS NULL/,
);
expect(text(countMissingLabelsSql)).toMatch(/fp\.superseded_at IS NULL/);
});
it('批量有上限 —— ⛔ 别一条 UPDATE 扫全表', () => {
expect(LABEL_REFRESH_BATCH).toBeGreaterThan(0);
expect(LABEL_REFRESH_BATCH).toBeLessThanOrEqual(20_000);
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment