Commit a57a58a8 by luoqi

feat: 摄入 treat_stages 字段 + PAC 标准 step enum

设计原则:字段名 + 内容都用 PAC 标准,host 字面值在 yaml 翻译
  - canonical 跟宿主无关(其他宿主接入只改 yaml,代码不动)
  - host "开髓"/"拔髓" → PAC pulp_extirpation
  - host "根备"/"根管预备" → PAC canal_preparation
  - host "根充"/"根管充填" → PAC canal_filling
  - host "种植体植入"/"种植一期" → PAC implant_placement
  - host "种植上部修复"/"种植冠修复"/"种植戴牙" → PAC crown_placement
  - 等 22 个 PAC 标准 step

改动:
  1. canonical-codes.ts:
     - 新加 PACTreatmentStep enum(22 个,英文 snake_case + 中文 label)
     - TreatmentMilestones.steps 改用 PAC step key(不再用中文词)
     - 新加 LegacyStepSubtypeKeywords:host 没填 stages 时词根 fallback
  2. canonical.ts: TreatmentCanonicalSchema 加 treatStages?: string[]
  3. fact-content-schemas.ts: treatment_record.content 加 treat_stages
  4. manifest.yaml § C.1/C.2: element_fields 加 treatStages
  5. treatment_actual.yaml + treatment_planned.yaml:
     - field_mapping 加 treatStages
     - enum_mapping 新加 treatStages 段(host 字面 → PAC enum,22 条)
  6. assembler-engine.applyEnum: 支持 array 字段(每元素 lookup mapping)
  7. treatment.parser: content.treat_stages 存数组(已 PAC 标准)
  8. chain-composer.matchMilestoneSteps:
     - 优先用 treat_stages 精确匹配 PAC step
     - fallback 用 LegacyStepSubtypeKeywords + subtype.includes()
     - S3 title 用 PACTreatmentStep 字典渲染中文(非 enum key)

验证(杨光宗 27 牙):
  入库:
    4-10 stages=["pulp_extirpation"] ← host "开髓"
    4-20 stages=["canal_preparation"] ← host "根备"
    5-04 stages=null ← host 没填,落空
    5-18 stages=null ← 充填 actual host 没填
  Chain:
    根管治疗 S3 done ✓ "开髓/拔 → 根管预备" / 已完成 3 次
    牙体修复 S3 done ✓ "树脂充填" / 已完成 1 次(走 subtype 词根 fallback)

