Commit 6e7d1f60 by luoqi

feat(reparse): 加 --patients-file —— 按受影响患者收窄,实测最高 250 倍

全量 reparse 的时间几乎全花在"证明没变"上。2026-07-27 测试服实测:
  全量范围            260,434 患者 / 87 批 / ~4.5 小时
  改 clinical-signals 字典 → 真正受影响 ~40,000 患者(15%)  → 6.5 倍
  改 planned「二期」规则   → 真正受影响  1,017 患者(0.4%) → 250 倍(2 分钟)

已有 --patient=<uuid>,... 但几千个 uuid 塞不进命令行(ARG_MAX),缺的就是文件入口。

用法:先用 SQL 圈出受影响患者,dump 成一行一个 id,再喂进来。
  psql -tAc "SELECT DISTINCT patient_id FROM patient_facts
             WHERE type='treatment_record' AND kind='planned'
               AND content->>'subtype' LIKE '%二期%'" > /tmp/ids.txt
  pnpm reparse -- --host=jvs-dw --subject-type=treatment --patients-file=/tmp/ids.txt

文件格式刻意宽容(操作者多半是 psql 直接倒出来的):uuid 与 externalId 混写、
# 整行/行尾注释、空行、行尾逗号分号、CRLF 全收;但不做猜测性纠错 —— 认不出的
原样送去库里查,查不到就 WARN 报出前 5 个,不静默丢(否则"为什么少跑了几百人"是无头案)。

 空结果必须硬失败,这是本次最要紧的一条:
   服务侧 `if (!scopePatientIds?.length)` 把空数组当"不限定" ——
   一个路径写错的收窄命令会静默变成 4.5 小时全量,且日志上完全看不出异常。
   现在解析为 0 / 一个都对不上库,都直接报错退出。

── 顺带修一个我自己踩出来的隐患 ──
CLI 末尾是 `void main()`(全仓 CLI 通例),我最初把纯函数留在 CLI 里、测试直接 import,
结果测试跑起来对本地库真跑了一次 reparse(115 行 Nest 日志)。两道修:
  ① 纯函数拆到 src/cli/reparse-patients-file.ts,测试只碰这里
  ② CLI 末尾加 `if (require.main === module)` 闸 —— 以后任何 import 都不会再误启动

实测(本地 friday):
  空文件      → reparse 失败:解析后为 0 个患者(拒绝退化成全量)
  混写清单    → 读到 4 token(uuid 1 / externalId 3 → 认领 2),WARN 1 个查不到,
                最终限定 3 患者,ColdImportService 确认「范围 3 患者」
