Commit cdad7db0 by luoqi

fix(plan): K00 按诊断词细分类目 + 低龄种植降位 + 「二期」正畸误吞 + 早矫措辞

分支主题(K00 focus category + 正畸映射)的一批年龄/诊断适配。核心是把召回「目标·X」
标签和话术的类目排序,从"只看 K 码 + 年龄"细到"看诊断原文词",纠三类线上误标。

── ① K00 大口袋按诊断词定方向(refineCategoriesForDiagnosis)──
K00「牙发育和萌出障碍」混着语义完全不同的病:乳牙滞留(要拔)/ 乳牙早失(管间隙)/
先天缺失(修复)/ 釉质发育不全(冠贴面)/ 萌出障碍(导萌),却共用固定序 [surgical,...]
→ focusCategory 恒为 surgical,给「乳牙早失」也打「目标·外科」(没牙可拔)。线上约 2800 条。
诊断码不动(还是 K00),只在同码内把最贴切类目提到首位。scenario 投影 name_zh、
signals 存 dxNameZh 快照,前端 ReasonLine 用同一函数复算,保证「目标·X」与本行首项一致。

── ② 低龄种植降位,边界与全仓另三处种植年龄闸统一为 ≤18 ──
recommendedCategoriesForAge 新增:age ≤ IMPLANT_LAST_AGE(18)时 implant 挪末位(只挪不删)。
 边界取 ≤18 而非 <18 —— 差一岁会自相矛盾:contraindication.feature.ts 是 age<=18 打
「种植禁忌」、potential-treatment 是 age>18 才产「潜在种植」。2026-07-27 生产实测:
恰好 18 岁有 1,299 条 plan、140 条 focusCategory=implant,若按 <18 判会在同一屏出现
「目标·种植」+ 画像「种植禁忌」。话术侧(fact-block / stable prompt)同步 ≤18。

── ③ planned 规则裸「二期」不再误吞正畸 ──
「正畸二期矫治」被 implant 规则的裸「二期」吞成种植(线上 陈芃霖 BJ0A094257)。
actual 侧的修法是收紧成「种植二期」,但 planned 侧种植二期普遍不带"种植"
(二期取模/二期手术/二期修复,prod 900+ 条),收紧会让它们掉进 _default 被丢。
改用位次:裸「二期」从 implant 主规则拆出、降到全表最末兜底 —— 带正畸词的先被
orthodontic 收走,其余纯「二期」仍落 implant,与修复前逐条一致。

── ④ 替牙期正畸措辞说「早期矫治」──
treatmentCategoryNameZhFor:age ≤ EARLY_ORTHO_MAX_AGE(12)时 orthodontic 显示「早期矫治」
(替牙期做的是一期干预,不是排齐恒牙列)。只改措辞不改类目,排除闸不受影响。