待办(后续):
  - lifecycle="linear_then_crown" 新加,endodontic 闭环额外要求 prosthodontic actual(冠)
  - 当前杨光宗 27 没戴冠仍 closed,临床其实差最后一步

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
parent e44e599e
......@@ -30,8 +30,9 @@ field_mapping:
occurredAt: rq # treat_plan 已发生,rq=就诊日,actual 时点
sourceEncounterExternalId: emr_id
category: category_raw # transforms 已带 treat_name 原文,这里 enum_mapping 翻译
subtype: treat_name # 原始 treatName 留作 subtype(给 chain milestone 字典匹配)
toothPosition: tooth_position # ⭐ 关键升级:48.7% 带牙位,chain S3 牙位级判定可用
subtype: treat_name # 原始 treatName 留作 subtype(给 chain milestone 字典 fallback 匹配)
toothPosition: tooth_position # ⭐ 48.7% 带牙位,chain S3 牙位级判定可用
treatStages: treat_stages # ⭐ W4 末:host stages array,enum_mapping 翻译到 PAC step
doctorId: user_id # 治疗医生(从 emr 父级继承)
doctorName: doctor_name # 治疗医生名(快照)
......@@ -44,6 +45,45 @@ field_mapping:
# WHERE brand IN ('瑞泰','瑞尔') GROUP BY 1 ORDER BY 2 DESC LIMIT 200`
# 看新出现的高频 treat_name,补到对应 category。
enum_mapping:
# ── treat_stages (W4 末加) — host step 词 → PAC 标准 enum ──
# 字段名 treatStages 是 PAC 标准(英文 camelCase),内容也是 PAC enum(snake_case)
# host 字面值(开髓/根备/根充等)只在这里出现一次,翻译完进 fact 就是 PAC 标准
# 其他宿主接入只改 yaml 这一段,代码不动
treatStages:
# 牙髓 / 根管
开髓: pulp_extirpation
拔髓: pulp_extirpation
根备: canal_preparation
根管预备: canal_preparation
根充: canal_filling
根管充填: canal_filling
# 种植
植入: implant_placement
种植体植入: implant_placement
种植一期: implant_placement
基台: abutment_placement
上部修复: crown_placement
种植冠修复: crown_placement
种植戴牙: crown_placement
# 牙周
龈上洁治: supragingival_scaling
龈下刮治: subgingival_scaling
刮治: subgingival_scaling
牙周维护: periodontal_maintenance
# 修复
充填: composite_filling
嵌体: inlay
冠修复: crown_restoration
桩核: post_core
# 正畸
粘附件: bracket_placement
托槽: bracket_placement
矫治器: bracket_placement
保持器: retainer
加力: ortho_adjustment
# 长尾兜底:host stage 词字典外 → 空(不进 PAC,treat_stages 数组减一元素)
_default: ''
category:
# ── 牙周(periodontic)
龈上洁治: periodontic
......
......@@ -31,6 +31,7 @@ field_mapping:
category: category_raw # transforms 已带 treat_name 原文,这里 enum_mapping 翻译
subtype: treat_name # 原始 treatName 留作 subtype
toothPosition: tooth_position
treatStages: treat_stages # W4 末:host stages array(planned 阶段通常空,但保留字段)
doctorId: user_id # 制定计划的医生(从 emr 父级继承)
doctorName: doctor_name # 计划医生名(快照)
......@@ -40,6 +41,36 @@ field_mapping:
# 维护:跑 SQL `SELECT DISTINCT treatName, count() FROM ... GROUP BY 1 ORDER BY 2 DESC LIMIT 200`
# 看新出现的高频项,补到本表。
enum_mapping:
# treat_stages 跟 treatment_actual.yaml 完全相同(W4 末加)
treatStages:
开髓: pulp_extirpation
拔髓: pulp_extirpation
根备: canal_preparation
根管预备: canal_preparation
根充: canal_filling
根管充填: canal_filling
植入: implant_placement
种植体植入: implant_placement
种植一期: implant_placement
基台: abutment_placement
上部修复: crown_placement
种植冠修复: crown_placement
种植戴牙: crown_placement
龈上洁治: supragingival_scaling
龈下刮治: subgingival_scaling
刮治: subgingival_scaling
牙周维护: periodontal_maintenance
充填: composite_filling
嵌体: inlay
冠修复: crown_restoration
桩核: post_core
粘附件: bracket_placement
托槽: bracket_placement
矫治器: bracket_placement
保持器: retainer
加力: ortho_adjustment
_default: ''
category:
# ── 牙周(periodontic)— 龈上洁治 / 牙周治疗 各种写法
龈上洁治: periodontic
......
......@@ -277,6 +277,7 @@ transforms:
treat_name: treatName
tooth_position: toothPosition
plan_code: planCode
treat_stages: treatStages # W4 末:host 给的 step 数组(开髓/根备/根充等),assembler 翻译到 PAC enum
where:
treat_name: { not_empty: true }
......@@ -297,6 +298,7 @@ transforms:
treat_name: treatName
tooth_position: toothPosition
plan_code: planCode
treat_stages: treatStages
where:
treat_name: { not_empty: true }
......
......@@ -7,7 +7,10 @@ import {
lookupTreatmentMilestone,
lookupTreatmentLifecycle,
parseComplaintCategories,
LegacyStepSubtypeKeywords,
PACTreatmentStep,
type PACTreatmentCategory,
type PACTreatmentStepKey,
type TreatmentMilestone,
} from '@pac/types';
import { fmtYearMonthDay } from '@pac/utils';
......@@ -702,7 +705,10 @@ interface MilestoneMatch {
stepToFact: Map<string, ChainComposeInputFact>;
}
/// actual treatments 按 milestone.steps 关键词匹配 subtype.includes(step)
/// actual treatments 按 milestone.steps 匹配 — W4 末改进:
/// ① 优先用 actual.content.treat_stages[](host 给的精确 step,已 yaml 翻译到 PAC enum)
/// ② fallback 用 LegacyStepSubtypeKeywords[step] 词根匹配 subtype.includes()
/// 给老数据 / host 没填 treat_stages 时兜底
function matchMilestoneSteps(
actuals: ChainComposeInputFact[],
milestone: TreatmentMilestone | undefined,
......@@ -719,10 +725,22 @@ function matchMilestoneSteps(
}
const matched: string[] = [];
for (const step of milestone.steps) {
const hit = actuals.find((tx) => {
const sub = String((tx.content as Record<string, unknown>).subtype ?? '');
return sub.includes(step);
// ① 优先 treat_stages 精确匹配(PAC 标准 enum)
let hit = actuals.find((tx) => {
const stages = (tx.content as Record<string, unknown>).treat_stages;
return Array.isArray(stages) && stages.includes(step);
});
// ② fallback:host 没填 stages → 用 LegacyStepSubtypeKeywords 词根匹配 subtype
if (!hit) {
const keywords = LegacyStepSubtypeKeywords[step] ?? [];
hit = actuals.find((tx) => {
const stages = (tx.content as Record<string, unknown>).treat_stages;
// 如果该 actual 有 stages 但不含本 step,不算 hit(避免 stages 已显式列出但漏的情况)
if (Array.isArray(stages) && stages.length > 0) return false;
const sub = String((tx.content as Record<string, unknown>).subtype ?? '');
return keywords.some((kw) => sub.includes(kw));
});
}
if (hit) {
matched.push(step);
stepToFact.set(step, hit);
......@@ -926,10 +944,13 @@ function buildStageNodes(opts: {
n.hint = status === 'entered' ? `等待${expectedHint}` : undefined;
return n;
}
// 命中的 milestone steps 拼起来(种植"植入 → 上部修复")
// 命中的 milestone steps 拼起来(种植"种植体植入 → 种植上部修复(冠)")
// PAC step enum 渲染中文 label(PACTreatmentStep 字典),fallback 给 step key 本身
const stepLabel = (step: string): string =>
PACTreatmentStep[step as PACTreatmentStepKey] ?? step;
const titleSteps =
matchedSteps.matched.length > 0
? matchedSteps.matched.map(shortLabel).join(' → ')
? matchedSteps.matched.map(stepLabel).map(shortLabel).join(' → ')
: shortSubtype(s3LastActual);
n.title = titleSteps || '治疗';
// detail:进度展示(W4 末改进 — 优先显示真实次数,字典分子分母对客服不直观)
......
......@@ -270,6 +270,21 @@ export class AssemblerEngine {
if (!mapping) return value;
const fieldMap = mapping[canonicalKey];
if (!fieldMap) return value;
// ⭐ W4 末:支持 array 字段(如 treat_stages = ["开髓","根备"] → ["pulp_extirpation","canal_preparation"])
if (Array.isArray(value)) {
const out: string[] = [];
for (const elem of value) {
if (typeof elem !== 'string' && typeof elem !== 'number') continue;
const key = String(elem);
const mapped = fieldMap[key];
const v = mapped !== undefined ? mapped : fieldMap['_default'];
// 空字符串 = 不进 PAC enum(host 长尾值映射不出,丢)
if (v && v !== '') out.push(v);
}
return out;
}
if (typeof value !== 'string' && typeof value !== 'number') return value;
const key = String(value);
const mapped = fieldMap[key];
......
......@@ -122,6 +122,10 @@ const TreatmentRecordContent = z
quantity: nullableNumber(),
/// 计量单位(颗/次/区/套 等)— 跟 quantity 配对
unit_name: nullableString(),
/// W4 末:PAC 标准 step enum 数组(host 字面值已在 yaml enum_mapping 翻译);
/// 例:["pulp_extirpation","canal_preparation","canal_filling"]
/// host 没填该字段 → undefined,chain-composer fallback subtype 词根
treat_stages: z.array(z.string()).nullable().optional(),
/// 反查源接诊
source_encounter_external_id: nullableString(),
/// 关联诊断 fact(可空 — 临床上治疗也可独立存在)
......
......@@ -53,6 +53,13 @@ export class TreatmentParser implements Parser {
const toothPosition = (c.toothPosition as string | undefined) ?? null;
const subtype = (c.subtype as string | undefined) ?? null;
// W4 末:treat_stages — host stage 词已在 assembler enum_mapping 翻译到 PAC 标准 enum
// applyEnum array 支持已加;这里只需做 array filter(去除空字符串/非字符串元素)
const treatStagesRaw = c.treatStages;
const treatStages = Array.isArray(treatStagesRaw)
? treatStagesRaw
.filter((s): s is string => typeof s === 'string' && s.length > 0)
: null;
const startedAt = c.startedAt ? new Date(c.startedAt as string) : null;
const completedAt = c.completedAt ? new Date(c.completedAt as string) : null;
const doctorId = c.doctorId ? String(c.doctorId) : null; // host Int64,0/null → null
......@@ -93,6 +100,7 @@ export class TreatmentParser implements Parser {
? Number(c.quantity)
: null,
unit_name: (c.unitName as string | undefined) ?? null,
treat_stages: treatStages && treatStages.length > 0 ? treatStages : null,
source_encounter_external_id: sourceEncounter,
related_diagnosis_subject_id: relatedDx,
review_window_days: reviewWindow,
......
......@@ -277,43 +277,109 @@ export const TreatmentLifecycles = {
} as const satisfies Record<string, TreatmentLifecycle>;
export type TreatmentLifecycleKey = keyof typeof TreatmentLifecycles;
/// 治疗里程碑 — 每个 PAC category 的关键 actual subtype 步骤
/// PAC 治疗 step 标准 enum(英文 snake_case)— **跟宿主无关**
///
/// 匹配规则:`treatment_record.content.subtype.includes(step)` 任一即算该步完成。
/// host subtype 文本含关键词即可(例 "种植体植入(进口)" includes "种植体植入" → 命中)。
/// 设计原则(W4 末):
/// - 字段名 + 内容都是 PAC 标准(host 字面值通过 yaml enum_mapping 翻译)
/// - 任何宿主接入:写 "开髓"/"拔髓"/"RCT-1"/"Endo Step 1" 都映射到 pulp_extirpation
/// - canonical 跟宿主语言/编码无关,只跟临床概念相关
///
/// minSteps = 满足 stage=3 ongoing 的最少步骤数;到 minSteps 才算"治疗执行进入正轨"。
/// 注:种植/根管 minSteps=2 — 单做植入未做上部修复 = stage=3 ongoing(在管),非闭环。
/// 匹配规则(chain-composer.matchMilestoneSteps):
/// - 优先 treat_stages[]: actual.content.treat_stages 含 step 即命中(精确)
/// - fallback subtype 词根:host 没填 treat_stages 时,subtype.includes(host 词根)
export const PACTreatmentStep = {
// 牙髓 / 根管
pulp_extirpation: '开髓 / 拔髓',
canal_preparation: '根管预备',
canal_filling: '根管充填',
post_endo_filling: '根管后充填',
// 种植
implant_placement: '种植体植入',
abutment_placement: '基台连接',
crown_placement: '种植上部修复(冠)',
// 牙周
supragingival_scaling: '龈上洁治',
subgingival_scaling: '龈下刮治',
periodontal_maintenance: '牙周维护',
// 修复
composite_filling: '树脂充填',
inlay: '嵌体',
crown_restoration: '冠修复',
post_core: '桩核',
// 外科 / 拔除
tooth_extraction: '拔除',
// 正畸
bracket_placement: '矫治器(粘附件)',
retainer: '保持器',
ortho_adjustment: '正畸加力调整',
// 预防
fluoride_application: '涂氟',
pit_fissure_sealant: '窝沟封闭',
professional_cleaning: '洁牙',
// 美学
whitening: '美白',
veneer: '贴面',
} as const;
export type PACTreatmentStepKey = keyof typeof PACTreatmentStep;
/// 治疗里程碑 — 每个 PAC category 的标准 step 列表
///
/// 匹配规则(chain-composer.matchMilestoneSteps,优先级从高到低):
/// ① actual.content.treat_stages[] 含本 step → 命中(精确,host 给了 stage 字段)
/// ② subtype.includes(legacySubtypeKeyword[step]) → 兜底匹配(host 没给 stage)
///
/// minSteps = 满足 stage=3 ongoing 的最少 step 数;到 minSteps 才算"治疗执行进入正轨"。
/// 注:种植 minSteps=2 — 单做植入未做上部修复 = stage=3 ongoing(在管),非闭环。
export interface TreatmentMilestone {
/// 期望步骤的 subtype 关键词(按时间序);UI 显示 chain timeline 节点 label 也用它
steps: readonly string[];
/// 达到 stage=3 ongoing 的最少完成步骤数(closed 需要全 steps 满足)
/// 期望 step(PAC 标准 enum,按时间序);UI 显示 chain timeline 节点 label 也用它
steps: readonly PACTreatmentStepKey[];
/// 达到 stage=3 ongoing 的最少 step 数(closed 需要全 steps 满足)
minSteps: number;
/// 生命周期类型 → 查 TreatmentLifecycles 拿 maxStage / expectedSpanMonths
lifecycle: TreatmentLifecycleKey;
}
export const TreatmentMilestones = {
// implant 字典加"种植牙冠修复" / "种植冠修复" 同义词 — host 实际写法,字典缺会让做完种植牙的患者
// 仍卡 stage=3 ongoing 误判为"未完成"(罗国标种植已植入+上部修复都做,显示 entered 是错的)
implant: { steps: ['种植体植入', '种植上部修复', '种植牙冠修复', '种植冠修复'], minSteps: 2, lifecycle: 'linear' },
// W4 末:词根匹配,跟 periodontic 同款。"根管治疗(磨牙)" includes "根管" / "根管充填" includes "根充"
// minSteps 降到 1 — host subtype 不区分"开髓 vs 根充",同 subtype 多次 actual 字典也只能 hit 1 step
// 杨光宗 27 牙做了 3 次"根管治疗(磨牙)" + 1 次 K03 后充填,临床完整;旧版 minSteps=2 卡 0/2 不合理
endodontic: { steps: ['根管', '根充', '开髓'], minSteps: 1, lifecycle: 'linear' },
orthodontic: { steps: ['矫治器', '保持器'], minSteps: 1, lifecycle: 'long_term' },
// W4 末:steps 改为更宽的"治疗动作词根",匹配 host subtype 包含即可
// "牙周刮治术" includes "刮治" / "全口龈上洁治" includes "洁治" / "牙周维护" includes "维护"
// 旧版用全词("全口洁治""龈下刮治")导致路遥牙周刮治术匹配 0/3 步骤误判
periodontic: { steps: ['洁治', '刮治', '维护'], minSteps: 1, lifecycle: 'lifelong_maintenance' },
restorative: { steps: ['充填', '嵌体'], minSteps: 1, lifecycle: 'one_shot' },
prosthodontic: { steps: ['冠', '桩核', '修复'], minSteps: 1, lifecycle: 'one_shot' },
surgical: { steps: ['拔除', '拔牙', '手术'], minSteps: 1, lifecycle: 'one_shot' },
preventive: { steps: ['洁牙', '涂氟', '封闭'], minSteps: 1, lifecycle: 'periodic' },
cosmetic: { steps: ['美白', '贴面'], minSteps: 1, lifecycle: 'one_shot' },
pediatric: { steps: [], minSteps: 1, lifecycle: 'one_shot' },
implant: { steps: ['implant_placement', 'crown_placement'], minSteps: 2, lifecycle: 'linear' },
// endodontic 3 步 + minSteps=2(开髓 + 根充至少);patient 真做了开髓+根备+根充才算完整
endodontic: { steps: ['pulp_extirpation', 'canal_preparation', 'canal_filling'], minSteps: 2, lifecycle: 'linear' },
orthodontic: { steps: ['bracket_placement', 'retainer'], minSteps: 1, lifecycle: 'long_term' },
periodontic: { steps: ['supragingival_scaling', 'subgingival_scaling', 'periodontal_maintenance'], minSteps: 1, lifecycle: 'lifelong_maintenance' },
restorative: { steps: ['composite_filling', 'inlay'], minSteps: 1, lifecycle: 'one_shot' },
prosthodontic: { steps: ['crown_restoration', 'post_core'], minSteps: 1, lifecycle: 'one_shot' },
surgical: { steps: ['tooth_extraction'], minSteps: 1, lifecycle: 'one_shot' },
preventive: { steps: ['professional_cleaning', 'fluoride_application', 'pit_fissure_sealant'], minSteps: 1, lifecycle: 'periodic' },
cosmetic: { steps: ['whitening', 'veneer'], minSteps: 1, lifecycle: 'one_shot' },
pediatric: { steps: [], minSteps: 1, lifecycle: 'one_shot' },
} as const satisfies Partial<Record<PACTreatmentCategory, TreatmentMilestone>>;
/// chain-composer fallback 用:host 没填 treat_stages 时,subtype 词根匹配
/// 这一段是 jvs-dw 特化的 fallback;别的宿主可以另写一份(进 yaml/per-host)
/// 真上多宿主时把这个挪到 per-host yaml(短期 hardcode)
export const LegacyStepSubtypeKeywords: Partial<Record<PACTreatmentStepKey, readonly string[]>> = {
pulp_extirpation: ['开髓', '拔髓'],
canal_preparation: ['根备', '根管预备'],
canal_filling: ['根充', '根管充填'],
implant_placement: ['种植体植入', '种植手术', '种植一期'],
crown_placement: ['种植上部修复', '种植冠修复', '种植戴牙'],
supragingival_scaling: ['洁治', '洁牙', '洗牙'],
subgingival_scaling: ['刮治', '龈下'],
periodontal_maintenance: ['维护'],
composite_filling: ['充填'],
inlay: ['嵌体'],
crown_restoration: ['冠'],
post_core: ['桩核'],
tooth_extraction: ['拔除', '拔牙'],
bracket_placement: ['矫治器', '附件', '托槽'],
retainer: ['保持器'],
ortho_adjustment: ['加力', '调整'],
fluoride_application: ['涂氟'],
pit_fissure_sealant: ['封闭'],
professional_cleaning: ['洁牙'],
whitening: ['美白'],
veneer: ['贴面'],
};
/// 查表(category 未定义 milestone → undefined,chain-composer 默认 minSteps=1 one_shot)
export function lookupTreatmentMilestone(
category: string,
......
......@@ -320,6 +320,9 @@ export const TreatmentCanonicalSchema = z
startedAt: optionalIsoDateTime,
completedAt: optionalIsoDateTime,
reviewWindowDays: coerceIntOptional,
/// W4 末加:PAC 标准 step enum 数组(host 字面值已在 yaml enum_mapping 翻译);
/// host 没填 → undefined,chain-composer fallback subtype 词根匹配
treatStages: z.array(z.string()).optional().nullable(),
})
.passthrough();
export type TreatmentCanonical = z.infer<typeof TreatmentCanonicalSchema>;
......
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