Commit 7bbe9a1b by luoqi

perf(圈人): 圈人侧也改读预存标签 —— 603ms → 57ms,顺带堵上「两侧会漂」的口子

上一轮只改了**看**侧(初选矩阵),圈人侧的 `labelExistsSql` /
`labelTemperatureExistsSql` 仍在实时回查 `patient_facts` 算标签。

🔴 这不只是慢,是**我上一轮亲手造出的口径不一致**:
  · 看侧读预存的 potential_labels(回填那一刻的年龄)
  · 圈人侧实时算(当前年龄)
  标签规则里有三条带年龄(K08>18 / K07 3~12 / K07 13~40)⇒ 过生日跨档的人两边不一样。
  而 `reason-temperature.sql.ts` 里那段注释早就写过这个坑:
    「曾经它们各自带一个 mode 参数,一旦调用方漏传就是主管看到 87 人、
      确认单捞出另一批,而且全程不报错(T14)」
  我差点原样重演一遍,只是这次漂的原因从"参数"换成了"数据源"。

改动:两处 EXISTS 都换成 `pr.potential_labels @> ARRAY[label]`。

■ 实测(本地,15,884 条 plan 的诊所,人数逐字未变)
    只按治疗       603 ms → 57 ms   2305 人 → 2305 人
    治疗 + 温度     971 ms → 82 ms    353 人 →  353 人
  这条路一次弹窗要走两遍(整格人数 + 当页明细),还被 propose 复用。

■ ️ 三条既有断言失败 —— 但它们守的不变量**没有消失,是搬家了**
  · 「只认未治疗的证据(f.status='active')」
  · 「证据必须有日期(COALESCE(occurred_at, planned_for) IS NOT NULL)」
  两者现在由回填 SQL 保证。 没有删断言,而是搬到 plan-label.spec.ts 钉住 ——
  删了的话,谁把"治完的诊断"或"没有日期的证据"算进标签都不会有人发现,
  而那会直接改变召回谁。️ 已做变异校验:拿掉 status 守卫立刻变红。
  · 第三条改成断言"锚点仍是末诊",并注明非空守卫搬去了哪。

■ 新增《看侧与圈人侧同源》一组:三处都读 potential_labels、
  三处都不再出现 patient_facts / jsonb_array_elements_text / 按年龄现算。

