Commit 36686f66 by luoqi

W4: 治疗链 5 阶段 + AI 话术 + DB 持久化 + 真实诊所多 brand 接入

主要工作(自 W3 末快照以来):

数据层(canonical-fact-layer):
- 治疗链 5 阶段模型(chain-composer S1/S2/S3/S4/S5)+ TreatmentMilestones + TreatmentLifecycles 字典
- alternative-closed 闸:同位置后续替代治疗覆盖原诊断 → 标 closed
- 同 (category, code) 桶按 tooth set overlap 合并(union-find);wholeMouth 桶 S2 修正
- chain.target = SQL 为准(按 plan_reasons.signals.triggers 对齐,plan-aggregate 注入)
- diagnosis name_zh 末尾标点清理;cooldown 内不标 ★ 潜在新链

召回算法:
- DiagnosisTreatmentMap K00-K09 全覆盖(加 K00/K01/K03/K06/K07/K09)
- treatment_initiation_recall 10 个 sub-scenarios + 配套 *_RECOMMENDED 推荐码
- 移除 INTAKE_MAX_DAYS 上界;scenario SQL 加预约排除(任何 sig 后预约即排)
- S2 改用预约主诉类别;S2 fallback 显示 planned 治疗

画像 Persona:
- treatment_chain_status feature 直接复用 ChainComposerService
- value/recall_risk/dnc feature 切到独立 fact_type(v2.1)
- status: in ['active', 'fulfilled'] 加载兼容已完成 actual

Plan 详情聚合:
- /plans/:id/full 接 personas + chains + facts + 话术
- script 从 plan_scripts DB 加载,markdown 反 parse 4 段 sections,前端零适配
- 列表页搜索/过滤改服务端(W3 末)

AI 话术(B 方案 重写):
- DeepSeek V4 Pro → Flash 切换(call defaultModelId + config defaultModel)
- schema 4 段 markdown 字符串(opening/followup/objection/close)对齐前端 mock
- prompt @2026-05-24-d:few-shot demo + / 反例 + 事实约束硬要求
- 事实漂移防护:诊所名 JVS_DW_CLINIC_NAMES 字典翻译、牙位 FDI→俗称、
  主诊医生从 facts 抽、通话称呼 nameSpokenForm(姓+先生/女士)
- scenario 内部 label 禁外露,opening 必须用临床事实开场

鉴权:
- A 方案 refresh token 真实实现(Redis jti rotation,无 host SSO 回调)
- 详情页电话查看 icon + reveal 接口

UI:
- 详情页 TopBar 跟列表页 PageHeader 风格统一
- 治疗链 5 阶段词表(chainStatusVisual)
- 闭环链去"建议下一步" + 闭环时间
- WhyCard 过滤 alternative-closed reasons

数据源接入:
- 5 家试点诊所 JVS DW 实接(瑞尔/瑞泰双 brand,tenant_id 路由)
- yaml transforms(Layer A.5)6 operator 白名单:split/route/derive/filter/project/pick_first_nonzero
- 实测 5000 患者 cohort,408K facts / 2207 plans / 0 failed

待办:
- task #46 cold-import --reparse mode(yaml 改后强制重 parse)
- EMR.treat_plan 语义错位(标记为 planned 实际是 actual);EMR.plan 字段未消费
- 双源 actual 去重(EMR vs settlement)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
parent 30196953
......@@ -27,3 +27,5 @@ pnpm-debug.log*
*.tsbuildinfo
prisma/migrations/dev
.history
# appointment — 全部预约(含未到诊)
# 消费 transforms 输出表 appointment_all_rows(等同 source fact_appointment_out,project 直通)
# 消费 transforms 输出表 appointment_all_rows(derive 拼好精确预约时刻 scheduled_at_ts)
canonical: appointment
emits:
......@@ -15,21 +15,30 @@ field_mapping:
externalId: id
patientExternalId: patient_id
clinicId: organization_id
scheduledAt: appo_time
scheduledAt: scheduled_at_ts # transforms 拼好的精确时刻(日期 + 起始时分)
arrivedAt: in_time
appointmentType: appo_type
# ⚠️ appointmentType 不映射 appo_type:appo_type 实际值是"复诊/初诊"(=visit_category),
# 不是 PAC appointment_type 通用语义"预约目的"(咨询/治疗/检查/拔牙等);
# 且"初诊/复诊"是衍生信号,PAC 自己从 encounter_record 聚合算更可信(单一源 = PAC)。
doctorId: appo_doc_id
status: appo_status
complaintCategory: appo_complaint_category # 预约科目 / 就诊意向(种植/正畸/…)
complaintText: appo_complaint # 预约主诉自由文本(跟 category 配对)
durationMinutes: appo_time_period # 预约时长(分钟)
cancellationReason: cancel_reason # 取消原因(自由文本:临时有事/再约/去了其他诊所…)
staffNotes: appo_remark # 预约备注(运营内部 / Layer C 备料)
# host appo_status:1=正常预约 / 2=已改约 / 3=已取消 / 4=到诊 / 5=接诊 / 6=结算 /
# 8=walk in变更 / 9=爽约
# host appo_status → PAC 8 态(1:1 细分,不再把已改约/结算都塞 scheduled/arrived):
# 1=正常→scheduled(唯一有效预约)/ 2=已改约→rescheduled(历史被取代,非有效)/ 3=已取消→cancelled
# 4=到诊→arrived / 5=接诊→in_treatment / 6=结算→completed(到诊+消费完成)
# 8=walk in 变更→walk_in / 9=爽约→no_show
enum_mapping:
status:
"1": scheduled
"2": scheduled
"2": rescheduled
"3": cancelled
"4": arrived
"5": arrived
"6": arrived
"8": scheduled # walk in 改约,等同新建
"9": no_show # 爽约
"5": in_treatment
"6": completed
"8": walk_in
"9": no_show
......@@ -24,6 +24,8 @@ field_mapping:
stdCodeRaw: std_code # 溯源:判 code_source(std_code / name_map / null)
name: message # 原始中文名(展示 + LLM 输入)
toothPosition: tooth_position
doctorId: user_id # 诊断医生(从 emr 父级继承)
doctorName: doctor_name # 诊断医生名(快照)
# ── code 翻译 ──
# ① ICD K 大类直通(已 substring 的 stdCode);② 中文标准术语 → K 码白名单(草案,⚠️ 待牙医 review)
......@@ -101,5 +103,155 @@ enum_mapping:
缺失牙: K08
牙列缺损: K08
牙列缺失: K08
后天性牙齿缺失: K08
全口牙列缺失: K08
上颌牙列缺损: K08
下颌牙列缺损: K08
废用牙: K08 # 临床功能性缺失
无功能牙: K08
# ── W3 末扩 K00-K08 全覆盖(源 DW 数据驱动,见 dw-data-source-issues #15)──
# K00 牙发育 / 萌出障碍 — 只收"真病种",不收"乳牙列 / 混合牙列"等正常生理态(走 _default 不入诊断)
乳牙滞留: K00
滞留乳牙: K00
乳牙早失: K00
乳牙松动: K00 # 替换期非自然脱落
替换期乳牙松动: K00
松动乳牙: K00
多生牙: K00
额外牙: K00
埋伏多生牙: K00
根方多生牙: K00
间多生牙: K00
腭侧多生牙: K00
前牙区埋伏多生牙: K00
先天缺失: K00
先天缺牙: K00
# ⭐ "牙齿缺少" 实际是 host 通用术语,大多用于成人后天缺失(临床路径=种植/修复,跟 K08 完全一致)
# 之前误归 K00 导致陆伟根(62 岁)等成人患者出现"先天/萌出处置"链 — 不合临床
# 改归 K08 — 跟 "牙缺失"/"后天性牙齿缺失" 同处理
牙齿缺少: K08
釉质发育不全: K00 # = K00.2 釉质形成
釉质发育不良: K00
牙釉质发育不全: K00
牙釉质发育不良: K00
氟斑牙: K00 # K00.3 牙齿氟中毒着色
四环素牙: K00 # K00.3 内源性着色
迟萌: K00 # K00.6 萌出障碍
恒牙迟萌: K00
萌出迟缓: K00
萌出不全: K00
异位萌出: K00
萌出间隙不足: K00
部分萌出: K00
未萌出: K00
牙萌出障碍: K00
牙萌出延迟: K00
# K01 阻生 / 埋伏(补 host 真实用词)
智齿: K01 # host 大量直接录"智齿"作为诊断,实际是阻生齿
阻生智齿: K01
近中阻生: K01
垂直位阻生齿: K01
垂直位智齿: K01
异位阻生: K01
# K03 牙体硬组织其他(补复合 / 不良修复体 / 牙齿磨耗)
牙齿楔状缺损: K03
牙体磨耗: K03
牙磨耗: K03
重度磨耗: K03
不良修复体: K03 # 已存在 host 数据,主要走 restorative / prosthodontic 重做
不良充填体: K03
死髓牙: K03 # 牙体已失活,临床走 prosthodontic 桩冠 / surgical 拔除
修复体脱落: K03
充填脱落: K03
冠折: K03
简单冠折: K03
复杂冠折: K03
根折: K03
牙外伤: K03
脱矿: K03 # 早期牙齿硬组织脱矿
牙本质敏感: K03
# K05 牙周 / 牙龈(补 host 真实高频用词)
牙周病: K05
全口慢性牙周炎: K05
萌出性龈炎: K05 # 实际牙龈炎,临床路径同 K05
牙菌斑堆积: K05
# K06 牙龈 / 牙槽嵴疾患(原本走 _default → code=null,W3 末归 K06)
牙周脓肿: K06
慢性牙周脓肿: K06
急性牙周脓肿: K06
冠周炎: K06 # K05.2 / K06 边缘,临床归 K06 (智齿冠周特化)
智齿冠周炎: K06
急性冠周炎: K06
慢性冠周炎: K06
牙龈萎缩: K06
龈萎缩: K06
牙龈增生: K06
龈增生: K06
药物性牙龈增生: K06
牙龈瘤: K06
龈瘤: K06
龈息肉: K06
根分叉病变: K06 # K05.x 并发,但临床上独立标
重度根分叉病变: K06
食物嵌塞: K06 # ICD 实际归 K07.6,但临床走 periodontic 处置 → 归 K06 路径
附着丧失: K06
附着龈缺如: K06
牙槽骨缺损: K06
牙槽骨骨尖: K06
牙槽嵴: K06
唇系带附着低: K06
唇系带附着过低: K06
唇系带附丽过低: K06
唇系带附着异常: K06
上唇系带附着低: K06
上唇系带附着异常: K06
舌系带过短: K06
舌系带短: K06
舌系带附着低: K06
舌系带附着异常: K06
舌系带附着过前: K06
系带异常: K06
# K09 牙发生囊肿(根尖囊肿/含牙囊肿/颌骨囊肿)— DW 真实数据 ~170 条,临床必摘除
根尖囊肿: K09
根尖周囊肿: K09
慢性根尖囊肿: K09
含牙囊肿: K09
颌骨囊肿: K09
上颌骨囊肿: K09
下颌骨囊肿: K09
牙源性囊肿: K09
# K07 错颌畸形(补"合"字版本 + 安氏分类异写)
错合畸形: K07 # host 用"合"而非"颌"也很多
错合畸形安氏Ⅰ类: K07
错合畸形安氏Ⅱ类: K07
错合畸形安氏Ⅲ类: K07
错合畸形安氏II类: K07 # 阿拉伯数字版本
错合畸形安氏III类: K07
安氏I类: K07
安氏II类: K07
安氏III类: K07
安氏Ⅰ类: K07
安氏Ⅱ类: K07
安氏Ⅲ类: K07
前牙反合: K07
前牙反颌: K07
深覆合: K07
深覆颌: K07
牙位置异常: K07
骨性I类: K07
骨性II类: K07
骨性III类: K07
骨性Ⅰ类: K07
骨性Ⅱ类: K07
骨性Ⅲ类: K07
# 兜底:翻不出 → 空 → parser 落 code=null(不丢 fact)
_default: ""
......@@ -18,3 +18,13 @@ field_mapping:
clinicId: organization_id
submittedAt: rq
encounterExternalId: registration_id
doctorId: user_id # 医生 id(与 settlement.doctor_id 同源 99.7%)
doctorName: doctor_name # 接诊医生名(快照)
# SOAP 自由文本段落(之前漏映射 → emr_record content 全 null,Layer C 无源)
illnessDesc: illness_desc # 主诉 / 现病史
preIllness: pre_illness # 现病史
pastHistory: past_hist # 既往史
generalCondition: gen_cond # 全身情况
examFindings: examine # 检查所见(JSON 原文,带牙位)
disposal: dispose # 处置(JSON 原文)
doctorAdvice: doc_order # 医嘱
......@@ -17,4 +17,6 @@ field_mapping:
clinicId: organization_id
startedAt: in_time
doctorId: appo_doc_id
notes: appo_complaint
# 主诉:归 chief_complaint 字段(原 notes 装的就是主诉,纠正命名)。
# notes 字段保留为通用备注,jvs-dw 暂无对应留 null;encounter_type 同理(host 无结构化)。
chiefComplaint: appo_complaint
# image — 影像 metadata(EMR.file_url 拆行而来,PAC 不持文件本体)
# 消费 transforms 输出表 image_rows(split_json_array file_url + derive external_id)
canonical: image
emits:
action: image_uploaded
subjectType: image
occurredAtField: uploadedAt
primary:
table: image_rows
key: image_external_id
dedup_by: image_external_id # 同一影像跨 emr 行重复兜底
field_mapping:
externalId: image_external_id
patientExternalId: patient_id
clinicId: organization_id
uploadedAt: captured_at
encounterExternalId: registration_id
modality: check_name
doctorId: user_id # 接诊医生(从 emr 父级继承,影像拍摄/解读医生)
doctorName: doctor_name
enum_mapping:
# host check_name → PAC modality 闭集(pa/bw/pano/cbct/intraoral_photo/scan/other)
# 真影像归对应类型;化验/表单/记录类(血常规/牙周大表/登记表…)无影像语义,归 other
modality:
"口内像照片": intraoral_photo
"面相照": intraoral_photo
"内窥镜": intraoral_photo
"小牙片": pa
"全景片/头颅正侧位片截图": pano
"全景片/侧位片/其他X线影像": pano
"CT影像截图": cbct
"口腔扫描": scan
"其他类型资料(非X影像)": other
"牙周大表": other
"血常规": other
"拔牙术前清单": other
"全身检查": other
"凝血四项": other
"术前讨论记录": other
"个人健康状况登记表": other
"分析结果": other
_default: other
......@@ -7,9 +7,10 @@ primary:
key: patient_id
field_mapping:
externalId: patient_id # Int64 → ColdImportService 入库时 String 化
name: client_name
gender: client_gender
birthDate: birthday
notes: follow_content
externalId: patient_id # Int64 → ColdImportService 入库时 String 化
name: client_name
gender: client_gender
birthDate: birthday
medicalRecordNumber: file_num # 病历号(辅助标识,客服沟通用)
notes: follow_content
# doNotContact / deceased 不映射 — 走 PatientCanonicalSchema default false
......@@ -15,7 +15,18 @@ field_mapping:
externalId: id
patientExternalId: patient_id
clinicId: organization_id
paidAt: rq
amount: settlement_money # FieldMapper.normalize 按 amount_unit=yuan 转 cents
paidAt: created_date # W3 末从 billing_date 升级 — 跟 treatment_actual 口径一致。
# - billing_date = 开单/收费动作时间(团购预付等失真)
# - created_date = 结算入库时间(接近实际治疗时点)
# - 100% created_date >= billing_date(9.4 万行 settlement_mode 实测,0 反向)
# ⭐ W3 末改 settlement_money → receivable_this(应收金额,不是实收)
# - settlement_money = 实际收到现金(会员卡扣费/套餐/团购/员工免费时 = 0)
# - receivable_this = 服务应收金额(反映患者带来的真实业务价值)
# - 例:王辉 5 次洁牙 settlement_money=0(扣会员卡)→ 改前 LTV=0 不合理
# receivable_this=¥760+350+200×N(应收)→ 改后真实反映"患者价值"
# - 跟 design.md 患者价值定义对齐:LTV = 患者带来的业务量,不是现金流
amount: receivable_this # FieldMapper.normalize 按 amount_unit=yuan 转 cents
method: payment_channel # transforms.pick_first_nonzero 推断的主导支付通道
orderExternalId: registration_id
doctorId: doctor_id # 收费医生(医患关系信号)
encounterExternalId: registration_id # 关联接诊(语义准:registration_id = 接诊号)
# orderExternalId 不映射:host 没真正的"医嘱单 id"概念,留 null 等其他 host(charge_order)填
......@@ -19,6 +19,8 @@ field_mapping:
occurredAt: rq
code: treat_name # "建议种植" 等 → enum_mapping 翻译到 PAC 推荐码
toothPosition: tooth_position
doctorId: user_id # 建议医生(从 emr 父级继承)
doctorName: doctor_name # 建议医生名(快照)
# treat_name 自由文本,enum_mapping cover 高频"建议 XX"白名单
# 长尾 _default: '' parser 跳过(不入 recommendation_record fact)
......
# referral — 转介绍(老带新 / 渠道归因)
# fact 挂在被推荐人(referee = patient_id);推荐人信息进 content 快照
# 消费 transforms 输出表 referral_rows(从 fact_client_out 过滤有 recommend_id 的行 + derive 外部 id)
canonical: referral
emits:
action: referral_recorded
subjectType: referral
occurredAtField: recordedAt
primary:
table: referral_rows
key: referral_external_id
dedup_by: referral_external_id
field_mapping:
externalId: referral_external_id
patientExternalId: patient_id # 被推荐人(referee)= 该行的 patient_id
# clinicId 不映射:fact_client_out(ADS 聚合)无 organization_id 列,referral 来自主档无明确 clinic
recordedAt: first_visit_time # 近似:推荐发生时间 ≈ 被推荐人首次到诊
referrerExternalId: recommend_id # 推荐人 host id
referrerName: recommend_name # 推荐人名(快照)
referrerChartNumber: recommend_file_num # 推荐人病历号(辅助)
# referrerType / channel:host 没明确字段,留 null(parser 兜底 'patient' 是潜在做法,这里先保真)
# refund — fact_settlement_out 退费明细(独立 fact_type refund_record)
# 消费 transforms 输出表 refund_rows(SQL WHERE is_refund=1 OR settlement_status=4)
#
# 双轨判定 — host 退费两种表达方式都收:
# ① is_refund=1 (显式标记的退费单,settlement_money 通常 > 0 表示"被退金额")
# ② settlement_status=4 (反向结算单,settlement_money 通常 < 0 表示"金额冲减")
# refund.parser 侧 Math.abs(amount) 统一成正 cents(语义 = 患者拿回的钱)
#
# 用途:
# - LTV 算法:value.feature.ts 已 SUM(refund.amount) 扣减 LTV
# - 5 阶段闭环风控:chain-composer.hasPostS3Refund 查"S3 后是否有退费"决定 S5
# - UI 时间轴:详情页"2026-03-15 退费 ¥3000(原因 X)" 独立事件
canonical: refund
emits:
action: refund_created
subjectType: refund
occurredAtField: refundedAt
kind: actual
primary:
table: refund_rows
key: id
dedup_by: id # 同 settlement_id 不应重复(SQL 已去重)
field_mapping:
externalId: id
patientExternalId: patient_id
clinicId: organization_id
# 退费时间 = created_date(结算记录入库时间)— 跟 payment / treatment 口径一致
refundedAt: created_date
# 金额 — yaml 直接映射 settlement_money(可能正可能负);parser 侧 Math.abs() 归一为正 cents
amount: settlement_money
# 反查关联的接诊(同 registration_id 找到对应 payment)
# 注:fact_settlement_out 没有"原 payment_id" 字段,只能靠 registration_id + 业务 reconcile
# 后续 host 若提供 original_settlement_id 再升级 paymentExternalId 映射
# reason 字段 host 暂无 — 待 DW 补"退费原因" 字段后映射
......@@ -18,10 +18,22 @@ field_mapping:
externalId: id
patientExternalId: patient_id
clinicId: organization_id
occurredAt: rq
occurredAt: created_date # W3 末从 billing_date 升级 — 治疗实际发生时间最佳代理。
# 真实语义对比:
# - billing_date = 开单/收费动作时间(团购券预付等场景在治疗前几小时,失真)
# - created_date = 结算记录入库时间(治疗后医生录入,接近实际治疗时点)
# 全量 244 万对验证(瑞泰):
# - 100% created_date >= billing_date(永远是治疗时间下界)
# - 77% 完全相同,23% created 晚于 billing(预付场景)
# - settlement.created vs EMR.created p50 差 5min,51% 在 5min 内
# null/parse 失败:0(271 万行实测)。0.8% case 偏差 > 1 天是 host 后补录,本就该按 created 算。
category: category_raw # transforms.derive 已把 settlement_type 复制到这
subtype: settlement_project_name
status: settlement_type # 留原 settlement_type 文字作 status(parser 不强消费)
doctorId: doctor_id # 执行医生(医患关系信号,100% 充实)
sourceEncounterExternalId: registration_id # 反查接诊(99.999% 充实,之前漏接导致 source_encounter null)
quantity: settlement_num # 治疗份数(种植 4 颗 / 套餐 5 套…)
unitName: settlement_unit_name # 计量单位(颗/次/区/套)
# settlement_type 全枚举(实测 distinct ~45)→ PAC category
# 维护:DW 加新 settlement_type 时跑 SQL `SELECT DISTINCT settlement_type FROM fact_settlement_out`
......
......@@ -22,6 +22,8 @@ field_mapping:
category: category_raw # transforms 已带 treat_name 原文,这里 enum_mapping 翻译
subtype: treat_name # 原始 treatName 留作 subtype
toothPosition: tooth_position
doctorId: user_id # 计划医生(从 emr 父级继承)
doctorName: doctor_name # 计划医生名(快照)
# treatName 实测 21k distinct,top 100 cover 80%。下面是按实测高频 + 临床合理拼出来的白名单。
# 长尾兜底 _default: ""(parser 见空 category 跳过该 fact,数据损失换数据质量)。
......@@ -38,9 +40,16 @@ enum_mapping:
"龈上洁治术/预防性洁治/洁牙/洗牙": periodontic
龈上洁治+牙周治疗: periodontic
全口龈上洁治: periodontic
"全口龈上洁治、抛光。": periodontic
"全口龈上洁治,抛光。": periodontic
"全口龈上洁治,抛光": periodontic
"全口龈上洁治。": periodontic
"全口龈上洁治术。": periodontic
全口洁治: periodontic
"全口洁治。": periodontic
全口洁牙: periodontic
全口超声洁治: periodontic
全口超声波洁治: periodontic
全口龈上超声洁治: periodontic
定期全口洁治: periodontic
"定期全口洁治\r\n": periodontic
......@@ -51,87 +60,186 @@ enum_mapping:
"洁治。": periodontic
洗牙: periodontic
洁治 OHI: periodontic
"洁治+OHI": periodontic
"龈上洁治;细洁": periodontic
"龈上洁治+细洁": periodontic
"龈上洁治+龈下细洁": periodontic
龈下细洁: periodontic
龈上超声洁治: periodontic
牙周基础治疗: periodontic
牙周系统治疗: periodontic
"牙周系统治疗。": periodontic
"OHI\r\n牙周系统治疗": periodontic
牙周治疗: periodontic
牙周维护: periodontic
牙周刮治: periodontic
牙周刮治术: periodontic
龈下刮治: periodontic
龈下刮治术: periodontic
"龈下刮治及根面平整术(>5mm牙周袋)/牙周刮治术/超声龈下清创术": periodontic
"龈下刮治及根面平整术(<5mm牙周袋)/牙周刮治术/超声龈下清创术": periodontic
根面平整术: periodontic
PMTC: periodontic
"PMTC+全口涂氟": periodontic
# ── 充填修复(restorative)
充填: restorative
"充填。": restorative
充填治疗: restorative
"充填治疗(乳牙单面洞)": restorative
充填术: restorative
树脂充填: restorative
光固化树脂充填: restorative
非美学区树脂充填: restorative
美学区简单树脂充填: restorative
"垫底+充填": restorative
重新充填: restorative
预防性充填: restorative
嵌体修复: restorative
嵌体: restorative
戴嵌体: restorative
龋齿充填: restorative
# ── 牙髓 / 根管(endodontic)
根管治疗: endodontic
"根管治疗(磨牙)": endodontic
"根管治疗(前牙及双尖牙单根管)": endodontic
"根管治疗(前牙及双尖牙多根管/显微根管)": endodontic
"根管治疗+冠修复": endodontic
"根管治疗+充填": endodontic
根管再治疗: endodontic
根管预备: endodontic
根管充填: endodontic
根充: endodontic
RCT: endodontic
牙髓治疗: endodontic
根尖诱导成形术: endodontic
根管充填: endodontic
拔髓: endodontic
干髓术: endodontic
盖髓术: endodontic
间接盖髓: endodontic
直接盖髓: endodontic
MTA盖髓: endodontic
冲洗上药: endodontic
# ── 种植(implant)— 注意"种植上部修复"算 prostho
# ── 种植(implant)— 注意"种植上部修复"算 prostho;"种植修复"在 host 文本是合并语,归 implant
种植: implant
种植手术: implant
种植体植入: implant
种植修复: implant
种植戴牙: implant
种植一期: implant
种植二期: implant
种植三期: implant
种植耗材: implant
种植基台: implant
种植取模: implant
种植拆线: implant
"种植冠修复(非美学区单颗)": implant
"简单种植术(非美学区,单颗)": implant
"复杂种植术(非美学区,连续缺失/多颗)": implant
"复杂种植术(非美学区,伴同期上颌窦底内提升/同期GBR术)": implant
"复杂种植术(同期上颌窦底外提升术)": implant
"复杂种植术(美学区连续2颗以上或伴骨量不足及GBR术)": implant
即刻种植术: implant
拔除后种植修复: implant
延期种植术: implant
"延期种植术(美学区,单颗)": implant
# ── 修复(prostho)— 冠 / 桥 / 义齿 / 桩核
冠修复: prosthodontic
"冠修复(非美学区)": prosthodontic
"冠修复(简单美学区)": prosthodontic
全冠修复: prosthodontic
牙冠修复: prosthodontic
重新冠修复: prosthodontic
戴冠: prosthodontic
粘冠: prosthodontic
拆冠: prosthodontic
戴牙: prosthodontic
桩核: prosthodontic
桩核冠: prosthodontic
固定修复: prosthodontic
活动修复: prosthodontic
重新修复: prosthodontic
修复: prosthodontic
预成冠修复: prosthodontic
牙体预备: prosthodontic
取模: prosthodontic
调合: prosthodontic
调颌: prosthodontic
种植上部修复: prosthodontic
种植体上部修复: prosthodontic
# ── 外科(surgical)— 拔除
# ── 外科(surgical)— 拔除 / 智齿 / 残根残冠
拔除: surgical
"拔除。": surgical
拔除术: surgical
拔牙: surgical
牙拔除术: surgical
"牙拔除术(松动乳牙)": surgical
简单牙拔除术: surgical
松动恒牙拔除术: surgical
松动乳牙拔除术: surgical
择期拔除: surgical
阻生齿拔除术: surgical
阻生牙拔除: surgical
"智齿拔除术(正位)": surgical
"智齿拔除术(复杂)": surgical
残根拔除术: surgical
"残根(残冠)拔除术": surgical
"正畸牙拔除术(正位)": surgical
# ── 正畸(orthodontic)
# ── 正畸(orthodontic)— 实测 host treatName 覆盖了"加力 / 安氏分类 / 矫治器/保持器" 全流程
正畸: orthodontic
正畸治疗: orthodontic
隐形正畸: orthodontic
固定正畸: orthodontic
早期矫治: orthodontic
"合垫/预防性矫治/功能矫治/早期阻断性矫治": orthodontic
"简单前牙排齐/安氏I类非拔牙矫治": orthodontic
"安氏I类拔牙矫治(轻中度支抗)": orthodontic
"安氏II类非拔牙矫治-涉及后牙咬合关系调整": orthodontic
"安氏II类拔牙矫治(轻中度支抗)": orthodontic
"安氏II类拔牙矫治(强支抗/种植支抗)": orthodontic
"安氏III类非拔牙矫治-涉及后牙咬合关系调整": orthodontic
"安氏III类拔牙矫治(轻中度支抗)": orthodontic
保持器: orthodontic
正畸保持器: orthodontic
正畸保持: orthodontic
正畸矫治器: orthodontic
正畸辅助治疗: orthodontic
正畸加力: orthodontic
"正畸加力。": orthodontic
加力: orthodontic
粘附件: orthodontic
粘接附件: orthodontic
"粘接全口附件。": orthodontic
重粘托槽: orthodontic
"重粘托槽。": orthodontic
精细调整: orthodontic
"精细调整。": orthodontic
"精调粘接附件。": orthodontic
发放矫治器: orthodontic
"发放新矫治器。": orthodontic
"去除矫正器,配戴保持器保持现有咬合关系。": orthodontic
# ── 预防(preventive)
# ── 预防(preventive)— 涂氟 / 封闭 / 检查
涂氟: preventive
全口涂氟: preventive
"牙面清洁,涂氟": preventive
窝沟封闭: preventive
"窝沟封闭。": preventive
窝沟封闭术: preventive
OHI: preventive
"OHI,涂氟": preventive
"OHI,涂氟": preventive
口腔卫生宣教: preventive
口腔检查: preventive
全口检查: preventive
常规检查: preventive
脱敏: preventive
抛光: preventive
全景片: preventive
CBCT: preventive
曲面断层: preventive
......@@ -140,7 +248,17 @@ enum_mapping:
# ── 美学(cosmetic)
美白: cosmetic
冷光美白: cosmetic
牙齿美白: cosmetic
牙位图美白: cosmetic
贴面: cosmetic
贴面修复: cosmetic
# 长尾兜底:enum_mapping 未命中 → category='',parser 跳过该 fact(silently warn)
# 维护:跑 SQL `SELECT JSONExtractString(t, 'treatName') treat_name, count() FROM
# fact_emr_treatment_out ARRAY JOIN JSONExtractArrayRaw(assumeNotNull(treat_plan)) AS t
# WHERE brand IN ('瑞泰','瑞尔') GROUP BY 1 ORDER BY 2 DESC LIMIT 200`
# 看新出现的高频 treat_name,补到上方对应 category。
# 故意不纳入(归 review / 非治疗 / 流程性 — 已被 transforms.route_by_pattern 分流走):
# 常规复查 / 复查 / 定期复查 / 检查(无具体内容)/ 已交付纸质病历 / 缴费 / 咨询 /
# 听方案 / 沟通治疗方案 / 方案沟通 / 拒绝拍片 / 未拍片 / 治疗中 / 未指定治疗项 / 转诊
_default: ""
......@@ -23,6 +23,8 @@ field_mapping:
category: category_raw # transforms 已固定填 _review_sentinel
subtype: treat_name # 原始动作名("常规复查"/"拆线"等)
toothPosition: tooth_position
doctorId: user_id # 计划医生(从 emr 父级继承)
doctorName: doctor_name # 计划医生名(快照)
enum_mapping:
category:
......
......@@ -62,9 +62,12 @@ timezone: Asia/Shanghai
sql_source:
kind: clickhouse
connection:
url: http://cc-2zen8w29e49076o83.public.clickhouse.ads.aliyuncs.com:8123
# 本地 DW 镜像(docker-compose clickhouse,全量复制自瑞尔 DW)。
# 切回远程 DW:url 换成 http://cc-2zen8w29e49076o83.public.clickhouse.ads.aliyuncs.com:8123
# username: jvs_pac,password_env 设 DW 真实密码。
url: http://localhost:8123
database: dw_group
username: jvs_pac
username: default
password_env: DW_CLICKHOUSE_PASSWORD
# 1000 cohort 患者 settlement 总行 ~ 20 万(p50=12/患者,max=11.6 万/异常患者)
# 100k 会截一半数据,关键事实(吴建康 61 行种植结算)被丢失 → scenario 错召回
......@@ -82,7 +85,7 @@ sql_source:
FROM dw_group.fact_client_out
WHERE last_visit_time IS NOT NULL
ORDER BY last_visit_time DESC
LIMIT 1000
LIMIT 100
# ── 预约(全部状态;in_time NULL 的在 transforms.route 时分流跳过 encounter) ──
fact_appointment_out: |
......@@ -91,34 +94,57 @@ sql_source:
WHERE (patient_id, brand) IN (
SELECT patient_id, brand FROM dw_group.fact_client_out
WHERE last_visit_time IS NOT NULL
ORDER BY last_visit_time DESC LIMIT 1000
ORDER BY last_visit_time DESC LIMIT 100
)
# ── EMR 全字段(自由文本 + diag/treat_plan JSON,后续 transforms 拆) ──
fact_emr_treatment_out: |
SELECT
id, patient_id, organization_id, brand, rq, registration_id,
illness_desc, pre_illness, past_hist, examine, dispose, doc_order,
id, patient_id, organization_id, brand, rq, registration_id, user_id, doctor_name,
illness_desc, pre_illness, past_hist, gen_cond, examine, dispose, doc_order,
diag, treat_plan, plan, file_url
FROM dw_group.fact_emr_treatment_out
WHERE (patient_id, brand) IN (
SELECT patient_id, brand FROM dw_group.fact_client_out
WHERE last_visit_time IS NOT NULL
ORDER BY last_visit_time DESC LIMIT 1000
ORDER BY last_visit_time DESC LIMIT 100
)
# ── 结算明细(已做治疗反推;退费/未完成结算行 PAC 不消费) ──
# created_date(结算记录入库时间)比 billing_date(开单/收费动作)更接近治疗实际发生:
# - 100% created_date >= billing_date(永远不会更早)
# - 23% case 团购券/储值/挂号预付,billing_date 在治疗前几小时,created_date 才是医生录治疗的时点
# - 全量 244 万对验证:settlement.created vs EMR.created p50 差 5min,51% 在 5min 内
fact_settlement_out: |
SELECT id, organization_id, brand, patient_id, rq, settlement_type,
settlement_project_name, settlement_money, registration_id,
is_refund, settlement_status
SELECT id, organization_id, brand, patient_id, rq, billing_date, created_date,
settlement_type, doctor_id,
settlement_project_name, settlement_money, settlement_num, settlement_unit_name,
registration_id, is_refund, settlement_status
FROM dw_group.fact_settlement_out
WHERE (is_refund IS NULL OR is_refund = 0)
AND settlement_status = 1
AND (patient_id, brand) IN (
SELECT patient_id, brand FROM dw_group.fact_client_out
WHERE last_visit_time IS NOT NULL
ORDER BY last_visit_time DESC LIMIT 1000
ORDER BY last_visit_time DESC LIMIT 100
)
# ── 退费明细(独立 fact_type refund_record;LTV 算法 - refund;S5 闭环风控)──
# 双轨判定 — host 退费两种表达方式都收:
# ① is_refund=1 (显式标记的退费单,settlement_money 通常 > 0 表示"被退金额")
# ② settlement_status=4 (反向结算单,settlement_money 通常 < 0 表示"金额冲减")
# parser 侧 Math.abs(amount) 统一成正 cents(语义 = 患者拿回的钱)
fact_settlement_out_refund: |
SELECT id, organization_id, brand, patient_id, rq, billing_date, created_date,
settlement_type, doctor_id,
settlement_project_name, settlement_money,
registration_id, is_refund, settlement_status
FROM dw_group.fact_settlement_out
WHERE (is_refund = 1 OR settlement_status = 4)
AND (patient_id, brand) IN (
SELECT patient_id, brand FROM dw_group.fact_client_out
WHERE last_visit_time IS NOT NULL
ORDER BY last_visit_time DESC LIMIT 100
)
# ── 支付通道明细(17 列 pay_*,后续 transforms.pick_first_nonzero 推) ──
......@@ -130,7 +156,7 @@ sql_source:
AND (patient_id, brand) IN (
SELECT patient_id, brand FROM dw_group.fact_client_out
WHERE last_visit_time IS NOT NULL
ORDER BY last_visit_time DESC LIMIT 1000
ORDER BY last_visit_time DESC LIMIT 100
)
# ─────────────────────────────────────────────────────────────
......@@ -148,9 +174,12 @@ transforms:
- brand
- rq
- registration_id
- user_id
- doctor_name
- illness_desc
- pre_illness
- past_hist
- gen_cond
- examine
- dispose
- doc_order
......@@ -169,6 +198,8 @@ transforms:
organization_id: organization_id
brand: brand
rq: rq
user_id: user_id # 诊断医生(继承 emr 父级,医患关系信号)
doctor_name: doctor_name # 诊断医生名(快照)
element_fields:
std_code: stdCode
message: message
......@@ -213,6 +244,8 @@ transforms:
organization_id: organization_id
brand: brand
rq: rq
user_id: user_id # 计划医生(继承 emr 父级)
doctor_name: doctor_name # 计划医生名
element_fields:
treat_name: treatName
tooth_position: toothPosition
......@@ -286,13 +319,46 @@ transforms:
from: treat_name
value: ''
# ── E. EMR.file_url JSON 拆行 → 影像 metadata(image_record) ──
# file_url 元素:{ check_name(影像类型), file_url(存储路径), created_gmt_at(拍摄时间) }
# PAC 不持文件本体,只落 metadata:类型 / 时间 / 关联接诊 → "拍过 CBCT=种植意向" 类信号
- kind: split_json_array
input: fact_emr_treatment_out
output: _image_raw
array_field: file_url
parent_keys:
emr_id: id
patient_id: patient_id
organization_id: organization_id
brand: brand
registration_id: registration_id
user_id: user_id # 接诊医生(影像通常在接诊时拍)
doctor_name: doctor_name
element_fields:
check_name: check_name
file_path: file_url
captured_at: created_gmt_at
where:
file_path: { not_empty: true }
# E.1 派生 image external_id(emr_id + 路径,跨数组元素保唯一)
- kind: derive
input: _image_raw
output: image_rows
fields:
image_external_id:
op: concat
parts: ['${emr_id}', '|img|', '${file_path}']
# ── D. settlement_out → treatment_actual_rows ──
# SQL 已 WHERE is_refund=0 + status=1;此处不再拆,直接 project rename 准备 enum_mapping
- kind: project
input: fact_settlement_out
output: _treatment_actual_raw
keep: [id, organization_id, brand, patient_id, rq, settlement_type,
settlement_project_name, settlement_money, registration_id]
keep: [id, organization_id, brand, patient_id, rq, billing_date, created_date,
settlement_type, doctor_id,
settlement_project_name, settlement_money, settlement_num, settlement_unit_name,
registration_id]
rename:
settlement_id: id # 跟下游 yaml field_mapping 对齐
......@@ -306,6 +372,16 @@ transforms:
from: settlement_type
value: ''
# ── D.2 退费 → refund_rows ──
# SQL 已 WHERE is_refund=1 OR status=4;此处 project 准备 refund.yaml 消费。
# refund.parser 侧 Math.abs(amount) 把 status=4 负 money 和 is_refund=1 正 money 都归一成"被退金额"正 cents。
- kind: project
input: fact_settlement_out_refund
output: refund_rows
keep: [id, organization_id, brand, patient_id, rq, billing_date, created_date,
settlement_type, doctor_id, settlement_project_name, settlement_money,
registration_id, is_refund, settlement_status]
# ── E. settlement_mode_out → payment_rows ──
# 17 列 pay_* → payment_channel 单列(代替 SQL multiIf)
- kind: pick_first_nonzero
......@@ -350,11 +426,38 @@ transforms:
where:
in_time: { not_empty: true }
# F.3 全部预约:project 直通(其实可以直接消费 fact_appointment_out;这里复制一份给 yaml 清晰)
- kind: project
# F.3 全部预约:拼精确预约时刻(appo_time 只到天,时分在 appo_datetime_str "13:00:00-14:00:00")
# appo_start_time = datetime_str 起始 8 位("13:00:00");scheduled_at_ts = 日期 + 起始时分
# datetime_str 为空时退化为 "YYYY-MM-DD "(尾空格,Date.parse 容忍)
- kind: derive
input: fact_appointment_out
output: appointment_all_rows
# keep 不写 = 全保留
fields:
appo_start_time:
op: substring
from: appo_datetime_str
start: 0
end: 8
scheduled_at_ts:
op: concat
parts: ['${appo_time}', ' ', '${appo_start_time}']
# ── G. fact_client_out → referral_rows(只取有 recommend_id 的行,即"被推荐来"的患者)──
# fact_client_out 一行 = 一个患者;recommend_id 非空 → 该患者是被推荐来的 → 产 1 条 referral_record
- kind: filter
input: fact_client_out
output: _referral_filtered
where:
recommend_id: { not_empty: true } # null / 0 / '' 都过滤掉
- kind: derive
input: _referral_filtered
output: referral_rows
fields:
# 推荐事件 id 合成:被推荐人 + 推荐人(同一被推荐人理论上只一条推荐关系)
referral_external_id:
op: concat
parts: ['${patient_id}', '|ref|', '${recommend_id}']
# ─────────────────────────────────────────────────────────────
# PAC 写的 assembler 配置(每个 canonical resource 一份)
......@@ -369,4 +472,11 @@ assemblers:
- { file: assemblers/treatment_actual.yaml }
- { file: assemblers/recommendation.yaml }
- { file: assemblers/emr.yaml }
- { file: assemblers/image.yaml }
# referral 暂不加载:jvs-dw 唯一可拼出 referral 的源是 fact_client_out(ADS 聚合,无 organization_id),
# 而 PAC patient_transactions.clinic_id 是立柱必填(架构原则不放宽 — 不为单 host 凑活)。
# PAC referral_record 主体定义保留(schema / parser / canonical / yaml 都齐全),
# 等其他 host 提供 DWD 明细级转介绍表(带 clinic_id)时启用。已记 DW 问题清单 #9。
# - { file: assemblers/referral.yaml }
- { file: assemblers/payment.yaml }
- { file: assemblers/refund.yaml }
-- AlterTable
ALTER TABLE "patients" ADD COLUMN "medical_record_number" TEXT;
......@@ -179,6 +179,11 @@ model Patient {
gender String?
/// 出生日期
birthDate DateTime? @map("birth_date") @db.Date
/// 病历号 host 病历主键(纸质档案号),辅助标识。跟 externalId 互补:
/// externalId = host 内部业务 id(系统主键,稳定但对人无意义)
/// medicalRecordNumber = 纸质档案 / 客服沟通用的可读编号( "BA43016")
/// 普适字段(任何医疗系统都有病历号)host 不提供时留 null
medicalRecordNumber String? @map("medical_record_number")
/// 偏好(沟通时间、语言、就诊偏好等)。形状自由,应用层 zod 校验:
/// { contactWindow?: {start,end}, language?, preferredChannel?, ... }
preferences Json?
......@@ -798,6 +803,11 @@ model PlanReason {
/// 应用层用 @pac/types PlanScenario enum 校验。后续扩展时在 enum 加新值即可。
scenario String
/// 子规则 key(scenario 内细分):missing_tooth / caries_no_filling / perio_no_srp / endo_no_rct
/// plan scenario 下不同 sub_key 1 (每个独立临床缺口独立 evidence)
/// nullable 兼容历史: plan_reasons scenario 级合并的(sub_key=null)
subKey String? @map("sub_key")
/// reason 在本次 inclusion 的得分(子场景基线 + value/risk/recency bonus 加权)
/// plan.priorityScore = MAX over plan_reasons.priority_score(客服列表排序冗余字段)
priorityScore Float @map("priority_score")
......@@ -807,8 +817,16 @@ model PlanReason {
/// 至今 60 天未见 crown 治疗记录或冠修复预约。"
reason String @db.Text
/// 证据:patient_facts.id 数组(决策依据)MIGRATION:GIN 索引
evidence String[] @map("evidence_fact_ids") @db.Uuid
/// 证据(W3 末改 JSON 统一, PersonaFeature.evidence 对齐):
/// {
/// factIds: string[], // 必填,patient_facts.id 数组
/// agentInvocationIds?: string[], // LLM 抽取来源(留位,W5+ recommendation )
/// ruleIds?: string[] // scenario 版本(留位)
/// }
/// UUID[] @db.Uuid 已淘汰 单一 factIds 形态限制了多源融合表达; jsonb 一统。
/// 反查"哪些 plan 因 X fact 命中"频率低 + plan_reasons 行级小,不再立 GIN(真要查可建 jsonb GIN)
/// 应用层 zod 校验形状(对齐 PersonaFeature.evidence 结构)
evidence Json @map("evidence")
/// **v2.1 算法可解释性** 6 因子优先级 breakdown + 规则版本号 + 抽取置信度。
/// 详情页用本字段渲染"为什么 73 分":
......@@ -823,6 +841,19 @@ model PlanReason {
/// 应用层 zod 校验(`ScenarioHit.priorityBreakdown` 类型);ScenarioHit 没产 breakdown 时为 null
breakdown Json? @map("breakdown")
/// **W3 末新增** 结构化召回信号(前端富文本渲染数据源)
/// DB 存原始 enum / canonical code,**不语义化**(中文 label 走前端字典翻译):
/// {
/// subKey: "missing_tooth",
/// triggers: [{ type: "diagnosis", code: "K08" }], // 数组支持多源融合
/// toothPosition: "21" | null,
/// daysSince: 149,
/// expectedCategories: ["implant", "prosthodontic"]
/// }
/// factId evidence_fact_ids 立柱字段(GIN 索引),避免重复存。
/// schema @pac/types ReasonSignalsSchema 强校验。
signals Json? @map("signals")
/// 产品收集(回访中心信息字段.docx):reason 生命周期类型 决定何时自动失效。
/// one_shot 短效 成因消失即应 close(待治疗治了 / 余款付了 / 续治完成)
/// recurring 长效 周期性触发(年度复查 / 半年定期洁牙 / 牙周维护)
......@@ -857,8 +888,9 @@ model PlanReason {
plan FollowupPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
/// plan 内每个 scenario 最多一条 reason(避免重复 inclusion)
@@unique([planId, scenario])
/// plan 内每个 (scenario, sub_key) 最多一条 reason
/// (PG 默认 nullable UNIQUE 里多个 null 算不同 兼容老数据 sub_key=null;新数据都填)
@@unique([planId, scenario, subKey])
/// 反查"哪些 plan 因 X scenario 命中"
@@index([scenario])
/// 产品收集:运营批量场景的查询(找该 batch 涉及哪些 plan)
......
......@@ -47,7 +47,7 @@ export function loadConfig(): AppConfig {
ai: {
deepseekApiKey: process.env.DEEPSEEK_API_KEY ?? '',
deepseekBaseUrl: process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com',
defaultModel: process.env.AI_DEFAULT_MODEL ?? 'deepseek-v4-pro',
defaultModel: process.env.AI_DEFAULT_MODEL ?? 'deepseek-v4-flash',
requestTimeoutSec: Number(process.env.AI_REQUEST_TIMEOUT_SEC ?? 60),
priceTable: parsePriceTable(process.env.AI_PRICE_TABLE_JSON),
},
......
......@@ -12,6 +12,7 @@ import {
/**
* Safety rules — 后置硬约束。
* LLM 偶尔会越过 schema 约束塞禁词,这里再补一道(防御性)。
* B 方案重写后,output 4 段都是 markdown 字符串,直接全文 join 扫禁词。
*/
const FORBIDDEN_PHRASES = [
'一定能', '保证', '绝对', '百分百', '100%',
......@@ -24,13 +25,7 @@ const safetyRules: ReadonlyArray<SafetyRule<DraftPlanScriptOutput>> = [
name: 'no_forbidden_phrases',
severity: 'block',
check(output) {
const fullText = [
output.opening,
output.keyMessage,
output.followup,
output.callToAction,
...output.objectionHandling.flatMap((o) => [o.objection, o.response]),
].join('\n');
const fullText = [output.opening, output.followup, output.objection, output.close].join('\n');
const hit = FORBIDDEN_PHRASES.filter((p) => fullText.includes(p));
return {
pass: hit.length === 0,
......@@ -39,36 +34,70 @@ const safetyRules: ReadonlyArray<SafetyRule<DraftPlanScriptOutput>> = [
},
},
{
name: 'call_to_action_has_time',
severity: 'warn', // warn 不阻断,只记日志,够多了再升 block
name: 'close_has_concrete_time',
severity: 'warn', // warn 不阻断,只记日志
check(output) {
// 简单启发:callToAction 应该包含数字(时间点)
const hasDigit = /\d/.test(output.callToAction);
return { pass: hasDigit, message: hasDigit ? undefined : 'callToAction 未含具体时间数字' };
// 启发式:结束段 markdown 必须含数字(具体时间点 / 日期)
const hasDigit = /\d/.test(output.close);
return { pass: hasDigit, message: hasDigit ? undefined : 'close 段未含具体时间数字' };
},
},
{
name: 'objection_uses_h3_blocks',
severity: 'warn',
check(output) {
// 启发式:objection 段必须按 ### A./B./C. 子标题切分
const hasH3 = /^###\s+[A-D]\./m.test(output.objection);
return { pass: hasH3, message: hasH3 ? undefined : 'objection 段未按 ### A./B. 子标题切分' };
},
},
];
/**
* 降级 fallback —— LLM 失败 / safety 拒收时用。
* 用 input 直接拼一份模板话术,保证客服一定有东西可用。
* 用 input 直接拼一份 4 段 markdown 模板话术,保证客服一定有东西可用。
*/
function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
const { patient, clinicName, plan, clinicalContext } = input;
const dayPart = clinicalContext.daysSinceLastVisit
? `已经 ${clinicalContext.daysSinceLastVisit} 天没见您了`
: '有段时间没见您了';
const lastSummary = clinicalContext.lastVisitSummary ?? '复查相关安排';
return {
opening: `${patient.nameMasked}您好`,
keyMessage: `我是${clinicName}的客服,${dayPart},想跟您约个时间复查一下。`,
followup: `根据病历记录,本次想跟您沟通的是:${plan.primaryScenarioLabel}。复查不需要太久,大概 20 分钟就能完成。您看本周末或下周初哪个时间方便?`,
objectionHandling: [
{ objection: '最近没时间', response: '理解,可以约到下个月,提前预约能避开排队。' },
{ objection: '再考虑一下', response: '好的,不过早处理对后续治疗效果更好,有问题随时联系我。' },
],
callToAction: '本周六上午 10 点或下周一晚上 7 点,您看哪个更合适?',
tone: 'warm',
opening: `**目的**:亲切自然地建立通话,围绕「${plan.primaryScenarioLabel}」开场,避免推销感。
> "${patient.nameMasked}您好,我是${clinicName}的客服。${dayPart},今天给您打电话,主要是想跟您同步上次的${lastSummary},您现在方便聊几分钟吗?"
**注意**
- 称呼用「${patient.nameMasked}」,不用全名
- 若不方便,主动询问合适回拨时间`,
followup: `**目的**:把诊所记录的临床事实,自然引到「该回来做点什么」。
> "根据病历记录,本次想跟您沟通的是:${plan.primaryScenarioLabel}。复查不需要太久,大概 20 分钟就能完成。"
> "我们这边可以帮您安排医生面诊评估,**这次只是评估和确认方案,不做任何操作**,您看本周末或下周初哪个时间方便?"
**异议预判**
- "最近没时间" → 提供更灵活的时间窗(早班/晚班)
- "再考虑一下" → 强调诊后复查窗口期,过期可能要重新评估
- "已在外院看过" → 提交「已在外院治疗」并关闭召回`,
objection: `### A. "我再考虑考虑"
> "完全理解。这样,我先帮您把医生的面诊时间留出来,**本周六上午 10 点或下周一晚上 7 点**,您选一个?到现场看了方案再决定也不晚。"
### B. "最近真的没时间"
> "理解,可以约到下个月,提前预约能避开排队。您下周或下下周哪天比较方便?我先帮您预留。"
### C. "已在别的医院看了"
> "好的${patient.nameMasked},那我这边帮您把这条记录关一下,日常护理还是按原来的周期回来就行,**祝您一切顺利**。"
> → 提交结果选「已在外院治疗」`,
close: `> "好的${patient.nameMasked},那我帮您约 **本周六上午 10 点**,到时候提前 10 分钟到前台就行。我会给您发个短信提醒,您注意接收。还有别的需要么?"
**回写要点**
- 成功约上面诊 → 提交结果选「成功转化为新预约」,填预约时间 + 医生
- 同意但未定日期 → 选「约定下次回访」,填预计时间
- 考虑中 → 选「考虑中近期再跟进」,7 天后系统提醒二次跟进`,
};
}
......@@ -79,7 +108,7 @@ export class DraftPlanScriptCall
readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script';
readonly promptVersion = DRAFT_PLAN_SCRIPT_PROMPT_VERSION;
readonly defaultModelId = 'deepseek-v4-pro';
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DraftPlanScriptSchema;
readonly safetyRules = safetyRules;
......
......@@ -41,36 +41,40 @@ export interface DraftPlanScriptInput {
clinicalContext: {
daysSinceLastVisit: number | null;
lastVisitSummary: string | null; // 上次到店做了什么(一句话)
pendingTreatments: string[]; // 待做治疗(简短描述,如"右上 16 缺失牙待修复")
pendingTreatments: string[]; // 待做治疗(简短描述,牙位已转患者俗称,如"缺失牙(下门牙)")
treatmentChainSummary: string | null; // 治疗链当前阶段一句话
/** 主诊医生名(从最近 treatment/diagnosis fact 抽);LLM 必须用此名,不可编造 */
primaryDoctorName: string | null;
};
}
/**
* AiCall 输出契约。
* AiCall 输出契约(B 方案 · 2026-05-24 重写)
*
* 注意:这是 LLM 直接输出的结构;orchestrator 会把它**渲染成 Markdown**
* 写入 PlanScript.content(schema 是 String? 存 Markdown,不存 JSON)。
* LLM 直接输出 4 段 Markdown 字符串 + tone tag。
* orchestrator 把每段直接映射到前端 ScriptSection,**不再做模板拼接**;
* 段内子结构(`**目的**:` / `**注意**` / `**异议预判**` / `**回写要点**` / `### A.` ...)
* 由 LLM 按 schema.ts 的 .describe() 契约自己写。
*
* 为什么不让 LLM 直接出 Markdown:
* 1. 结构化输出便于 safety gate 按字段校验(opening 是否含禁词 / objection 是否 ≤3 条等)
* 2. 渲染层可以独立调整模板(改格式不动 LLM)
* 3. 调试页可以分字段展示 / 对比版本差异
* 为什么从"结构化字段+后端拼模板" 改成"LLM 直出 Markdown":
* - 段内子结构需求(目的/正文/注意)模板化后失去个性化,套话感重
* - 4 段独立 markdown 字段对 zod 仍是强校验(长度 + .describe() 格式契约)
* - 流式仍然可行(generateObject partial 阶段每段 string 逐字符流出)
* - Flash 模型友好 — schema 字段少,LLM 单字段长 markdown 比多字段嵌套对象更稳
*/
export interface DraftPlanScriptOutput {
/** 开场白(15 字内,带称呼) */
/** 整体语气标签(给客服参考) */
tone: 'warm' | 'professional' | 'urgent';
/** 开场段 markdown:**目的** + > 通话片段 + **注意** bullet */
opening: string;
/** 核心传达信息(60 字内,说明为什么联系) */
keyMessage: string;
/** 跟进话术(完整对话片段,200 字内) */
/** 切入话题段 markdown:**目的** + 1-2 段 > 对话 + **异议预判** bullet */
followup: string;
/** 异议处理(最多 3 条) */
objectionHandling: Array<{
objection: string;
response: string;
}>;
/** 明确下一步动作(给 2 个具体时间选项) */
callToAction: string;
/** 语气标签(给客服参考) */
tone: 'warm' | 'professional' | 'urgent';
/** 异议处理段 markdown:3-4 个 ### A./B./C./D. 子标题 + > 应对 */
objection: string;
/** 结束·信息确认段 markdown:> 确认话术 + **回写要点** bullet */
close: string;
}
......@@ -11,32 +11,84 @@ import type { DraftPlanScriptInput } from './input.types';
* 即可对比版本效果。
*
* 历史:
* - 2026-05-17-a — 初版,deepseek-v4-pro
* - 2026-05-17-a — 初版,deepseek-v4-pro,5 字段(opening/keyMessage/followup/objectionHandling/callToAction)
* - 2026-05-24-b — B 方案重写,deepseek-v4-flash,4 段直出 markdown
* + few-shot demo,对齐前端 mockScript 4 段格式
* - 2026-05-24-c — 事实漂移修:补医生名/牙位俗称/诊所名硬约束
* few-shot 改用 {占位符} 防止抄具体名字
* - 2026-05-24-d — 称呼用通话名(姓+先生/女士);明禁念 scenario 内部 label;
* 要求 opening/followup 引用 ≥1 / ≥2 条具体临床事实
*/
export const DRAFT_PLAN_SCRIPT_PROMPT_VERSION = 'draft_plan_script@2026-05-17-a';
export const DRAFT_PLAN_SCRIPT_PROMPT_VERSION = 'draft_plan_script@2026-05-24-d';
/**
* System prompt(稳定指令,不随 input 变)。
* 写指令时优先用"必须 / 禁止 / 不得"这类强约束词,LLM 遵循度更高。
*
* 设计原则(B 方案):
* - 让 LLM 直出 markdown,格式契约由 schema.ts .describe() 强约束 + system 反例提示
* - Flash 模型偏好"清晰指令 + 至少 1 个完整 few-shot",所以下面 user prompt 末尾会带 1 个完整 example
* - 强约束词("必须"/"禁止"/"不得") + 反例段("❌ 错示范") Flash 遵循度提升明显
*/
export const DRAFT_PLAN_SCRIPT_SYSTEM = `你是某连锁牙科诊所的资深客服顾问,有 10 年外呼经验,擅长在不显得推销的前提下,自然地把患者请回诊所复诊。
# 核心原则
1. 语气温和、专业,不要"销售感"。患者是来看牙的,不是被推销的。
2. 必须提到具体临床事实(治疗阶段、牙位号、上次到店做的事),让患者感受到诊所记得 ta。
3. 禁止承诺疗效("一定能治好"/"百分百"等绝对化用语,医疗合规红线)。
4. 禁止使用"亲"、"宝"等口语化称呼;统一用"X 先生/女士"。
5. callToAction 必须给具体时间选项,不能只说"有空过来";患者要能立刻回"好,周六可以"。
6. 异议处理覆盖最常见的几种(没时间/再考虑/价格疑虑),不要面面俱到。
# 输出约束
严格按提供的 JSON schema 输出,不要加任何 schema 以外的字段。所有文案使用简体中文。`;
2. 必须提到具体临床事实(治疗阶段、上次到店做的事),让患者感受到诊所记得 ta。
3. 禁止承诺疗效("一定能治好"/"百分百"/"绝对有效" 等绝对化用语,医疗合规红线)。
4. 禁止使用"亲"、"宝"、"小哥哥/小姐姐" 等口语化称呼;统一用"X 先生/女士"。
5. 必须给具体时间选项(如"周四晚上 7 点或周六上午"),不能只说"有空过来";患者要能立刻回"好,周六可以"。
6. 异议处理覆盖最常见的几种(没时间/再考虑/价格疑虑/已在外院),不要面面俱到。
# 事实约束(绝不可编 — 患者一听就穿帮)
- **称呼**:严格使用 user prompt「患者.称呼」给的字符串(已是"X 先生/女士" 通话名),不得改成"亲""帅哥""路总"等,也不能用"路*""路星"等带掩码/全名
- **诊所名**:严格使用 user prompt「诊所.名称」给的字符串,不得简称/改字/补"中心""旗舰店"等任何字
- **医生名**:如果 user prompt「主诊医生」有值,统一用此名称呼(如"王医生");没有则用"您的主诊医生"泛指,绝不要编"李医生""张主任"
- **牙位**:对患者只能说俗称(上门牙/下门牙/智齿/大牙/前牙/后牙/虎牙),禁止说 FDI 数字编号("21""36""47" 这种患者完全听不懂)
- user prompt「待做治疗」已经把牙位翻译成俗称,直接照抄;若有补充牙位也只能用俗称
- **数字日期**:不要编未给出的具体日期(如"5/21"),给时间选项时用"本周X / 下周X 晚上 X 点" 这种相对说法
- **召回场景代号**(⭐ 重要):user prompt「召回场景.主场景」字段是**内部分类编码**(如"启治召回"、"治疗后复诊召回"),**绝对不能在话术中念出**!患者听不懂"启治召回"是什么意思,会立即觉得是机器外呼。
- opening 段必须以"具体临床事实"开场,例如:"上次{主诊医生}给您看牙时提到{待做治疗或上次到店}"、"您 X 月在我们这里检查过 {上次到店}",而不是"今天给您打电话是因为「启治召回」"
# 让患者感受"诊所记得我"(必须做到)
opening + followup 两段加起来,**必须自然引用至少 3 条** user prompt 给的具体临床事实,可选项:
- 上次到店时间(如"上周/上个月/X 月")
- 上次到店做了什么(lastVisitSummary)
- 主诊医生姓名(若有)
- 待做治疗具体内容(pendingTreatments,含俗称牙位)
- 待做治疗的临床后果(如"再拖可能要正畸/补骨,流程更长")
- 触发原因里的临床信号(如"距诊断已 X 天")
不要只说"有段时间没见您了" / "想跟您约时间复查" 这种万能空话 — 这样的话客服自己都说不出口。
# 输出约束(B 方案 · 重要)
你只输出 1 个 JSON 对象,包含 5 个 key:tone / opening / followup / objection / close。
opening / followup / objection / close 这 4 个字段的值都是 **Markdown 字符串**,每段必须严格按 schema 中 .describe() 指定的子结构格式(目的/正文/注意/异议预判/回写要点)。
# ❌ 错示范(必须避免)
- opening 段缺 \`**目的**:\` 开头 → 客服无法快速 scan 目的
- followup 段把异议应对话术写完整 → 跟 objection 段重复
- objection 段用 bullet \`- ...\` 列异议 → 必须用 \`### A. "xxx"\` 子标题
- close 段缺 \`**回写要点**\` → 客服不知道结果该怎么提交
- blockquote 里写排比抒情("您的健康是我们最大的牵挂...") → 假销售感
- ❌ "牙位 21""牙位 36""左上 26 牙" → 患者听不懂 FDI 牙位号
- ❌ "李医生""王主任"(user prompt 未给医生名时) → 编造医生名穿帮
- ❌ "本周四(5/21)" → 编造未给出的具体日期
- ❌ "围绕「启治召回」开场" / "本次想跟您沟通的是:启治召回" → 把内部 scenario label 念给患者听
- ❌ "路*您好" / "段*先生您好" → 用了掩码字符,通话名必须从 user prompt「患者.称呼」整体照抄
# ✅ 好示范的特征
- 开场白 30 秒内讲清楚"我是谁 / 为什么打 / 现在方不方便"
- 切入话题用"上次李医生 X 月给您拍了 CBCT" 这类硬事实开门
- 结束确认时复述"周四(5/21)晚 7:00 李医生" 完整信息
所有文案使用简体中文。只输出 JSON,不要任何解释。`;
/**
* 业务 prompt — 装配 input 成具体上下文。
*
* 设计:把患者信息以"病历摘要"风格组织,而不是堆 key:value,
* LLM 对自然语言上下文比对 JSON 上下文更稳。
* 设计:
* - 把患者信息以"病历摘要"风格组织,LLM 对自然语言上下文比对 JSON 上下文更稳
* - 末尾带 1 个完整 few-shot example(精简版,展示 4 段 markdown 格式) — Flash 对 example 学习快
*/
export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string {
const { patient, clinicName, plan, personaHighlights, clinicalContext } = input;
......@@ -65,11 +117,11 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
- 性别:${patient.gender ?? '未知'}
- 年龄:${patient.age ?? '未知'}
## 召回场景
- 主场景:${plan.primaryScenarioLabel}
## 召回场景(⚠️ 内部分类,**不要在话术中念出**)
- 主场景代号:${plan.primaryScenarioLabel}(给客服理解任务性质用,患者听不懂)
- 综合优先级:${plan.priorityScore}
## 触发原因
## 触发原因(给客服理解为什么挑这个患者;话术里要用更通俗的说法引出)
${reasonsLines}
## 患者画像关键特征
......@@ -78,12 +130,32 @@ ${personaLines}
## 临床上下文
- 距上次到店:${clinicalContext.daysSinceLastVisit ?? '未知'}
- 上次到店:${clinicalContext.lastVisitSummary ?? '无记录'}
- 主诊医生:${clinicalContext.primaryDoctorName ?? '(未知 — 话术中用「您的主诊医生」泛指,不要编名)'}
- 治疗链状态:${clinicalContext.treatmentChainSummary ?? '无数据'}
- 待做治疗:
- 待做治疗(牙位已转患者俗称,直接照抄):
${pendingLines}
# 任务
请为这通外呼电话准备话术。**必须** keyMessage followup 中至少引用一条上述临床事实(治疗阶段 / 牙位 / 上次内容),不要只说"有段时间没见您了"这种空话。
请为这通外呼电话准备话术。**必须** followup 段引用至少一条上述临床事实(治疗阶段 / 上次内容 / 上面给的待做治疗),不要只说"有段时间没见您了"这种空话。
**事实约束硬要求**:诊所名/医生名/牙位俗称严格按上面给的字段,**不要编**。若主诊医生为未知,"您的主诊医生" / "医生" 泛指。
# 输出格式参考(精简 few-shot,只看格式骨架 文字内的 {占位} 必须用 user prompt 真实字段替换)
\`\`\`json
{
"tone": "warm",
"opening": "**目的**:亲切自然地建立通话,用「上次到店」做切入,避免推销感。\\n\\n> \\"您好,请问是{患者称呼}吗?我是{诊所名}的客服。{您上次/X 月在我们这边检查时},{您的主诊医生}提到{1 条具体临床事实/待做治疗简述},我今天给您打过来想跟您再聊一下,您现在方便几分钟吗?\\"\\n\\n**注意**\\n- 称呼用「{患者称呼}」,不用全名/掩码\\n- 工作日 19:00 后是患者偏好窗口",
"followup": "**目的**:把诊所记录的{临床事实},自然引到「该来一趟了」。\\n\\n> \\"上次{您的主诊医生}给您检查时提到{临床事实+牙位俗称}。如果再拖,后面治疗的流程会更复杂、成本也更高。\\"\\n\\n> \\"我们想约您近期来做个面诊评估,**这次只是评估和确认方案,不做任何操作,大概 30 分钟**,您看本周或下周哪天方便?\\"\\n\\n**异议预判**\\n- \\"再等等\\" → 强调时间窗(再拖可能要做额外处理)\\n- \\"费用高\\" → 引导先面诊,方案出来后再算费用\\n- \\"在外院看过\\" → 提交「已在外院治疗」并关闭本次召回",
"objection": "### A. \\"我再考虑考虑\\"\\n> \\"完全理解,这是个不小的决定。这样,我先帮您把{您的主诊医生}的面诊时间留出来,**本周六上午或下周一晚上 7 点**,您选一个?到现场看了方案再决定也不晚。\\"\\n\\n### B. \\"价格太贵\\"\\n> \\"具体价格要看实际情况,{您的主诊医生}面诊后会给您 2-3 个方案,有不同档位可选。您先来评估,价格不合适咱们再聊别的方式。\\"\\n\\n### C. \\"已经在别的医院看了\\"\\n> \\"好的{患者称呼},那我这边帮您把这条记录关一下,日常护理还是按原来的周期回来就行,**祝您一切顺利**。\\"\\n> → 提交结果选「已在外院治疗」",
"close": "> \\"好的{患者称呼},那我帮您约 **本周六上午 10 点**,到时候提前 10 分钟到前台就行。我会给您发个短信提醒,您注意接收。还有别的需要么?\\"\\n\\n**回写要点**\\n- 成功约上面诊 → 提交结果选「成功转化为新预约」,填预约时间 + 医生\\n- 同意但未定日期 → 选「约定下次回访」,填预计时间\\n- 考虑中 → 选「考虑中近期再跟进」,7 天后系统提醒二次跟进"
}
\`\`\`
⚠️ few-shot 里的 \`{占位}\` 必须替换成 user prompt 给的真实字段:
- \`{诊所名}\` ← user prompt「诊所.名称」(严格照抄,不简称、不加字)
- \`{患者称呼}\` ← user prompt「患者.称呼」(脱敏后的姓 + 先生/女士)
- \`{您的主诊医生}\` ← user prompt「主诊医生」字段值,若为未知就用"您的主诊医生"泛指(绝不要编"李医生""王主任")
- \`{临床事实+牙位俗称}\` ← user prompt「上次到店」「待做治疗」字段,牙位**已经是俗称**(上门牙/智齿/大牙等),直接照抄,绝不要写成"21 牙""36 位"
按 JSON schema 输出,只输出 JSON,不要任何解释性文字。`;
}
......@@ -3,48 +3,75 @@ import { z } from 'zod';
/**
* DraftPlanScript AiCall 的 Zod 输出 schema。
*
* generateObject 会把 schema 转 JSON Schema 注入 prompt,LLM 强制按此 shape 输出。
* .describe() 是给 LLM 的字段语义提示 — 写清楚能显著降低偏差,务必维护好。
* 设计原则(B 方案 · 2026-05-24):
* - 4 段直出 markdown,每段一个字段,LLM 自由写 markdown 但格式契约由 .describe() 强约束
* - generateObject 会把 schema 转 JSON Schema 注入 prompt;LLM 强制按此 shape 输出
* - 流式时每段是 string 字段,partial 阶段 LLM 还没写完的段为 undefined → orchestrator 透 ''
* - .describe() 写清楚段内 markdown 子结构格式(目的 / 正文 / 注意 / 异议预判 / 回写要点)
*
* 跟前端 mock-data.ts mockScript.sections 完全对齐(4 段 id),前端零改动接 4 段。
*/
export const DraftPlanScriptSchema = z.object({
opening: z
.string()
.min(2)
.max(30)
.describe('开场白,中文不超过 15 字,带患者称呼(如"王女士您好")'),
tone: z
.enum(['warm', 'professional', 'urgent'])
.describe('整体语气标签:warm=温和家常 / professional=专业稳重 / urgent=有时效紧迫'),
keyMessage: z
opening: z
.string()
.min(10)
.max(120)
.describe('核心传达信息,中文不超过 60 字。必须提到具体临床事实(治疗阶段/牙位/上次内容),让患者感受到诊所记得 ta。不得承诺疗效。'),
.min(60)
.max(700)
.describe(
[
'【开场段 markdown,必须严格按以下 3 部分格式】',
'第 1 部分:`**目的**:xxx` — 一句话讲清开场目的(15-40 字)',
'第 2 部分:空行后 `> "通话片段..."` blockquote — 客服开场白完整一段(80-200 字),包含:自报家门(诊所+岗位)+ 来电原因(必须提具体临床事实)+ 礼貌征询是否方便',
'第 3 部分:空行后 `**注意**` 标题 + 2-3 条 `- xxx` bullet — 客服执行注意点(称呼/时段/口吻)',
'禁止:加 ### 标题、加表情符号、blockquote 里使用排比抒情体',
].join('\n'),
),
followup: z
.string()
.min(20)
.max(400)
.describe('跟进话术完整片段,中文不超过 200 字。语气温和专业,避免推销感。可以包含 1-2 个开放性问题。'),
objectionHandling: z
.array(
z.object({
objection: z.string().min(2).max(40).describe('患者可能的拒绝理由,如"最近没时间"'),
response: z.string().min(10).max(80).describe('应对话术,中文不超过 30 字'),
}),
)
.min(1)
.max(3)
.describe('最多 3 条异议处理'),
.min(120)
.max(1000)
.describe(
[
'【切入话题段 markdown,必须严格按以下 3 部分格式】',
'第 1 部分:`**目的**:xxx` — 一句话讲本段任务(15-40 字)',
'第 2 部分:空行后 1-2 段 `> "..."` blockquote — 先讲临床事实+风险(治疗阶段/牙位号/上次诊断),再给具体行动选项(必须含"这次只是评估,30 分钟"这类降低门槛话术,可加粗关键信息)',
'第 3 部分:空行后 `**异议预判**` 标题 + 2-4 条 `- "trigger" → action` bullet — 列患者可能的反对+对应处理思路',
'禁止:在本段写完整异议应对话术(那是 objection 段的事)',
].join('\n'),
),
callToAction: z
objection: z
.string()
.min(10)
.max(80)
.describe('明确下一步动作,必须可执行 — 给出 2 个具体时间选项(如"本周六上午 10 点或下周一晚上 7 点")'),
.min(150)
.max(1400)
.describe(
[
'【异议处理段 markdown,必须严格按以下格式】',
'3-4 个子条目,每个子条目:',
' - `### A. "我再考虑考虑"` 子标题(A/B/C/D 顺序)',
' - 紧跟一行 blockquote `> "应对话术..."`(30-100 字,要给具体时间选项或具体方案)',
' - 可选追加一行 `> → 提交结果选「xxx」` 指明客服回写动作',
'常见异议优先覆盖:再考虑 / 价格贵 / 没时间 / 已在外院 / 治疗冲突等',
'禁止:把所有异议合并成一个长段、用 bullet 列举(必须 ### 子标题分块)',
].join('\n'),
),
tone: z
.enum(['warm', 'professional', 'urgent'])
.describe('整体语气标签:warm=温和家常 / professional=专业稳重 / urgent=有时效紧迫'),
close: z
.string()
.min(80)
.max(700)
.describe(
[
'【结束·信息确认段 markdown,必须严格按以下 2 部分格式】',
'第 1 部分:`> "确认话术..."` blockquote — 复述敲定的安排:具体时间(周X晚上X点)+ 医生名 + 提醒方式(短信/企微)+ 礼貌结尾',
'第 2 部分:空行后 `**回写要点**` 标题 + 2-4 条 `- xxx → 提交结果选「xxx」` bullet — 列不同通话结果对应的客服回写动作',
'禁止:省略具体时间敲定、省略回写要点',
].join('\n'),
),
});
export type DraftPlanScriptOutputZ = z.infer<typeof DraftPlanScriptSchema>;
......@@ -76,7 +76,7 @@ export class DraftPlanSummaryCall
readonly kind = 'summary' as const;
readonly callKey = 'draft_plan_summary';
readonly promptVersion = DRAFT_PLAN_SUMMARY_PROMPT_VERSION;
readonly defaultModelId = 'deepseek-v4-pro';
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DraftPlanSummarySchema;
readonly safetyRules = safetyRules;
......
......@@ -43,11 +43,12 @@ export interface DraftPlanSummaryInput {
chains: Array<{
name: string; // 如 "缺失牙修复链(36)"
category: string; // 治疗类别
status: 'closed' | 'ongoing' | 'uninitiated';
/// 5 阶段链状态(W3 末从 3 态升级)— 详见 chain-composer.service.ts
status: 'discovered' | 'entered' | 'ongoing' | 'closed';
currentStage: number;
diagnosedAt?: string;
gapDays?: number;
nodes: Array<{ stage: number; label: string; at: string; done?: boolean; current?: boolean }>;
nodes: Array<{ stage: number; label?: string; title?: string; detail?: string; doctor?: string; at: string; done?: boolean; current?: boolean }>;
}>;
}
......
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { Prisma } from '@prisma/client';
import { maskName, fmtYearMonth } from '@pac/utils';
import { fmtYearMonth } from '@pac/utils';
import { planScenarioLabel, personaFeatureMeta } from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service';
import { AiCallRunnerService } from '../ai-call-runner.service';
......@@ -37,9 +37,16 @@ export type PlanScriptStreamEvent =
}
| { type: 'error'; message: string };
/** 渲染后的 section,前端直接消费 */
/**
* 渲染后的 section,前端直接消费(B 方案:4 段对齐前端 mockScript)。
* - opening 开场
* - followup 切入话题
* - objection 异议处理
* - close 结束·信息确认
* 跟 apps/pac-web/.../mock-data.ts mockScript.sections id 一致,前端零改动消费。
*/
export interface ScriptSectionDto {
id: 'opening' | 'key' | 'followup' | 'objection' | 'cta';
id: 'opening' | 'followup' | 'objection' | 'close';
label: string;
durationHint: string;
markdown: string;
......@@ -322,12 +329,15 @@ export class PlanScriptOrchestrator {
return {
patient: {
nameMasked: maskName(patient.name),
// 注意:LLM 用的 nameMasked 是"路先生/女士"通话称呼,不是脱敏掩码"路*"
// (后者是 UI 列表展示用,客服通话不能直接念"路星")
nameMasked: nameSpokenForm(patient.name, patient.gender),
gender: patient.gender,
age: patient.birthDate ? calcAge(patient.birthDate) : null,
},
// 暂用 targetClinicId 兜底;后续接 host.displayName 或宿主字典查 clinic.name
clinicName: plan.targetClinicId ?? '本诊所',
// 临时:hardcoded jvs-dw 诊所字典(TODO #56 接 host 字典或新建 clinics 表)
// ⚠️ 直接吐 UUID 进 prompt 会让 LLM 编造"XX 客服中心",必须翻译成中文名
clinicName: resolveClinicName(plan.targetClinicId),
plan: {
primaryScenarioLabel: plan.reasons[0]
? planScenarioLabel(plan.reasons[0].scenario)
......@@ -348,6 +358,7 @@ export class PlanScriptOrchestrator {
lastVisitSummary: summarizeLastVisit(latestEnc),
pendingTreatments: extractPendingTreatments(facts),
treatmentChainSummary: summarizeChain(encounters),
primaryDoctorName: extractPrimaryDoctor(facts),
},
};
}
......@@ -357,30 +368,27 @@ export class PlanScriptOrchestrator {
// 渲染:LLM 结构化输出 → Markdown(给 PlanScript.content)
// ─────────────────────────────────────────────
/**
* 渲染整篇 markdown(写到 PlanScript.content 字段)。
* B 方案:4 段直接拼接(LLM 已经写好每段 markdown,后端不做模板加工)。
*/
function renderMarkdown(
out: DraftPlanScriptOutput,
meta: { patientNameMasked: string },
): string {
const objectionBlock = out.objectionHandling
.map((o, i) => `${i + 1}. **${o.objection}** → ${o.response}`)
.join('\n');
return `> 患者:${meta.patientNameMasked} · 语气:${toneLabel(out.tone)}
## 开场
${out.opening}
## 核心信息
${out.keyMessage}
## 跟进话术
## 切入话题
${out.followup}
## 异议处理
${objectionBlock}
${out.objection}
## 行动呼吁
${out.callToAction}
## 结束 · 信息确认
${out.close}
`;
}
......@@ -389,53 +397,42 @@ function toneLabel(tone: DraftPlanScriptOutput['tone']): string {
}
/**
* 把(可能不完整的)结构化输出渲染成 5 段 ScriptSection,前端直接消费。
* partial 时缺字段的段落 markdown 留空(前端按"占位/正在生成"展示)。
* 把(可能不完整的)结构化输出渲染成 4 段 ScriptSection,前端直接消费。
*
* B 方案设计要点:
* - LLM 直出 4 段 markdown(含 `**目的**:` / `**注意**` / `**异议预判**` / `**回写要点**` 子结构)
* - 这里不做任何模板加工,只是把 string 字段透传到 sections.markdown
* - partial 流式阶段:某段还没生成时为 undefined,渲染成空串(前端 ScriptMarkdown 自然占位)
* - 4 段顺序 + id 跟前端 mockScript.sections 完全一致,前端零适配消费
*/
function renderSections(
out: Partial<DraftPlanScriptOutput>,
meta: { patientNameMasked: string },
_meta: { patientNameMasked: string },
): ScriptSectionDto[] {
const objections = (out.objectionHandling ?? []).filter(
(o): o is { objection: string; response: string } =>
!!o && typeof o.objection === 'string' && typeof o.response === 'string',
);
const objectionMd = objections
.map((o, i) => `${i + 1}. **${o.objection}** → ${o.response}`)
.join('\n');
return [
{
id: 'opening',
label: '开场',
durationHint: '15 秒',
markdown: out.opening ? `> ${out.opening}` : '',
},
{
id: 'key',
label: '核心信息',
durationHint: '30 秒',
markdown: out.keyMessage
? `**目的**:让 ${meta.patientNameMasked} 感受到诊所还记得 ta,自然引出复诊。\n\n> ${out.keyMessage}`
: '',
markdown: out.opening ?? '',
},
{
id: 'followup',
label: '跟进话术',
label: '切入话题',
durationHint: '1–2 分钟',
markdown: out.followup ? `> ${out.followup}` : '',
markdown: out.followup ?? '',
},
{
id: 'objection',
label: '异议处理',
durationHint: '按需',
markdown: objectionMd,
markdown: out.objection ?? '',
},
{
id: 'cta',
label: '行动呼吁',
durationHint: '20 秒',
markdown: out.callToAction ? `> ${out.callToAction}` : '',
id: 'close',
label: '结束 · 信息确认',
durationHint: '30 秒',
markdown: out.close ?? '',
},
];
}
......@@ -496,11 +493,120 @@ function extractPendingTreatments(facts: FactRow[]): string[] {
if (!expectedCats) continue;
const fulfilled = expectedCats.some((cat) => doneCats.has(cat));
if (fulfilled) continue;
items.push(tooth ? `${name}(${tooth})` : name);
// 牙位转俗称(LLM 不能对患者说"牙位 21")
const friendly = tooth ? toothFriendly(tooth) : '';
items.push(friendly ? `${name}(${friendly})` : name);
}
return Array.from(new Set(items)).slice(0, 3);
}
// ─────────────────────────────────────────────
// 事实漂移防护(W4 修)— 诊所/牙位/医生/称呼 必须用真实信息,不让 LLM 编
// ─────────────────────────────────────────────
/**
* 通话称呼 — "姓 + 先生/女士"(性别未知用"您")。
*
* 跟 UI 列表的 maskName 区分:
* - UI 列表展示 → maskName("路星") = "路*"(脱敏)
* - 客服通话 → nameSpokenForm("路星","男") = "路先生"(真实可念)
* LLM 的 input.patient.nameMasked 字段用本函数产物(命名是历史,语义已转通话名)。
*/
function nameSpokenForm(name: string | null, gender: string | null): string {
if (!name) return '您';
const surname = name.charAt(0);
if (!surname) return '您';
if (gender === '男' || gender === 'M' || gender === 'male') return `${surname}先生`;
if (gender === '女' || gender === 'F' || gender === 'female') return `${surname}女士`;
return surname ? `${surname}先生` : '您'; // 性别未知默认先生(亚洲诊所语境),可调
}
/**
* 临时 jvs-dw 诊所字典(TODO #56:接 host clinic 字典或建 PAC clinics 表)。
* 直接吐 clinicId UUID 进 prompt → LLM 当成乱码会编造"XX 客服中心",必须翻译。
*/
const JVS_DW_CLINIC_NAMES: Record<string, string> = {
c18cadf2d3cd4adda5527debd41356eb: '通善口腔学前街医院',
e83d432a38bb4f6284713b36db4e7497: '通善口腔上海世纪公园诊所',
dad2f04a120947e2b82b41cbd108f3f4: '通善口腔杭州高德诊所',
'7d49539c7573490387c03e6496ff1a6c': '通善口腔杭州大厦诊所',
'66701845dd2342e19f9e9f576c4ffe9c': '通善口腔北京朝阳公园诊所',
};
function resolveClinicName(clinicId: string | null): string {
if (!clinicId) return '本诊所';
return JVS_DW_CLINIC_NAMES[clinicId] ?? '本诊所';
}
/**
* FDI 牙位号 → 患者俗称。
* 设计:粗粒度即可("上门牙"够,不需要"左上中切牙"),让客服讲话自然。
* 多牙位用"/"分隔,去重保序。
*
* FDI 规则:
* 第 1 位 = 象限(1-4 恒牙,5-8 乳牙)
* 第 2 位 = 位置(1=中切,2=侧切,3=尖牙,4-5=前磨,6-7=磨牙,8=智齿)
* 1x/2x 在上颌,3x/4x 在下颌
*/
function toothFriendly(fdiStr: string): string {
const parts = fdiStr.split(/[;,,;\s]+/).map((s) => s.trim()).filter(Boolean);
const friendly: string[] = [];
const seen = new Set<string>();
for (const p of parts) {
const label = fdiToFriendly(p);
if (label && !seen.has(label)) {
seen.add(label);
friendly.push(label);
}
}
// 兜底:都解析不出就原样返回(避免空字符串)
return friendly.length > 0 ? friendly.join('/') : fdiStr;
}
function fdiToFriendly(fdi: string): string | null {
// 整体全口
if (fdi === '*whole' || fdi.toLowerCase() === 'whole') return '全口';
const m = /^([1-8])([1-8])$/.exec(fdi);
if (!m) return null;
const q = Number(m[1]);
const t = Number(m[2]);
const upper = q === 1 || q === 2 || q === 5 || q === 6;
const isPrimary = q >= 5; // 乳牙
const where = upper ? '上' : '下';
const baby = isPrimary ? '乳' : '';
// 位置粗粒度
if (t === 1 || t === 2) return `${where}${baby}门牙`;
if (t === 3) return `${where}尖牙`;
if (t === 4 || t === 5) return `${where}小磨牙`;
if (t === 6 || t === 7) return `${where}大磨牙`;
if (t === 8) return '智齿';
return null;
}
/**
* 从最近的 treatment/diagnosis fact 抽主诊医生名。
* 没抽到 → null(prompt 里走 fallback,让 LLM 用"您的主诊医生"泛指,不编)。
*/
function extractPrimaryDoctor(facts: FactRow[]): string | null {
// facts 已按 occurredAt desc 排序,优先 treatment/diagnosis(更"主诊"),其次 emr/encounter
const candidates = facts.filter(
(f) => f.type === 'treatment_record' || f.type === 'diagnosis_record',
);
for (const f of candidates) {
const c = f.content as Record<string, unknown> | null;
const name = (c?.doctor_name as string | undefined)?.trim();
if (name && name.length > 0 && name.length <= 10) return name;
}
// 兜底:再扫所有 fact 任一 doctor_name
for (const f of facts) {
const c = f.content as Record<string, unknown> | null;
const name = (c?.doctor_name as string | undefined)?.trim();
if (name && name.length > 0 && name.length <= 10) return name;
}
return null;
}
function summarizeChain(encounters: FactRow[]): string | null {
if (encounters.length === 0) return null;
// v2.1:encounter_record 只元数据,不再有 treatment_stage / treatment_category 嵌套
......
......@@ -277,7 +277,9 @@ export class PlanSummaryOrchestrator {
id: f.id,
type: f.type,
kind: f.kind,
status: f.status,
occurredAt: f.occurredAt,
plannedFor: (f as { plannedFor?: Date | null }).plannedFor ?? null,
content: f.content,
})),
);
......
......@@ -8,6 +8,7 @@ import {
type ExchangeCodeResponse,
type Permission,
type RefreshTokenResponse,
type TokenDictionary,
type TokenExchangeResponse,
type TokenExchangeRequest,
type UserRole,
......@@ -21,10 +22,25 @@ import { parseDurationToSeconds } from './duration';
const CODE_PREFIX = 'pac:exchange-code:';
const REFRESH_PREFIX = 'pac:refresh:';
/**
* Refresh token payload(W3 末 A 方案降级版)
*
* 容纳重签 access token 所需的全部 user 上下文(role/clinicIds/dictionary),
* **无需** refresh 时回调 host SSO 拉权限即可重发 access。
*
* 安全考量:
* - refresh token 用独立 secret(JWT_REFRESH_SECRET),access 烂不代表 refresh 烂
* - Redis jti 白名单(7 天 TTL),被偷的 refresh 不会无限刷(rotation 后老 jti 立即作废)
* - 权限变更/禁用最长 7 天(refresh TTL)才生效 — POC 阶段可接受;
* 生产前要加 host SSO `/users/{id}` 回调拉最新 role/clinics(见下方 TODO)
*/
interface RefreshPayload {
sub: string;
hostId: string;
tenantId: string;
clinicIds: string[];
role: UserRole;
dictionary?: TokenDictionary;
jti: string;
}
......@@ -67,6 +83,9 @@ export class AuthService {
sub: req.user.userId,
hostId: host.id,
tenantId,
clinicIds,
role: req.user.role,
dictionary: req.user.dictionary,
});
const code = randomCode(24);
......@@ -100,6 +119,21 @@ export class AuthService {
};
}
/**
* Refresh access token(W3 末 A 方案 — 本地降级,无 host SSO 回调)
*
* 流程:
* 1. 验证 refresh token 签名(独立 secret JWT_REFRESH_SECRET)
* 2. Redis 校验 jti 是否还在白名单(未被 rotation 作废、未被人工撤销)
* 3. 用 refresh payload 里的 sub/role/clinicIds/dictionary 重签新 access token
* 4. **Rotation**:作废老 jti,签发新 refresh token(新 jti 入 Redis),
* 防 refresh token 被偷后无限刷;被偷者一旦用了,真主人下次刷新就因老 jti 被撤销而失败 → 早发现
*
* TODO(host SSO 集成,W5+):
* 生产前需在步骤 3 前回调 host `/users/{userId}` 拉最新 role/clinicIds —
* 防"用户已禁用 / 权限已降级" 还能继续刷 token 用 7 天。
* 联调 host SSO 后,把 refresh payload 里的 role/clinicIds 改为"仅缓存,以 host 返回为准"。
*/
async refresh(refreshToken: string): Promise<RefreshTokenResponse> {
let payload: RefreshPayload;
try {
......@@ -113,13 +147,31 @@ export class AuthService {
const stored = await this.redis.get(REFRESH_PREFIX + payload.jti);
if (!stored) throw new BizError(ApiCode.AUTH_INVALID_REFRESH_TOKEN, 'refresh token revoked');
// Real impl needs to re-derive role/permissions/clinics from a host SSO or
// a local user table. The scaffold deliberately fails closed so the host
// backend re-runs token exchange. See architecture §3.6.
throw new BizError(
ApiCode.AUTH_INVALID_REFRESH_TOKEN,
'Refresh requires re-exchange via host backend (scaffold placeholder)',
);
// ── 步骤 3:重签 access token(payload 里有完整 user 上下文)──
const permissions = this.resolvePermissions(payload.role);
const accessToken = await this.signAccessToken({
sub: payload.sub,
hostId: payload.hostId,
tenantId: payload.tenantId,
clinicIds: payload.clinicIds,
role: payload.role,
permissions,
...(payload.dictionary ? { dictionary: payload.dictionary } : {}),
});
// ── 步骤 4:rotation — 作废老 jti,签发新 refresh token ──
await this.redis.del(REFRESH_PREFIX + payload.jti);
const newRefreshToken = await this.signRefreshToken({
sub: payload.sub,
hostId: payload.hostId,
tenantId: payload.tenantId,
clinicIds: payload.clinicIds,
role: payload.role,
dictionary: payload.dictionary,
});
const expiresIn = parseDurationToSeconds(this.config.getOrThrow<string>('jwt.expiresIn'));
return { accessToken, refreshToken: newRefreshToken, expiresIn };
}
async signAccessToken(
......@@ -135,6 +187,9 @@ export class AuthService {
sub: string;
hostId: string;
tenantId: string;
clinicIds: string[];
role: UserRole;
dictionary?: TokenDictionary;
}): Promise<string> {
const jti = randomCode(16);
const ttl = parseDurationToSeconds(this.config.getOrThrow<string>('jwt.refreshExpiresIn'));
......
......@@ -28,4 +28,14 @@ export class PatientController {
const limit = Math.min(Math.max(Number(limitRaw ?? 50), 1), 500);
return this.patient.getTimeline(scope, id, limit);
}
@Get(':id/phone-reveal')
@RequirePermission(Permission.PATIENT_VIEW)
@ApiOperation({ summary: 'Reveal masked phone — 拉患者明文手机号(需 PATIENT_VIEW 权限)' })
revealPhone(
@TenantScope() scope: TenantScopeContext,
@Param('id') id: string,
) {
return this.patient.revealPhone(scope, id);
}
}
......@@ -17,6 +17,33 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de
export class PatientService {
constructor(private readonly prisma: PrismaService) {}
/**
* Reveal — 拉患者真实手机号(掩码 138****3405 → 13812343405)。
*
* 仅 PATIENT_VIEW 权限的用户可调,且强制 tenant scope 校验。
*
* TODO(W5+ 生产前):落 audit log(patient_pii_reveal_logs 表)
* 记录 { userId, patientId, field='phone', revealedAt, scope:plan/timeline/manual };
* 合规审计能反查"谁何时看了哪位患者的电话"。POC 阶段先口头记录。
*/
async revealPhone(
scope: TenantScopeContext,
patientId: string,
): Promise<{ phone: string | null }> {
const patient = await this.prisma.patient.findFirst({
where: {
id: patientId,
hostId: scope.hostId,
tenantId: scope.tenantId,
},
select: { phone: true },
});
if (!patient) {
throw new NotFoundException(`patient ${patientId} not found in scope`);
}
return { phone: patient.phone };
}
async getTimeline(
scope: TenantScopeContext,
patientId: string,
......
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType, FactKind, lookupDxTreatment } from '@pac/types';
import {
PersonaFeatureKey,
FactType,
FactKind,
lookupDxTreatment,
treatmentCategoryNameZh,
} from '@pac/types';
import type {
FeatureExtractor,
FeatureExtractorContext,
......@@ -75,6 +81,9 @@ export class TreatmentChainStatusFeatureExtractor implements FeatureExtractor {
}
// 找出"诊断 + 推荐但无对应治疗"的缺口
// gap 字符串短化(旧版含全口 26 颗牙位 + raw enum,UI 严重溢出):
// "K05/11;12;13;...37 → 未做 periodontic"(60+ 字符)
// → "牙周治疗 全口 26 牙"(短 + 用 chainLabel 中文,牙位 formatTooth 截断)
const gaps: string[] = [];
const factIds: string[] = [];
const signalSources = [...diagnoses, ...recommendations];
......@@ -83,11 +92,18 @@ export class TreatmentChainStatusFeatureExtractor implements FeatureExtractor {
const c = sig.content as Record<string, unknown>;
const code = String(c.code ?? '');
const tooth = String(c.tooth_position ?? '');
const expectedCats = lookupDxTreatment(code)?.categories;
const rule = lookupDxTreatment(code);
const expectedCats = rule?.categories;
if (!expectedCats) continue;
const hasFollowup = expectedCats.some((cat) => actualByCat.has(cat));
if (!hasFollowup) {
gaps.push(`${code}${tooth ? '/' + tooth : ''} → 未做 ${expectedCats.join('或')}`);
const chainLabel = rule?.chainLabel ?? treatmentCategoryNameZh(expectedCats[0]!) ?? code;
// normalize 后再生成 gap 文本 — 跟 chain-composer 桶分一致:
// 同 N 颗牙不同顺序("17;47;37" / "47;17;37")算 1 个 gap,不重复
const normalizedTooth = rule?.wholeMouth
? '全口'
: formatToothShort(normalizeTooth(tooth));
gaps.push(`${chainLabel} ${normalizedTooth}`);
}
}
for (const tx of treatments) factIds.push(tx.id);
......@@ -97,7 +113,9 @@ export class TreatmentChainStatusFeatureExtractor implements FeatureExtractor {
let score: number;
if (gaps.length > 0) {
status = 'gap';
description = `治疗链有 ${gaps.length} 处缺口 — ${gaps.slice(0, 2).join(';')}`;
// 去重(同 K 码 + 牙位的多次诊断 / 推荐 → 同一 gap;UI 只显示 distinct)
const dedupGaps = [...new Set(gaps)];
description = `${dedupGaps.length} 处缺口 — ${dedupGaps.slice(0, 3).join('、')}`;
score = 3;
} else if (inProgressCount > 0) {
status = 'in_progress';
......@@ -122,3 +140,22 @@ export class TreatmentChainStatusFeatureExtractor implements FeatureExtractor {
};
}
}
/// 牙位规整化(去 trim + 去重 + 字典序)— 跟 chain-composer.normalizeTooth 同语义,
/// 让"17;47;37" / "47;17;37" 算同一个 gap,不因 host 录入顺序差异重复
function normalizeTooth(s: string): string {
if (!s.trim()) return '';
return Array.from(new Set(s.split(';').map((t) => t.trim()).filter(Boolean)))
.sort()
.join(';');
}
/// 牙位短化:>20 颗 → 全口 N 牙;≤4 颗 → 完整列;否则截前 4 + 等 N 颗
function formatToothShort(tooth: string, wholeMouthDefault?: boolean): string {
if (!tooth.trim()) return wholeMouthDefault ? '全口' : '未标注牙位';
const list = tooth.split(';').map((s) => s.trim()).filter(Boolean);
if (list.length === 0) return '未标注牙位';
if (list.length >= 20) return `全口 ${list.length} 牙`;
if (list.length <= 4) return list.join(';');
return `${list.slice(0, 4).join(';')}${list.length} 颗`;
}
......@@ -58,9 +58,11 @@ export class ValueFeatureExtractor implements FeatureExtractor {
const score = tier?.score ?? 0;
const yuan = (total / 100).toFixed(2);
// description 简化 — UI 紧凑展示;笔数/退款明细走 facts 时间轴 / profile.paymentCount 等
// (旧版 "新客/未消费(累计净消费 ¥58.00,含付款 1 笔 / 充值 0 笔 / 退款 0 笔)" UI 严重溢出)
return {
key: this.key,
description: `${label}(累计净消费 ¥${yuan},含付款 ${payments.length} 笔 / 充值 ${recharges.length} 笔 / 退款 ${refunds.length} 笔)`,
description: `${label} · 累计 ¥${yuan}`,
score,
evidence: { factIds },
};
......
......@@ -120,7 +120,9 @@ export class PersonaService {
hostId: patient.hostId,
tenantId: patient.tenantId,
patientId: patient.id,
status: 'active',
// 跟 chain-composer / scenario SQL 对齐:actual treatment 完成后 status='fulfilled',
// 不能只取 'active' 否则已完成治疗在 persona 看不到 → treatment_chain_status 误算 gap
status: { in: ['active', 'fulfilled'] },
},
select: {
id: true,
......
......@@ -52,9 +52,28 @@ export class PlanAggregateService {
) {
const persona = await this.loadCurrentPersona(patient.id);
const facts = await this.loadActiveFacts(scope, patient.id);
// W4:话术从 DB 加载(LLM 流式生成完会 upsert 到 plan_scripts)
// 没生成过 → script=null,前端走 mock 兜底
const script = plan ? await this.loadPlanScript(plan.id) : null;
// v2.1:chain-composer 读独立 diagnosis_record / treatment_record / recommendation_record,
// 传所有 facts,内部按 type 分组(encounter_record 已只元数据)
const chains = this.chainComposer.compose(facts);
// ⭐ SQL 为准:chain ★ 严格按 plan_reasons.signal_code 对齐(W3 末)
// chain-composer 只产链客观状态;真正"该召回 ★" 由 scenario SQL 决定
// 规则:discovered chain.code === reason.signals.triggers[0].code(K00/K01/.../K09 严格匹配)
// ★ 集合 = SQL reason 集合,数量完全一致(陆伟根 K07 reason → 只 K07 chain ★;K08 chain 不 ★)
const reasonCodes = new Set<string>();
for (const r of plan?.reasons ?? []) {
const sigs = (r.signals ?? {}) as { triggers?: Array<{ code?: string }> };
for (const t of sigs.triggers ?? []) {
if (t.code) reasonCodes.add(t.code);
}
}
for (const c of chains) {
if (c.status === 'discovered') {
c.target = !!(c.code && reasonCodes.has(c.code));
}
}
return {
patient: serializePatient(patient),
......@@ -63,9 +82,20 @@ export class PlanAggregateService {
persona: persona ? serializePersona(persona) : null,
chains,
facts: facts.map(serializeFact),
script: script ? serializeScript(script) : null,
};
}
/**
* W4:加载该 plan 的最新 ready 话术(LLM 生成完会 upsert 进 plan_scripts)。
* pending/failed 的不返回 — 前端走 mock 兜底,客服点"重新生成"再触发 LLM。
*/
private loadPlanScript(planId: string) {
return this.prisma.planScript.findUnique({
where: { planId },
});
}
// ─────────────────────────────────────────────
// 数据加载(分小函数 — 便于测试 + 单一职责)
// ─────────────────────────────────────────────
......@@ -201,12 +231,14 @@ function serializePlan(plan: {
reasons: Array<{
id: string;
scenario: string;
subKey: string | null;
priorityScore: number;
reason: string;
lifecycle: string;
source: string;
evidence: string[];
evidence: Prisma.JsonValue;
breakdown: Prisma.JsonValue;
signals: Prisma.JsonValue;
}>;
}) {
return {
......@@ -227,14 +259,18 @@ function serializePlan(plan: {
reasons: plan.reasons.map((r) => ({
id: r.id,
scenario: r.scenario,
subKey: r.subKey,
priorityScore: r.priorityScore,
reason: r.reason,
lifecycle: r.lifecycle,
source: r.source,
evidenceFactIds: r.evidence,
/// W3 末:evidence 改 JSON 统一(跟 PersonaFeature.evidence 对齐),透 JSON 整体
evidence: r.evidence,
/// v2.1:6 因子算法 breakdown + subKey,UI 详情页"为什么 73 分"渲染数据来源
/// (不含 ruleIds — 算法版本追溯靠 git + plan.createdAt)
breakdown: r.breakdown,
/// W3 末:结构化召回信号(raw enum / canonical code,前端字典翻译富文本)
signals: r.signals,
})),
};
}
......@@ -258,12 +294,97 @@ function serializePersona(persona: {
};
}
/**
* W4 新加:PlanScript serializer + markdown → sections 反 parse。
*
* 后端写库时 plan-script.orchestrator.renderMarkdown 把 4 段拼成:
* > 患者:xxx · 语气:xxx
* ## 开场\n{opening md}\n## 切入话题\n{followup md}\n## 异议处理\n{objection md}\n## 结束 · 信息确认\n{close md}
* 这里反向用 regex 按 H2 标题切回 4 段,前端拿到的 sections shape 跟 mockScript 完全一致。
*
* 设计决策:**前端单一消费 sections 接口**(mock / 真实 / 流式三路同 shape),
* 反 parse 集中在后端做,避免前端多套渲染分支。
*/
function serializeScript(s: {
id: string;
content: string | null;
source: string | null;
status: string;
generatedAt: Date | null;
updatedAt: Date;
}) {
const sections = parseScriptMarkdownToSections(s.content ?? '');
return {
id: s.id,
status: s.status,
source: (s.source as 'agent' | 'template_fallback' | 'manual' | null) ?? 'agent',
generatedAt: (s.generatedAt ?? s.updatedAt).toISOString(),
sections,
};
}
const SECTION_HEAD_TO_ID: Record<string, 'opening' | 'followup' | 'objection' | 'close'> = {
开场: 'opening',
切入话题: 'followup',
异议处理: 'objection',
'结束 · 信息确认': 'close',
};
const SECTION_META: Record<
'opening' | 'followup' | 'objection' | 'close',
{ label: string; durationHint: string }
> = {
opening: { label: '开场', durationHint: '30 秒' },
followup: { label: '切入话题', durationHint: '1–2 分钟' },
objection: { label: '异议处理', durationHint: '按需' },
close: { label: '结束 · 信息确认', durationHint: '30 秒' },
};
function parseScriptMarkdownToSections(md: string) {
// 按 H2 切分:`^## (xxx)$` 之后到下一个 H2 之间为内容
const ids: Array<'opening' | 'followup' | 'objection' | 'close'> = [
'opening',
'followup',
'objection',
'close',
];
const result = ids.map((id) => ({
id,
label: SECTION_META[id].label,
durationHint: SECTION_META[id].durationHint,
markdown: '',
}));
if (!md) return result;
const lines = md.split('\n');
let currentId: 'opening' | 'followup' | 'objection' | 'close' | null = null;
const buf: Record<string, string[]> = {};
for (const line of lines) {
const m = /^##\s+(.+?)\s*$/.exec(line);
if (m) {
const head = (m[1] ?? '').trim();
const matched = SECTION_HEAD_TO_ID[head];
currentId = matched ?? null;
if (currentId) buf[currentId] = [];
continue;
}
if (currentId) {
buf[currentId] = buf[currentId] ?? [];
buf[currentId]!.push(line);
}
}
for (const sec of result) {
sec.markdown = (buf[sec.id] ?? []).join('\n').trim();
}
return result;
}
function serializeFact(f: {
id: string;
type: string;
kind: string;
status: string;
occurredAt: Date | null;
plannedFor: Date | null;
clinicId: string | null;
title: string | null;
summary: string | null;
......@@ -275,6 +396,8 @@ function serializeFact(f: {
kind: f.kind,
status: f.status,
occurredAt: f.occurredAt?.toISOString() ?? null,
// planned 类(预约/计划治疗)时间在 plannedFor;透传给前端时间轴排序兜底
plannedFor: f.plannedFor?.toISOString() ?? null,
clinicId: f.clinicId,
title: f.title,
summary: f.summary,
......@@ -285,11 +408,13 @@ function serializeFact(f: {
// ─────────────────────────────────────────────
// 小工具
// ─────────────────────────────────────────────
/// payment/recharge/refund.content 实际字段是 `amount_cents`(parser 写库时归一为 cents int);
/// 老代码读 `c.amount` 是字段名错配,导致 LTV 永远 0(实测路遥 ¥58 显示成 0)。W3 末修正。
function sumAmount(facts: Array<{ content: Prisma.JsonValue }>): number {
let total = 0;
for (const f of facts) {
const c = f.content as Record<string, unknown>;
total += Number(c?.amount ?? 0);
total += Number(c?.amount_cents ?? c?.amount ?? 0);
}
return total;
}
......
......@@ -4,6 +4,7 @@ import {
ListExecutionsResponseSchema,
ListPlansQuerySchema,
ListPlansResponseSchema,
PlanCountsResponseSchema,
PlanActionAckSchema,
PlanDetailResponseSchema,
RecomputeAckSchema,
......@@ -14,6 +15,7 @@ import {
export class ListPlansQueryDto extends createZodDto(ListPlansQuerySchema) {}
export class ListPlansResponseDto extends createZodDto(ListPlansResponseSchema) {}
export class PlanCountsResponseDto extends createZodDto(PlanCountsResponseSchema) {}
export class PlanDetailResponseDto extends createZodDto(PlanDetailResponseSchema) {}
export class AssignPlanRequestDto extends createZodDto(AssignPlanRequestSchema) {}
export class RecyclePlanRequestDto extends createZodDto(RecyclePlanRequestSchema) {}
......
import { Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { FactType, FactKind, lookupDxTreatment } from '@pac/types';
import { fmtYearMonth } from '@pac/utils';
import {
FactType,
FactKind,
lookupDxTreatment,
lookupTreatmentMilestone,
lookupTreatmentLifecycle,
parseComplaintCategories,
type PACTreatmentCategory,
type TreatmentMilestone,
} from '@pac/types';
import { fmtYearMonthDay } from '@pac/utils';
/**
* ChainComposerService(v2.1 — 读独立 diagnosis_record / treatment_record / recommendation_record)
* ChainComposerService — 5 阶段治疗链分析(纯展示用,不参与召回算法)
*
* v2.1 重塑:不再从 encounter_record.content.treatments[]/diagnoses[] 嵌套数组拼链,
* 改读独立 fact_type:
* - diagnosis_record(独立诊断,有 code / tooth_position)
* - treatment_record(独立治疗,actual/planned + category)
* - recommendation_record(医生建议,LLM/规则抽取)
* - encounter_record(只元数据;时间锚点用)
* **重要边界**:跟 scenario SQL 召回口径**完全解耦**:
* - 召回算法(treatment_initiation_recall)排除条件 = "诊断后有任何同类 actual treatment"
* → 本质是召回 S1(发现机会)且未启动治疗的患者,该口径**不变**
* - chain-composer 是给客服**详情页看清治疗链真实进度**用的,5 阶段精细判定
* - 如果未来要做"治疗链内召回"(种植已植入未修复)再写新 scenario 读 TreatmentMilestones
*
* 5 阶段(对齐 design 文档 §0 / canonical-codes.ts TreatmentLifecycles):
* ① 发现治疗机会 = diagnosis / recommendation / image / EMR finding
* ② 进入治疗链 = planned treatment / appointment(planned) / payment / 发现后接诊
* ③ 治疗执行 = actual treatment(命中 milestone steps 数 >= minSteps)
* ④ 术后与复查管理 = post-S3 review encounter / planned review / *_REVIEW_RECOMMENDED
* ⑤ 治疗链闭环 = S3 全 milestone 满足 + S4 命中 + 无 refund 后置 + 无反弹诊断
* + lifecycle.maxStage = 5(periodontic 维护型 maxStage=4 永不 closed)
*
* 5 status × stage 映射:
* - discovered(S1) : 仅 S1 命中 — UI 显示"★ 发现治疗机会"(召回算法的目标候选)
* - entered(S2) : S2 命中但无 actual treatment — UI 显示"进入治疗链"
* - ongoing(S3 / S4) : 有 actual 但未到 closed — UI 显示"治疗执行中 / 复查管理中"
* - closed(S5) : 全条件满足 — UI 显示"闭环"
*
* 共享给:
* - PlanSummaryOrchestrator(AI 话术输入)
* - PlanAggregateService(patient 详情页)
*
* 5 阶段固定:
* ① 发现机会 ② 进入治疗链 ③ 治疗执行 ④ 术后管理 ⑤ 闭环
*/
@Injectable()
export class ChainComposerService {
compose(facts: ChainComposeInputFact[]): ComposedChain[] {
const diagnoses = facts.filter((f) => f.type === FactType.DIAGNOSIS_RECORD);
const recommendations = facts.filter((f) => f.type === FactType.RECOMMENDATION_RECORD);
const treatments = facts.filter((f) => f.type === FactType.TREATMENT_RECORD);
// 按 fact_type 索引,各阶段判定取所需信号
const byType = groupByType(facts);
const chains: ComposedChain[] = [];
chains.push(...this.composeUninitiated(diagnoses, recommendations, treatments));
chains.push(...this.composeByCategory(treatments));
return chains;
}
// 同 patient 全 facts 学一遍 doctor_id → doctor_name,渲染时缺 name 的 fact 兜底查表
const doctorMap = buildDoctorMap(facts);
/**
* 漏治链:有 diagnosis/recommendation 但无对应 actual treatment 的链。
* 召回算法的目标候选(target=true)。
*/
private composeUninitiated(
diagnoses: ChainComposeInputFact[],
recommendations: ChainComposeInputFact[],
treatments: ChainComposeInputFact[],
): ComposedChain[] {
const actualCats = new Set<string>();
for (const tx of treatments) {
if (tx.kind !== FactKind.ACTUAL) continue;
const c = tx.content as Record<string, unknown>;
const cat = String(c.category ?? '');
if (cat) actualCats.add(cat);
// 收集"链桶"(W3 末重塑):按 (category, code, tooth) 分组,每个桶 → 1 条独立链
// - K08 多牙位(34/21/14-17;47)→ 分别立链;
// - 同 K 码 + 同牙位的多次诊断 → 同桶 → 1 链(最早 sig 为主)
// - 无诊断但有 actual(prosthodontic 等)→ tooth=null 单桶
// 旧版按 category 聚合 → K08 多牙位被合并成"种植修复·34"漏掉"·21",scenario 召回 21 但 chain 不显示
const buckets = collectChainBuckets(byType);
const chains: ComposedChain[] = [];
for (const bucket of buckets) {
const chain = inferChainStage(bucket, byType, doctorMap);
if (chain) chains.push(chain);
}
// W3 末加去重 — 同 (code, tooth_position) 多次重复诊断只产一条链,保留最早信号。
// 实例:周伯君 K08 牙位 41;31;32 跨 9 月连续 4 次诊断同一缺牙,UI 不该显示 4 条独立漏治链,
// 应合并为一条"最早 2025-09 诊断,243 天未启动"。
const dedupKey = (code: string, tooth: string) => `${code}|${tooth.trim()}`;
const earliestByKey = new Map<string, ChainComposeInputFact>();
const signals = [...diagnoses, ...recommendations];
for (const sig of signals) {
const c = sig.content as Record<string, unknown>;
const code = String(c.code ?? '');
const tooth = String(c.tooth_position ?? '');
const inferred = inferUninitiated(code, tooth);
if (inferred.category === 'unknown') continue;
const expectedCats = lookupDxTreatment(code)?.categories ?? [inferred.category];
if (expectedCats.some((cat) => actualCats.has(cat))) continue;
const key = dedupKey(code, tooth);
const prev = earliestByKey.get(key);
// 保留 occurredAt 最早的(gap 最长,临床最紧迫)
if (
!prev ||
(sig.occurredAt &&
(!prev.occurredAt || sig.occurredAt.getTime() < prev.occurredAt.getTime()))
) {
earliestByKey.set(key, sig);
}
// 去重:同 (chainLabel + toothPosition) 的链合并,保留 stage 最高的(K08 primary=implant
// 立 1 条 + 患者做的"种植上部修复"actual.category=prosthodontic 立 1 条 → 都叫"种植修复·xx",
// 临床上是同一条治疗链,UI 不该显示两份)
const dedupKey = (c: ComposedChain) => `${c.name}`;
const bestByKey = new Map<string, ComposedChain>();
for (const c of chains) {
const key = dedupKey(c);
const prev = bestByKey.get(key);
if (!prev || c.currentStage > prev.currentStage) bestByKey.set(key, c);
}
const deduped = [...bestByKey.values()];
const out: ComposedChain[] = [];
for (const sig of earliestByKey.values()) {
const c = sig.content as Record<string, unknown>;
const code = String(c.code ?? '');
const codeName = String(c.name_zh ?? c.name ?? '');
const tooth = String(c.tooth_position ?? '');
const inferred = inferUninitiated(code, tooth);
const occurredAt = sig.occurredAt;
const gapDays = occurredAt
? Math.floor((Date.now() - occurredAt.getTime()) / 86400_000)
: 0;
// 替代闭环 pass:同 patient 同 tooth overlap 后续有"已治"链(closed/ongoing s>=3) →
// 把当前 discovered/entered chain 标记为 closed(by alternative)
// 例:罗国标 K04 14;15 根管诊断 → 后来 K08 14-17;47 + implant actual → K04 chain 视为替代闭环
markAlternativeClosed(deduped);
// ⭐ chain ★ 标记不在这里做 patient 级对齐(撤回 filter)
// 理由:chain-composer 只产 chain 客观状态(discovered/entered/ongoing/closed)
// ★ 是否真"该召回" 由 scenario SQL 决定 — plan-aggregate.getPlanFull 调用 chain composer 后,
// 按 plan_reasons 重新覆盖 chain.target,确保 ★ 数 = SQL reason 数(SQL 为准)
const aligned = deduped;
out.push({
id: `chain_uninit_${sig.id}`,
name: inferred.chainName,
category: inferred.category,
status: 'uninitiated',
currentStage: 1,
target: true,
estimatedValueCents: inferred.estimatedValueCents,
diagnosedAt: occurredAt ? fmtYearMonth(occurredAt) : '—',
gapDays,
nodes: [
{
stage: 1,
label: `${codeName || code || '诊断'} · 待启动治疗`,
at: occurredAt ? fmtYearMonth(occurredAt) : '—',
done: true,
note: tooth ? `牙位 ${tooth}` : undefined,
},
{ stage: 2, label: '未进入', at: '—', missing: true },
{ stage: 3, label: '未进入', at: '—', missing: true },
{ stage: 4, label: '未进入', at: '—', missing: true },
{ stage: 5, label: '未进入', at: '—', missing: true },
],
});
// 排序:discovered(召回候选)优先,然后按 stage 倒序,内部按时间倒序
aligned.sort((a, b) => {
const pa = STATUS_PRIORITY[a.status] ?? 99;
const pb = STATUS_PRIORITY[b.status] ?? 99;
if (pa !== pb) return pa - pb;
if (a.currentStage !== b.currentStage) return b.currentStage - a.currentStage;
return (b.gapDays ?? 0) - (a.gapDays ?? 0);
});
return aligned;
}
}
/**
* 替代闭环判定 — patient 级 cross-chain pass
*
* 临床场景:K04 14;15 根管诊断后,牙保不住 → 拔了做 K08 14;15 种植。
* K04 chain 原本被算 discovered "潜在新链",但实际方案已变 → 客服不该召回。
* chain-composer 标记 status='closed' + alternativeClosedBy = 替代 chain.name + target=false。
*
* 判定规则(只取明确"替代终止" 场景):
* - 当前 chain 是 target=true(discovered / entered)
* - 同 patient 存在 other chain 满足:
* ① other.tooth 跟 current.tooth 有 overlap > 0
* ② other.category IN ['implant','surgical','prosthodontic'] — "替代终止性"治疗类
* (做了种植/拔除/冠修复 → 原牙的根管/牙周/充填 chain 失去意义)
* ③ other.diagnosedAt >= current.diagnosedAt(后续诊断,不是更早的)
*
* 注:不强求 other.currentStage >= 3(已治) — 仅"同位置后续 K08 诊断"就足够说明方案变更,
* 客服可以告诉患者"医生已经规划替代方案,先看治疗链全景再决定要不要打"。
*/
/**
* patient 级对齐 — 跟 scenario SQL 排除口径完全一致
*
* 规则:同 patient 内,若 category C 下存在任一 ongoing/closed 链(即患者真做过 C 类 actual),
* 则该 cat 所有 discovered 链**直接从 chains 列表删除**(不只是 target=false 灰显)。
*
* 例:张超 K04 endodontic — 11;22 已做根管(closed)→ 12;13、24;25;42 discovered 桶被 filter 掉,
* UI 完全不显示。chain 数 = reason 数完全对齐,客服无歧义。
*
* limitation:host 结算无牙位级粒度,无法精准判定"12;13 还没做"。等 host 升级 settlement_tooth_position
* 后可改成 chain 级精准 — 真没做的牙位仍立链召回。当前是 patient 级粗粒度对齐。
*/
function filterDiscoveredByPatientLevel(chains: ComposedChain[]): ComposedChain[] {
// 收集每个 category 是否"已启动"(任一桶 status='ongoing'/'closed' 或 'entered')
// 包括 entered — 患者主动预约了同 cat 也算"已动",不再算潜在新发现
const catStarted = new Set<string>();
for (const c of chains) {
if (c.status === 'ongoing' || c.status === 'closed' || c.status === 'entered') {
catStarted.add(c.category);
}
// 按 gapDays 降序(最久的排前)
out.sort((a, b) => (b.gapDays ?? 0) - (a.gapDays ?? 0));
return out;
}
// filter out:discovered chain 的 DxMap.code.categories 全集中**任一**已启动 → 直接删
// 跟 scenario SQL.excludeCats 完全对齐(K00 categories=['surgical','prosthodontic','implant','orthodontic']
// 任一启动即 K00 不召回 / 不立链)
return chains.filter((c) => {
if (c.status !== 'discovered') return true;
const cats = c.allCategories ?? [c.category]; // fallback 兜底
return !cats.some((cat) => catStarted.has(cat));
});
}
/** 按 treatment_record.content.category 分组 → 在管 / 闭环链 */
private composeByCategory(treatments: ChainComposeInputFact[]): ComposedChain[] {
const byCategory = new Map<string, ChainComposeInputFact[]>();
for (const tx of treatments) {
if (tx.kind !== FactKind.ACTUAL) continue;
const c = tx.content as Record<string, unknown>;
const cat = String(c.category ?? 'unknown');
const arr = byCategory.get(cat) ?? [];
arr.push(tx);
byCategory.set(cat, arr);
function markAlternativeClosed(chains: ComposedChain[]): void {
const ALT_CATS = new Set(['implant', 'surgical', 'prosthodontic']);
for (const c of chains) {
if (c.status !== 'discovered' && c.status !== 'entered') continue;
if (!c.toothPosition || !c.diagnosedAt) continue;
for (const other of chains) {
if (other === c) continue;
if (!ALT_CATS.has(other.category)) continue;
if (!other.toothPosition || !other.diagnosedAt) continue;
// ⭐ 同 category 不交叉关闭(W3 末)— alt-close 语义是 K04 根管→K08 种植 这种"不同方案"替代,
// 同病种(都 implant / 都 surgical)多次诊断不是"替代"是"重复",该在桶分阶段合并而非这里互判
// 例:王辉 K08 多次诊断 tooth 顺序差异 → 旧版同 implant 互判 alt-closed → 误标
if (other.category === c.category) continue;
if (other.diagnosedAt < c.diagnosedAt) continue;
if (!toothOverlap(c.toothPosition, other.toothPosition)) continue;
// 命中 — 标记替代闭环
c.status = 'closed';
c.target = false;
c.alternativeClosedBy = other.name;
c.currentStage = 5;
break;
}
}
}
// ─────────────────────────────────────────────
// 5 阶段引擎核心
// ─────────────────────────────────────────────
/**
* 单"链桶" 5 阶段判定 —— bucket = (category, code, tooth) 三元组,本桶内 sig 已分好。
* 返回 null 表示该 bucket 无意义(无信号),不立链。
*/
function inferChainStage(
bucket: ChainBucket,
byType: FactsByType,
doctorMap: Map<string, string>,
): ComposedChain | null {
const { category, signals: s1Facts } = bucket;
const milestone = lookupTreatmentMilestone(category);
const lifecycle = milestone
? lookupTreatmentLifecycle(milestone.lifecycle)
: DEFAULT_LIFECYCLE;
const maxAllowedStage = lifecycle.maxStage;
const out: ComposedChain[] = [];
for (const [cat, txs] of byCategory.entries()) {
const sorted = [...txs].sort(
(a, b) => (a.occurredAt?.getTime() ?? 0) - (b.occurredAt?.getTime() ?? 0),
);
const first = sorted[0]!;
const last = sorted[sorted.length - 1]!;
const allCompleted = sorted.every((t) => {
const c = t.content as Record<string, unknown>;
return c.status === 'completed';
});
out.push({
id: `chain_${cat}_${first.id}`,
name: CATEGORY_LABEL[cat] ?? cat,
category: cat,
status: allCompleted ? 'closed' : 'ongoing',
currentStage: allCompleted ? 5 : Math.min(3 + sorted.length, 4),
nodes: [
{ stage: 1, label: '发现机会', at: fmtYearMonth(first.occurredAt!), done: true },
{ stage: 2, label: '进入治疗链', at: fmtYearMonth(first.occurredAt!), done: true },
{ stage: 3, label: '治疗执行', at: fmtYearMonth(last.occurredAt!), done: true },
{
stage: 4,
label: '术后管理',
at: allCompleted ? fmtYearMonth(last.occurredAt!) : '进行中',
done: allCompleted,
current: !allCompleted,
},
{
stage: 5,
label: allCompleted ? '闭环' : '未到',
at: allCompleted ? fmtYearMonth(last.occurredAt!) : '—',
done: allCompleted,
missing: !allCompleted,
},
],
});
// ─ S1 信号 ─(本 bucket 内 sig,已由 collectChainBuckets 按 code+tooth 分好)
const actuals0 = getActualTreatments(category, byType);
if (s1Facts.length === 0 && actuals0.length === 0) {
// 既没诊断也没治疗 — 不立链(全是其他 category 残留信号场景)
return null;
}
// 弱信号链过滤:有 actual 但完全无诊断 + actual subtype 也不命中 milestone steps → 丢弃
// 例:罗国标 preventive RVG/CT 被 host 归预防类,但 subtype 不在["洁牙","涂氟","封闭"]字典 →
// 立链后是 stage=1 discovered + "无诊断信号" 占位 → 客服看到困惑。
if (s1Facts.length === 0) {
const matched0 = matchMilestoneSteps(actuals0, milestone);
if (milestone && matched0.matched.length === 0) {
return null;
}
return out;
}
const s1Earliest = earliest(s1Facts);
// ─ 先算 S3 actual(后面 S2 需要 s3FirstActual 作 actual-only 桶 anchor)
// **时间方向** — 只算 s1 之后的 actual(跟 scenario SQL `tx.occurred_at >= sig.occurred_at` 同口径)
// s1Earliest null(actual-only 桶)→ 全 actual 都算(无 sig 锚点)
const allActuals = getActualTreatments(category, byType);
const actuals = s1Earliest?.occurredAt
? allActuals.filter((tx) => tx.occurredAt && tx.occurredAt.getTime() >= s1Earliest.occurredAt!.getTime())
: allActuals;
const matchedSteps = matchMilestoneSteps(actuals, milestone);
const s3Reached = milestone ? matchedSteps.matched.length >= milestone.minSteps : actuals.length > 0;
const s3FirstActual = earliest(actuals);
const s3LastActual = latest(actuals);
// ─ S2 信号 ─ "进入治疗链" 事件(预约 complaint_category / planned treatment fallback)
// anchor 时间:s1AnchorTime(诊断之后);actual-only 桶 → null(任意时间)
// 时间上界:s3FirstActual — 只对 **预约** 生效(临床:先约后做);planned 不约束(医生随时可开下一步)
const s1AnchorTime = s1Earliest?.occurredAt ?? null;
const s2UpperTime = s3FirstActual?.occurredAt ?? null;
const s2Hits = collectS2Facts(category, byType, s1AnchorTime, bucket.tooth, s2UpperTime);
const s2Earliest = earliest(s2Hits);
// ─ S4 信号 ─ S3 之后的复查 / 术后随访
const s3AnchorTime = s3LastActual?.occurredAt ?? null;
const s4Hits = s3AnchorTime ? collectS4Facts(category, byType, s3AnchorTime) : [];
const s4Earliest = earliest(s4Hits);
// ─ S5 信号 ─ closed 条件(W3 末调整 — 不再要求 allSatisfied)
// 1. S3 满足 minSteps(s3Reached) — 例:种植 minSteps=2 要植入+修复都做才可能 closed;
// 单步类(充填/拔除/洁牙)minSteps=1 做一次即可。**不再要求 milestone 全 steps 都做**
// (preventive 字典 ['洁牙','涂氟','封闭'] 只做洁牙也算 closed,涂氟/封闭非必做)
// 2. S4 至少 1 命中(术后随访 / 复查 encounter / planned review / *_REVIEW_RECOMMENDED)
// 3. lifecycle.maxStage = 5(lifelong_maintenance 直接卡死 stage 4)
// 4. 无 S3 之后的 refund 同 patient(简化:patient 级,跨 category 也判 — 后续接 host order_id 后细化)
// 5. 无 S3 之后同位置反弹诊断(同 code + 牙位 overlap > 0 视为反弹)
const s5Eligible =
s3Reached &&
s4Hits.length > 0 &&
maxAllowedStage === 5 &&
!hasPostS3Refund(byType, s3AnchorTime) &&
!hasPostS3Relapse(category, byType, s3AnchorTime);
// ─ 推导 currentStage + status ─
let currentStage: 1 | 2 | 3 | 4 | 5;
let status: ChainStatus;
if (s5Eligible) {
currentStage = 5;
status = 'closed';
} else if (s4Hits.length > 0 && s3Reached) {
// S4 命中但不到 S5(可能 lifelong_maintenance 卡 4,或 milestone 未全满足,或反弹)
currentStage = 4;
status = 'ongoing';
} else if (s3Reached) {
currentStage = 3;
status = 'ongoing';
} else if (s2Hits.length > 0) {
currentStage = 2;
status = 'entered';
} else {
currentStage = 1;
status = 'discovered';
}
// ─ 链元信息 ─
const code = s1Earliest ? String((s1Earliest.content as Record<string, unknown>).code ?? '') : '';
const rawTooth = s1Earliest ? String((s1Earliest.content as Record<string, unknown>).tooth_position ?? '') : '';
const dxRule = code ? lookupDxTreatment(code) : undefined;
const fallback = dxRule?.wholeMouth ? '全口' : '未标注牙位';
const chainLabel = dxRule?.chainLabel ?? CATEGORY_LABEL[category] ?? category;
// ⭐ tooth 显示用 bucket.tooth(union-find 合并后的并集 normalize 排序),不用 s1 第一条 rawTooth
// union-find 把同 (category, code) tooth 有交集的多个桶合并 → bucket.tooth 是完整并集
// 显示并集让客服一眼看到"全部受影响牙位",不是只看到首诊那 1-3 颗
// 例:张超 K08 合并 9 次诊断 → bucket.tooth="11;15;16;17;22;26;27;37" (8 颗) vs 旧版只显示 s1 的 "15;16;17"
// wholeMouth(K05/SRP_RECOMMENDED)桶 tooth='*whole' 统一显示"全口"
const tooth = dxRule?.wholeMouth
? '全口'
: (bucket.tooth && bucket.tooth !== '*whole' ? bucket.tooth : rawTooth);
const chainName = `${chainLabel} · ${tooth || fallback}`;
const diagnosedAt = s1Earliest?.occurredAt ? fmtYearMonthDay(s1Earliest.occurredAt) : '—';
const gapDays = s1Earliest?.occurredAt
? Math.floor((Date.now() - s1Earliest.occurredAt.getTime()) / 86400_000)
: undefined;
// ★ 潜在新链 = 仅 discovered(纯诊断、零治疗规划 / 付费)。
// entered(已有 planned 治疗 / 大额付款,但还没 actual)属于"已启动·待治疗",跟 scenario SQL 的
// "未启动相关治疗" 排除口径对齐 — 已 planned 的不再算"潜在",避免跟 plan_reasons 集合背离。
//
// ⭐ cooldown 同步(W3 末):scenario SQL 已过滤 < cooldownDays 的诊断(K02=14/K04=14/K05=30/K08=30),
// chain-composer 也尊重此口径 — discovered 状态 + 未过 cooldown → **直接不立链**
// (不搞"诊断观察期"灰态 — 真没到召回时机,UI 就别显示;过 cooldown 后才出现)
// entered/ongoing/closed 不受 cooldown 影响(已经在治疗链里了)
const cooldownDays = dxRule?.cooldownDays ?? 0;
if (status === 'discovered' && (gapDays ?? 0) < cooldownDays) {
return null;
}
return {
id: `chain_${category}_${s1Earliest?.id ?? s3FirstActual?.id ?? 'unknown'}`,
name: chainName,
category,
// chain 对应诊断码(K0x)— SQL 为准对齐用:plan-aggregate 按 chain.code === reason.signal_code 严格匹配 ★
// bucket.code 从 s1Earliest 拿;actual-only 桶(无诊断)code='' 不参与 ★ 对齐
code: bucket.code || (s1Earliest ? String((s1Earliest.content as Record<string, unknown>).code ?? '') : ''),
// DxMap.code.categories 全集 — 给其他场景(如 alternative-closed)用
allCategories: dxRule?.categories,
status,
currentStage,
target: status === 'discovered',
diagnosedAt,
gapDays,
lifecycleNoteZh: lifecycle.noteZh,
// 给 cross-chain alternative-closed pass 用;wholeMouth 桶 bucket.tooth='*whole'(内部 key),
// 那种情况回填 s1Earliest 真实牙位串;其他用 bucket.tooth 并集(union-find 后)
toothPosition: bucket.tooth && bucket.tooth !== '*whole' ? bucket.tooth : rawTooth,
nodes: buildStageNodes({
currentStage,
status,
category,
lifecycle,
milestone,
s1Earliest,
s2Earliest,
s1Facts,
actuals,
matchedSteps,
s3LastActual,
s4Earliest,
s4Hits,
byType,
doctorMap,
}),
};
}
// ─────────────────────────────────────────────
// 配置(集中此处便于业务方调)
// 信号收集 helpers
// ─────────────────────────────────────────────
/// 漏治链推断:dx code → 链名(估值字段已废弃 — 没有可信来源,业务方未校准)
interface FactsByType {
diagnosis: ChainComposeInputFact[];
recommendation: ChainComposeInputFact[];
treatment: ChainComposeInputFact[];
appointment: ChainComposeInputFact[];
payment: ChainComposeInputFact[];
encounter: ChainComposeInputFact[];
refund: ChainComposeInputFact[];
}
function groupByType(facts: ChainComposeInputFact[]): FactsByType {
return {
diagnosis: facts.filter((f) => f.type === FactType.DIAGNOSIS_RECORD),
recommendation: facts.filter((f) => f.type === FactType.RECOMMENDATION_RECORD),
treatment: facts.filter((f) => f.type === FactType.TREATMENT_RECORD),
appointment: facts.filter((f) => f.type === FactType.APPOINTMENT_RECORD),
payment: facts.filter((f) => f.type === FactType.PAYMENT_RECORD),
encounter: facts.filter((f) => f.type === FactType.ENCOUNTER_RECORD),
refund: facts.filter((f) => f.type === FactType.REFUND_RECORD),
};
}
/**
* doctor_id → doctor_name 解析表(从所有 facts 学习一遍)。
*
* 缘由:host 部分 fact 给 doctor_id 但缺 doctor_name(典型:fact_settlement_out 只有 doctor_id;
* fact_appointment_out 也常缺 name)。同 patient 的 emr_record / diagnosis_record 一般两个都给,
* 这里用 patient 内 (doctor_id, doctor_name) 学一个 map,渲染时缺 name 的 fact fallback 查表。
*
* 同 patient 内一般不会有两位医生同 ID(host id 唯一),冲突时最后写入胜出。
*/
function buildDoctorMap(facts: ChainComposeInputFact[]): Map<string, string> {
const map = new Map<string, string>();
for (const f of facts) {
const c = f.content as Record<string, unknown> | null;
if (!c) continue;
const id = c.doctor_id ? String(c.doctor_id) : '';
const name = c.doctor_name ? String(c.doctor_name) : '';
if (id && name) map.set(id, name);
}
return map;
}
/// 取 fact 的医生名:有 doctor_name 直接用,否则用 doctor_id 查解析表
function resolveDoctorName(
f: ChainComposeInputFact | null,
doctorMap: Map<string, string>,
): string | undefined {
if (!f) return undefined;
const c = f.content as Record<string, unknown> | null;
if (!c) return undefined;
const name = (c.doctor_name as string | undefined) ?? '';
if (name) return name;
const id = c.doctor_id ? String(c.doctor_id) : '';
return id ? doctorMap.get(id) : undefined;
}
/**
* 链桶(W3 末新)— 每个 bucket 立一条独立 chain
* key = (category, code, tooth) 三元组
* signals = 同桶的全部诊断/推荐 fact(同 K 码 + 同牙位的多次诊断合并到一桶)
*
* 实例罗国标 K08 多牙位:
* bucket A: (implant, K08, 34) → K08 34 诊断 5-13(actual 12-15 在后 → 闭环)
* bucket B: (implant, K08, 21) → K08 21 诊断 12-25/12-31(无 21 actual → 缺口/discovered)
* bucket C: (implant, K08, 14;15;16;17;47) → K08 14-17;47 诊断 4-23(actual 早于诊断 → 缺口)
* 旧版按 category 聚合 → 只立"种植修复·34"(K08 最早牙位),漏掉 K08 21 / K08 14-17 → scenario 召回 21 但 chain 不显示
*/
interface ChainBucket {
category: string;
code: string; // '' 表示无诊断,纯 actual-only 桶
tooth: string;
signals: ChainComposeInputFact[];
}
function collectChainBuckets(byType: FactsByType): ChainBucket[] {
const map = new Map<string, ChainBucket>();
const addSignal = (category: string, code: string, tooth: string, sig: ChainComposeInputFact) => {
const key = `${category}|${code}|${tooth}`;
let b = map.get(key);
if (!b) {
b = { category, code, tooth, signals: [] };
map.set(key, b);
}
b.signals.push(sig);
};
// 诊断 / 推荐 — primary category(rule.categories[0])
// ⭐ wholeMouth=true 的码(K05 牙周等)用统一 bucket key '*whole'(不按精确 tooth 串)
// 临床上全口治疗就一条链,复诊医生改了几颗牙位描述(如 42→32)不应拆桶
// ⭐ 非 wholeMouth 也用 **normalizeTooth**(排序+去重)做桶 key — host 多次诊断
// 顺序可能不同("17;47;37" vs "47;17;37"),同 3 颗牙就该合 1 桶(W3 末)
// 例:王辉 K08 多次诊断 17;47;37 / 47;17;37 字符串顺序差异 → 旧版拆 2 条种植链,新版合 1 条
for (const dx of byType.diagnosis) {
const c = dx.content as Record<string, unknown>;
const code = String(c.code ?? '');
const rawTooth = String(c.tooth_position ?? '').trim();
const rule = lookupDxTreatment(code);
if (rule && rule.categories.length > 0) {
const tooth = rule.wholeMouth ? '*whole' : normalizeTooth(rawTooth);
addSignal(rule.categories[0]!, code, tooth, dx);
}
}
for (const rec of byType.recommendation) {
const c = rec.content as Record<string, unknown>;
const code = String(c.code ?? '');
const rawTooth = String(c.tooth_position ?? '').trim();
const rule = lookupDxTreatment(code);
if (rule && rule.categories.length > 0) {
const tooth = rule.wholeMouth ? '*whole' : normalizeTooth(rawTooth);
addSignal(rule.categories[0]!, code, tooth, rec);
}
}
// actual-only:有 actual 但 category 没出现在以上 dx 桶里 → 立无诊断空桶,等 inferChainStage 判定
// ⭐ 排除清单(W3 末):
// - review 非治疗动作(复查)
// - unknown yaml 未分类
// - preventive 洁牙 / 涂氟 / 封闭 等周期性预防保健,临床上跟"治疗链"语义不符(无诊断 → 不该立链)
// 真有诊断 + preventive 治疗的(如 K00 釉质发育不全 → 涂氟)仍立桶(因有 S1 dx 桶,
// 这里只过滤 **actual-only** preventive)。K05 牙周诊断的"洁牙"已通过 K05.categories 含
// preventive 算"已启动牙周治疗",不需要独立 preventive 链兜底。
const EXCLUDED_ACTUAL_ONLY_CATS = new Set(['review', 'unknown', 'preventive']);
const dxCategories = new Set([...map.values()].map((b) => b.category));
const actualCategories = new Set<string>();
for (const tx of byType.treatment) {
if (tx.kind !== FactKind.ACTUAL) continue;
const cat = String((tx.content as Record<string, unknown>).category ?? '');
if (cat && !EXCLUDED_ACTUAL_ONLY_CATS.has(cat)) actualCategories.add(cat);
}
for (const cat of actualCategories) {
if (!dxCategories.has(cat)) {
map.set(`${cat}||`, { category: cat, code: '', tooth: '', signals: [] });
}
}
// ⭐ tooth overlap union(W3 末)— 同 (category, code) 下,tooth 集合有交集的桶合并为一条链
// 解决:host 多次诊断牙位漂移(医生陆续完成 47/37,牙位描述跟着变 → {4,37}/{37,47}/{47})
// 普通 normalizeTooth 严格匹配只合"完全相同 tooth",不合"有重叠 tooth";
// 临床上多次诊断同 K 码下相邻牙位都是同一治疗链,应合
// wholeMouth 桶 tooth='*whole' 已经合并,跳过
// actual-only 桶 tooth='' 也跳过(本就是粗粒度兜底)
return mergeOverlappingBuckets([...map.values()]);
}
/// 同 (category, code) 下,tooth 集合有交集的桶合并 — 用 union-find 处理传递性
/// (A∩B、B∩C 但 A∩C=∅ → A、B、C 应合 1 桶,union-find 自动处理)
function mergeOverlappingBuckets(buckets: ChainBucket[]): ChainBucket[] {
if (buckets.length <= 1) return buckets;
// 按 (category, code) 分组
const groups = new Map<string, number[]>();
buckets.forEach((b, i) => {
const key = `${b.category}|${b.code}`;
const arr = groups.get(key) ?? [];
arr.push(i);
groups.set(key, arr);
});
// union-find
const parent = buckets.map((_, i) => i);
const find = (i: number): number => (parent[i] === i ? i : (parent[i] = find(parent[i]!)));
const union = (a: number, b: number) => {
const ra = find(a), rb = find(b);
if (ra !== rb) parent[ra] = rb;
};
for (const ids of groups.values()) {
if (ids.length <= 1) continue;
for (let i = 0; i < ids.length; i++) {
const bi = buckets[ids[i]!]!;
// tooth 空(actual-only)/ '*whole'(wholeMouth)— 不参与 overlap union(各自独立)
if (!bi.tooth || bi.tooth === '*whole') continue;
for (let j = i + 1; j < ids.length; j++) {
const bj = buckets[ids[j]!]!;
if (!bj.tooth || bj.tooth === '*whole') continue;
if (toothOverlap(bi.tooth, bj.tooth)) {
union(ids[i]!, ids[j]!);
}
}
}
}
// 合并:root → 合后桶(tooth 取并集 normalizeTooth,signals 合并)
const merged = new Map<number, ChainBucket>();
for (let i = 0; i < buckets.length; i++) {
const root = find(i);
const m = merged.get(root);
if (!m) {
merged.set(root, { ...buckets[i]!, signals: [...buckets[i]!.signals] });
} else {
// tooth 取并集再 normalize
m.tooth = normalizeTooth(`${m.tooth};${buckets[i]!.tooth}`);
m.signals.push(...buckets[i]!.signals);
}
}
return [...merged.values()];
}
/// S1 = diagnosis/recommendation 中 code 映射到当前 category 的 fact
function collectS1Facts(category: string, byType: FactsByType): ChainComposeInputFact[] {
const out: ChainComposeInputFact[] = [];
for (const f of [...byType.diagnosis, ...byType.recommendation]) {
const code = String((f.content as Record<string, unknown>).code ?? '');
const rule = lookupDxTreatment(code);
if (rule?.categories.includes(category as never)) out.push(f);
}
return out;
}
/// S2 = S1 之后的"进入治疗链" 强信号 — **预约主诉类别**(W3 末重写)
///
/// ⚠️ **只信 canonical code,不信中文 name 关键词**(W3 末修复):
/// code 已是 canonical-codes.ts zod 校验过的闭集(K00-K14 + 推荐码),稳定可靠;
/// 中文 name 是 host 自由文本,关键词匹配会误判 —— 例如 K04「慢性根尖牙周炎」
/// 字面含「牙周」二字,曾被 `name.includes('牙周')` 误归为 periodontic(实为 endodontic 根管)。
/// 旧版用 planned treatment(医生录入治疗计划)作 S2 信号,问题:
/// - 医生侧动作 ≠ 患者承诺(医生 3 年开 SRP 计划,患者每次只洁牙拒绝 SRP)
/// - 实例:王辉 K05 慢性牙龈炎 + 3 次 planned 龈上洁治术 → 旧版 entered,实际患者一次没做 SRP
///
/// 类别 / 链名 / 全口语义全部读单一真理源 canonical-codes.DiagnosisTreatmentMap;
/// 不在闭集内的 code(如 K07)→ unknown,不产链。
/// 新版只看 **patient 主动预约同 category** (appointment.complaint_category 命中):
/// - "牙周" 预约 → S2 命中 K05 牙周链
/// - "种植" 预约 → S2 命中 K08 种植链
/// - "常规" / "拔牙" / 其他不匹配 → 不命中
/// - 预约是患者主动行为(挂号、约时间、来店),代理"真意向" 比"医生侧计划" 准
///
/// **空牙位语义**(map.wholeMouth):
/// - K05 牙周炎/牙周病:全口性疾病,医生不写牙位 = 全口 → 显示"全口"
/// - 其他(K02/K04/K08 等):本应有具体牙位,空 = 数据缺失 → 显示"未标注牙位"
function inferUninitiated(
dxCode: string,
tooth: string,
): { category: string; chainName: string; estimatedValueCents: number } {
const rule = lookupDxTreatment(dxCode);
if (!rule) {
return { category: 'unknown', chainName: '漏治-未启动治疗', estimatedValueCents: 0 };
/// 桶 tooth 不做约束:预约 complaint_category 无牙位字段(挂号阶段不细到牙),按 category 粗判即可
/// (path 牙位精度走后续 S3 actual treatment 阶段)
function collectS2Facts(
category: string,
byType: FactsByType,
s1AnchorTime: Date | null,
_bucketTooth: string, // 保留参数 — appointment 阶段无牙位粒度,不消费
apptUpperTime: Date | null, // 预约时间上界(s3FirstActual)— 临床上预约必须早于 actual
): ChainComposeInputFact[] {
// anchor=null(actual-only 桶)→ 不做时间下界约束(patient 全程都算)
// anchor 非空 → sig 之后才算(diagnosis 锚点)
const passesLower = (f: ChainComposeInputFact): boolean => {
if (!s1AnchorTime) return true;
const t = effectiveTime(f);
return !!(t && t.getTime() >= s1AnchorTime.getTime());
};
// 预约时间上界:appointment 必须早于 S3 第一笔 actual(先约后做);planned 不约束(医生随时开下一步计划)
const passesUpper = (f: ChainComposeInputFact): boolean => {
if (!apptUpperTime || f.type !== FactType.APPOINTMENT_RECORD) return true;
const t = effectiveTime(f);
return !!(t && t.getTime() < apptUpperTime.getTime());
};
const out: ChainComposeInputFact[] = [];
// ① 预约 complaint_category 命中(强信号 — 患者主动预约)
for (const appt of byType.appointment) {
if (appt.status === 'cancelled') continue;
if (!passesLower(appt) || !passesUpper(appt)) continue;
const rawComplaint = String((appt.content as Record<string, unknown>).complaint_category ?? '');
const cats = parseComplaintCategories(rawComplaint);
if (cats.includes(category as PACTreatmentCategory)) {
out.push(appt);
}
}
if (out.length > 0) return out;
// ② fallback: planned treatment(同 category) — 弱信号(医生计划随时可开,治疗中也可开下一步)
// 不受 apptUpperTime 约束 — "做完一项,医生开下一项" 也是合法 S2 信号(显示给客服看)
// 例:路遥 K05 首诊当天 actual 洁治 + 同日 planned 牙周刮治术 → 显示"已开计划·牙周刮治术"
for (const tx of byType.treatment) {
if (tx.kind !== FactKind.PLANNED) continue;
const tc = tx.content as Record<string, unknown>;
const cat = String(tc.category ?? '');
if (cat !== category || !passesLower(tx)) continue;
out.push(tx);
}
return out;
}
/// 牙位串规整化 — 用于桶分 key:同 N 颗牙不同顺序("17;47;37" / "47;17;37")合 1 桶。
/// 牙位号去 trim + 去重 + 字典序排序 + ";" 拼接。空 → 返回空串(actual-only 桶兼容)。
/// 桶 name 显示仍用 s1Earliest 的 rawTooth(医生录入原序),保留语义。
function normalizeTooth(s: string): string {
if (!s.trim()) return '';
const list = Array.from(
new Set(s.split(';').map((t) => t.trim()).filter(Boolean)),
).sort();
return list.join(';');
}
/// 牙位是否有交集(用 ; 分隔,trim 后比较)
function toothOverlap(a: string, b: string): boolean {
const setA = new Set(
a.split(';').map((s) => s.trim()).filter(Boolean),
);
if (setA.size === 0) return false;
for (const t of b.split(';')) {
if (setA.has(t.trim())) return true;
}
return false;
}
/// (W3 末废弃)S2 大额 payment 阈值字典 — payment 无 category/tooth 关联,作为 S2 信号会误归桶
/// 例:罗国标 K04 14;15 桶被"种植定金 ¥4260"误判 entered。改成只用 planned treatment 作 S2 信号。
/// 字典保留(防有 host 接入时按 payment 阈值过滤误算入 LTV / 大额识别),实际不消费。
const _S2_PAYMENT_THRESHOLD_CENTS_DEPRECATED: Record<string, number> = {
implant: 500000,
orthodontic: 500000,
prosthodontic: 200000,
endodontic: 100000,
periodontic: 50000,
restorative: 30000,
surgical: 30000,
preventive: 30000,
cosmetic: 100000,
pediatric: 30000,
};
void _S2_PAYMENT_THRESHOLD_CENTS_DEPRECATED;
/// S3 = 同 category 的 actual treatment
function getActualTreatments(category: string, byType: FactsByType): ChainComposeInputFact[] {
return byType.treatment.filter((tx) => {
if (tx.kind !== FactKind.ACTUAL) return false;
const cat = String((tx.content as Record<string, unknown>).category ?? '');
return cat === category;
});
}
interface MilestoneMatch {
/// 命中的 milestone step(按字典顺序)
matched: string[];
/// 是否全步骤都命中
allSatisfied: boolean;
/// 每个 step → 命中的 actual treatment fact(用于 timeline label)
stepToFact: Map<string, ChainComposeInputFact>;
}
/// actual treatments 按 milestone.steps 关键词匹配 subtype.includes(step)
function matchMilestoneSteps(
actuals: ChainComposeInputFact[],
milestone: TreatmentMilestone | undefined,
): MilestoneMatch {
const stepToFact = new Map<string, ChainComposeInputFact>();
if (!milestone || milestone.steps.length === 0) {
// 无字典定义 → 视为 one_shot:有 actual 即满足"step 1"
if (actuals.length > 0) stepToFact.set('treatment', actuals[0]!);
return {
matched: actuals.length > 0 ? ['treatment'] : [],
allSatisfied: actuals.length > 0,
stepToFact,
};
}
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);
});
if (hit) {
matched.push(step);
stepToFact.set(step, hit);
}
}
const t = tooth.trim();
const fallback = rule.wholeMouth ? '全口' : '未标注牙位';
return {
category: rule.categories[0]!,
chainName: `${rule.chainLabel} · ${t || fallback}`,
estimatedValueCents: 0,
matched,
allSatisfied: matched.length === milestone.steps.length,
stepToFact,
};
}
/// S4 = S3 之后的复查 / 术后随访信号
function collectS4Facts(
category: string,
byType: FactsByType,
s3AnchorTime: Date,
): ChainComposeInputFact[] {
const after = (f: ChainComposeInputFact) => f.occurredAt && f.occurredAt.getTime() > s3AnchorTime.getTime();
const out: ChainComposeInputFact[] = [];
// review category 的 treatment_record(复查 / 拆线 / 暂观,真实落 fact)
for (const tx of byType.treatment) {
const cat = String((tx.content as Record<string, unknown>).category ?? '');
if (cat === 'review' && after(tx)) out.push(tx);
}
// planned 同 category 复查(treat_plan 有 review 类计划)
for (const tx of byType.treatment) {
if (tx.kind !== FactKind.PLANNED) continue;
const cat = String((tx.content as Record<string, unknown>).category ?? '');
if (cat === category && after(tx)) out.push(tx);
}
// S3 之后的 encounter(复诊接诊)— 粗粒度,任何后续接诊都算"复查/术后" 信号
for (const enc of byType.encounter) {
if (after(enc)) out.push(enc);
}
// recommendation_record 中的 ANNUAL_REVIEW_RECOMMENDED 等(粗粒度,任何 review 类推荐都算)
for (const rec of byType.recommendation) {
const code = String((rec.content as Record<string, unknown>).code ?? '');
if (code.includes('REVIEW') && after(rec)) out.push(rec);
}
return out;
}
/// 是否 S3 之后有 refund(同 patient 级,跨 category 也判;后续接 host order_id 后细化)
function hasPostS3Refund(byType: FactsByType, s3AnchorTime: Date | null): boolean {
if (!s3AnchorTime) return false;
return byType.refund.some(
(r) => r.occurredAt && r.occurredAt.getTime() > s3AnchorTime.getTime(),
);
}
/// 是否 S3 之后同位置反弹诊断(同 code + 牙位 overlap > 0)
function hasPostS3Relapse(
category: string,
byType: FactsByType,
s3AnchorTime: Date | null,
): boolean {
if (!s3AnchorTime) return false;
return byType.diagnosis.some((dx) => {
if (!dx.occurredAt || dx.occurredAt.getTime() <= s3AnchorTime.getTime()) return false;
const code = String((dx.content as Record<string, unknown>).code ?? '');
const rule = lookupDxTreatment(code);
return rule?.categories.includes(category as never);
});
}
// ─────────────────────────────────────────────
// timeline nodes 构造
// ─────────────────────────────────────────────
function buildStageNodes(opts: {
currentStage: 1 | 2 | 3 | 4 | 5;
status: ChainStatus;
category: string;
lifecycle: { maxStage: 4 | 5; noteZh: string; expectedSpanMonths?: number };
milestone: TreatmentMilestone | undefined;
s1Earliest: ChainComposeInputFact | null;
s2Earliest: ChainComposeInputFact | null;
s1Facts: ChainComposeInputFact[];
actuals: ChainComposeInputFact[];
matchedSteps: MilestoneMatch;
s3LastActual: ChainComposeInputFact | null;
s4Earliest: ChainComposeInputFact | null;
s4Hits: ChainComposeInputFact[];
byType: FactsByType;
doctorMap: Map<string, string>;
}): ChainNode[] {
const {
currentStage,
status,
category,
lifecycle,
milestone,
s1Earliest,
s2Earliest,
s1Facts,
actuals,
matchedSteps,
s3LastActual,
s4Earliest,
s4Hits,
byType,
doctorMap,
} = opts;
const reached = (s: number) => currentStage >= s;
const cur = (s: number) => currentStage === s;
const fmt = (d: Date | null | undefined) => (d ? fmtYearMonthDay(d) : '—');
const expectedHint = CATEGORY_RECOMMEND[category] ?? '相应治疗';
// ─── S1 发现机会 ───────────────────────────────────
const s1Node: ChainNode = (() => {
const n: ChainNode = { stage: 1, at: '—', done: reached(1), current: cur(1) };
if (!s1Earliest) {
// 无诊断但有 actual treatment(host 数据缺诊断 case)
n.title = '—';
n.detail = '无诊断信号';
return n;
}
const c = s1Earliest.content as Record<string, unknown>;
const isRec = s1Earliest.type === FactType.RECOMMENDATION_RECORD;
const code = String(c.code ?? '');
const codeName =
String(c.name_zh ?? c.name ?? '') ||
lookupDxTreatment(code)?.chainLabel ||
code ||
(isRec ? '医生建议' : '诊断');
n.title = isRec ? `建议 ${codeName}` : codeName;
n.detail = formatToothShort(String(c.tooth_position ?? ''), category);
n.doctor = resolveDoctorName(s1Earliest, doctorMap);
n.at = fmt(effectiveTime(s1Earliest));
return n;
})();
// ─── S2 进入治疗链 ─────────────────────────────────
// ⭐ done 状态独立判定:不能只看 currentStage>=2(那样 S3→S2 直接跳的患者也假打 ✓)
// 真正 done 必须有 s2Earliest 信号(预约 complaint_category 命中);否则即使 stage=3 也显示"跳过"
// 临床场景:急诊补牙 / 检查发现龋直接当场修(S1→S3 直跳,没经过预约修复主诉) — 这种 S2 是真没命中
const s2Node: ChainNode = (() => {
const s2Done = !!s2Earliest;
const n: ChainNode = { stage: 2, at: '—', done: s2Done, current: cur(2), missing: !s2Done };
if (!s2Earliest) {
// S2 无信号:看 status 决定文案
if (status === 'discovered') {
// 未启动治疗,医生建议什么(rose hint)
n.title = '尚未启动';
n.hint = `建议${expectedHint}`;
} else {
// S3+ 状态但 S2 没命中 → 患者跳过预约直接来诊(常见于急诊处理 / 复诊连带补牙)
n.title = '直接执行';
n.detail = '未经预约';
}
return n;
}
const c = s2Earliest.content as Record<string, unknown>;
if (s2Earliest.type === FactType.APPOINTMENT_RECORD) {
// 预约 complaint_category 命中:显示"预约 + 主诉文本"
const complaint = String(c.complaint_text ?? c.complaint_category ?? '').trim();
n.title = '预约就诊';
n.detail = complaint ? shortLabel(complaint) : '已主动预约';
n.doctor = resolveDoctorName(s2Earliest, doctorMap);
} else if (s2Earliest.type === FactType.TREATMENT_RECORD) {
// (兜底)planned 治疗:显示 subtype + "计划"
n.title = shortLabel(String(c.subtype ?? '') || '治疗计划');
n.detail = '已开计划';
n.doctor = resolveDoctorName(s2Earliest, doctorMap);
} else if (s2Earliest.type === FactType.PAYMENT_RECORD) {
// (兜底)大额 payment:显示"已付款 ¥X"
const amount = Number(c.amount_cents ?? 0);
n.title = '已付款';
n.detail = formatYuan(amount);
} else {
n.title = '已进入';
}
n.at = fmt(effectiveTime(s2Earliest));
return n;
})();
// ─── S3 治疗执行 ───────────────────────────────────
const s3Node: ChainNode = (() => {
const n: ChainNode = { stage: 3, at: '—', done: reached(3), current: cur(3), missing: !reached(3) };
if (!s3LastActual) {
n.title = '未进行';
n.hint = status === 'entered' ? `等待${expectedHint}` : undefined;
return n;
}
// 命中的 milestone steps 拼起来(种植"植入 → 上部修复")
const titleSteps =
matchedSteps.matched.length > 0
? matchedSteps.matched.map(shortLabel).join(' → ')
: shortSubtype(s3LastActual);
n.title = titleSteps || '治疗';
// detail:进度 + 总金额(if available)
const totalCents = sumAmountCents(actuals);
const progress = milestone && milestone.steps.length > 1
? `${matchedSteps.matched.length}/${milestone.steps.length} 步骤`
: `${actuals.length} 次治疗`;
n.detail = totalCents > 0 ? `${progress} · ${formatYuan(totalCents)}` : progress;
n.doctor = resolveDoctorName(s3LastActual, doctorMap);
n.at = fmt(s3LastActual.occurredAt);
return n;
})();
// ─── S4 术后复查 ───────────────────────────────────
const s4Node: ChainNode = (() => {
const n: ChainNode = { stage: 4, at: '—', done: reached(4), current: cur(4), missing: !reached(4) };
if (!s4Earliest) {
n.title = '未复查';
n.hint = status === 'ongoing' && currentStage === 3 ? '建议术后复诊' : undefined;
return n;
}
const c = s4Earliest.content as Record<string, unknown>;
// 若是复评 diagnosis,提示牙位差异
if (s4Earliest.type === FactType.DIAGNOSIS_RECORD) {
const code = String(c.code ?? '');
const codeName = String(c.name_zh ?? c.name ?? code);
n.title = `${codeName} 复评`;
const reTooth = String(c.tooth_position ?? '').split(';').filter(Boolean).length;
const baseTooth = s1Earliest
? String((s1Earliest.content as Record<string, unknown>).tooth_position ?? '')
.split(';').filter(Boolean).length
: 0;
if (baseTooth > 0 && reTooth !== baseTooth) {
const diff = reTooth - baseTooth;
n.detail = `${reTooth}${diff > 0 ? `(+${diff})` : `(${diff})`}`;
} else {
n.detail = reTooth > 0 ? `${reTooth} 牙` : '—';
}
} else if (s4Earliest.type === FactType.ENCOUNTER_RECORD) {
n.title = '复诊接诊';
n.detail = `共 ${s4Hits.filter((f) => f.type === FactType.ENCOUNTER_RECORD).length} 次`;
} else if (s4Earliest.type === FactType.TREATMENT_RECORD) {
const cat = String(c.category ?? '');
n.title = cat === 'review' ? '复查 / 拆线' : '复查计划';
n.detail = shortLabel(String(c.subtype ?? '')) || '—';
} else {
n.title = '复查';
}
n.doctor = resolveDoctorName(s4Earliest, doctorMap);
n.at = fmt(effectiveTime(s4Earliest));
return n;
})();
// ─── S5 闭环 / 维护期 / 未闭环 ─────────────────────
const s5Node: ChainNode = (() => {
const n: ChainNode = { stage: 5, at: '—', done: reached(5), current: cur(5), missing: !reached(5) };
// lifelong_maintenance:永远卡 stage 4,S5 显示"维护期"
if (lifecycle.maxStage === 4) {
n.title = '维护期';
n.detail = lifecycle.noteZh;
// 查 active 预约
const nextApt = findNextActiveAppointment(byType.appointment);
if (nextApt) {
n.hint = `已预约 ${fmt(effectiveTime(nextApt))}`;
} else {
n.hint = '建议周期复查';
}
n.missing = false; // 维护型不能算"missing 未到",改"无终点"
return n;
}
// 已闭环
if (status === 'closed') {
n.title = '已闭环';
// 总周期 = S1 → S4
if (s1Earliest && s4Earliest) {
const s1t = effectiveTime(s1Earliest);
const s4t = effectiveTime(s4Earliest);
if (s1t && s4t) {
const days = Math.floor((s4t.getTime() - s1t.getTime()) / 86400_000);
n.detail = `周期 ${days} 天`;
}
}
if (s4Earliest) n.at = fmt(effectiveTime(s4Earliest));
return n;
}
// 未闭环:提示还差什么
n.title = '未闭环';
if (milestone && matchedSteps.matched.length < milestone.steps.length) {
const missing = milestone.steps.filter((s) => !matchedSteps.matched.includes(s));
if (missing.length > 0) {
n.hint = `待 ${missing.map(shortLabel).join(' / ')}`;
}
} else if (currentStage === 3) {
n.hint = '待术后复查';
}
return n;
})();
return [s1Node, s2Node, s3Node, s4Node, s5Node];
}
/// 牙位简化显示:全口 / 22 颗 / 11;12;... / 未标注牙位
function formatToothShort(tooth: string, category: string): string {
if (!tooth.trim()) {
const rule = lookupDxTreatment(/* dxCode 无 */ '');
return rule?.wholeMouth || category === 'periodontic' ? '全口' : '未标注牙位';
}
const list = tooth.split(';').map((s) => s.trim()).filter(Boolean);
if (list.length === 0) return '未标注';
if (list.length <= 3) return list.join(';');
if (list.length >= 20) return `全口 ${list.length} 牙`;
return `${list.slice(0, 3).join(';')}${list.length} 牙`;
}
function formatYuan(cents: number): string {
if (!cents) return '';
const y = cents / 100;
if (y >= 10000) return ${(y / 10000).toFixed(1)}万`;
return ${y.toFixed(0)}`;
}
function sumAmountCents(facts: ChainComposeInputFact[]): number {
let sum = 0;
for (const f of facts) {
const c = f.content as Record<string, unknown>;
const v = Number(c.amount_cents ?? 0);
if (Number.isFinite(v)) sum += v;
}
return sum;
}
/// 找最近一条 active planned 预约(术后/复查 S5 提示用)
function findNextActiveAppointment(
appointments: ChainComposeInputFact[],
): ChainComposeInputFact | null {
const now = Date.now();
let best: ChainComposeInputFact | null = null;
let bestDelta = Infinity;
for (const ap of appointments) {
if (ap.kind !== FactKind.PLANNED) continue;
const t = effectiveTime(ap);
if (!t || t.getTime() < now) continue; // 过去的不算
const delta = t.getTime() - now;
if (delta < bestDelta) {
bestDelta = delta;
best = ap;
}
}
return best;
}
/// 各 category 的"建议什么治疗" 短词(给 discovered/entered hint 用)
const CATEGORY_RECOMMEND: Record<string, string> = {
implant: '种植/桥/义齿',
prosthodontic: '冠桥修复',
restorative: '充填修复',
endodontic: '根管治疗',
periodontic: '牙周基础治疗',
surgical: '拔除',
orthodontic: '正畸治疗',
preventive: '预防保健',
cosmetic: '美学修复',
pediatric: '儿童牙科',
};
// ─────────────────────────────────────────────
// 工具 / 字典
// ─────────────────────────────────────────────
/// 取 fact 的有效时间:actual 用 occurredAt,planned 用 plannedFor 兜底。
/// planned treatment 的 occurredAt 通常 null(parser 设计),真值在 plannedFor。
function effectiveTime(f: ChainComposeInputFact): Date | null {
return f.occurredAt ?? f.plannedFor ?? null;
}
function earliest(facts: ChainComposeInputFact[]): ChainComposeInputFact | null {
let best: ChainComposeInputFact | null = null;
let bestT: Date | null = null;
for (const f of facts) {
const t = effectiveTime(f);
if (!t) continue;
if (!bestT || t.getTime() < bestT.getTime()) {
best = f;
bestT = t;
}
}
return best;
}
function latest(facts: ChainComposeInputFact[]): ChainComposeInputFact | null {
let best: ChainComposeInputFact | null = null;
let bestT: Date | null = null;
for (const f of facts) {
const t = effectiveTime(f);
if (!t) continue;
if (!bestT || t.getTime() > bestT.getTime()) {
best = f;
bestT = t;
}
}
return best;
}
/**
* subtype 简称:host settlement_project_name 去括号 + 截 6 字。
* "种植体植入费(颗)" → "种植体植入费";"冠修复(国产全瓷)" → "冠修复"。
*/
function shortSubtype(t: ChainComposeInputFact): string {
const c = t.content as Record<string, unknown>;
const sub = String(c.subtype ?? '').trim();
return shortLabel(sub);
}
function shortLabel(s: string): string {
if (!s) return '';
const clean = s.replace(/[((].+?[))]/g, '').trim();
return clean.length > 6 ? clean.slice(0, 6) : clean;
}
const CATEGORY_LABEL: Record<string, string> = {
endodontic: '根管治疗',
prosthodontic: '修复(冠 / 桥)',
......@@ -218,11 +1116,20 @@ const CATEGORY_LABEL: Record<string, string> = {
preventive: '预防保健',
cosmetic: '美容修复',
pediatric: '儿童牙科',
/// W3 末第 11 类(防御性 — review 通常 kind=planned 不会走 composeByCategory,
/// 但若 W5+ Layer C LLM 抽出 actual review,UI 才不会显示 raw 'review' 字符串)
review: '复查 / 流程',
};
/// 排序优先级:召回候选(discovered/entered)排前,运营关注度依次降低
const STATUS_PRIORITY: Record<ChainStatus, number> = {
discovered: 1,
entered: 2,
ongoing: 3,
closed: 4,
};
/// 没字典的 category 默认 lifecycle(one_shot 等价)
const DEFAULT_LIFECYCLE = { maxStage: 5 as const, noteZh: '默认一次性' };
// ─────────────────────────────────────────────
// Types(对外接口)
// ─────────────────────────────────────────────
......@@ -231,30 +1138,86 @@ export interface ChainComposeInputFact {
id: string;
type: string;
kind: string;
/// fact 版本流状态('active' / 'superseded' / 'cancelled' / 'fulfilled' / 'expired' / 'invalidated')
/// chain-composer S2 用此过滤已取消预约;后续阶段也可用做"软删除" 兜底
status: string;
occurredAt: Date | null;
/// planned 类(治疗计划 / 复查计划 / 预约) — occurredAt 通常为 null,真值在 plannedFor。
/// 时间比较 / 显示 / 排序统一用 effectiveTime() = occurredAt ?? plannedFor。
plannedFor?: Date | null;
content: Prisma.JsonValue;
}
/// 5 阶段链状态(替代旧 'closed'|'ongoing'|'uninitiated' 三态)
///
/// - discovered: 仅 S1 命中(诊断/推荐)— 召回算法关注的"潜在新链"
/// - entered: S2 命中(已挂号/预约/付款但未真正治疗)
/// - ongoing: S3 或 S4 命中(治疗中 / 复查中)
/// - closed: S5 全条件满足 — 真正闭环(lifelong_maintenance 类永不到此)
export type ChainStatus = 'discovered' | 'entered' | 'ongoing' | 'closed';
export interface ComposedChain {
id: string;
name: string;
category: string;
status: 'closed' | 'ongoing' | 'uninitiated';
/// 该链对应诊断码(K00 / K01 / ... / K09)— plan-aggregate 按 chain.code === reason.signal_code 对齐 ★
/// actual-only 桶(纯 actual,无诊断)code=''
code?: string;
/// 该链对应诊断码的全部可关联 categories(DxMap.code.categories)— alt-close 等场景用
allCategories?: readonly string[];
status: ChainStatus;
currentStage: number;
/// 召回算法的关注候选标记(status='discovered'/'entered' 时 true,UI 展示"★"标)
target?: boolean;
/// 估值字段保留兼容,实际不再使用(无可信来源)
estimatedValueCents?: number;
/// S1 信号(首次诊断 / 推荐)发生日 — UI "诊断于 YYYY.MM.DD" 用
diagnosedAt?: string;
/// S1 至今天数 — gap 越大临床越紧迫(召回算法的紧迫度信号)
gapDays?: number;
nodes: Array<{
stage: number;
label: string;
at: string;
done?: boolean;
current?: boolean;
missing?: boolean;
note?: string;
}>;
}
/** 兼容旧 ComposeInputEncounter 类型(给老调用方过渡)— v2.1 改用 ChainComposeInputFact */
/// 生命周期提示("终身维护(永不闭环)" 等)— UI tooltip
lifecycleNoteZh?: string;
/// 桶 tooth_position(用于 cross-chain "alternative-closed" 判定:同 patient 同 tooth overlap)
toothPosition?: string;
/// "替代治疗已覆盖"原因 chain.name(被同 tooth 后续 K08+种植 / 拔除等替代时填,UI 显示提示)
alternativeClosedBy?: string;
nodes: ChainNode[];
}
/**
* 5 阶段节点 — 每节点最多 4 信息位 + 视觉状态(W3 末重塑)。
*
* 设计:STAGE_LABELS (前端 ① 发现机会/② 进入治疗链/...) 是静态 header,**不在 node 里**。
* node 装"该阶段具体做了什么 + 关键事实 + 医生 + 时间"4 信息位:
*
* L1 title = 具体动作(K05 牙周炎 / *全口洁治 / 牙周刮治术 / 复诊接诊 / 维护期)
* L2 detail = 关键事实(全口 26 牙 / ¥58 ✓ / 1/2 步骤 / 牙位 +1 颗)
* L3 doctor = 主治 / 计划 医生名(用于追溯,可空)
* L4 at = 日期 YYYY.MM.DD(planned 用 plannedFor 兜底)
*
* 状态字段(done/current/missing)给视觉用:
* - done: 该阶段已达成,圆点实心
* - current: 当前所处阶段(currentStage),圆点高亮
* - missing: 未达到,圆点虚线 / 灰
*
* hint = 给 discovered/entered/未闭环 显示的"建议下一步" 友好提示
* (例:S2 hint = "尚未启动 · 建议种植/桥/义齿";S5 hint = "维护型 · 下次 3-6 个月内")
*/
export interface ChainNode {
stage: number;
/// 兼容旧字段(若 title/detail 都不存在前端可回退到 label,但优先用结构化字段)
label?: string;
title?: string;
detail?: string;
doctor?: string;
at: string;
done?: boolean;
current?: boolean;
missing?: boolean;
hint?: string;
/// 旧字段保留兼容(未使用)
note?: string;
}
/** 兼容旧 ComposeInputEncounter 类型(给老调用方过渡) */
export type ComposeInputEncounter = ChainComposeInputFact;
......@@ -161,22 +161,19 @@ export class PlanEngineService {
const newReasonSet = new Set(hits.map((h) => h.scenarioKey + ':' + (h.subKey ?? '')));
// 比对 active plan 的 reason 是否变化
// ⭐ W3 末修:比对维度 = (scenario, subKey) 二元组集合,**不能只看 scenario**
// 旧 bug:加新 SUB_SCENARIOS(K01/K07/K00 等)后,oldScenarios={treatment_initiation_recall}
// 跟 newScenarios={treatment_initiation_recall} 相等 → 判 unchanged → 新 subKey 丢弃
// 实际只刷了 priorityScore,plan_reasons 残留老的 1 条 missing_tooth
const isActive = latest && (latest.status === 'active' || latest.status === 'assigned');
if (isActive) {
const oldReasonSet = new Set(
latest.reasons.map((r) => {
const ev = r.evidence as string[] | undefined;
// 简化对比:scenario 集合是否一致
return r.scenario + ':' + (Array.isArray(ev) ? '' : '');
}),
);
// 详细对比:基于 reason 文本(简化用 set 长度 + scenario 集合)
const oldScenarios = new Set(latest.reasons.map((r) => r.scenario));
const newScenarios = new Set(hits.map((h) => h.scenarioKey));
const sameScenarios =
oldScenarios.size === newScenarios.size &&
[...oldScenarios].every((s) => newScenarios.has(s));
if (sameScenarios) {
const key = (sc: string, sk: string | null | undefined) => `${sc}|${sk ?? ''}`;
const oldSubScenarios = new Set(latest.reasons.map((r) => key(r.scenario, r.subKey)));
const newSubScenarios = new Set(hits.map((h) => key(h.scenarioKey, h.subKey)));
const sameSubScenarios =
oldSubScenarios.size === newSubScenarios.size &&
[...oldSubScenarios].every((k) => newSubScenarios.has(k));
if (sameSubScenarios) {
// 只刷 priorityScore,不创新版本
if (latest.priorityScore !== newPriorityScore) {
await this.prisma.followupPlan.update({
......@@ -222,24 +219,29 @@ export class PlanEngineService {
recommendedChannel: head.recommendedChannel ?? null,
status: 'active',
reasons: {
// 同 patient 同 scenario 多 sub-rule 命中(如 K02 龋 + K04 根尖 + K01 阻生
// 都触发 treatment_initiation_recall)时合并为单条 reason:
// priorityScore = MAX,reason 文本拼接,subKey 数组 / 取首 subKey,
// evidence factIds 合集
// UNIQUE(plan_id, scenario)约束限制每 plan 内 scenario 唯一。
// W3 末改:plan_reasons 维度 = (plan, scenario, sub_key) — 每独立临床缺口一行,
// 不再合并子规则(K08 缺牙 / K05 牙周 / K04 根管 是不同治疗体系,合并 reason
// 长且生硬、evidence 粒度丢)。话术 / 摘要 / 执行回写仍 plan 级(展示细 / 触达粗)。
// UNIQUE(plan_id, scenario, sub_key)约束保证同子规则不重复。
createMany: {
data: this.mergeHitsByScenario(hits).map((h) => ({
data: hits.map((h) => ({
scenario: h.scenarioKey,
subKey: h.subKey ?? null,
priorityScore: h.priorityScore,
reason: h.reason,
evidence: h.evidence.factIds,
evidence: {
factIds: h.evidence.factIds,
} as Prisma.InputJsonValue,
breakdown: h.priorityBreakdown
? ({
priority: { ...h.priorityBreakdown },
subKey: h.subKey ?? null,
mergedSubKeys: h.mergedSubKeys ?? undefined,
} as Prisma.InputJsonValue)
: Prisma.JsonNull,
// 结构化召回信号(DB 存 raw enum,前端字典翻译富文本渲染)
signals: h.signals
? (h.signals as unknown as Prisma.InputJsonValue)
: Prisma.JsonNull,
lifecycle: this.lifecycleFor(h.scenarioKey, h.subKey),
source: 'algorithm',
})),
......@@ -252,52 +254,6 @@ export class PlanEngineService {
return latest ? 'superseded' : 'created';
}
/**
* 合并同 scenario 的多 sub-rule 命中(满足 UNIQUE(plan_id, scenario) 约束)
*
* 场景:K02 + K04 + K01 多病并发,3 个 sub-rule 都命中
* treatment_initiation_recall scenario → 必须合并为单条 reason。
*
* 合并策略:
* - priorityScore = MAX over hits
* - reason 文本拼接(用 " | " 分隔)
* - subKey = 首个 hit 的 subKey;额外塞 mergedSubKeys 数组到 breakdown
* - evidence.factIds = union
* - priorityBreakdown = 取分最高那条 hit 的 breakdown(细节最有代表性)
* - recommendedAt/Role/Channel = 取首个 hit
*/
private mergeHitsByScenario(
hits: ScenarioHitWithKey[],
): MergedHit[] {
const byScenario = new Map<string, ScenarioHitWithKey[]>();
for (const h of hits) {
const arr = byScenario.get(h.scenarioKey) ?? [];
arr.push(h);
byScenario.set(h.scenarioKey, arr);
}
const merged: MergedHit[] = [];
for (const [, group] of byScenario.entries()) {
if (group.length === 1) {
merged.push({ ...group[0]!, mergedSubKeys: undefined });
continue;
}
const top = [...group].sort((a, b) => b.priorityScore - a.priorityScore)[0]!;
const allFactIds = Array.from(
new Set(group.flatMap((h) => h.evidence.factIds)),
);
const reasonText = group.map((h) => h.reason).join(' | ');
const subKeys = group.map((h) => h.subKey).filter(Boolean) as string[];
merged.push({
...top,
priorityScore: top.priorityScore, // = MAX(已是排序后取首)
reason: reasonText,
evidence: { factIds: allFactIds },
mergedSubKeys: subKeys,
});
}
return merged;
}
/// 子场景 → 生命周期类型(one_shot 短效 / recurring 长效)
private lifecycleFor(scenario: string, subKey?: string): string {
// 复查类长效(种植年度 / 牙周维护),其余短效
......@@ -307,7 +263,6 @@ export class PlanEngineService {
}
type ScenarioHitWithKey = ScenarioHit & { scenarioKey: string };
type MergedHit = ScenarioHitWithKey & { mergedSubKeys?: string[] };
export interface EngineRunResult {
scenariosRun: number;
......
......@@ -40,8 +40,8 @@ export interface ScenarioHit {
/// 期望执行诊所(null = 集团池)
targetClinicId?: string | null;
/// 证据 fact.ids(plan_reasons.evidence_fact_ids UUID[] 落库)
/// **不再装 ruleIds** — 规则版本靠 git + plan.createdAt 追溯,DB 不冗余存字符串常量
/// 证据 fact.ids(plan_reasons.evidence JSON 落库,W3 末从 UUID[] 改 jsonb 统一跟 PersonaFeature 对齐)
/// scenario 输出 { factIds[] };后端 plan-engine 写入时包成 { factIds: [...] } JSON
evidence: {
factIds: string[];
};
......@@ -49,6 +49,11 @@ export interface ScenarioHit {
/// 子场景标识(reason 文本内已含,落 plan_reasons.breakdown.subKey 便于 metrics 检索)
subKey?: string;
/// **W3 末新增**:结构化召回信号(plan_reasons.signals JSON 落库)。
/// DB 存原始 enum / canonical code(不语义化),前端用 @pac/types 字典翻译富文本渲染。
/// 详见 packages/types/src/schemas/reason-signals.ts
signals?: import('@pac/types').ReasonSignals;
/// 6 因子优先级 breakdown(算法 v2);可空兼容旧 scenario
/// 详情页用本字段渲染"客服可解释性"。
priorityBreakdown?: import('./priority-scorer').PriorityBreakdown;
......
import { Injectable, Logger } from '@nestjs/common';
import { PlanScenario, DiagnosisTreatmentMap } from '@pac/types';
import {
PlanScenario,
DiagnosisTreatmentMap,
lookupDxTreatment,
diagnosisCodeNameZh,
treatmentCategoryNameZh,
} from '@pac/types';
import { PrismaService } from '../../../../prisma/prisma.service';
import type {
PlanScenarioPlugin,
......@@ -19,23 +25,39 @@ import { calcPriority } from '../priority-scorer';
*
* 3 个主子场景(单价由高到低):
*
* #A 缺失牙 → 种植/桥/义齿(诊断后 30-180 天,基线 60)
* #A 缺失牙 → 种植/桥/义齿(黄金窗 30-180 天,基线 60)
* 触发:diagnosis_record.code='K08' 或 recommendation_record.code='IMPLANT_RECOMMENDED'
* 排除:任一 treatment_record(actual).category IN ('implant','prosthodontic')
* 排除:诊断后启动的 treatment_record(actual).category IN ('implant','prosthodontic')
*
* #B 龋齿 → 充填(诊断后 14-90 天,基线 45)
* #B 龋齿 → 充填(黄金窗 14-90 天,基线 45)
* 触发:diagnosis_record.code='K02' 或 recommendation_record.code='FILLING_RECOMMENDED'
* 排除:treatment_record(actual).category='restorative' 且 occurred_at ≥ 诊断日
* 排除:诊断后启动的 treatment_record(actual).category='restorative'
*
* #C 牙周诊断 → SRP/翻瓣(诊断后 30-120 天,基线 50)
* #C 牙周诊断 → SRP/翻瓣(黄金窗 30-120 天,基线 50)
* 触发:diagnosis_record.code='K05' 或 recommendation_record.code='SRP_RECOMMENDED'
* 排除:treatment_record(actual).category='periodontic' 且 occurred_at ≥ 诊断日
* 排除:诊断后启动的 treatment_record(actual).category='periodontic'
*
* #D 牙髓/根尖周炎 → 根管(诊断后 14-90 天,基线 52)
* #D 牙髓/根尖周炎 → 根管(黄金窗 14-90 天,基线 52)
* 触发:diagnosis_record.code='K04'
* 排除:treatment_record(actual).category='endodontic'
* 排除:诊断后启动的 treatment_record(actual).category='endodontic'
* (K04 拖久 → 根尖脓肿/拔牙,故窗口比龋齿略紧、紧迫临界 45 天)
*
* ⭐ 入池窗口 vs 黄金窗(W3 末修正,别再混用):
* - goldenRange = 算分用(priority-scorer.computeTimeWindowFactor):窗内 1.0,过早 0.6→1.0,
* 过晚从 end 到 2×end 衰减到 0.4。**只影响优先级,不当入池硬边界**。
* - 入池窗口 = [start, INTAKE_MAX_DAYS]:下界 start(刚诊断 <start 天还在考虑期,不急召);
* 上界放宽到 INTAKE_MAX_DAYS(缺牙拖 1 年比拖 3 个月更该召!超 golden 上界仍入池,交 scorer 衰减)。
* - 旧 bug:入池上界 = golden end → 缺牙 >180 天直接踢出池,scorer 的"过晚"分支成死代码。
*
* ⭐ 触发时间 = COALESCE(occurred_at, planned_for):
* diagnosis 用 occurred_at;recommendation 是 planned kind,occurred_at 为 null、时间在 planned_for。
* 旧 bug:SQL 写 `occurred_at IS NOT NULL` → recommendation 触发(recCodes)全部命不中,死代码。
*
* ⚠️ 牙位无法做"按牙排除"(数据源限制,非 bug):
* "已做治疗"的真凭据是结算表 fact_settlement_out(花钱才算做)→ 落 actual treatment,
* 而结算按收费项记账,**结构上无牙位列**。故排除只能 patient 级(同 patient 做过同类治疗即排)。
* treat_plan 里 46% 有牙位,但落的是 planned(≠已做),不能用于排除。反馈 DW 在结算补牙位再升级。
*
* 通用过滤(每个子场景 SQL 自己写,共用片段后续抽):
* active + 非 DNC + 非 deceased(phone 缺失暂不强制,DW 不提供)
*/
......@@ -44,48 +66,109 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
readonly key = PlanScenario.TREATMENT_INITIATION_RECALL;
private readonly logger = new Logger(TreatmentInitiationRecallScenario.name);
// ─ 子场景配置(集中此处便于业务方调参)─
/// W3 末决策:**移除入池时间上界硬过滤**(原 730 天)
/// - 临床缺口不会自愈(K08 缺牙/K07 错颌/K00 先天缺失 拖 5 年仍是缺口,该召还是该召)
/// - 算法层已有衰减:priority-scorer.computeTimeWindowFactor 对超 goldenRange.end 的诊断按 [end → 2×end] 衰减到 0.4
/// - 真正"该止损"的判断让 scorer 给低分 + UI 排到尾部 + 客服自选,**不在 SQL 层硬切**
/// - 副作用:召回池可能膨胀(老 K05 牙周诊断永远在),但 scorer 衰减 + priority 排序自然处理
private static readonly INTAKE_MAX_DAYS = null;
// ─ 子场景配置 ─
//
// 临床窗口 / 类别 / 紧迫临界 **统一从 canonical-codes.DiagnosisTreatmentMap 读**(单一真理源),
// 本表只装跟"召回话术 / 业务话目标"相关的 UI 文案 + base 基线分。
// 改窗口/类别去改字典,不要在这里硬编码,否则 scenario / chain-composer / scorer 会跟字典背离。
// W3 末扩 9 子场景(K00-K08 全覆盖,跟 DiagnosisTreatmentMap 一对一)
// base 分级:K08 缺牙 60(单价 1.5-3 万)/ K07 正畸 55(单价 3-5 万)/ K04 牙髓 52(防恶化)/
// K05 牙周 50 / K02 龋齿 45 / K03 牙体 35 / K06 牙龈 35 / K01 阻生 30 / K00 萌出 25
// 不漏码原则:host 数据出现的诊断都进召回池,priority 自然衰减(低 base + 慢窗口 → 排到后面)
private static readonly SUB_SCENARIOS = {
missing_tooth: {
base: 60,
goldenRange: [30, 180] as [number, number],
urgencyDayThreshold: 120, // 缺牙超 120 天 → 邻牙倾斜风险
primaryCode: 'K08', // → DiagnosisTreatmentMap.K08(cooldownDays/windowDays/urgencyDayThreshold/categories)
dxCodes: ['K08'],
recCodes: ['IMPLANT_RECOMMENDED'],
// 排除类别 = 单一真理源 DiagnosisTreatmentMap['K08'].categories
excludeCats: DiagnosisTreatmentMap.K08.categories,
label: '缺失牙未启动修复',
goal: '邀约启动缺失牙修复(种植/桥/义齿),避免邻牙倾斜 / 对颌伸长',
},
ortho_no_consult: {
base: 55,
primaryCode: 'K07',
dxCodes: ['K07'],
recCodes: ['ORTHO_CONSULT_RECOMMENDED'],
label: '错颌畸形未启动正畸',
goal: '邀约做正畸初诊评估,把握矫治窗口(尤其混合牙列期 8-12 岁 / 成人骨健全期)',
},
endo_no_rct: {
base: 52,
primaryCode: 'K04',
dxCodes: ['K04'],
recCodes: ['RCT_RECOMMENDED'],
label: '牙髓炎/根尖周炎未做根管',
goal: '邀约尽快做根管治疗,避免发展为根尖脓肿 / 拔牙',
},
perio_no_srp: {
base: 50,
primaryCode: 'K05',
dxCodes: ['K05'],
recCodes: ['SRP_RECOMMENDED'],
label: '牙周炎未做基础治疗',
goal: '邀约做牙周基础治疗(SRP / 翻瓣),控制炎症发展',
},
caries_no_filling: {
base: 45,
goldenRange: [14, 90] as [number, number],
urgencyDayThreshold: 60,
primaryCode: 'K02',
dxCodes: ['K02'],
recCodes: ['FILLING_RECOMMENDED'],
excludeCats: DiagnosisTreatmentMap.K02.categories,
label: '龋齿未做充填',
goal: '邀约尽快做龋齿充填,避免发展为牙髓炎',
},
perio_no_srp: {
hard_tissue_damage: {
base: 35,
primaryCode: 'K03',
dxCodes: ['K03'],
recCodes: ['HARD_TISSUE_REPAIR_RECOMMENDED', 'CROWN_RECOMMENDED'],
// K03 复合:残根残冠→拔(surgical)/ 楔缺→充填(restorative)/ 大缺损→冠桥(prosthodontic)
// 三类 actual 任一存在即排除(见 DxMap K03.categories)— 数据噪音可控
label: '牙体损伤未修复',
goal: '邀约处理牙体硬组织损伤(楔状缺损/残根/残冠 → 充填/嵌体/拔除修复)',
},
gum_alveolar_lesion: {
base: 35,
primaryCode: 'K06',
// K06 病种保留为 K06,不归 K05(不改诊断码原则)— 临床路径同 periodontic+surgical
dxCodes: ['K06'],
recCodes: ['GUM_TREATMENT_RECOMMENDED'],
label: '牙龈/牙槽嵴疾患未处置',
goal: '邀约处理牙龈/牙槽嵴问题(牙龈萎缩/增生/瘤/根分叉病变/系带异常 → 牙周治疗或手术)',
},
impacted_tooth: {
base: 30,
primaryCode: 'K01',
// 智齿阻生临床不强制拔除(无症状可观察);base 低让分数沉底,真急的患者靠 urgencyBonus 升上来
dxCodes: ['K01'],
recCodes: ['EXTRACTION_RECOMMENDED'],
label: '阻生牙未拔除',
goal: '邀约评估阻生牙是否需拔除(智齿冠周炎反复 / 顶坏邻牙 / 正畸需要)',
},
jaw_cyst: {
base: 50,
goldenRange: [30, 120] as [number, number],
urgencyDayThreshold: 90,
dxCodes: ['K05'],
recCodes: ['SRP_RECOMMENDED'],
excludeCats: DiagnosisTreatmentMap.K05.categories,
label: '牙周炎未做基础治疗',
goal: '邀约做牙周基础治疗(SRP / 翻瓣),控制炎症发展',
primaryCode: 'K09',
dxCodes: ['K09'],
recCodes: ['JAW_CYST_REMOVAL_RECOMMENDED'],
// 颌骨囊肿临床必处理(不摘除会继续扩大压迫邻牙/神经),base 中等偏高
label: '颌骨囊肿未处理',
goal: '邀约尽快做颌骨囊肿摘除术,避免囊肿扩大压迫邻牙 / 神经',
},
endo_no_rct: {
base: 52,
goldenRange: [14, 90] as [number, number],
urgencyDayThreshold: 45, // 牙髓/根尖周炎拖久 → 根尖脓肿 / 拔牙风险
dxCodes: ['K04'],
recCodes: [], // 字典暂无 ENDO/RCT_RECOMMENDED 推荐码
excludeCats: DiagnosisTreatmentMap.K04.categories, // endodontic
label: '牙髓炎/根尖周炎未做根管',
goal: '邀约尽快做根管治疗,避免发展为根尖脓肿 / 拔牙',
development_eruption: {
base: 25,
primaryCode: 'K00',
// K00 真病种(乳牙滞留 / 多生牙 / 先天缺失 / 釉质发育不全 / 萌出障碍)— 不含"乳牙列""混合牙列"等正常态(yaml 不映射)
// 儿童居多,base 最低;windowDays 365 给足观察期
dxCodes: ['K00'],
recCodes: ['ERUPTION_INTERVENTION_RECOMMENDED'],
label: '牙发育/萌出异常未处置',
goal: '邀约评估处置(乳牙滞留/多生牙拔除 · 先天缺失修复 · 釉质发育不全美容修复 · 萌出障碍助萌)',
},
} as const;
......@@ -114,17 +197,79 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
subKey: string,
cfg: (typeof TreatmentInitiationRecallScenario.SUB_SCENARIOS)[keyof typeof TreatmentInitiationRecallScenario.SUB_SCENARIOS],
): Promise<ScenarioHit[]> {
const [start, end] = cfg.goldenRange;
// 临床窗口 / 类别 / 紧迫临界从 canonical-codes.DiagnosisTreatmentMap 单一真理源读
const rule = lookupDxTreatment(cfg.primaryCode);
if (!rule) {
throw new Error(
`SUB_SCENARIOS[${subKey}].primaryCode=${cfg.primaryCode} 在 DiagnosisTreatmentMap 中找不到 — ` +
`检查 canonical-codes.ts(单一真理源)`,
);
}
const start = rule.cooldownDays;
const goldenRange: [number, number] = [rule.cooldownDays, rule.windowDays];
const dxCodes = cfg.dxCodes as readonly string[];
const recCodes = cfg.recCodes as readonly string[];
const allCodes = [...dxCodes, ...recCodes];
const excludeCats = cfg.excludeCats as readonly string[];
const excludeCats = rule.categories as readonly string[];
// (W3 末)SQL 预约排除已放宽 — 不再按 complaint_category 匹配,只看 sig 后有任何预约即排
// 理由:召回目的就是建预约,患者已经有未来预约 → 不需要再 push
// SQL:找 patient + 最早命中信号 fact(取 occurred_at 最早,daysSince 用它)
// 触发信号来自 diagnosis_record 或 recommendation_record(任一)
// 排除条件:任一 actual treatment_record category 落 excludeCats
//
// 注:用 $queryRaw 拼数组参数 — Postgres ANY 接 String[]
// ╔═════════════════════════════════════════════════════════════════════╗
// ║ 召回 SQL 完整解读(initiation = 潜在治疗新链召回) ║
// ║ ║
// ║ 一句话:"找诊断了 / 医生推荐了,但**没启动**对应治疗的患者" ║
// ║ ║
// ║ ┌─ 主查询(找触发信号 sig + 算 daysSince) ─────────────────────────┐ ║
// ║ │ 从 patients 表起,JOIN patient_profiles + patient_facts(sig) │ ║
// ║ │ 每命中一条 (patient × signal_fact) 组合返回一行 │ ║
// ║ │ daysSince = 今天 - 触发时间(单位:天,给 scorer 用) │ ║
// ║ └────────────────────────────────────────────────────────────────────┘ ║
// ║ ║
// ║ ┌─ ① 隔离闸(强制基线) ──────────────────────────────────────────┐ ║
// ║ │ p.host_id = scope.hostId + p.tenant_id = scope.tenantId │ ║
// ║ │ → PAC 三层模型的 platform+tenant 硬隔离,跨租户不漏 │ ║
// ║ └────────────────────────────────────────────────────────────────────┘ ║
// ║ ║
// ║ ┌─ ② 合规闸 ────────────────────────────────────────────────────┐ ║
// ║ │ p.active = true (患者主档活跃) │ ║
// ║ │ pp.do_not_contact = false (没勾"勿打扰") │ ║
// ║ │ pp.deceased = false (没标"已故") │ ║
// ║ │ → 法务 / 投诉风险硬过滤,任一不满足直接踢 │ ║
// ║ └────────────────────────────────────────────────────────────────────┘ ║
// ║ ║
// ║ ┌─ ③ 触发信号(sig 这条 fact 必须是"该召回理由") ───────────────┐ ║
// ║ │ sig.status = 'active' (当前版本,非历史) │ ║
// ║ │ sig.type IN ('diagnosis_record', (诊断) │ ║
// ║ │ 'recommendation_record') (医生 / LLM 推荐) │ ║
// ║ │ sig.content->>'code' = ANY(allCodes) (K08/K07/.. 或 *_RECOMMENDED)│ ║
// ║ └────────────────────────────────────────────────────────────────────┘ ║
// ║ ║
// ║ ┌─ ④ 触发时间下界(冷静期 cooldown) ────────────────────────────┐ ║
// ║ │ COALESCE(sig.occurred_at, sig.planned_for) <= now - cooldownDays │ ║
// ║ │ → 刚诊断 < cooldown 天(K08=30天/K04=14天/...)还在医生考虑期, │ ║
// ║ │ 客服过早打扰不合适。从字典 DiagnosisTreatmentMap[code] 读 │ ║
// ║ │ 注:旧版还有上界(730天),W3 末已废 — 缺口不会自愈,scorer 自然衰减│ ║
// ║ └────────────────────────────────────────────────────────────────────┘ ║
// ║ ║
// ║ ┌─ ⑤ 排除闸:NOT EXISTS 同类 actual 治疗 ───────────────────────┐ ║
// ║ │ tx.type = 'treatment_record' AND tx.kind = 'actual' │ ║
// ║ │ tx.status IN ('active','fulfilled') (完成的 actual 是 fulfilled)│ ║
// ║ │ tx.content->>'category' = ANY(excludeCats) │ ║
// ║ │ excludeCats 来自 DxMap.K08.categories = ['implant','prostho']│ ║
// ║ │ tx.occurred_at >= sig.occurred_at ⭐ 关键:时间方向 │ ║
// ║ │ 只算"诊断之后才启动"的治疗,历史旧治疗(可能是另一颗牙)不算 │ ║
// ║ │ → 任一同类 actual 存在即排除该 patient │ ║
// ║ │ ⚠️ patient 级排除(不是牙位级)— 因结算表无牙位列,DW 限制 │ ║
// ║ └────────────────────────────────────────────────────────────────────┘ ║
// ║ ║
// ║ COALESCE(occurred_at, planned_for): ║
// ║ diagnosis_record 是 actual(已发生)→ 用 occurred_at ║
// ║ recommendation_record 是 planned(未发生)→ 用 planned_for ║
// ║ ║
// ║ 输出:每个命中 (patient × sig) 一行,后段 byPatient Map 去重 ║
// ║ 只留 daysSince 最大那条(最早诊断 = 最有召回价值) ║
// ╚═════════════════════════════════════════════════════════════════════╝
const rows: HitRow[] = await this.prisma.$queryRaw`
SELECT
p.id AS patient_id,
......@@ -136,32 +281,40 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
sig.content->>'extracted_by' AS extracted_by,
sig.content->>'confidence' AS confidence,
sig.clinic_id AS clinic_id,
sig.occurred_at AS signal_occurred_at,
EXTRACT(DAY FROM ${scope.now}::timestamptz - sig.occurred_at)::int AS days_since
COALESCE(sig.occurred_at, sig.planned_for) AS signal_occurred_at,
EXTRACT(DAY FROM ${scope.now}::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since
FROM patients p
JOIN patient_profiles pp ON pp.patient_id = p.id
JOIN patient_facts sig ON sig.patient_id = p.id
WHERE p.host_id = ${scope.hostId}::uuid
AND p.tenant_id = ${scope.tenantId}
AND p.active = true
AND pp.do_not_contact = false
AND pp.deceased = false
AND sig.status = 'active'
AND sig.type IN ('diagnosis_record', 'recommendation_record')
AND sig.content->>'code' = ANY(${allCodes}::text[])
AND sig.occurred_at IS NOT NULL
AND sig.occurred_at BETWEEN
${this.daysAgo(scope.now, end)}::timestamptz
AND ${this.daysAgo(scope.now, start)}::timestamptz
AND NOT EXISTS (
WHERE p.host_id = ${scope.hostId}::uuid -- ① 隔离闸
AND p.tenant_id = ${scope.tenantId} -- ① 隔离闸
AND p.active = true -- ② 合规闸
AND pp.do_not_contact = false -- ② 合规闸
AND pp.deceased = false -- ② 合规闸
AND sig.status = 'active' -- ③ 触发信号 active 版本
AND sig.type IN ('diagnosis_record', 'recommendation_record') -- ③ 信号类型
AND sig.content->>'code' = ANY(${allCodes}::text[]) -- ③ 信号 code 命中
AND COALESCE(sig.occurred_at, sig.planned_for) IS NOT NULL -- ④ 时间不为空
AND COALESCE(sig.occurred_at, sig.planned_for) <= ${this.daysAgo(scope.now, start)}::timestamptz -- ④ 过 cooldown
AND NOT EXISTS ( -- ⑤a 排除:同类 actual 治疗(已开始做)
SELECT 1 FROM patient_facts tx
WHERE tx.patient_id = p.id
AND tx.type = 'treatment_record'
AND tx.kind = 'actual'
-- status 必须 IN (active, fulfilled):actual treatment 完成后 status='fulfilled'(不是 'active'),
-- 早期写 active 漏了已完成的 — 会把"已做 prostho"的患者错误召回为"缺失牙未启动修复"
AND tx.status IN ('active', 'fulfilled')
AND tx.status IN ('active', 'fulfilled') -- actual 完成 status=fulfilled
AND tx.content->>'category' = ANY(${excludeCats}::text[])
AND tx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for) -- ⭐ 时间方向:诊断之后
)
AND NOT EXISTS ( -- ⑤b 排除:患者已有未来预约(W3 末放宽)
-- 召回目的 = 让客服建预约。患者已经有未来预约 → 客服不需要再 push,医生到诊现场处理即可
-- 不按 complaint_category 匹配(陆伟根典型:K07 诊断 + 修复预约 → 不召也对,反正会来诊)
-- **只看 status='active'**(scheduled 未到诊)— fulfilled(已到诊)是历史,⑤a actual treatment 排除已覆盖
-- 时间锚点:future 预约(planned_for > now)— "已经有要来的事"
SELECT 1 FROM patient_facts appt
WHERE appt.patient_id = p.id
AND appt.type = 'appointment_record'
AND appt.status = 'active'
AND COALESCE(appt.planned_for, appt.occurred_at) > ${scope.now}::timestamptz
)
`;
......@@ -191,12 +344,12 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
const { score, breakdown } = calcPriority({
base: cfg.base,
daysSince: r.days_since,
goldenRange: cfg.goldenRange,
goldenRange,
valueScore: persona?.valueScore ?? null,
riskScore: persona?.riskScore ?? null,
recentExecutions: execCtx.get(patientId) ?? [],
signalConfidences: [confidence],
urgencyDayThreshold: cfg.urgencyDayThreshold,
urgencyDayThreshold: rule.urgencyDayThreshold,
});
const toothStr = r.tooth ? ` · 牙位 ${r.tooth}` : '';
......@@ -204,10 +357,14 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
r.signal_type === 'recommendation_record'
? '(医生建议)'
: '(诊断)';
// 触发信号类型(原 enum,不语义化;前端用 triggerTypeLabelZh 翻译)
const triggerType =
r.signal_type === 'recommendation_record' ? 'recommendation' : 'diagnosis';
hits.push({
patientId,
patientExternalId: r.patient_external_id,
reason: `${cfg.label}${toothStr}${r.signal_code}${sourceStr} ${r.days_since} 天前,未启动 ${excludeCats.join('/')}`,
// reason 文本兜底(AI prompt / 调试用,前端不依赖此字段)
reason: `${cfg.label}${toothStr}${diagnosisCodeNameZh(r.signal_code)}${sourceStr} ${r.days_since} 天前,未启动${excludeCats.map(treatmentCategoryNameZh).join(' / ')}`,
priorityScore: score,
goal: cfg.goal,
recommendedRole: 'staff',
......@@ -217,9 +374,18 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 目标诊所 = 诊断出该未治疗需求的诊所(患者最可能回访的地方)
targetClinicId: r.clinic_id ?? null,
evidence: {
// [0] = 触发 fact(对应 signals.triggers[0]);其余是相关参考 fact
factIds: [r.signal_fact_id],
},
subKey,
// 结构化召回信号(DB 存 raw enum / canonical code,前端字典翻译富文本)
signals: {
subKey,
triggers: [{ type: triggerType, code: r.signal_code }],
toothPosition: r.tooth ?? null,
daysSince: r.days_since,
expectedCategories: [...excludeCats],
},
priorityBreakdown: breakdown,
});
}
......
......@@ -22,6 +22,7 @@ import {
AssignPlanRequestDto,
ListPlansQueryDto,
ListPlansResponseDto,
PlanCountsResponseDto,
PlanActionAckDto,
PlanDetailResponseDto,
RecomputeAckDto,
......@@ -59,6 +60,19 @@ export class PlanController {
return this.plans.list(scope, query, user.permissions);
}
@Get('counts')
@ZodResponse({ status: 200, type: PlanCountsResponseDto })
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({
summary: 'Plan counts for KPI / tab badges (one SQL roundtrip, replaces 5 list calls)',
})
counts(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
) {
return this.plans.counts(scope, user.permissions);
}
@Get(':id')
@ZodResponse({ status: 200, type: PlanDetailResponseDto })
@RequirePermission(Permission.PLAN_VIEW_OWN)
......
......@@ -10,7 +10,9 @@ import {
Permission,
planScenarioLabel,
type ListPlansResponse,
type PlanCountsResponse,
type PlanDetailResponse,
type PlanReasonBrief,
} from '@pac/types';
import { maskName, maskPhone } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service';
......@@ -87,13 +89,33 @@ export class PlanService {
];
}
// W3 末:服务端 keyword 模糊匹配 — patient.name / phone / externalId 任一命中
// (替代前端本页 .filter,改成跨全表搜索,真实接入万人级数据后才能用)
if (query.keyword) {
where.patient = {
OR: [
{ name: { contains: query.keyword, mode: 'insensitive' } },
{ phone: { contains: query.keyword } },
{ externalId: { contains: query.keyword, mode: 'insensitive' } },
],
};
}
// W3 末:服务端 sort —— 替代前端 .sort,跨页排序正确
const orderBy: Prisma.FollowupPlanOrderByWithRelationInput[] =
query.sort === 'priority_asc'
? [{ priorityScore: 'asc' }, { createdAt: 'asc' }]
: query.sort === 'created_desc'
? [{ createdAt: 'desc' }]
: [{ priorityScore: 'desc' }, { createdAt: 'asc' }]; // priority_desc 默认
const skip = (query.page - 1) * query.pageSize;
const [total, rows] = await Promise.all([
this.prisma.followupPlan.count({ where }),
this.prisma.followupPlan.findMany({
where,
include: { reasons: { orderBy: { priorityScore: 'desc' } } },
orderBy: [{ priorityScore: 'desc' }, { createdAt: 'asc' }],
orderBy,
skip,
take: query.pageSize,
}),
......@@ -125,11 +147,15 @@ export class PlanService {
scenarioLabel: planScenarioLabel(base.scenario),
patient: {
externalId: patient?.externalId ?? '',
/// name 原值透出(权限内可见);nameMasked 保留作脱敏视图兜底
name: patient?.name ?? null,
nameMasked: maskName(patient?.name ?? null),
phoneMasked: maskPhone(patient?.phone ?? null),
gender: patient?.gender ?? null,
age: patient?.birthDate ? calcAge(patient.birthDate) : null,
},
/// reasons 透 signals JSON,前端 ReasonLine 用字典翻译富文本(跟详情页同源)
reasons: p.reasons.map(toPlanReasonBrief),
};
}),
page: query.page,
......@@ -139,6 +165,46 @@ export class PlanService {
}
// ─────────────────────────────────────────────
// counts — KPI / Tab badge 聚合(一次 SQL 5 个 count,避免前端 5 个并发 list 请求)
// ─────────────────────────────────────────────
async counts(
scope: TenantScopeContext,
permissions: readonly string[],
): Promise<PlanCountsResponse> {
const base: Prisma.FollowupPlanWhereInput = {
hostId: scope.hostId,
tenantId: scope.tenantId,
supersededAt: null,
};
// staff 受 clinic 隔离(跟 list 同口径)
const clinicScope: Prisma.FollowupPlanWhereInput =
scope.clinicIds.length > 0
? { OR: [{ targetClinicId: { in: scope.clinicIds } }, { targetClinicId: null }] }
: {};
const canViewAll = permissions.includes(Permission.PLAN_VIEW_ALL);
const [mine, mineAssigned, mineCompleted, pool, all] = await Promise.all([
this.prisma.followupPlan.count({
where: { ...base, assigneeUserId: scope.userId, ...clinicScope },
}),
this.prisma.followupPlan.count({
where: { ...base, assigneeUserId: scope.userId, status: 'assigned', ...clinicScope },
}),
this.prisma.followupPlan.count({
where: { ...base, assigneeUserId: scope.userId, status: 'completed', ...clinicScope },
}),
this.prisma.followupPlan.count({
where: { ...base, status: 'active', assigneeUserId: null, ...clinicScope },
}),
canViewAll
? this.prisma.followupPlan.count({ where: base })
: Promise.resolve(0),
]);
return { mine, mineAssigned, mineCompleted, pool, all };
}
// ─────────────────────────────────────────────
// detail
// ─────────────────────────────────────────────
......@@ -303,6 +369,20 @@ type PlanScriptRow = Prisma.PlanScriptGetPayload<{}>;
type PlanSummaryRow = Prisma.PlanSummaryGetPayload<{}>;
type PlanExecutionRow = Prisma.PlanExecutionGetPayload<{}>;
/// reasons 行 → PlanReasonBrief(给列表用)。signals 透 raw JSON,前端字典翻译富文本。
function toPlanReasonBrief(
r: PlanRow['reasons'][number],
): PlanReasonBrief {
return {
id: r.id,
scenario: r.scenario,
subKey: r.subKey,
signals: (r.signals as PlanReasonBrief['signals']) ?? null,
priorityScore: r.priorityScore,
reason: r.reason,
};
}
function serializePlan(p: PlanRow): import('@pac/types').FollowupPlan {
// FollowupPlanSchema 需要顶层 scenario / inclusionReason / evidence
// DB 这些在 PlanReason 子表;取 primary reason(MAX priorityScore)填充顶层
......@@ -310,7 +390,7 @@ function serializePlan(p: PlanRow): import('@pac/types').FollowupPlan {
const evidence = {
factIds: Array.from(
new Set(
p.reasons.flatMap((r) => (r.evidence as unknown as string[] | null) ?? []),
p.reasons.flatMap((r) => ((r.evidence as { factIds?: string[] } | null)?.factIds ?? [])),
),
),
personaVersion: undefined, // 让 caller 在 detail 里通过 persona.version 补全
......@@ -455,7 +535,7 @@ function computePlanEvidence(
return {
factIds: Array.from(
new Set(
plan.reasons.flatMap((r) => (r.evidence as unknown as string[] | null) ?? []),
plan.reasons.flatMap((r) => ((r.evidence as { factIds?: string[] } | null)?.factIds ?? [])),
),
),
personaVersion: persona?.version,
......
......@@ -281,6 +281,8 @@ export class ColdImportService {
phone: (c.phone as string | undefined) ?? synthesizeDemoPhone(externalId),
gender: (c.gender as string | undefined) ?? null,
birthDate: c.birthDate ? new Date(c.birthDate as string) : null,
medicalRecordNumber:
(c.medicalRecordNumber as string | undefined) ?? null,
preferences:
c.preferences && typeof c.preferences === 'object'
? (c.preferences as Prisma.InputJsonValue)
......
......@@ -61,6 +61,13 @@ const EncounterRecordContent = z
encounter_external_id: z.string().min(1),
doctor_id: nullableString(),
doctor_name: nullableString(),
/// 主诉(SOAP 的 S)— 通用临床字段,任何接诊都有
chief_complaint: nullableString(),
/// 接诊类型(初诊 / 复诊 / 急诊 / 常规检查 等)— 通用临床分类。
/// ⚠️ 不接 host 的"复诊/初诊"标记(那是衍生,PAC 自己从 encounter 集合算更可信);
/// 真正"急诊/常规"等结构化分类 host 多无,该字段大多 null,等其他 host 提供再填。
encounter_type: nullableString(),
/// 一般备注(自由文本)— 跟 chief_complaint 互补
notes: nullableString(),
})
.passthrough();
......@@ -88,6 +95,9 @@ const DiagnosisRecordContent = z
.optional()
.default(null),
onset_date: nullableString(),
/// 诊断医生(继承 emr 父级 user_id;医患关系信号)
doctor_id: nullableString(),
doctor_name: nullableString(),
/// 反查源接诊;算法 JOIN 凭这字段
source_encounter_external_id: z.string().min(1),
})
......@@ -105,6 +115,13 @@ const TreatmentRecordContent = z
status: PACTreatmentStatusSchema,
started_at: nullableString(),
completed_at: nullableString(),
/// 执行医生 id(host 侧)— 医患关系信号(主治医生绑定),供 doctor_relation persona / 话术个性化
doctor_id: nullableString(),
doctor_name: nullableString(),
/// 治疗份数(种植 4 颗 / 龈下刮治 3 区 / 套餐 5 套 …)— 通用临床计量
quantity: nullableNumber(),
/// 计量单位(颗/次/区/套 等)— 跟 quantity 配对
unit_name: nullableString(),
/// 反查源接诊
source_encounter_external_id: nullableString(),
/// 关联诊断 fact(可空 — 临床上治疗也可独立存在)
......@@ -121,6 +138,9 @@ const RecommendationRecordContent = z
.object({
code: PACDiagnosisCodeSchema, // IMPLANT_RECOMMENDED 等"推荐码"
tooth_position: toothPositionText(),
/// 建议医生(继承 emr 父级 user_id;话术个性化用)
doctor_id: nullableString(),
doctor_name: nullableString(),
/// 抽取来源(规则 / LLM / 医生录入)
extracted_by: RecommendationExtractedBySchema,
/// LLM 置信度(0-1);rule / doctor_input 为 null
......@@ -134,17 +154,40 @@ const RecommendationRecordContent = z
.passthrough();
/**
* emr_record — 电子病历(自由文本保留)
* emr_record — 电子病历(PAC 标准病历语义模型,覆盖完整 SOAP 环节)
*
* ⭐ **这是 PAC 自己定的标准,不被任何单一宿主形态牵着走**:
* - 字段 = 临床病历的通用语义全集(主诉/现病史/既往史/检查/诊断/治疗计划/处置/医嘱…),
* 是所有宿主病历的统一投影目标。
* - 某宿主映射不上某字段 → 留 null,**绝不因此从标准里删字段**。
* 例:瑞尔 DW 把诊断/治疗拆成结构化数组(→ diagnosis_record / treatment_record),
* 其 emr 的 diagnosis_text / treatment_plan 自然留 null;
* 但小诊所宿主病历常是"一段诊断描述 + 一段治疗计划自由文本",这俩字段就有值。
* - 宿主有什么、怎么翻译 = yaml field_mapping 的活,宿主形态不反向裁剪 PAC 标准。
*
* 字段是 Layer C(LLM/规则抽取)的源:exam_findings 常带牙位 + 临床描述,可补诊断码覆盖 / 牙位短板。
*/
const EmrRecordContent = z
.object({
emr_external_id: z.string().min(1),
encounter_external_id: nullableString(),
illness_desc: nullableString(),
pre_illness: nullableString(),
// 接诊医生(PAC 不建医生表,医生名作为 fact 快照随病历落库;
// doctor_id 与 settlement.doctor_id 同源 → treatment/payment 可借此映射到医生名)
doctor_id: nullableString(),
doctor_name: nullableString(),
// S 主观:主诉 / 现病史 / 既往史 / 全身情况
illness_desc: nullableString(), // 主诉 / 疾病描述
pre_illness: nullableString(), // 现病史
past_history: nullableString(), // 既往史
general_condition: nullableString(), // 全身情况 / 系统回顾
// O 客观:检查所见
exam_findings: nullableString(), // 检查所见(可带牙位的临床观察)
// A 评估:诊断自由文本(结构化诊断走 diagnosis_record;宿主无结构化时落这里)
diagnosis_text: nullableString(),
// P 计划:治疗计划自由文本(结构化治疗走 treatment_record)+ 处置 + 医嘱
treatment_plan: nullableString(),
doctor_advice: nullableString(),
disposal: nullableString(), // 处置(本次实操)
doctor_advice: nullableString(), // 医嘱
})
.passthrough(); // 自由文本 fact 允许 host 多带字段(算法不消费,Layer C 抽取消费)
......@@ -159,6 +202,9 @@ const ImageRecordContent = z
tooth_positions: z.array(z.string()).nullable().optional().default(null),
finding: nullableString(),
captured_at: nullableString(),
/// 拍摄/解读医生(继承 emr 父级 user_id,通常 = 接诊医生)
doctor_id: nullableString(),
doctor_name: nullableString(),
})
.passthrough();
......@@ -183,8 +229,31 @@ const AppointmentRecordContent = z
arrived_at: isoDateString.nullable().optional().default(null),
appointment_type: nullableString(),
doctor_id: nullableString(),
// 预约科目/就诊意向(常规/正畸/种植/修复/拔牙/牙周…),host appo_complaint_category
complaint_category: nullableString(),
// 预约主诉自由文本(跟 complaint_category 配对:分类 + 原文)— Layer C 源
complaint_text: nullableString(),
// 预约时长(分钟),host appo_time_period
duration_minutes: nullableNumber(),
// 取消原因(自由文本):"临时有事 / 不用回访 / 再约 / 去了其他诊所…"— 爽约/取消分析
cancellation_reason: nullableString(),
// 预约备注(运营内部自由文本):"转 X 医 / 后续修复 / …"— Layer C 备料
staff_notes: nullableString(),
// 8 态细分(host 8 状态 1:1):
// scheduled(正常,唯一有效预约)/ rescheduled(已改约,历史被取代)/ cancelled(已取消)
// arrived(到诊)/ in_treatment(接诊)/ completed(结算,到诊+消费完成)
// no_show(爽约)/ walk_in(walk-in 变更)
status: z
.enum(['scheduled', 'arrived', 'cancelled', 'no_show'])
.enum([
'scheduled',
'rescheduled',
'cancelled',
'arrived',
'in_treatment',
'completed',
'no_show',
'walk_in',
])
.nullable()
.optional()
.default(null),
......@@ -258,6 +327,11 @@ const PaymentRecordContent = z
payment_external_id: z.string().min(1),
amount_cents: z.number().int().nonnegative(),
channel: nullableString(),
/// 收费医生 id(host 侧)— 医患关系信号(患者主要花钱给哪个医生)
doctor_id: nullableString(),
/// 关联接诊 id(反查"这次收款关联哪次接诊")
encounter_external_id: nullableString(),
/// 关联医嘱单 id(charge_order)— 其他 host 可能提供;host 不区分 order/encounter 时留 null
related_order_external_id: nullableString(),
})
.passthrough();
......@@ -288,10 +362,29 @@ const ComplaintRecordContent = z
})
.passthrough();
/**
* referral_record — 转介绍(老带新 / 渠道归因)
*
* fact 挂在**被推荐人**(referee = fact.patient_id),表达"我是被 XXX 推荐来的"。
* 推荐人(referrer)信息作为**快照**进 content(PAC 不维护医生/员工/患者主数据外的 referrer 表;
* referrer 可能跨 host / 跨诊所 / 是员工,统一存名 + 类型即可,跟 emr.doctor_name 模式一致)。
*/
const ReferralRecordContent = z
.object({
referral_external_id: z.string().min(1),
referrer_patient_external_id: nullableString(),
/// 推荐人 host id(可能是 patient_id / employee_id);仅作标识,PAC 不强约束类型
referrer_external_id: nullableString(),
/// 推荐人姓名(快照)
referrer_name: nullableString(),
/// 推荐人病历号(若 referrer 是患者;辅助标识)
referrer_chart_number: nullableString(),
/// 推荐人类型:patient(老带新)/ staff(员工)/ partner(合作渠道)/ other
referrer_type: z
.enum(['patient', 'staff', 'partner', 'other'])
.nullable()
.optional()
.default(null),
/// 推荐渠道(host 可能不提供)
channel: nullableString(),
})
.passthrough();
......
......@@ -35,8 +35,10 @@ export class AppointmentParser implements Parser {
const hostStatus = String(c.status ?? 'scheduled').toLowerCase();
const factStatus = AppointmentParser.STATUS_MAP[hostStatus] ?? FactStatus.ACTIVE;
// arrived → 该预约已变成"已发生事实";其他状态保持 planned
const kind = hostStatus === 'arrived' ? FactKind.ACTUAL : FactKind.PLANNED;
// 到诊/接诊/结算 = 已发生事实(actual);其余(含已改约/取消/爽约)= planned
const kind = AppointmentParser.ACTUAL_STATUSES.has(hostStatus)
? FactKind.ACTUAL
: FactKind.PLANNED;
const scheduledAt = c.scheduledAt ? new Date(c.scheduledAt as string) : null;
const arrivedAt = c.arrivedAt ? new Date(c.arrivedAt as string) : null;
......@@ -51,9 +53,13 @@ export class AppointmentParser implements Parser {
type: FactType.APPOINTMENT_RECORD,
status: factStatus,
clinicId: ctx.transaction.clinicId,
// kind=actual 时 occurredAt = arrivedAt;kind=planned 时 plannedFor = scheduledAt
occurredAt: kind === FactKind.ACTUAL ? arrivedAt : null,
plannedFor: kind === FactKind.PLANNED ? scheduledAt : null,
// 时间语义(两列不同):
// scheduledAt ← appo_time(约定来诊"日期",Date,100% 有)
// arrivedAt ← in_time(实际到诊"时刻",DateTime,仅到诊类填,且~25% 到诊缺失)
// occurredAt(已发生):到诊时刻优先,缺失时兜底约定日(否则到诊无 in_time 会两时间皆 null 沉底)
// plannedFor(约定):始终 = 约定日(预约固有属性,不因 kind 丢失)
occurredAt: kind === FactKind.ACTUAL ? (arrivedAt ?? scheduledAt) : null,
plannedFor: scheduledAt,
validFrom: scheduledAt,
title: appointmentType
? `预约 ${appointmentType}(${this.formatDate(scheduledAt)})`
......@@ -67,30 +73,58 @@ export class AppointmentParser implements Parser {
: null,
content: {
appointment_external_id: externalId,
scheduled_at: c.scheduledAt ?? null,
arrived_at: c.arrivedAt ?? null,
scheduled_at: scheduledAt ? scheduledAt.toISOString() : null,
arrived_at: arrivedAt ? arrivedAt.toISOString() : null,
appointment_type: appointmentType,
doctor_id: doctorId,
// host 业务状态(scheduled/arrived/cancelled/no_show)— 跟 fact.status(PAC 版本流)互补
complaint_category: (c.complaintCategory as string | undefined) ?? null,
complaint_text: (c.complaintText as string | undefined) ?? null,
duration_minutes:
c.durationMinutes != null && c.durationMinutes !== ''
? Number(c.durationMinutes)
: null,
cancellation_reason:
(c.cancellationReason as string | undefined) ?? null,
staff_notes: (c.staffNotes as string | undefined) ?? null,
// host 业务状态(8 态细分)— 跟 fact.status(PAC 版本流)互补
status: AppointmentParser.HOST_STATUS_ENUM[hostStatus] ?? null,
},
},
];
}
// host status → FactStatus(PAC 版本流状态)
// 已发生事实(actual)的 host 状态:到诊 / 接诊 / 结算 + walk-in 变更(已到店)
private static readonly ACTUAL_STATUSES = new Set([
'arrived',
'in_treatment',
'completed',
'walk_in',
]);
// host status(8 态)→ FactStatus(PAC 版本流状态)
// scheduled → active(唯一有效预约)
// rescheduled → superseded(已被改约取代的历史,不当有效预约,默认时间轴/召回不计)
// arrived/in_treatment/completed/walk_in → fulfilled(已发生)
// cancelled → cancelled / no_show → expired
private static readonly STATUS_MAP: Record<string, FactStatus> = {
scheduled: FactStatus.ACTIVE,
rescheduled: FactStatus.SUPERSEDED,
arrived: FactStatus.FULFILLED,
in_treatment: FactStatus.FULFILLED,
completed: FactStatus.FULFILLED,
walk_in: FactStatus.FULFILLED,
cancelled: FactStatus.CANCELLED,
no_show: FactStatus.EXPIRED,
};
// host_status → content.status(host 业务状态闭集,zod 强校验)
// 防御性映射:任何未识别的 host 值 → null,fact.status 仍能由 STATUS_MAP fallback
// host_status → content.status(8 态闭集,zod 强校验);未识别 → null
private static readonly HOST_STATUS_ENUM: Record<string, string | null> = {
scheduled: 'scheduled',
rescheduled: 'rescheduled',
arrived: 'arrived',
in_treatment: 'in_treatment',
completed: 'completed',
walk_in: 'walk_in',
cancelled: 'cancelled',
no_show: 'no_show',
};
......
......@@ -53,7 +53,9 @@ export class DiagnosisParser implements Parser {
const toothPosition = (c.toothPosition as string | undefined) ?? null;
const sourceEncounter = (c.sourceEncounterExternalId as string | undefined) ?? null;
const nameZh = (c.name as string | undefined) ?? null;
// host 录入习惯尾部带标点("慢性牙龈炎;" / "牙周炎。" / "牙龈炎," / "龋齿?" / "缺失?"...)
// 在 parser 层一次性清洗:首尾空白 + 尾部中英文标点 → 下游 chain-composer / WhyCard / persona 全部受益
const nameZh = cleanName((c.name as string | undefined) ?? null);
return [
{
......@@ -72,9 +74,24 @@ export class DiagnosisParser implements Parser {
tooth_position: toothPosition,
severity: (c.severity as string | undefined) ?? null,
onset_date: (c.onsetDate as string | undefined) ?? null,
doctor_id: c.doctorId ? String(c.doctorId) : null, // host user_id Int64 → string
doctor_name: (c.doctorName as string | undefined) ?? null,
source_encounter_external_id: sourceEncounter ?? externalId,
},
},
];
}
}
/// 诊断名清洗:首尾空白 + 尾部标点(中英文,含 host 常见的 ;。,;,??!! 等)
/// 例:" 慢性牙龈炎;" → "慢性牙龈炎"
function cleanName(s: string | null): string | null {
if (!s) return null;
// 1. 首尾空白(含全角空格)
// 2. 尾部标点 — 中英文混合:;。,;.,??!!::、
const cleaned = s
.trim()
.replace(/[\s ;。,;.,??!!::、]+$/g, '')
.trim();
return cleaned || null;
}
......@@ -15,6 +15,15 @@ export class EmrParser implements Parser {
readonly name = Action.EMR_SUBMITTED;
private readonly logger = new Logger(EmrParser.name);
/// 规整为文本:examine/dispose 在 host 是 JSON 文本,但 CH 读取层会把像 JSON 的
/// String 自动 parse 成 object/array。这里统一存回 JSON 原文字符串(content schema 要 string,
/// Layer C 后续自己 parse)。纯文本字段经此原样返回。
private asText(v: unknown): string | null {
if (v == null) return null;
if (typeof v === 'string') return v;
return JSON.stringify(v);
}
parse(ctx: ParserContext): FactDraft[] {
const c = ctx.canonicalRow;
const externalId = String(c.externalId ?? ctx.transaction.subjectId).trim();
......@@ -43,11 +52,19 @@ export class EmrParser implements Parser {
content: {
emr_external_id: externalId,
encounter_external_id: encounterExternalId,
diagnosis_text: c.diagnosisText ?? null,
diagnosis_codes: c.diagnosisCodes ?? null,
treatment_plan: c.treatmentPlan ?? null,
doctor_advice: c.doctorAdvice ?? null,
image_ids: c.imageIds ?? [],
doctor_id: this.asText(c.doctorId), // host user_id(医生),与 settlement.doctor_id 同源
doctor_name: this.asText(c.doctorName), // 接诊医生名(快照,PAC 不建医生表)
// SOAP 自由文本段落(canonical 字段由 emr.yaml field_mapping 翻译)
// PAC 标准病历字段全集(宿主映射不上的自然 null,如瑞尔的 diagnosis_text/treatment_plan)
illness_desc: this.asText(c.illnessDesc),
pre_illness: this.asText(c.preIllness),
past_history: this.asText(c.pastHistory),
general_condition: this.asText(c.generalCondition),
exam_findings: this.asText(c.examFindings), // host examine 原文(JSON,带牙位)
diagnosis_text: this.asText(c.diagnosisText), // 结构化宿主留 null,自由文本宿主有值
treatment_plan: this.asText(c.treatmentPlan), // 同上
disposal: this.asText(c.disposal), // host dispose 原文(JSON)
doctor_advice: this.asText(c.doctorAdvice), // host doc_order
quality_check: qualityStatus
? {
status: qualityStatus,
......
......@@ -31,6 +31,8 @@ export class EncounterParser implements Parser {
const doctorId = (c.doctorId as string | undefined) ?? null;
const doctorName = (c.doctorName as string | undefined) ?? null;
const chiefComplaint = (c.chiefComplaint as string | undefined) ?? null;
const encounterType = (c.encounterType as string | undefined) ?? null;
const notes = (c.notes as string | undefined) ?? null;
return [
......@@ -42,11 +44,13 @@ export class EncounterParser implements Parser {
clinicId: ctx.transaction.clinicId,
occurredAt: ctx.transaction.occurredAt,
title: `接诊 ${encExternalId}`,
summary: notes ?? null,
summary: chiefComplaint ?? notes ?? null,
content: {
encounter_external_id: encExternalId,
doctor_id: doctorId,
doctor_name: doctorName,
chief_complaint: chiefComplaint,
encounter_type: encounterType,
notes,
},
},
......
......@@ -45,6 +45,8 @@ export class ImageParser implements Parser {
tooth_positions: toothPositions,
finding,
encounter_external_id: encounterExternalId,
doctor_id: c.doctorId ? String(c.doctorId) : null,
doctor_name: (c.doctorName as string | undefined) ?? null,
},
},
];
......
......@@ -38,8 +38,11 @@ export class PaymentParser implements Parser {
const amount = Number(c.amount ?? 0); // cents
const paidAt = c.paidAt ? new Date(c.paidAt as string) : null;
const encounterExternalId =
(c.encounterExternalId as string | undefined) ?? null;
const orderExternalId = (c.orderExternalId as string | undefined) ?? null;
const method = (c.method as string | undefined) ?? null;
const doctorId = c.doctorId ? String(c.doctorId) : null; // host Int64,0/null → null
const currency = (c.currency as string | undefined) ?? 'CNY';
return [
......@@ -56,6 +59,8 @@ export class PaymentParser implements Parser {
payment_external_id: externalId,
amount_cents: amount,
channel: method,
doctor_id: doctorId,
encounter_external_id: encounterExternalId,
related_order_external_id: orderExternalId,
},
},
......
......@@ -67,6 +67,8 @@ export class RecommendationParser implements Parser {
content: {
code: codeParsed.data,
tooth_position: (c.toothPosition as string | undefined) ?? null,
doctor_id: c.doctorId ? String(c.doctorId) : null,
doctor_name: (c.doctorName as string | undefined) ?? null,
extracted_by: extractedBy,
confidence,
source_fact_subject_id:
......
......@@ -6,8 +6,9 @@ import type { FactDraft, Parser, ParserContext } from './parser.interface';
* ReferralParser — `referral_recorded` 解析器
*
* 产 1 个 `referral_record`(actual)— 老带新 / 渠道归因事实。
* fact 挂在**推荐人**(referrer)patient 上;被推荐人(referee)的 fact 由
* 应用层按需聚合(persona.engagement / 渠道分析时反查)。
* fact 挂在**被推荐人**(referee = fact.patient_id),表达"我是被 XXX 推荐来的"。
* 推荐人(referrer)作为快照进 content(PAC 不维护 referrer 主数据;
* referrer 可能跨 host / 是员工,统一存名 + 类型即可,跟 emr.doctor_name 模式一致)。
*/
@Injectable()
export class ReferralParser implements Parser {
......@@ -18,15 +19,26 @@ export class ReferralParser implements Parser {
const c = ctx.canonicalRow;
const externalId = String(c.externalId ?? ctx.transaction.subjectId).trim();
if (!externalId) {
this.logger.warn(`referral parser: missing externalId; tx=${ctx.transaction.id};skip`);
this.logger.warn(
`referral parser: missing externalId; tx=${ctx.transaction.id};skip`,
);
return [];
}
const recordedAt = c.recordedAt ? new Date(c.recordedAt as string) : null;
const referrerExternalId = c.referrerExternalId
? String(c.referrerExternalId)
: null;
const referrerName = (c.referrerName as string | undefined) ?? null;
const referrerChartNumber =
(c.referrerChartNumber as string | undefined) ?? null;
const referrerTypeRaw = (c.referrerType as string | undefined) ?? null;
const referrerType = (
['patient', 'staff', 'partner', 'other'] as const
).includes(referrerTypeRaw as never)
? (referrerTypeRaw as 'patient' | 'staff' | 'partner' | 'other')
: null;
const channel = (c.channel as string | undefined) ?? null;
const conversionAmount = c.conversionAmount != null ? Number(c.conversionAmount) : null;
const referrerExternalId = (c.referrerPatientExternalId as string | undefined) ?? null;
const refereeExternalId = (c.refereePatientExternalId as string | undefined) ?? null;
return [
{
......@@ -36,14 +48,15 @@ export class ReferralParser implements Parser {
status: FactStatus.ACTIVE,
clinicId: ctx.transaction.clinicId,
occurredAt: recordedAt,
title: `转介绍 ${externalId}${channel ? '(' + channel + ')' : ''}`,
summary: refereeExternalId ? `推荐对象:${refereeExternalId}` : null,
title: `转介绍来源 ${referrerName ?? referrerExternalId ?? '?'}${channel ? '(' + channel + ')' : ''}`,
summary: null,
content: {
referral_external_id: externalId,
referrer_external_id: referrerExternalId,
referrer_name: referrerName,
referrer_chart_number: referrerChartNumber,
referrer_type: referrerType,
channel,
referrer_patient_external_id: referrerExternalId,
referee_patient_external_id: refereeExternalId,
conversion_amount: conversionAmount,
},
},
];
......
......@@ -21,7 +21,11 @@ export class RefundParser implements Parser {
return [];
}
const amount = Number(c.amount ?? 0); // cents
// Math.abs 归一:host 退费两种表达
// ① is_refund=1 → settlement_money 通常 > 0(被退金额本身)
// ② settlement_status=4 → settlement_money 通常 < 0(反向冲减)
// 业务语义统一为正 cents = "患者拿回的钱"
const amount = Math.abs(Number(c.amount ?? 0)); // cents
const refundedAt = c.refundedAt ? new Date(c.refundedAt as string) : null;
const paymentExternalId = (c.paymentExternalId as string | undefined) ?? null;
const reason = (c.reason as string | undefined) ?? null;
......@@ -38,8 +42,8 @@ export class RefundParser implements Parser {
summary: reason,
content: {
refund_external_id: externalId,
amount,
payment_external_id: paymentExternalId,
amount_cents: amount, // schema 要 amount_cents(W3 末 jvs-dw 接入时修正)
related_payment_external_id: paymentExternalId, // schema 要 related_payment_external_id
reason,
},
},
......
......@@ -55,6 +55,7 @@ export class TreatmentParser implements Parser {
const subtype = (c.subtype as string | undefined) ?? 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
const sourceEncounter = (c.sourceEncounterExternalId as string | undefined) ?? null;
const relatedDx = (c.relatedDiagnosisExternalId as string | undefined) ?? null;
const reviewWindow =
......@@ -85,6 +86,13 @@ export class TreatmentParser implements Parser {
status: contentStatus,
started_at: startedAt ? startedAt.toISOString() : null,
completed_at: completedAt ? completedAt.toISOString() : null,
doctor_id: doctorId,
doctor_name: (c.doctorName as string | undefined) ?? null,
quantity:
c.quantity != null && c.quantity !== ''
? Number(c.quantity)
: null,
unit_name: (c.unitName as string | undefined) ?? null,
source_encounter_external_id: sourceEncounter,
related_diagnosis_subject_id: relatedDx,
review_window_days: reviewWindow,
......
......@@ -159,7 +159,17 @@ describe('canonical-fact-layer | 闸 4:yaml enum_mapping 目标 ∈ canonical-co
// appointment.status 是 host-side enum(scheduled/arrived/cancelled/no_show),不在 PAC canonical-codes
// 不强约束 — 校验跳过该字段
};
const appointmentStatusEnum = new Set(['scheduled', 'arrived', 'cancelled', 'no_show']);
// 8 态细分(跟 fact-content-schemas AppointmentRecordContent.status 对齐)
const appointmentStatusEnum = new Set([
'scheduled',
'rescheduled',
'cancelled',
'arrived',
'in_treatment',
'completed',
'no_show',
'walk_in',
]);
for (const { host, file, config } of yamls) {
const mappings = config.enum_mapping ?? {};
......
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
......@@ -124,3 +124,10 @@ body {
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
/* fadeIn — EmrSoapView carousel 切换淡入 */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
......@@ -41,7 +41,8 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
profile: {
doNotContact: real.profile?.doNotContact ?? false,
deceased: real.profile?.deceased ?? false,
primaryContactType: real.profile?.primaryContactType ?? '本人',
// host 没给 → null,UI 用 '—' 占位;不再前端硬兜底 '本人'(误导客服以为是本人)
primaryContactType: real.profile?.primaryContactType ?? null,
ltv: Math.round((real.profile?.ltvCents ?? 0) / 100),
firstVisit: real.profile?.firstVisit ?? '',
lastVisit: real.profile?.lastVisit ?? '',
......@@ -60,13 +61,21 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
target: c.target,
diagnosedAt: c.diagnosedAt,
gapDays: c.gapDays,
lifecycleNoteZh: c.lifecycleNoteZh,
toothPosition: c.toothPosition,
alternativeClosedBy: c.alternativeClosedBy,
nodes: c.nodes.map((n) => ({
stage: n.stage as 1 | 2 | 3 | 4 | 5,
label: n.label,
title: n.title,
detail: n.detail,
doctor: n.doctor,
at: n.at,
hint: n.hint,
done: n.done,
current: n.current,
missing: n.missing,
// 旧字段兼容透传(老调用方)
label: n.label,
note: n.note,
})),
}));
......@@ -92,12 +101,14 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
const planReasons: PlanReason[] = (real.plan?.reasons ?? []).map((r) => ({
id: r.id,
scenario: r.scenario,
subKey: r.subKey,
signals: r.signals as PlanReason['signals'],
scenarioLabel: planScenarioLabel(r.scenario),
priorityScore: r.priorityScore,
lifecycle: r.lifecycle as 'one_shot' | 'recurring',
source: r.source as PlanReason['source'],
reason: r.reason,
evidence: r.evidenceFactIds.map((id) => {
evidence: (r.evidence?.factIds ?? []).map((id) => {
const f = real.facts.find((x) => x.id === id);
return {
id,
......@@ -140,6 +151,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
kind: f.kind,
status: f.status,
occurredAt: f.occurredAt ?? null,
plannedFor: f.plannedFor ?? null,
clinicId: f.clinicId ?? null,
title: f.title ?? null,
summary: f.summary ?? null,
......@@ -153,7 +165,17 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
plan,
facts,
summaries: mockSummaries, // AI 假数据 — 接入 Vercel AI SDK 时换
script: mockScript, // 同上
// W4:话术 DB 持久化 — LLM 生成完会落 plan_scripts;real.script 命中即用真实数据
// 没生成过(real.script=null) → mock 兜底,客服点"重新生成"触发首次生成
// shape 跟 mockScript 完全一致(status/source/generatedAt/sections),用 as 转把后端宽 string 收窄到 mock 字面量类型
script: real.script && real.script.status === 'ready'
? ({
status: 'ready',
source: real.script.source,
generatedAt: new Date(real.script.generatedAt),
sections: real.script.sections,
} as typeof mockScript)
: mockScript,
outcomeOptions: mockOutcomes as OutcomeOption[],
fmtRel,
};
......@@ -166,6 +188,7 @@ export type AdaptedFact = {
kind: string;
status: string;
occurredAt: string | null;
plannedFor: string | null;
clinicId: string | null;
title: string | null;
summary: string | null;
......
'use client';
import { cn } from '@/lib/utils';
import { useState } from 'react';
import { cn, formatDaysReadable } from '@/lib/utils';
import { Chip } from './shared';
import type { Chain } from './mock-data';
......@@ -28,11 +29,151 @@ function chainTargetMeta(category: string | undefined): { evidence: string; wind
}
}
function ChainStatusBadge({ status }: { status: Chain['status'] }) {
if (status === 'closed') return <Chip tone="emerald" size="xs">已闭环</Chip>;
if (status === 'ongoing') return <Chip tone="sky" icon size="xs">在管</Chip>;
if (status === 'uninitiated') return <Chip tone="rose" icon size="xs">潜在新链</Chip>;
return <Chip tone="slate" size="xs">{status}</Chip>;
// ─────────────────────────────────────────────
// 5 阶段状态词表 — 单一真理源(badge / sidebar / list 都引用,避免随手起名)
// status × currentStage 决定文案 & 配色,不在调用方再编。
//
// discovered ★ 潜在新链 rose
// entered ⏵ 已进入 amber
// ongoing stage=3 ↻ 在管 · 治疗中 sky
// ongoing stage=4 ↻ 在管 · 复查中 sky
// closed ✓ 已闭环 emerald
//
// "二级信息"(时间/断口)由调用点拼,不进词表
// ─────────────────────────────────────────────
type ChainStatusVisual = {
short: string; // 短标签(badge / sidebar 用),如 "已进入"
long: string; // 长标签(全景卡 badge 用),如 "↻ 在管 · 治疗中"
icon: string; // ★ / ⏵ / ↻ / ✓
tone: 'rose' | 'amber' | 'sky' | 'emerald';
};
function chainStatusVisual(chain: Pick<Chain, 'status' | 'currentStage'>): ChainStatusVisual {
if (chain.status === 'closed') return { short: '已闭环', long: '✓ 已闭环', icon: '✓', tone: 'emerald' };
if (chain.status === 'entered') return { short: '已进入', long: '⏵ 已进入', icon: '⏵', tone: 'amber' };
if (chain.status === 'discovered') return { short: '潜在新链', long: '★ 潜在新链', icon: '★', tone: 'rose' };
// ongoing — 按 stage 区分"治疗中" / "复查中" (业务上是不同语义,客服需要知道病人在做啥)
const sub = chain.currentStage >= 4 ? '复查中' : '治疗中';
return { short: `在管 · ${sub}`, long: `↻ 在管 · ${sub}`, icon: '↻', tone: 'sky' };
}
// 5 状态徽章(W3 末从 3 态升级)— 后端 chain-composer 5 阶段引擎产物
function ChainStatusBadge({ status, currentStage }: { status: Chain['status']; currentStage: Chain['currentStage'] }) {
const v = chainStatusVisual({ status, currentStage });
return (
<Chip tone={v.tone} size="xs" icon={v.tone !== 'emerald'}>
{v.short}
</Chip>
);
}
/**
* TimelineStageNode — 5 阶段 timeline 单节点(共享渲染)
*
* 4 信息位:
* L0 圆点 + ① 发现机会(静态阶段 header)
* L1 title 具体动作(如 慢性牙周炎 / *全口洁治)
* L2 detail 关键事实(如 全口 26 牙 / ¥58 / 1/3 步骤)
* L3 doctor 医生名(可空)
* L4 at 日期 YYYY.MM.DD
* L5 hint discovered/未闭环 等态的"建议下一步"提示(rose tone)
*
* 视觉:
* - done 实心圆 ✓(theme 色:闭环 emerald / 在管 sky / 召回 rose)
* - current 实心圆高亮(amber ring)
* - missing 虚线灰圆
*/
function TimelineStageNode({
node,
idx,
theme,
}: {
node: Chain['nodes'][number];
idx: number;
theme: 'rose' | 'emerald' | 'sky' | 'amber';
}) {
const isDone = !!node.done;
const isCurrent = !!node.current;
const dotCls = isDone
? cn(THEME_DOT[theme])
: isCurrent
? 'bg-amber-400 border-amber-400 ring-2 ring-amber-100'
: 'bg-white border-dashed border-slate-300';
const titleCls = isDone || isCurrent ? 'text-slate-900' : 'text-slate-400';
const detailCls = isDone || isCurrent ? 'text-slate-600' : 'text-slate-400';
const atCls = isDone || isCurrent ? 'text-slate-500' : 'text-slate-300';
const dash = '—';
const title = node.title || node.label || dash;
const detail = node.detail || dash;
const doctor = node.doctor || dash;
const at = node.at || dash;
return (
// min-w-0 + overflow-hidden 让 truncate 在 grid col 里生效
<div className="flex flex-col items-center text-center min-w-0 overflow-hidden px-0.5">
{/* L0 dot */}
<span
className={cn(
'relative z-10 flex items-center justify-center w-[14px] h-[14px] rounded-full border-2',
dotCls,
)}
>
{isDone && (
<svg viewBox="0 0 12 12" className="w-2 h-2 text-white">
<path d="M2 6.5l2.5 2.5L10 3" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" />
</svg>
)}
</span>
{/* stage header(静态)*/}
<div
className={cn(
'mt-1 text-[9.5px] font-medium tracking-wider whitespace-nowrap',
isDone || isCurrent ? 'text-slate-400' : 'text-slate-300',
)}
>
{STAGE_NUM[idx]} {STAGE_LABELS[idx]}
</div>
{/* 4 行强制占位(单行省略号,字段空用 — 占位 → 行对齐)
标题 title="..." 给 hover 看全文 */}
<div
className={cn('mt-1 text-[11px] font-semibold leading-tight truncate w-full', titleCls)}
title={title}
>
{title}
</div>
<div className={cn('text-[10px] mt-0.5 leading-tight truncate w-full', detailCls)} title={detail}>
{detail}
</div>
<div className="text-[10px] mt-0.5 leading-tight truncate w-full text-slate-500" title={doctor}>
{doctor}
</div>
<div className={cn('text-[10px] mt-0.5 tabular-nums leading-tight truncate w-full', atCls)} title={at}>
{at}
</div>
</div>
);
}
/// 从 nodes 收集所有 hint,按阶段排序 — 给底部"建议下一步" 汇总区用
function collectHints(chain: Chain): Array<{ stage: number; text: string }> {
return chain.nodes
.filter((n) => !!n.hint)
.map((n) => ({ stage: n.stage, text: n.hint! }));
}
const THEME_DOT: Record<'rose' | 'emerald' | 'sky' | 'amber', string> = {
rose: 'bg-rose-500 border-rose-500',
emerald: 'bg-emerald-500 border-emerald-500',
sky: 'bg-sky-500 border-sky-500',
amber: 'bg-amber-500 border-amber-500',
};
/// 状态 → 主题色映射
function themeOfStatus(status: Chain['status']): 'rose' | 'emerald' | 'sky' | 'amber' {
if (status === 'closed') return 'emerald';
if (status === 'ongoing') return 'sky';
if (status === 'entered') return 'amber';
return 'rose';
}
// ──────────────────────────────────────────
......@@ -50,64 +191,35 @@ export function ChainTimeline({ chains }: { chains: Chain[] }) {
}
function TargetTimelineRow({ chain }: { chain: Chain }) {
const theme = themeOfStatus(chain.status);
return (
<div className="relative rounded-lg border border-rose-200 bg-gradient-to-br from-rose-50/50 to-white px-3 pt-3 pb-2 ring-1 ring-rose-100">
{/* 左上角 corner badge 已表达"★ 潜在新链 = discovered" 状态,
标题旁不再重复 ChainStatusBadge */}
<span className="absolute -top-2 left-3 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-rose-600 text-white text-[9.5px] font-medium shadow-sm">
★ 潜在新链
</span>
<div className="flex items-center gap-2 mb-2">
<span className="text-[13.5px] font-semibold text-slate-900">{chain.name}</span>
<ChainStatusBadge status={chain.status} />
<div className="flex items-center gap-2 mb-2 min-w-0">
<span className="text-[13.5px] font-semibold text-slate-900 truncate" title={chain.name}>
{chain.name}
</span>
{chain.lifecycleNoteZh && (
<span className="text-[10.5px] text-slate-500 whitespace-nowrap">· {chain.lifecycleNoteZh}</span>
)}
</div>
<div className="relative pt-0.5 pb-1">
<div className="absolute left-3 right-3 top-[7px] h-px bg-slate-200" />
<div className="absolute left-3 top-[7px] h-px bg-rose-400" style={{ width: '16%' }} />
<div className="absolute left-3 top-[7px] h-px bg-rose-400" style={{ width: `${(chain.currentStage / 5) * 100}%` }} />
<div className="grid grid-cols-5 gap-2">
{chain.nodes.map((n, idx) => {
const isDone = !!n.done;
return (
<div key={idx} className="flex flex-col items-center text-center min-w-0">
<span
className={cn(
'relative z-10 flex items-center justify-center w-[14px] h-[14px] rounded-full border-2',
isDone ? 'bg-rose-500 border-rose-500' : 'bg-white border-dashed border-slate-300',
)}
>
{isDone && (
<svg viewBox="0 0 12 12" className="w-2 h-2 text-white">
<path
d="M2 6.5l2.5 2.5L10 3"
stroke="currentColor"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
/>
</svg>
)}
</span>
<div className="mt-1 leading-tight">
<div className={cn('text-[10.5px] font-medium', isDone ? 'text-slate-700' : 'text-slate-400')}>
{isDone ? n.label : '未进入'}
</div>
<div className={cn('text-[9.5px] tabular-nums', isDone ? 'text-slate-500' : 'text-slate-300')}>
{n.at}
</div>
</div>
<div
className={cn(
'mt-0.5 text-[9px] font-medium tracking-wider',
isDone ? 'text-slate-400' : 'text-slate-300',
)}
>
{STAGE_NUM[idx]} {STAGE_LABELS[idx]}
</div>
</div>
);
})}
{chain.nodes.map((n, idx) => (
<TimelineStageNode key={idx} node={n} idx={idx} theme={theme} />
))}
</div>
</div>
{/* 断口 banner — 召回核心信号(诊断 + gap + 医嘱时窗)。
新链场景里这条已完整表达"该做什么 + 多久没做",不再加底部"建议下一步" 行 */}
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded bg-rose-50 border border-rose-100 text-[11.5px]">
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 text-rose-600 flex-none" fill="none" stroke="currentColor" strokeWidth="2">
<path
......@@ -115,9 +227,9 @@ function TargetTimelineRow({ chain }: { chain: Chain }) {
strokeLinecap="round"
/>
</svg>
<div className="text-rose-800 leading-tight">
<div className="text-rose-800 leading-tight min-w-0 truncate">
<strong className="font-semibold">链断口</strong> · 已诊断 {chain.diagnosedAt}
<strong className="font-semibold tabular-nums"> · {chain.gapDays}未进入治疗链</strong>
<strong className="font-semibold tabular-nums"> · {formatDaysReadable(chain.gapDays)}未进入治疗链</strong>
<span className="text-rose-600/80 ml-1">{chainTargetMeta(chain.category).window}</span>
</div>
</div>
......@@ -218,9 +330,10 @@ function MiniDots({
// ──────────────────────────────────────────
export function ChainSidebar({ chains }: { chains: Chain[] }) {
const sorted = [...chains].sort((a, b) => (b.target ? 1 : 0) - (a.target ? 1 : 0));
const visible = sorted.slice(0, 3);
return (
<div className="space-y-1.5">
{sorted.map((c) => (
{visible.map((c) => (
<ChainSidebarRow key={c.id} chain={c} />
))}
</div>
......@@ -229,60 +342,296 @@ export function ChainSidebar({ chains }: { chains: Chain[] }) {
function ChainSidebarRow({ chain }: { chain: Chain }) {
const reach = chain.status === 'closed' ? 5 : chain.currentStage;
const lastNode = chain.nodes[4];
const currNode = chain.nodes[chain.currentStage - 1];
// 最近活跃步骤(已 done 或 current 的最大 stage 节点)→ 显示该步骤名 + 时间
const recentNode = chain.nodes
.filter((n) => n.done || n.current)
.sort((a, b) => b.stage - a.stage)[0];
if (chain.target) {
// 新链:用 S1 节点真实数据(诊断名 + 牙位 + 医生)替代旧 hard-coded chainTargetMeta.evidence
const s1 = chain.nodes[0];
const evidence = [s1?.title, s1?.detail].filter(Boolean).join(' · ');
return (
<div className="relative rounded-md border border-rose-300 bg-gradient-to-br from-rose-50 to-white px-2.5 pt-2 pb-2 ring-1 ring-rose-100">
<span className="absolute -top-1.5 left-2 inline-flex items-center px-1.5 py-px rounded-full bg-rose-600 text-white text-[9px] font-bold shadow-sm">
★ 潜在新链
</span>
<div className="flex items-center justify-between gap-2 mt-0.5">
<span className="text-[12px] font-semibold text-slate-900 truncate">{chain.name}</span>
<span className="text-[12px] font-semibold text-slate-900 truncate" title={chain.name}>
{chain.name}
</span>
</div>
<div className="flex items-center gap-1.5 mt-1">
<MiniDots reach={1} closed={false} target />
<span className="text-[10px] text-rose-600 font-semibold tabular-nums">
{chain.gapDays}断口
{formatDaysReadable(chain.gapDays)}断口
</span>
</div>
<div className="text-[10.5px] text-slate-600 mt-1 leading-tight">
{chainTargetMeta(chain.category).evidence} · {chain.diagnosedAt}
<div className="text-[10.5px] text-slate-600 mt-1 leading-tight truncate" title={`${evidence || '已诊断'} · ${chain.diagnosedAt}`}>
{evidence || '已诊断'} · {chain.diagnosedAt}
</div>
</div>
);
}
// 二级信息(时间):闭环→闭环 at;entered→启动 at;ongoing→最近 done 节点 at
// (discovered+!target 已被后端 filter,不会到这里)
const closedAt = recentNode?.at || chain.nodes[0]?.at || chain.diagnosedAt || '—';
const sub =
chain.status === 'closed'
? `闭环 ${closedAt}`
: chain.status === 'entered'
? `启动 ${recentNode?.at || chain.diagnosedAt || '—'}`
: recentNode?.at
? `最近 ${recentNode.at}`
: '';
const v = chainStatusVisual(chain);
const wrapCls = TONE_WRAP[v.tone];
const dotCls = TONE_DOT[v.tone];
return (
<div
className={cn(
'rounded-md border px-2.5 py-2',
chain.status === 'closed' ? 'bg-emerald-50/40 border-emerald-200/70' : 'bg-sky-50/40 border-sky-200/70',
)}
>
<div className={cn('rounded-md border px-2.5 py-2', wrapCls)}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<span
className={cn(
'flex-none w-4 h-4 rounded-full flex items-center justify-center text-[10px] font-bold',
chain.status === 'closed' ? 'bg-emerald-500 text-white' : 'bg-sky-500 text-white',
)}
>
{chain.status === 'closed' ? '✓' : '↻'}
<span className={cn('flex-none w-4 h-4 rounded-full flex items-center justify-center text-[10px] font-bold', dotCls)}>
{v.icon}
</span>
<span className="text-[12px] font-medium text-slate-900 truncate" title={chain.name}>
{chain.name}
</span>
<span className="text-[12px] font-medium text-slate-900 truncate">{chain.name}</span>
</div>
{/* closed/ongoing 链无可信的成交金额(payment 跟 treatment 无强关联);
金额维度不展示,改用"完成阶段 + 时间"作为概览信息 */}
<span className="text-[10px] text-slate-500 tabular-nums whitespace-nowrap">
阶段 {chain.currentStage}/5
</span>
</div>
<div className="flex items-center gap-1.5 mt-1">
<div className="flex items-center gap-1.5 mt-1 min-w-0">
<MiniDots reach={reach} closed={chain.status === 'closed'} />
<span className="text-[10px] text-slate-500 truncate">
{chain.status === 'closed' ? `闭环 · ${lastNode?.at ?? ''}` : `在管 · ${currNode?.note || '进行中'}`}
<span className={cn('text-[10px] font-medium flex-none', TONE_TEXT[v.tone])}>{v.short}</span>
{sub && (
<>
<span className="text-slate-300 flex-none">·</span>
<span className="text-[10px] text-slate-500 truncate" title={sub}>
{sub}
</span>
</>
)}
</div>
</div>
);
}
// status 主色 → 卡片背景 / dot / 文字 三套类名(单一来源,免散写)
type ChainTone = 'rose' | 'amber' | 'sky' | 'emerald';
const TONE_WRAP: Record<ChainTone, string> = {
rose: 'bg-rose-50/40 border-rose-200/70',
amber: 'bg-amber-50/40 border-amber-200/70',
sky: 'bg-sky-50/40 border-sky-200/70',
emerald: 'bg-emerald-50/40 border-emerald-200/70',
};
const TONE_DOT: Record<ChainTone, string> = {
rose: 'bg-rose-500 text-white',
amber: 'bg-amber-500 text-white',
sky: 'bg-sky-500 text-white',
emerald: 'bg-emerald-500 text-white',
};
const TONE_TEXT: Record<ChainTone, string> = {
rose: 'text-rose-700',
amber: 'text-amber-700',
sky: 'text-sky-700',
emerald: 'text-emerald-700',
};
// ──────────────────────────────────────────
// ChainDetailView — drawer 用:上明细 + 下全部列表(点击切换)
// ──────────────────────────────────────────
export function ChainDetailView({ chains }: { chains: Chain[] }) {
const sorted = [...chains].sort((a, b) => (b.target ? 1 : 0) - (a.target ? 1 : 0));
const [activeId, setActiveId] = useState<string | undefined>(sorted[0]?.id);
const active = sorted.find((c) => c.id === activeId) ?? sorted[0];
if (!active) return <div className="text-[12px] text-slate-400">无治疗链数据</div>;
return (
<div className="space-y-4">
{/* 上半:激活的明细(target → TimelineRow;closed → ClosedFullCard)*/}
{active.target ? (
<TargetTimelineRow chain={active} />
) : (
<ClosedChainFullCard chain={active} />
)}
{/* 下半:全部列表(点击切换上面的明细)*/}
<div className="pt-3 border-t border-slate-200">
<div className="text-[11px] text-slate-500 mb-2">
全部 {chains.length} 条治疗链
</div>
<div className="grid grid-cols-2 gap-2">
{sorted.map((c) => (
<ChainListItem
key={c.id}
chain={c}
active={c.id === active.id}
onClick={() => setActiveId(c.id)}
/>
))}
</div>
</div>
</div>
);
}
function ChainListItem({
chain,
active,
onClick,
}: {
chain: Chain;
active: boolean;
onClick: () => void;
}) {
const isTarget = !!chain.target;
// 用 chainStatusVisual 统一图标 + tone — 5 态精确(包括 discovered 未召回降级 slate)
const v = chainStatusVisual(chain);
const iconBgClass: Record<string, string> = {
rose: 'bg-rose-100 text-rose-700',
amber: 'bg-amber-100 text-amber-700',
sky: 'bg-sky-100 text-sky-700',
emerald: 'bg-emerald-100 text-emerald-700',
slate: 'bg-slate-100 text-slate-500',
};
const activeRing: Record<string, string> = {
rose: 'border-rose-400 bg-rose-50 ring-1 ring-rose-200',
amber: 'border-amber-400 bg-amber-50 ring-1 ring-amber-200',
sky: 'border-teal-400 bg-teal-50 ring-1 ring-teal-200',
emerald: 'border-emerald-400 bg-emerald-50 ring-1 ring-emerald-200',
slate: 'border-slate-300 bg-slate-50 ring-1 ring-slate-200',
};
return (
<button
type="button"
onClick={onClick}
className={cn(
'text-left rounded-md border px-2.5 py-2 transition',
active
? activeRing[v.tone]
: 'border-slate-200 bg-white hover:bg-slate-50',
)}
>
<div className="flex items-center gap-1.5">
<span className={cn('inline-flex items-center justify-center w-4 h-4 rounded-full text-[9px] font-bold flex-none', iconBgClass[v.tone])}>
{v.icon}
</span>
<span className="text-[12px] font-medium text-slate-900 truncate">{chain.name}</span>
</div>
{(() => {
if (isTarget) {
// discovered + target=true(真潜在新链):"断口" 补召回紧迫度
return (
<div className="mt-1 text-[10.5px] text-slate-500 truncate">
<span className={TONE_TEXT[v.tone]}>{v.short}</span>
<span className="text-slate-300 mx-1">·</span>断口 {formatDaysReadable(chain.gapDays)} · {chain.diagnosedAt}
</div>
);
}
// 后端 filterDiscoveredByPatientLevel 已删 discovered+!target,这里只剩 entered/ongoing/closed
const sub =
chain.status === 'closed'
? `闭环 ${chain.nodes[4]?.at || [...chain.nodes].reverse().find((n) => n.done)?.at || chain.diagnosedAt || '—'}`
: chain.status === 'entered'
? `启动 ${chain.diagnosedAt}`
: `最近 ${[...chain.nodes].reverse().find((n) => n.done || n.current)?.at || '—'}`;
return (
<div className="mt-1 text-[10.5px] text-slate-500 truncate">
<span className={TONE_TEXT[v.tone]}>{v.short}</span>
<span className="text-slate-300 mx-1">·</span>{sub}
</div>
);
})()}
</button>
);
}
function ClosedChainFullCard({ chain }: { chain: Chain }) {
const theme = themeOfStatus(chain.status);
// 统一走 chainStatusVisual — corner badge 用 long 标签("↻ 在管 · 治疗中" / "⏵ 已进入" / "✓ 已闭环")
// 跟侧边/列表的"短标签"含义对齐,只是位置不同写法略丰富
const v = chainStatusVisual(chain);
const cardCls =
v.tone === 'emerald'
? 'border-emerald-200 bg-gradient-to-br from-emerald-50/50 to-white ring-emerald-100'
: v.tone === 'sky'
? 'border-sky-200 bg-gradient-to-br from-sky-50/50 to-white ring-sky-100'
: 'border-amber-200 bg-gradient-to-br from-amber-50/50 to-white ring-amber-100';
const badgeCls =
v.tone === 'emerald' ? 'bg-emerald-600' : v.tone === 'sky' ? 'bg-sky-600' : 'bg-amber-600';
const lineCls =
v.tone === 'emerald' ? 'bg-emerald-400' : v.tone === 'sky' ? 'bg-sky-400' : 'bg-amber-400';
const badgeLabel = v.long;
// 闭环链不再渲染"建议下一步"(已闭环就没"下一步"可建议;
// 替代闭环时 chain header amber "已被替代:X" chip 已表达后续方案,不需要重复)
const hints = chain.status === 'closed' ? [] : collectHints(chain);
return (
<div className={cn('relative rounded-lg border ring-1 px-3 pt-3 pb-2', cardCls)}>
<span className={cn('absolute -top-2 left-3 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-white text-[9.5px] font-medium shadow-sm', badgeCls)}>
{badgeLabel}
</span>
{/* 左上角 corner badge 已表达 status,标题旁不再重复 ChainStatusBadge */}
<div className="flex items-center gap-2 mb-2 min-w-0">
<span className="text-[13.5px] font-semibold text-slate-900 truncate" title={chain.name}>
{chain.name}
</span>
{chain.lifecycleNoteZh && (
<span className="text-[10.5px] text-slate-500 whitespace-nowrap">· {chain.lifecycleNoteZh}</span>
)}
{chain.alternativeClosedBy && (
<span
className="text-[10.5px] px-1.5 py-px rounded bg-amber-100 text-amber-800 whitespace-nowrap"
title={`原诊断方案已被 "${chain.alternativeClosedBy}" 替代( K04 K08 拔除做种植);客服不需要召回此链`}
>
⟳ 已被替代:{chain.alternativeClosedBy}
</span>
)}
</div>
<div className="relative pt-0.5 pb-1">
<div className="absolute left-3 right-3 top-[7px] h-px bg-slate-200" />
<div
className={cn('absolute left-3 top-[7px] h-px', lineCls)}
style={{ width: chain.status === 'closed' ? '100%' : `${(chain.currentStage / 5) * 100}%` }}
/>
<div className="grid grid-cols-5 gap-2">
{chain.nodes.map((n, idx) => (
<TimelineStageNode key={idx} node={n} idx={idx} theme={theme} />
))}
</div>
</div>
<ChainHintFooter hints={hints} tone={theme} />
</div>
);
}
/// 底部汇总"建议下一步" — 只取最有意义的一条(优先 stage 最小的 = 离当前最近的下一步)
/// 全部 hint 通过 title 可 hover 查看(完整信息保留,UI 不冗长)
function ChainHintFooter({
hints,
tone,
}: {
hints: Array<{ stage: number; text: string }>;
tone: 'rose' | 'emerald' | 'sky' | 'amber';
}) {
if (hints.length === 0) return null;
const cls =
tone === 'rose'
? 'bg-rose-50 border-rose-100 text-rose-700'
: tone === 'sky'
? 'bg-sky-50 border-sky-100 text-sky-700'
: tone === 'amber'
? 'bg-amber-50 border-amber-100 text-amber-700'
: 'bg-emerald-50 border-emerald-100 text-emerald-700';
const primary = [...hints].sort((a, b) => a.stage - b.stage)[0]!;
const allText = hints.map((h) => h.text).join(' · ');
return (
<div
className={cn(
'mt-2 flex items-center gap-1.5 px-2.5 py-1.5 rounded border text-[11.5px] leading-tight',
cls,
)}
title={hints.length > 1 ? `全部建议:${allText}` : undefined}
>
<span className="font-semibold flex-none">建议下一步</span>
<span className="text-slate-400 flex-none">·</span>
<span className="min-w-0 truncate">{primary.text}</span>
</div>
);
}
......@@ -3,7 +3,10 @@
import { type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { AIStamp, Chip, MD, tone } from './shared';
import { ChainTimeline } from './chain-viz';
import { ChainDetailView } from './chain-viz';
import { EmrSoapView } from './emr-soap-view';
import { FactsTimeline } from './facts-timeline';
import { cleanPersonaValue } from './persona-display';
import type { Chain, PersonaFeature, PlanReason } from './mock-data';
import type { AdaptedFact } from './adapt-data';
......@@ -59,42 +62,18 @@ export function Drawer({
if (kind === 'chain-detail') {
title = '治疗链全景';
subtitle = `${chains.length} 条治疗链 · 含本次召回目标 + 历史信任锚`;
body = (
<div className="space-y-4">
<ChainTimeline chains={chains} />
<div className="pt-3 border-t border-slate-200">
<div className="flex items-center justify-between mb-2">
<h4 className="text-[12.5px] font-semibold text-slate-900">AI 跨链叙述</h4>
<div className="flex items-center gap-2">
<RegenSummaryBtn streaming={!!summaryStreaming} onClick={onRegenerateSummary} />
<AIStamp
relative={
summaryOverride?.treatmentChain ? '刚刚' : fmtRel(summaries.treatment_chain.generatedAt)
}
/>
</div>
</div>
{summaryStreaming && !chainText && <StreamingHint />}
<MD text={chainText} />
</div>
</div>
);
// AI 跨链叙述移除 — 5 阶段 timeline 已表达全部事实(具体动作 / 关键事实 / 医生 / 时间 / 建议下一步),
// AI 文本再叠加是冗余;后续若客服真有"自然语言摘要"需求,可在病历快读里加而非这里。
body = <ChainDetailView chains={chains} />;
width = 'w-[760px]';
} else if (kind === 'medical') {
// 病历快读改为 PAC 事实驱动 + SOAP 结构化展示(W3 末从 AI 文本改造)
// 数据源:emr_record + 同 source_encounter 的 diagnosis_record / treatment_record / image_record
const emrCount = facts.filter((f) => f.type === 'emr_record').length;
title = '病历快读';
subtitle = 'AI 整理的患者病历摘要';
body = (
<>
<div className="mb-3 flex items-center justify-between">
<AIStamp
relative={summaryOverride?.medicalRecord ? '刚刚' : fmtRel(summaries.medical_record.generatedAt)}
/>
<RegenSummaryBtn streaming={!!summaryStreaming} onClick={onRegenerateSummary} />
</div>
{summaryStreaming && !medicalText && <StreamingHint />}
<MD text={medicalText} />
</>
);
subtitle = `${emrCount} 次接诊病历`;
body = <EmrSoapView facts={facts} />;
width = 'w-[700px]';
} else if (kind === 'image') {
const imageFacts = facts.filter((f) => f.type === 'image_record');
title = `影像资料(${imageFacts.length})`;
......@@ -105,7 +84,7 @@ export function Drawer({
width = 'w-[640px]';
} else if (kind === 'facts') {
title = `患者事实时间轴(${facts.length})`;
subtitle = '倒序展示 · 真实 fact(诊断 / 治疗 / 病历 / 收款 / 接诊)';
subtitle = '按时间倒序';
body = <FactsTimeline facts={facts} />;
width = 'w-[640px]';
} else if (kind === 'persona') {
......@@ -116,13 +95,19 @@ export function Drawer({
<AIStamp relative={fmtRel(persona.computedAt)} label="画像重算" />
{persona.features.map((f) => {
const T = tone(f.tone);
const { tag, text } = cleanPersonaValue(f.value);
return (
<div key={f.key} className="rounded-md border border-slate-200 p-3">
<div className={cn('inline-flex items-center gap-1.5 text-[11px] font-semibold', T.text)}>
<span className={cn('w-1.5 h-1.5 rounded-full', T.dot)} />
{f.label}
{tag && (
<span className="ml-1 px-1 py-0 rounded bg-slate-100 text-slate-600 text-[10.5px] font-normal">
{tag}
</span>
)}
</div>
<div className="text-[13px] font-medium text-slate-900 mt-1">{f.value}</div>
{text && <div className="text-[13px] font-medium text-slate-900 mt-1">{text}</div>}
</div>
);
})}
......@@ -319,148 +304,3 @@ function CBCTImageView() {
);
}
// ──────────────────────────────────────────
// FactsTimeline
// ──────────────────────────────────────────
/**
* v2.1 真实 fact 时间轴
* - 按 occurredAt 倒序展示
* - 按 fact.type 上色 + 中文 label
* - title / summary 走 fact 字段;无则从 content 关键字段降级展示
*/
function FactsTimeline({ facts }: { facts: AdaptedFact[] }) {
if (facts.length === 0) {
return (
<div className="text-center py-12 text-sm text-slate-400">无 fact</div>
);
}
// 严格全局倒序:占位 -Infinity 让 null occurredAt 沉底,不会跟有效时间混
const sorted = [...facts].sort((a, b) => {
const at = a.occurredAt ? new Date(a.occurredAt).getTime() : -Infinity;
const bt = b.occurredAt ? new Date(b.occurredAt).getTime() : -Infinity;
return bt - at;
});
// fact.type → 颜色 + 中文标签 + 图标(SVG path d)
const CAT: Record<string, { tone: string; label: string; d: string }> = {
diagnosis_record: { tone: 'rose', label: '诊断', d: 'M12 9v4M12 17h.01M10.3 3.86 1.82 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.86a2 2 0 0 0-3.4 0z' },
treatment_record: { tone: 'teal', label: '治疗', d: 'M9 12l2 2 4-4M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z' },
recommendation_record: { tone: 'indigo', label: '医嘱', d: 'M14 2H6a2 2 0 0 0-2 2v16c0 1.1.9 2 2 2h12a2 2 0 0 0 2-2V8l-6-6zM14 2v6h6M9 13h6M9 17h6' },
encounter_record: { tone: 'slate', label: '接诊', d: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM22 11h-6M19 8v6' },
emr_record: { tone: 'sky', label: '病历', d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z M14 2v6h6 M9 13h6 M9 17h6' },
image_record: { tone: 'violet', label: '影像', d: 'M21 19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2zM8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 21' },
appointment_record: { tone: 'amber', label: '预约', d: 'M3 4h18v18H3zM16 2v4M8 2v4M3 10h18' },
payment_record: { tone: 'emerald', label: '收款', d: 'M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7H14a3.5 3.5 0 0 1 0 7H6' },
refund_record: { tone: 'rose', label: '退费', d: 'M3 12h18M9 6l-6 6 6 6' },
consultation_record: { tone: 'sky', label: '咨询', d: 'M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z' },
visit_registration_record: { tone: 'slate', label: '挂号', d: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z' },
order_record: { tone: 'emerald', label: '医嘱单', d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
recharge_record: { tone: 'emerald', label: '充值', d: 'M12 5v14m-7-7h14' },
complaint_record: { tone: 'rose', label: '投诉', d: 'M10.3 3.86 1.82 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.86a2 2 0 0 0-3.4 0z' },
referral_record: { tone: 'indigo', label: '转介', d: 'M17 1l4 4-4 4M3 11V9a4 4 0 0 1 4-4h14M7 23l-4-4 4-4M21 13v2a4 4 0 0 1-4 4H3' },
};
// v2.1 真实 fact 摘要文本生成(按 fact.type 拼)
const factSummary = (f: AdaptedFact): { title: string; note: string } => {
const c = (f.content ?? {}) as Record<string, unknown>;
switch (f.type) {
case 'diagnosis_record': {
const code = (c.code as string) ?? '?';
const name = (c.name_zh as string) ?? (c.name as string) ?? '';
const tooth = (c.tooth_position as string) ?? '';
return {
title: `诊断 ${code}${name ? ` · ${name}` : ''}`,
note: tooth ? `牙位 ${tooth}` : '',
};
}
case 'treatment_record': {
const cat = (c.category as string) ?? '';
const sub = (c.subtype as string) ?? '';
const tooth = (c.tooth_position as string) ?? '';
const status = (c.status as string) ?? '';
return {
title: `治疗 ${cat}${sub ? ` · ${sub}` : ''}`,
note: [tooth && `牙位 ${tooth}`, status && `状态 ${status}`].filter(Boolean).join(' · '),
};
}
case 'recommendation_record': {
const code = (c.code as string) ?? '?';
const tooth = (c.tooth_position as string) ?? '';
const conf = (c.confidence as number) ?? null;
return {
title: `建议 ${code}`,
note: [tooth && `牙位 ${tooth}`, conf != null && `置信 ${conf}`].filter(Boolean).join(' · '),
};
}
case 'encounter_record': {
const doc = (c.doctor_name as string) ?? (c.doctor_id as string) ?? '';
const notes = (c.notes as string) ?? '';
return { title: f.title ?? '接诊', note: [doc && `医生 ${doc}`, notes].filter(Boolean).join(' · ') };
}
case 'emr_record': {
const ill = (c.illness_desc as string) ?? '';
return { title: f.title ?? '病历', note: ill.slice(0, 60) };
}
case 'appointment_record': {
const at = (c.scheduled_at as string) ?? '';
const type = (c.appointment_type as string) ?? '';
const st = (c.status as string) ?? '';
return { title: `预约${type ? ` ${type}` : ''}`, note: [at, st].filter(Boolean).join(' · ') };
}
case 'payment_record': {
const cents = Number(c.amount_cents ?? 0);
const ch = (c.channel as string) ?? '';
return { title: `收款 ¥${(cents / 100).toFixed(2)}`, note: ch };
}
default: {
return { title: f.title ?? f.type, note: f.summary ?? '' };
}
}
};
return (
<div className="space-y-3">
<div className="text-[11px] text-slate-500 flex items-center gap-2">
<Chip tone="slate" size="xs">
{sorted.length} 条事实
</Chip>
<span>真实 fact · 按发生时间倒序</span>
</div>
<div className="relative pl-6">
<div className="absolute left-[10px] top-1 bottom-1 w-px bg-slate-200" />
{sorted.map((f) => {
const c = CAT[f.type] ?? { tone: 'slate', label: f.type, d: 'M12 2L2 7l10 5 10-5-10-5z' };
const T = tone(c.tone);
const { title, note } = factSummary(f);
const dateStr = f.occurredAt
? new Date(f.occurredAt).toLocaleDateString('zh-CN').replace(/\//g, '.')
: '—';
return (
<div key={f.id} className="relative pb-3">
<span
className={cn(
'absolute -left-[22px] top-0.5 w-5 h-5 rounded-full flex items-center justify-center ring-2 ring-white',
T.bg,
T.text,
)}
>
<svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d={c.d} />
</svg>
</span>
<div className="flex items-center gap-2">
<span className="text-[11px] text-slate-500 tabular-nums font-mono">{dateStr}</span>
<Chip tone={c.tone} size="xs">{c.label}</Chip>
{f.status !== 'active' && (
<Chip tone="slate" size="xs">{f.status}</Chip>
)}
</div>
<div className="mt-0.5 text-[12.5px] font-medium text-slate-900">{title}</div>
{note && <div className="text-[11px] text-slate-500 mt-0.5">{note}</div>}
</div>
);
})}
</div>
<div className="text-[10.5px] text-slate-400 pt-2 border-t border-slate-100">
真实 patient_facts(active 版本)· 详细 content 可在患者档案 fact 下钻查看
</div>
</div>
);
}
'use client';
import { useState, useRef, useEffect } from 'react';
import { diagnosisCodeNameZh, treatmentCategoryNameZh } from '@pac/types';
import { cn, formatToothPosition } from '@/lib/utils';
import type { AdaptedFact } from './adapt-data';
/**
* EmrSoapView — 病历快读(SOAP 结构化 + 多 EMR carousel 翻页)
*
* 数据源:**纯 PAC 事实层**(emr_record + 同 source_encounter 的 diagnosis_record /
* treatment_record / image_record),**不依赖 AI**。
*
* SOAP 分段:
* S Subjective(主观) — illness_desc + pre_illness + past_history + general_condition(自由文本)
* O Objective(客观) — exam_findings JSON array(按牙位)+ 关联影像 metadata
* A Assessment(诊断) — 同 source_encounter 的 diagnosis_record 列表(K 码 + 牙位 + 名)
* P Plan(计划) — disposal + doctor_advice + 同 source_encounter 的 treatment_record(planned)
*
* 多 EMR carousel(轻量自实现,不引 embla):
* - 左右箭头切换 + 圆点 indicator + N/M 进度
* - 键盘 ← → 切换
* - 当前卡淡入展示;默认最新一次接诊(idx=0)
*/
export function EmrSoapView({ facts }: { facts: AdaptedFact[] }) {
const emrs = facts
.filter((f) => f.type === 'emr_record')
.sort((a, b) => (b.occurredAt ?? '').localeCompare(a.occurredAt ?? ''));
const [idx, setIdx] = useState(0);
const total = emrs.length;
const containerRef = useRef<HTMLDivElement>(null);
// 键盘 ← → 切换(当前 drawer 内焦点时生效)
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (total <= 1) return;
if (e.key === 'ArrowLeft') setIdx((i) => (i - 1 + total) % total);
else if (e.key === 'ArrowRight') setIdx((i) => (i + 1) % total);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [total]);
if (total === 0) {
return <div className="py-8 text-center text-[12px] text-slate-400">无病历 fact</div>;
}
const current = emrs[idx]!;
const prev = () => setIdx((i) => (i - 1 + total) % total);
const next = () => setIdx((i) => (i + 1) % total);
return (
<div ref={containerRef} className="space-y-3">
{/* 顶部 carousel 导航条 — 圆点 + N/M + 箭头 */}
{total > 1 && (
<div className="flex items-center justify-between gap-3 px-1">
<button
onClick={prev}
aria-label="上一次接诊"
className="inline-flex items-center justify-center w-7 h-7 rounded-md border border-slate-200 bg-white hover:bg-slate-50 hover:border-slate-300 text-slate-600 transition-colors"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-3.5 h-3.5">
<path d="M15 18l-6-6 6-6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{/* 圆点 indicator */}
<div className="flex items-center gap-1.5 flex-1 justify-center">
{emrs.map((emr, i) => (
<button
key={emr.id}
onClick={() => setIdx(i)}
aria-label={`第 ${i + 1} 次接诊`}
className={cn(
'h-1.5 rounded-full transition-all',
i === idx ? 'w-6 bg-indigo-500' : 'w-1.5 bg-slate-300 hover:bg-slate-400',
)}
/>
))}
</div>
<div className="flex items-center gap-2 flex-none">
<span className="text-[11px] text-slate-500 tabular-nums">
<span className="font-semibold text-slate-800">{total - idx}</span> / {total}
</span>
<button
onClick={next}
aria-label="下一次接诊"
className="inline-flex items-center justify-center w-7 h-7 rounded-md border border-slate-200 bg-white hover:bg-slate-50 hover:border-slate-300 text-slate-600 transition-colors"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-3.5 h-3.5">
<path d="M9 18l6-6-6-6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</div>
)}
{/* 当前卡 — key 用 emr.id 保证切换时 React 重建,触发淡入动画
visitIdx = 真实就诊顺序(最早 = 第 1 次,最近 = 第 N 次)。emrs 已按时间倒序,所以是 total - idx。 */}
<div key={current.id} className="animate-[fadeIn_0.18s_ease-out]">
<EmrSection
emr={current}
facts={facts}
isLatest={idx === 0}
totalCount={total}
visitIdx={total - idx}
/>
</div>
</div>
);
}
function EmrSection({
emr,
facts,
isLatest,
totalCount,
visitIdx,
}: {
emr: AdaptedFact;
facts: AdaptedFact[];
isLatest: boolean;
totalCount: number;
/// 真实就诊顺序号(1 = 最早就诊,N = 最近一次)
visitIdx: number;
}) {
const c = (emr.content as Record<string, unknown>) ?? {};
// ⚠️ source_encounter_external_id 实际是病历 id(emr_external_id),不是 registration_id
// parser 里 diagnosis/treatment_planned/image 的 sourceEncounterExternalId = emr.id(ad575...)
// 所以匹配同次接诊用 emr_external_id,不能用 encounter_external_id(那个是挂号号)
const emrId = String(c.emr_external_id ?? '');
const doctor = (c.doctor_name as string | undefined) ?? '—';
const date = emr.occurredAt?.slice(0, 10) ?? '—';
// 关联同 encounter 的 diagnosis / treatment(planned) / image
const sameEncounter = (f: AdaptedFact) =>
String((f.content as Record<string, unknown> | null)?.source_encounter_external_id ?? '') === emrId;
const diagnoses = facts.filter((f) => f.type === 'diagnosis_record' && sameEncounter(f));
const plannedTx = facts.filter((f) => f.type === 'treatment_record' && f.kind === 'planned' && sameEncounter(f));
const images = facts.filter((f) => f.type === 'image_record' && sameEncounter(f));
// S 主观 — 自由文本
const sFields = [
{ k: '主诉', v: c.illness_desc as string },
{ k: '现病史', v: c.pre_illness as string },
{ k: '既往史', v: c.past_history as string },
{ k: '一般情况', v: c.general_condition as string },
].filter((x) => !!x.v);
// O 客观 — examine 按牙位拆
const examFindings = parseJsonArray(c.exam_findings as string);
// P 计划 — disposal(按牙位)+ doctor_advice
const disposals = parseJsonArray(c.disposal as string);
const doctorAdvice = (c.doctor_advice as string | undefined) ?? '';
return (
<section className="rounded-lg border border-slate-200 bg-white overflow-hidden">
{/* 头:日期 + 医生 + 第几次 */}
<header className="px-3 py-2 bg-slate-50 border-b border-slate-200 flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-[12.5px] font-semibold text-slate-900 tabular-nums">{date}</span>
<span className="text-[11px] text-slate-500">·</span>
<span className="text-[11.5px] text-slate-700">{doctor} 医生</span>
</div>
<span className="text-[10.5px] text-slate-400 nums">
{isLatest && totalCount > 1 && <span className="mr-1 text-emerald-600">最近</span>}
{visitIdx} 次接诊
</span>
</header>
<div className="p-3 space-y-3">
{/* S */}
{sFields.length > 0 && (
<SoapBlock letter="S" tone="sky" title="主观(Subjective)">
<div className="space-y-1.5">
{sFields.map((x) => (
<div key={x.k} className="text-[12px] text-slate-700 leading-relaxed">
<span className="text-[11px] text-slate-500 mr-1">{x.k}:</span>
{x.v}
</div>
))}
</div>
</SoapBlock>
)}
{/* O */}
{(examFindings.length > 0 || images.length > 0) && (
<SoapBlock letter="O" tone="indigo" title="客观(Objective)">
{examFindings.length > 0 && (
<ul className="space-y-1 mb-2">
{examFindings.map((x, i) => (
<li key={i} className="text-[12px] text-slate-700 leading-relaxed">
{x.toothPosition && (
<span className="inline-block px-1.5 py-0 mr-1.5 bg-slate-100 text-slate-700 rounded text-[10.5px] tabular-nums align-middle">
{formatToothPosition(x.toothPosition)}
</span>
)}
{x.message}
</li>
))}
</ul>
)}
{images.length > 0 && (
<div className="flex items-center gap-1.5 text-[11px] text-slate-500">
<svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="m21 15-5-5L5 21" />
</svg>
影像 {images.length} 张:
{images.map((img, i) => {
const modality = (img.content as Record<string, unknown> | null)?.modality as string | undefined;
return (
<span key={i} className="px-1.5 py-px bg-slate-100 rounded text-[10px] text-slate-700">
{modality || '影像'}
</span>
);
})}
</div>
)}
</SoapBlock>
)}
{/* A */}
{diagnoses.length > 0 && (
<SoapBlock letter="A" tone="rose" title="评估(Assessment)">
<ul className="space-y-1">
{diagnoses.map((dx) => {
const dc = dx.content as Record<string, unknown>;
const code = String(dc.code ?? '');
// badge 直接显示字典翻译后的诊断名(K05 → 慢性牙周炎),沿用 key 样式(rose)
// host 原始 name_zh / name 在 host 端可能跟字典名略不同,但客服关心标准名 — 走字典
const badgeText = code ? diagnosisCodeNameZh(code) : String(dc.name_zh ?? dc.name ?? '—');
const tooth = String(dc.tooth_position ?? '');
return (
<li key={dx.id} className="text-[12px] text-slate-700 leading-relaxed">
<span className="px-1.5 py-px mr-1.5 bg-rose-50 text-rose-700 rounded text-[10.5px] font-medium">
{badgeText}
</span>
{tooth && <span className="text-[11px] text-slate-500">牙位 {formatToothPosition(tooth)}</span>}
</li>
);
})}
</ul>
</SoapBlock>
)}
{/* P */}
{(disposals.length > 0 || plannedTx.length > 0 || doctorAdvice) && (
<SoapBlock letter="P" tone="emerald" title="计划(Plan)">
{disposals.length > 0 && (
<div className="mb-2">
<div className="text-[11px] text-slate-500 mb-1">处置:</div>
<ul className="space-y-1">
{disposals.map((x, i) => (
<li key={i} className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-line">
{x.toothPosition && (
<span className="inline-block px-1.5 py-0 mr-1.5 bg-slate-100 text-slate-700 rounded text-[10.5px] tabular-nums align-middle">
{formatToothPosition(x.toothPosition)}
</span>
)}
{x.message}
</li>
))}
</ul>
</div>
)}
{plannedTx.length > 0 && (
<div className="mb-2">
<div className="text-[11px] text-slate-500 mb-1">治疗计划:</div>
<ul className="space-y-1">
{plannedTx.map((tx) => {
const tc = tx.content as Record<string, unknown>;
const subtype = String(tc.subtype ?? '');
const cat = String(tc.category ?? '');
// badge 显示字典翻译(periodontic → 牙周),沿用 key 样式(emerald)
const badgeText = cat ? treatmentCategoryNameZh(cat) : '治疗';
const tooth = String(tc.tooth_position ?? '');
return (
<li key={tx.id} className="text-[12px] text-slate-700 leading-relaxed">
<span className="px-1.5 py-px mr-1.5 bg-emerald-50 text-emerald-700 rounded text-[10.5px] font-medium">
{badgeText}
</span>
<span className="font-medium">{subtype}</span>
{tooth && <span className="ml-1.5 text-[11px] text-slate-500">· 牙位 {formatToothPosition(tooth)}</span>}
</li>
);
})}
</ul>
</div>
)}
{doctorAdvice && (
<div>
<div className="text-[11px] text-slate-500 mb-1">医嘱:</div>
<div className="text-[12px] text-slate-700 leading-relaxed">{doctorAdvice}</div>
</div>
)}
</SoapBlock>
)}
</div>
</section>
);
}
function SoapBlock({
letter,
tone,
title,
children,
}: {
letter: string;
tone: 'sky' | 'indigo' | 'rose' | 'emerald';
title: string;
children: React.ReactNode;
}) {
const toneCls = {
sky: 'bg-sky-100 text-sky-700',
indigo: 'bg-indigo-100 text-indigo-700',
rose: 'bg-rose-100 text-rose-700',
emerald: 'bg-emerald-100 text-emerald-700',
}[tone];
return (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<span className={`inline-flex items-center justify-center w-4 h-4 rounded text-[10.5px] font-bold ${toneCls}`}>
{letter}
</span>
<span className="text-[11.5px] font-semibold text-slate-800">{title}</span>
</div>
<div className="pl-5">{children}</div>
</div>
);
}
/// 解析 host EMR 的 JSON 数组字段(exam_findings / disposal):字符串或 array 都接受
function parseJsonArray(raw: unknown): Array<{ toothPosition?: string; message?: string }> {
if (Array.isArray(raw)) return raw as Array<{ toothPosition?: string; message?: string }>;
if (typeof raw !== 'string' || !raw.trim() || raw === 'null') return [];
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed;
return [];
} catch {
return [];
}
}
/// 牙位紧凑显示(全口压缩)— UI 已有 formatToothPosition 但本组件场景更紧凑
'use client';
import { useState, useMemo } from 'react';
import {
Stethoscope,
Pill,
ClipboardList,
UserRound,
FileText,
Image as ImageIcon,
Calendar,
CircleDollarSign,
Undo2,
MessageCircle,
Receipt,
Wallet,
AlertTriangle,
Users,
AlertOctagon,
} from 'lucide-react';
import { diagnosisCodeNameZh, treatmentCategoryNameZh } from '@pac/types';
import { cn, formatToothPosition } from '@/lib/utils';
import { Chip } from './shared';
import type { AdaptedFact } from './adapt-data';
/**
* FactsTimeline — 患者事实时间轴(drawer 用)
*
* 新版改进(W3 末):
* ① 顶部加 fact_type 多选筛选(chip 切换,显示该 type 计数)
* ② EMR / 影像节点 value 重写(原"病历 ad575..."无意义,改"主诉" / "modality + 牙位")
* ③ 图标走 lucide-react(替代 inline SVG path,更易维护)
* ④ 按 occurredAt ?? plannedFor 倒序;planned 加"约"前缀
*/
export function FactsTimeline({ facts }: { facts: AdaptedFact[] }) {
if (facts.length === 0) {
return <div className="text-center py-12 text-sm text-slate-400">无 fact</div>;
}
// ─ 按 type 统计 + 默认全选 ─
const typeCounts = useMemo(() => {
const c = new Map<string, number>();
for (const f of facts) c.set(f.type, (c.get(f.type) ?? 0) + 1);
return c;
}, [facts]);
const allTypes = useMemo(() => [...typeCounts.keys()].sort(), [typeCounts]);
const [selected, setSelected] = useState<Set<string>>(() => new Set(allTypes));
const allOn = selected.size === allTypes.length;
const toggleAll = () => setSelected(allOn ? new Set() : new Set(allTypes));
const toggleType = (t: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(t)) next.delete(t);
else next.add(t);
return next;
});
// 时间轴排序键:actual 用 occurredAt;planned 用 plannedFor 兜底
const tkey = (f: AdaptedFact) => {
const t = f.occurredAt ?? f.plannedFor;
return t ? new Date(t).getTime() : -Infinity;
};
const sorted = useMemo(
() => [...facts].filter((f) => selected.has(f.type)).sort((a, b) => tkey(b) - tkey(a)),
[facts, selected],
);
return (
<div className="space-y-3">
{/* 结构化统计摘要 — 资金 / 临床 / 行为 / 时间窗 四块,一眼看清患者画像 */}
<StatsSummary facts={facts} />
{/* 类型多选筛选 chip 行 */}
<div className="flex flex-wrap items-center gap-1.5 pb-2 border-b border-slate-100">
<FilterChip label="全部" count={facts.length} active={allOn} onClick={toggleAll} />
<span className="text-slate-200 mx-0.5">|</span>
{allTypes.map((t) => {
const meta = FACT_META[t] ?? FACT_META_FALLBACK;
return (
<FilterChip
key={t}
label={meta.label}
count={typeCounts.get(t) ?? 0}
active={selected.has(t)}
tone={meta.tone}
onClick={() => toggleType(t)}
/>
);
})}
</div>
{/* 选中类型为空的提示 */}
{sorted.length === 0 && (
<div className="text-center py-12 text-sm text-slate-400">已勾选类型无事实</div>
)}
{/* 时间轴 */}
{sorted.length > 0 && (
<div className="relative pl-6">
<div className="absolute left-[10px] top-1 bottom-1 w-px bg-slate-200" />
{sorted.map((f) => (
<TimelineRow key={f.id} fact={f} />
))}
</div>
)}
</div>
);
}
// ─────────────────────────────────────────────
// StatsSummary — 4 块结构化聚合(资金 / 临床 / 行为 / 时间窗)
// ─────────────────────────────────────────────
function StatsSummary({ facts }: { facts: AdaptedFact[] }) {
// ─ 资金 ─
let paid = 0;
let refunded = 0;
for (const f of facts) {
const c = (f.content ?? {}) as Record<string, unknown>;
const cents = Number(c.amount_cents ?? 0);
if (!Number.isFinite(cents)) continue;
if (f.type === 'payment_record') paid += cents;
else if (f.type === 'refund_record') refunded += Math.abs(cents);
}
const netCents = paid - refunded;
// ─ 临床 ─ 诊断 top codes / 治疗 top categories
const dxCodes = new Map<string, number>();
const txCats = new Map<string, number>();
for (const f of facts) {
const c = (f.content ?? {}) as Record<string, unknown>;
if (f.type === 'diagnosis_record') {
const code = String(c.code ?? '');
if (code) dxCodes.set(code, (dxCodes.get(code) ?? 0) + 1);
} else if (f.type === 'treatment_record') {
const cat = String(c.category ?? '');
if (cat && cat !== 'review') txCats.set(cat, (txCats.get(cat) ?? 0) + 1);
}
}
const dxTop = [...dxCodes.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3);
const txTop = [...txCats.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3);
// ─ 行为 ─ 接诊 / 预约履约率
const encounterCount = facts.filter((f) => f.type === 'encounter_record').length;
const apptAll = facts.filter((f) => f.type === 'appointment_record');
const apptArrived = apptAll.filter((f) => {
const c = (f.content ?? {}) as Record<string, unknown>;
const st = String(c.status ?? '');
return st === 'arrived' || st === 'completed' || st === 'in_treatment';
}).length;
const apptShowRate = apptAll.length > 0 ? Math.round((apptArrived / apptAll.length) * 100) : null;
// ─ 时间窗 ─ 首次 / 最近 / 跨度天数
const allTimes = facts
.map((f) => f.occurredAt ?? f.plannedFor)
.filter((t): t is string => !!t)
.map((t) => new Date(t).getTime())
.filter((t) => !Number.isNaN(t));
const firstT = allTimes.length ? Math.min(...allTimes) : null;
const lastT = allTimes.length ? Math.max(...allTimes) : null;
const spanDays = firstT && lastT ? Math.round((lastT - firstT) / 86400_000) : null;
const fmtDate = (t: number | null) =>
t ? new Date(t).toLocaleDateString('zh-CN').replace(/\//g, '.') : '—';
return (
<div className="rounded-lg border border-slate-200 bg-slate-50/60 p-2.5">
<div className="grid grid-cols-4 gap-3">
<StatBlock label="累计净消费">
<div className="text-[13px] font-semibold text-slate-900 tabular-nums">
¥{(netCents / 100).toLocaleString()}
</div>
<div className="text-[10px] text-slate-500 tabular-nums">
收款 ¥{(paid / 100).toLocaleString()}
{refunded > 0 && (
<span className="text-rose-600 ml-1">- ¥{(refunded / 100).toLocaleString()}</span>
)}
</div>
</StatBlock>
<StatBlock label={`诊断 ${dxCodes.size} 种`}>
<div className="text-[11px] text-slate-700 leading-tight">
{dxTop.length > 0 ? (
dxTop.map(([code, n]) => (
<span key={code} className="inline-block mr-1.5" title={code}>
<span className="font-medium">{diagnosisCodeNameZh(code)}</span>
<span className="text-slate-400 ml-0.5">×{n}</span>
</span>
))
) : (
<span className="text-slate-400"></span>
)}
</div>
</StatBlock>
<StatBlock label={`治疗 ${txCats.size} 类`}>
<div className="text-[11px] text-slate-700 leading-tight">
{txTop.length > 0 ? (
txTop.map(([cat, n]) => (
<span key={cat} className="inline-block mr-1.5" title={cat}>
<span className="font-medium">{treatmentCategoryNameZh(cat)}</span>
<span className="text-slate-400 ml-0.5">×{n}</span>
</span>
))
) : (
<span className="text-slate-400"></span>
)}
</div>
</StatBlock>
<StatBlock label={`接诊 ${encounterCount}`}>
<div className="text-[11px] text-slate-700 leading-tight">
预约 {apptAll.length}{' '}
{apptShowRate != null && (
<span className={cn('tabular-nums', apptShowRate >= 80 ? 'text-emerald-700' : 'text-amber-700')}>
履约 {apptShowRate}%
</span>
)}
</div>
<div className="text-[10px] text-slate-500 tabular-nums">
{fmtDate(firstT)}{fmtDate(lastT)}
{spanDays != null && spanDays > 0 && <span className="ml-1">· 跨 {spanDays}</span>}
</div>
</StatBlock>
</div>
</div>
);
}
function StatBlock({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5 min-w-0">
<div className="text-[10px] text-slate-500 truncate">{label}</div>
{children}
</div>
);
}
// ─────────────────────────────────────────────
// 单个 fact 时间轴行
// ─────────────────────────────────────────────
function TimelineRow({ fact }: { fact: AdaptedFact }) {
const meta = FACT_META[fact.type] ?? FACT_META_FALLBACK;
const Icon = meta.Icon;
const T = toneOf(meta.tone);
const { title, note } = factSummary(fact);
// planned 事实(预约/计划治疗)显示 plannedFor;加"约"前缀以区分已发生
const tIso = fact.occurredAt ?? fact.plannedFor;
const dateStr = tIso
? (fact.occurredAt ? '' : '约 ') + new Date(tIso).toLocaleDateString('zh-CN').replace(/\//g, '.')
: '—';
return (
<div className="relative pb-3">
<span
className={cn(
'absolute -left-[22px] top-0.5 w-5 h-5 rounded-full flex items-center justify-center ring-2 ring-white',
T.bg,
T.text,
)}
>
<Icon className="w-3 h-3" />
</span>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] text-slate-500 tabular-nums font-mono">{dateStr}</span>
<Chip tone={meta.tone} size="xs">
{meta.label}
</Chip>
{fact.status !== 'active' && (
<Chip tone="slate" size="xs">
{FACT_STATUS_ZH[fact.status] ?? fact.status}
</Chip>
)}
</div>
<div className="mt-0.5 text-[12.5px] font-medium text-slate-900">{title}</div>
{note && <div className="text-[11px] text-slate-500 mt-0.5">{note}</div>}
</div>
);
}
// ─────────────────────────────────────────────
// 顶部筛选 chip
// ─────────────────────────────────────────────
function FilterChip({
label,
count,
active,
tone = 'slate',
onClick,
}: {
label: string;
count: number;
active: boolean;
tone?: string;
onClick: () => void;
}) {
const T = toneOf(tone);
return (
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] border transition-colors',
active
? cn(T.bg, T.text, T.border, 'font-medium')
: 'bg-slate-50 text-slate-500 border-slate-200 hover:bg-slate-100',
)}
>
<span>{label}</span>
<span
className={cn(
'tabular-nums text-[10px] px-1 rounded',
active ? 'bg-white/60' : 'bg-slate-200/60 text-slate-600',
)}
>
{count}
</span>
</button>
);
}
// ─────────────────────────────────────────────
// fact.type → 元信息(图标 + 中文 + 色)
// ─────────────────────────────────────────────
interface FactMeta {
label: string;
tone: string;
Icon: React.ComponentType<{ className?: string }>;
}
const FACT_META: Record<string, FactMeta> = {
diagnosis_record: { label: '诊断', tone: 'rose', Icon: AlertTriangle },
treatment_record: { label: '治疗', tone: 'teal', Icon: Stethoscope },
recommendation_record: { label: '医嘱', tone: 'indigo', Icon: Pill },
encounter_record: { label: '接诊', tone: 'slate', Icon: UserRound },
emr_record: { label: '病历', tone: 'sky', Icon: FileText },
image_record: { label: '影像', tone: 'violet', Icon: ImageIcon },
appointment_record: { label: '预约', tone: 'amber', Icon: Calendar },
payment_record: { label: '收款', tone: 'emerald', Icon: CircleDollarSign },
refund_record: { label: '退费', tone: 'rose', Icon: Undo2 },
consultation_record: { label: '咨询', tone: 'sky', Icon: MessageCircle },
visit_registration_record: { label: '挂号', tone: 'slate', Icon: ClipboardList },
order_record: { label: '医嘱单', tone: 'emerald', Icon: Receipt },
recharge_record: { label: '充值', tone: 'emerald', Icon: Wallet },
complaint_record: { label: '投诉', tone: 'rose', Icon: AlertOctagon },
referral_record: { label: '转介', tone: 'indigo', Icon: Users },
};
const FACT_META_FALLBACK: FactMeta = { label: '事实', tone: 'slate', Icon: FileText };
interface ToneCls {
bg: string;
text: string;
border: string;
}
const TONE_FALLBACK: ToneCls = { bg: 'bg-slate-100', text: 'text-slate-700', border: 'border-slate-300' };
const TONE = {
rose: { bg: 'bg-rose-100', text: 'text-rose-700', border: 'border-rose-200' },
teal: { bg: 'bg-teal-100', text: 'text-teal-700', border: 'border-teal-200' },
indigo: { bg: 'bg-indigo-100', text: 'text-indigo-700', border: 'border-indigo-200' },
slate: TONE_FALLBACK,
sky: { bg: 'bg-sky-100', text: 'text-sky-700', border: 'border-sky-200' },
violet: { bg: 'bg-violet-100', text: 'text-violet-700', border: 'border-violet-200' },
amber: { bg: 'bg-amber-100', text: 'text-amber-800', border: 'border-amber-200' },
emerald: { bg: 'bg-emerald-100', text: 'text-emerald-700', border: 'border-emerald-200' },
} as const satisfies Record<string, ToneCls>;
const toneOf = (tone: string): ToneCls => (TONE as Record<string, ToneCls>)[tone] ?? TONE_FALLBACK;
const FACT_STATUS_ZH: Record<string, string> = {
active: '当前',
superseded: '已替代',
cancelled: '已取消',
fulfilled: '已完成',
expired: '已过期',
invalidated: '已失效',
};
const APPT_STATUS_ZH: Record<string, string> = {
scheduled: '待就诊',
rescheduled: '已改约',
cancelled: '已取消',
arrived: '已到诊',
in_treatment: '就诊中',
completed: '已完成',
no_show: '爽约',
walk_in: '现场加号',
};
const TX_STATUS_ZH: Record<string, string> = {
planned: '计划中',
in_progress: '进行中',
completed: '已完成',
cancelled: '已取消',
failed: '失败',
};
const MODALITY_ZH: Record<string, string> = {
pa: '根尖片',
bw: '咬翼片',
pano: '曲面断层',
cbct: 'CBCT',
intraoral_photo: '口内照片',
};
const CHANNEL_ZH: Record<string, string> = {
cash: '现金',
wechat: '微信',
alipay: '支付宝',
card: '银行卡',
pos: 'POS',
store: '储值',
insurance: '保险',
advance: '预付',
membership_card: '会员卡',
medical_insurance: '医保',
apple_pay: 'Apple Pay',
third_party: '第三方',
check: '支票',
debt: '挂账',
mini_program: '小程序',
other: '其他',
};
const APPT_TYPE_ZH: Record<string, string> = {
first_visit: '初诊',
follow_up: '复诊',
emergency: '急诊',
consultation: '咨询',
};
const ENCOUNTER_TYPE_ZH: Record<string, string> = {
first_visit: '初诊',
follow_up: '复诊',
emergency: '急诊',
};
function hhmm(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime())
? ''
: d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false });
}
function truncate(s: string, n: number): string {
if (!s) return '';
return s.length > n ? s.slice(0, n) + '…' : s;
}
// ─────────────────────────────────────────────
// fact 摘要文本(每个 type 一个 case)
// ─────────────────────────────────────────────
/**
* fact 摘要文本(每个 type 自定义有意义字段;严格去重)
*
* 去重纪律(W3 末调):
* - **title 不重复 type 词**(左侧已有 type chip "诊断"/"治疗"/"预约"/"接诊"…)
* → title 直接显示具体内容(如 "慢性牙周炎"/"全口洁治"/"初诊·张三主诉"),省"诊断 / 治疗" 前缀
* - **note 不重复 fact.status**(右侧已有 "已替代/已取消/已完成/已过期/已失效" status chip 兜底显示)
* → 治疗 status 不再显示;预约 status 仅在非默认态(no_show/cancelled 等)时显示
*
* 各 type 显示选型:
* diagnosis : 中文病名 / 牙位 + 医生
* treatment : subtype / 牙位 + quantity unit + 医生
* encounter : encounter_type (初/复诊) + chief_complaint + 医生
* emr : illness_desc 主诉 / 治疗计划 + 医生
* image : modality 中文 / 牙位 + finding + 医生
* appointment: type 翻译 + 时间 + 主诉(+ 非默认 status / cancellation_reason if any)
* payment : 金额 + channel 中文
* refund : 金额 + reason
*
* 移除:doctor_id / *_external_id / source_encounter_id / code_source 等 raw / 技术字段
*/
function factSummary(f: AdaptedFact): { title: string; note: string } {
const c = (f.content ?? {}) as Record<string, unknown>;
const doctor = (c.doctor_name as string | undefined) ?? '';
switch (f.type) {
case 'diagnosis_record': {
const code = (c.code as string) ?? '?';
const name = (c.name_zh as string) ?? (c.name as string) ?? '';
const tooth = (c.tooth_position as string) ?? '';
return {
title: name || code,
note: [tooth && `牙位 ${formatToothPosition(tooth, 4)}`, doctor && `${doctor}医生`].filter(Boolean).join(' · '),
};
}
case 'treatment_record': {
const cat = (c.category as string) ?? '';
const sub = (c.subtype as string) ?? '';
const tooth = (c.tooth_position as string) ?? '';
const qty = c.quantity != null ? Number(c.quantity) : null;
const unit = (c.unit_name as string) ?? '';
const qtyStr = qty != null && qty > 0 ? `${qty}${unit ? ' ' + unit : ''}` : '';
// status 不在 note 显示:fact.status chip 已表达"已完成/已取消"
return {
title: sub || treatmentCategoryNameZh(cat) || '治疗',
note: [qtyStr, tooth && `牙位 ${formatToothPosition(tooth, 4)}`, doctor && `${doctor}医生`].filter(Boolean).join(' · '),
};
}
case 'recommendation_record': {
const code = (c.code as string) ?? '?';
const tooth = (c.tooth_position as string) ?? '';
return {
title: `建议 ${code}`,
note: [tooth && `牙位 ${formatToothPosition(tooth, 4)}`, doctor && `${doctor}医生`].filter(Boolean).join(' · '),
};
}
case 'encounter_record': {
const etype = (c.encounter_type as string) ?? '';
const chief = (c.chief_complaint as string) ?? '';
const etypeZh = etype ? ENCOUNTER_TYPE_ZH[etype] ?? etype : '';
// title 优先用 chief_complaint 主诉(最有信息),fallback 用初/复诊;不写"接诊"(chip 重复)
const title = chief
? truncate(chief, 24)
: etypeZh
? etypeZh
: '到诊';
return {
title,
note: [etypeZh && chief && etypeZh, doctor && `${doctor}医生`].filter(Boolean).join(' · '),
};
}
case 'emr_record': {
// 主诉(L1)+ 治疗计划/处置(L2)+ 医生 — raw id / pre_illness(跟 illness_desc 重复)移除
// title 不再加"病历"前缀(chip 已表达)
const ill = (c.illness_desc as string) ?? '';
const plan = (c.treatment_plan as string) ?? '';
const disp = parseFirstMessage(c.disposal);
const planText = plan || disp;
return {
title: ill ? truncate(ill, 30) : '病历记录',
note: [planText && `计划:${truncate(planText, 30)}`, doctor && `${doctor}医生`].filter(Boolean).join(' · '),
};
}
case 'image_record': {
const mod = (c.modality as string) ?? '';
const finding = (c.finding as string) ?? '';
const tooths = (c.tooth_positions as string | string[] | null) ?? null;
const toothStr = Array.isArray(tooths) ? tooths.join(';') : tooths || '';
const modZh = MODALITY_ZH[mod] ?? mod ?? '影像';
return {
title: modZh,
note: [
toothStr && `牙位 ${formatToothPosition(toothStr, 4)}`,
finding && truncate(finding, 30),
doctor && `${doctor}医生`,
]
.filter(Boolean)
.join(' · '),
};
}
case 'appointment_record': {
const at = (c.scheduled_at as string) ?? '';
const type = (c.appointment_type as string) ?? '';
const st = (c.status as string) ?? '';
const cancel = (c.cancellation_reason as string) ?? '';
const complaint = (c.complaint_text as string) ?? (c.complaint_category as string) ?? '';
const time = at ? hhmm(at) : '';
const typeZh = type ? APPT_TYPE_ZH[type] ?? type : '';
// title 不加"预约"前缀(chip 已表达);用 type 翻译 + 时段
// status 仅在非默认态(no_show / cancelled / rescheduled / arrived)时显示,避免普通"scheduled" 噪音
const showStatus = st && st !== 'scheduled' && st !== 'completed';
return {
title: [typeZh, time].filter(Boolean).join(' · ') || '预约',
note: [
complaint && truncate(complaint, 20),
showStatus && (APPT_STATUS_ZH[st] ?? st),
cancel && `取消原因:${truncate(cancel, 16)}`,
]
.filter(Boolean)
.join(' · '),
};
}
case 'payment_record': {
// title 不加"收款"前缀(chip 已表达),直接金额;note 显示渠道翻译
const cents = Number(c.amount_cents ?? 0);
const ch = (c.channel as string) ?? '';
const chZh = CHANNEL_ZH[ch] ?? ch;
return { title: ${(cents / 100).toFixed(2)}`, note: chZh };
}
case 'refund_record': {
const cents = Number(c.amount_cents ?? 0);
const reason = (c.reason as string) ?? '';
return { title: ${(cents / 100).toFixed(2)}`, note: reason };
}
default: {
return { title: f.title ?? f.type, note: f.summary ?? '' };
}
}
}
/// 从 JSON 数组字段(EMR.disposal / treatment_plan 等)取第一条 message
function parseFirstMessage(raw: unknown): string {
if (typeof raw !== 'string' || !raw || raw === 'null') return '';
try {
const arr = JSON.parse(raw);
if (Array.isArray(arr) && arr[0] && typeof arr[0] === 'object') {
return String((arr[0] as Record<string, unknown>).message ?? '');
}
} catch {
/* swallow */
}
return '';
}
......@@ -48,7 +48,7 @@ export const mockPatient = {
profile: {
doNotContact: false,
deceased: false,
primaryContactType: '本人',
primaryContactType: '本人' as string | null,
ltv: 48200,
firstVisit: '2022-06-08',
lastVisit: fmtDate(daysAgo(38)),
......@@ -58,21 +58,41 @@ export const mockPatient = {
// ────────────────────────────────────────────────
// Treatment Chains(治疗链 5 阶段,锚定 pac-goal-alignment §0)
// ────────────────────────────────────────────────
/// 5 阶段节点结构(W3 末重塑,跟后端 chain-composer ChainNode 同源)
/// 每节点最多 4 信息位 + 视觉状态:
/// - title : 具体动作(如 慢性牙周炎 / *全口洁治 / 牙周刮治术)
/// - detail : 关键事实(如 全口 26 牙 / 1/3 步骤 / ¥58)
/// - doctor : 主治 / 计划 医生名
/// - at : 日期 YYYY.MM.DD
/// - hint : discovered/entered/未闭环 显示的"建议下一步" 友好提示
/// label/note 保留兼容(旧前端调用)— 优先用 title/detail/doctor/hint
export type ChainNode = {
stage: 1 | 2 | 3 | 4 | 5;
label: string;
title?: string;
detail?: string;
doctor?: string;
at: string;
note?: string;
hint?: string;
done?: boolean;
current?: boolean;
missing?: boolean;
/// 兼容旧字段(逐步淘汰)
label?: string;
note?: string;
};
/// 5 阶段链状态(W3 末从 3 态升级,跟后端 chain-composer 同源)
/// - discovered: 仅 S1 命中(诊断/推荐)— 召回算法关注的"潜在新链"
/// - entered: S2 命中(已挂号/预约/付款但未真正治疗)
/// - ongoing: S3 或 S4 命中(治疗中 / 复查中)
/// - closed: S5 全条件满足 — 真正闭环(lifelong_maintenance 类永不到此)
export type ChainStatus = 'discovered' | 'entered' | 'ongoing' | 'closed';
export type Chain = {
id: string;
name: string;
category: string;
status: 'closed' | 'ongoing' | 'uninitiated';
status: ChainStatus;
currentStage: 1 | 2 | 3 | 4 | 5;
value?: number;
estimatedValue?: number;
......@@ -80,6 +100,12 @@ export type Chain = {
target?: boolean;
diagnosedAt?: string;
gapDays?: number;
/// 生命周期提示("终身维护(永不闭环)" 等)— UI tooltip
lifecycleNoteZh?: string;
/// 桶牙位(cross-chain alternative-closed 判定用,UI 不直接展示)
toothPosition?: string;
/// "替代治疗已覆盖"原因 chain.name(K04 后续 K08+种植覆盖等)
alternativeClosedBy?: string;
nodes: ChainNode[];
};
......@@ -125,7 +151,7 @@ export const mockChains: Chain[] = [
id: 'chain_implant_NEW',
name: '种植修复 · 36 号牙',
category: 'implant',
status: 'uninitiated',
status: 'discovered',
currentStage: 1,
target: true,
estimatedValue: 22000,
......@@ -208,6 +234,16 @@ export const mockPersona = {
export type PlanReason = {
id: string;
scenario: string;
/// 子规则 key(missing_tooth / perio_no_srp / endo_no_rct …)— W3 末 plan_reasons 拆细后每行带
subKey?: string | null;
/// W3 末:结构化召回信号(DB 存 raw enum,前端字典翻译富文本)
signals?: {
subKey: string;
triggers: Array<{ type: string; code?: string | null }>;
toothPosition?: string | null;
daysSince: number;
expectedCategories: string[];
} | null;
scenarioLabel: string;
priorityScore: number;
lifecycle: 'one_shot' | 'recurring';
......@@ -371,7 +407,7 @@ export const mockScript = {
},
{
id: 'followup',
label: '切入种植话题',
label: '切入话题',
durationHint: '1–2 分钟',
markdown: `**目的**:把 5 个月前的诊断结论,自然回到"该启动了"。
......
/**
* persona-display — persona feature.description 展示辅助
*
* 后端 persona feature description 故意带 `[英文 enum]` 前缀(给 LLM prompt 当
* machine-readable hint,例 `[gap] 治疗链有 3 处缺口`),前端展示要中文化或分离成 chip。
*
* 用法:
* const { tag, text } = cleanPersonaValue(feature.value);
* // tag 渲染成小灰 chip(可空);text 主描述
*/
const PERSONA_STATUS_ZH: Record<string, string> = {
// treatment_chain_status
gap: '缺口',
ongoing: '在管',
closed: '闭环',
in_progress: '在管',
no_chain: '无链',
// recall_risk
high: '高',
medium: '中',
low: '低',
none: '无',
// do_not_contact_status
contactable: '可触达',
DNC: '拒不打扰',
// value
diamond: '钻卡',
gold: '金卡',
silver: '银卡',
normal: '普通',
new: '新客',
};
export function cleanPersonaValue(raw: string | null | undefined): {
tag: string | null;
text: string;
} {
if (!raw) return { tag: null, text: '—' };
const m = /^\s*\[([A-Za-z_]+)\]\s*(.*)$/.exec(raw);
if (!m) return { tag: null, text: raw };
const enumKey = m[1]!;
const text = m[2]!.trim();
const tag = PERSONA_STATUS_ZH[enumKey] ?? enumKey;
// 文本完全等同 tag 翻译时(例 "[contactable] 可触达") 只显示 tag,避免重复
if (text === PERSONA_STATUS_ZH[enumKey]) return { tag, text: '' };
return { tag, text };
}
/**
* 取 persona description 的"短标签" — 用于"关键事实"等紧凑位置(右侧 hint 列)
*
* 后端 description 常带详细括号(给客服详情看),紧凑位放不下,要短词:
* "新客/未消费(累计净消费 ¥58.00,含 ...)" → "新客"
* "钻卡(LTV ¥48,200,含 ...)" → "钻卡"
* "[gap] 治疗链有 3 处缺口 — K05..." → "缺口"(取 [enum] 翻译)
*
* 规则:
* ① 有 [enum] 前缀 → 用翻译后的 tag(简洁)
* ② 否则 → 截首段(到第一个 "/" 或 "(" 或 "(" 前)
*/
export function shortPersonaValueLabel(raw: string | null | undefined): string {
if (!raw) return '—';
// ① [enum] 前缀模式 — 直接用 tag
const m = /^\s*\[([A-Za-z_]+)\]/.exec(raw);
if (m) return PERSONA_STATUS_ZH[m[1]!] ?? m[1]!;
// ② 截首段:遇到 · / 或 ( 或 ( 停(分隔符语义都是"主词 · 详情")
const stop = raw.search(/[·\/(()]/);
return (stop > 0 ? raw.slice(0, stop) : raw).trim();
}
'use client';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { plansApi } from '@/components/plans/plans-api';
import { useAuthStore } from '@/stores/auth-store';
import {
cn,
formatGender,
formatDaysReadable,
} from '@/lib/utils';
import { PersonaFeatureKey, treatmentCategoryNameZh } from '@pac/types';
import { AIStamp, Chip, PriorityBar, SidebarCard, tone } from './shared';
import { cleanPersonaValue, shortPersonaValueLabel } from './persona-display';
import { ReasonLine } from './reason-line';
import { ChainSidebar } from './chain-viz';
import { ScriptView, type ScriptViewMode } from './script-viewer';
import { OutcomeForm } from './outcome-form';
......@@ -59,7 +69,6 @@ export function PlanDetailApp({
const facts = data.facts ?? [];
const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null);
const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
const [toast, setToast] = useState<{ kind: string; title: string; msg: string } | null>(null);
const { state: streamState, regenerate, abort } = useScriptStream();
const { state: summaryState, regenerate: regenerateSummary } = useSummaryStream();
......@@ -71,11 +80,15 @@ export function PlanDetailApp({
}>({});
const effectivePlan = { ...plan, ...planOverride };
const reason = plan.reasons[0]!;
const reasons = plan.reasons;
// 统一用全局 sonner toast(shadcn 体系,已在 layout 挂 <Toaster/>),自动消失
const showToast = (kind: string, title: string, msg: string) => {
setToast({ kind, title, msg });
setTimeout(() => setToast(null), 3000);
const opts = msg ? { description: msg } : undefined;
if (kind === 'rose') toast.error(title, opts);
else if (kind === 'emerald') toast.success(title, opts);
else if (kind === 'amber') toast.warning(title, opts);
else toast(title, opts);
};
// 流式 done / error 时弹 toast
......@@ -182,21 +195,39 @@ export function PlanDetailApp({
style={{ fontFamily: '"PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif' }}
>
{banner}
<TopBar plan={plan} reason={reason} />
<TopBar plan={plan} reason={reasons[0]!} />
<div className="flex-1 min-h-0">
<div className="max-w-[1440px] h-full mx-auto px-5 py-3">
<div className="h-full mx-auto px-5 py-3">
<div className="grid h-full gap-3" style={{ gridTemplateColumns: '300px 1fr 380px' }}>
{/* ─── LEFT ─── */}
<aside className="min-h-0 flex flex-col gap-2.5 overflow-y-auto pr-1">
<IdentityCard
patient={patient}
onOpenImage={() => setDrawerOpen('image')}
onOpenProfile={() => alert('跳转宿主患者档案')}
onOpenImage={() =>
showToast('slate', '影像调阅', '跳转宿主页面')
}
onOpenProfile={() =>
showToast('slate', '患者档案', '跳转宿主页面')
}
/>
<WhyCard
reasons={reasons}
chains={chains}
onOpenMedical={() => setDrawerOpen('medical')}
/>
<KeyFactsCard
patient={patient}
persona={persona}
facts={facts}
onOpenDetail={() => setDrawerOpen('facts')}
/>
<SuggestionCard
plan={effectivePlan}
patient={patient}
persona={persona}
facts={facts}
/>
<WhyCard reason={reason} priorityScore={plan.priorityScore} onOpenMedical={() => setDrawerOpen('medical')} />
<KeyFactsCard patient={patient} onOpenDetail={() => setDrawerOpen('facts')} />
<SuggestionCard plan={effectivePlan} patient={patient} />
<SidebarCard
title="治疗链"
meta={`${chains.length} 条`}
......@@ -341,14 +372,13 @@ export function PlanDetailApp({
chains={chains}
persona={persona}
summaries={summaries}
reason={reason}
reason={reasons[0]!}
facts={facts}
fmtRel={fmtRel}
summaryOverride={summaryOverride}
summaryStreaming={summaryStreaming}
onRegenerateSummary={() => void regenerateSummary(plan.id)}
/>
{toast && <Toast {...toast} />}
</div>
);
}
......@@ -363,29 +393,42 @@ function TopBar({
plan: typeof mockPlan;
reason: typeof mockPlan.reasons[0];
}) {
const user = useAuthStore((s) => s.user);
return (
<div className="bg-white border-b border-slate-200 flex-none">
<div className="max-w-[1440px] mx-auto px-5 h-11 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="w-5 h-5 rounded bg-teal-600 text-white text-[10px] font-bold flex items-center justify-center">
P
<header className="flex flex-none items-center justify-between gap-3 border-b border-slate-200 bg-white px-5 py-3">
<div className="flex min-w-0 items-center gap-3">
<div className="inline-flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-teal-600 text-[12px] font-bold text-white">
PAC
</span>
<span className="text-[12.5px] font-semibold text-slate-900">PAC</span>
<span className="text-[11px] text-slate-400">/ 召回中心 / 任务详情</span>
<Chip tone="rose" icon size="xs" className="ml-2">
{reason.scenarioLabel}
</Chip>
<PriorityBar score={plan.priorityScore} label="优先级" />
<span className="text-[13px] font-semibold text-slate-900">疗效保障</span>
</div>
<div className="flex items-center gap-3 text-[11px] text-slate-600">
<span>
承接 <strong className="text-slate-900">{plan.assignee.name}</strong>
</span>
<span className="text-slate-300">·</span>
<RecycleCountdown recycleAt={plan.recycleAt} />
<span className="text-slate-300">/</span>
<div className="min-w-0">
<h1 className="truncate text-[15px] font-semibold leading-tight text-slate-900">任务详情</h1>
<p className="mt-0.5 truncate text-[11px] text-slate-500">
召回中心
</p>
</div>
{/* 保留要素:scenario chip + 优先级条 */}
<Chip tone="rose" icon size="xs" className="ml-2">
{reason.scenarioLabel}
</Chip>
<PriorityBar score={plan.priorityScore} label="优先级" />
</div>
</div>
<div className="flex flex-none items-center gap-3 text-[11px] text-slate-600">
{/* 保留要素:回收倒计时 */}
<RecycleCountdown recycleAt={plan.recycleAt} />
{/* 跟列表页一致:用户信息 + 头像 */}
<div className="hidden text-right text-[11.5px] leading-tight text-slate-500 md:block">
<div className="font-medium text-slate-700">{user?.sub ?? '—'}</div>
<div className="nums">{user?.clinicIds?.length ?? 0} 个诊所 · {user?.role ?? '—'}</div>
</div>
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-teal-400 to-teal-600 text-[12px] font-bold text-white">
{(user?.sub ?? '?').charAt(0).toUpperCase()}
</span>
</div>
</header>
);
}
......@@ -496,9 +539,46 @@ function IdentityCard({
onOpenProfile: () => void;
}) {
const [copied, setCopied] = useState(false);
const copyPhone = (e: React.MouseEvent) => {
const [revealed, setRevealed] = useState(false);
const [revealedPhone, setRevealedPhone] = useState<string | null>(null);
// patient.phone 当前 backend 已返回明文(plan-aggregate.service.ts:131),前端优先用;
// 若未来 backend 隐藏明文(只发 phoneMasked),则降级调 /patients/:id/phone-reveal 拉
// TODO(W5+):backend 应只回 phoneMasked,reveal 走专用端点 + audit log
const toggleReveal = async (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard?.writeText(patient.phone);
if (revealed) {
setRevealed(false);
return;
}
// 已有明文 → 直接显示;否则调 API
if (patient.phone && patient.phone !== patient.phoneMasked) {
setRevealedPhone(patient.phone);
setRevealed(true);
return;
}
try {
const { phone } = await plansApi.revealPhone(patient.id);
setRevealedPhone(phone);
setRevealed(true);
} catch (err) {
toast.error(err instanceof Error ? err.message : '获取号码失败');
}
};
const copyPhone = async (e: React.MouseEvent) => {
e.stopPropagation();
// 复制总是明文;若未 reveal 则先拉(用户意图明确想要 raw)
let raw = revealedPhone ?? patient.phone;
if (!raw || raw === patient.phoneMasked) {
try {
const { phone } = await plansApi.revealPhone(patient.id);
raw = phone ?? '';
} catch (err) {
toast.error(err instanceof Error ? err.message : '获取号码失败');
return;
}
}
if (!raw) return;
await navigator.clipboard?.writeText(raw);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
......@@ -510,7 +590,7 @@ function IdentityCard({
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span className="text-[15px] font-semibold text-slate-900 leading-tight">{patient.name}</span>
<span className="text-[10.5px] text-slate-500">
{patient.gender}·{patient.age}
{formatGender(patient.gender)}·{patient.age}
</span>
</div>
<div className="flex items-center gap-2 flex-none">
......@@ -530,7 +610,33 @@ function IdentityCard({
))}
</div>
<div className="mt-1 flex items-center gap-1.5">
<span className="text-[12px] tabular-nums font-mono text-slate-700">{patient.phoneMasked}</span>
<span className="text-[12px] tabular-nums font-mono text-slate-700">
{revealed && revealedPhone ? revealedPhone : patient.phoneMasked}
</span>
{/* 查看明文 — eye / eye-off toggle(reveal,需 PATIENT_VIEW 权限) */}
<button
onClick={toggleReveal}
title={revealed ? '隐藏号码' : '查看号码'}
className={cn(
'inline-flex items-center justify-center w-5 h-5 rounded transition-colors',
revealed ? 'text-teal-700 bg-teal-50' : 'text-slate-400 hover:text-teal-700 hover:bg-teal-50',
)}
>
{revealed ? (
// eye-off
<svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M17.94 17.94A10.94 10.94 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A10.94 10.94 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" strokeLinecap="round" strokeLinejoin="round" />
<line x1="1" y1="1" x2="23" y2="23" strokeLinecap="round" />
</svg>
) : (
// eye
<svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
)}
</button>
{/* 复制(总是复制明文,未 reveal 时自动拉) */}
<button
onClick={copyPhone}
title="复制号码"
......@@ -558,18 +664,45 @@ function IdentityCard({
}
// ──────────────────────────────────────────
// WhyCard — 召回原因 + v2.1 6 因子可解释性 breakdown
// WhyCard — 召回原因列表(W3 末:plan_reasons 按 sub_key 拆分;
// 每行用 signals JSON + @pac/types 字典翻译富文本渲染,关键字高亮。reason 文本仅作 signals 缺失时 fallback)
// ──────────────────────────────────────────
function WhyCard({
reason,
priorityScore,
reasons,
chains,
onOpenMedical,
}: {
reason: PlanReason;
priorityScore: number;
reasons: PlanReason[];
chains: typeof mockChains;
onOpenMedical: () => void;
}) {
const bd = reason.breakdown;
// 替代闭环联动:若 reason.toothPosition 命中任一 chain.alternativeClosedBy(同牙位被后续替代方案覆盖),
// 该 reason 已无召回意义 — 直接从列表 drop,不显示。
// 治疗链全景里那条 chain 自带 amber "已被替代" chip,客服需要时去全景看,WhyCard 不再重复噪音。
// (scenario SQL 是 patient 级粗粒度,跨 chain alternativeClosedBy 只在展示层 chain-composer 跑,故过滤只在 UI 侧做)
const altCoveredTeeth = new Set<string>();
for (const c of chains) {
if (!c.alternativeClosedBy || !c.toothPosition) continue;
altCoveredTeeth.add(c.toothPosition);
}
const visibleReasons = reasons.filter((r) => {
const tooth = r.signals?.toothPosition ?? '';
return !tooth || !altCoveredTeeth.has(tooth);
});
if (visibleReasons.length === 0) {
return (
<SidebarCard
title="为什么召回"
action={
<button onClick={onOpenMedical} className="text-[10.5px] text-teal-700 hover:underline">
病历快读 →
</button>
}
>
<div className="text-[11.5px] text-slate-400 italic">所有召回原因均已被替代方案覆盖,可不召回</div>
</SidebarCard>
);
}
return (
<SidebarCard
title="为什么召回"
......@@ -579,93 +712,140 @@ function WhyCard({
</button>
}
>
<p className="text-[12.5px] text-slate-700 leading-relaxed">{reason.reason}</p>
{bd && (
<div className="mt-2.5 pt-2.5 border-t border-slate-100 space-y-1">
<div className="flex items-center justify-between text-[10.5px] text-slate-500">
<span className="font-medium">为什么 {priorityScore}</span>
<span className="text-slate-400">6 因子算法 v2.1</span>
</div>
<BreakdownRow
label="临床基线"
value={`${bd.priority.clinicalBase} × ${bd.priority.timeWindowFactor.toFixed(2)} = ${bd.priority.main.toFixed(1)}`}
tone="slate"
/>
{bd.priority.valueBonus > 0 && (
<BreakdownRow label="患者价值" value={`+${bd.priority.valueBonus}`} tone="emerald" />
)}
{bd.priority.likelihoodBonus > 0 && (
<BreakdownRow
label="转化可能"
value={`+${bd.priority.likelihoodBonus}`}
tone="emerald"
/>
)}
{bd.priority.urgencyBonus > 0 && (
<BreakdownRow label="临床紧迫" value={`+${bd.priority.urgencyBonus}`} tone="amber" />
)}
{bd.priority.confidenceFactor < 1.0 && (
<BreakdownRow
label="信号置信"
value={`× ${bd.priority.confidenceFactor.toFixed(2)}`}
tone="rose"
/>
)}
{bd.mergedSubKeys && bd.mergedSubKeys.length > 1 && (
<div className="mt-1.5 flex items-center gap-1.5 text-[10px] text-slate-400">
<span>合并子规则:</span>
{bd.mergedSubKeys.map((k) => (
<span key={k} className="px-1 py-px rounded bg-slate-100 text-slate-600 font-mono">
{k}
</span>
))}
<ul className="space-y-2 text-[12.5px] text-slate-700 leading-relaxed">
{visibleReasons.map((r) => (
<li key={r.id} className="flex gap-1.5">
{visibleReasons.length > 1 && <span className="text-rose-500 flex-none mt-[2px]"></span>}
<div className="flex-1 min-w-0">
<ReasonLine reason={r} />
</div>
)}
</div>
)}
</li>
))}
</ul>
</SidebarCard>
);
}
function BreakdownRow({
label,
value,
tone,
}: {
label: string;
value: string;
tone: 'slate' | 'emerald' | 'amber' | 'rose';
}) {
const toneClass = {
slate: 'text-slate-700',
emerald: 'text-emerald-600',
amber: 'text-amber-600',
rose: 'text-rose-600',
}[tone];
return (
<div className="flex items-center justify-between text-[11px]">
<span className="text-slate-500">{label}</span>
<span className={`${toneClass} font-medium tabular-nums`}>{value}</span>
</div>
);
}
// ──────────────────────────────────────────
// KeyFactsCard
// KeyFactsCard — 关键事实(5 行)
// 主诊医生 · 累计消费 · 上次到诊 · 首次就诊 · 联系人
//
// 数据源全走真实 PAC 数据(无 mock 兜底):
// - 主诊医生:从 facts 算 doctor_id 出现频次 top 1(同 chain-composer doctorMap),host 缺值 → '—'
// - 累计消费:profile.ltv(LTV cents 已扣 refund);右侧 hint 显示 persona.value description(画像评级)
// - 上次/首次到诊:profile.lastVisit / firstVisit
// - 联系人:profile.primaryContactType(host 暂无数据,统一 '—' 占位)
// hint 列:除累计消费用 persona value 外,其他用 facts 推断的主要治疗类目 top 1-2
// ──────────────────────────────────────────
function KeyFactsCard({
patient,
persona,
facts,
onOpenDetail,
}: {
patient: typeof mockPatient;
persona: typeof mockPersona;
facts: AdaptedFact[];
onOpenDetail: () => void;
}) {
// ─ 主诊医生 ─ 取 facts 中 doctor_id 出现频次最高的,并解析 doctor_name
// 同 chain-composer.buildDoctorMap 逻辑同源:同 patient (id,name) 双全的 fact 学一遍 map
const attendingDoctor = (() => {
const idCount = new Map<string, number>();
const idToName = new Map<string, string>();
for (const f of facts) {
const c = f.content as Record<string, unknown> | null;
if (!c) continue;
const id = c.doctor_id ? String(c.doctor_id) : '';
const name = c.doctor_name ? String(c.doctor_name) : '';
if (!id) continue;
idCount.set(id, (idCount.get(id) ?? 0) + 1);
if (name) idToName.set(id, name);
}
if (idCount.size === 0) return '—';
const topId = [...idCount.entries()].sort((a, b) => b[1] - a[1])[0]![0];
return idToName.get(topId) || `医生 #${topId}`;
})();
// ─ 累计消费 ─ 真实 LTV(已扣 refund);hint 用 persona.value 画像评级"短标签"
// 后端 description "新客/未消费(累计净消费 ¥58.00,含 ...)" 太长 → 取首段 "新客"(去 / 和括号),
// 跟主值 ¥58 互补不重复
const ltvYuan = (patient.profile.ltv ?? 0).toLocaleString();
const facts = [
{ label: '累计消费', value: ltvYuan === '0' ? '¥0(新客)' : ${ltvYuan}`, hint: patient.tags[0] ?? '' },
{ label: '上次到诊', value: patient.profile.lastVisit || '—', hint: '' },
{ label: '首次就诊', value: patient.profile.firstVisit || '—', hint: '' },
{ label: '联系人', value: patient.profile.primaryContactType ?? '本人', hint: '' },
const valueFeature = persona.features.find((f) => f.key === PersonaFeatureKey.VALUE);
const valueHint = shortPersonaValueLabel(valueFeature?.value);
// ─ 治疗类目 top 1-2 ─ facts 里 treatment_record.category 出现频次 top(用于主诊医生右侧 hint)
const mainCategories = (() => {
const counter = new Map<string, number>();
for (const f of facts) {
if (f.type !== 'treatment_record') continue;
const cat = String((f.content as Record<string, unknown> | null)?.category ?? '');
if (!cat || cat === 'review') continue;
counter.set(cat, (counter.get(cat) ?? 0) + 1);
}
const top = [...counter.entries()].sort((a, b) => b[1] - a[1]).slice(0, 2);
return top.length ? top.map(([c]) => treatmentCategoryNameZh(c)).join(' · ') : '';
})();
// ─ 某次就诊"做了什么"摘要(上次到诊 / 首次就诊 hint)─
// 窗口 = visit day ±1 day(EMR/治疗结算常延后一天,纳入)
// 优先级:
// ① actual treatment 治疗类目(去重,如 "牙周 · 预防")
// ② 诊断码 + 中文名(如 "K05 慢性牙周炎")
// ③ EMR 主诉(illness_desc / pre_illness 截 12 字,如 "下前牙区牙龈肿痛…")
// ④ 兜底 "到诊"
const visitPurpose = (day: string | undefined): string => {
if (!day) return '';
const within1Day = (occurredAt: string | null): boolean => {
if (!occurredAt) return false;
const d = occurredAt.slice(0, 10);
// d ∈ [day, day+1](正向 1 日窗口,容忍 EMR/治疗结算延后)
if (d === day) return true;
const dDate = new Date(d).getTime();
const dayDate = new Date(day).getTime();
const diff = (dDate - dayDate) / 86400_000;
return diff > 0 && diff <= 1;
};
// ① 实际治疗类(去重)
const cats = new Set<string>();
for (const f of facts) {
if (f.type !== 'treatment_record' || f.kind !== 'actual') continue;
if (!within1Day(f.occurredAt)) continue;
const c = String((f.content as Record<string, unknown> | null)?.category ?? '');
if (c && c !== 'review') cats.add(c);
}
if (cats.size > 0) return [...cats].map(treatmentCategoryNameZh).join(' · ');
// ② 诊断名(取第一条非空)
for (const f of facts) {
if (f.type !== 'diagnosis_record') continue;
if (!within1Day(f.occurredAt)) continue;
const c = f.content as Record<string, unknown> | null;
const name = String(c?.name_zh ?? c?.name ?? '');
if (name) return `${name ? ' ' : ''}${name}`.trim();
}
// ③ EMR 主诉(illness_desc 优先,fallback pre_illness)
for (const f of facts) {
if (f.type !== 'emr_record') continue;
if (!within1Day(f.occurredAt)) continue;
const c = f.content as Record<string, unknown> | null;
const text = String(c?.illness_desc ?? c?.pre_illness ?? '').trim();
if (text) return text.length > 12 ? text.slice(0, 12) + '…' : text;
}
// ④ 兜底
return '到诊';
};
// 顺序:累计消费 → 主诊医生 → 上次到诊 → 首次就诊 → 联系人(user 反馈调整)
const rows = [
{ label: '累计消费', value: ${ltvYuan}`, hint: valueHint },
{ label: '主诊医生', value: attendingDoctor, hint: mainCategories || '' },
{ label: '上次到诊', value: patient.profile.lastVisit || '—', hint: visitPurpose(patient.profile.lastVisit) },
{ label: '首次就诊', value: patient.profile.firstVisit || '—', hint: visitPurpose(patient.profile.firstVisit) },
{ label: '联系人', value: patient.profile.primaryContactType || '—', hint: '' },
];
return (
<SidebarCard
......@@ -678,11 +858,15 @@ function KeyFactsCard({
}
>
<div className="space-y-1">
{facts.map((f) => (
<div key={f.label} className="flex items-center gap-2 text-[11px]">
<span className="text-slate-500 w-14 flex-none">{f.label}</span>
<span className="text-slate-900 font-medium flex-1 truncate tabular-nums">{f.value}</span>
<span className="text-slate-400 text-[10px] truncate">{f.hint}</span>
{rows.map((r) => (
<div key={r.label} className="flex items-center gap-2 text-[11px]">
<span className="text-slate-500 w-14 flex-none">{r.label}</span>
<span className="text-slate-900 font-medium flex-1 truncate tabular-nums" title={r.value}>
{r.value}
</span>
<span className="text-slate-400 text-[10px] truncate max-w-[100px]" title={r.hint}>
{r.hint}
</span>
</div>
))}
</div>
......@@ -693,23 +877,23 @@ function KeyFactsCard({
// ──────────────────────────────────────────
// SuggestionCard
// ──────────────────────────────────────────
function SuggestionCard({ plan, patient: _patient }: { plan: typeof mockPlan; patient: typeof mockPatient }) {
function SuggestionCard({
plan,
patient,
persona,
facts,
}: {
plan: typeof mockPlan;
patient: typeof mockPatient;
persona: typeof mockPersona;
facts: AdaptedFact[];
}) {
// 推荐渠道 / 推荐时间 / 推荐角色 已移除 —— v1 算法对所有召回写死 phone/staff/asap,
// 无 per-patient 差异(详见接口字段定性);风险规避原为前端写死文案,一并移除。
// 保留有真实区分度的字段:本次目标(plan.goal)/ 归属诊所(targetClinic)。
const items: Array<{
label: string;
value: string;
hint?: string;
icon: ReactNode;
tone: string;
highlight?: boolean;
}> = [
// 无 per-patient 差异(详见接口字段定性)。保留:本次目标(plan.goal) / 归属诊所(targetClinic)。
const items = [
{
label: '本次目标',
// ⭐ v2.1 真实数据 plan.goal(ScenarioHit.goal 透传);没有就显示降级文案
value: plan.goal ?? '(算法未生成目标)',
hint: '主要任务',
icon: (
<svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="1.8">
<circle cx="12" cy="12" r="9" />
......@@ -722,58 +906,49 @@ function SuggestionCard({ plan, patient: _patient }: { plan: typeof mockPlan; pa
{
label: '归属诊所',
value: plan.targetClinic,
hint: '治疗主诊',
icon: (
<svg
viewBox="0 0 24 24"
className="w-3 h-3"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 21h18M5 21V7l8-4 8 4v14M9 9h1m-1 4h1m-1 4h1M14 9h1m-1 4h1m-1 4h1" />
</svg>
),
tone: 'slate',
},
];
// ⭐ 风险规避 — 多信号合一(PAC 当前已有的真实风险信号)
const risks = computeRisks({ patient, persona, facts, plan });
return (
<SidebarCard title="召回建议" defaultOpen={false}>
<div className="space-y-1">
{items.map((it) => {
const T = tone(it.tone);
return (
<div
key={it.label}
className={cn(
'flex items-start gap-2 text-[11px] py-0.5',
it.highlight && 'bg-amber-50/60 rounded px-1 -mx-1',
)}
>
<div key={it.label} className="flex items-start gap-2 text-[11px] py-0.5">
<span className={cn('flex-none w-5 h-5 rounded flex items-center justify-center mt-px', T.bg, T.text)}>
{it.icon}
</span>
<span
className={cn(
'w-14 flex-none mt-0.5',
it.highlight ? 'text-amber-700 font-semibold' : 'text-slate-500',
)}
>
{it.label}
</span>
<span
className={cn(
'font-medium flex-1 leading-snug min-w-0',
it.highlight ? 'text-amber-900' : 'text-slate-900 truncate',
)}
>
<span className="w-14 flex-none mt-0.5 text-slate-500">{it.label}</span>
{/* 自动换行 + 左对齐,不再 truncate;长内容(本次目标)完整展示 */}
<span className="font-medium flex-1 leading-snug min-w-0 text-slate-900 break-words">
{it.value}
</span>
</div>
);
})}
{/* 风险规避(图 2 样式:多信号 · 分隔,琥珀色调) */}
{risks.length > 0 && (
<div className="flex items-start gap-2 text-[11px] py-0.5 mt-1 bg-amber-50/60 rounded px-1 -mx-1">
<span className="flex-none w-5 h-5 rounded flex items-center justify-center mt-px bg-amber-100 text-amber-700">
<svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 9v4M12 17h.01M10.3 3.86 1.82 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.86a2 2 0 0 0-3.4 0z" />
</svg>
</span>
<span className="w-14 flex-none mt-0.5 text-amber-700 font-semibold">风险规避</span>
<span className="font-medium flex-1 leading-snug min-w-0 text-amber-900 break-words">
{risks.join(' · ')}
</span>
</div>
)}
</div>
<div className="mt-2 pt-2 border-t border-slate-100 flex items-center justify-between">
<span className="text-[10.5px] text-slate-500">
......@@ -788,7 +963,8 @@ function SuggestionCard({ plan, patient: _patient }: { plan: typeof mockPlan; pa
key={i}
className={cn(
'w-2 h-1.5 rounded-sm',
i < plan.contactAttempts ? 'bg-amber-500' : i === plan.contactAttempts ? 'bg-teal-500' : 'bg-slate-200',
// 仅"已触达"染色,不预亮"下一格" — 旧版 0/4 时第一格 teal 高亮被误读为已用
i < plan.contactAttempts ? 'bg-amber-500' : 'bg-slate-200',
)}
/>
))}
......@@ -798,6 +974,70 @@ function SuggestionCard({ plan, patient: _patient }: { plan: typeof mockPlan; pa
);
}
/**
* 计算风险规避提示 — PAC 已有数据驱动
*
* 信号源(全是 PAC 已有字段):
* ① profile.doNotContact / deceased — 合规硬约束
* ② persona recall_risk feature — 流失/复发风险等级
* ③ 历史 refund_record — 退费提示触达谨慎
* ④ plan.contactAttempts 接近熔断 — 触达≥75% 限额时提示
* ⑤ patient.age < 14 / >= 70 — 触达需家属(host 暂无监护人字段)
* ⑥ 慢性病(K05 牙周长期诊断) — 终身维护型,触达避免逼单
*/
function computeRisks({
patient,
persona,
facts,
plan,
}: {
patient: typeof mockPatient;
persona: typeof mockPersona;
facts: AdaptedFact[];
plan: typeof mockPlan;
}): string[] {
const out: string[] = [];
// ① 合规硬约束
if (patient.profile.doNotContact) out.push('⛔ 已标记不打扰');
if (patient.profile.deceased) out.push('⛔ 已故');
// ② persona recall_risk
const riskFeature = persona.features.find((f) => f.key === 'recall_risk');
if (riskFeature && /高|high/i.test(riskFeature.value)) {
out.push('流失风险高 · 谨慎触达');
}
// ③ 历史退费
const refundCount = facts.filter((f) => f.type === 'refund_record').length;
if (refundCount > 0) out.push(`有 ${refundCount} 次历史退费 · 避免逼单`);
// ④ 触达接近熔断(>=75% 已用)
if (plan.maxContactAttempts > 0 && plan.contactAttempts >= plan.maxContactAttempts * 0.75) {
out.push(`已触达 ${plan.contactAttempts}/${plan.maxContactAttempts} · 接近熔断`);
}
// ⑤ 年龄段触达约束
if (patient.age != null) {
if (patient.age < 14) out.push('未成年 · 需联系监护人');
else if (patient.age >= 70) out.push('高龄 · 建议联系家属陪同');
}
// ⑥ 终身维护型慢性病(K05 诊断 + 已有 periodontic 治疗)
const hasK05 = facts.some((f) => f.type === 'diagnosis_record' && String((f.content as Record<string, unknown> | null)?.code ?? '') === 'K05');
const hasPerio = facts.some(
(f) =>
f.type === 'treatment_record' &&
f.kind === 'actual' &&
String((f.content as Record<string, unknown> | null)?.category ?? '') === 'periodontic',
);
if (hasK05 && hasPerio) {
out.push('牙周慢性病 · 终身维护型,沟通节奏宽松');
}
return out;
}
// ──────────────────────────────────────────
// PersonaQuickList
// ──────────────────────────────────────────
......@@ -806,11 +1046,19 @@ function PersonaQuickList({ features }: { features: typeof mockPersona.features
<ul className="space-y-1">
{features.map((f) => {
const T = tone(f.tone);
const { tag, text } = cleanPersonaValue(f.value);
return (
<li key={f.key} className="flex items-center gap-2">
<span className={cn('flex-none w-1.5 h-1.5 rounded-full', T.dot)} />
<span className={cn('text-[10.5px] font-semibold', T.text)}>{f.label}</span>
<span className="text-[10.5px] text-slate-700 truncate flex-1">{f.value}</span>
{tag && (
<span className="text-[10px] px-1 py-0 rounded bg-slate-100 text-slate-600 tabular-nums">
{tag}
</span>
)}
<span className="text-[10.5px] text-slate-700 truncate flex-1" title={f.value}>
{text}
</span>
</li>
);
})}
......@@ -818,6 +1066,7 @@ function PersonaQuickList({ features }: { features: typeof mockPersona.features
);
}
// ──────────────────────────────────────────
// RegenBtn — 流式重新生成按钮
// - 空闲态:点击触发 SSE 调用
......@@ -877,23 +1126,3 @@ function DotsThreePulse() {
// ──────────────────────────────────────────
// Toast
// ──────────────────────────────────────────
function Toast({ kind, title, msg }: { kind: string; title: string; msg: string }) {
const T = tone(kind);
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50">
<div
className={cn(
'rounded-lg shadow-lg px-4 py-3 flex items-center gap-3 ring-1 ring-inset min-w-[280px]',
T.bg,
T.ring,
)}
>
<span className={cn('w-2.5 h-2.5 rounded-full', T.dot)} />
<div>
<div className={cn('text-[13px] font-semibold', T.text)}>{title}</div>
<div className="text-[11.5px] text-slate-600 font-mono mt-0.5">{msg}</div>
</div>
</div>
</div>
);
}
......@@ -51,11 +51,25 @@ export type PlanDetailData = {
reasons: Array<{
id: string;
scenario: string;
subKey: string | null;
/// 结构化召回信号(DB 存 raw enum,前端用 @pac/types 字典翻译富文本)
signals: {
subKey: string;
triggers: Array<{ type: string; code?: string | null }>;
toothPosition?: string | null;
daysSince: number;
expectedCategories: string[];
} | null;
priorityScore: number;
reason: string;
lifecycle: string;
source: string;
evidenceFactIds: string[];
/// W3 末:evidence 改 JSON 统一(跟 PersonaFeature 对齐),前端读 evidence.factIds
evidence: {
factIds: string[];
agentInvocationIds?: string[];
ruleIds?: string[];
} | null;
/// v2.1 算法可解释性 — 6 因子算分 breakdown(详情页"为什么 X 分"渲染数据)
/// null = scenario plugin 未产 breakdown(老 scenario / 手动添加)
breakdown:
......@@ -92,19 +106,31 @@ export type PlanDetailData = {
id: string;
name: string;
category: string;
status: 'closed' | 'ongoing' | 'uninitiated';
/// 5 阶段链状态(W3 末从 3 态升级,跟后端 chain-composer 同源)
status: 'discovered' | 'entered' | 'ongoing' | 'closed';
currentStage: number;
target?: boolean;
estimatedValueCents?: number;
diagnosedAt?: string;
gapDays?: number;
/// 生命周期提示("终身维护(永不闭环)" 等)
lifecycleNoteZh?: string;
/// 桶牙位(cross-chain alternative-closed 判定用,UI 不直接展示)
toothPosition?: string;
/// "替代治疗已覆盖"原因 chain.name(K04 后续 K08+种植覆盖等)
alternativeClosedBy?: string;
nodes: Array<{
stage: number;
label: string;
title?: string;
detail?: string;
doctor?: string;
at: string;
hint?: string;
done?: boolean;
current?: boolean;
missing?: boolean;
/// 旧兼容字段(逐步淘汰)
label?: string;
note?: string;
}>;
}>;
......@@ -114,9 +140,24 @@ export type PlanDetailData = {
kind: string;
status: string;
occurredAt: string | null;
plannedFor: string | null;
clinicId: string | null;
title: string | null;
summary: string | null;
content: unknown;
}>;
/// W4:LLM 生成完会 upsert 到 DB(plan_scripts),此处反 parse 4 段 sections;
/// null = 该 plan 还未生成话术,前端走 mockScript 兜底 + 引导客服点"重新生成"
script: {
id: string;
status: string; // ready / pending / failed
source: 'agent' | 'template_fallback' | 'manual';
generatedAt: string;
sections: Array<{
id: string;
label: string;
durationHint: string;
markdown: string;
}>;
} | null;
};
'use client';
import {
subLabelZh,
triggerTypeLabelZh,
diagnosisCodeNameZh,
treatmentCategoryNameZh,
} from '@pac/types';
import { formatToothPosition, formatDaysReadable } from '@/lib/utils';
/**
* ReasonLine — 召回理由富文本渲染。
*
* 数据契约:后端只回原始 enum / canonical code(reason.signals JSON),
* 前端用 @pac/types 字典翻译 + 富文本组合(加粗 + 玫红高亮,色彩克制)。
*
* 同源:列表页 PatientPlanCard + 详情页 WhyCard 共用。
*
* 老数据/异常兜底:signals 缺失时落回 reason 文本。
*/
export interface ReasonLineInput {
scenario: string;
reason: string;
signals?: {
subKey: string;
triggers: Array<{ type: string; code?: string | null }>;
toothPosition?: string | null;
daysSince: number;
expectedCategories: string[];
} | null;
}
export function ReasonLine({ reason }: { reason: ReasonLineInput }) {
if (!reason.signals) return <span>{reason.reason}</span>;
const s = reason.signals;
const trig = s.triggers[0]; // 当前单源;多源 W5+ 再调
const subLabel = subLabelZh(reason.scenario, s.subKey);
const cats = s.expectedCategories.map(treatmentCategoryNameZh).join(' / ');
return (
<span>
<strong className="text-slate-900">{subLabel}</strong>
{s.toothPosition && ` · 牙位 ${formatToothPosition(s.toothPosition)}`}
{trig?.code && ` — ${diagnosisCodeNameZh(trig.code)}`}
{trig && `(${triggerTypeLabelZh(trig.type)})`}{' '}
<strong className="text-rose-600 tabular-nums">{formatDaysReadable(s.daysSince)}</strong>
{cats && `,未启动 ${cats}`}
</span>
);
}
......@@ -56,7 +56,7 @@ export function TaskDrawer({ currentPlanId }: { currentPlanId: string }) {
const q = search.trim().toLowerCase();
arr = arr.filter(
(p) =>
(p.patient.nameMasked ?? '').toLowerCase().includes(q) ||
(p.patient.name ?? p.patient.nameMasked ?? '').toLowerCase().includes(q) ||
(p.patient.phoneMasked ?? '').includes(q) ||
p.patient.externalId.toLowerCase().includes(q),
);
......@@ -246,7 +246,7 @@ function TaskRow({
clinicName: string | null;
onSelect: () => void;
}) {
const name = item.patient.nameMasked ?? item.patient.externalId;
const name = item.patient.name ?? item.patient.nameMasked ?? item.patient.externalId;
const done = isDone(item.status);
return (
<li>
......
......@@ -21,7 +21,8 @@ import type { ScriptSection } from './mock-data';
*/
interface ServerSection {
id: 'opening' | 'key' | 'followup' | 'objection' | 'cta';
// B 方案:4 段对齐前端 mock(opening / followup / objection / close)
id: 'opening' | 'followup' | 'objection' | 'close';
label: string;
durationHint: string;
markdown: string;
......@@ -51,12 +52,18 @@ export interface UseScriptStream {
reset: () => void;
}
// B 方案(2026-05-24):后端 4 段对齐 mockScript — opening / followup / objection / close
const LABEL_FALLBACK: Record<ServerSection['id'], string> = {
opening: '开场',
key: '核心信息',
followup: '跟进话术',
followup: '切入话题',
objection: '异议处理',
cta: '行动呼吁',
close: '结束 · 信息确认',
};
const DURATION_FALLBACK: Record<ServerSection['id'], string> = {
opening: '30 秒',
followup: '1–2 分钟',
objection: '按需',
close: '30 秒',
};
export function useScriptStream(): UseScriptStream {
......@@ -242,10 +249,10 @@ function parseSseFrame(frame: string): SseEvent | null {
}
function makeEmptySections(): ScriptSection[] {
return (['opening', 'key', 'followup', 'objection', 'cta'] as const).map((id) => ({
return (['opening', 'followup', 'objection', 'close'] as const).map((id) => ({
id,
label: LABEL_FALLBACK[id],
durationHint: '',
durationHint: DURATION_FALLBACK[id],
markdown: '',
}));
}
......
'use client';
import type { ListPlansQuery, ListPlansResponse, PlanActionAck } from '@pac/types';
import type {
ListPlansQuery,
ListPlansResponse,
PlanActionAck,
PlanCountsResponse,
} from '@pac/types';
import { api } from '@/lib/api-client';
import type { PlanDetailData } from '@/components/plan-detail/plan-detail-types';
......@@ -13,11 +18,16 @@ export const plansApi = {
status: q.status,
targetClinicId: q.targetClinicId,
assigneeUserId: q.assigneeUserId,
keyword: q.keyword,
sort: q.sort,
page: q.page ?? 1,
pageSize: q.pageSize ?? 20,
},
}),
/** KPI / Tab badge 聚合计数 — 一次拉齐(替代之前 5 个并发 list 请求)*/
counts: () => api.get<PlanCountsResponse>('/pac/v1/plans/counts'),
/** 聚合详情:plan + patient + profile + persona + chains + facts(给 PlanDetailApp 用)*/
getAggregate: (planId: string) =>
api.get<PlanDetailData>(`/pac/v1/plans/${encodeURIComponent(planId)}/full`),
......@@ -33,4 +43,10 @@ export const plansApi = {
api.post<PlanActionAck>(`/pac/v1/plans/${encodeURIComponent(planId)}/recycle`, {
reason,
}),
/** 拉患者明文手机号(reveal,需 PATIENT_VIEW 权限) — GET /patients/{id}/phone-reveal */
revealPhone: (patientId: string) =>
api.get<{ phone: string | null }>(
`/pac/v1/patients/${encodeURIComponent(patientId)}/phone-reveal`,
),
};
'use client';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import {
ChevronLeft,
......@@ -28,10 +28,11 @@ import {
import { useAuthStore } from '@/stores/auth-store';
import { useHasPermission } from '@/hooks/use-permission';
import { ApiError } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { cn, formatGender } from '@/lib/utils';
import { plansApi } from './plans-api';
import { usePlansList } from './use-plans-list';
import { usePlanCounts } from './use-plan-counts';
import { ReasonLine } from '@/components/plan-detail/reason-line';
// ─────────────────────────────────────────────────────────────────
// 召回任务工作台(/plans)
......@@ -120,24 +121,24 @@ export function PlansListApp() {
void reloadCounts();
};
// 客户端二次过滤 + 排序(搜索 / 排序后端暂不支持)
const visible = useMemo(() => {
if (state.status !== 'ready') return [] as PlanListItem[];
let items = state.data.items;
if (q.trim()) {
const lq = q.trim().toLowerCase();
items = items.filter(
(p) =>
(p.patient.nameMasked ?? '').toLowerCase().includes(lq) ||
(p.patient.phoneMasked ?? '').includes(lq) ||
p.patient.externalId.toLowerCase().includes(lq),
);
}
const arr = [...items];
if (sort === 'priority_desc') arr.sort((a, b) => b.priorityScore - a.priorityScore);
if (sort === 'priority_asc') arr.sort((a, b) => a.priorityScore - b.priorityScore);
return arr;
}, [state, q, sort]);
// W3 末:keyword / sort 改服务端(usePlansList → /pac/v1/plans?keyword=&sort=),
// 跨全表搜索 + 跨页排序;前端不再 .filter / .sort 本页 items
// - keyword 300ms debounce 防抖,避免每键一次请求
// - sort 变化立刻 setQuery
useEffect(() => {
const trimmed = q.trim();
const handle = setTimeout(() => {
setQuery({ keyword: trimmed || undefined });
}, 300);
return () => clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q]);
useEffect(() => {
setQuery({ sort });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sort]);
const visible: PlanListItem[] = state.status === 'ready' ? state.data.items : [];
const toggleSelect = (id: string) =>
setSelected((prev) => {
......@@ -161,7 +162,7 @@ export function PlansListApp() {
if (!user) return;
try {
await plansApi.assign(plan.id, user.sub);
toast.success('已认领', { description: plan.patient.nameMasked ?? plan.patient.externalId });
toast.success('已认领', { description: plan.patient.name ?? plan.patient.nameMasked ?? plan.patient.externalId });
refreshAll();
} catch (e) {
toast.error('认领失败', { description: errMsg(e) });
......@@ -170,7 +171,7 @@ export function PlansListApp() {
const handleRecycleOne = async (plan: PlanListItem) => {
try {
await plansApi.recycle(plan.id);
toast.success('已回收到池', { description: plan.patient.nameMasked ?? plan.patient.externalId });
toast.success('已回收到池', { description: plan.patient.name ?? plan.patient.nameMasked ?? plan.patient.externalId });
refreshAll();
} catch (e) {
toast.error('回收失败', { description: errMsg(e) });
......@@ -325,7 +326,7 @@ function PageHeader({
<div className="flex min-w-0 items-center gap-3">
<div className="inline-flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-teal-600 text-[12px] font-bold text-white">PAC</span>
<span className="text-[13px] font-semibold text-slate-900">召回平台</span>
<span className="text-[13px] font-semibold text-slate-900">疗效保障</span>
</div>
<span className="text-slate-300">/</span>
<div className="min-w-0">
......@@ -489,7 +490,7 @@ function FilterBar({
<Input
compact
type="search"
placeholder="搜索 姓名 / 手机 / 外部 ID(本页)"
placeholder="搜索 姓名 / 手机 / 外部 ID"
value={q}
onChange={(e) => setQ(e.target.value)}
className="w-64 pl-8"
......@@ -605,7 +606,7 @@ function PatientPlanCard({
const isMine = p.assigneeUserId === me;
const isPool = p.status === 'active' && !p.assigneeUserId;
const isClosed = p.status === 'completed' || p.status === 'abandoned';
const displayName = p.patient.nameMasked ?? p.patient.externalId;
const displayName = p.patient.name ?? p.patient.nameMasked ?? p.patient.externalId;
const d = DENSITY[density];
const pad = d.pad;
......@@ -640,7 +641,7 @@ function PatientPlanCard({
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className={cn('truncate font-semibold leading-tight text-slate-900', titleSize)}>{displayName}</span>
<span className="text-[10.5px] text-slate-500 nums">
{p.patient.gender ?? '?'} · {p.patient.age ?? '?'}
{formatGender(p.patient.gender)} · {p.patient.age ?? '?'}
</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
......@@ -671,9 +672,14 @@ function PatientPlanCard({
<StatusPill status={p.status} />
</div>
<p className={cn('leading-snug text-slate-700', d.reason, pad, 'pb-3 pt-0')}>
{p.inclusionReason}
</p>
{/* 召回理由富文本(跟详情页 WhyCard 同源 ReasonLine):后端给 signals raw JSON,前端字典翻译。
列表只展示 primary(MAX priorityScore)一条,卡片保持紧凑;多 reason 全量在详情页看。 */}
<div className={cn('leading-snug text-slate-700', d.reason, pad, 'pb-3 pt-0')}>
{p.reasons[0] ? <ReasonLine reason={p.reasons[0]} /> : <span>{p.inclusionReason}</span>}
{p.reasons.length > 1 && (
<span className="ml-1.5 text-[10.5px] text-slate-400">+{p.reasons.length - 1}</span>
)}
</div>
{/* 底:meta + 操作 */}
<div className={cn('mt-auto flex items-center justify-between gap-2 border-t border-slate-100', d.foot)}>
......
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError } from '@/lib/api-client';
import type { PlanDetailData } from '@/components/plan-detail/plan-detail-types';
import { plansApi } from './plans-api';
......@@ -28,9 +28,14 @@ export function usePlanAggregate(planId: string) {
}
}, [planId]);
// 去重守卫:同一 planId 自动加载只触发一次 —— 规避 React 18 StrictMode(dev)
// 双调 effect 导致 /full 请求两次。planId 变化时重置;手动 refresh 不受限。
const loadedFor = useRef<string | null>(null);
useEffect(() => {
if (loadedFor.current === planId) return;
loadedFor.current = planId;
void load();
}, [load]);
}, [planId, load]);
return { state, refresh: load };
}
......@@ -4,8 +4,11 @@ import { useCallback, useEffect, useState } from 'react';
import { plansApi } from './plans-api';
/**
* KPI / Tab badge 用的轻量计数:并行 5 个 pageSize=1 的 list 拉 total,
* KPI / Tab badge 用的轻量计数:走后端 /plans/counts 单端点(一次 SQL 聚合 5 个 count),
* 不动主列表 query。任何主列表刷新时,通过 token (`refreshToken`) 触发同步。
*
* 历史:早期是前端并行 5 个 pageSize=1 list 请求,列表页刷新会产生 6+ 个 HTTP 请求 +
* StrictMode dev 双挂翻倍,网络面板看着很吓人。W3 末改为后端聚合。
*/
export interface PlanCounts {
mine: number;
......@@ -24,22 +27,10 @@ export function usePlanCounts(refreshToken: number) {
const load = useCallback(async () => {
setLoading(true);
try {
const [mine, mineAssigned, mineCompleted, pool, all] = await Promise.all([
plansApi.list({ view: 'mine', page: 1, pageSize: 1 }),
plansApi.list({ view: 'mine', status: 'assigned', page: 1, pageSize: 1 }),
plansApi.list({ view: 'mine', status: 'completed', page: 1, pageSize: 1 }),
plansApi.list({ view: 'pool', page: 1, pageSize: 1 }),
plansApi.list({ view: 'all', page: 1, pageSize: 1 }).catch(() => ({ total: 0 })),
]);
setCounts({
mine: mine.total,
mineAssigned: mineAssigned.total,
mineCompleted: mineCompleted.total,
pool: pool.total,
all: (all as { total: number }).total,
});
const data = await plansApi.counts();
setCounts(data);
} catch {
// 单个失败不影响整体,保持上次值
// 失败保持上次值,不打断 UI
} finally {
setLoading(false);
}
......
......@@ -4,3 +4,61 @@ import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* 天数可读性增强:"149" → "149 天(4 个月)";"427" → "427 天(1 年 2 个月)"。
* 只有跨过月/年阈值才显示括号(避免"5 天(0 个月)"这种废话)。
*
* 规则:
* < 30 天 → 仅"X 天"
* 30-364 天 → "X 天(N 个月)" — N = floor(days/30)
* ≥ 365 天 → "X 天(N 年 M 个月)";M=0 时简化为"X 天(N 年)"
*
* 公共 helper:reason / chain "X 天断口" / persona / fact timeline 都能用。
*/
export function formatDaysReadable(days: number | null | undefined): string {
if (days == null || days < 0) return '—';
const d = Math.floor(days);
if (d < 30) return `${d} 天`;
if (d < 365) {
const m = Math.floor(d / 30);
return `${d} 天(${m} 个月)`;
}
const y = Math.floor(d / 365);
const m = Math.floor((d % 365) / 30);
return m > 0 ? `${d} 天(${y}${m} 个月)` : `${d} 天(${y} 年)`;
}
/**
* 牙位显示:原文用 ; 分隔多颗,超过 5 颗显示前 5 + "等 N 颗"。
* 例:"14;15;16;17;47" → "14;15;16;17;47"(5 颗内,原样)
* "11;12;13;…23 颗" → "11;12;13;21;22 等 23 颗"
* 空 / null → "—"
*/
export function formatToothPosition(
raw: string | null | undefined,
maxShow = 5,
): string {
if (!raw) return '—';
const parts = raw
.split(';')
.map((s) => s.trim())
.filter(Boolean);
if (parts.length <= maxShow) return parts.join(';');
return `${parts.slice(0, maxShow).join(';')}${parts.length} 颗`;
}
/**
* 性别 canonical 值 → 中文显示 label。
*
* 后端存的是 canonical 值(理想为 M/F,但摄入侧"自由文本直通"可能漏出
* 男/女/male/female/1/2 等长尾),这里宽容兜底,任何识别不了的回退到 dash。
* 显示是 i18n 关注点,归前端 —— 接口只负责给 canonical 值。
*/
export function formatGender(raw: string | null | undefined): string {
if (!raw) return '—';
const g = raw.trim().toLowerCase();
if (['m', 'male', '男', '1'].includes(g)) return '男';
if (['f', 'female', '女', '2'].includes(g)) return '女';
return '—';
}
......@@ -17,6 +17,31 @@ services:
timeout: 5s
retries: 10
# 本地 DW 镜像 —— 把瑞尔 DW(dw_group.fact_*_out)全量复制到本地,
# cold-import 连本地即可,不依赖远程 DW 可用性。仅本地开发用(含真实 PII,勿外传)。
clickhouse:
image: clickhouse/clickhouse-server:24.3-alpine
container_name: pac-clickhouse
restart: unless-stopped
environment:
CLICKHOUSE_USER: default
CLICKHOUSE_PASSWORD: pac
CLICKHOUSE_DB: dw_group
ports:
- "8123:8123" # HTTP(PAC @clickhouse/client 连这个)
- "9100:9000" # native(避开常见 9000 占用;remote() 复制用)
ulimits:
nofile:
soft: 262144
hard: 262144
volumes:
- clickhouse_data:/var/lib/clickhouse
# 本地开发关闭 healthcheck —— alpine wget 走 localhost IPv6 失败
# ("Connection refused" 循环) → 每 5s spawn 失败 wget 持续占 1 核 CPU(实测 110%)。
# 生产用时改回:test: ["CMD-SHELL", "wget --spider -q http://127.0.0.1:8123/ping || exit 1"]
healthcheck:
disable: true
redis:
image: redis:7-alpine
container_name: pac-redis
......@@ -88,3 +113,4 @@ services:
volumes:
postgres_data:
redis_data:
clickhouse_data:
......@@ -186,6 +186,7 @@
},
"active": "5c26df11b9d3d65c",
"lastOpenFiles": [
"dw-data-source-issues.md",
"db-suggest-after-review.md",
"algorithm/potential-treatment-recall-flow.md",
"algorithm/potential-treatment-recall.md",
......@@ -214,7 +215,6 @@
"demo-page-prompt.md",
"db-design-v2.md",
"architecture.md",
"db-review-confirm.md",
"scenarios",
"db-review-confirm.pdf"
]
......
......@@ -754,6 +754,54 @@ WHERE p.active AND NOT pp.do_not_contact AND NOT pp.deceased
- 数仓常见坑:**只给中文 name(利于显示),没给可计算的语义 key**;即便有"码",也可能是每条记录的随机主键 hash(防重用),不是类别码。
- 影响:PAC 只能退到"中文 name → enum_mapping 白名单"+ W5+ LLM 兜底,白名单需持续维护、永远有长尾。
- 反馈方向:推动宿主在明细层补**稳定语义码**(诊断规范填 ICD;治疗归一到集团项目字典),从源头解决。
8. **写入是 upsert 当前态,还是 append 事件流?状态变更留不留痕?**(W3 末瑞尔 DW 预约表实战)
- 瑞尔 DW 预约表是**混合**:① 同一 `appointment_id` 的状态流转(预约→到诊→结算)是**就地 upsert**——只剩终态,中间态丢失;② **改约**则是**新建一条新 id + 把旧 id 标"已改约"**——改约历史以"已改约"行留痕。
- 影响:`created/arrived/cancelled` 等 in-place 流转,**cold-import 只看终态**(动作流要靠增量 pull 多次比对前后态,或 host push / append 事件日志);改约历史可见但**"已改约"≠"有效预约"**,务必映射成独立状态(rescheduled),否则虚高有效预约(瑞尔已改约占 44.7%)。
- 反馈方向:行为分析(爽约率/改约频率/漏斗)需 **append 事件流**;当前态表只够"看现状"。
9. **时间字段的粒度与拆分**(W3 末瑞尔预约时间实战)
- 坑:约定"日期"与"时分"可能分列存(瑞尔 `appo_time` 只到天 Date,真正时分在 `appo_datetime_str` "13:00:00-14:00:00" + `appo_time_period` 时长);只取日期会让同日多次改约"看着重复"。
- 处理:transforms 拼出精确 Timestamptz(日期 + 起始时分);区分"约定时刻"(scheduled)与"实际发生时刻"(arrived=签到 in_time),两者落 `plannedFor` / `occurredAt`
- 顺带:`appo_complaint_category`(种植/正畸/…)这类"预约科目"是**就诊意向强信号**,值得标准化进 fact,别只当展示字段丢掉。
10. **"已做治疗"的凭据表与牙位粒度**(W3 末潜在召回 SQL 审计)
- 坑:同一概念(治疗)在 host 有**两个口径**——"治疗计划"(`treat_plan`,含牙位,但≠已做)和"已结算收费"(`fact_settlement_out`,花钱才算做,但**按收费项记账,无牙位列**)。
- 影响:潜在治疗召回的"排除已做治疗"必须信结算(actual),而结算无牙位 → **牙位级排除做不了,只能 patient 级**(同 patient 做过同类治疗即排,会粗化:A 牙做过种植会挡掉 B 牙缺口)。
- 缓解:排除条件加**时间方向**(`treatment.occurred_at >= 诊断时间`),只排"诊断后才启动"的治疗,避免历史旧治疗误排新诊断(实测 K08 误伤 33%)。
- 反馈方向:推动 DW 在结算明细补 `tooth_position`,或用 `treat_plan.planCode` 关联结算行,才能升级到牙位级排除。
11. **时间窗别一杆当两用:入池边界 vs 优先级权重要分开**(W3 末同审计)
- 坑:把"黄金窗"(算优先级用,如缺牙 30-180 天)直接当**入池硬边界**(SQL `BETWEEN`),会把超上界的患者直接踢出池 —— 缺牙拖 1 年比拖 3 个月更该召,却因"超 180 天"漏掉(实测超窗 3 个全 >360 天,窗内仅 2 个)。
- 处理:入池上界放宽(如 730 天),黄金窗只喂给打分器算 `timeWindowFactor`(过晚衰减到 0.4);超窗患者照常入池、分数低、排后面。下界保留(刚诊断 <start 天还在考虑期,不急召)。
12. **fact 标准主体由 PAC 定,别被单一宿主牵着走 + 摄入断链体检**(W3 末审计 emr/影像)
- 原则:`fact.content` schema = PAC 自己定的临床通用语义全集(所有宿主的统一投影目标)。
某宿主映射不上某字段 → 留 null,**绝不因"这个 host 没有 / 拆走了"从标准删字段**
例:emr_record 必须保留 `diagnosis_text` / `treatment_plan`(小诊所病历常是自由文本),
哪怕瑞尔 DW 把诊断 / 治疗拆成了结构化数组(这俩字段对瑞尔留 null)。
- 判断依据 = **语义普适性(跨宿主通用),不是来源**:host 的设计若普适就大方采纳为标准
(如瑞尔预约 8 态多为通用就诊生命周期 scheduled/arrived/completed/cancelled/no_show/rescheduled,
配得上 PAC 标准);PAC 自拍的若不普适也要砍。来源是 host 还是 PAC 不重要。
- 真正的反模式:为贴合某 host 而**删通用字段**(如删 diagnosis_text)或**把 host 私有字段塞进标准**
正确是逐项问"是否跨宿主普适" —— 这是 PAC 团队(懂业务 + 临床)的持续判断活,非机械规则。
- **摄入断链体检**(三处任一断 → 主体静默变空壳,无报错):
① SQL 没 SELECT 该列;② yaml `field_mapping` 没映射(parser 读 canonical 名 → undefined → null);
③ assembler 没注册进 `manifest.assemblers`(整个主体不进 dispatcher)。
- 实测踩坑:emr_record 803 条自由文本**全 null**(②yaml 漏映射 + parser 字段名对不上);
image_record **0 条**(①file_url 无 transform + ③无 image.yaml 注册)。修复后 emr 文本全有、影像 756 条。
- 体检方法:逐 fact_type 抽查 `content` 各字段非空率;非空率为 0 的字段顺管道三处查断点。
13. **字段名 + 注释只能参考,真实含义靠数据推**(W3 末 billing_date 实战)
- 坑:host 字段名/comment 经常误导,顾名思义会选错字段。
- 实战:settlement 4 个时间字段 `rq` / `settlement_time` / `billing_date` / `created_date`,
字面看 `billing_date` 像"开单日"(以为偏行政),`rq` 像"日期"(以为主用)。PAC 起初选 rq → 治疗时间 vs 诊断时间口径不一致(rq 是结算操作日,可能延后 1-64 天)。
- 用数据推真实含义:
- billing_date **99.96%** 等于同 encounter 的 emr.rq(接诊日)→ **真实含义 = 治疗发生当天**
- 同 encounter 的多行 settlement 共享同一 billing_date(distinct=1)→ 一次接诊一次开单(治疗事件级)
- rq 跟 billing_date **97% 同天**,但偶有延后 → 是结算操作日
- 结论:PAC 治疗时间应该用 `billing_date`(跟诊断时间同口径 = 接诊日)。
- 推导方法(可复用):① 字段类型 + 取值范围 + null 率;② 同行多字段对比(rq vs billing_date 差异分布);
③ 跨表 join 验证语义(settlement.billing_date vs emr.rq 是否相等);④ 多行同 key 聚合(同 encounter 多行 distinct);
⑤ 拒绝从命名猜结论。
- 文档化:每个时间/枚举字段在 yaml field_mapping 注释处记录"数据推论 + 充实度 + 选择理由",
避免下任接手又被命名误导。
### 7.3 PAC 字典 vs 宿主码的关系
......@@ -795,6 +843,7 @@ WHERE p.active AND NOT pp.do_not_contact AND NOT pp.deceased
| **D16**(W3) | **enum_mapping `_default` 兜底键**:长尾值统一映射,parser 跳过 | ✅ |
| **D17**(W3) | **SQL 朴素化**:SQL 退化为"等价 csv 单表 dump",形态改造全归 transforms | ✅ |
| **D18**(W3) | **4 入口统一管道**:cold/DW/pull/push 共用 transforms + assembler + parser,dispatcher 接 `emitsResolver`(§十)| ✅ |
| **D19**(W3) | **fact 标准主体由 PAC 自己定,不被任何单一宿主形态裁剪** | ✅ |
---
......
# 治疗链 5 阶段判定模型
> **版本:** v1.0 · W3 末确立
> **状态:** ✅ 落地(chain-composer.service.ts + canonical-codes.ts TreatmentMilestones)
>
> **重要边界**:本文档讲的是**治疗链展示层**模型 — 给客服详情页看清每个 patient 的治疗链真实进度。
> **跟召回算法解耦**:treatment_initiation_recall scenario 排除口径不变(诊断后有任何同类 actual treatment → 排除),
> 那是召回 S1(发现机会)且未启动治疗的患者。两件事别混。
---
## 0. 5 阶段定义(对齐 design 文档 §0)
```mermaid
flowchart LR
S1["① 发现治疗机会<br/>影像 / EMR / 医嘱 / 收费"]
S2["② 进入治疗链<br/>治疗计划 / 收费单 / 收款 / 预约 / 接诊"]
S3["③ 治疗执行<br/>处置 / 用药 / EMR / 收费 / 退费 / 补费"]
S4["④ 术后与复查管理<br/>术后医嘱 / 复查计划 / 术后随访"]
S5["⑤ 治疗链闭环<br/>处置完成 + 复查完成 + 无风险信号"]
S1 --> S2 --> S3 --> S4 --> S5
```
---
## 1. 信号 → 阶段映射(PAC fact 来源)
| 阶段 | PAC fact 信号 | 实现位置 |
|---|---|---|
| **S1 发现机会** | `diagnosis_record`(K0x 命中 category)、`recommendation_record`(*_RECOMMENDED 命中) | `collectS1Facts` |
| **S2 进入治疗链** | ① `treatment_record(kind=planned)`(同 category)<br/>`payment_record`(大额,>= S2_PAYMENT_THRESHOLD_CENTS[category]) | `collectS2Facts` |
| **S3 治疗执行** | `treatment_record(kind=actual)`,subtype 关键词命中 `TreatmentMilestones[category].steps`,匹配数 >= `minSteps` | `matchMilestoneSteps` |
| **S4 术后复查** | ① review category 的 `treatment_record`<br/>② 同 category planned review<br/>③ S3 之后任意 `encounter_record`(复诊接诊)<br/>`recommendation_record` 含 'REVIEW' 关键词 | `collectS4Facts` |
| **S5 闭环** | S3 reached + S4 hit + lifecycle.maxStage=5 + 无 S3 后 refund + 无同位置反弹诊断 | inferChainStage 内 |
**故意排除的信号**(避免噪音):
- S2 不算 `appointment` / `encounter`(粒度太粗,任一回访都算"进入种植链"会错)
- S2 不算小额 payment(挂号费 58 元被算"进入种植链"会错)
- S5 不要求 `matchedSteps.allSatisfied`(preventive 字典 ['洁牙','涂氟','封闭'] 做一项即可闭环;
种植 minSteps=2 严格要求"植入+修复"都做)
---
## 2. 字典(canonical-codes.ts 单一真理源)
### TreatmentLifecycles — 决定能否闭环
| key | maxStage | expectedSpanMonths | 说明 |
|---|---|---|---|
| `one_shot` | 5 | 3 | 一次治疗即闭环(充填/拔除/美容) |
| `linear` | 5 | 6 | 多步线性(种植 2 步 / 根管 2 步) |
| `long_term` | 5 | 24 | 长周期(正畸) |
| `periodic` | 5 | 12 | 周期性(预防/年度洁牙) |
| `lifelong_maintenance` | **4** | — | **永不闭环**(牙周炎慢性病,做完一次仍需终身维护) |
### TreatmentMilestones — category × 关键步骤
| category | steps | minSteps | lifecycle |
|---|---|---|---|
| implant | 种植体植入 → 种植上部修复 | 2 | linear |
| endodontic | 开髓 → 根管充填 | 2 | linear |
| orthodontic | 矫治器 → 保持器 | 1 | long_term |
| periodontic | 全口洁治 / 龈下刮治 / 牙周维护 | 1 | lifelong_maintenance |
| restorative | 充填 / 嵌体 | 1 | one_shot |
| prosthodontic | 冠 / 桩核 / 修复 | 1 | one_shot |
| surgical | 拔除 / 拔牙 / 手术 | 1 | one_shot |
| preventive | 洁牙 / 涂氟 / 封闭 | 1 | periodic |
| cosmetic | 美白 / 贴面 | 1 | one_shot |
| pediatric | (无具体步骤,有 actual 即满足) | 1 | one_shot |
**匹配规则**:`treatment_record.content.subtype.includes(step)` 任一即算该步完成。
host subtype 文本含关键词即可(例 "种植体植入(进口)" includes "种植体植入" → 命中)。
---
## 3. 状态(ChainStatus)
5 阶段对应 4 个 status(uninitiated 已废弃合并入 discovered):
| status | currentStage | 含义 | UI 颜色 |
|---|---|---|---|
| `discovered` | 1 | 仅 S1 命中(诊断/推荐)— **召回算法关注的"潜在新链"** | rose ★ 发现机会 |
| `entered` | 2 | S2 命中(已挂号/预约/付款/有计划) | amber 已进入 |
| `ongoing` | 3 / 4 | S3 治疗中 或 S4 复查中 | sky 在管 |
| `closed` | 5 | 闭环 — lifelong_maintenance 永不到此 | emerald 已闭环 |
`target?: boolean` 标记 = `status IN ('discovered','entered')` — 召回算法关注度高的链。
---
## 4. 验证样本(W3 末 1000-cohort)
| 患者 | 链分布(简化) |
|---|---|
| **路遥** | discovered 种植修复·21;41(K08 73 天) / **ongoing** 牙周治疗·全口(lifelong_maintenance,3-11 洁治 + 5-19 复评) |
| 罗国标 | discovered 预防保健 / ongoing 根管/牙周/种植 / **closed** 外科+充填(one_shot) |
| 陆磊 | entered 牙周/种植(付款进入未启动) / closed 预防+外科 |
| 李忠林 | entered 牙周/根管 / ongoing 种植(植入未修复 — minSteps=2 未满) / closed 龋齿充填+外科 |
---
## 5. 关键设计决策
### D1:跟召回算法解耦
治疗链 5 阶段**纯展示用**。召回算法继续用"诊断后有任何同类 actual" 简单规则,不读 TreatmentMilestones。
未来若要做"治疗链内召回"(例:种植已植入未修复 stage=3 患者催完成),再写新 scenario 读字典。
### D2:S2 收紧 — 大额 payment 阈值
appointment / 小额 payment / encounter 不算 S2(避免挂号费 58 元被算成"进入种植链")。
阈值字典 `S2_PAYMENT_THRESHOLD_CENTS`(种植 ¥5000 / 充填 ¥300 / 等),后续可挪到 host config。
### D3:闭环不要求 milestone 全 steps 满足
preventive 字典 ['洁牙','涂氟','封闭'] 做一项即满 minSteps=1 → 可闭环。
种植 minSteps=2 严格要求"植入+修复"都做才可能 closed。
"全 steps 满足才能闭环" 太严,会导致几乎所有 multi-step category 永远 ongoing。
### D4:同 chainLabel 去重
K08 → primary='implant' 立 1 条链;患者做的"种植上部修复" actual.category='prosthodontic' 也立 1 条。
两条 chainLabel 都叫"种植修复·xx" — 临床上是同一条链。
compose() 末尾去重 pass:同 chainLabel 保留 stage 最高的那条。
### D5:牙周 lifelong_maintenance
牙周炎是慢性病,做完一次洁治仍需终身维护复查。**永远停留 stage=4 ongoing**,不到 closed。
UI 显示"维护期 · 上次维护 YYYY-MM-DD",客服可周期触达。
---
## 6. 待完善(后续迭代)
- **planned treatment 字典扩展**:目前路遥的 K08 EMR.treat_plan 里有"延期种植术"但
`treatment_planned.yaml` enum_mapping 未覆盖 → 该患者 implant planned 全部丢 → S2 严格判定下 stage=1 discovered
应补 implant 类 treatName 到字典。
- **risk signals 完整化**:目前 S5 检查 refund 和反弹诊断;`complaint_record` 字段已定义但 DW 暂无源,W5+ 接入后补
- **正畸 closed 精细化**:保持器交付 + 12 月内无新调整 才算 closed;目前简化为有 actual + S4 命中即可
- **Layer C LLM 抽影像/EMR finding** 补 S1 信号源 — W5+ 跟其他 LLM 任务一起做
# DW 数据源问题清单(给 DW 团队反馈)
> **背景**:PAC W3 末系统审计 5 张 CH 表(fact_client_out / fact_appointment_out / fact_emr_treatment_out / fact_settlement_out / fact_settlement_mode_out)与 PAC 标准主体的映射,发现以下需 DW 改进的问题。
> **目的**:不是 PAC 单方面诉求,而是数据源质量提升 — 双方达成共识后由 DW 团队评估实施排期。
---
## 🔴 P0 — 直接影响业务
### 1. fact_client_out 没 phone
- **现状**:无 phone / mobile / tel 字段
- **影响**:PAC 召回硬过滤要求 `phone IS NOT NULL`,DW 不给 → 当前用假手机号兜底(无法真实触达)
- **诉求**:补 phone(合规允许范围内)
### 2. 字典完整性问题(根本性)
覆盖多种表现:
- **id 缺人类可读 name**:doctor_id / user_id 等数字 id 各事件表不带 name 快照(只 emr 表 doctor_name 有)
- **value 没 key**:settlement_type / treatName / check_name / card_type_name 全是中文名,无稳定语义码 → PAC 只能维护中文白名单(98% 命中+长尾兜底)
- **枚举值无语义文档**:status (1/2/3) / is_first (0/1/2) / recomend_type / appo_status 各码含义无文档
- **看似有 code 实际不可用**:treat_plan.planCode 是 hash(每条独立,非类别码)
**诉求**:
1. 所有 id 字段在事件表带 name 快照(doctor_name 进 settlement/appointment 表)
2. 类别字段补稳定语义码(诊断填 ICD;治疗归集团项目字典;card 拆 VIP/医保/活动/团购 独立字段)
3. 枚举值含义文档化(status 1=? 2=? 3=?)
### 3. fact_client_out 是 ADS 聚合层(应为 DWD)
| 表 | 实际层级 | 评价 |
|---|---|---|
| fact_client_out(12.9万=患者数)| **ADS 聚合** ❌ | is_dental_loss / first_visit_time / customer_level / favor_doctor 一堆衍生 |
| fact_appointment_out / fact_emr_treatment_out / fact_settlement_out / fact_settlement_mode_out | **DWD 明细** ✅ | 一事件一行 |
- **影响**:PAC 不敢用聚合衍生字段(只取 name/gender/birthday/file_num 原子字段)
- **诉求**:fact_client_out 提供 DWD 版(只给身份原子字段,衍生聚合 PAC 自己算)
### 4. 写入方式:appointment 是混合 upsert + 改约新建,其他未明
- **appointment**:状态流转(scheduled→arrived→completed)是 in-place upsert(只看终态);改约 = 新建 + 老的标"已改约"(部分留痕)。**已改约占 44.7%** 已映射 rescheduled
- **emr / settlement / settlement_mode**:写入方式未明确(append 还是 upsert?)
- **影响**:cold-import 只能看到终态,无法监控 appointment_created/changed/cancelled/fulfilled 等动作事件;爽约率/改约频率/漏斗分析需事件流
- **诉求**:除患者外用 **append 事件流**(状态变更新增行而不是改原行),或提供独立"状态变更日志表"
---
## 🟡 P1 — 数据质量
### 5. settlement 在"按牙类"治疗项目上缺 tooth_position
- **现状**:settlement_out 无任何 tooth 字段(完整字段扫描确认);按牙治疗(种植/拔牙/根管/充填)的项目本应带牙位,实际只在 EMR 自由文本(emr.dispose 39% / treat_plan 46% 带 toothPosition)
- **临床合理**:洁牙/X光检查/全身检查等**全口/检查类项目本就无牙位**,不强求
- **影响**:PAC 牙位级排除做不了 — 36 牙做过种植会挡掉 21 牙缺口召回(只能 patient 级)
- **诉求**(收窄):**只对"按牙类"治疗项目** 在结算明细补 tooth_position(种植 36/拔牙 48 这种);全口类继续无牙位 OK
- **关联键不强求**(临床多对多本质,host 端也做不到 1:1 link;诊断/治疗/结算之间只能"同 encounter + 同 tooth + 名字模糊匹配")
### 6. fact_emr_treatment_out 子表炸行
- **现状**:file_url 子表 JOIN 无 groupArray,**193万 → 39.5万真实 EMR**(5 倍膨胀)
- **PAC 侧**:已用 dedup_by 兜底
- **诉求**:DW 修(groupArray)
### 7. plan 字段 vs treat_plan 语义未文档化
- **现状**:fact_emr_treatment_out 两个治疗计划字段,**65% 完全不同**(395656 行只 4 行相同)
- **影响**:PAC 暂不接 plan(避免重复/过时数据)
- **诉求**:DW 说明两字段语义区别
### 8. settlement 4 个时间字段语义需文档化(W3 末数据推论)
| 字段 | 实测语义 | PAC 选用 |
|---|---|---|
| `billing_date` | **99.96% = emr.rq(接诊日)= 治疗发生当天** | ⭐ PAC treatment.occurredAt / payment.paidAt 用这个 |
| `rq` / `settlement_time` | 结算操作日(97% 与 billing_date 同天,偶有延后 1-64 天) | 不用 |
| `created_date` | 记录创建时间 | 不用(元数据) |
- **教训**:字段名"billing_date"字面像"开单日"以为偏行政,实际是治疗发生时的开单 = 治疗日。PAC 起初误选 rq → 治疗时间 vs 诊断时间口径不一致。
- **诉求**:DW 文档化四个时间字段定义,推荐"治疗实际时间"用 billing_date
---
## 🟢 P2 — 业务覆盖缺口
### 9. 缺投诉 / 转介绍事实表
- **现状**:DW 没 complaint 表;转介绍只有 client_out.recommend_*(聚合,17% 充实)
- **影响**:PAC complaint_record / referral_record fact_type 已定但数据源缺
- **诉求**:DW 提供这两张明细表
### 10. 接诊结束时间缺失
- **现状**:in_time 有(接诊开始),无 out_time(结束)
- **影响**:无法算接诊时长、医生效率
### 11. 多 brand ID 重复(已知坑)
- patient_id / appointment_id 等跨 brand 重复
- PAC 用 brand → tenant_id 解决,接入新 brand 复用此模式
---
## 工程契约
### 12. updated_at 字段需保证(PAC 增量同步 cursor 基础)
- 每张表需有 updated_at 列单调递增刷新(每次写入/修改都更新)
- 这是 PAC 增量 pull 的 cursor 基础
- **诉求**:确认 DW 各表都有此列且 ETL 写入时刷新
### 13. ETL 频率需说明
- 表是 view 还是物理实表?ETL 频率(实时/小时/T+1)?
- 影响 PAC 增量 pull 节奏 + 数据新鲜度
- **诉求**:DW 说明各表 ETL 时延
---
## 沟通方法论(本次审计的收获)
1. **字段名 + 注释只能参考,真实含义靠数据推**(实战:billing_date 字面像"开单日",数据推实际是"治疗发生日")
2. **临床多对多是本质**:诊断/治疗/结算 1:1 强关联是不切实际的诉求(host 也做不到),PAC 接受隐式关联(同 encounter + 同 tooth + 名字模糊)
3. **不要让 host 形态裁剪 PAC 标准**:PAC fact 标准应基于临床通用语义,某 host 没的字段留 null,不删
4. **衍生 vs 事实**:host 给的聚合(is_dental_loss / customer_level / first_visit_time)PAC 不接,PAC 自己从事实算更可信(单一真理源)
### 14. 生产 host 数据混入测试账户
- **现状**:`fact_client_out``client_name='束乾凤test'` 测试账户,跨 18 个月(2024-09→2026-04)
累积 1531 条 `fact_settlement_out` 退费记录(净退费 ¥489.66)
- **影响**:PAC 100-cohort 把它纳入 → refund_record 99% 集中在 1 个测试账户,真实业务退费观察被掩盖
- **诉求**:DW 侧标记 / 过滤测试账户(如加 `is_test` 字段或专用 organization_id 隔离),否则
PAC 这边只能"按 client_name LIKE '%test%'" 拍黑名单,跨 host 不可移植
......@@ -6,9 +6,9 @@
```mermaid
flowchart LR
S1["① 发现治疗机会<br/>影像 / EMR / 医嘱 / 收费"]
S2["② 进入治疗链<br/>治疗计划形成 / 收费单提交 / 收款完成 / 预约计划形成 / 患者被接诊"]
S3["③ 治疗执行<br/>处置 / 用药 / EMR / 收费 / 退费 / 补费"]
S1["① 发现治疗机会<br/>影像 / EMR / 医嘱 / 收费"]
S2["② 进入治疗链<br/>治疗计划形成 / 收费单提交 / 收款完成 / 预约计划形成 / 患者被接诊"]
S3["③ 治疗执行<br/>处置 / 用药 / EMR / 收费 / 退费 / 补费"]
S4["④ 术后与复查管理<br/>术后医嘱 / 用药提醒 / 复查计划 / 术后随访"]
S5["⑤ 治疗链闭环<br/>处置完成 + 复查完成 + 无风险信号"]
......
......@@ -47,13 +47,19 @@ export const PACDiagnosisCodes = {
ENDO_FRACTURE: { nameZh: '根管牙折裂', business: true as const },
// ── 推荐码(EMR 文本抽取产物 / recommendation_record.content.code)──
IMPLANT_RECOMMENDED: { nameZh: '建议种植' },
CROWN_RECOMMENDED: { nameZh: '建议戴冠' },
FILLING_RECOMMENDED: { nameZh: '建议充填' },
SRP_RECOMMENDED: { nameZh: '建议牙周基础治疗' },
EXTRACTION_RECOMMENDED: { nameZh: '建议拔除' },
ANNUAL_REVIEW_RECOMMENDED: { nameZh: '建议年度复查' },
ORTHO_CONSULT_RECOMMENDED: { nameZh: '建议正畸咨询' },
// 跟 K0x scenario 一一对应,Layer C LLM 抽取上线后立即起效
IMPLANT_RECOMMENDED: { nameZh: '建议种植' }, // → K08 missing_tooth
CROWN_RECOMMENDED: { nameZh: '建议戴冠' }, // → K08 / K04 后续
FILLING_RECOMMENDED: { nameZh: '建议充填' }, // → K02 caries
SRP_RECOMMENDED: { nameZh: '建议牙周基础治疗' }, // → K05 perio
EXTRACTION_RECOMMENDED: { nameZh: '建议拔除' }, // → K01 impacted / K03 残根
ANNUAL_REVIEW_RECOMMENDED: { nameZh: '建议年度复查' }, // → post-treatment(K05/K08 复查)
ORTHO_CONSULT_RECOMMENDED: { nameZh: '建议正畸咨询' }, // → K07 ortho
RCT_RECOMMENDED: { nameZh: '建议根管治疗' }, // → K04 endo
HARD_TISSUE_REPAIR_RECOMMENDED: { nameZh: '建议牙体修复' }, // → K03 楔缺/缺损 充填或嵌体
GUM_TREATMENT_RECOMMENDED: { nameZh: '建议牙龈/牙槽嵴处置' }, // → K06 牙龈萎缩/增生/系带切除
ERUPTION_INTERVENTION_RECOMMENDED: { nameZh: '建议萌出干预' }, // → K00 乳牙滞留拔除 / 多生牙 / 萌出助萌
JAW_CYST_REMOVAL_RECOMMENDED: { nameZh: '建议囊肿摘除术' }, // → K09 颌骨囊肿
} as const;
export type PACDiagnosisCode = keyof typeof PACDiagnosisCodes;
export const PACDiagnosisCodeSchema = z.enum(
......@@ -92,6 +98,41 @@ export const PACTreatmentCategories = {
review: { nameZh: '复查 / 流程' },
} as const;
export type PACTreatmentCategory = keyof typeof PACTreatmentCategories;
/**
* 预约主诉类别 → PAC treatment category 映射(W3 末新加)
*
* 数据源:DW `fact_appointment_out.appo_complaint_category` 真实分布(8 大类 + 复合)
* 常规 (467k) · 正畸 (138k) · 种植 (64k) · 儿科 (31k) · 修复 (21k) ·
* 早矫 (13k) · 拔牙 (11k) · 牙周 (5.8k)
*
* 用途:chain-composer S2 判定 — 患者真"进入治疗链"的代理信号(医生侧 planned 不算,患者主动预约才算)
* 例:K05 牙周链需要 appointment.complaint_category 含"牙周" 才算 S2 命中
*
* "常规"映射 preventive — 临床上 = 检查/洁牙(预防),不算任何治疗链的"已进入"信号
* 复合值("常规,种植")host 用 "," 分隔,消费方 split 后逐个匹配
*/
export const APPT_COMPLAINT_TO_CATEGORY: Record<string, PACTreatmentCategory> = {
常规: 'preventive',
正畸: 'orthodontic',
早矫: 'orthodontic', // 儿童早期矫治
种植: 'implant',
儿科: 'pediatric',
修复: 'prosthodontic',
拔牙: 'surgical',
牙周: 'periodontic',
};
/// 把 host 预约 complaint_category 字符串("牙周" / "常规,种植" 复合)解析成 PAC category 集合
export function parseComplaintCategories(raw: string | null | undefined): PACTreatmentCategory[] {
if (!raw) return [];
const out = new Set<PACTreatmentCategory>();
for (const part of raw.split(/[,,]/).map((s) => s.trim()).filter(Boolean)) {
const cat = APPT_COMPLAINT_TO_CATEGORY[part];
if (cat) out.add(cat);
}
return [...out];
}
export const PACTreatmentCategorySchema = z.enum(
Object.keys(PACTreatmentCategories) as [
PACTreatmentCategory,
......@@ -111,29 +152,59 @@ export const PACTreatmentCategorySchema = z.enum(
// - plan/engine/scenarios/treatment-initiation-recall.scenario.ts(用 categories 作 excludeCats)
//
// key = 诊断码(K0x)或推荐码(*_RECOMMENDED);value:
// - categories : 满足任一即视为"已治疗"(消缺口 / 排除召回)
// - windowDays : 临床黄金窗(诊断后多少天内该启动治疗,超过即缺口/紧迫)
// - chainLabel : 漏治链 UI 显示名(chain-composer 用)
// - wholeMouth : 空牙位时显示"全口"(牙周类全口性疾病)否则"未标注牙位"
// - categories : 满足任一即视为"已治疗"(消缺口 / 排除召回)
// - cooldownDays : 入池下界 = 治疗考虑期(诊断后 < cooldownDays 不召回 / 不立链)
// - windowDays : 黄金窗上界(诊断后多少天内该启动治疗,超过即过窗,scorer 衰减)
// - urgencyDayThreshold : 临床紧迫临界(超过此天数额外加 urgencyBonus)
// - chainLabel : 漏治链 UI 显示名(chain-composer 用)
// - wholeMouth : 空牙位时显示"全口"(牙周类全口性疾病)否则"未标注牙位"
//
// 下游消费(单一真理源 — 不允许任一处再硬编码窗口/临界):
// - plan/engine/scenarios/treatment-initiation-recall.scenario.ts(入池下/上界 + scorer 用)
// - plan/engine/chain-composer.service.ts(cooldown 内不立"潜在新链",避免与 scenario 口径背离)
// - plan/engine/priority-scorer.ts(goldenRange / urgency 算分)
// =============================================================
export interface DxTreatmentRule {
categories: readonly PACTreatmentCategory[];
cooldownDays: number;
windowDays: number;
urgencyDayThreshold: number;
chainLabel: string;
wholeMouth?: boolean;
}
export const DiagnosisTreatmentMap = {
K02: { categories: ['restorative'], windowDays: 90, chainLabel: '龋齿充填' },
K04: { categories: ['endodontic'], windowDays: 60, chainLabel: '根管治疗' },
K05: { categories: ['periodontic'], windowDays: 120, chainLabel: '牙周治疗', wholeMouth: true },
K08: { categories: ['implant', 'prosthodontic'], windowDays: 180, chainLabel: '种植修复' },
IMPLANT_RECOMMENDED: { categories: ['implant'], windowDays: 180, chainLabel: '种植修复' },
CROWN_RECOMMENDED: { categories: ['prosthodontic'], windowDays: 90, chainLabel: '冠修复' },
FILLING_RECOMMENDED: { categories: ['restorative'], windowDays: 60, chainLabel: '龋齿充填' },
SRP_RECOMMENDED: { categories: ['periodontic'], windowDays: 120, chainLabel: '牙周治疗', wholeMouth: true },
EXTRACTION_RECOMMENDED: { categories: ['surgical'], windowDays: 30, chainLabel: '拔除' },
// 诊断码(K0x)— 跟同临床类目的推荐码(*_RECOMMENDED)窗口/临界严格一致
// W3 末扩 K00-K08 全覆盖(数据驱动:host DW 真实数据 244 万 EMR diag 频次扫描后落码,见 dw-data-source-issues #15)
// 不漏码、不改诊断码(K06 病种保留为 K06,不归 K05);base 分按临床召回价值分级(K07 正畸 55 高 / K00 萌出 25 低)
K00: { categories: ['surgical', 'prosthodontic', 'implant', 'orthodontic'], cooldownDays: 30, windowDays: 365, urgencyDayThreshold: 180, chainLabel: '先天/萌出处置' },
K01: { categories: ['surgical'], cooldownDays: 14, windowDays: 180, urgencyDayThreshold: 90, chainLabel: '阻生牙拔除' },
K02: { categories: ['restorative'], cooldownDays: 14, windowDays: 90, urgencyDayThreshold: 60, chainLabel: '龋齿充填' },
K03: { categories: ['restorative', 'prosthodontic', 'surgical'], cooldownDays: 14, windowDays: 90, urgencyDayThreshold: 60, chainLabel: '牙体修复' },
K04: { categories: ['endodontic'], cooldownDays: 14, windowDays: 60, urgencyDayThreshold: 45, chainLabel: '根管治疗' },
// K05 categories=['periodontic'] 严格(W3 末撤回 C 修复)
// 之前曾加 'preventive' 想让做过洁牙的患者不被召回,但临床上洁牙(¥260-350 预防) ≠ 龈上洁治术(¥600+ SRP)
// host 数据已正确区分两者:subtype/category 字段不同。王辉就是"医生让做 SRP 但只做洁牙"典型患者 —
// 应该被召回去促进真 SRP,而不是被算"已启动"放过。chain-composer 显示 S3 未进行也支持这判定
K05: { categories: ['periodontic'], cooldownDays: 30, windowDays: 120, urgencyDayThreshold: 90, chainLabel: '牙周治疗', wholeMouth: true },
K06: { categories: ['periodontic', 'surgical'], cooldownDays: 14, windowDays: 120, urgencyDayThreshold: 60, chainLabel: '牙龈/牙槽嵴处置' },
K07: { categories: ['orthodontic'], cooldownDays: 30, windowDays: 365, urgencyDayThreshold: 180, chainLabel: '正畸矫治' },
K08: { categories: ['implant', 'prosthodontic'], cooldownDays: 30, windowDays: 180, urgencyDayThreshold: 120, chainLabel: '种植修复' },
K09: { categories: ['surgical'], cooldownDays: 14, windowDays: 90, urgencyDayThreshold: 60, chainLabel: '颌骨囊肿摘除' },
// 推荐码 — 同临床类目继承 K0x 配置
IMPLANT_RECOMMENDED: { categories: ['implant'], cooldownDays: 30, windowDays: 180, urgencyDayThreshold: 120, chainLabel: '种植修复' },
CROWN_RECOMMENDED: { categories: ['prosthodontic'], cooldownDays: 14, windowDays: 90, urgencyDayThreshold: 60, chainLabel: '冠修复' },
FILLING_RECOMMENDED: { categories: ['restorative'], cooldownDays: 14, windowDays: 60, urgencyDayThreshold: 45, chainLabel: '龋齿充填' },
SRP_RECOMMENDED: { categories: ['periodontic'], cooldownDays: 30, windowDays: 120, urgencyDayThreshold: 90, chainLabel: '牙周治疗', wholeMouth: true },
EXTRACTION_RECOMMENDED: { categories: ['surgical'], cooldownDays: 7, windowDays: 30, urgencyDayThreshold: 14, chainLabel: '拔除' },
ORTHO_CONSULT_RECOMMENDED: { categories: ['orthodontic'], cooldownDays: 30, windowDays: 365, urgencyDayThreshold: 180, chainLabel: '正畸矫治' },
ANNUAL_REVIEW_RECOMMENDED: { categories: ['periodontic'], cooldownDays: 60, windowDays: 540, urgencyDayThreshold: 365, chainLabel: '年度复查' },
RCT_RECOMMENDED: { categories: ['endodontic'], cooldownDays: 14, windowDays: 60, urgencyDayThreshold: 45, chainLabel: '根管治疗' },
HARD_TISSUE_REPAIR_RECOMMENDED: { categories: ['restorative', 'prosthodontic', 'surgical'], cooldownDays: 14, windowDays: 90, urgencyDayThreshold: 60, chainLabel: '牙体修复' },
GUM_TREATMENT_RECOMMENDED: { categories: ['periodontic', 'surgical'], cooldownDays: 14, windowDays: 120, urgencyDayThreshold: 60, chainLabel: '牙龈/牙槽嵴处置' },
ERUPTION_INTERVENTION_RECOMMENDED: { categories: ['surgical', 'orthodontic', 'prosthodontic', 'implant'], cooldownDays: 30, windowDays: 365, urgencyDayThreshold: 180, chainLabel: '先天/萌出处置' },
JAW_CYST_REMOVAL_RECOMMENDED: { categories: ['surgical'], cooldownDays: 14, windowDays: 90, urgencyDayThreshold: 60, chainLabel: '颌骨囊肿摘除' },
} as const satisfies Record<string, DxTreatmentRule>;
export type DiagnosisTreatmentCode = keyof typeof DiagnosisTreatmentMap;
......@@ -163,6 +234,94 @@ export const PACTreatmentStatusSchema = z.enum(
);
// =============================================================
// 治疗链生命周期模型(TreatmentLifecycle + TreatmentMilestones)
// =============================================================
//
// 用途:chain-composer 的 5 阶段引擎读它判定 (patient × category) 当前 stage 和 closed/ongoing。
//
// **跟召回算法解耦**:scenario SQL 排除口径不读本字典(召回的是"未启动治疗"的患者,
// 按现有"诊断后无任何同类 actual"判定即可)。本字典纯展示用,让客服在详情页看清治疗链
// 真实进度。如果未来上"治疗链内召回"(种植已植入未修复等)再让那个新 scenario 读本字典。
//
// 5 阶段(对齐 design 文档 §0):
// ① 发现治疗机会 = diagnosis / recommendation / image / EMR finding
// ② 进入治疗链 = planned treatment / appointment / payment / 发现后接诊
// ③ 治疗执行 = actual treatment(命中 milestone steps 数 >= minSteps)
// ④ 术后与复查管理 = post-S3 review encounter / planned review / *_REVIEW_RECOMMENDED
// ⑤ 治疗链闭环 = S3 全 milestone 满足 + S4 命中 + 无 refund 后置 + 无反弹诊断 + lifecycle 允许闭环
//
// **closed 条件比"做过 actual"严得多** — 路遥牙周(lifelong_maintenance)永远停在 stage=4。
// =============================================================
/// 治疗链生命周期类型 — 决定能否闭环 + 周期长度参考
export interface TreatmentLifecycle {
/// 该类型能到的最高 stage(lifelong_maintenance 卡 4,永不 closed)
maxStage: 4 | 5;
/// 预期治疗周期(月)— S4→S5 判定时"X 个月内无新调整 → closed"用
expectedSpanMonths?: number;
/// 给 UI 提示 + 业务方调字典时用
noteZh: string;
}
export const TreatmentLifecycles = {
/// 一次治疗 + 一次复查即可闭环(充填 / 修复 / 拔除 / 美容 / 儿牙)
one_shot: { maxStage: 5, expectedSpanMonths: 3, noteZh: '一次性治疗' },
/// 多步治疗 + 复查即可闭环(种植 / 根管)
linear: { maxStage: 5, expectedSpanMonths: 6, noteZh: '多步线性治疗' },
/// 长周期治疗(正畸 2 年);S3 后 12+ 月平稳无新调整 → closed
long_term: { maxStage: 5, expectedSpanMonths: 24, noteZh: '长周期治疗' },
/// 周期性治疗(预防/年度洁牙),每年 1 次为常态
periodic: { maxStage: 5, expectedSpanMonths: 12, noteZh: '周期性治疗' },
/// 终身维护型(牙周炎慢性病)— 永远停留 stage=4,做完一次维护仍需周期复查
lifelong_maintenance: { maxStage: 4, noteZh: '终身维护(永不闭环)' },
} as const satisfies Record<string, TreatmentLifecycle>;
export type TreatmentLifecycleKey = keyof typeof TreatmentLifecycles;
/// 治疗里程碑 — 每个 PAC category 的关键 actual subtype 步骤
///
/// 匹配规则:`treatment_record.content.subtype.includes(step)` 任一即算该步完成。
/// host subtype 文本含关键词即可(例 "种植体植入(进口)" includes "种植体植入" → 命中)。
///
/// minSteps = 满足 stage=3 ongoing 的最少步骤数;到 minSteps 才算"治疗执行进入正轨"。
/// 注:种植/根管 minSteps=2 — 单做植入未做上部修复 = stage=3 ongoing(在管),非闭环。
export interface TreatmentMilestone {
/// 期望步骤的 subtype 关键词(按时间序);UI 显示 chain timeline 节点 label 也用它
steps: readonly string[];
/// 达到 stage=3 ongoing 的最少完成步骤数(closed 需要全 steps 满足)
minSteps: number;
/// 生命周期类型 → 查 TreatmentLifecycles 拿 maxStage / expectedSpanMonths
lifecycle: TreatmentLifecycleKey;
}
export const TreatmentMilestones = {
// implant 字典加"种植牙冠修复" / "种植冠修复" 同义词 — host 实际写法,字典缺会让做完种植牙的患者
// 仍卡 stage=3 ongoing 误判为"未完成"(罗国标种植已植入+上部修复都做,显示 entered 是错的)
implant: { steps: ['种植体植入', '种植上部修复', '种植牙冠修复', '种植冠修复'], minSteps: 2, lifecycle: 'linear' },
endodontic: { steps: ['开髓', '根管充填'], minSteps: 2, lifecycle: 'linear' },
orthodontic: { steps: ['矫治器', '保持器'], minSteps: 1, lifecycle: 'long_term' },
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' },
} as const satisfies Partial<Record<PACTreatmentCategory, TreatmentMilestone>>;
/// 查表(category 未定义 milestone → undefined,chain-composer 默认 minSteps=1 one_shot)
export function lookupTreatmentMilestone(
category: string,
): TreatmentMilestone | undefined {
return (TreatmentMilestones as Record<string, TreatmentMilestone>)[category];
}
export function lookupTreatmentLifecycle(
key: TreatmentLifecycleKey,
): TreatmentLifecycle {
return TreatmentLifecycles[key];
}
// =============================================================
// 牙位(FDIToothPosition)— FDI 国际标准 11-48(恒牙)+ 51-85(乳牙)
// =============================================================
......@@ -239,3 +398,71 @@ export function isFDIToothPosition(v: unknown): v is FDIToothPosition {
(FDI_TOOTH_POSITIONS as readonly string[]).includes(v)
);
}
// =============================================================
// 工具:code → 中文名(单一真理源,前后端共用)
// - 后端拼"自然语言句子"(plan reason / persona description)时用,根上不漏 enum code
// - 前端展示"字段型 enum"(category / status / code 列)时也用,不必接口冗余塞 xxName
// - 查不到回退原值(防漂移:新码忘登记时至少显示 code,不报错)
// =============================================================
/** 诊断码 / 推荐码 / 业务码 → 中文名(K08 → 牙列丢失 / 缺牙) */
export function diagnosisCodeNameZh(code: string): string {
return (
(PACDiagnosisCodes as Record<string, { nameZh: string }>)[code]?.nameZh ??
code
);
}
/** 治疗类别 → 中文名(implant → 种植) */
export function treatmentCategoryNameZh(category: string): string {
return (
(PACTreatmentCategories as Record<string, { nameZh: string }>)[category]
?.nameZh ?? category
);
}
// =============================================================
// scenario 子规则 / 触发类型 → 中文标签(前后端共用)
// =============================================================
/**
* scenario × sub_key 复合 key → 子规则中文标签。
* 跟 scenario plugin 内 cfg.label 同一份语义,提取到这里作单一源,
* 前端用 subLabelZh(scenarioKey, subKey) 翻译,scenario 内 cfg 改成 reference。
*/
export const PACScenarioSubLabels: Record<string, string> = {
// treatment_initiation_recall 9 子规则(W3 末 K00-K09 全覆盖)— 跟 scenario plugin SUB_SCENARIOS.label 一一对齐
'treatment_initiation_recall.missing_tooth': '缺失牙未启动修复', // K08
'treatment_initiation_recall.ortho_no_consult': '错颌畸形未启动正畸', // K07
'treatment_initiation_recall.endo_no_rct': '牙髓炎 / 根尖周炎未做根管', // K04
'treatment_initiation_recall.perio_no_srp': '牙周炎未做基础治疗', // K05
'treatment_initiation_recall.caries_no_filling': '龋齿未做充填', // K02
'treatment_initiation_recall.hard_tissue_damage': '牙体损伤未修复', // K03
'treatment_initiation_recall.gum_alveolar_lesion': '牙龈 / 牙槽嵴疾患未处置', // K06
'treatment_initiation_recall.impacted_tooth': '阻生牙未拔除', // K01
'treatment_initiation_recall.jaw_cyst': '颌骨囊肿未处理', // K09
'treatment_initiation_recall.development_eruption': '牙发育 / 萌出异常未处置', // K00
};
export function subLabelZh(
scenarioKey: string,
subKey: string | null | undefined,
): string {
if (!subKey) return scenarioKey;
return PACScenarioSubLabels[`${scenarioKey}.${subKey}`] ?? subKey;
}
/** 触发信号类型 → 中文标签(用于"诊断 / 医生建议 / 影像所见 / ..." 显示)*/
export const PACTriggerTypeLabels: Record<string, string> = {
diagnosis: '诊断',
recommendation: '医生建议',
image_finding: '影像所见',
emr_signal: '病历信号',
visit_gap: '到诊间隔',
other: '其他',
};
export function triggerTypeLabelZh(type: string): string {
return PACTriggerTypeLabels[type] ?? type;
}
......@@ -48,6 +48,8 @@ export const PatientCanonicalSchema = z
phone: z.string().optional().nullable(),
gender: z.string().optional().nullable(), // free text per §3.4.1
birthDate: optionalDate,
/// 病历号(host 病历主键,如 "BA43016");跟 externalId 互补 — 客服沟通用。
medicalRecordNumber: z.string().optional().nullable(),
preferences: z.record(z.string(), z.unknown()).optional().nullable(),
/// 'active' / 'archived'(v2:'merged' 已删) — host raw 值,PAC 入库时映射成 active boolean
status: z.string().optional().default('active'),
......@@ -79,9 +81,21 @@ export const AppointmentCanonicalSchema = z
patientExternalId: z.string().min(1),
clinicId: z.string().min(1),
scheduledAt: isoDateTime,
status: z.enum(['scheduled', 'arrived', 'cancelled', 'no_show']),
// 8 态细分(跟 fact-content AppointmentRecordContent.status / jvs-dw enum_mapping 对齐)
status: z.enum([
'scheduled',
'rescheduled',
'cancelled',
'arrived',
'in_treatment',
'completed',
'no_show',
'walk_in',
]),
appointmentType: z.string().optional().nullable(),
doctorId: z.string().optional().nullable(),
complaintCategory: z.string().optional().nullable(),
durationMinutes: z.coerce.number().optional().nullable(),
arrivedAt: optionalIsoDateTime,
createdAt: optionalIsoDateTime,
updatedAt: optionalIsoDateTime,
......@@ -374,19 +388,25 @@ export type ComplaintCanonical = z.infer<typeof ComplaintCanonicalSchema>;
/// 转介绍记录(老客带新)
/// 产品收集
/// referral_record:fact 挂在**被推荐人**(referee = patientExternalId),
/// 推荐人(referrer)信息进 content 快照(PAC 不建推荐人主数据表,
/// referrer 可能是别 host 患者 / 员工,放 content 更通用)。
export const ReferralCanonicalSchema = z
.object({
externalId: z.string().min(1),
/// 推荐人(老客)— 关联 patient
referrerPatientExternalId: z.string().min(1),
/// 被推荐人(新客)— 关联 patient(如该新客已建档),否则 nullable
refereePatientExternalId: z.string().optional().nullable(),
clinicId: z.string().min(1),
recordedAt: isoDateTime,
/// 被推荐人(referee)= fact 挂的 patient
patientExternalId: z.string().optional().nullable(),
/// clinic 可空:referral 来自 patient 主档时无明确 clinic
clinicId: z.string().optional().nullable(),
recordedAt: optionalIsoDateTime,
/// 推荐人(referrer)信息 — 快照,不依赖 referrer 是否在 PAC patient 库
referrerExternalId: z.string().optional().nullable(),
referrerName: z.string().optional().nullable(),
referrerChartNumber: z.string().optional().nullable(),
/// 推荐人类型:patient(老带新)/ staff(员工)/ partner / other
referrerType: z.string().optional().nullable(),
/// 渠道:wechat / face_to_face / event / ...(host 自定义)
channel: z.string().optional().nullable(),
/// 转化金额(被推荐人首单消费,cents);未转化为 0 / null
conversionAmount: coerceIntOptional,
})
.passthrough();
export type ReferralCanonical = z.infer<typeof ReferralCanonicalSchema>;
......
......@@ -9,3 +9,4 @@ export * from './agent';
export * from './sync';
export * from './admin';
export * from './canonical';
export * from './reason-signals';
......@@ -10,6 +10,7 @@ import {
import { PagingResponseSchema } from './common';
import { PatientSchema } from './patient';
import { PersonaSchema } from './persona';
import { ReasonSignalsSchema } from './reason-signals';
// =============================================================
// Layer 3 — FollowupPlan
......@@ -126,14 +127,20 @@ export const ListPlansQuerySchema = z.object({
assigneeUserId: z.string().optional(),
view: z.enum(['pool', 'mine', 'all']).optional()
.describe('"pool" = active+unassigned; "mine" = assigned to caller; "all" = no extra filter'),
/// 关键字搜索:服务端按 patient.name / phone / externalId 模糊匹配(W3 末加,替代前端本页 filter)
keyword: z.string().trim().min(1).optional(),
/// 排序:服务端 ORDER BY(W3 末加,替代前端 .sort)
sort: z.enum(['priority_desc', 'priority_asc', 'created_desc']).default('priority_desc'),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
export type ListPlansQuery = z.infer<typeof ListPlansQuerySchema>;
/// 列表行用的患者简介(已脱敏)— 避免列表每行再发请求
/// 列表行用的患者简介 — 避免列表每行再发请求。
/// name 是原值(权限内客服可见),nameMasked 仅作脱敏展示场景备用。
export const PlanPatientBriefSchema = z.object({
externalId: z.string(),
name: z.string().nullable(),
nameMasked: z.string().nullable(),
phoneMasked: z.string().nullable(),
gender: z.string().nullable(),
......@@ -141,10 +148,24 @@ export const PlanPatientBriefSchema = z.object({
});
export type PlanPatientBrief = z.infer<typeof PlanPatientBriefSchema>;
/// 列表行 = FollowupPlan + patient brief + 中文 scenarioLabel(后端预翻译,前端零计算)
/// 列表行用的 reason 简介 — 富文本由前端 ReasonLine 用 signals 组装。
/// 跟 plan-aggregate 详情页 reasons 同构(字段子集),保证前端可共享 ReasonLine 组件。
export const PlanReasonBriefSchema = z.object({
id: z.string().uuid(),
scenario: z.string(),
subKey: z.string().nullable(),
signals: ReasonSignalsSchema.nullable(),
priorityScore: z.number(),
/// 老数据 / signals 缺失时的兜底自然语言
reason: z.string(),
});
export type PlanReasonBrief = z.infer<typeof PlanReasonBriefSchema>;
/// 列表行 = FollowupPlan + patient brief + reasons brief + 中文 scenarioLabel(后端预翻译,前端零计算)
export const PlanListItemSchema = FollowupPlanSchema.extend({
patient: PlanPatientBriefSchema,
scenarioLabel: z.string(),
reasons: z.array(PlanReasonBriefSchema),
});
export type PlanListItem = z.infer<typeof PlanListItemSchema>;
......@@ -153,6 +174,16 @@ export const ListPlansResponseSchema = z
.extend(PagingResponseSchema.shape);
export type ListPlansResponse = z.infer<typeof ListPlansResponseSchema>;
/// KPI / Tab badge 用的聚合计数 — 后端一次 SQL 给齐,避免列表页 5 个并发 list 请求。
export const PlanCountsResponseSchema = z.object({
mine: z.number().int(),
mineAssigned: z.number().int(),
mineCompleted: z.number().int(),
pool: z.number().int(),
all: z.number().int(),
});
export type PlanCountsResponse = z.infer<typeof PlanCountsResponseSchema>;
export const PlanDetailResponseSchema = z.object({
plan: FollowupPlanSchema,
patient: PatientSchema,
......
import { z } from 'zod';
/**
* ReasonSignals — plan_reasons.signals JSON 字段的强校验 schema。
*
* 设计原则(W3 末):
* - DB 存 **原始 enum / canonical code**,不存中文(name_zh 翻译归前端 i18n)
* - 字段命名 PAC 标准 camelCase,跨 scenario 通用
* - triggers 是**数组**(留多信号源融合余地;当前 scenario 单源 → 长度 1)
* - factId 不存这里 — 走 plan_reasons.evidence JSON(`{ factIds[] }`,跟 PersonaFeature.evidence 同形),避免重复
*
* 前端用 PACScenarioSubLabels / PACTriggerTypeLabels / diagnosisCodeNameZh /
* treatmentCategoryNameZh 翻译展示。
*/
export const ReasonSignalsSchema = z.object({
/// 子规则 key(对齐 scenario 内 subKey:missing_tooth / caries_no_filling / perio_no_srp / endo_no_rct …)
subKey: z.string().min(1),
/// 触发信号数组(多信号源融合留余地)
/// 当前 scenario 单源命中 → 长度 1;未来 K08 + IMPLANT_RECOMMENDED + CBCT 三源合并 → 长度 3
triggers: z
.array(
z.object({
/// 触发类型 enum:diagnosis / recommendation / image_finding / emr_signal / visit_gap / ...
/// 留 z.string() 不强 enum,未来扩展不动 schema(canonical labels 字典翻译)
type: z.string().min(1),
/// 触发码(PAC canonical):K08 / IMPLANT_RECOMMENDED 等;visit_gap 等无 code 场景为 null
code: z.string().nullable().optional(),
}),
)
.min(1),
/// 牙位原文(可空 — 全口治疗 / 沉睡客户激活 等场景无牙位)
toothPosition: z.string().nullable().optional(),
/// 紧迫维度:触发信号距今多少天(跨场景通用)
daysSince: z.number().int().nonnegative(),
/// 期望待启动类别(raw PAC treatment category enum;前端用 treatmentCategoryNameZh 翻译)
/// 不同场景含义:
/// - 召回类:期望启动哪类治疗(种植 / 充填 / 牙周基础 / 根管)
/// - 沉睡激活:可能是 [recall_visit] 等
expectedCategories: z.array(z.string()),
});
export type ReasonSignals = z.infer<typeof ReasonSignalsSchema>;
......@@ -57,3 +57,11 @@ export function fmtYearMonth(d: Date | string): string {
const target = typeof d === 'string' ? new Date(d) : d;
return `${target.getFullYear()}.${String(target.getMonth() + 1).padStart(2, '0')}`;
}
/**
* `YYYY.MM.DD`(year + month + day,治疗链节点 / fact 时间轴精确日期)
*/
export function fmtYearMonthDay(d: Date | string): string {
const target = typeof d === 'string' ? new Date(d) : d;
return `${target.getFullYear()}.${String(target.getMonth() + 1).padStart(2, '0')}.${String(target.getDate()).padStart(2, '0')}`;
}
......@@ -48,7 +48,7 @@ export function verifySecret(secret: string, stored: string): boolean {
}
// 格式化 / 掩码 / 相对时间 — 浏览器 + Node 双端可用
export { maskPhone, maskName, fmtRel, fmtDate, fmtYearMonth } from './format';
export { maskPhone, maskName, fmtRel, fmtDate, fmtYearMonth, fmtYearMonthDay } from './format';
export function chunk<T>(arr: T[], size: number): T[][] {
if (size <= 0) return [arr];
......
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