reason-signals schema 补 dxNameZh(nullable,旧 plan 无 → 回落原顺序,无回归)。
删 tests/tmp-jieya.spec.ts(纯 console.log 探针,无断言,不进仓库)。
测试 546 passed,两个 app tsc 干净。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 1c537ccb
Pipeline #3454 failed in 0 seconds
......@@ -2,13 +2,21 @@
# 共享字典 · 治疗 category 关键词兜底 —— 【planned(未来计划)语义变体】
# 与 actual 变体是有意语义分歧:planned 文本本就以"建议/拟/推荐"措辞 → 不剥这些词,
# 只剥纯条件从句(必要时/如需/择期)。
# ⚠️ TODO(继承自 jvs-dw 陈旧拷贝,行为保真原样搬入):implant 规则里裸「二期」会误吞
# "二期隐形矫正"(actual 变体已改「种植二期」)。修复属行为变更,须评估 + reparse,另行提交。
# 规则顺序=优先级(首个命中即用);宿主本地规则(若有)自动排在本文件规则之前。
# 源出处:jvs-dw treatment_planned.yaml。
#
# ⭐ 2026-07 修「裸『二期』误吞正畸」(线上反馈 陈芃霖 BJ0A094257:2025-12-20 计划
# 「正畸二期矫治」被归成 implant/种植)。
# actual 变体的修法是把词收紧成「种植二期」,但 planned 侧不能照抄 —— planned 的
# 种植二期说法**普遍不带"种植"**(二期取模 346 / 二期手术 56 / 二期修复 36 / 裸"二期" 24…,
# prod 实测),收紧会让这 900+ 条掉进 _default 被丢弃,是更大的回归。
# 故改用**位次**解决:裸「二期」从 implant 主规则拆出、降到全表最末兜底 →
# 带正畸词的(二期矫治 / 二期矫正 / 正畸二期…)先被 orthodontic 收走,
# 其余纯"二期"仍落 implant,行为与修复前逐条一致。见文件尾那条规则。
# ═══════════════════════════════════════════════════════════
keyword_mapping:
category:
- { value: implant, any: [种植, 即拔即种, 植体, 二期] }
- { value: implant, any: [种植, 即拔即种, 植体] }
- { value: endodontic, any: [根管, RCT, 牙髓, 开髓, 根备, 根充, 盖髓, 摘髓] }
- { value: orthodontic, any: [正畸, 矫治, 矫正, 托槽, 保持器, 粘附件, 隐适美, 扩弓] }
- { value: cosmetic, any: [贴面, 漂白, 美白] }
......@@ -18,6 +26,12 @@ keyword_mapping:
- { value: preventive, any: [涂氟, 窝沟封闭, 封闭, 防龋, OHI, 口腔卫生宣教] }
- { value: surgical, any: [拔除, 拔牙, 切开, 翻瓣, 切除, 系带, 脓肿, 囊肿] }
- { value: pediatric, any: [乳牙, 儿童, 年轻恒牙] }
# ⭐ 裸「二期」兜底 —— **必须留在最末**(尤其在 orthodontic 之后):
# 种植的二期在 planned 里常省掉"种植"(二期取模 / 二期手术 / 二期修复 / 三个月后二期),
# 前面 9 条都收不住,靠这条归 implant;而"二期矫治/二期矫正/正畸二期"含正畸词,
# 在上面第 3 条就被 orthodontic 拿走,走不到这里。**别把它挪回文件头**,
# 挪回去就退回 2026-07 之前的误吞(prod 420 条正畸计划被标成种植)。
- { value: implant, any: [二期] }
# planned 语义:计划本就以"建议/拟/推荐"措辞 → 不剥,只剥纯条件词(必要时/如需/择期)。
# 例 "建议全口洁治后,必要时正畸治疗" → 丢"必要时正畸治疗" → "建议全口洁治后" → periodontic。
......
......@@ -15,7 +15,7 @@ import { smartDateDisplay, toothFriendly } from './script-facts';
import { resolveDiseaseLabel } from './disease-knowledge';
import { deidentifyDoctor } from './pii';
import { AGENT_IDENTITY_PLACEHOLDER } from './agent-identity';
import { DENTURE_FIRST_AGE } from '@pac/types';
import { DENTURE_FIRST_AGE, IMPLANT_LAST_AGE, EARLY_ORTHO_MAX_AGE } from '@pac/types';
export function buildRichFactBlock(input: DraftPlanScriptInput): string {
const { patient, clinicName, plan, clinicalContext } = input;
......@@ -73,6 +73,11 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
// 70-80 能否种植看身体条件与患者意愿,由医生评估)。PAC 只调整介绍先后,不替医生下结论。
// 口径与召回建议治疗同源(DENTURE_FIRST_AGE)。
const dentureFirst = patient.age != null && patient.age > DENTURE_FIRST_AGE;
// 低龄种植闸(口径同召回建议治疗 IMPLANT_LAST_AGE):颌骨没长完种植体会相对埋没,
// 属临床相对禁忌 —— 话术不主推种植,落到"来院评估"。与上面高龄块互斥。
const implantLast = patient.age != null && patient.age <= IMPLANT_LAST_AGE;
// 早矫措辞(口径同 treatmentCategoryNameZhFor EARLY_ORTHO_MAX_AGE):替牙期讲"早期矫治"。
const earlyOrtho = patient.age != null && patient.age <= EARLY_ORTHO_MAX_AGE;
return `# 本次回访患者信息(只能用以下事实,不要编造或推断额外信息;下面是朴素事实,自然取用即可)
......@@ -97,7 +102,7 @@ ${others.length ? `\n## 其他可一并关心的问题(以本次聚焦为主,自
## 患者
- ${basics}
- 熟络度:${relationSignal}(语气怎么拿捏见沟通知识,你按这信号判断)${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**(创伤小、周期短),种植可作为并行选项一起提,\n 但**不要主推手术、不要承诺能不能种** —— 统一落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}`;
- 熟络度:${relationSignal}(语气怎么拿捏见沟通知识,你按这信号判断)${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**(创伤小、周期短),种植可作为并行选项一起提,\n 但**不要主推手术、不要承诺能不能种** —— 统一落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}${implantLast ? `\n\n## 低龄沟通(硬约束)\n- 本患者 ${patient.age} 岁,颌骨尚未发育完成:**不要主推种植**(种植体不随颌骨生长,未成年是相对禁忌)。\n 涉及缺牙先讲**间隙管理 / 正畸方向**,修复方式统一落到"来院让医生按发育情况评估",不承诺能不能种。` : ''}${earlyOrtho ? `\n\n## 矫治措辞(约束)\n- 本患者 ${patient.age} 岁处替牙期:涉及矫正一律说「**早期矫治**」(干预颌骨发育与间隙管理),\n 不要说成给恒牙列排齐的"正畸/戴牙套";具体做不做、做哪种,落到"来院让医生评估"。` : ''}`;
}
/**
......
......@@ -67,7 +67,7 @@ export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> {
export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> {
readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script_write';
readonly promptVersion = 'draft_plan_script@2026-06-16-deep-write-v17'; // v17: 高龄硬约束(≥75 缺牙禁推种植/手术)。v16: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话
readonly promptVersion = 'draft_plan_script@2026-06-16-deep-write-v18'; // v18: 低龄约束(<18 不主推种植)+ 替牙期(≤12)说「早期矫治」。v17: 高龄硬约束(≥75 缺牙禁推种植/手术)。v16: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepWriteSchema;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
......
......@@ -4,7 +4,7 @@ import { resolveDisease } from './phrasing';
import { deidentifyDoctor } from '../../shared/pii';
import { renderTreatmentPlan, buildPersonaGuide } from '../../shared/fact-block';
import { AGENT_IDENTITY_PLACEHOLDER } from '../../shared/agent-identity';
import { DENTURE_FIRST_AGE } from '@pac/types';
import { DENTURE_FIRST_AGE, IMPLANT_LAST_AGE, EARLY_ORTHO_MAX_AGE } from '@pac/types';
/**
* Prompt 版本管理约定:
......@@ -108,6 +108,9 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
const noXray = patient.age == null || patient.age <= 18;
// 高龄介绍顺序(同 fact-block,口径共用 DENTURE_FIRST_AGE):义齿在前、种植并行,不下禁忌结论。
const dentureFirst = patient.age != null && patient.age > DENTURE_FIRST_AGE;
// 低龄种植闸 / 早矫措辞(同 fact-block,口径共用 IMPLANT_LAST_AGE / EARLY_ORTHO_MAX_AGE)
const implantLast = patient.age != null && patient.age <= IMPLANT_LAST_AGE;
const earlyOrtho = patient.age != null && patient.age <= EARLY_ORTHO_MAX_AGE;
// 自报家门:姓名**不进 prompt** —— 给 【回访客服】 占位符,LLM 原样输出,读详情时按当前登录客服回填。
// (话术缓存是 per-plan、召回池共享,烤进人名会让后开的客服读到别人的名字。见 agent-identity.ts)
......@@ -145,5 +148,5 @@ ${advLines}
- ${basics}
## 语气
- ${toneHint}${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**(删除模板里的拍片句)' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**,种植可并行提及,\n 但**不要主推手术、不要承诺能不能种** —— 落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}${persona ? `\n\n${persona}` : ''}`;
- ${toneHint}${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**(删除模板里的拍片句)' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**,种植可并行提及,\n 但**不要主推手术、不要承诺能不能种** —— 落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}${implantLast ? `\n\n## 低龄沟通(硬约束)\n- 本患者 ${patient.age} 岁,颌骨尚未发育完成:**不要主推种植**(未成年相对禁忌)。缺牙先讲间隙管理 / 正畸方向,\n 修复方式落到"来院让医生按发育情况评估",不承诺能不能种。` : ''}${earlyOrtho ? `\n\n## 矫治措辞(约束)\n- 本患者 ${patient.age} 岁处替牙期:涉及矫正一律说「**早期矫治**」,不要说成给恒牙列排齐的"正畸/戴牙套"。` : ''}${persona ? `\n\n${persona}` : ''}`;
}
......@@ -65,7 +65,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc
* 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。
*/
const DRAFT_PLAN_SCRIPT_PROMPT_VERSION =
'draft_plan_script@2026-06-08-4module-v29'; // v29: 高龄硬约束(≥75 缺牙禁推种植/手术,落活动义齿;口径同召回目标标签 ELDERLY_PROSTHESIS_AGE)。v28: 自报家门去人名 → 占位【回访客服】(生成期占位/渲染期按登录人回填;修「话术缓存 per-plan + 召回池共享 → 后开的客服读到别人名字」线上 bug);input 去 agent 字段,同 plan 不同客服共享 AI 缓存。v27: schema 去硬长度约束(.min/.max → describe 软引导,修 qwen too_small 必失败);v26: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档,不诱导推销);v24: 治疗计划补 plannedTreatments(treatment_record planned 结构化;原只读常空的 emr.treatment_plan → 话术缺治疗计划);v23: common.md + 人群共性 SKILL 去 brace(原 {应治未治项}/{诊断医生}/{智能称呼} 改朴素措辞;稳健自身句位模板的 {} 不变,填空机制照常,行为基本不变);v22: 新老客改'熟络度'(recency为主+次数为辅,去二分标签,交LLM);v21: 开场日期改锚【最近一次就诊】(原误用诊断日,患者后来又来过会错位)+'来过之后'/告知'之前那次'区分;v20: schema describe 收口(去与模板矛盾的开场顺序/'下周'/负面例,只留段用途+关键约束)+ prompt/兜底 软化'下周'+ 修陈旧注释;v19: stable format.md 精简(去 common/机器闸重复、自查砍到高风险3条)+ 成人模板优化(去重/换标题/软化结束语);v18: base-common 精简合并去重 + 软化治疗方案口径(可点名/不报价不定细化/落点复查);v17: 目录重组(shared/+tiers/stable/)— base 拆 common+format、人群拆共性知识+稳健句位、病种文案归 stable phrasing、安全单一源 safety-rules、composer tier-aware;修开场顺序冲突。v16: 儿童模板复查段修复(删写死"3个月常规涂氟检查"→对齐本次{应治未治项}+用{复查时长},涂氟降级顺带);child SKILL 1.4.0;v15: user prompt 加"医生那次交代"(医嘱/建议/治疗计划,来自聚焦病历,仅引用不演绎);medicalRecord 补 recommendations;v14: user prompt 加 {牙位}(FDI→俗称)+ 本次目标(plan.goal)+ ≤18 禁拍片 belt;占位收口 {牙位}(删 【缺失牙位】);adult/child 模板带牙位;v13: 撤 token,人名去名留称呼(徐女士/韩医生 直接给,非 token);开场白先称呼确认对方再自报家门;v12: user prompt 人名脱敏(称呼/诊断医生/客服 用 token,生成后回填;监护人全名不进 prompt);v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板
'draft_plan_script@2026-06-08-4module-v30'; // v30: 低龄约束(<18 不主推种植,口径同召回 IMPLANT_LAST_AGE)+ 替牙期(≤12)矫治措辞统一说「早期矫治」(同 EARLY_ORTHO_MAX_AGE)。v29: 高龄硬约束(≥75 缺牙禁推种植/手术,落活动义齿;口径同召回目标标签 ELDERLY_PROSTHESIS_AGE)。v28: 自报家门去人名 → 占位【回访客服】(生成期占位/渲染期按登录人回填;修「话术缓存 per-plan + 召回池共享 → 后开的客服读到别人名字」线上 bug);input 去 agent 字段,同 plan 不同客服共享 AI 缓存。v27: schema 去硬长度约束(.min/.max → describe 软引导,修 qwen too_small 必失败);v26: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档,不诱导推销);v24: 治疗计划补 plannedTreatments(treatment_record planned 结构化;原只读常空的 emr.treatment_plan → 话术缺治疗计划);v23: common.md + 人群共性 SKILL 去 brace(原 {应治未治项}/{诊断医生}/{智能称呼} 改朴素措辞;稳健自身句位模板的 {} 不变,填空机制照常,行为基本不变);v22: 新老客改'熟络度'(recency为主+次数为辅,去二分标签,交LLM);v21: 开场日期改锚【最近一次就诊】(原误用诊断日,患者后来又来过会错位)+'来过之后'/告知'之前那次'区分;v20: schema describe 收口(去与模板矛盾的开场顺序/'下周'/负面例,只留段用途+关键约束)+ prompt/兜底 软化'下周'+ 修陈旧注释;v19: stable format.md 精简(去 common/机器闸重复、自查砍到高风险3条)+ 成人模板优化(去重/换标题/软化结束语);v18: base-common 精简合并去重 + 软化治疗方案口径(可点名/不报价不定细化/落点复查);v17: 目录重组(shared/+tiers/stable/)— base 拆 common+format、人群拆共性知识+稳健句位、病种文案归 stable phrasing、安全单一源 safety-rules、composer tier-aware;修开场顺序冲突。v16: 儿童模板复查段修复(删写死"3个月常规涂氟检查"→对齐本次{应治未治项}+用{复查时长},涂氟降级顺带);child SKILL 1.4.0;v15: user prompt 加"医生那次交代"(医嘱/建议/治疗计划,来自聚焦病历,仅引用不演绎);medicalRecord 补 recommendations;v14: user prompt 加 {牙位}(FDI→俗称)+ 本次目标(plan.goal)+ ≤18 禁拍片 belt;占位收口 {牙位}(删 【缺失牙位】);adult/child 模板带牙位;v13: 撤 token,人名去名留称呼(徐女士/韩医生 直接给,非 token);开场白先称呼确认对方再自报家门;v12: user prompt 人名脱敏(称呼/诊断医生/客服 用 token,生成后回填;监护人全名不进 prompt);v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板
@Injectable()
export class DraftPlanScriptCall
......
......@@ -22,7 +22,7 @@ import { type DeepDraft, draftOutputToDeep } from '../deep/types';
*
* callKey 仍用 'draft_plan_script'(同一逻辑调用),档位差异落 promptVersion → eval 可按版本切档对比。
*/
const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-06-08-standard-v16'; // v16: 高龄硬约束(≥75 缺牙禁推种植/手术)。v15: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v14: schema 去硬长度/段数约束(.min/.max/.length → describe 软引导);v13: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档);v11: 病历补全(治疗计划=plannedTreatments 结构化 + 一般情况/处置/诊断说明,对齐页面 emr-soap);v10: format.md 去污染(删 tier 名/去模板对比/见 common.md 等解释性 meta,纯指令);流式输出(段数组 partial 边出边渲染);v9: 真去模板 — 4 固定角色字段 → 自由 sections[];v8: 去 {} 替换占位;v7: format 瘦身 + opening 放开 + closing 软化
const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-06-08-standard-v17'; // v17: 低龄约束(<18 不主推种植)+ 替牙期(≤12)说「早期矫治」。v16: 高龄硬约束(≥75 缺牙禁推种植/手术)。v15: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v14: schema 去硬长度/段数约束(.min/.max/.length → describe 软引导);v13: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档);v11: 病历补全(治疗计划=plannedTreatments 结构化 + 一般情况/处置/诊断说明,对齐页面 emr-soap);v10: format.md 去污染(删 tier 名/去模板对比/见 common.md 等解释性 meta,纯指令);流式输出(段数组 partial 边出边渲染);v9: 真去模板 — 4 固定角色字段 → 自由 sections[];v8: 去 {} 替换占位;v7: format 瘦身 + opening 放开 + closing 软化
@Injectable()
export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DeepDraft> {
......
......@@ -9,6 +9,7 @@ import {
treatmentCategoryNameZhForTeeth,
treatmentCategoryNameZhFor,
recommendedCategoriesForAge,
refineCategoriesForDiagnosis,
} from '@pac/types';
import { PrismaService } from '../../../../prisma/prisma.service';
import type {
......@@ -352,6 +353,9 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
sig.id AS signal_fact_id,
sig.type AS signal_type,
sig.content->>'code' AS signal_code,
-- 诊断原文词(K00 主类目细分用:滞留→外科 / 早失→正畸 / 先天缺→修复…)。
-- 纯投影,不进任何 WHERE —— 返回行集与加它之前逐行相同。
sig.content->>'name_zh' AS signal_name_zh,
-- ⭐ 牙位级相减:有牙位信号 → 剩余未治牙位;全口信号 → 原样 NULL(gap 核心,共享模块)
${gap.toothOutput} AS tooth,
sig.content->>'extracted_by' AS extracted_by,
......@@ -484,11 +488,17 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// lead=建议(即无诊断,仅医生建议)→ 用建议名(如"建议种植")替代子场景诊断词"缺失牙未启动修复"。
const leadTrig = r.cluster_triggers?.[0] ?? { type: triggerType, code: r.signal_code };
const isRecLead = leadTrig.type === 'recommendation';
// ⭐ 建议治疗:走**源头**的年龄排序(recommendedCategoriesForAge),此处不做任何年龄分支。
// >70 岁缺牙 → 活动义齿排在种植前(并行保留,不删种植 —— 高龄非种植禁忌,由医生评身体条件)。
// ⚠️ 只影响"建议什么"的顺序与措辞;排除闸仍用完整的 expectedCats(见上方 excludeCats)。
// ⭐ 建议治疗:走**源头**的两道重排,此处不做任何本地分支。
// ① refineCategoriesForDiagnosis(临床语义)—— K00 同码内按诊断词定方向:
// 乳牙滞留→外科(有牙可拔) / 乳牙早失→正畸(牙没了,管间隙) / 先天缺→修复 / 萌出障碍→正畸。
// ② recommendedCategoriesForAge(年龄)—— >70 岁缺牙义齿前置、<18 岁种植降末位(均并行保留)。
// ⚠️ 两道都只挪位不增删;排除闸仍用完整的 expectedCats(见上方 excludeCats)。
const age = r.patient_age ?? null;
const recCats = recommendedCategoriesForAge(expectedCats, age);
const dxNameZh = r.signal_name_zh ?? null;
const recCats = recommendedCategoriesForAge(
refineCategoriesForDiagnosis(r.signal_code, dxNameZh, expectedCats),
age,
);
const focusCategory = recCats[0] ?? null;
const catsStr = recCats.map((c) => treatmentCategoryNameZhFor(c, r.tooth, age)).join(' / ');
hits.push({
......@@ -532,11 +542,14 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 不可变锚点:展示层据此实时算天数,跟治疗链断口同源(避免快照随天数陈旧 → "388/389"漂移)
signalOccurredAt: r.signal_occurred_at?.toISOString() ?? null,
expectedCategories: [...expectedCats],
// ⭐ 建议治疗主类目(已按年龄适配)—— 前端「目标 · X」标签据此渲染,
// ⭐ 建议治疗主类目(已按诊断词 + 年龄适配)—— 前端「目标 · X」标签据此渲染,
// 不再直接取 expectedCategories[0](那会让 90 岁老人也显示"目标 · 种植")。
focusCategory,
// 生成当时的年龄快照(话术 / 展示按此措辞;null = 无生日不适配)
patientAge: age,
// 诊断原文词快照 —— 前端 ReasonLine 要用同一套 refineCategoriesForDiagnosis 复算
// "未启动 X / Y" 的类目串,没这个字段前端算不出跟后端一致的顺序。
dxNameZh,
},
priorityBreakdown: breakdown,
});
......@@ -615,6 +628,7 @@ interface HitRow {
signal_fact_id: string;
signal_type: string; // 'diagnosis_record' | 'recommendation_record'
signal_code: string;
signal_name_zh: string | null; // 诊断原文词(K00 细分用);历史 fact 可能没写 → null 走默认序
tooth: string | null;
extracted_by: string | null;
confidence: string | null; // pg 转 string,JS Number 转换
......
import {
DENTURE_FIRST_AGE,
IMPLANT_LAST_AGE,
EARLY_ORTHO_MAX_AGE,
refineCategoriesForDiagnosis,
recommendedCategoriesForAge,
treatmentCategoryNameZhFor,
treatmentCategoryNameZhForTeeth,
......@@ -112,3 +115,141 @@ describe('红线②:排除闸不受年龄影响', () => {
expect(cats).toContain('prosthodontic');
});
});
// =============================================================
// 2026-07 线上反馈(陈芃霖 BJ0A094257):K00 细分 + 低龄种植闸 + 早矫措辞
// 三者与「活动义齿」同构 —— 只挪位 / 只改措辞,排除闸与是否召回一律不动。
// =============================================================
describe('refineCategoriesForDiagnosis — K00 同码内按诊断词定方向', () => {
const K00 = lookupDxTreatment('K00')!.categories; // ['surgical','prosthodontic','implant','orthodontic']
test('⭐ 乳牙早失 → 正畸在前(牙已经掉了,没得拔;临床是间隙保持/早矫)', () => {
// 这就是线上那条错标:9 岁患儿 K00「乳牙早失」被打成「目标 · 外科」
const r = refineCategoriesForDiagnosis('K00', '乳牙早失', K00);
expect(r[0]).toBe('orthodontic');
});
test('乳牙滞留 / 多生牙 → 外科在前(有实体牙要拔,现状即正确)', () => {
expect(refineCategoriesForDiagnosis('K00', '乳牙滞留', K00)[0]).toBe('surgical');
expect(refineCategoriesForDiagnosis('K00', '多生牙', K00)[0]).toBe('surgical');
expect(refineCategoriesForDiagnosis('K00', '滞留乳牙', K00)[0]).toBe('surgical');
});
test('先天缺失 / 釉质发育不全 → 修复在前', () => {
expect(refineCategoriesForDiagnosis('K00', '先天缺失', K00)[0]).toBe('prosthodontic');
expect(refineCategoriesForDiagnosis('K00', '先天缺牙', K00)[0]).toBe('prosthodontic');
expect(refineCategoriesForDiagnosis('K00', '釉质发育不全', K00)[0]).toBe('prosthodontic');
});
test('异位萌出 / 迟萌 → 正畸在前(助萌导萌)', () => {
expect(refineCategoriesForDiagnosis('K00', '异位萌出', K00)[0]).toBe('orthodontic');
expect(refineCategoriesForDiagnosis('K00', '迟萌', K00)[0]).toBe('orthodontic');
});
test('⭐ 匹配顺序:「乳牙早失」不能被泛化的「缺失」规则吃掉', () => {
expect(refineCategoriesForDiagnosis('K00', '乳牙早失', K00)[0]).toBe('orthodontic');
expect(refineCategoriesForDiagnosis('K00', '先天缺失', K00)[0]).toBe('prosthodontic');
});
test('红线:只挪位,集合恒等(K00 四类目一个不少)', () => {
for (const name of ['乳牙早失', '乳牙滞留', '先天缺失', '釉质发育不全', '异位萌出']) {
const r = refineCategoriesForDiagnosis('K00', name, K00);
expect([...r].sort()).toEqual([...K00].sort());
}
});
test('红线:诊断码不动 —— 本函数不产出 code,K00 永远还是 K00', () => {
// 与 canonical-codes 302 行那条 "后天缺牙 → K08" recode 是两件事:
// 那条是宿主真标错了码;这里 K00 本身没标错,只是方向要细分。
expect(lookupDxTreatment('K00')).toBeTruthy();
expect(lookupDxTreatment('K00')!.categories).toEqual(K00);
});
test('回落:非 K00 / 空诊断词 / 未命中词 → 原样返回(旧数据无回归)', () => {
const K08 = lookupDxTreatment('K08')!.categories;
expect(refineCategoriesForDiagnosis('K08', '牙列缺损', K08)).toEqual(K08);
expect(refineCategoriesForDiagnosis('K00', null, K00)).toEqual(K00);
expect(refineCategoriesForDiagnosis('K00', ' ', K00)).toEqual(K00);
expect(refineCategoriesForDiagnosis('K00', '混合牙列', K00)).toEqual(K00); // 未命中 → 原序
});
});
describe('低龄种植闸 —— 与高龄义齿同构:只降位,不删', () => {
const K08 = lookupDxTreatment('K08')!.categories;
test('⭐ <18 岁缺牙 → 种植降到末位(颌骨未发育完成)', () => {
const r = recommendedCategoriesForAge(K08, 9);
expect(r[r.length - 1]).toBe('implant');
expect(r[0]).not.toBe('implant');
});
test('红线:种植仍在集合里(不替医生下禁忌结论)', () => {
const r = recommendedCategoriesForAge(K08, 9);
expect([...r].sort()).toEqual([...K08].sort());
});
test('⭐ 边界:恰好 18 岁 → **降位**(与全仓另外三处种植年龄闸同口径 ≤18)', () => {
// 差一岁就会自相矛盾 —— 2026-07-27 生产实测:恰好 18 岁有 1,299 条 plan、
// 其中 140 条 focusCategory=implant;若这里按 <18 判,这 140 条会在同一屏上
// 出现「目标 · 种植」+ 画像「种植禁忌」(contraindication.feature.ts 是 age <= 18)。
expect(recommendedCategoriesForAge(K08, IMPLANT_LAST_AGE)[0]).not.toBe('implant');
expect(recommendedCategoriesForAge(K08, IMPLANT_LAST_AGE - 1)[0]).not.toBe('implant');
// 满 19 才恢复原序 —— 与 potential-treatment 的 `age > 18 才产潜在种植` 接得上
expect(recommendedCategoriesForAge(K08, IMPLANT_LAST_AGE + 1)[0]).toBe('implant');
});
test('高龄 / 低龄两条互斥,各走各的', () => {
expect(recommendedCategoriesForAge(K08, 90)[0]).toBe('prosthodontic'); // 高龄:义齿前置
expect(recommendedCategoriesForAge(K08, 9)[0]).toBe('prosthodontic'); // 低龄:种植后置,同样是义齿/修复顶上
expect(recommendedCategoriesForAge(K08, 35)[0]).toBe('implant'); // 中间年龄:原样
});
test('单类目不受影响(没得挪)', () => {
expect(recommendedCategoriesForAge(['implant'], 9)).toEqual(['implant']);
});
});
describe('早矫措辞 —— 与「活动义齿」同构:同类目,只改展示名', () => {
test('⭐ ≤12 岁 orthodontic → 「早期矫治」', () => {
expect(treatmentCategoryNameZhFor('orthodontic', null, 9)).toBe('早期矫治');
expect(treatmentCategoryNameZhFor('orthodontic', null, EARLY_ORTHO_MAX_AGE)).toBe('早期矫治');
});
test('>12 岁 → 回落原名(恒牙列是常规正畸)', () => {
expect(treatmentCategoryNameZhFor('orthodontic', null, EARLY_ORTHO_MAX_AGE + 1)).toBe(
treatmentCategoryNameZhForTeeth('orthodontic', null),
);
expect(treatmentCategoryNameZhFor('orthodontic', null, 30)).toBe(
treatmentCategoryNameZhForTeeth('orthodontic', null),
);
});
test('无年龄 → 回落原名(不猜)', () => {
expect(treatmentCategoryNameZhFor('orthodontic', null, null)).toBe(
treatmentCategoryNameZhForTeeth('orthodontic', null),
);
});
test('红线:只改措辞不改类目 —— K07 排除闸仍认 orthodontic', () => {
expect(lookupDxTreatment('K07')!.categories).toEqual(['orthodontic']);
});
});
describe('端到端:线上那条错标的修复(9 岁 · K00 乳牙早失 · 牙位 1C;1D;3D)', () => {
test('⭐ 修前「目标 · 外科」→ 修后「目标 · 早期矫治」', () => {
const K00 = lookupDxTreatment('K00')!.categories;
const age = 9;
// 后端 scenario 与前端 ReasonLine 走的是同一对函数、同一顺序
const recCats = recommendedCategoriesForAge(
refineCategoriesForDiagnosis('K00', '乳牙早失', K00),
age,
);
expect(recCats[0]).toBe('orthodontic'); // focusCategory
expect(treatmentCategoryNameZhFor(recCats[0]!, '1C;1D;3D', age)).toBe('早期矫治');
// 未成年 → 种植沉到末位
expect(recCats[recCats.length - 1]).toBe('implant');
// 红线:集合没变 → 排除闸/召回与否完全不受影响
expect([...recCats].sort()).toEqual([...K00].sort());
});
});
......@@ -566,3 +566,45 @@ describe('fact-block | renderTreatmentPlan(结构化 planned 主,host 自由文
expect(renderTreatmentPlan(emptyMr())).toBeNull();
});
});
// =============================================================
// 2026-07 线上反馈:年龄护栏三条(高龄义齿 / 低龄种植闸 / 替牙期早矫措辞)
// 三条同构 —— 都是"按年龄给措辞约束",都不改召回、不改类目集合。
// =============================================================
describe('fact-block | 年龄护栏(高龄义齿 / 低龄种植 / 早矫措辞)', () => {
const block = (age: number | null) => buildRichFactBlock(makeInput({ patient: { age } }));
test('⭐ 9 岁:低龄种植闸 + 早矫措辞都上,高龄块不出现', () => {
const b = block(9);
expect(b).toContain('不要主推种植');
expect(b).toContain('颌骨尚未发育完成');
expect(b).toContain('早期矫治');
expect(b).not.toContain('先讲活动义齿');
});
test('15 岁:仍禁主推种植,但已过替牙期 → 不再强制"早期矫治"措辞', () => {
const b = block(15);
expect(b).toContain('不要主推种植');
expect(b).not.toContain('处替牙期');
});
test('35 岁:三条护栏都不出现(中间年龄原样)', () => {
const b = block(35);
expect(b).not.toContain('不要主推种植');
expect(b).not.toContain('处替牙期');
expect(b).not.toContain('先讲活动义齿');
});
test('90 岁:只出高龄义齿块(与低龄块互斥)', () => {
const b = block(90);
expect(b).toContain('先讲活动义齿');
expect(b).not.toContain('颌骨尚未发育完成');
});
test('无年龄:不猜 —— 三条护栏都不出(禁拍片 belt 另有兜底,不受影响)', () => {
const b = block(null);
expect(b).not.toContain('不要主推种植');
expect(b).not.toContain('处替牙期');
expect(b).not.toContain('先讲活动义齿');
});
});
......@@ -84,4 +84,60 @@ describe('treatment_planned keyword classifier (计划:不剥建议/拟)', () =>
expect(strip).not.toContain('建议');
expect(strip).toContain('必要时');
});
// ─────────────────────────────────────────────────────────
// 2026-07 线上反馈(陈芃霖 BJ0A094257):裸「二期」误吞正畸。
// 修法 = 把裸「二期」从 implant 主规则降到全表最末兜底(不是收紧成「种植二期」——
// planned 侧的种植二期普遍不带"种植",收紧会丢 900+ 条,见 yaml 头注释)。
// 下面两组用例把这个**位次**锁死:动了顺序就红。
// ─────────────────────────────────────────────────────────
describe('裸「二期」位次(正畸先收 / 种植兜底)', () => {
// 组一:带正畸词 → 必须 orthodontic(prod 实测 420 条 planned 属这组)
const orthoCases = [
'正畸二期矫治', // ⭐ 线上原文:2025-12-20 的计划
'二期矫治',
'二期矫正',
'正畸二期',
'正畸二期治疗',
'早期矫正+二期矫正',
'早期矫治+二期矫治',
'酌情二期矫治',
'恒牙列期酌情二期矫治',
'二期矫治(大概率拔牙)',
];
it.each(orthoCases)('「%s」→ orthodontic(不再被种植吞)', (name) => {
expect(classifyByKeyword(name, rules, strip)).toBe('orthodontic');
});
// 组二:无正畸词 → 仍 implant(修复前后逐条一致,不能因为改顺序把它们丢了)
const implantCases = [
'二期取模', // prod 346 条,planned 里最大的一组
'二期手术',
'二期修复',
'二期',
'三个月后二期',
'3个月后二期手术',
'4-6个月后二期修复',
'二期口扫',
];
it.each(implantCases)('「%s」→ implant(种植二期常省"种植",靠末位兜底)', (name) => {
expect(classifyByKeyword(name, rules, strip)).toBe('implant');
});
it('真种植计划不受影响(含"种植"走第 1 条,不依赖兜底)', () => {
expect(classifyByKeyword('种植修复', rules, strip)).toBe('implant');
expect(classifyByKeyword('正畸后种植修复', rules, strip)).toBe('implant');
expect(classifyByKeyword('种植二期', rules, strip)).toBe('implant');
});
it('⭐ 结构约束:裸「二期」规则必须排在 orthodontic 之后(挪回文件头即误吞)', () => {
const bareErqi = rules.findIndex((r) => r.value === 'implant' && r.any.includes('二期'));
const ortho = rules.findIndex((r) => r.value === 'orthodontic');
expect(bareErqi).toBeGreaterThan(-1);
expect(ortho).toBeGreaterThan(-1);
expect(bareErqi).toBeGreaterThan(ortho);
// 且 implant 主规则(种植/植体)仍在最前,不能被降位
expect(rules.findIndex((r) => r.value === 'implant' && r.any.includes('种植'))).toBeLessThan(ortho);
});
});
});
......@@ -93,6 +93,8 @@ export type PlanDetailData = {
focusCategory?: string | null;
/** plan 生成时的年龄快照(无生日 → null);展示按此措辞 */
patientAge?: number | null;
/** 触发诊断原文词快照(如「乳牙早失」);K00 细分用,旧 plan 无 → 回落原顺序 */
dxNameZh?: string | null;
} | null;
priorityScore: number;
reason: string;
......
......@@ -6,6 +6,7 @@ import {
diagnosisCodeNameZh,
treatmentCategoryNameZhFor,
recommendedCategoriesForAge,
refineCategoriesForDiagnosis,
} from '@pac/types';
import { formatToothPosition, formatDaysReadable } from '@/lib/utils';
......@@ -32,6 +33,8 @@ export interface ReasonLineInput {
focusCategory?: string | null;
/** plan 生成时的年龄快照(无生日 → null);展示按此措辞 */
patientAge?: number | null;
/** 触发诊断原文词快照(如「乳牙早失」);K00 细分用,旧 plan 无 → 回落原顺序 */
dxNameZh?: string | null;
} | null;
}
......@@ -54,10 +57,15 @@ export function ReasonLine({ reason }: { reason: ReasonLineInput }) {
const isRecLead = trig?.type === 'recommendation';
const subLabel =
isRecLead && trig?.code ? diagnosisCodeNameZh(trig.code) : subLabelZh(reason.scenario, s.subKey);
// 建议治疗:走**源头**的年龄排序(与后端同一个 recommendedCategoriesForAge),
// >70 岁缺牙 → 「活动义齿 / 种植」(义齿在前,种植并行保留 —— 高龄非种植禁忌);
// 乳牙 restorative → 「充填」(去嵌体)。旧 plan 无 patientAge → 原顺序,行为不变。
const cats = recommendedCategoriesForAge(s.expectedCategories, s.patientAge ?? null)
// 建议治疗:走**源头**的两道重排(与后端同一对函数,保证「目标 · X」与本行首项一致):
// ① refineCategoriesForDiagnosis —— K00 按诊断词定方向(早失→正畸、滞留→外科…);
// ② recommendedCategoriesForAge —— >70 岁缺牙义齿前置、<18 岁种植降末位(均并行保留);
// 乳牙 restorative → 「充填」(去嵌体);≤12 岁 orthodontic → 「早期矫治」。
// 旧 plan 无 patientAge / dxNameZh → 原顺序,行为不变。
const cats = recommendedCategoriesForAge(
refineCategoriesForDiagnosis(trig?.code, s.dxNameZh, s.expectedCategories),
s.patientAge ?? null,
)
.map((c) => treatmentCategoryNameZhFor(c, s.toothPosition, s.patientAge ?? null))
.join(' / ');
return (
......
......@@ -754,13 +754,100 @@ export function treatmentCategoryNameZhForTeeth(
export const DENTURE_FIRST_AGE = 70;
/**
* 「种植降位」年龄线 —— **≤ 该年龄**时把种植挪到建议序末位。
*
* 临床口径:种植体是骨结合的刚性锚,**不随颌骨生长移动** —— 颌骨发育完成前(女约 17-18、
* 男约 18-20)植入,会随邻牙继续萌出而相对埋没、咬合紊乱,是公认的相对禁忌。
*
* ⭐ 边界必须是 **≤18**,与全仓另外三处种植年龄闸同口径 —— 差一岁就会自相矛盾:
* contraindication.feature.ts `age <= 18` → 打「种植禁忌」
* potential-treatment.feature.ts `age > 18` → 才产「潜在种植」
* fact-block.ts noXray `age <= 18` → 话术禁提拍片
* 2026-07-27 生产实测:恰好 18 岁的患者有 1,299 条 plan,其中 140 条 focusCategory=implant。
* 若本条按 <18 判,这 140 条会在**同一屏**上出现「目标 · 种植」+ 画像「种植禁忌」。
*
* 处理方式与 [[DENTURE_FIRST_AGE]] 一致:**只挪位不删** —— PAC 不替医生下禁忌结论,
* 但也绝不把「目标 · 种植」摆到未成年人头上(2026-07 线上实测 1462 条,其中 731 条 <12 岁)。
*/
export const IMPLANT_LAST_AGE = 18;
/**
* 「早期矫治」措辞年龄线 —— **≤ 该年龄**时 orthodontic 的展示名说「早期矫治」。
*
* 临床口径:早矫(一期)在替牙期 / 混合牙列做,约 6-12 岁,目标是干预颌骨发育、不良习惯
* 与间隙管理;12 岁后恒牙列已成,是常规(二期)正畸,目标是排齐与咬合精调。
*
* ⚠️ 只改**措辞**不改类目 —— 两者同属 orthodontic,排除闸不受影响。
* ⚠️ 年龄只是**默认推定**,推不翻"早矫已在外院做完"这类病历事实(那需要外院治疗史通道,
* 本函数够不着);措辞层面按年龄给最可能的说法,准确度由上游事实兜。
*/
export const EARLY_ORTHO_MAX_AGE = 12;
/**
* K00 主类目细分(**同码内重排,不改诊断码**)。
*
* 背景(2026-07 线上反馈 陈芃霖 BJ0A094257):K00「牙发育和萌出障碍」是个大口袋 ——
* 乳牙滞留(该掉没掉,要拔)/ 乳牙早失(已经掉了,要管间隙)/ 先天缺失 / 釉质发育不全 /
* 萌出障碍 语义完全不同,却共用一份固定类目序 `[surgical, ...]` → focusCategory 恒为
* surgical,给「乳牙早失」也打「目标 · 外科」(没牙可拔)。线上约 2800 条这样的错标。
*
* ⚠️ **诊断码不动** —— K00 还是 K00(不像 302 行那条"后天缺牙 → K08"是真的标错了码);
* 这里只是同一个 K00 内部按诊断词把最贴切的类目提到首位,其余保持原相对顺序。
* ⚠️ 与 [[recommendedCategoriesForAge]] 同边界:只挪位,返回集合恒等于入参集合,排除闸不受影响。
*
* 匹配顺序敏感 —— 自上而下首个命中生效(「乳牙早失」须先于泛化的「缺失」)。
* 未命中(含 name_zh 为空的历史数据)→ 原样返回,行为与修复前一致。
*/
export const K00_LEAD_CATEGORY_RULES: ReadonlyArray<{
pattern: RegExp;
lead: string;
why: string;
}> = [
// 该掉没掉 / 多余牙 / 该掉在掉 —— 有实体牙要处理,外科正确(线上占比最大,现状即对)
{ pattern: /滞留|多生|松动/, lead: 'surgical', why: '有实体牙待拔' },
// 已经掉了 —— 没牙可拔,临床是间隙保持 / 早期矫治
{ pattern: /早失/, lead: 'orthodontic', why: '牙已缺失,管间隙' },
// 萌不出来 / 长歪了 —— 助萌、导萌、排齐
{ pattern: /萌出|迟萌|阻萌|埋伏|异位/, lead: 'orthodontic', why: '萌出障碍待导萌' },
// 先天没长 —— 修复方向(未成年由年龄闸把种植压后,见 recommendedCategoriesForAge)
{ pattern: /先天缺|缺失|缺牙/, lead: 'prosthodontic', why: '先天缺牙待修复' },
// 形态 / 色泽 / 结构异常 —— 冠或贴面类修复
{ pattern: /釉质|氟斑|四环素|过小牙|畸形牙|发育不全|发育不良/, lead: 'prosthodontic', why: '牙体形态色泽异常待修复' },
];
/**
* 诊断词细分建议类目(目前只作用于 K00)—— 排在年龄适配**之前**的一道语义重排。
*
* 顺序:`refineCategoriesForDiagnosis`(临床语义)→ `recommendedCategoriesForAge`(年龄)。
* 前者定"这个病该往哪个方向治",后者定"这个岁数该先讲哪个"。两道都只挪位。
*
* @param code 诊断码(非 K00 一律原样返回)
* @param nameZh 诊断原文词(DW diag message,如「乳牙早失」);空 → 原样返回
* @param categories 该诊断的候选治疗类目
*/
export function refineCategoriesForDiagnosis(
code: string | null | undefined,
nameZh: string | null | undefined,
categories: readonly string[],
): readonly string[] {
if (code !== 'K00') return categories;
const name = (nameZh ?? '').trim();
if (!name) return categories;
const hit = K00_LEAD_CATEGORY_RULES.find((r) => r.pattern.test(name));
if (!hit || !categories.includes(hit.lead)) return categories;
return [hit.lead, ...categories.filter((c) => c !== hit.lead)];
}
/**
* 诊断 → 建议治疗类目(**按年龄排序**)—— 建议侧的单一真理源。
*
* ⚠️ 边界:只调整**顺序**,不增删类目 —— 返回集合恒等于入参集合。
* 排除闸(`DiagnosisTreatmentMap[].categories`:做过其中任一即算已解决)完全不受影响。
*
* 规则:候选同时含 implant + prosthodontic(缺牙 K08 这类"种植或义齿"并行可选)时,
* 两条规则(互斥 —— 年龄不可能同时 >70 与 <18):
* ① 候选同时含 implant + prosthodontic(缺牙 K08 这类"种植或义齿"并行可选)时,
* 年龄 > DENTURE_FIRST_AGE → prosthodontic 提到 implant 之前(义齿在前,种植并行保留);
* ② 年龄 ≤ IMPLANT_LAST_AGE(颌骨未发育完成)→ implant 挪到**末位**(仍保留,不删)。
* 其余情况原样返回。
*
* @param categories 该诊断的候选治疗类目
......@@ -770,8 +857,13 @@ export function recommendedCategoriesForAge(
categories: readonly string[],
age: number | null | undefined,
): readonly string[] {
if (age == null) return categories;
// ② 未成年:种植降到末位(与 ① 互斥,先判 —— 低龄不可能同时触发义齿前置)
if (age <= IMPLANT_LAST_AGE && categories.includes('implant') && categories.length > 1) {
return [...categories.filter((c) => c !== 'implant'), 'implant'];
}
// ① 高龄:活动义齿前置
const dentureFirst =
age != null &&
age > DENTURE_FIRST_AGE &&
categories.includes('implant') &&
categories.includes('prosthodontic');
......@@ -787,6 +879,7 @@ export function recommendedCategoriesForAge(
* 建议治疗的中文标签(年龄 + 牙位双适配)—— 前后端共用,保证目标标签 / 理由 / 话术一致。
* 高龄的 prosthodontic 明确说「活动义齿」(而非泛化的"修复 / 冠桥" —— 冠桥需邻牙条件,
* 高龄同样受限,说清是活动义齿更贴合实际推荐)。
* 低龄的 orthodontic 明确说「早期矫治」(替牙期做的是一期干预,不是排齐恒牙列)。
*/
export function treatmentCategoryNameZhFor(
category: string,
......@@ -796,6 +889,9 @@ export function treatmentCategoryNameZhFor(
if (category === 'prosthodontic' && age != null && age > DENTURE_FIRST_AGE) {
return '活动义齿';
}
if (category === 'orthodontic' && age != null && age <= EARLY_ORTHO_MAX_AGE) {
return '早期矫治';
}
return treatmentCategoryNameZhForTeeth(category, toothPosition);
}
......
......@@ -57,9 +57,15 @@ export const ReasonSignalsSchema = z.object({
focusCategory: z.string().nullable().optional(),
/// plan 生成时的患者年龄快照(无生日 → null)。
/// 用途:展示 / 话术按年龄措辞(如高龄缺牙说「活动义齿」而非「种植」)。
/// 用途:展示 / 话术按年龄措辞(如高龄缺牙说「活动义齿」而非「种植」,低龄正畸说「早期矫治」)。
/// 存快照而非实时算:与 focusCategory 同源同刻,避免生日跨阈值后标签与理由文案打架。
patientAge: z.number().int().nullable().optional(),
/// 触发诊断的原文词快照(DW diag message,如「乳牙早失」;推荐类信号 → null)。
/// 用途:前端 ReasonLine 用 refineCategoriesForDiagnosis 复算类目顺序,与后端 focusCategory 同口径
/// —— K00 是大口袋(滞留/早失/先天缺/釉质…方向完全不同),只给 code 前端分不出方向。
/// optional/nullable:旧 plan 无此字段 → 细分不生效,回落原顺序(无回归)。
dxNameZh: z.string().nullable().optional(),
});
export type ReasonSignals = z.infer<typeof ReasonSignalsSchema>;
......
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