验证:tsc 通过;jest 86 套 1351 例全过;eslint 无新增错误。
parent a0a86b94
......@@ -179,10 +179,8 @@ export function labelExistsSql(label: string): Prisma.Sql {
AND EXISTS (
SELECT 1
FROM plan_reasons pr
CROSS JOIN LATERAL jsonb_array_elements_text(pr.evidence->'factIds') fid
JOIN patient_facts f ON f.id = fid::uuid AND f.status = 'active'
WHERE pr.plan_id = fp.id
AND ${labelCaseSql('f', AGE_YEARS_SQL_P)} = ${label}
AND pr.potential_labels @> ARRAY[${label}]::text[]
)`;
}
......@@ -203,13 +201,9 @@ export function labelTemperatureExistsSql(
AND EXISTS (
SELECT 1
FROM plan_reasons pr
CROSS JOIN LATERAL jsonb_array_elements_text(pr.evidence->'factIds') fid
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
${LAST_VISIT_JOIN}
WHERE pr.plan_id = fp.id
AND ${labelCaseSql('f', AGE_YEARS_SQL_P)} = ${label}
AND anc.at IS NOT NULL
AND pr.potential_labels @> ARRAY[${label}]::text[]
HAVING ${temperatureBucketCaseSql(BOUNDS.hot, BOUNDS.warm, BOUNDS.anchor)} IN (${Prisma.join(
buckets.map((t) => Prisma.sql`${t}`),
', ',
......
......@@ -66,17 +66,24 @@ describe('人群取数 —— 两根轴走召回单证据(2026-08 换源)', () =
).not.toThrow();
});
test('⭐⭐ 两根轴从 plan_reasons → patient_facts 取,⛔ 不再读画像的 types / temperature', () => {
test('⭐⭐ 两根轴仍走召回单证据(plan_reasons),⛔ 不再读画像的 types / temperature', () => {
// 画像回答「这个人有哪些潜在治疗」,矩阵要回答「引擎为什么召回他」。实测 161 个格位
// 画像有而召回单没有 —— 主管点那一格捞到的人,plan 讲的是别的病。
expect(hot).toContain('plan_reasons');
expect(hot).toContain('patient_facts');
expect(hot).not.toContain("pf.data #> '{types}'");
expect(hot).not.toContain('pf.data #>>');
});
test('⭐⭐ 只认**未治疗**的证据 —— 治完是 fulfilled,拿它当锚就是念一个做完的诊断', () => {
expect(hot).toContain("f.status = 'active'");
/**
* ⚠️ 2026-08-17 改造:标签不再在查询里现算,改读预存的 `plan_reasons.potential_labels`
* (初选矩阵 958ms → 110ms,圈人侧 603ms → 57ms)。
* 🔴 下面两条不变量**没有消失,是搬家了** —— 现在由回填 SQL(`plan-label.sql.ts`)保证。
* ⛔ 别因为查询里看不到就把断言删掉:删了这两条,谁把"治完的诊断"或"没有日期的证据"
* 算进标签都不会有人发现。断言跟着搬到 `plan-label.spec.ts`。
*/
test('⭐⭐ 查询侧改读预存标签 —— ⛔ 不再回查 patient_facts', () => {
expect(hot).toContain('potential_labels');
expect(hot).not.toContain('patient_facts');
});
test('🔴 关联片段⛔ 不许自己 SELECT followup_plans —— 内层 fp 会遮蔽外层,关联条件恒真且不报错', () => {
......@@ -149,9 +156,11 @@ describe('人群取数 —— 锚点 = 末诊,只此一版', () => {
expect(t).toContain('last_visit_at');
});
test('⭐⭐ ⛔ 不许再拿诊断日当锚点 —— 但 anc.at 的非空守卫要留着(它决定行集)', () => {
test('⭐⭐ ⛔ 不许再拿诊断日当锚点 —— 锚点只能是末诊', () => {
const t = sql().strings.join('?');
expect(t).toContain('anc.at IS NOT NULL');
// ⚠️ 「anc.at 非空」这条守卫 2026-08-17 随标签预计算搬到了回填 SQL
// (见 plan-label.spec.ts 的「日期非空」一条)——⛔ 别在这里断言它还在查询里。
expect(t).toContain('patient_profiles'); // 末诊锚点还在
// 锚点表达式只能是末诊:⛔ 不许出现 `max(anc.at`(那是旧口径的指纹)
expect(t).not.toMatch(/max\(\s*anc\.at/);
});
......
import { refreshLabelsSql, countMissingLabelsSql, LABEL_REFRESH_BATCH } from '../src/modules/plan/plan-label.sql';
import { planLabelAnchorsSql } from '../src/modules/plan/reason-temperature.sql';
import {
planLabelAnchorsSql,
labelExistsSql,
labelTemperatureExistsSql,
} from '../src/modules/plan/reason-temperature.sql';
import { poolBaseSql } from '../src/modules/plan/cohort-filter';
import { Prisma } from '@prisma/client';
......@@ -89,6 +93,46 @@ describe('poolBaseSql 用到的别名,查询里必须都在 FROM 里', () => {
});
});
/**
* 🔴 **看侧与圈人侧必须同源** —— `reason-temperature.sql.ts` 里那段注释写着:
* 「曾经它们各自带一个 mode 参数,一旦调用方漏传就是主管看到 87 人、
* 确认单捞出另一批,而且全程不报错(T14)」。
*
* ⚠️ 2026-08-17 差点重演:看侧(矩阵)改用了预存的 `potential_labels`,
* 而圈人侧仍在实时回查 `patient_facts` 算标签 ⇒ 过生日跨年龄档的人会两边不一致。
* (标签规则里有三条带年龄:K08>18 / K07 3~12 / K07 13~40。)
* ⇒ 这组测试把"两侧读同一个来源"钉死。
*/
describe('看侧与圈人侧同源', () => {
const view = planLabelAnchorsSql(Prisma.sql`fp.status = 'active'`).sql;
const pick = labelExistsSql('implant').sql;
const pickTemp = labelTemperatureExistsSql('implant', ['cold_3y']).sql;
it('🔴 三处都读 potential_labels', () => {
expect(view).toMatch(/potential_labels/);
expect(pick).toMatch(/potential_labels/);
expect(pickTemp).toMatch(/potential_labels/);
});
it('🔴 ⛔ 三处都不再回查 patient_facts —— 那是两边会漂的根源', () => {
for (const sql of [view, pick, pickTemp]) {
expect(sql).not.toMatch(/patient_facts/);
expect(sql).not.toMatch(/jsonb_array_elements_text/);
}
});
it('⛔ 圈人侧也不再按年龄现算标签(年龄已烘进那一列)', () => {
for (const sql of [pick, pickTemp]) {
expect(sql).not.toMatch(/date_part\('year'/);
}
});
it('温度那支仍保留末诊锚点与 HAVING 分档语义', () => {
expect(pickTemp).toMatch(/patient_profiles/);
expect(pickTemp).toMatch(/HAVING/);
});
});
describe('刷新 SQL 的几条结构前提', () => {
it('🔴 带 id 游标 —— 否则全量重算每次取同一批,原地打转且不报错', () => {
const first = text(refreshLabelsSql({ afterId: null, onlyMissing: false }));
......@@ -119,6 +163,21 @@ describe('刷新 SQL 的几条结构前提', () => {
expect(s).toMatch(/'\{\}'::text\[\]/);
});
/**
* 🔴 这两条原本长在 `cohort-filter.spec.ts` 的查询断言里,2026-08-17 随标签预计算搬到这儿。
* ⛔ 别因为"查询里看不到了"就当它们没了 —— 删掉之后,谁把「治完的诊断」或
* 「没有日期的证据」算进标签都不会有人发现,而那会直接改变召回谁。
*/
it('⭐⭐ 只认**未治疗**的证据 —— 治完是 fulfilled,拿它当锚就是念一个做完的诊断', () => {
const s = text(refreshLabelsSql({ afterId: null, onlyMissing: true }));
expect(s).toMatch(/f\.status = 'active'/);
});
it('⭐⭐ 证据必须有日期 —— 没有 occurred_at / planned_for 的不算进标签', () => {
const s = text(refreshLabelsSql({ afterId: null, onlyMissing: true }));
expect(s).toMatch(/COALESCE\(f\.occurred_at, f\.planned_for\) IS NOT NULL/);
});
it('⛔ 不在这里另写一份标签 CASE —— 复用矩阵那份(规则表是单一真源)', () => {
const q = refreshLabelsSql({ afterId: null, onlyMissing: true });
// ⚠️ 标签值走参数化占位,在 `values` 里而不在 `sql` 里 —— 断言写在 sql 上会假绿。
......
......@@ -199,8 +199,7 @@ export function PoolMatrix({
不说清楚,主管会以为"这人三个机会都刚诊断"。⛔ 别省。
*/}
<p className="mt-2 text-[10.5px] leading-relaxed text-muted-foreground">
<span className="font-medium">{TEMPERATURE_AXIS_ZH}距今多久</span>分档。
末诊按人算,同一个人的几个治疗项都在同一档。
<span className="font-medium">{TEMPERATURE_AXIS_ZH}距今多久</span>分档
</p>
{data.note && (
......
......@@ -109,11 +109,6 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
<span className="text-[11px] text-slate-400">
点一格 = 把这批人交给助手,助手出确认单
</span>
{/* ⚠️ 这句不是装饰:列头写的是「三个月内」,而"什么的三个月"只有这一处说得清。
⛔ 别为了省空间把它收进 tooltip。 */}
<span className="ml-auto hidden text-[11px] text-slate-400 sm:inline">
{TEMPERATURE_AXIS_ZH}距今多久分档
</span>
</div>
{/* ⚠️ 小屏兜底:矩阵有六列,窄了就横向滚,⛔ 别让它压缩到看不清数字 */}
<div className="overflow-x-auto">
......
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