Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
144aebad
Commit
144aebad
authored
Jul 24, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
Merge branch 'fix/age-aware-treatment-goal' into test
parents
2142038e
3ff9c827
Pipeline
#3434
failed in 0 seconds
Changes
13
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
13 changed files
with
250 additions
and
14 deletions
+250
-14
apps/pac-service/src/modules/ai/calls/draft-plan-script/shared/fact-block.ts
+6
-1
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/deep/calls.ts
+1
-1
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/stable/prompt.ts
+4
-1
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/stable/stable.call.ts
+1
-1
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/standard/standard.call.ts
+1
-1
apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts
+27
-2
apps/pac-service/tests/age-aware-treatment.spec.ts
+114
-0
apps/pac-web/src/components/plan-detail/mock-data.ts
+2
-0
apps/pac-web/src/components/plan-detail/plan-detail-app.tsx
+7
-3
apps/pac-web/src/components/plan-detail/plan-detail-types.ts
+4
-0
apps/pac-web/src/components/plan-detail/reason-line.tsx
+11
-4
packages/types/src/canonical-codes.ts
+60
-0
packages/types/src/schemas/reason-signals.ts
+12
-0
No files found.
apps/pac-service/src/modules/ai/calls/draft-plan-script/shared/fact-block.ts
View file @
144aebad
...
@@ -15,6 +15,7 @@ import { smartDateDisplay, toothFriendly } from './script-facts';
...
@@ -15,6 +15,7 @@ import { smartDateDisplay, toothFriendly } from './script-facts';
import
{
resolveDiseaseLabel
}
from
'./disease-knowledge'
;
import
{
resolveDiseaseLabel
}
from
'./disease-knowledge'
;
import
{
deidentifyDoctor
}
from
'./pii'
;
import
{
deidentifyDoctor
}
from
'./pii'
;
import
{
AGENT_IDENTITY_PLACEHOLDER
}
from
'./agent-identity'
;
import
{
AGENT_IDENTITY_PLACEHOLDER
}
from
'./agent-identity'
;
import
{
DENTURE_FIRST_AGE
}
from
'@pac/types'
;
export
function
buildRichFactBlock
(
input
:
DraftPlanScriptInput
):
string
{
export
function
buildRichFactBlock
(
input
:
DraftPlanScriptInput
):
string
{
const
{
patient
,
clinicName
,
plan
,
clinicalContext
}
=
input
;
const
{
patient
,
clinicName
,
plan
,
clinicalContext
}
=
input
;
...
@@ -68,6 +69,10 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
...
@@ -68,6 +69,10 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
clinicalContext
.
daysSinceLastVisit
??
'未知'
clinicalContext
.
daysSinceLastVisit
??
'未知'
}
天
`;
}
天
`;
const noXray = patient.age == null || patient.age <= 18;
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 `
#
本次回访患者信息
(
只能用以下事实
,
不要编造或推断额外信息
;
下面是朴素事实
,
自然取用即可
)
return `
#
本次回访患者信息
(
只能用以下事实
,
不要编造或推断额外信息
;
下面是朴素事实
,
自然取用即可
)
...
@@ -92,7 +97,7 @@ ${others.length ? `\n## 其他可一并关心的问题(以本次聚焦为主,自
...
@@ -92,7 +97,7 @@ ${others.length ? `\n## 其他可一并关心的问题(以本次聚焦为主,自
##
患者
##
患者
-
$
{
basics
}
-
$
{
basics
}
-
熟络度
:
$
{
relationSignal
}(
语气怎么拿捏见沟通知识
,
你按这信号判断
)
$
{
noXray
?
'
\
n
\
n## 安全(硬约束)
\
n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**'
:
''
}
`;
-
熟络度
:
$
{
relationSignal
}(
语气怎么拿捏见沟通知识
,
你按这信号判断
)
$
{
noXray
?
'
\
n
\
n## 安全(硬约束)
\
n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**'
:
''
}
$
{
dentureFirst
?
`\n\n## 高龄沟通(约束)\n- 本患者
${
patient
.
age
}
岁:缺牙修复**先讲活动义齿**(创伤小、周期短),种植可作为并行选项一起提,\n 但**不要主推手术、不要承诺能不能种** —— 统一落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。`
:
''
}
`;
}
}
/**
/**
...
...
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/deep/calls.ts
View file @
144aebad
...
@@ -67,7 +67,7 @@ export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> {
...
@@ -67,7 +67,7 @@ export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> {
export
class
DeepWriteCall
implements
AiCall
<
DeepWriteInput
,
DeepWriteZ
>
{
export
class
DeepWriteCall
implements
AiCall
<
DeepWriteInput
,
DeepWriteZ
>
{
readonly
kind
=
'script'
as
const
;
readonly
kind
=
'script'
as
const
;
readonly
callKey
=
'draft_plan_script_write'
;
readonly
callKey
=
'draft_plan_script_write'
;
readonly
promptVersion
=
'draft_plan_script@2026-06-16-deep-write-v1
6'
;
//
v16: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话
readonly
promptVersion
=
'draft_plan_script@2026-06-16-deep-write-v1
7'
;
// v17: 高龄硬约束(≥75 缺牙禁推种植/手术)。
v16: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话
readonly
defaultModelId
=
'deepseek-v4-flash'
;
readonly
defaultModelId
=
'deepseek-v4-flash'
;
readonly
outputSchema
=
DeepWriteSchema
;
readonly
outputSchema
=
DeepWriteSchema
;
constructor
(
private
readonly
skillRegistry
:
DraftPlanScriptSkillRegistry
)
{}
constructor
(
private
readonly
skillRegistry
:
DraftPlanScriptSkillRegistry
)
{}
...
...
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/stable/prompt.ts
View file @
144aebad
...
@@ -4,6 +4,7 @@ import { resolveDisease } from './phrasing';
...
@@ -4,6 +4,7 @@ import { resolveDisease } from './phrasing';
import
{
deidentifyDoctor
}
from
'../../shared/pii'
;
import
{
deidentifyDoctor
}
from
'../../shared/pii'
;
import
{
renderTreatmentPlan
,
buildPersonaGuide
}
from
'../../shared/fact-block'
;
import
{
renderTreatmentPlan
,
buildPersonaGuide
}
from
'../../shared/fact-block'
;
import
{
AGENT_IDENTITY_PLACEHOLDER
}
from
'../../shared/agent-identity'
;
import
{
AGENT_IDENTITY_PLACEHOLDER
}
from
'../../shared/agent-identity'
;
import
{
DENTURE_FIRST_AGE
}
from
'@pac/types'
;
/**
/**
* Prompt 版本管理约定:
* Prompt 版本管理约定:
...
@@ -105,6 +106,8 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
...
@@ -105,6 +106,8 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
}
天
(
语气怎么拿捏见沟通知识
,
你按这信号判断
)
`;
}
天
(
语气怎么拿捏见沟通知识
,
你按这信号判断
)
`;
// ≤18 / 年龄未知 → 禁拍片 belt(青少年走成人模板时,成人模板含"拍片"句,这里硬提醒删)
// ≤18 / 年龄未知 → 禁拍片 belt(青少年走成人模板时,成人模板含"拍片"句,这里硬提醒删)
const noXray = patient.age == null || patient.age <= 18;
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 原样输出,读详情时按当前登录客服回填。
// 自报家门:姓名**不进 prompt** —— 给 【回访客服】 占位符,LLM 原样输出,读详情时按当前登录客服回填。
// (话术缓存是 per-plan、召回池共享,烤进人名会让后开的客服读到别人的名字。见 agent-identity.ts)
// (话术缓存是 per-plan、召回池共享,烤进人名会让后开的客服读到别人的名字。见 agent-identity.ts)
...
@@ -142,5 +145,5 @@ ${advLines}
...
@@ -142,5 +145,5 @@ ${advLines}
-
$
{
basics
}
-
$
{
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
}
`
:
''
}
`;
}
}
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/stable/stable.call.ts
View file @
144aebad
...
@@ -65,7 +65,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc
...
@@ -65,7 +65,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc
* 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。
* 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。
*/
*/
const
DRAFT_PLAN_SCRIPT_PROMPT_VERSION
=
const
DRAFT_PLAN_SCRIPT_PROMPT_VERSION
=
'draft_plan_script@2026-06-08-4module-v2
8'
;
//
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-v2
9'
;
// 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
()
@
Injectable
()
export
class
DraftPlanScriptCall
export
class
DraftPlanScriptCall
...
...
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/standard/standard.call.ts
View file @
144aebad
...
@@ -22,7 +22,7 @@ import { type DeepDraft, draftOutputToDeep } from '../deep/types';
...
@@ -22,7 +22,7 @@ import { type DeepDraft, draftOutputToDeep } from '../deep/types';
*
*
* callKey 仍用 'draft_plan_script'(同一逻辑调用),档位差异落 promptVersion → eval 可按版本切档对比。
* callKey 仍用 'draft_plan_script'(同一逻辑调用),档位差异落 promptVersion → eval 可按版本切档对比。
*/
*/
const
STANDARD_PROMPT_VERSION
=
'draft_plan_script@2026-06-08-standard-v1
5'
;
//
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-v1
6'
;
// 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
()
@
Injectable
()
export
class
StandardScriptCall
implements
AiCall
<
DraftPlanScriptInput
,
DeepDraft
>
{
export
class
StandardScriptCall
implements
AiCall
<
DraftPlanScriptInput
,
DeepDraft
>
{
...
...
apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts
View file @
144aebad
...
@@ -7,6 +7,8 @@ import {
...
@@ -7,6 +7,8 @@ import {
diagnosisCodeNameZh
,
diagnosisCodeNameZh
,
treatmentCategoryNameZh
,
treatmentCategoryNameZh
,
treatmentCategoryNameZhForTeeth
,
treatmentCategoryNameZhForTeeth
,
treatmentCategoryNameZhFor
,
recommendedCategoriesForAge
,
}
from
'@pac/types'
;
}
from
'@pac/types'
;
import
{
PrismaService
}
from
'../../../../prisma/prisma.service'
;
import
{
PrismaService
}
from
'../../../../prisma/prisma.service'
;
import
type
{
import
type
{
...
@@ -124,6 +126,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
...
@@ -124,6 +126,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
primaryCode
:
'K08'
,
// → DiagnosisTreatmentMap.K08(cooldownDays/windowDays/urgencyDayThreshold/categories)
primaryCode
:
'K08'
,
// → DiagnosisTreatmentMap.K08(cooldownDays/windowDays/urgencyDayThreshold/categories)
label
:
'缺失牙未启动修复'
,
label
:
'缺失牙未启动修复'
,
goal
:
'邀约启动缺失牙修复(种植/桥/义齿),避免邻牙倾斜 / 对颌伸长'
,
goal
:
'邀约启动缺失牙修复(种植/桥/义齿),避免邻牙倾斜 / 对颌伸长'
,
// 高龄版目标(>DENTURE_FIRST_AGE):义齿在前、种植并行 —— 高龄**不是种植禁忌**,
// 能否种植取决于骨量骨质/全身状况/患者意愿,由医生评估;PAC 只调整介绍顺序,不下结论。
// ⚠️ 只改"建议做什么",不改要不要召回 —— 缺牙影响咀嚼营养,高龄同样该管。
goalElderly
:
'邀约评估缺牙修复方案(活动义齿为主、种植可并行考虑,由医生按身体条件评估),改善咀嚼、避免邻牙倾斜 / 对颌伸长'
,
// ⭐ §E gap 修正 flag(乳牙/智齿/正畸减数位/先天缺失剔除)→ 单一真理源 GAP_FLAGS_BY_PRIMARY['K08']
// ⭐ §E gap 修正 flag(乳牙/智齿/正畸减数位/先天缺失剔除)→ 单一真理源 GAP_FLAGS_BY_PRIMARY['K08']
// (potential-treatment-gap.sql),召回与潜在治疗画像共用,口径一致。
// (potential-treatment-gap.sql),召回与潜在治疗画像共用,口径一致。
},
},
...
@@ -339,6 +346,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
...
@@ -339,6 +346,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
SELECT
SELECT
p.id AS patient_id,
p.id AS patient_id,
p.external_id AS patient_external_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.id AS signal_fact_id,
sig.type AS signal_type,
sig.type AS signal_type,
sig.content->>'code' AS signal_code,
sig.content->>'code' AS signal_code,
...
@@ -456,7 +465,13 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
...
@@ -456,7 +465,13 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// lead=建议(即无诊断,仅医生建议)→ 用建议名(如"建议种植")替代子场景诊断词"缺失牙未启动修复"。
// lead=建议(即无诊断,仅医生建议)→ 用建议名(如"建议种植")替代子场景诊断词"缺失牙未启动修复"。
const
leadTrig
=
r
.
cluster_triggers
?.[
0
]
??
{
type
:
triggerType
,
code
:
r
.
signal_code
};
const
leadTrig
=
r
.
cluster_triggers
?.[
0
]
??
{
type
:
triggerType
,
code
:
r
.
signal_code
};
const
isRecLead
=
leadTrig
.
type
===
'recommendation'
;
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
({
hits
.
push
({
patientId
,
patientId
,
patientExternalId
:
r
.
patient_external_id
,
patientExternalId
:
r
.
patient_external_id
,
...
@@ -466,7 +481,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
...
@@ -466,7 +481,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
?
`
${
diagnosisCodeNameZh
(
leadTrig
.
code
)}${
toothStr
}${
sourceStr
}
${
r
.
days_since
}
天前,未启动
${
catsStr
}
`
?
`
${
diagnosisCodeNameZh
(
leadTrig
.
code
)}${
toothStr
}${
sourceStr
}
${
r
.
days_since
}
天前,未启动
${
catsStr
}
`
:
`
${
cfg
.
label
}${
toothStr
}
—
${
diagnosisCodeNameZh
(
r
.
signal_code
)}${
sourceStr
}
${
r
.
days_since
}
天前,未启动
${
catsStr
}
`
,
:
`
${
cfg
.
label
}${
toothStr
}
—
${
diagnosisCodeNameZh
(
r
.
signal_code
)}${
sourceStr
}
${
r
.
days_since
}
天前,未启动
${
catsStr
}
`
,
priorityScore
:
score
,
priorityScore
:
score
,
goal
:
cfg
.
goal
,
// 高龄目标文案(义齿在前、种植并行);判据同源头 —— recCats 首项被换成义齿即为高龄路径
goal
:
(
focusCategory
===
'prosthodontic'
&&
expectedCats
[
0
]
===
'implant'
?
(
cfg
as
{
goalElderly
?:
string
}).
goalElderly
:
null
)
??
cfg
.
goal
,
recommendedRole
:
'staff'
,
recommendedRole
:
'staff'
,
recommendedChannel
:
'phone'
,
recommendedChannel
:
'phone'
,
// recommendedAt 不赋值(留 null):v1 没有"最佳触达时间"算法,全填当天无意义,
// recommendedAt 不赋值(留 null):v1 没有"最佳触达时间"算法,全填当天无意义,
...
@@ -494,6 +513,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
...
@@ -494,6 +513,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 不可变锚点:展示层据此实时算天数,跟治疗链断口同源(避免快照随天数陈旧 → "388/389"漂移)
// 不可变锚点:展示层据此实时算天数,跟治疗链断口同源(避免快照随天数陈旧 → "388/389"漂移)
signalOccurredAt: r.signal_occurred_at?.toISOString() ?? null,
signalOccurredAt: r.signal_occurred_at?.toISOString() ?? null,
expectedCategories: [...expectedCats],
expectedCategories: [...expectedCats],
// ⭐ 建议治疗主类目(已按年龄适配)—— 前端「目标 · X」标签据此渲染,
// 不再直接取 expectedCategories[0](那会让 90 岁老人也显示"目标 · 种植")。
focusCategory,
// 生成当时的年龄快照(话术 / 展示按此措辞;null = 无生日不适配)
patientAge: age,
},
},
priorityBreakdown: breakdown,
priorityBreakdown: breakdown,
});
});
...
@@ -568,6 +592,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
...
@@ -568,6 +592,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
interface
HitRow
{
interface
HitRow
{
patient_id
:
string
;
patient_id
:
string
;
patient_external_id
:
string
;
patient_external_id
:
string
;
patient_age
:
number
|
null
;
// 无生日 → null(不做年龄适配)
signal_fact_id
:
string
;
signal_fact_id
:
string
;
signal_type
:
string
;
// 'diagnosis_record' | 'recommendation_record'
signal_type
:
string
;
// 'diagnosis_record' | 'recommendation_record'
signal_code
:
string
;
signal_code
:
string
;
...
...
apps/pac-service/tests/age-aware-treatment.spec.ts
0 → 100644
View file @
144aebad
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'
);
});
});
apps/pac-web/src/components/plan-detail/mock-data.ts
View file @
144aebad
...
@@ -275,6 +275,8 @@ export type PlanReason = {
...
@@ -275,6 +275,8 @@ export type PlanReason = {
toothPosition
?:
string
|
null
;
toothPosition
?:
string
|
null
;
daysSince
:
number
;
daysSince
:
number
;
expectedCategories
:
string
[];
expectedCategories
:
string
[];
focusCategory
?:
string
|
null
;
patientAge
?:
number
|
null
;
}
|
null
;
}
|
null
;
scenarioLabel
:
string
;
scenarioLabel
:
string
;
priorityScore
:
number
;
priorityScore
:
number
;
...
...
apps/pac-web/src/components/plan-detail/plan-detail-app.tsx
View file @
144aebad
...
@@ -38,6 +38,7 @@ import {
...
@@ -38,6 +38,7 @@ import {
PersonaFeatureKey
,
PersonaFeatureKey
,
treatmentCategoryNameZh
,
treatmentCategoryNameZh
,
treatmentCategoryNameZhForTeeth
,
treatmentCategoryNameZhForTeeth
,
treatmentCategoryNameZhFor
,
diagnosisCodeNameZh
,
diagnosisCodeNameZh
,
EXECUTION_OUTCOME_META
,
EXECUTION_OUTCOME_META
,
RECALL_FEEDBACK_OPTIONS
,
RECALL_FEEDBACK_OPTIONS
,
...
@@ -248,10 +249,13 @@ export function PlanDetailApp({
...
@@ -248,10 +249,13 @@ export function PlanDetailApp({
return
code
?
diagnosisCodeNameZh
(
code
)
:
null
;
return
code
?
diagnosisCodeNameZh
(
code
)
:
null
;
})();
})();
const
focusTreatment
=
(()
=>
{
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
;
if
(
!
cat
)
return
null
;
//
乳牙龋齿只显「充填」(去嵌体)— 共享口径 treatmentCategoryNameZhForTeeth(前后端一致)
//
年龄 + 牙位双适配(高龄缺牙 → 活动义齿;乳牙龋齿 → 只显充填):共享口径,前后端一致
return
treatmentCategoryNameZhFor
Teeth
(
cat
,
focusedReason
?.
signals
?.
toothPosition
);
return
treatmentCategoryNameZhFor
(
cat
,
sig
?.
toothPosition
,
sig
?.
patientAge
??
null
);
})();
})();
const
submitOutcome
=
async
(
formData
:
{
const
submitOutcome
=
async
(
formData
:
{
...
...
apps/pac-web/src/components/plan-detail/plan-detail-types.ts
View file @
144aebad
...
@@ -89,6 +89,10 @@ export type PlanDetailData = {
...
@@ -89,6 +89,10 @@ export type PlanDetailData = {
toothPosition
?:
string
|
null
;
toothPosition
?:
string
|
null
;
daysSince
:
number
;
daysSince
:
number
;
expectedCategories
:
string
[];
expectedCategories
:
string
[];
/** 按年龄适配后的建议治疗主类目;旧 plan 无此字段 → 回落 expectedCategories[0] */
focusCategory
?:
string
|
null
;
/** plan 生成时的年龄快照(无生日 → null);展示按此措辞 */
patientAge
?:
number
|
null
;
}
|
null
;
}
|
null
;
priorityScore
:
number
;
priorityScore
:
number
;
reason
:
string
;
reason
:
string
;
...
...
apps/pac-web/src/components/plan-detail/reason-line.tsx
View file @
144aebad
...
@@ -4,7 +4,8 @@ import {
...
@@ -4,7 +4,8 @@ import {
subLabelZh
,
subLabelZh
,
triggerTypeLabelZh
,
triggerTypeLabelZh
,
diagnosisCodeNameZh
,
diagnosisCodeNameZh
,
treatmentCategoryNameZhForTeeth
,
treatmentCategoryNameZhFor
,
recommendedCategoriesForAge
,
}
from
'@pac/types'
;
}
from
'@pac/types'
;
import
{
formatToothPosition
,
formatDaysReadable
}
from
'@/lib/utils'
;
import
{
formatToothPosition
,
formatDaysReadable
}
from
'@/lib/utils'
;
...
@@ -27,6 +28,10 @@ export interface ReasonLineInput {
...
@@ -27,6 +28,10 @@ export interface ReasonLineInput {
toothPosition
?:
string
|
null
;
toothPosition
?:
string
|
null
;
daysSince
:
number
;
daysSince
:
number
;
expectedCategories
:
string
[];
expectedCategories
:
string
[];
/** 按年龄适配后的建议治疗主类目;旧 plan 无此字段 → 回落 expectedCategories[0] */
focusCategory
?:
string
|
null
;
/** plan 生成时的年龄快照(无生日 → null);展示按此措辞 */
patientAge
?:
number
|
null
;
}
|
null
;
}
|
null
;
}
}
...
@@ -49,9 +54,11 @@ export function ReasonLine({ reason }: { reason: ReasonLineInput }) {
...
@@ -49,9 +54,11 @@ export function ReasonLine({ reason }: { reason: ReasonLineInput }) {
const
isRecLead
=
trig
?.
type
===
'recommendation'
;
const
isRecLead
=
trig
?.
type
===
'recommendation'
;
const
subLabel
=
const
subLabel
=
isRecLead
&&
trig
?.
code
?
diagnosisCodeNameZh
(
trig
.
code
)
:
subLabelZh
(
reason
.
scenario
,
s
.
subKey
);
isRecLead
&&
trig
?.
code
?
diagnosisCodeNameZh
(
trig
.
code
)
:
subLabelZh
(
reason
.
scenario
,
s
.
subKey
);
// 乳牙 restorative 只显「充填」(去嵌体)— 共享口径 treatmentCategoryNameZhForTeeth(前后端一致)
// 建议治疗:走**源头**的年龄排序(与后端同一个 recommendedCategoriesForAge),
const
cats
=
s
.
expectedCategories
// >70 岁缺牙 → 「活动义齿 / 种植」(义齿在前,种植并行保留 —— 高龄非种植禁忌);
.
map
((
c
)
=>
treatmentCategoryNameZhForTeeth
(
c
,
s
.
toothPosition
))
// 乳牙 restorative → 「充填」(去嵌体)。旧 plan 无 patientAge → 原顺序,行为不变。
const
cats
=
recommendedCategoriesForAge
(
s
.
expectedCategories
,
s
.
patientAge
??
null
)
.
map
((
c
)
=>
treatmentCategoryNameZhFor
(
c
,
s
.
toothPosition
,
s
.
patientAge
??
null
))
.
join
(
' / '
);
.
join
(
' / '
);
return
(
return
(
<
span
>
<
span
>
...
...
packages/types/src/canonical-codes.ts
View file @
144aebad
...
@@ -740,6 +740,66 @@ export function treatmentCategoryNameZhForTeeth(
...
@@ -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 子规则 / 触发类型 → 中文标签(前后端共用)
// scenario 子规则 / 触发类型 → 中文标签(前后端共用)
// =============================================================
// =============================================================
...
...
packages/types/src/schemas/reason-signals.ts
View file @
144aebad
...
@@ -48,6 +48,18 @@ export const ReasonSignalsSchema = z.object({
...
@@ -48,6 +48,18 @@ export const ReasonSignalsSchema = z.object({
/// - 召回类:期望启动哪类治疗(种植 / 充填 / 牙周基础 / 根管)
/// - 召回类:期望启动哪类治疗(种植 / 充填 / 牙周基础 / 根管)
/// - 沉睡激活:可能是 [recall_visit] 等
/// - 沉睡激活:可能是 [recall_visit] 等
expectedCategories
:
z
.
array
(
z
.
string
()),
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
>
;
export
type
ReasonSignals
=
z
.
infer
<
typeof
ReasonSignalsSchema
>
;
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment