Commit 40393fbe by luoqi

fix(recall): 正畸/牙周误召 — 治疗分类 + 时间方向两处修复

根因(陈施羽 998421 复现):
1. 分类 bug:keyword "二期" 太宽,把"二期隐形矫正"(正畸)误判成 implant 种植
   → treatment_actual.yaml: 二期 → 种植二期;ortho 加"隐形矫"兜底
2. 时间方向:正畸/牙周全口慢性病,复诊反复重记诊断,"治疗须晚于诊断"失效 → 误召"未启动"
   → canonical-codes K05/K07 加 excludeIfEverTreated;scenario ⑤a 对其忽略时间方向(曾做过即排除)
   按牙位的码(K02/K04/K08 等)规则不变

定向重处理工具(线上只重摄受影响的 ~1493 人,不全量):
- cold-import: PAC_COHORT_ONLY_PATIENT 支持逗号列表(IN 过滤)
- recompute-persona / recompute-plans: 加 --pids=<逗号externalId> 定向重算

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent b756531c
...@@ -442,9 +442,11 @@ enum_mapping: ...@@ -442,9 +442,11 @@ enum_mapping:
# ⚠️ 安全:只接管精确未命中的长尾,精确命中零影响;错配最坏退化=_default(现状),无回归。 # ⚠️ 安全:只接管精确未命中的长尾,精确命中零影响;错配最坏退化=_default(现状),无回归。
keyword_mapping: keyword_mapping:
category: category:
- { value: implant, any: [种植, 即拔即种, 植体, 二期] } # ⚠️ "二期" 必须限定到「种植二期」:裸"二期"会误吞"二期隐形矫正"(正畸)等 → 误判 implant。
# 种植语境的二期都带"种植"(种植二期);ortho 的"二期隐形矫正/二期矫正"靠下面 orthodontic 兜住。
- { value: implant, any: [种植, 即拔即种, 植体, 种植二期] }
- { value: endodontic, any: [根管, RCT, 牙髓, 开髓, 根备, 根充, 盖髓, 摘髓] } - { value: endodontic, any: [根管, RCT, 牙髓, 开髓, 根备, 根充, 盖髓, 摘髓] }
- { value: orthodontic, any: [正畸, 矫治, 矫正, 托槽, 保持器, 粘附件, 隐适美, 扩弓] } - { value: orthodontic, any: [正畸, 矫治, 矫正, 托槽, 保持器, 粘附件, 隐适美, 隐形矫, 扩弓] }
- { value: cosmetic, any: [贴面, 漂白, 美白] } - { value: cosmetic, any: [贴面, 漂白, 美白] }
- { value: prosthodontic, any: [, , 义齿, 修复体, 桩核, 桩冠, 戴牙, 全瓷, 烤瓷, 重新粘接] } - { value: prosthodontic, any: [, , 义齿, 修复体, 桩核, 桩冠, 戴牙, 全瓷, 烤瓷, 重新粘接] }
- { value: restorative, any: [充填, 补牙, 树脂, 玻璃离子, 嵌体, 垫底] } - { value: restorative, any: [充填, 补牙, 树脂, 玻璃离子, 嵌体, 垫底] }
......
...@@ -17,6 +17,7 @@ import { PrismaService } from '../prisma/prisma.service'; ...@@ -17,6 +17,7 @@ import { PrismaService } from '../prisma/prisma.service';
interface Args { interface Args {
host: string; host: string;
pid?: string; pid?: string;
pids?: string[]; // 多个 externalId(逗号分隔),定向重算受影响子集用
} }
function parseArgs(argv: string[]): Args { function parseArgs(argv: string[]): Args {
...@@ -24,6 +25,9 @@ function parseArgs(argv: string[]): Args { ...@@ -24,6 +25,9 @@ function parseArgs(argv: string[]): Args {
for (const a of argv) { for (const a of argv) {
if (a.startsWith('--host=')) args.host = a.slice('--host='.length); if (a.startsWith('--host=')) args.host = a.slice('--host='.length);
else if (a.startsWith('--pid=')) args.pid = a.slice('--pid='.length); else if (a.startsWith('--pid=')) args.pid = a.slice('--pid='.length);
else if (a.startsWith('--pids=')) {
args.pids = a.slice('--pids='.length).split(',').map((s) => s.trim()).filter(Boolean);
}
} }
return args; return args;
} }
...@@ -46,7 +50,7 @@ async function bootstrap() { ...@@ -46,7 +50,7 @@ async function bootstrap() {
where: { where: {
hostId: host.id, hostId: host.id,
active: true, active: true,
...(args.pid ? { externalId: args.pid } : {}), ...(args.pids?.length ? { externalId: { in: args.pids } } : args.pid ? { externalId: args.pid } : {}),
}, },
select: { id: true, externalId: true, name: true }, select: { id: true, externalId: true, name: true },
}); });
......
...@@ -2,8 +2,9 @@ ...@@ -2,8 +2,9 @@
* Recompute Plans CLI — 批量跑 PlanEngine,产 FollowupPlan + PlanReason * Recompute Plans CLI — 批量跑 PlanEngine,产 FollowupPlan + PlanReason
* *
* Usage: * Usage:
* pnpm recompute-plans # 默认 host=demo * pnpm recompute-plans # 默认 host=demo,全量
* pnpm recompute-plans -- --host=friday * pnpm recompute-plans -- --host=friday
* pnpm recompute-plans -- --host=jvs-dw --pids=998421,xxx # 只重算指定 externalId(定向,O(子集))
*/ */
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
...@@ -13,12 +14,16 @@ import { PrismaService } from '../prisma/prisma.service'; ...@@ -13,12 +14,16 @@ import { PrismaService } from '../prisma/prisma.service';
interface Args { interface Args {
host: string; host: string;
pids?: string[]; // 指定 externalId(逗号分隔)→ 只重算这些患者(定向,recomputeForPatient)
} }
function parseArgs(argv: string[]): Args { function parseArgs(argv: string[]): Args {
const args: Args = { host: 'demo' }; const args: Args = { host: 'demo' };
for (const a of argv) { for (const a of argv) {
if (a.startsWith('--host=')) args.host = a.slice('--host='.length); if (a.startsWith('--host=')) args.host = a.slice('--host='.length);
else if (a.startsWith('--pids=')) {
args.pids = a.slice('--pids='.length).split(',').map((s) => s.trim()).filter(Boolean);
}
} }
return args; return args;
} }
...@@ -36,6 +41,28 @@ async function bootstrap() { ...@@ -36,6 +41,28 @@ async function bootstrap() {
const host = await prisma.host.findUnique({ where: { name: args.host } }); const host = await prisma.host.findUnique({ where: { name: args.host } });
if (!host) throw new Error(`Host '${args.host}' not found`); if (!host) throw new Error(`Host '${args.host}' not found`);
// ── 定向模式:只重算指定 externalId 的患者(recomputeForPatient,O(子集))──
if (args.pids?.length) {
const patients = await prisma.patient.findMany({
where: { hostId: host.id, externalId: { in: args.pids } },
select: { id: true, tenantId: true, externalId: true },
});
logger.log(`▶ 定向重算 ${patients.length} patients(host=${args.host})...`);
let created = 0, failed = 0;
for (const p of patients) {
try {
const r = await engine.recomputeForPatient({ hostId: host.id, tenantId: p.tenantId, patientId: p.id });
created += r.plansCreated;
} catch (err) {
failed++;
logger.error(` ${p.externalId} FAILED: ${err instanceof Error ? err.message : err}`);
}
}
logger.log(`Done(定向):patients=${patients.length} plansCreated=${created} failed=${failed}`);
return;
}
// 取该 host 第一个 tenant(demo 场景固定一个) // 取该 host 第一个 tenant(demo 场景固定一个)
const tenants = await prisma.patient.findMany({ const tenants = await prisma.patient.findMany({
where: { hostId: host.id }, where: { hostId: host.id },
......
...@@ -239,6 +239,13 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -239,6 +239,13 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
? Prisma.sql`AND p.id = ${scope.patientId}::uuid` ? Prisma.sql`AND p.id = ${scope.patientId}::uuid`
: Prisma.empty; : Prisma.empty;
// ⑤a 时间方向开关:excludeIfEverTreated 的码(全口长疗程,正畸 K07 / 牙周 K05)忽略时间方向 —
// "曾做过同类治疗"即排除(复诊反复重记诊断,不能要求治疗晚于诊断,否则误召"未启动")。
// 按牙位的码保留"治疗晚于诊断",靠牙位区分新旧病灶。
const afterDxFrag = rule.excludeIfEverTreated
? Prisma.empty
: Prisma.sql`AND tx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`;
// ╔═════════════════════════════════════════════════════════════════════╗ // ╔═════════════════════════════════════════════════════════════════════╗
// ║ 召回 SQL 完整解读(initiation = 潜在治疗新链召回) ║ // ║ 召回 SQL 完整解读(initiation = 潜在治疗新链召回) ║
// ║ ║ // ║ ║
...@@ -337,7 +344,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -337,7 +344,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
AND tx.kind = 'actual' AND tx.kind = 'actual'
AND tx.status IN ('active', 'fulfilled') -- actual 完成 status=fulfilled AND tx.status IN ('active', 'fulfilled') -- actual 完成 status=fulfilled
AND tx.content->>'category' = ANY(${excludeCats}::text[]) AND tx.content->>'category' = ANY(${excludeCats}::text[])
AND tx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for) -- ⭐ 时间方向:诊断之后 ${afterDxFrag} -- ⭐ 时间方向:诊断之后(excludeIfEverTreated 码忽略,见 afterDxFrag)
-- W4 末升级:牙位级 overlap(详见上面 ⑤a 注释 box) -- W4 末升级:牙位级 overlap(详见上面 ⑤a 注释 box)
AND ( AND (
-- 信号无牙位(全口诊断如 K05)→ patient/category 级排除,跟现状一致 -- 信号无牙位(全口诊断如 K05)→ patient/category 级排除,跟现状一致
......
...@@ -323,6 +323,14 @@ export class ClickHouseSourceService { ...@@ -323,6 +323,14 @@ export class ClickHouseSourceService {
if (cursorCol && cursorCfg?.cursorValue) { if (cursorCol && cursorCfg?.cursorValue) {
whereParts.push(`${cursorCol} > '${cursorCfg.cursorValue.replace(/'/g, "''")}'`); whereParts.push(`${cursorCol} > '${cursorCfg.cursorValue.replace(/'/g, "''")}'`);
} }
// dev/ops:只摄入指定患者(PAC_COHORT_ONLY_PATIENT=<patient_id> 或逗号列表 id1,id2,...)
// 单患者复现 / 定向重摄受影响子集(分类修复后只重摄 reclassify 的患者)用。
const onlyPatient = process.env.PAC_COHORT_ONLY_PATIENT?.trim();
if (onlyPatient) {
const ids = onlyPatient.split(',').map((s) => s.trim()).filter(Boolean);
const quoted = ids.map((id) => `'${id.replace(/'/g, "''")}'`).join(', ');
whereParts.push(ids.length === 1 ? `${patient_key_column} = ${quoted}` : `${patient_key_column} IN (${quoted})`);
}
const whereSql = whereParts.length > 0 ? ` WHERE ${whereParts.join(' AND ')}` : ''; const whereSql = whereParts.length > 0 ? ` WHERE ${whereParts.join(' AND ')}` : '';
const selectCols = tenant_key_column const selectCols = tenant_key_column
? `${patient_key_column}, ${tenant_key_column}` ? `${patient_key_column}, ${tenant_key_column}`
......
...@@ -172,6 +172,11 @@ export interface DxTreatmentRule { ...@@ -172,6 +172,11 @@ export interface DxTreatmentRule {
urgencyDayThreshold: number; urgencyDayThreshold: number;
chainLabel: string; chainLabel: string;
wholeMouth?: boolean; wholeMouth?: boolean;
/// 治疗"一次性发起、非按牙位"(全口长疗程/慢性,如正畸 K07、牙周 K05):
/// 召回排除闸 ⑤a **忽略时间方向** —— 只要曾做过同类治疗即排除(不要求治疗晚于诊断)。
/// 原因:这类病复诊会反复重记诊断,旧诊断之后没有"更晚的治疗"不代表没治 → 否则误召"未启动"。
/// 按牙位的码(K02 龋/K04 根管/K08 缺牙等)不设此项 — 时间方向+牙位匹配才正确(新牙长龋该召)。
excludeIfEverTreated?: boolean;
} }
export const DiagnosisTreatmentMap = { export const DiagnosisTreatmentMap = {
...@@ -187,9 +192,9 @@ export const DiagnosisTreatmentMap = { ...@@ -187,9 +192,9 @@ export const DiagnosisTreatmentMap = {
// 之前曾加 'preventive' 想让做过洁牙的患者不被召回,但临床上洁牙(¥260-350 预防) ≠ 龈上洁治术(¥600+ SRP) // 之前曾加 'preventive' 想让做过洁牙的患者不被召回,但临床上洁牙(¥260-350 预防) ≠ 龈上洁治术(¥600+ SRP)
// host 数据已正确区分两者:subtype/category 字段不同。王辉就是"医生让做 SRP 但只做洁牙"典型患者 — // host 数据已正确区分两者:subtype/category 字段不同。王辉就是"医生让做 SRP 但只做洁牙"典型患者 —
// 应该被召回去促进真 SRP,而不是被算"已启动"放过。chain-composer 显示 S3 未进行也支持这判定 // 应该被召回去促进真 SRP,而不是被算"已启动"放过。chain-composer 显示 S3 未进行也支持这判定
K05: { categories: ['periodontic'], cooldownDays: 30, windowDays: 120, urgencyDayThreshold: 90, chainLabel: '牙周治疗', wholeMouth: true }, K05: { categories: ['periodontic'], cooldownDays: 30, windowDays: 120, urgencyDayThreshold: 90, chainLabel: '牙周治疗', wholeMouth: true, excludeIfEverTreated: true },
K06: { categories: ['periodontic', 'surgical'], cooldownDays: 14, windowDays: 120, urgencyDayThreshold: 60, chainLabel: '牙龈/牙槽嵴处置' }, K06: { categories: ['periodontic', 'surgical'], cooldownDays: 14, windowDays: 120, urgencyDayThreshold: 60, chainLabel: '牙龈/牙槽嵴处置' },
K07: { categories: ['orthodontic'], cooldownDays: 30, windowDays: 365, urgencyDayThreshold: 180, chainLabel: '正畸矫治' }, K07: { categories: ['orthodontic'], cooldownDays: 30, windowDays: 365, urgencyDayThreshold: 180, chainLabel: '正畸矫治', excludeIfEverTreated: true },
K08: { categories: ['implant', 'prosthodontic'], cooldownDays: 30, windowDays: 180, urgencyDayThreshold: 120, chainLabel: '种植修复' }, K08: { categories: ['implant', 'prosthodontic'], cooldownDays: 30, windowDays: 180, urgencyDayThreshold: 120, chainLabel: '种植修复' },
K09: { categories: ['surgical'], cooldownDays: 14, windowDays: 90, urgencyDayThreshold: 60, chainLabel: '颌骨囊肿摘除' }, K09: { categories: ['surgical'], cooldownDays: 14, windowDays: 90, urgencyDayThreshold: 60, chainLabel: '颌骨囊肿摘除' },
// 推荐码 — 同临床类目继承 K0x 配置 // 推荐码 — 同临床类目继承 K0x 配置
......
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