Commit 3ff9c827 by luoqi

fix(recall): 缺牙建议治疗按年龄排序 —— >70 岁活动义齿在前、种植并行

现象(业务反馈):90 岁(吕学文)、78 岁(李石明)的缺牙召回,界面一律显示
「目标 · 种植」,话术也推种植。业务反弹"为什么给 90 岁老人推种植"。

根因:UI 取 signals.expectedCategories[0],而 K08 的 categories 首项恒为 implant。
     整条链路(目标标签 / 理由文案 / goal / 话术)都没有年龄维度 ——
     召回场景 SQL 与 priority-scorer 里一个年龄条件都没有。

## 修法:改在诊断→治疗的源头,不在流程末端打特例

canonical-codes.ts 新增 recommendedCategoriesForAge(categories, age) 作建议侧单一源:
候选同时含 implant + prosthodontic 时,age > DENTURE_FIRST_AGE(70)→ prosthodontic 提前。
scenario / 前端 reason-line / 话术 prompt 全部调这一个 resolver,不各写年龄分支。

## 业务方口径(对齐后修正)

初版做成了"高龄禁推种植",过重。业务反馈:**70-80 不是种植禁忌**,能否种植取决于
骨量骨质 / 全身状况 / 患者意愿,由医生评估。故改为:
  - 建议**只调顺序**:活动义齿在前,**种植并行保留**(不删)
  - 话术从"严禁推荐种植"改为"先讲活动义齿,种植可并行提及;不主推手术、
    不承诺能不能种 —— 落到来院让医生按身体条件评估"
  - PAC 不替医生下禁忌结论

## 三条设计红线(各有用例锁住)

1. 只排序不增删 —— 任何年龄下返回集合恒等于入参(种植不会被抹掉)
2. 不碰排除闸 —— DiagnosisTreatmentMap[].categories 仍是完整候选集
   (否则高龄患者做过种植反而排除不掉 → 重复召回)
3. 不碰"要不要召回" —— 缺牙影响咀嚼营养,高龄同样该管

## 改动

- canonical-codes:DENTURE_FIRST_AGE=70 + recommendedCategoriesForAge + treatmentCategoryNameZhFor
- reason-signals:signals 加 focusCategory / patientAge(均 optional,旧 plan 回落原行为)
- scenario:SQL 取年龄 → 走 resolver;高龄 goal 文案(义齿为主、种植并行)
- 前端:目标标签用 focusCategory;理由行调同一 resolver 显示「活动义齿 / 种植」
- 话术:fact-block + stable prompt 加高龄沟通约束;三档 promptVersion bump

实测(本地 75 岁真实患者重算):focusCategory=prosthodontic · patientAge=75 ·
理由「…未启动活动义齿 / 种植」。308 tests passed,service + web typecheck 干净。

️ 存量:plan 变化判定只比 (scenario, subKey),signals 变化不升版本 →
   已有 plan 不会自动拿到新字段(前端回落原行为)。生产 775 个 80+ 缺牙患者
   需回填或强制重算才生效,另行处理。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent a91f8a37