测试 547 passed(新增 13 例)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 469ef2c9
/**
* `--patients-file` 的解析helper —— 独立成模块,**不要塞回 reparse.cli.ts**。
*
* 原因:CLI 文件末尾是 `void main()`(全仓 CLI 通例),任何 import 都会当场启动 Nest
* 并真跑一次 reparse。2026-07-27 我把测试直接 import CLI,结果测试跑起来对本地库
* 执行了一次真实 reparse(115 行 Nest 日志)—— 纯函数放这里,测试只碰这里。
* (CLI 侧另加了 require.main 闸做第二道防线。)
*/
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* 解析 --patients-file 的内容。
*
* 宽容到什么程度是刻意的:操作者多半是 `psql -tAc ... > ids.txt` 直接倒出来的,
* 会带前后空格、空行、行尾逗号;混着 uuid 和病历号也很常见(手上有什么用什么)。
* 但**不做猜测性纠错** —— 认不出的原样返回,由调用方去库里查、查不到就报出来,不静默丢。
*/
export function parsePatientsFile(raw: string): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const line of raw.split(/\r?\n/)) {
// 允许行尾注释:`<id> # 张三 缺牙` —— 圈人清单常顺手标注是谁
const token = line.split('#')[0]!.trim().replace(/[,;]+$/, '');
if (!token) continue;
if (seen.has(token)) continue;
seen.add(token);
out.push(token);
}
return out;
}
/** 按形态拆成 uuid(patient.id)与其余(当 externalId 去库里查) */
export function splitPatientTokens(tokens: string[]): { ids: string[]; externalIds: string[] } {
const ids: string[] = [];
const externalIds: string[] = [];
for (const t of tokens) (UUID_RE.test(t) ? ids : externalIds).push(t);
return { ids, externalIds };
}
......@@ -7,13 +7,32 @@
* Usage:
* pnpm reparse -- --host=jvs-dw --subject-type=diagnosis # 实跑 + 定向重算
* pnpm reparse -- --host=jvs-dw --subject-type=diagnosis --dry-run # 只报会改多少,不写
* pnpm reparse -- --host=jvs-dw --patient=<uuid>,<uuid> # 限定患者
* pnpm reparse -- --host=jvs-dw --patient=<uuid>,<uuid> # 限定患者(少量,命令行)
* pnpm reparse -- --host=jvs-dw --patients-file=./ids.txt # 限定患者(大批,见下)
* pnpm reparse -- --host=jvs-dw --subject-type=diagnosis --no-recompute # 只重衍 fact,稍后单独 recompute
*
* ⭐ --patients-file:**按受影响患者收窄**,reparse 最有效的提速手段。
* 全量 reparse 的时间几乎全花在"证明没变"上 —— 2026-07-27 测试服实测:
* 260,434 患者 / 87 批 / ~4.5 小时,而其中真正会变的:
* · 改 clinical-signals 字典 → 约 4 万患者(15%) → 6.5 倍
* · 改 planned「二期」归类规则 → 1,017 患者(0.4%) → 250 倍(2 分钟跑完)
* 用法:先用 SQL 圈出受影响患者,dump 成一行一个 id 的文件,再喂给本参数。例:
* psql -tAc "SELECT DISTINCT patient_id FROM patient_facts
* WHERE type='treatment_record' AND kind='planned'
* AND content->>'subtype' LIKE '%二期%'" > /tmp/ids.txt
* pnpm reparse -- --host=jvs-dw --subject-type=treatment --patients-file=/tmp/ids.txt
* 文件支持 patient.id(uuid)与 externalId 混写,# 开头为注释,空行忽略。
* ⚠️ 解析结果为空时**直接报错退出**,绝不退化成全量 —— 服务侧 `patientIds` 传空数组
* 等同"不限定"(cold-import.service.ts `if (!scopePatientIds?.length)`),
* 一个写错路径的收窄命令若静默变成 4.5 小时全量,是最难发现的那种事故。
*
* 通过 NestApplicationContext 启动(不起 HTTP),复用 DI。
*/
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { existsSync, readFileSync } from 'node:fs';
// 纯函数拆在独立模块 —— 本文件末尾 `void main()`,被 import 就会真跑 reparse,测试不能碰
import { parsePatientsFile, splitPatientTokens } from './reparse-patients-file';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { ColdImportService } from '../modules/sync/cold-import/cold-import.service';
......@@ -25,6 +44,7 @@ interface CliArgs {
host: string;
subjectTypes?: string[];
patientIds?: string[];
patientsFile?: string;
dryRun: boolean;
recompute: boolean;
help: boolean;
......@@ -40,6 +60,7 @@ function parseArgs(argv: string[]): CliArgs {
else if (a.startsWith('--host=')) args.host = a.slice('--host='.length);
else if (a.startsWith('--subject-type=')) args.subjectTypes = a.slice('--subject-type='.length).split(',').map((s) => s.trim()).filter(Boolean);
else if (a.startsWith('--patient=')) args.patientIds = a.slice('--patient='.length).split(',').map((s) => s.trim()).filter(Boolean);
else if (a.startsWith('--patients-file=')) args.patientsFile = a.slice('--patients-file='.length).trim();
}
return args;
}
......@@ -51,7 +72,11 @@ async function main(): Promise<void> {
// eslint-disable-next-line no-console
console.log(
'pnpm reparse -- --host=<name> [--dir=./data/<host>] [--subject-type=diagnosis,treatment]\n' +
' [--patient=<uuid>,...] [--dry-run] [--no-recompute]',
' [--patient=<uuid>,...] [--patients-file=<path>] [--dry-run] [--no-recompute]\n' +
'\n' +
' --patients-file 一行一个 patient.id(uuid)或 externalId,# 注释、空行忽略。\n' +
' 按受影响患者收窄是 reparse 最有效的提速手段(实测可达 250 倍)。\n' +
' 解析为空会直接报错,不会退化成全量。',
);
return;
}
......@@ -63,17 +88,64 @@ async function main(): Promise<void> {
const persona = app.get(PersonaService);
const planEngine = app.get(PlanEngineService);
// ── --patients-file:解析 → 认领 externalId → 与 --patient 合并 ──
let patientIds = args.patientIds;
if (args.patientsFile) {
if (!existsSync(args.patientsFile)) {
throw new Error(`--patients-file 不存在:${args.patientsFile}`);
}
const tokens = parsePatientsFile(readFileSync(args.patientsFile, 'utf8'));
if (tokens.length === 0) {
// ⚠️ 不能放过 —— 空 patientIds 到服务侧等同"不限定" = 全量 reparse(4.5 小时)
throw new Error(`--patients-file 解析后为 0 个患者:${args.patientsFile}(拒绝退化成全量)`);
}
const { ids, externalIds } = splitPatientTokens(tokens);
// externalId 只在 host 内唯一 —— 必须带 hostId 查,否则跨宿主撞号
let resolved: string[] = [];
if (externalIds.length > 0) {
const host = await prisma.host.findUnique({ where: { name: args.host }, select: { id: true } });
if (!host) throw new Error(`host '${args.host}' 不存在`);
const rows = await prisma.patient.findMany({
where: { hostId: host.id, externalId: { in: externalIds } },
select: { id: true, externalId: true },
});
resolved = rows.map((r) => r.id);
const found = new Set(rows.map((r) => r.externalId));
const missing = externalIds.filter((e) => !found.has(e));
if (missing.length > 0) {
// 报出来但不中断:圈人清单里混进几个别的 host / 已删患者是常态,
// 静默丢会让"为什么少跑了几百人"变成无头案。
logger.warn(
`--patients-file 有 ${missing.length} 个 externalId 在 host=${args.host} 查不到,已跳过` +
`(前 5 个:${missing.slice(0, 5).join(', ')})`,
);
}
}
const merged = [...new Set([...(args.patientIds ?? []), ...ids, ...resolved])];
if (merged.length === 0) {
throw new Error(`--patients-file 里没有一个能对上库中的患者:${args.patientsFile}(拒绝退化成全量)`);
}
patientIds = merged;
logger.log(
`--patients-file ${args.patientsFile}:读到 ${tokens.length} 个 token ` +
`(uuid ${ids.length} / externalId ${externalIds.length} → 认领 ${resolved.length});` +
`最终限定 ${merged.length} 个患者`,
);
}
logger.log(
`reparse start: host=${args.host} dir=${args.dir} ` +
`subjectTypes=${args.subjectTypes?.join(',') ?? 'ALL'} ` +
`patients=${args.patientIds?.length ?? 'all'} dryRun=${args.dryRun} recompute=${args.recompute}`,
`patients=${patientIds?.length ?? 'all'} dryRun=${args.dryRun} recompute=${args.recompute}`,
);
const result = await coldImport.reparseFromTransactions({
dir: args.dir,
hostName: args.host,
subjectTypes: args.subjectTypes,
patientIds: args.patientIds,
patientIds,
dryRun: args.dryRun,
});
......@@ -134,4 +206,6 @@ async function main(): Promise<void> {
}
}
void main();
// ⚠️ 只在被直接执行时跑 —— 没这道闸,任何 import 本文件的代码(测试/工具脚本)
// 都会当场启动 Nest 并对连着的库跑一次真实 reparse。2026-07-27 实际踩过。
if (require.main === module) void main();
import { parsePatientsFile, splitPatientTokens } from "../src/cli/reparse-patients-file";
/**
* `--patients-file` 解析回归 —— 这个参数决定 reparse 跑 2 分钟还是 4.5 小时。
*
* 背景(2026-07-27 测试服实测):全量 reparse = 260,434 患者 / 87 批 / ~4.5 小时,
* 而时间几乎全花在"证明没变"上 —— 真正会变的:
* · 改 clinical-signals 字典 → 约 4 万患者(15%)
* · 改 planned「二期」归类规则 → 1,017 患者(0.4%)→ 收窄后 2 分钟跑完
*
* ⭐ 最要紧的一条不在本文件而在 CLI:解析结果为空必须**报错退出**。
* 服务侧 `if (!scopePatientIds?.length)` 把空数组当"不限定" →
* 一个路径写错的收窄命令会静默变成 4.5 小时全量,且日志上看不出异常。
* 这里先把"什么算解析出来了"钉死。
*/
describe('parsePatientsFile — 宽容但不猜', () => {
it('一行一个,去空行去首尾空格', () => {
expect(parsePatientsFile('a\n b \n\n\nc\n')).toEqual(['a', 'b', 'c']);
});
it('# 注释整行忽略', () => {
expect(parsePatientsFile('# 受影响患者\na\n#b\nc')).toEqual(['a', 'c']);
});
it('⭐ 行尾注释也剥掉 —— 圈人清单常顺手标注是谁', () => {
expect(parsePatientsFile('a # 张三 缺牙\nb\t# 李四')).toEqual(['a', 'b']);
});
it('去重(psql dump 混着几次查询很常见)', () => {
expect(parsePatientsFile('a\nb\na\nb\nc')).toEqual(['a', 'b', 'c']);
});
it('剥行尾逗号/分号 —— 从 SQL IN 列表直接拷出来的形态', () => {
expect(parsePatientsFile('a,\nb;\nc')).toEqual(['a', 'b', 'c']);
});
it('CRLF(Windows / 某些导出工具)', () => {
expect(parsePatientsFile('a\r\nb\r\n')).toEqual(['a', 'b']);
});
it('全是空白/注释 → 空数组(CLI 据此报错,不退化成全量)', () => {
expect(parsePatientsFile('\n\n \n# 只有注释\n')).toEqual([]);
expect(parsePatientsFile('')).toEqual([]);
});
it('认不出的原样保留 —— 不做猜测性纠错,交给库里查、查不到再报', () => {
expect(parsePatientsFile('BJ0A094257\n不是id\n123')).toEqual(['BJ0A094257', '不是id', '123']);
});
});
describe('splitPatientTokens — uuid 走 patient.id,其余当 externalId', () => {
const UUID = 'f5ac71d5-c8b8-4f39-8090-ec3478810aa0'; // 生产真实患者 陈芃霖
it('标准 uuid 归 ids', () => {
expect(splitPatientTokens([UUID])).toEqual({ ids: [UUID], externalIds: [] });
});
it('大写 uuid 也认(psql / Excel 可能改大小写)', () => {
const up = UUID.toUpperCase();
expect(splitPatientTokens([up]).ids).toEqual([up]);
});
it('病历号 / 数字 externalId 归 externalIds', () => {
expect(splitPatientTokens(['BJ0A094257', '1551051'])).toEqual({
ids: [],
externalIds: ['BJ0A094257', '1551051'],
});
});
it('⭐ 混写 —— 手上有什么用什么,两类都要能收', () => {
const r = splitPatientTokens([UUID, 'BJ0A094257', 'f5ac71d5-c8b8-4f39-8090-ec3478810aa1']);
expect(r.ids).toHaveLength(2);
expect(r.externalIds).toEqual(['BJ0A094257']);
});
it('长得像但不是 uuid(缺段/多段)→ 当 externalId,不冒充 id', () => {
expect(splitPatientTokens(['f5ac71d5-c8b8-4f39-8090']).externalIds).toHaveLength(1);
expect(splitPatientTokens(['f5ac71d5c8b84f398090ec3478810aa0']).externalIds).toHaveLength(1);
});
});
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