Pipeline #3433 failed in 0 seconds
......@@ -15,6 +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';
export function buildRichFactBlock(input: DraftPlanScriptInput): string {
const { patient, clinicName, plan, clinicalContext } = input;
......@@ -68,6 +69,10 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
clinicalContext.daysSinceLastVisit ?? '未知'
} `;
const noXray = patient.age == null || patient.age <= 18;
// 高龄介绍顺序:活动义齿在前、种植并行 —— 高龄**不是种植禁忌**(业务方口径:
// 70-80 能否种植看身体条件与患者意愿,由医生评估)。PAC 只调整介绍先后,不替医生下结论。
// 口径与召回建议治疗同源(DENTURE_FIRST_AGE)。
const dentureFirst = patient.age != null && patient.age > DENTURE_FIRST_AGE;
return `# 本次回访患者信息(只能用以下事实,不要编造或推断额外信息;下面是朴素事实,自然取用即可)
......@@ -92,7 +97,7 @@ ${others.length ? `\n## 其他可一并关心的问题(以本次聚焦为主,自
## 患者
- ${basics}
- 熟络度:${relationSignal}(语气怎么拿捏见沟通知识,你按这信号判断)${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**' : ''}`;
- 熟络度:${relationSignal}(语气怎么拿捏见沟通知识,你按这信号判断)${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**' : ''}${dentureFirst ? `\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-v16'; // v16: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话
readonly promptVersion = 'draft_plan_script@2026-06-16-deep-write-v17'; // v17: 高龄硬约束(≥75 缺牙禁推种植/手术)。v16: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepWriteSchema;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
......
......@@ -4,6 +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';
/**
* Prompt 版本管理约定:
......@@ -105,6 +106,8 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
} (语气怎么拿捏见沟通知识,你按这信号判断)`;
// ≤18 / 年龄未知 → 禁拍片 belt(青少年走成人模板时,成人模板含"拍片"句,这里硬提醒删)
const noXray = patient.age == null || patient.age <= 18;
// 高龄介绍顺序(同 fact-block,口径共用 DENTURE_FIRST_AGE):义齿在前、种植并行,不下禁忌结论。
const dentureFirst = patient.age != null && patient.age > DENTURE_FIRST_AGE;
// 自报家门:姓名**不进 prompt** —— 给 【回访客服】 占位符,LLM 原样输出,读详情时按当前登录客服回填。
// (话术缓存是 per-plan、召回池共享,烤进人名会让后开的客服读到别人的名字。见 agent-identity.ts)
......@@ -142,5 +145,5 @@ ${advLines}
- ${basics}
## 语气
- ${toneHint}${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**(删除模板里的拍片句)' : ''}${persona ? `\n\n${persona}` : ''}`;
- ${toneHint}${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**(删除模板里的拍片句)' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**,种植可并行提及,\n 但**不要主推手术、不要承诺能不能种** —— 落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}${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-v28'; // 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-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: 还原原模板
@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-v15'; // 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-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 软化
@Injectable()
export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DeepDraft> {
......
......@@ -7,6 +7,8 @@ import {
diagnosisCodeNameZh,
treatmentCategoryNameZh,
treatmentCategoryNameZhForTeeth,
treatmentCategoryNameZhFor,
recommendedCategoriesForAge,
} from '@pac/types';
import { PrismaService } from '../../../../prisma/prisma.service';
import type {
......@@ -124,6 +126,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
primaryCode: 'K08', // → DiagnosisTreatmentMap.K08(cooldownDays/windowDays/urgencyDayThreshold/categories)
label: '缺失牙未启动修复',
goal: '邀约启动缺失牙修复(种植/桥/义齿),避免邻牙倾斜 / 对颌伸长',
// 高龄版目标(>DENTURE_FIRST_AGE):义齿在前、种植并行 —— 高龄**不是种植禁忌**,
// 能否种植取决于骨量骨质/全身状况/患者意愿,由医生评估;PAC 只调整介绍顺序,不下结论。
// ⚠️ 只改"建议做什么",不改要不要召回 —— 缺牙影响咀嚼营养,高龄同样该管。
goalElderly:
'邀约评估缺牙修复方案(活动义齿为主、种植可并行考虑,由医生按身体条件评估),改善咀嚼、避免邻牙倾斜 / 对颌伸长',
// ⭐ §E gap 修正 flag(乳牙/智齿/正畸减数位/先天缺失剔除)→ 单一真理源 GAP_FLAGS_BY_PRIMARY['K08']
// (potential-treatment-gap.sql),召回与潜在治疗画像共用,口径一致。
},
......@@ -339,6 +346,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
SELECT
p.id AS patient_id,
p.external_id AS patient_external_id,
-- 建议治疗的年龄适配用(只影响"建议做什么",不参与召回筛选/排除;无生日 → NULL 走默认)
date_part('year', age(${scope.now}::timestamptz, p.birth_date))::int AS patient_age,
sig.id AS signal_fact_id,
sig.type AS signal_type,
sig.content->>'code' AS signal_code,
......@@ -456,7 +465,13 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// lead=建议(即无诊断,仅医生建议)→ 用建议名(如"建议种植")替代子场景诊断词"缺失牙未启动修复"。
const leadTrig = r.cluster_triggers?.[0] ?? { type: triggerType, code: r.signal_code };
const isRecLead = leadTrig.type === 'recommendation';
const catsStr = expectedCats.map((c) => treatmentCategoryNameZhForTeeth(c, r.tooth)).join(' / ');
// ⭐ 建议治疗:走**源头**的年龄排序(recommendedCategoriesForAge),此处不做任何年龄分支。
// >70 岁缺牙 → 活动义齿排在种植前(并行保留,不删种植 —— 高龄非种植禁忌,由医生评身体条件)。
// ⚠️ 只影响"建议什么"的顺序与措辞;排除闸仍用完整的 expectedCats(见上方 excludeCats)。
const age = r.patient_age ?? null;
const recCats = recommendedCategoriesForAge(expectedCats, age);
const focusCategory = recCats[0] ?? null;
const catsStr = recCats.map((c) => treatmentCategoryNameZhFor(c, r.tooth, age)).join(' / ');
hits.push({
patientId,
patientExternalId: r.patient_external_id,
......@@ -466,7 +481,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
? `${diagnosisCodeNameZh(leadTrig.code)}${toothStr}${sourceStr} ${r.days_since} 天前,未启动${catsStr}`
: `${cfg.label}${toothStr}${diagnosisCodeNameZh(r.signal_code)}${sourceStr} ${r.days_since} 天前,未启动${catsStr}`,
priorityScore: score,
goal: cfg.goal,
// 高龄目标文案(义齿在前、种植并行);判据同源头 —— recCats 首项被换成义齿即为高龄路径
goal:
(focusCategory === 'prosthodontic' && expectedCats[0] === 'implant'
? (cfg as { goalElderly?: string }).goalElderly
: null) ?? cfg.goal,
recommendedRole: 'staff',
recommendedChannel: 'phone',
// recommendedAt 不赋值(留 null):v1 没有"最佳触达时间"算法,全填当天无意义,
......@@ -494,6 +513,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 不可变锚点:展示层据此实时算天数,跟治疗链断口同源(避免快照随天数陈旧 → "388/389"漂移)
signalOccurredAt: r.signal_occurred_at?.toISOString() ?? null,
expectedCategories: [...expectedCats],
// ⭐ 建议治疗主类目(已按年龄适配)—— 前端「目标 · X」标签据此渲染,
// 不再直接取 expectedCategories[0](那会让 90 岁老人也显示"目标 · 种植")。
focusCategory,
// 生成当时的年龄快照(话术 / 展示按此措辞;null = 无生日不适配)
patientAge: age,
},
priorityBreakdown: breakdown,
});
......@@ -568,6 +592,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
interface HitRow {
patient_id: string;
patient_external_id: string;
patient_age: number | null; // 无生日 → null(不做年龄适配)
signal_fact_id: string;
signal_type: string; // 'diagnosis_record' | 'recommendation_record'
signal_code: string;
......
import {
DENTURE_FIRST_AGE,
recommendedCategoriesForAge,
treatmentCategoryNameZhFor,
treatmentCategoryNameZhForTeeth,
lookupDxTreatment,
} from '@pac/types';
/**
* 建议治疗的年龄适配回归(源头层:诊断 → 建议治疗的**排序**)。
*
* 背景(2026-07 业务反馈):90 岁 / 78 岁患者的缺牙召回,界面目标一律「目标 · 种植」——
* 因为 UI 取 expectedCategories[0],而 K08 的 categories 首项恒为 implant。
*
* 业务方口径(对齐后):**70-80 不是种植禁忌**,能否种植看骨量骨质 / 全身状况 / 患者意愿,
* 由医生评估。PAC 该做的是**调整推荐顺序**(活动义齿在前、种植并行),不是禁推种植。
*
* 设计边界(三条红线,下面各有用例锁住):
* 1. 只改顺序,不增删类目 —— 返回集合恒等于入参集合(种植不能被抹掉)
* 2. 不碰排除闸(DiagnosisTreatmentMap[].categories 仍是完整候选集)
* 3. 不碰"要不要召回"(缺牙影响咀嚼营养,高龄同样该管)
*/
describe('recommendedCategoriesForAge — 源头:建议治疗排序', () => {
const K08 = lookupDxTreatment('K08')!.categories; // ['implant','prosthodontic']
test('⭐ >70 岁缺牙 → 活动义齿在前,种植并行保留(不是二选一)', () => {
const r = recommendedCategoriesForAge(K08, 90);
expect(r[0]).toBe('prosthodontic'); // 义齿在前
expect(r).toContain('implant'); // ⭐ 种植仍在 —— 高龄非禁忌,只是排后面
expect(r).toHaveLength(K08.length); // 只挪位,不增删
});
test('边界:恰好 70 岁不变(口径是「大于 70」)', () => {
expect(recommendedCategoriesForAge(K08, DENTURE_FIRST_AGE)[0]).toBe('implant');
expect(recommendedCategoriesForAge(K08, DENTURE_FIRST_AGE + 1)[0]).toBe('prosthodontic');
});
test('未达线 / 无年龄 → 原顺序', () => {
expect(recommendedCategoriesForAge(K08, 35)[0]).toBe('implant');
expect(recommendedCategoriesForAge(K08, null)[0]).toBe('implant');
expect(recommendedCategoriesForAge(K08, undefined)[0]).toBe('implant');
});
test('只在「种植 + 义齿」并存时排序;单一类目不受年龄影响', () => {
// 龋齿 90 岁也还是补牙,不该被改写
expect(recommendedCategoriesForAge(['restorative'], 90)).toEqual(['restorative']);
expect(recommendedCategoriesForAge(['endodontic'], 92)).toEqual(['endodontic']);
expect(recommendedCategoriesForAge(['periodontic'], 88)).toEqual(['periodontic']);
});
test('⭐ 红线①:永不增删类目 —— 任何年龄下集合恒等', () => {
for (const age of [null, 30, 70, 71, 90, 120]) {
const r = recommendedCategoriesForAge(K08, age);
expect([...r].sort()).toEqual([...K08].sort());
}
});
test('空候选 → 空', () => {
expect(recommendedCategoriesForAge([], 90)).toEqual([]);
});
});
describe('treatmentCategoryNameZhFor — 展示标签', () => {
test('⭐ >70 岁的 prosthodontic → 「活动义齿」(而非泛化"修复 / 冠桥")', () => {
// 冠桥需邻牙条件,高龄同样受限;说清是活动义齿更贴合实际推荐
expect(treatmentCategoryNameZhFor('prosthodontic', null, 90)).toBe('活动义齿');
});
test('未达线 → 原标签', () => {
expect(treatmentCategoryNameZhFor('prosthodontic', null, 40)).toBe(
treatmentCategoryNameZhForTeeth('prosthodontic', null),
);
});
test('高龄的种植标签不变(仍叫"种植",只是排在义齿后)', () => {
expect(treatmentCategoryNameZhFor('implant', null, 90)).toBe(
treatmentCategoryNameZhForTeeth('implant', null),
);
});
test('乳牙口径不被破坏(既有行为保留)', () => {
expect(treatmentCategoryNameZhFor('restorative', '51;52', 8)).toBe('充填');
expect(treatmentCategoryNameZhFor('restorative', '51;52', null)).toBe('充填');
});
});
describe('组合:高龄缺牙的完整展示串', () => {
test('⭐ 90 岁 → 「活动义齿 / 种植」(并行,义齿在前)', () => {
const K08 = lookupDxTreatment('K08')!.categories;
const s = recommendedCategoriesForAge(K08, 90)
.map((c) => treatmentCategoryNameZhFor(c, null, 90))
.join(' / ');
expect(s).toBe('活动义齿 / 种植');
});
test('40 岁 → 原样(种植在前)', () => {
const K08 = lookupDxTreatment('K08')!.categories;
const s = recommendedCategoriesForAge(K08, 40)
.map((c) => treatmentCategoryNameZhFor(c, null, 40))
.join(' / ');
expect(s.startsWith('种植')).toBe(true);
});
});
describe('红线②:排除闸不受年龄影响', () => {
test('K08 的 categories 仍是完整候选集(做过任一都算已解决)', () => {
// 年龄适配只改"建议",绝不能收窄这个集合 ——
// 否则高龄患者做过种植反而排除不掉(会重复召回)。
const cats = lookupDxTreatment('K08')!.categories;
expect(cats).toContain('implant');
expect(cats).toContain('prosthodontic');
});
});
......@@ -275,6 +275,8 @@ export type PlanReason = {
toothPosition?: string | null;
daysSince: number;
expectedCategories: string[];
focusCategory?: string | null;
patientAge?: number | null;
} | null;
scenarioLabel: string;
priorityScore: number;
......
......@@ -38,6 +38,7 @@ import {
PersonaFeatureKey,
treatmentCategoryNameZh,
treatmentCategoryNameZhForTeeth,
treatmentCategoryNameZhFor,
diagnosisCodeNameZh,
EXECUTION_OUTCOME_META,
RECALL_FEEDBACK_OPTIONS,
......@@ -248,10 +249,13 @@ export function PlanDetailApp({
return code ? diagnosisCodeNameZh(code) : null;
})();
const focusTreatment = (() => {
const cat = focusedReason?.signals?.expectedCategories?.[0];
const sig = focusedReason?.signals;
// ⭐ 优先用后端按年龄适配过的主类目(focusCategory);旧 plan 无此字段 → 回落候选集首项。
// 直接取 expectedCategories[0] 会让高龄缺牙患者显示「目标 · 种植」(临床不合适)。
const cat = sig?.focusCategory ?? sig?.expectedCategories?.[0];
if (!cat) return null;
// 乳牙龋齿只显「充填」(去嵌体)— 共享口径 treatmentCategoryNameZhForTeeth(前后端一致)
return treatmentCategoryNameZhForTeeth(cat, focusedReason?.signals?.toothPosition);
// 年龄 + 牙位双适配(高龄缺牙 → 活动义齿;乳牙龋齿 → 只显充填):共享口径,前后端一致
return treatmentCategoryNameZhFor(cat, sig?.toothPosition, sig?.patientAge ?? null);
})();
const submitOutcome = async (formData: {
......
......@@ -89,6 +89,10 @@ export type PlanDetailData = {
toothPosition?: string | null;
daysSince: number;
expectedCategories: string[];
/** 按年龄适配后的建议治疗主类目;旧 plan 无此字段 → 回落 expectedCategories[0] */
focusCategory?: string | null;
/** plan 生成时的年龄快照(无生日 → null);展示按此措辞 */
patientAge?: number | null;
} | null;
priorityScore: number;
reason: string;
......
......@@ -4,7 +4,8 @@ import {
subLabelZh,
triggerTypeLabelZh,
diagnosisCodeNameZh,
treatmentCategoryNameZhForTeeth,
treatmentCategoryNameZhFor,
recommendedCategoriesForAge,
} from '@pac/types';
import { formatToothPosition, formatDaysReadable } from '@/lib/utils';
......@@ -27,6 +28,10 @@ export interface ReasonLineInput {
toothPosition?: string | null;
daysSince: number;
expectedCategories: string[];
/** 按年龄适配后的建议治疗主类目;旧 plan 无此字段 → 回落 expectedCategories[0] */
focusCategory?: string | null;
/** plan 生成时的年龄快照(无生日 → null);展示按此措辞 */
patientAge?: number | null;
} | null;
}
......@@ -49,9 +54,11 @@ export function ReasonLine({ reason }: { reason: ReasonLineInput }) {
const isRecLead = trig?.type === 'recommendation';
const subLabel =
isRecLead && trig?.code ? diagnosisCodeNameZh(trig.code) : subLabelZh(reason.scenario, s.subKey);
// 乳牙 restorative 只显「充填」(去嵌体)— 共享口径 treatmentCategoryNameZhForTeeth(前后端一致)
const cats = s.expectedCategories
.map((c) => treatmentCategoryNameZhForTeeth(c, s.toothPosition))
// 建议治疗:走**源头**的年龄排序(与后端同一个 recommendedCategoriesForAge),
// >70 岁缺牙 → 「活动义齿 / 种植」(义齿在前,种植并行保留 —— 高龄非种植禁忌);
// 乳牙 restorative → 「充填」(去嵌体)。旧 plan 无 patientAge → 原顺序,行为不变。
const cats = recommendedCategoriesForAge(s.expectedCategories, s.patientAge ?? null)
.map((c) => treatmentCategoryNameZhFor(c, s.toothPosition, s.patientAge ?? null))
.join(' / ');
return (
<span>
......
......@@ -740,6 +740,66 @@ export function treatmentCategoryNameZhForTeeth(
}
// =============================================================
// 建议治疗的年龄适配(源头:诊断 → 建议治疗的**排序**;不改排除闸、不改是否召回)
// =============================================================
/**
* 「活动义齿优先」年龄线 —— **> 该年龄**时,缺牙修复的建议顺序把活动义齿提到种植之前。
*
* 临床口径(2026-07 与业务方对齐):
* - 70-80 岁**不是种植禁忌** —— 能不能种要看骨量骨质、全身状况与患者意愿,由医生评估;
* - 但高龄群体整体上创伤更小、周期更短的活动义齿更稳妥,**推荐时应先讲义齿**;
* - 两者**并行提供**,不替患者做决定,也不替医生下禁忌结论。
*/
export const DENTURE_FIRST_AGE = 70;
/**
* 诊断 → 建议治疗类目(**按年龄排序**)—— 建议侧的单一真理源。
*
* ⚠️ 边界:只调整**顺序**,不增删类目 —— 返回集合恒等于入参集合。
* 排除闸(`DiagnosisTreatmentMap[].categories`:做过其中任一即算已解决)完全不受影响。
*
* 规则:候选同时含 implant + prosthodontic(缺牙 K08 这类"种植或义齿"并行可选)时,
* 年龄 > DENTURE_FIRST_AGE → prosthodontic 提到 implant 之前(义齿在前,种植并行保留);
* 其余情况原样返回。
*
* @param categories 该诊断的候选治疗类目
* @param age 患者年龄;null/undefined(无生日)→ 不排序,原样返回
*/
export function recommendedCategoriesForAge(
categories: readonly string[],
age: number | null | undefined,
): readonly string[] {
const dentureFirst =
age != null &&
age > DENTURE_FIRST_AGE &&
categories.includes('implant') &&
categories.includes('prosthodontic');
if (!dentureFirst) return categories;
// 只挪位:prosthodontic 提到最前,其余保持原相对顺序(种植仍在,作并行选项)
return [
'prosthodontic',
...categories.filter((c) => c !== 'prosthodontic'),
];
}
/**
* 建议治疗的中文标签(年龄 + 牙位双适配)—— 前后端共用,保证目标标签 / 理由 / 话术一致。
* 高龄的 prosthodontic 明确说「活动义齿」(而非泛化的"修复 / 冠桥" —— 冠桥需邻牙条件,
* 高龄同样受限,说清是活动义齿更贴合实际推荐)。
*/
export function treatmentCategoryNameZhFor(
category: string,
toothPosition: string | null | undefined,
age: number | null | undefined,
): string {
if (category === 'prosthodontic' && age != null && age > DENTURE_FIRST_AGE) {
return '活动义齿';
}
return treatmentCategoryNameZhForTeeth(category, toothPosition);
}
// =============================================================
// scenario 子规则 / 触发类型 → 中文标签(前后端共用)
// =============================================================
......
......@@ -48,6 +48,18 @@ export const ReasonSignalsSchema = z.object({
/// - 召回类:期望启动哪类治疗(种植 / 充填 / 牙周基础 / 根管)
/// - 沉睡激活:可能是 [recall_visit] 等
expectedCategories: z.array(z.string()),
/// 建议治疗**主类目**(已按患者年龄适配)—— 展示层「目标 · X」标签据此渲染。
/// 为什么不直接取 expectedCategories[0]:那是排除闸的完整候选集(做过其中任一都算已解决),
/// 首项恒为 implant,会让高龄患者也显示「目标 · 种植」(临床不合适,业务实测反弹)。
/// 本字段由 scenario 用 primaryTreatmentCategoryForAge 算好;
/// optional/nullable:旧 plan 无此字段 → 展示层回落 expectedCategories[0](无回归)。
focusCategory: z.string().nullable().optional(),
/// plan 生成时的患者年龄快照(无生日 → null)。
/// 用途:展示 / 话术按年龄措辞(如高龄缺牙说「活动义齿」而非「种植」)。
/// 存快照而非实时算:与 focusCategory 同源同刻,避免生日跨阈值后标签与理由文案打架。
patientAge: z.number().int().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