Commit 2111418b by luoqi

merge: feat/contraindication-model → main(禁忌能力:病历抽临床信号 + 三档裁决 + 四类圈人 + FRIDAY push 索引)

parents 887069e5 89dac080
# ═══════════════════════════════════════════════════════════
# 共享字典 · 临床信号抽取(既往史 / 诊断原文 → 结构化 clinical_signals)
#
# 【临床通用层】「口服抗凝药 = 出血风险」「颌骨放疗 = 骨坏死风险」这类判断是医学知识,
# 不随宿主变 —— 与 diagnosis-kcode.yaml / recommendation-code.yaml 同层同治理纪律:
# - 改本文件影响【所有】宿主 → 改完对受影响宿主重衍生,复用既有 reparse(不另造 CLI):
# pnpm reparse -- --host=jvs-dw --subject-type=emr,diagnosis --dry-run
# (reparse 拿 transaction.rawPayload 离线重跑装配+解析,不连 DW、不碰账本;
# 信号抽取挂在 ParserPipeline 上,自然跟着重跑)
# - 新词归层判断:标准临床术语 / 药品通用名 → 这里;某宿主的模板痕迹 → 该宿主 yaml(暂无);
# - 只收【确定性可判】的类别。需要定量值或状态判断的一律不收,见文末「刻意不收」。
#
# 消费方:ClinicalSignalExtractor(modules/clinical-signals)。抽取结果写回**源 fact 自己的**
# content.clinical_signals —— 遵守 FactType 的设计纪律「fact 只装源事件,衍生信息留在源
# fact 的 content JSONB 里」,不新建衍生 fact 类型。
#
# ⭐ valid_until 在**抽取时**就按 ttl 算好写进 signal。时效规则只活在本文件一处:
# 画像(TS)和召回闸门(SQL)各自只做 `valid_until IS NULL OR valid_until > now` 这一个比较,
# 逻辑不会分叉。(教训:⑤g 未来回访闸就是靠"判断下沉到数据"才没分叉。)
# ═══════════════════════════════════════════════════════════
version: clinical-signals@2026-07-27 # 抽取器版本号,写进 signal.extracted_by,便于按版本重扫
# ─────────────────────────────────────────────────────────
# 一、扫哪些 fact 的哪些 content 字段
#
# 这里是 **PAC 标准化后的 fact 字段**,不是宿主原始列 —— 宿主怎么映射是 assembler 的活,
# 到这一层已经是统一语义(emr_record 的 SOAP 字段全集见 fact-content-schemas.ts)。
# 加字段 = 改本段一行,抽取器不动。
# ─────────────────────────────────────────────────────────
scan_fields:
- fact_type: emr_record
fields:
- past_history # 既往史 —— 主力(生产 88.9 万条,覆盖 34 万患者)
- general_condition # 全身情况 / 系统回顾
- pre_illness # 现病史(妊娠/哺乳常写在这)
- doctor_advice # 医嘱(「术前停抗凝药」这类)
# ⚠️ 刻意不扫 illness_desc(主诉)/ exam_findings(检查所见)/ disposal(处置):
# 主诉是患者口述的牙齿问题,检查所见是口内所见,处置是本次操作 —— 都不承载全身病史。
# 扫了只会引入噪音(「无痛」含"无"、"痛"等),而不会提高召准。
- fact_type: diagnosis_record
fields:
- name_zh # 诊断原文 —— 严重度只在文本里(生产 severity 列 296 万条 100% null)
# ─────────────────────────────────────────────────────────
# 二、否定判定 —— 本字典最要紧的一段
#
# 中文病历既往史 97% 是否定式(生产实测 top15 高频值里 14 个是否定或新冠筛查模板)。
# naive 含词匹配会把「否认药物过敏」标成麻醉禁忌 —— 那是医疗事故,不是 bug。
#
# 【作用域规则】否定词的作用域 = 从它自己到**句末**,其中:
# - 断句符 sentence_delimiters(。!?换行)才切断作用域
# - 并列符(;;、,)**不切断** —— 这是关键:
# 「患者否认有高血压、冠心病等心血管疾病;糖尿病、痛风等代谢性疾病。」
# ↑否认 ↑糖尿病在分号后,仍在否定作用域内
# 生产实测:按分号切 → 糖尿病阳性 1,328 人;按句切 → 141 人。差 9.4 倍,
# 而正确答案是后者。这一条错了,整个禁忌能力就是负资产。
#
# 【再断言】否定词之后若出现 reassert_markers(有/患有/服用/术后…)且更贴近关键词,
# 翻回阳性 —— 覆盖「否认系统病史,有高血压」这类先否后肯的写法。
#
# 【不可判】同句同时出现否定词和「/」模板残留(「患者否认/自述有…」,生产 116 条)→
# negated=null。这不是 NLP 能力问题,是**源数据本身没有结论**:医生没删模板里那个斜杠。
# 不可判信号照常写进 fact(可查、可人工复核),但**不产禁忌**。PAC 不替宿主兜底。
# ─────────────────────────────────────────────────────────
negation:
sentence_delimiters: ['。', '.', '!', '!', '?', '?', "\n"]
markers: [否认, , , 不详, , 排除, 阴性, 未见, 未诉, 否定]
reassert: [, 患有, , 自述有, 主诉, 服用, 口服, 长期服, 术后, , ]
# 模板未删干净 → 不可判(negated=null)。命中即整句所有信号都标不可判。
undecidable_patterns: ['否认/自述', '自述/否认', '否认/有', '有/否认', '否认或']
# ─────────────────────────────────────────────────────────
# 三、信号词典
#
# severity 三档 —— 决定下游怎么用,是本设计的收束点:
# absolute 绝对禁忌 → 打禁忌标签;将来接召回闸门就是挡这一档
# relative 相对禁忌 → 打标签 + 话术警示,**不挡召回**(能不能做由医生按身体条件评估)
# check 核查项 → 只提示客服「预约前确认X」,不叫禁忌
# ↑ 定量条件无数据的一律落这档(见文末),不硬判成禁忌
#
# contraindicates 用治疗域而非 PAC 治疗 category —— 一个抗凝药同时禁种植/拔牙/牙周手术,
# 按病因建模、按治疗域映射,才不会四份规则互相打架。
# 域:implant 种植 / surgery 外科(拔牙·翻瓣·植骨)/ anesthesia 局麻 / orthodontic 正畸 / xray 拍片
#
# ttl —— 你的行业洞察落地:「禁忌≠终身禁忌」。三种写法:
# lifelong 终身(valid_until = null)
# days: N 源 fact occurredAt + N 天后自动失效
# review_days: N 超过 N 天未再提及 → 降级为 check(状态可能已变,需重新核实),不直接消失
# ─────────────────────────────────────────────────────────
signals:
# ── 出血风险(手术域绝对禁忌)────────────────────────
- code: anticoagulant
label: 长期抗凝/抗血小板药物
any: [抗凝, 华法林, 波立维, 泰嘉, 氯吡格雷, 利伐沙班, 达比加群, 阿司匹林, 抗血小板, 抗血栓]
contraindicates: [surgery, implant]
severity: absolute
ttl: { review_days: 730 } # 会停药 → 两年未再提及降级核查
note: 生产真阳性率 96%(「口服波立维」不可能是否定句);停药需医生指导,PAC 只提示不指导
- code: coagulopathy
label: 凝血功能障碍
any: [血友病, 凝血功能障碍, 凝血障碍, 血小板减少, 再生障碍性贫血]
contraindicates: [surgery, implant]
severity: absolute
ttl: lifelong
# ── 颌骨坏死风险(种植/外科绝对禁忌)──────────────────
- code: antiresorptive
label: 抗骨吸收药物史
any: [双膦酸盐, 地舒单抗, 唑来膦酸, 阿仑膦酸, 帕米膦酸, 普罗力, 密固达, 福善美]
contraindicates: [implant, surgery]
severity: absolute
ttl: lifelong
note: MRONJ(药物相关性颌骨坏死)风险不可逆,停药后仍持续 → 终身
- code: radiotherapy
label: 放疗史
any: [放疗, 放射治疗, 放射线治疗]
contraindicates: [implant, surgery]
severity: absolute
ttl: lifelong
note: 颌骨放射性骨坏死风险。⚠️ 词典判不出放疗部位(乳腺癌放疗 vs 颌面部放疗风险差别很大)
→ 标签文案必须带原文,让医生看到「5年前乳房癌手术,放疗」自行判断,PAC 不替他下结论
- code: chemotherapy
label: 化疗史
any: [化疗, 化学治疗]
contraindicates: [implant, surgery]
severity: relative
ttl: { review_days: 365 }
# ── 心血管(手术应激,相对)────────────────────────
- code: cardiac_implant
label: 心脏植入物/心血管手术史
any: [起搏器, 心脏支架, 冠脉支架, 心脏搭桥, 冠脉搭桥, 心肌梗死, 心梗, 心脏手术, 瓣膜置换]
contraindicates: [implant, surgery]
severity: relative
ttl: lifelong
note: ⚠️「支架」裸词会误吞「钛网支架骨增量手术」(口腔术式)→ 必须写全「心脏支架/冠脉支架」
# ── 妊娠哺乳(强时效)──────────────────────────
- code: pregnancy
label: 妊娠期
any: [妊娠, 怀孕, 孕期, 已孕]
# ⚠️ 生产全量抽取实测揪出的两类假阳性,别删:
# 备孕期(78 行)—— 还没怀上,判妊娠禁忌是错的
# 妊娠期龈炎(58 行)—— 那是**牙龈炎诊断名**,不是当前妊娠状态(多为「曾患妊娠期龈炎」)
not_any: [备孕, 备孕期, 妊娠期龈炎, 妊娠期牙龈炎, 妊娠性龈炎, 妊娠期牙龈瘤]
contraindicates: [implant, surgery, xray]
severity: absolute
ttl: { days: 280 }
note: 生产实测这类记录横跨 2016–2026;不设窗口 = 十年前的妊娠今天还挂着
- code: lactation
label: 哺乳期
any: [哺乳]
contraindicates: [implant, xray]
severity: absolute
ttl: { days: 180 } # 业务洞察:产后 6 个月解除
# ── 麻醉 ────────────────────────────────
- code: local_anesthetic_allergy
label: 局麻药物过敏
any: [利多卡因过敏, 普鲁卡因过敏, 阿替卡因过敏, 卡波卡因过敏, 布比卡因过敏, 局麻药过敏, 麻药过敏, 麻醉药过敏]
contraindicates: [anesthesia]
severity: absolute
ttl: lifelong
note: ⚠️ 必须整词匹配「X过敏」。裸「过敏」在生产命中 22,653 条,其中 98.4% 是「否认药物过敏史」
- code: asthma
label: 哮喘
any: [哮喘]
contraindicates: [anesthesia]
severity: relative
ttl: { review_days: 730 }
# ── 正畸 ────────────────────────────────
- code: severe_periodontitis
label: 重度牙周炎
any: [重度牙周炎, 重度牙周病, 中重度牙周炎, 中重度牙周病]
contraindicates: [orthodontic]
severity: relative
ttl: { review_days: 365 }
note: 源=diagnosis_record.name_zh(生产 6,674 行 / 约 4,900 人)。牙周炎控制后可正畸 →
relative 不挡召回,只提示「需牙周科评估后再启动矫治」。一年未复诊则降级核查
# ── 核查项(有病史但控制状态未知 —— 不叫禁忌)──────────
# ⭐ 这一档是本设计对业务细则最大的一处偏离,理由见文末「刻意不收」。
- code: diabetes
label: 糖尿病史(控制状态未知)
any: [糖尿病, 糖尿]
contraindicates: []
severity: check
ttl: { review_days: 730 }
note: 细则要求「HbA1c>7%」→ 生产全量 HbA1c 仅 10 条,判不了。控制达标的糖尿病可以种植
(业务洞察原话),硬判禁忌医学上是错的 → 降级核查项,把判断权还给医生
- code: hypertension
label: 高血压史(控制状态未知)
any: [高血压]
contraindicates: []
severity: check
ttl: { review_days: 730 }
note: 细则要求「收缩压>160mmHg」→ 血压数值几乎无人记录,同上降级
- code: osteoporosis
label: 骨质疏松
any: [骨质疏松]
contraindicates: []
severity: check
ttl: { review_days: 730 }
note: 骨质疏松本身不是禁忌,**用药才是** → 独立成核查项,提示客服确认是否在用抗骨吸收药
(常与 antiresorptive 同句命中,那条才是绝对禁忌)
- code: smoking
label: 吸烟史
any: [吸烟史, 抽烟史, 烟龄]
contraindicates: []
severity: check
ttl: { review_days: 730 }
note: 重度吸烟影响种植骨结合,但「重度」无量化数据 → 核查项。
⚠️ 必须带「史/龄」,裸「吸烟」在生产 5,010 条里 85% 是「无吸烟史」
# ─────────────────────────────────────────────────────────
# 四、刻意不收(留档,免得下次有人"补全"进来)
#
# ① 急性感染 / 发热 —— 既往史里「发热」几乎全是新冠筛查模板的否定式
# (「近2周内无体温大于37.3摄氏度」生产 5.3 万条)。假阳性源太危险,且急性期是**就诊当天**
# 判断的事,病历回溯没有意义。放弃。
# ② 颞下颌关节紊乱「急性期」—— 病历不记录急性/缓解期。无数据。
# ③ 严重骨性错颌「拒绝正颌手术」—— 病历不记录患者拒绝。无数据。
# ④ 牙槽骨吸收 >根长1/2 —— 生产 diagnosis 里「牙槽骨吸收」仅 18 行且无程度量化;
# 真正的骨吸收程度在影像报告里,PAC 目前只摄 AI 影像的 10 个病种码,无骨吸收量。
# ⑤ HbA1c / 收缩压 阈值 —— 见上 diabetes / hypertension note。
# ⑥ 肝炎 / 甲亢 / 肾病 —— 生产真阳性率 0.2% / 5.5%,几乎全是「否认肝炎、梅毒、艾滋病」
# 这类传染病否定串;且它们对牙科治疗的禁忌性远弱于上面几类。收益不抵噪音。
#
# 共同判据:**需要定量值或状态判断,而源数据只有一个名词** → 不收,或降级为 check。
# 这是「PAC 不造数据只讲事实」在本模块的具体落法。
# ─────────────────────────────────────────────────────────
-- CreateIndex
--
-- 【为什么要这条索引】
-- 每次摄入收尾都要按 sync_log_id 反查本轮 touched 患者
-- (cold-import.service.ts `touchedRows`,push / cold-import / 增量共用这一条路径):
-- SELECT id, patient_id, tenant_id FROM patient_transactions
-- WHERE sync_log_id = $1 AND patient_id IS NOT NULL
-- 外键约束**不会**自动建索引,所以这句一直在全表扫。
--
-- 2026-07-27 测试服实测(FRIDAY 对接增量 push 报 SocketTimeoutException 时定位到):
-- 推 2 行患者主档 → 单次请求 74s,74s 全耗在上面这条 SQL(逐 6s 采 pg_stat_activity,
-- 同一条查询年龄从 6s 一路涨到 74s);EXPLAIN:
-- Parallel Seq Scan on patient_transactions (cost=0.00..2150645.78 rows=1579)
-- patient_transactions:测试服 12,519,060 行 / 33 GB;生产 24,074,031 行 / 64 GB。
-- 建索引后本地实测 plan 变 Index Scan(cost=0.29..4.31)。
-- 这条随摄入量增长只会更糟 —— 属结构性缺失,不是调优。
--
-- 【CONCURRENTLY 与上线方式】
-- 普通 CREATE INDEX 会 ACCESS EXCLUSIVE 锁全表,33/64GB 上要锁几分钟,期间摄入与 push 全挂,
-- 故用 CONCURRENTLY(不锁写)。
--
-- ⚠️ 关于「Prisma migrate 有事务包裹所以跑不了 CONCURRENTLY」——
-- 那是 20260701060607 里的说法,**在当前 Prisma 6.19 已不成立**:2026-07-27 本地实测
-- `prisma migrate deploy` 直接把本文件跑通、索引建出、plan 生效。所以本条**可以**随
-- migrate deploy 走,不需要 `migrate resolve` 那套。
--
-- 但**推荐仍手动先建**,理由与事务无关,是停机时间:
-- deploy 时 pac-service 通过 depends_on 等 pac-migrate 退出才启动,而 33/64GB 建索引要几分钟
-- → 白等一段服务不可用。先手动建好(业务照常),deploy 时本条 IF NOT EXISTS 直接空跑:
-- docker exec pac-postgres-1 psql -U pac -d pac -c \
-- 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "patient_transactions_sync_log_id_idx" \
-- ON "patient_transactions"("sync_log_id");'
CREATE INDEX CONCURRENTLY IF NOT EXISTS "patient_transactions_sync_log_id_idx" ON "patient_transactions"("sync_log_id");
...@@ -516,6 +516,16 @@ model PatientTransaction { ...@@ -516,6 +516,16 @@ model PatientTransaction {
@@index([receivedAt]) @@index([receivedAt])
/// 水位游标查询(persona 重算"自上次以来新增的 transaction") /// 水位游标查询(persona 重算"自上次以来新增的 transaction")
@@index([eventSeq]) @@index([eventSeq])
/// 一次摄入产生了哪些 transaction —— 每次 push / cold-import / 增量收尾都要按 sync_log_id
/// 反查 touched 患者(cold-import.service.ts `touchedRows`)
///
/// ⚠️ 外键**不会**自动建索引。缺这条 每次 push 全表 Seq Scan:
/// 2026-07-27 测试服实测,FRIDAY 对接推 2 行患者主档耗时 74s,EXPLAIN
/// `Parallel Seq Scan on patient_transactions (cost=0.00..2150645.78)`,
/// 1251 万行 / 33GB;生产同表 2407 万行 / 64GB(更慢)
/// 对方 HTTP 客户端直接 `SocketTimeoutException: Read timed out`
/// 这条随表增长只会更糟 —— 摄入量越大、push 越慢,属结构性问题不是调优。
@@index([syncLogId])
/// 幂等键 partial UNIQUE (host_id, tenant_id, source_event_id) WHERE source_event_id IS NOT NULL /// 幂等键 partial UNIQUE (host_id, tenant_id, source_event_id) WHERE source_event_id IS NOT NULL
/// Prisma 不支持声明式 partial unique;migration 20260528000002 是单一真理源。 /// Prisma 不支持声明式 partial unique;migration 20260528000002 是单一真理源。
/// (init migration 漏建,20260528000002 dedup + 建索引) /// (init migration 漏建,20260528000002 dedup + 建索引)
......
...@@ -10,6 +10,7 @@ import { RedisModule } from './redis/redis.module'; ...@@ -10,6 +10,7 @@ import { RedisModule } from './redis/redis.module';
import { AlertingModule } from './common/alerting/alerting.module'; import { AlertingModule } from './common/alerting/alerting.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { FactsModule } from './modules/facts/facts.module'; import { FactsModule } from './modules/facts/facts.module';
import { ClinicalSignalsModule } from './modules/clinical-signals/clinical-signals.module';
import { SyncModule } from './modules/sync/sync.module'; import { SyncModule } from './modules/sync/sync.module';
import { PatientModule } from './modules/patient/patient.module'; import { PatientModule } from './modules/patient/patient.module';
import { PersonaModule } from './modules/persona/persona.module'; import { PersonaModule } from './modules/persona/persona.module';
...@@ -47,6 +48,8 @@ import { HealthController } from './health.controller'; ...@@ -47,6 +48,8 @@ import { HealthController } from './health.controller';
AlertingModule, AlertingModule,
AuthModule, AuthModule,
FactsModule, FactsModule,
// @Global —— 摄入管线 / 画像特征 / 重扫 CLI 三处都要,放前面保证 SyncModule 解析时已就绪
ClinicalSignalsModule,
SyncModule, SyncModule,
PatientModule, PatientModule,
PersonaModule, PersonaModule,
......
...@@ -107,7 +107,16 @@ ${others.length ? `\n## 其他可一并关心的问题(以本次聚焦为主,自 ...@@ -107,7 +107,16 @@ ${others.length ? `\n## 其他可一并关心的问题(以本次聚焦为主,自
* (修:旧版只读已删的 value/recall_risk key → 画像对话术全暗;现消费真实 16 特征。) * (修:旧版只读已删的 value/recall_risk key → 画像对话术全暗;现消费真实 16 特征。)
*/ */
const SCRIPT_PERSONA_GUIDE: Record<string, { warn?: boolean; guide: string }> = { const SCRIPT_PERSONA_GUIDE: Record<string, { warn?: boolean; guide: string }> = {
contraindication: { warn: true, guide: '安全红线:不得推荐该禁忌项目' }, // description 形如「手术禁忌 / 种植禁忌(长期抗凝药)· 待核查:糖尿病史(控制状态未知)」——
// 两段语义完全不同,指引必须分开说,否则模型会把「待核查」也当成不能做:
// 「X 禁忌」= 别推该项目;「待核查」= 只是有病史、控制状态未知,该问一句而不是回避。
// 且一律不在电话里替医生下结论(能不能做由医生按身体条件评估),这与高龄种植口径同源。
contraindication: {
warn: true,
guide:
'安全红线:标了「X 禁忌」的项目本次不要推荐、不要承诺;标了「待核查」的只是有相关病史' +
'(控制状态未知),自然问一句近况即可,别说成不能治。任何情况下都不在电话里判定能不能做,统一落到"来院让医生评估"',
},
treatment_sensitivity: { warn: true, guide: '沟通安抚:强调无痛/麻醉/慢节奏,先共情再谈治疗' }, treatment_sensitivity: { warn: true, guide: '沟通安抚:强调无痛/麻醉/慢节奏,先共情再谈治疗' },
special_attention: { warn: true, guide: '服务注意:不可等候→提前确认时间;免打扰→克制频次' }, special_attention: { warn: true, guide: '服务注意:不可等候→提前确认时间;免打扰→克制频次' },
rfm: { guide: '据分群定语气:重要/高价值→更上心维系、可适度尊享;低活跃→先唤醒别硬推' }, rfm: { guide: '据分群定语气:重要/高价值→更上心维系、可适度尊享;低活跃→先唤醒别硬推' },
......
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import type { ClinicalSignal } from '@pac/types';
import type { FactDraft } from '../sync/pipeline/parsers/parser.interface';
import {
buildScanIndex,
loadSignalDict,
type SignalDict,
} from './signal-dict';
import { extractSignalsFromContent } from './signal-extractor';
/**
* ClinicalSignalService —— 把抽取挂进 fact 衍生管线的唯一接口。
*
* 【为什么挂在 ParserPipeline 而不是各个 parser 里】
* 源字段要跨 fact 类型且可配置(既往史在 emr_record,严重度在 diagnosis_record.name_zh) ——
* 挂在 parser 里意味着每加一个源字段就要改一个 parser;挂在 pipeline 上只改 yaml 一行。
* parser 的职责是"把宿主行翻译成 PAC 标准 fact",信号抽取是对**已标准化内容**的再解读,
* 本来就该在 parser 之后。
*
* 【为什么写回源 fact 的 content 而不新建 fact 类型】
* FactType 的设计纪律:「fact 只装源事件,不做衍生 fact 派生 — 衍生信息留在源 fact 的
* content JSONB 里」。信号跟着源病历的生命周期走(病历订正 → 重放 → 信号自动重抽;
* 病历作废 → status 变更,信号随之失效),不需要额外维护一致性。
*
* 【为什么没命中就不写这个 key】
* content hash 变了就会 supersede 出一个新 fact 版本。生产 256 万条 emr_record 里
* 绝大多数是「无特殊陈述」这类无信号文本 —— 给它们都塞一个 `clinical_signals: []`
* 会凭空掀起 256 万条版本波。只给真有命中的写。
*/
@Injectable()
export class ClinicalSignalService implements OnModuleInit {
private readonly logger = new Logger(ClinicalSignalService.name);
private dict!: SignalDict;
private scanIndex!: Map<string, string[]>;
onModuleInit(): void {
// 启动即加载并强校验 —— 字典写错宁可起不来,也不要静默把禁忌判反。
this.dict = loadSignalDict();
this.scanIndex = buildScanIndex(this.dict);
this.logger.log(
`clinical-signals 字典已加载:${this.dict.version} ` +
`(${this.dict.signals.length} 信号 / ${this.dict.scan_fields.length} 类源 fact)`,
);
}
get version(): string {
return this.dict.version;
}
/** 该 fact 类型是否在扫描范围内 */
scannedFields(factType: string): string[] | undefined {
return this.scanIndex.get(factType);
}
/**
* 抽取一条 draft 的信号。命中则返回带 `clinical_signals` 的新 draft,否则原样返回。
*
* 不抛错:抽取失败绝不能拖垮 fact 落库 —— 病历本身是主数据,信号只是旁注。
* 失败只 warn,下次重放或全量重扫会自愈。
*/
enrich(draft: FactDraft, occurredAt: Date | null): FactDraft {
const fields = this.scanIndex.get(draft.type);
if (!fields) return draft;
// FactDraft.content 是 Prisma.InputJsonValue(联合类型);parser 产出的实际都是对象,
// 非对象形态直接放行(没字段可扫)。
const content = draft.content;
if (typeof content !== 'object' || content === null || Array.isArray(content)) return draft;
try {
const signals = extractSignalsFromContent(
content as Record<string, unknown>,
fields,
occurredAt ?? draft.occurredAt ?? null,
this.dict,
);
if (signals.length === 0) return draft;
return { ...draft, content: { ...content, clinical_signals: signals } };
} catch (err) {
this.logger.warn(
`clinical-signals 抽取失败 subjectId=${draft.subjectId}: ${(err as Error).message}`,
);
return draft;
}
}
/** 批量版 —— 供 ParserPipeline 的 bulk 路径与全量重扫 CLI 共用 */
enrichAll(drafts: FactDraft[], occurredAt: Date | null): FactDraft[] {
return drafts.map((d) => this.enrich(d, occurredAt));
}
/** 直接对一份 content 抽取(全量重扫 CLI 用,不经过 parser) */
extractForContent(
factType: string,
content: Record<string, unknown>,
occurredAt: Date | null,
): ClinicalSignal[] | null {
const fields = this.scanIndex.get(factType);
if (!fields) return null;
return extractSignalsFromContent(content, fields, occurredAt, this.dict);
}
}
import { Global, Module } from '@nestjs/common';
import { ClinicalSignalService } from './clinical-signal.service';
/**
* 临床信号模块。
*
* @Global —— 三类互不相邻的消费方都要用:摄入管线(ParserPipeline)、画像特征
* (contraindication)、重扫 CLI。它无状态、只持有一份只读字典,做全局比让三条链路
* 各自 import 更省事,也避免字典被加载三遍。
*/
@Global()
@Module({
providers: [ClinicalSignalService],
exports: [ClinicalSignalService],
})
export class ClinicalSignalsModule {}
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { load as yamlLoad } from 'js-yaml';
import { z } from 'zod';
import { SignalSeveritySchema, TreatmentDomainSchema } from '@pac/types';
/**
* clinical-signals.yaml 的加载与校验。
*
* 路径用 process.cwd() 而非 __dirname —— 与 sync.service.ts / skill-registry 同一个理由:
* SWC dev 与 tsc prod 的输出目录结构不同,__dirname 跨态不稳;cwd 在 dev/prod 都是
* apps/pac-service 根(Dockerfile:47 把 data/ 复制到镜像同一相对位置)。
*/
const TtlSchema = z.union([
z.literal('lifelong'),
z.object({ days: z.number().int().positive() }).strict(),
z.object({ review_days: z.number().int().positive() }).strict(),
]);
export type SignalTtl = z.infer<typeof TtlSchema>;
const SignalDefSchema = z.object({
code: z.string().min(1),
label: z.string().min(1),
any: z.array(z.string().min(1)).min(1),
/**
* 排除词 —— 命中位置若落在这些更长的词里,该次命中作废。
* 中文没有词边界,`any` 的短词必然会被更长的词包住(「孕期」⊂「备孕期」),
* 靠加长 `any` 治不了(变体无穷),必须有一条显式的反向清单。
*/
not_any: z.array(z.string().min(1)).optional(),
contraindicates: z.array(TreatmentDomainSchema),
severity: SignalSeveritySchema,
ttl: TtlSchema,
note: z.string().optional(),
});
export type SignalDef = z.infer<typeof SignalDefSchema>;
const ScanFieldSchema = z.object({
fact_type: z.string().min(1),
fields: z.array(z.string().min(1)).min(1),
});
const NegationSchema = z.object({
sentence_delimiters: z.array(z.string().min(1)).min(1),
markers: z.array(z.string().min(1)).min(1),
reassert: z.array(z.string().min(1)).min(1),
undecidable_patterns: z.array(z.string().min(1)),
});
export const SignalDictSchema = z.object({
version: z.string().min(1),
scan_fields: z.array(ScanFieldSchema).min(1),
negation: NegationSchema,
signals: z.array(SignalDefSchema).min(1),
});
export type SignalDict = z.infer<typeof SignalDictSchema>;
export function resolveSignalDictPath(): string {
const override = process.env.PAC_CLINICAL_SIGNALS_DICT;
if (override) return override;
return resolve(process.cwd(), 'data/_shared/dict/clinical-signals.yaml');
}
/**
* 加载并校验字典。校验失败**直接抛** —— 与 fact-writer 的 zod 闸同一个态度:
* 不允许"宽松降级",一个写错的字典会静默把禁忌判反,那比启动失败危险得多。
*/
export function loadSignalDict(path = resolveSignalDictPath()): SignalDict {
if (!existsSync(path)) {
throw new Error(`clinical-signals 字典不存在:${path}(cwd=${process.cwd()})`);
}
const dict = SignalDictSchema.parse(yamlLoad(readFileSync(path, 'utf8')));
const dupes = dict.signals
.map((s) => s.code)
.filter((c, i, arr) => arr.indexOf(c) !== i);
if (dupes.length > 0) {
throw new Error(`clinical-signals 字典存在重复 code:${[...new Set(dupes)].join(', ')}`);
}
// absolute/relative 必须声明治疗域,否则这条信号打了标签却不知道禁什么 —— 是配置错,不是空集。
for (const s of dict.signals) {
if (s.severity !== 'check' && s.contraindicates.length === 0) {
throw new Error(`信号 ${s.code} severity=${s.severity} 却未声明 contraindicates`);
}
if (s.severity === 'check' && s.contraindicates.length > 0) {
throw new Error(`信号 ${s.code} severity=check 不应声明 contraindicates(核查项不产禁忌)`);
}
}
return dict;
}
/** (factType → 要扫的字段名)索引。抽取器按 fact 类型 O(1) 取。 */
export function buildScanIndex(dict: SignalDict): Map<string, string[]> {
const idx = new Map<string, string[]>();
for (const sf of dict.scan_fields) idx.set(sf.fact_type, sf.fields);
return idx;
}
import type { ClinicalSignal } from '@pac/types';
import type { SignalDef, SignalDict, SignalTtl } from './signal-dict';
/**
* 临床信号抽取 —— 纯函数,无 Nest / 无 IO。
*
* 之所以刻意做成纯函数:准确率必须能拿**生产真实文本**回归验证
* (tests/clinical-signal-extractor.spec.ts 与 cli/scan-clinical-signals 共用同一份实现),
* 一旦掺进 DI 就只能靠构造 mock 来测,而这个模块判错的代价是医疗风险,不是功能缺陷。
*/
/** 句子 + 它在原文里的起始偏移 */
interface Sentence {
text: string;
offset: number;
}
/** 子句分隔符 —— **不切断否定作用域**,只用来判断「再断言」是否另起了一段。见下 isNegated。 */
const CLAUSE_SEPARATORS = [';', ';', '、', ',', ','];
function splitSentences(text: string, delimiters: string[]): Sentence[] {
const out: Sentence[] = [];
let start = 0;
for (let i = 0; i < text.length; i++) {
if (delimiters.includes(text[i]!)) {
if (i > start) out.push({ text: text.slice(start, i), offset: start });
start = i + 1;
}
}
if (start < text.length) out.push({ text: text.slice(start), offset: start });
return out;
}
/**
* 判定句内某关键词是否被否定。
*
* 【作用域】否定词管到**句末**,并列符(;;、,)不切断 —— 这是全模块最要紧的一条规则:
*
* 「患者否认有高血压、冠心病等心血管疾病;糖尿病、痛风等代谢性疾病。」
* ↑否认 ↑糖尿病在分号之后,仍在否定作用域内
*
* 生产 88.9 万条既往史实测:按分号切作用域 → 糖尿病判阳性 1,328 人;
* 按句切 → 141 人。**差 9.4 倍**,而正确答案是后者。这条错了整个模块就是负资产。
*
* 【再断言】只有当否定词与关键词之间**隔了一个并列符**时,`有/患/服用…` 才翻回阳性:
*
* 「否认系统病史,有高血压」 → 有 在「,」之后 → 阳性 ✅
* 「患者否认有高血压」 → 有 紧贴否认,无分隔符 → 仍是否定 ✅
*
* 若不要求隔分隔符,「否认有」这个极常见的搭配会被整片判成阳性 —— 那是医疗事故级的错。
*/
function isNegated(
sentence: string,
kwIndex: number,
markers: string[],
reassert: string[],
): boolean {
const before = sentence.slice(0, kwIndex);
// 取**最近**的那个否定词(一句里可能有多个:「否认A,未见B」)
let negEnd = -1;
for (const m of markers) {
const i = before.lastIndexOf(m);
if (i >= 0 && i + m.length > negEnd) negEnd = i + m.length;
}
if (negEnd < 0) return false;
const between = sentence.slice(negEnd, kwIndex);
const sepIdx = Math.max(...CLAUSE_SEPARATORS.map((s) => between.lastIndexOf(s)));
if (sepIdx < 0) return true; // 否定词与关键词之间没有并列符 → 同一段陈述,否定成立
// 分隔符之后才出现的再断言词才算数
const afterSep = between.slice(sepIdx + 1);
return !reassert.some((r) => afterSep.includes(r));
}
function addDays(base: Date, days: number): string {
const d = new Date(base.getTime() + days * 86_400_000);
return d.toISOString().slice(0, 10);
}
/** ttl → (validUntil 硬失效日, reviewAfter 复评日)。两者都相对**源事实发生时刻**算,与"现在"无关。 */
function resolveTtl(
ttl: SignalTtl,
occurredAt: Date | null,
): { validUntil: string | null; reviewAfter: string | null } {
if (ttl === 'lifelong') return { validUntil: null, reviewAfter: null };
// 源 fact 没有 occurredAt(理论上 actual 都有)→ 无法起算时效。
// 保守取向:不敢标"已失效"(可能漏挡真禁忌),按终身处理,交由人看原文判断。
if (!occurredAt) return { validUntil: null, reviewAfter: null };
if ('days' in ttl) return { validUntil: addDays(occurredAt, ttl.days), reviewAfter: null };
return { validUntil: null, reviewAfter: addDays(occurredAt, ttl.review_days) };
}
/**
* 该次命中是否被排除词吞掉(命中区间落在某个更长的排除词里)。
*
* 中文无词边界,短关键词必然被更长的词包住 —— 生产实测两例:
* 「孕期」⊂「备孕期」 备孕 = 还没怀上,判成妊娠禁忌是错的(78 行)
* 「妊娠」⊂「妊娠期龈炎」 那是牙龈炎诊断名,不是当前妊娠状态(58 行)
* 靠把 `any` 写得更长治不了(变体无穷),必须有显式反向清单。
*/
function excluded(sentence: string, idx: number, kwLen: number, notAny: string[]): boolean {
for (const bad of notAny) {
let from = 0;
for (;;) {
const b = sentence.indexOf(bad, from);
if (b < 0) break;
if (b <= idx && b + bad.length >= idx + kwLen) return true; // 命中区间被完整包住
from = b + 1;
}
}
return false;
}
/** 找到首个未被排除的命中位置;没有则 -1 */
function findKeyword(sentence: string, kw: string, notAny: string[]): number {
let from = 0;
for (;;) {
const i = sentence.indexOf(kw, from);
if (i < 0) return -1;
if (!excluded(sentence, i, kw.length, notAny)) return i;
from = i + 1;
}
}
/** 一个 code 在一段文本里的多次命中如何收敛 —— 阳性 > 不可判 > 否定 */
function rank(negated: boolean | null): number {
return negated === false ? 2 : negated === null ? 1 : 0;
}
/**
* 从**一个字段**的文本里抽信号。
*
* @param occurredAt 源 fact 的发生时刻,用于起算时效
*/
export function extractSignalsFromText(
text: string,
field: string,
occurredAt: Date | null,
dict: SignalDict,
): ClinicalSignal[] {
if (!text || !text.trim()) return [];
const { markers, reassert, sentence_delimiters, undecidable_patterns } = dict.negation;
const sentences = splitSentences(text, sentence_delimiters);
// code → 当前最优命中(按 rank 收敛)
const best = new Map<string, ClinicalSignal>();
for (const sent of sentences) {
// 模板没删干净(「患者否认/自述有…」)→ 本句所有信号都判不可判。
// 这不是抽取能力不足,是**源数据本身没有结论** —— PAC 不替宿主猜。
const undecidable = undecidable_patterns.some((p) => sent.text.includes(p));
for (const def of dict.signals) {
const notAny = def.not_any ?? [];
for (const kw of def.any) {
const idx = findKeyword(sent.text, kw, notAny);
if (idx < 0) continue;
const negated = undecidable
? null
: isNegated(sent.text, idx, markers, reassert);
const { validUntil, reviewAfter } = resolveTtl(def.ttl, occurredAt);
const candidate: ClinicalSignal = {
code: def.code,
label: def.label,
phrase: sent.text.trim().slice(0, 120),
span: [sent.offset + idx, sent.offset + idx + kw.length],
field,
negated,
severity: def.severity,
contraindicates: def.contraindicates,
validUntil,
reviewAfter,
extractedBy: dict.version,
};
const prev = best.get(def.code);
if (!prev || rank(candidate.negated) > rank(prev.negated)) {
best.set(def.code, candidate);
}
break; // 同一 def 命中一个关键词即可,不必把同义词全试一遍
}
}
}
return [...best.values()];
}
/**
* 从一条 fact 的 content 抽信号(按字典配置的字段范围)。
*
* 多字段命中同一 code 时同样按 阳性>不可判>否定 收敛 —— 既往史写"否认糖尿病"、
* 现病史写"糖尿病服药中",以后者为准(漏挡禁忌比多挡一条危险得多)。
*/
export function extractSignalsFromContent(
content: Record<string, unknown>,
fields: string[],
occurredAt: Date | null,
dict: SignalDict,
): ClinicalSignal[] {
const best = new Map<string, ClinicalSignal>();
for (const field of fields) {
const raw = content[field];
if (typeof raw !== 'string') continue;
for (const sig of extractSignalsFromText(raw, field, occurredAt, dict)) {
const prev = best.get(sig.code);
if (!prev || rank(sig.negated) > rank(prev.negated)) best.set(sig.code, sig);
}
}
return [...best.values()];
}
/** 字典里所有信号定义,按 code 索引(消费方查 label / severity 用) */
export function indexSignalDefs(dict: SignalDict): Map<string, SignalDef> {
return new Map(dict.signals.map((s) => [s.code, s]));
}
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey } from '@pac/types'; import {
CONTRAINDICATION_DOMAIN_ORDER,
CONTRAINDICATION_LABEL,
type ClinicalSignal,
PersonaFeatureKey,
type TreatmentDomain,
} from '@pac/types';
import type { import type {
ActiveFact,
FeatureExtractor, FeatureExtractor,
FeatureExtractorContext, FeatureExtractorContext,
PersonaFeatureDraft, PersonaFeatureDraft,
} from './feature.interface'; } from './feature.interface';
import { ageYearsAt } from './potential-treatment.feature'; import { ageYearsAt } from './potential-treatment.feature';
import { nextAgeBoundary } from './time-boundary'; import { earliestFuture, nextAgeBoundary } from './time-boundary';
/** /**
* contraindication 禁忌标签(D.2.4)— 规则层,snapshot · 多标签 * contraindication 禁忌标签(D.2.4)— snapshot · 多标签
* *
* 标签:种植禁忌 / 正畸禁忌 / 麻醉禁忌 / 手术禁忌(治疗可行性 + 安全预警)。 * ── 数据从哪来 ──
* 病历自由文本在**摄入时**已被 ClinicalSignalService 抽成结构化 `content.clinical_signals`
* (字典 data/_shared/dict/clinical-signals.yaml)。本特征只做**裁决**,不碰文本:
* *
* ⚠️ v1 只实现【种植年龄禁忌:年龄≤18(骨骼发育未完全)】—— 这是唯一可用确定性规则可靠判定的。 * 摄入层 抽取(这句话里提到抗凝药吗) per-row、确定性、可重放
* 其余禁忌(糖尿病/高血压/过敏/抗凝/妊娠/放疗…)依赖既往史自由文本,而: * ↓ 写回源 fact 的 content.clinical_signals
* ① 否定问题:含"过敏"的既往史 97% 是"否认过敏"——naive 关键词会把无过敏标成麻醉禁忌(医疗事故); * 衍生层 裁决(他现在是不是手术禁忌) 跨记录、跨时间 ← 本文件
* ② 量化条件无数据:HbA1c(全量仅10条)/收缩压 无法判"控制不佳",分不清能否种植;
* ③ 需控制状态/急性期判断 + 时效窗口。
* → 这些是 Layer C(LLM 抽取:读既往史、处理否定、判控制/急性)的活,留 W5+。本特征 data 结构
* 已留多标签位,Layer C 就绪后并入(同一 key,补 types)。
* *
* 时效:年龄禁忌随年龄自动解除(满 19 岁后重算即不打);validUntil 标注到期日(满 19 岁)。 * 这条分界线跟 PAC 已有的「翻译 vs 衍生」是同一条(诊断 K 码在 assembler 翻译,
* 治疗链在 chain-composer 衍生)。抽取混进本文件的话,召回闸门要用就只能重写一遍正则。
*
* ── 三档严重度决定下游怎么用 ──
* absolute 绝对禁忌 → 打「X 禁忌」标签;将来接召回闸门挡的就是这一档
* relative 相对禁忌 → 打标签 + 话术警示,**不挡召回**(能不能做由医生按身体条件评估)
* check 核查项 → **本特征不产出**(见下)
* 定量条件无数据的(糖尿病要 HbA1c、高血压要收缩压)一律落 check —— 控制达标的糖尿病
* 是可以种植的,硬判禁忌医学上就是错的。判断权还给医生。
*
* ⭐ check 档为什么不出现在画像里:业务口径 —— 「有糖尿病史但控制状态未知」对客服不构成
* 可执行信息,摆出来只会让人把「问一句」当成「不能治」,是净噪音。信号本身**照常抽取并
* 存在 fact 的 content.clinical_signals 里**(事实一条不丢,可查可回溯),只是画像层不说。
* 这就是「只讲事实」与「只说有用的话」的分工:事实层求全,画像层求准。
*
* ── 时效 ──
* 「禁忌≠终身禁忌」:妊娠 280 天、哺乳 180 天(产后半年解除)、放疗/抗骨吸收药终身。
* 到期日在**抽取时**就按源病历的 occurredAt 算好写进 signal,本文件只做一次比较。
* nextBoundaryAt 报出最近的到期时刻 → 到期当晚自动重算,标签自己消失,不用人管。
*/ */
@Injectable() @Injectable()
export class ContraindicationFeatureExtractor implements FeatureExtractor { export class ContraindicationFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.CONTRAINDICATION; readonly key = PersonaFeatureKey.CONTRAINDICATION;
private static readonly IMPLANT_MIN_AGE = 19; // ≤18 禁忌 → 满 19 解除 private static readonly IMPLANT_MIN_AGE = 19; // ≤18 禁忌 → 满 19 解除
/// 会带 clinical_signals 的 fact 类型(与 clinical-signals.yaml 的 scan_fields 对应)。
/// 这里列的是**读取范围**,写入范围由 yaml 决定;yaml 加了新类型而这里没加 = 白抽,
/// 由 tests/clinical-signal-extractor.spec.ts 的一致性用例守住。
private static readonly SIGNAL_FACT_TYPES = ['emr_record', 'diagnosis_record'] as const;
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null { extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
const birth = ctx.patient.birthDate; const detail: ContraindicationDetail[] = [];
if (!birth) return null; const factIds = new Set<string>();
const age = ageYearsAt(birth, ctx.now);
if (age === null || age < 0 || age > 120) return null; // ── ① 年龄禁忌(不依赖任何 fact,证据是 patient.birthDate)──
const age = ctx.patient.birthDate ? ageYearsAt(ctx.patient.birthDate, ctx.now) : null;
const types: Array<{ code: string; zh: string; reason: string; validUntil: string | null }> = []; if (age !== null && age >= 0 && age <= 18) {
// 种植年龄禁忌:≤18 岁 const lift = new Date(ctx.patient.birthDate!);
if (age <= 18) {
const lift = new Date(birth);
lift.setFullYear(lift.getFullYear() + ContraindicationFeatureExtractor.IMPLANT_MIN_AGE); lift.setFullYear(lift.getFullYear() + ContraindicationFeatureExtractor.IMPLANT_MIN_AGE);
types.push({ detail.push({
code: 'implant_age', code: 'implant_age',
zh: '种植禁忌', label: '骨骼发育未完全',
reason: `年龄${age}岁(骨骼发育未完全)`, domains: ['implant'],
severity: 'absolute',
reason: `年龄${age}岁`,
phrase: null,
source: null,
validUntil: lift.toISOString().slice(0, 10), validUntil: lift.toISOString().slice(0, 10),
}); });
} }
// 其余禁忌(糖尿病/过敏/抗凝/妊娠…)留 Layer C —— 见类注释
if (types.length === 0) return null; // ── ② 病历信号裁决(check 档在 resolveSignals 里已滤掉,不进画像)──
detail.push(...resolveSignals(collectSignals(ctx, factIds), ctx.now));
if (detail.length === 0) return null;
const domains = orderedDomains(detail);
const labels = domains.map((d) => CONTRAINDICATION_LABEL[d]);
return { return {
key: this.key, key: this.key,
description: types.map((t) => `${t.zh}(${t.reason})`).join(' / '), description: `${labels.join(' / ')}(${[...new Set(detail.map((d) => d.label))].join('、')})`,
score: null, score: null,
data: { types: types.map((t) => t.code), labels: types.map((t) => t.zh), detail: types }, data: {
evidence: { factIds: [] }, // v1 证据=patient.birthDate(年龄禁忌,非 fact) // 兼容既有消费方:persona-tag-filters 按 data.domains 圈人(治疗域四类),
// 前端 compactPersonaValue 读 data.labels;data.types 留病因码供详情/排查用。
types: detail.map((d) => d.code),
labels,
domains,
detail,
},
evidence: { factIds: [...factIds] },
}; };
} }
/** /**
* 种植年龄禁忌满 19 岁自动解除 —— 到期日已写在 data.detail[].validUntil,这里报同一个时刻。 * 到期即改档的时刻:年龄满 19 / 任一禁忌的失效日 / 任一信号的复评日。
* 本特征不依赖任何 fact:没有边界,一个 18 岁患者的「种植禁忌」会挂到他下次恰好有新事实为止。 *
* 缺了这个,一个 2026-01 记录「哺乳期」的患者,标签会一直挂到她下次恰好有新病历为止 ——
* 水位闸只在事实变了时放行,而"时间过去了"不是事实变化。
*/ */
nextBoundaryAt(ctx: FeatureExtractorContext): Date | null { nextBoundaryAt(ctx: FeatureExtractorContext): Date | null {
return nextAgeBoundary(ctx.patient.birthDate, ctx.now, [ const dates: Array<Date | null> = [
nextAgeBoundary(ctx.patient.birthDate, ctx.now, [
ContraindicationFeatureExtractor.IMPLANT_MIN_AGE, ContraindicationFeatureExtractor.IMPLANT_MIN_AGE,
]); ]),
];
for (const s of collectSignals(ctx, new Set())) {
if (s.negated !== false) continue;
if (s.validUntil) dates.push(new Date(`${s.validUntil}T00:00:00Z`));
if (s.reviewAfter) dates.push(new Date(`${s.reviewAfter}T00:00:00Z`));
}
return earliestFuture(ctx.now, ...dates);
} }
}
interface ContraindicationDetail {
code: string;
label: string;
domains: TreatmentDomain[];
/** 只会是 absolute / relative —— check 档在裁决时已滤掉,不进画像 */
severity: 'absolute' | 'relative';
reason: string;
/// 医生原话片段 —— 详情页/话术引用的是这个,不是 PAC 的转述
phrase: string | null;
/// 证据来源:哪条 fact 的哪个字段 + 记录日期
source: { factId: string; field: string; occurredAt: string | null } | null;
validUntil: string | null;
}
/** 带 fact 溯源信息的信号 */
interface SourcedSignal extends ClinicalSignal {
factId: string;
occurredAt: Date | null;
} }
function collectSignals(ctx: FeatureExtractorContext, factIds: Set<string>): SourcedSignal[] {
const out: SourcedSignal[] = [];
for (const type of ContraindicationFeatureExtractor['SIGNAL_FACT_TYPES']) {
for (const f of ctx.factsByType.get(type) ?? []) {
const raw = (f.content as { clinical_signals?: unknown } | null)?.clinical_signals;
if (!Array.isArray(raw)) continue;
for (const s of raw as ClinicalSignal[]) {
out.push({ ...s, factId: f.id, occurredAt: (f as ActiveFact).occurredAt });
if (s.negated === false) factIds.add(f.id);
}
}
}
return out;
}
/**
* 同一个 code 在多年病历里反复出现时怎么裁决。
*
* 【终身型(validUntil=null 且 reviewAfter=null)】阳性一旦出现就不被后续否定推翻。
* 放疗史 / 抗骨吸收药的骨坏死风险是不可逆的 —— 后来某份病历套用「否认系统病史」模板
* 没提它,不代表那次放疗没发生过。记录冲突时保留更危险的一侧。
*
* 【其余】**最近一条病历为准**。抗凝药会停、牙周炎会控制住、妊娠会结束,
* 最新的医生记录就是最新的事实。
*/
function resolveSignals(signals: SourcedSignal[], now: Date): ContraindicationDetail[] {
const byCode = new Map<string, SourcedSignal>();
for (const s of signals) {
const prev = byCode.get(s.code);
if (!prev) {
byCode.set(s.code, s);
continue;
}
const lifelong = s.validUntil === null && s.reviewAfter === null;
if (lifelong) {
// 阳性优先,其次比新
if (prev.negated !== false && s.negated === false) byCode.set(s.code, s);
else if (prev.negated === s.negated && newer(s, prev)) byCode.set(s.code, s);
} else if (newer(s, prev)) {
byCode.set(s.code, s);
}
}
const out: ContraindicationDetail[] = [];
for (const s of byCode.values()) {
if (s.negated !== false) continue; // 否定 / 不可判都不产禁忌
// check 档(糖尿病/高血压/骨质疏松/吸烟史 —— 有病史但控制状态未知)不进画像:
// 对客服不构成可执行信息,摆出来只会让人把「问一句」当成「不能治」。
// 信号本身照常留在 fact 的 content.clinical_signals 里,事实一条不丢。
if (s.severity === 'check') continue;
if (s.validUntil && s.validUntil <= iso(now)) continue; // 已过期(2016 年那条哺乳期)
// 过了复评日:**仍然算禁忌**(抗凝药可能已停,但漏挡比多挡危险得多),
// 只在依据行注明记录较早 —— 不降级、不消失,把"要不要确认"交给客服看日期判断。
const stale = s.reviewAfter != null && s.reviewAfter <= iso(now);
out.push({
code: s.code,
label: s.label,
domains: s.contraindicates,
severity: s.severity,
reason: stale ? `${s.label}(记录较早,建议确认近况)` : s.label,
phrase: s.phrase,
source: {
factId: s.factId,
field: s.field,
occurredAt: s.occurredAt ? iso(s.occurredAt) : null,
},
validUntil: s.validUntil,
});
}
return out;
}
function newer(a: SourcedSignal, b: SourcedSignal): boolean {
return (a.occurredAt?.getTime() ?? 0) > (b.occurredAt?.getTime() ?? 0);
}
function iso(d: Date): string {
return d.toISOString().slice(0, 10);
}
/** 命中的治疗域,按风险高低固定序 —— 前端与话术共用同一个顺序,免得两处各排各的 */
function orderedDomains(detail: ContraindicationDetail[]): TreatmentDomain[] {
const hit = new Set(detail.flatMap((d) => d.domains));
return CONTRAINDICATION_DOMAIN_ORDER.filter((d) => hit.has(d));
}
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
*/ */
import { z } from 'zod'; import { z } from 'zod';
import { import {
ClinicalSignalsSchema,
FactType, FactType,
PACDiagnosisCodeSchema, PACDiagnosisCodeSchema,
PACTreatmentCategorySchema, PACTreatmentCategorySchema,
...@@ -43,6 +44,24 @@ const toothPositionText = () => nullableString(); ...@@ -43,6 +44,24 @@ const toothPositionText = () => nullableString();
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
const nullableString = () => z.string().nullable().optional().default(null); const nullableString = () => z.string().nullable().optional().default(null);
/**
* 临床信号(clinical_signals)—— 从本条 fact 自己的自由文本里抽出的结构化旁注。
*
* 【为什么是「可选且不给默认值」】
* 给了默认 `[]` 就意味着**每条** fact 的 content 都多一个 key,content hash 全变 →
* 生产 256 万条 emr_record 会整体 supersede 出新版本。绝大多数病历是「无特殊陈述」这类
* 无信号文本,为它们掀一次全量版本波毫无收益。只有真命中的 fact 才带这个 key。
* (生产全量实测:702 万行文本里只有 0.75% 会命中 → 版本波规模可控。)
*
* 【为什么不新建衍生 fact 类型】
* 见 FactType 的设计纪律:「fact 只装源事件,不做衍生 fact 派生 —— 衍生信息留在源 fact 的
* content JSONB 里」。信号跟着源病历的生命周期走,不需要额外维护一致性。
*
* 消费方:persona contraindication 特征;召回闸门(预留)可直接
* `content->'clinical_signals' @> '[{"code":"...","negated":false}]'`。
*/
const clinicalSignals = () => ClinicalSignalsSchema.optional();
const nullableNumber = () => z.number().nullable().optional().default(null); const nullableNumber = () => z.number().nullable().optional().default(null);
const isoDateString = z const isoDateString = z
.string() .string()
...@@ -101,6 +120,8 @@ const DiagnosisRecordContent = z ...@@ -101,6 +120,8 @@ const DiagnosisRecordContent = z
doctor_name: nullableString(), doctor_name: nullableString(),
/// 反查源接诊;算法 JOIN 凭这字段 /// 反查源接诊;算法 JOIN 凭这字段
source_encounter_external_id: z.string().min(1), source_encounter_external_id: z.string().min(1),
/// 从 name_zh 抽的临床信号(严重度只在诊断原文里 —— 生产 severity 列 296 万条 100% null)
clinical_signals: clinicalSignals(),
}) })
.passthrough(); .passthrough();
...@@ -198,6 +219,8 @@ const EmrRecordContent = z ...@@ -198,6 +219,8 @@ const EmrRecordContent = z
treatment_plan: nullableString(), treatment_plan: nullableString(),
disposal: nullableString(), // 处置(本次实操) disposal: nullableString(), // 处置(本次实操)
doctor_advice: nullableString(), // 医嘱 doctor_advice: nullableString(), // 医嘱
/// 从上面 SOAP 字段抽的临床信号(扫哪几个字段由 clinical-signals.yaml 配置)
clinical_signals: clinicalSignals(),
}) })
.passthrough(); // 自由文本 fact 允许 host 多带字段(算法不消费,Layer C 抽取消费) .passthrough(); // 自由文本 fact 允许 host 多带字段(算法不消费,Layer C 抽取消费)
......
...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; ...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import type { Action } from '@pac/types'; import type { Action } from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { ParserRegistry } from './parsers/parser.registry'; import { ParserRegistry } from './parsers/parser.registry';
import { ClinicalSignalService } from '../../clinical-signals/clinical-signal.service';
import { import {
type BulkEntry, type BulkEntry,
FactWriter, FactWriter,
...@@ -15,8 +16,13 @@ import { ...@@ -15,8 +16,13 @@ import {
* 流程: * 流程:
* 1. 按 transaction.action 查 ParserRegistry * 1. 按 transaction.action 查 ParserRegistry
* 2. parser.parse(ctx) → FactDraft[] * 2. parser.parse(ctx) → FactDraft[]
* 3. 逐个调用 FactWriter.writeDraft * 3. ClinicalSignalService.enrich —— 按 clinical-signals.yaml 配置的字段抽临床信号,
* 4. 汇总 metrics(created / superseded / unchanged / evidence_appended) * 命中则写进 draft.content.clinical_signals(**不新建衍生 fact**,见该 service 注释)
* 4. 逐个调用 FactWriter.writeDraft
* 5. 汇总 metrics(created / superseded / unchanged / evidence_appended)
*
* 信号抽取放在这一层而不是各 parser 里:源字段跨 fact 类型且由 yaml 配置
* (既往史在 emr_record,诊断严重度在 diagnosis_record.name_zh),挂 pipeline 上加字段只改 yaml。
* *
* 设计: * 设计:
* - 无 parser 的 action 静默跳过(返回 zero metrics)— transaction 已落账,稍后补 parser 重放即可 * - 无 parser 的 action 静默跳过(返回 zero metrics)— transaction 已落账,稍后补 parser 重放即可
...@@ -32,6 +38,7 @@ export class ParserPipeline { ...@@ -32,6 +38,7 @@ export class ParserPipeline {
private readonly registry: ParserRegistry, private readonly registry: ParserRegistry,
private readonly writer: FactWriter, private readonly writer: FactWriter,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly signals: ClinicalSignalService,
) {} ) {}
/** /**
...@@ -79,7 +86,10 @@ export class ParserPipeline { ...@@ -79,7 +86,10 @@ export class ParserPipeline {
return metrics; return metrics;
} }
const drafts = parser.parse({ transaction, canonicalRow }); const drafts = this.signals.enrichAll(
parser.parse({ transaction, canonicalRow }),
transaction.occurredAt,
);
const sourceUpdatedAt = parseSourceUpdatedAt(canonicalRow); const sourceUpdatedAt = parseSourceUpdatedAt(canonicalRow);
// fact 的 source_unit = 该患者的 source_unit(集团内 subject_id 撞号消歧) // fact 的 source_unit = 该患者的 source_unit(集团内 subject_id 撞号消歧)
...@@ -174,10 +184,13 @@ export class ParserPipeline { ...@@ -174,10 +184,13 @@ export class ParserPipeline {
} }
if (!item.transaction.patientId) continue; if (!item.transaction.patientId) continue;
try { try {
const drafts = parser.parse({ const drafts = this.signals.enrichAll(
parser.parse({
transaction: item.transaction, transaction: item.transaction,
canonicalRow: item.canonicalRow, canonicalRow: item.canonicalRow,
}); }),
item.transaction.occurredAt,
);
const sourceUpdatedAt = parseSourceUpdatedAt(item.canonicalRow); const sourceUpdatedAt = parseSourceUpdatedAt(item.canonicalRow);
for (const draft of drafts) { for (const draft of drafts) {
bulkEntries.push({ bulkEntries.push({
......
import { ContraindicationFeatureExtractor } from '../src/modules/persona/features/contraindication.feature';
import { loadSignalDict } from '../src/modules/clinical-signals/signal-dict';
import {
extractSignalsFromContent,
extractSignalsFromText,
} from '../src/modules/clinical-signals/signal-extractor';
/**
* 临床信号抽取回归。
*
* 【这个文件为什么比一般 spec 重要】
* 判错的代价不是功能缺陷,是医疗风险 —— 把「否认药物过敏」判成阳性,PAC 会给一个
* 没有过敏的患者挂「麻醉禁忌」;反过来把真的抗凝药漏掉,PAC 会把一个出血高危患者
* 推去拔牙。两个方向都不是"体验问题"。
*
* 下面的用例**全部抄自生产真实文本**(jvs-dw,88.9 万条既往史里的高频形态),
* 不是我编的语料 —— 编出来的句子只能验证我脑子里的规则,验证不了医生实际怎么写。
*/
const dict = loadSignalDict();
const OCCURRED = new Date('2026-01-01T00:00:00Z');
function codes(text: string, field = 'past_history') {
return extractSignalsFromText(text, field, OCCURRED, dict);
}
function find(text: string, code: string) {
return codes(text).find((s) => s.code === code);
}
describe('字典自身', () => {
it('加载并通过 schema 校验', () => {
expect(dict.signals.length).toBeGreaterThan(0);
expect(dict.version).toMatch(/^clinical-signals@/);
});
it('check 档不得声明治疗域,absolute/relative 必须声明(loader 强校验)', () => {
for (const s of dict.signals) {
if (s.severity === 'check') expect(s.contraindicates).toHaveLength(0);
else expect(s.contraindicates.length).toBeGreaterThan(0);
}
});
// yaml 里加了新 fact 类型而画像那边没加 = 白抽:信号写进库了,但没人读。
// 这种漏配不会报错,只会安静地少一批禁忌 —— 必须由测试守住。
it('yaml 的 scan_fields 与画像 contraindication 读取的 fact 类型一致', () => {
const yamlTypes = dict.scan_fields.map((f) => f.fact_type).sort();
const readTypes = [
...(ContraindicationFeatureExtractor as unknown as { SIGNAL_FACT_TYPES: readonly string[] })
.SIGNAL_FACT_TYPES,
].sort();
expect(readTypes).toEqual(yamlTypes);
});
it('扫描字段里不含主诉/检查所见/处置 —— 它们不承载全身病史,只会引噪音', () => {
const emr = dict.scan_fields.find((f) => f.fact_type === 'emr_record');
expect(emr?.fields).not.toContain('illness_desc');
expect(emr?.fields).not.toContain('exam_findings');
expect(emr?.fields).not.toContain('disposal');
});
});
describe('否定作用域 —— 并列符不切断(本模块最要紧的一条)', () => {
// 生产 594 条。分号后的「糖尿病」仍在「患者否认」的作用域内。
// 按分号切作用域会把它判成阳性 → 生产实测 1,328 人 vs 正确的 141 人,差 9.4 倍。
const 长否定串 =
'患者否认有高血压、冠心病等心血管疾病;糖尿病、痛风等代谢性疾病。患者否认有肝炎、梅毒、艾滋病等传染性疾病。';
it('分号后的并列项仍被否定', () => {
expect(find(长否定串, 'diabetes')?.negated).toBe(true);
expect(find(长否定串, 'hypertension')?.negated).toBe(true);
});
it('句号切断作用域 —— 下一句独立判定', () => {
const t = '否认系统性疾病史。5年前安装心脏起搏器。';
expect(find(t, 'cardiac_implant')?.negated).toBe(false);
});
it('「否认有X」不因中间那个「有」翻成阳性', () => {
// 若「再断言」不要求隔并列符,「否认有」这个极常见搭配会被整片判反 —— 医疗事故级
expect(find('患者否认有高血压', 'hypertension')?.negated).toBe(true);
});
it('隔了并列符的再断言翻回阳性', () => {
expect(find('否认系统病史,有高血压病史', 'hypertension')?.negated).toBe(false);
});
it('取最近的否定词,不被句首的否定词裹挟', () => {
expect(find('否认药物过敏史,有哮喘史', 'asthma')?.negated).toBe(false);
});
});
describe('阳性 —— 生产真实句', () => {
it.each([
['3年前行心脏支架治疗,现口服抗凝血药,降脂药治疗', 'anticoagulant'],
['4年前外院种植修复,3年前心脏搭桥手术,目前口服阿司匹林等抗凝药物,全身状况稳定', 'anticoagulant'],
['2020年5月因脚踝粉碎性骨折术后注射地舒单抗治疗骨质疏松,第二针未注射', 'antiresorptive'],
['2+年前颌面部放疗史', 'radiotherapy'],
['5年前安装心脏起搏器,半年前服用抗凝史', 'cardiac_implant'],
['佩戴心脏起搏器38年', 'cardiac_implant'],
['哺乳期', 'lactation'],
['1年前外院牙周治疗,现怀孕7月余', 'pregnancy'],
['青霉素过敏史,利多卡因过敏', 'local_anesthetic_allergy'],
['有哮喘史,四年前有过敏性紫癜', 'asthma'],
])('%s → %s 阳性', (text, code) => {
expect(find(text, code)?.negated).toBe(false);
});
});
describe('否定 —— 生产高频模板', () => {
it.each([
['无特殊陈述。否认药物过敏史', 'local_anesthetic_allergy'],
['否认系统病史、否认传染病史,否认药物过敏史', 'diabetes'],
['既往体健,无高血压,心脏病,糖尿病史', 'diabetes'],
['既往体健,无高血压,心脏病,糖尿病史', 'hypertension'],
['每日刷牙两次,横竖混刷,每次小于3min。无吸烟史。余否认特殊', 'smoking'],
])('%s → %s 不判阳性', (text, code) => {
const s = find(text, code);
if (s) expect(s.negated).not.toBe(false);
});
it('新冠筛查模板不误伤(生产 5.3 万条)', () => {
const t =
'近2周内无体温大于37.3摄氏度;无流涕、咽喉痛等临床表现;近2周内无疫区(含境外)居住或旅行史;未接触过发热病人;无外出';
expect(codes(t).filter((s) => s.negated === false)).toHaveLength(0);
});
});
describe('不可判 —— 源数据本身没有结论', () => {
// 生产 116 条:医生没删模板里那个斜杠。这不是 NLP 能力问题
it('「患者否认/自述有…」→ negated=null,且不产禁忌', () => {
const t = '患者否认/自述有高血压、冠心病等心血管疾病;糖尿病、痛风等代谢性疾病';
expect(find(t, 'hypertension')?.negated).toBeNull();
expect(find(t, 'diabetes')?.negated).toBeNull();
});
});
describe('关键词精度陷阱(注释里写了原因的,这里守住)', () => {
it('「钛网支架骨增量手术」是口腔术式,不能被裸「支架」吞成心脏植入物', () => {
expect(find('2025年5月7日,右下后牙区行钛网支架骨增量手术', 'cardiac_implant')).toBeUndefined();
});
it('裸「过敏」不产局麻过敏(生产 22,653 条里 98.4% 是「否认药物过敏史」)', () => {
expect(find('否认系统性疾病史及过敏史', 'local_anesthetic_allergy')).toBeUndefined();
expect(find('过敏性鼻炎', 'local_anesthetic_allergy')).toBeUndefined();
});
it('吸烟必须带「史/龄」—— 裸「吸烟」在生产 85% 是「无吸烟史」', () => {
expect(find('患者自述有吸烟史', 'smoking')?.negated).toBe(false);
});
// 下面两条是生产全量抽取(702 万行)实测揪出来的假阳性,不是设想出来的
it('「备孕期」不算妊娠 —— 还没怀上(生产 78 行)', () => {
expect(find('备孕期,otc洁牙', 'pregnancy')).toBeUndefined();
});
it('「妊娠期龈炎」是牙龈炎诊断名,不是当前妊娠状态(生产 58 行)', () => {
expect(find('曾患妊娠期龈炎,否认夜磨牙史', 'pregnancy')).toBeUndefined();
// 真怀孕仍要抓到
expect(find('妊娠期龈炎;现怀孕6个月', 'pregnancy')?.negated).toBe(false);
});
});
describe('时效 —— 你的行业洞察落地:禁忌≠终身禁忌', () => {
it('放疗 / 抗骨吸收药 = 终身(validUntil=null)', () => {
expect(find('2+年前颌面部放疗史', 'radiotherapy')?.validUntil).toBeNull();
expect(find('长期服用双膦酸盐', 'antiresorptive')?.validUntil).toBeNull();
});
it('哺乳期 = 源事实发生日 + 180 天(产后 6 个月解除)', () => {
expect(find('哺乳期', 'lactation')?.validUntil).toBe('2026-06-30');
});
it('妊娠 = +280 天', () => {
expect(find('妊娠5月余', 'pregnancy')?.validUntil).toBe('2026-10-08');
});
it('抗凝药走复评日而非硬失效 —— 会停药,但不该凭空消失', () => {
const s = find('长期口服泰嘉(抗凝药物)', 'anticoagulant');
expect(s?.validUntil).toBeNull();
expect(s?.reviewAfter).toBe('2028-01-01'); // 2026-01-01 + 730d(两个平年)
});
it('时效相对**源事实发生时刻**算,与"现在"无关 —— 存的是日期不是布尔,不会随时间变质', () => {
const old = extractSignalsFromText('哺乳期', 'past_history', new Date('2016-03-01'), dict);
expect(old[0]?.validUntil).toBe('2016-08-28'); // 十年前那条自然已过期,但事实照样留着
});
});
describe('证据可回溯', () => {
it('带原文片段、字符区间与来源字段 —— 详情页展示医生原话,不是 PAC 的转述', () => {
const text = '既往体健。3年前行心脏支架治疗,现口服抗凝血药。';
const s = extractSignalsFromText(text, 'past_history', OCCURRED, dict).find(
(x) => x.code === 'cardiac_implant',
);
expect(s?.field).toBe('past_history');
expect(text.slice(s!.span[0], s!.span[1])).toBe('心脏支架');
expect(s?.phrase).toContain('心脏支架治疗');
expect(s?.extractedBy).toBe(dict.version);
});
});
describe('多字段收敛', () => {
it('阳性 > 不可判 > 否定 —— 漏挡禁忌比多挡一条危险得多', () => {
const out = extractSignalsFromContent(
{
past_history: '否认系统性疾病史。',
pre_illness: '现怀孕7月余。',
},
['past_history', 'pre_illness'],
OCCURRED,
dict,
);
expect(out.find((s) => s.code === 'pregnancy')?.negated).toBe(false);
});
it('非字符串字段安全跳过(exam_findings 是 JSON 原文)', () => {
expect(
extractSignalsFromContent({ past_history: null, other: 42 }, ['past_history', 'other'], OCCURRED, dict),
).toHaveLength(0);
});
});
describe('无信号的文本不产任何 signal —— 关系到会不会掀起无谓的 fact 版本波', () => {
it.each(['无特殊陈述', '无特殊', '体健', '同前', '曾在外院补牙', '定期洁牙史'])('%s', (t) => {
expect(codes(t)).toHaveLength(0);
});
});
import type { ClinicalSignal } from '@pac/types';
import { ContraindicationFeatureExtractor } from '../src/modules/persona/features/contraindication.feature';
import type { FeatureExtractorContext } from '../src/modules/persona/features/feature.interface';
import { loadSignalDict } from '../src/modules/clinical-signals/signal-dict';
import { extractSignalsFromContent } from '../src/modules/clinical-signals/signal-extractor';
/**
* 禁忌裁决层回归(persona contraindication 特征)。
*
* 抽取(文本→信号)由 clinical-signal-extractor.spec.ts 守;本文件守**裁决**:
* 同一个 code 在多年病历里反复出现怎么办、到期怎么消、超复评期怎么降级、
* 核查项有没有被当成禁忌。这一层错了不会报错,只会安静地给客服一个错标签。
*/
const NOW = new Date('2026-07-27T00:00:00Z');
const dict = loadSignalDict();
const extractor = new ContraindicationFeatureExtractor();
/** 拿真字典把一段真实病历文本抽成 signal —— 不手搓 signal 对象,免得测的是我编的形状 */
function signalsOf(text: string, occurredAt: string, field = 'past_history'): ClinicalSignal[] {
return extractSignalsFromContent({ [field]: text }, [field], new Date(`${occurredAt}T00:00:00Z`), dict);
}
let factSeq = 0;
function emr(text: string, occurredAt: string, field = 'past_history') {
return {
id: `f${++factSeq}`,
subjectId: `s${factSeq}`,
type: 'emr_record',
kind: 'actual',
status: 'active',
occurredAt: new Date(`${occurredAt}T00:00:00Z`),
plannedFor: null,
clinicId: null,
content: { [field]: text, clinical_signals: signalsOf(text, occurredAt, field) },
};
}
function diagnosis(nameZh: string, occurredAt: string) {
return {
...emr(nameZh, occurredAt, 'name_zh'),
type: 'diagnosis_record',
};
}
function ctx(over: Partial<FeatureExtractorContext> = {}): FeatureExtractorContext {
return {
patient: { id: 'p1', externalId: 'e1', name: null, phone: null, gender: null, birthDate: null, active: true },
profile: null,
factsByType: new Map(),
relations: [],
appointmentsAll: [],
potentialGaps: [],
now: NOW,
populationStats: null,
...over,
} as FeatureExtractorContext;
}
function withFacts(facts: ReturnType<typeof emr>[], over: Partial<FeatureExtractorContext> = {}) {
const byType = new Map<string, unknown[]>();
for (const f of facts) {
const arr = byType.get(f.type) ?? [];
arr.push(f);
byType.set(f.type, arr);
}
return ctx({ factsByType: byType as never, ...over });
}
/** 便捷取值 */
const data = (c: FeatureExtractorContext) =>
extractor.extract(c)?.data as
| { types: string[]; labels: string[]; domains: string[]; detail: Array<Record<string, unknown>>; checks: Array<Record<string, unknown>> }
| undefined;
describe('无信号 → 不产标签', () => {
it('既往史「无特殊陈述」的成年人没有任何禁忌', () => {
expect(extractor.extract(withFacts([emr('无特殊陈述。否认药物过敏史', '2026-05-01')]))).toBeNull();
});
});
describe('① 年龄禁忌(不依赖 fact)', () => {
it('未满 19 → 种植禁忌,到期日 = 19 岁生日', () => {
const d = data(ctx({ patient: { ...ctx().patient, birthDate: new Date('2010-03-05') } }));
expect(d?.types).toContain('implant_age');
expect(d?.labels).toEqual(['种植禁忌']);
expect(d?.detail[0]!.validUntil).toBe('2029-03-05');
});
it('满 19 → 不再打标', () => {
expect(extractor.extract(ctx({ patient: { ...ctx().patient, birthDate: new Date('2000-01-01') } }))).toBeNull();
});
});
describe('② 病历信号 → 禁忌标签', () => {
it('抗凝药 → 手术禁忌 + 种植禁忌(一个病因映射多个治疗域)', () => {
const d = data(withFacts([emr('3年前行心脏支架治疗,现口服抗凝血药,降脂药治疗', '2025-06-01')]));
expect(d?.types).toEqual(expect.arrayContaining(['anticoagulant', 'cardiac_implant']));
expect(d?.labels).toEqual(['手术禁忌', '种植禁忌']); // 按风险固定序,不是命中序
});
it('重度牙周炎(来自诊断原文)→ 正畸禁忌', () => {
const d = data(withFacts([diagnosis('重度牙周炎', '2026-06-01')]));
expect(d?.types).toEqual(['severe_periodontitis']);
expect(d?.labels).toEqual(['正畸禁忌']);
});
it('description 自包含 —— 客服不点开也知道禁什么、凭什么', () => {
const f = extractor.extract(withFacts([emr('2+年前颌面部放疗史', '2025-01-01')]));
expect(f?.description).toBe('手术禁忌 / 种植禁忌(放疗史)');
});
it('evidence.factIds 指向真实病历 —— 旧版是空数组,客服看到标签不知凭什么', () => {
const f = extractor.extract(withFacts([emr('长期口服泰嘉(抗凝药物)', '2026-01-01')]));
expect(f?.evidence.factIds.length).toBe(1);
expect((f?.data as { detail: Array<{ phrase: string }> }).detail[0]!.phrase).toContain('泰嘉');
});
});
describe('③ 时效 —— 禁忌≠终身禁忌', () => {
it('2016 年那条「哺乳期」今天已失效,不再打标', () => {
expect(extractor.extract(withFacts([emr('哺乳期', '2016-03-01')]))).toBeNull();
});
it('三个月前的「哺乳期」仍在有效期内', () => {
const d = data(withFacts([emr('哺乳期', '2026-05-01')]));
expect(d?.types).toContain('lactation');
});
it('放疗终身有效 —— 十年前的记录今天照样禁', () => {
const d = data(withFacts([emr('乳腺癌放疗史', '2016-03-01')]));
expect(d?.types).toContain('radiotherapy');
});
});
describe('④ 超复评期仍算禁忌,只在依据里注明记录较早', () => {
// 抗凝药可能已停,但**漏挡比多挡危险得多** —— 不降级、不消失,把"要不要确认"交给客服看日期判断
it('三年前的抗凝药记录(复评期 730 天)仍是手术/种植禁忌', () => {
const d = data(withFacts([emr('长期口服泰嘉(抗凝药物)', '2023-01-01')]));
expect(d?.types).toContain('anticoagulant');
expect(d?.labels).toEqual(['手术禁忌', '种植禁忌']);
expect(String(d?.detail[0]!.reason)).toContain('记录较早');
});
it('复评期内的不加提示', () => {
const d = data(withFacts([emr('长期口服泰嘉(抗凝药物)', '2026-06-01')]));
expect(String(d?.detail[0]!.reason)).not.toContain('记录较早');
});
});
describe('⑤ 多年病历冲突裁决', () => {
it('终身型:阳性一旦出现,不被后续否定推翻(骨坏死风险不可逆)', () => {
const d = data(
withFacts([
emr('2+年前颌面部放疗史', '2020-01-01'),
emr('否认放疗史、否认系统性疾病史。', '2026-06-01'), // 后来的模板没提 → 不能抹掉
]),
);
expect(d?.types).toContain('radiotherapy');
});
it('非终身型:以最近一条病历为准(妊娠会结束)', () => {
const d = data(
withFacts([
emr('现怀孕7月余', '2026-06-01'),
emr('否认妊娠', '2026-07-01'),
]),
);
expect(d?.types ?? []).not.toContain('pregnancy');
});
});
describe('⑥ check 档不进画像 —— 业务口径:对客服不构成可执行信息', () => {
// 「有糖尿病史但控制状态未知」摆给客服看,只会让人把「问一句」当成「不能治」。
// 信号本身照常抽取并存在 fact 的 clinical_signals 里,事实一条不丢,只是画像层不说。
it('只有糖尿病史 → 完全不产禁忌特征', () => {
expect(extractor.extract(withFacts([emr('糖尿病,长期服药史、其余体健', '2026-06-01')]))).toBeNull();
});
it.each(['高血压病史现服药控制', '骨质疏松症', '患者自述有吸烟史'])('%s 也不产', (t) => {
expect(extractor.extract(withFacts([emr(t, '2026-06-01')]))).toBeNull();
});
it('真禁忌与 check 同句时,只讲禁忌那部分', () => {
const f = extractor.extract(
withFacts([emr('高血压病史现服药控制;曾在外院做个心脏支架手术', '2026-06-01')]),
);
expect(f?.description).toBe('手术禁忌 / 种植禁忌(心脏植入物/心血管手术史)');
expect(f?.description).not.toContain('高血压');
});
it('抽取层照常抽到 check 信号 —— 画像不说 ≠ 事实没存', () => {
const sigs = signalsOf('糖尿病,长期服药史、其余体健', '2026-06-01');
expect(sigs.find((s) => s.code === 'diabetes')?.negated).toBe(false);
});
});
describe('⑦ nextBoundaryAt —— 到期当晚自动重算,标签自己消失', () => {
it('报出哺乳期的失效日', () => {
// 缺了它,这个标签会一直挂到她下次恰好有新病历为止(水位闸只在事实变了时放行)
const at = extractor.nextBoundaryAt(withFacts([emr('哺乳期', '2026-05-01')]));
expect(at?.toISOString().slice(0, 10)).toBe('2026-10-28'); // 2026-05-01 + 180d
});
it('多个到期日取最早的那个', () => {
const at = extractor.nextBoundaryAt(
withFacts([emr('哺乳期', '2026-05-01'), emr('现怀孕7月余', '2026-06-01')]),
);
expect(at?.toISOString().slice(0, 10)).toBe('2026-10-28'); // 哺乳 10-28 早于妊娠 2027-03-08
});
it('全终身型 + 成年 → 无边界(再等下去也不会变)', () => {
expect(extractor.nextBoundaryAt(withFacts([emr('乳腺癌放疗史', '2020-01-01')]))).toBeNull();
});
});
import { diagnosisCodeNameZh, treatmentCategoryNameZh } from '@pac/types'; import { type ClinicalSignal, diagnosisCodeNameZh, treatmentCategoryNameZh } from '@pac/types';
import type { AdaptedFact } from './adapt-data'; import type { AdaptedFact } from './adapt-data';
/** /**
...@@ -12,6 +12,8 @@ export interface FactLabel { ...@@ -12,6 +12,8 @@ export interface FactLabel {
badge: string; badge: string;
badgeTone: string; badgeTone: string;
text: string; text: string;
/** 悬停补充(医生原话全文等);缺省时调用方用 text 兜底 */
hover?: string;
} }
const MODALITY_ZH: Record<string, string> = { const MODALITY_ZH: Record<string, string> = {
...@@ -90,13 +92,49 @@ export function factLabel(f: AdaptedFact): FactLabel { ...@@ -90,13 +92,49 @@ export function factLabel(f: AdaptedFact): FactLabel {
}; };
return { badge: '预约', badgeTone: 'bg-sky-50 text-sky-700', text: ST_ZH[st] ?? st ?? '预约' }; return { badge: '预约', badgeTone: 'bg-sky-50 text-sky-700', text: ST_ZH[st] ?? st ?? '预约' };
} }
case 'encounter_record': // ⚠️ 下面三类的 f.title 都是 `<中文> <externalId>`(接诊 16432552 / 挂号 xxx / 前台接待 xxx)——
return { badge: '就诊', badgeTone: 'bg-teal-50 text-teal-700', text: f.title ?? '到诊' }; // 直接显示等于把宿主内部 ID 甩给客服。一律改取 content 里有语义的字段,取不到就只留中文词。
case 'visit_registration_record': case 'encounter_record': {
return { badge: '挂号', badgeTone: 'bg-teal-50 text-teal-700', text: f.title ?? '挂号' }; const tone = { badge: '就诊', badgeTone: 'bg-teal-50 text-teal-700' };
case 'emr_record': const cc = String(c.chief_complaint ?? '').trim() || String(f.summary ?? '').trim();
// 治疗敏感 / 不可等候的证据来自病历自由文本 —— summary 是命中的那段 const doc = String(c.doctor_name ?? '').trim();
return { badge: '病历', badgeTone: 'bg-slate-100 text-slate-600', text: f.summary ?? f.title ?? '病历' }; if (cc) return { ...tone, text: cc, hover: doc ? `${cc}(${doc}医生)` : cc };
return { ...tone, text: doc ? `${doc}医生接诊` : '到诊' };
}
case 'visit_registration_record': {
const doc = String(c.doctor_name ?? '').trim();
const dept = String(c.department ?? c.department_name ?? '').trim();
const text = [dept, doc ? `${doc}医生` : ''].filter(Boolean).join(' · ') || '挂号';
return { badge: '挂号', badgeTone: 'bg-teal-50 text-teal-700', text };
}
case 'visit_reception_record':
return { badge: '接待', badgeTone: 'bg-teal-50 text-teal-700', text: '前台接待' };
case 'emr_record': {
const tone = { badge: '病历', badgeTone: 'bg-slate-100 text-slate-600' };
// ⚠️ **不能用 f.summary** —— emr parser 写进去的是 `关联接诊:<encounter_id>`(内部 ID),
// 客服在画像「依据」里看到「关联接诊:15514」完全无从理解。
// (旧注释说"summary 是命中的那段",与 emr.parser.ts 实际写入不符,是错的。)
// ① 优先:摄入时抽出的临床信号 —— 这条病历到底"命中了什么"
const sigs = Array.isArray(c.clinical_signals) ? (c.clinical_signals as ClinicalSignal[]) : [];
const positive = sigs.filter((s) => s?.negated === false);
if (positive.length > 0) {
return {
...tone,
text: [...new Set(positive.map((s) => s.label))].join('、'),
// 悬停给医生原话 —— 标签是 PAC 的归纳,原话才是事实本身
hover: [...new Set(positive.map((s) => s.phrase).filter(Boolean))].join('\n'),
};
}
// ② 退一步:病历里真正有内容的那段(SOAP 按信息量排序),而不是内部 ID
const snippet = [c.past_history, c.illness_desc, c.general_condition, c.doctor_advice]
.map((x) => String(x ?? '').trim())
.find((x) => x.length > 0);
if (snippet) return { ...tone, text: snippet.slice(0, 40), hover: snippet };
return { ...tone, text: f.title ?? '病历' };
}
case 'consultation_record': case 'consultation_record':
return { badge: '咨询', badgeTone: 'bg-amber-50 text-amber-700', text: f.title ?? '咨询' }; return { badge: '咨询', badgeTone: 'bg-amber-50 text-amber-700', text: f.title ?? '咨询' };
default: default:
......
...@@ -185,7 +185,7 @@ function FeatureCard({ ...@@ -185,7 +185,7 @@ function FeatureCard({
</div> </div>
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{shown.map((fact) => { {shown.map((fact) => {
const { badge, badgeTone, text: label } = factLabel(fact); const { badge, badgeTone, text: label, hover } = factLabel(fact);
return ( return (
<li key={fact.id} className="flex items-baseline gap-1.5 text-[11px] leading-relaxed"> <li key={fact.id} className="flex items-baseline gap-1.5 text-[11px] leading-relaxed">
{/* 日期不折行 —— 定宽 68px 装不下 `2026-03-27`(11px 字号)会断成两行; {/* 日期不折行 —— 定宽 68px 装不下 `2026-03-27`(11px 字号)会断成两行;
...@@ -194,7 +194,7 @@ function FeatureCard({ ...@@ -194,7 +194,7 @@ function FeatureCard({
{(fact.occurredAt ?? fact.plannedFor ?? '').slice(0, 10) || '—'} {(fact.occurredAt ?? fact.plannedFor ?? '').slice(0, 10) || '—'}
</span> </span>
<span className={cn('flex-none rounded px-1 text-[10px]', badgeTone)}>{badge}</span> <span className={cn('flex-none rounded px-1 text-[10px]', badgeTone)}>{badge}</span>
<span className="min-w-0 flex-1 truncate text-slate-700" title={label}> <span className="min-w-0 flex-1 truncate text-slate-700" title={hover ?? label}>
{label} {label}
</span> </span>
</li> </li>
......
...@@ -1612,6 +1612,80 @@ function RecallBriefLine({ ...@@ -1612,6 +1612,80 @@ function RecallBriefLine({
} }
// ────────────────────────────────────────── // ──────────────────────────────────────────
/**
* contraindication 特征的 data 形态(后端 contraindication.feature.ts)。
* `detail` 是禁忌(absolute/relative),`checks` 是核查项 —— 两者刻意分开,
* 核查项按定义不是禁忌,合并显示会让客服把「问一句」当成「不能治」。
*/
interface ContraindicationEntry {
code: string;
label: string;
reason?: string;
phrase?: string | null;
source?: { factId: string; field: string; occurredAt: string | null } | null;
}
interface ContraindicationData {
labels?: string[];
detail?: ContraindicationEntry[];
}
/**
* 关键事实卡底部「风险提示位」的一条。
* 目前只有画像禁忌产它;这是**通用形态** —— 以后投诉 / 退费纠纷 / 医疗争议接入时
* 按同一形状追加,渲染不用改。
*/
interface RiskNote {
/** 一行标题,如「手术禁忌 / 种植禁忌」 */
title: string;
/** 具体依据,如「放疗史 · 2022-12-02」—— 必须直接露出,不能只塞 tooltip */
detail?: string;
/** hover 补充(病历原文全文) */
tooltip?: string;
/** true = 次要提示(核查项),弱化显示;false = 硬风险 */
muted?: boolean;
}
/**
* 风险提示位开关 —— **当前关闭**。
*
* 关掉的理由(业务口径):画像标签卡的那句摘要已经会提到禁忌,关键事实卡再来一条
* 红块在同屏内重复,信息密度不划算。禁忌本身照常在画像标签 chip + 画像详情抽屉里露出。
*
* 保留代码而不是删掉:这个位置将来要接投诉 / 退费纠纷 / 医疗争议(业务已提出设想,
* 只是现在没数据)。届时把这里改回 true,再往 buildRiskNotes 里追加来源即可。
*/
const SHOW_RISK_NOTES = false;
/** 中文顿号连接,去重去空 */
function joinZh(xs: Array<string | null | undefined>): string {
return [...new Set(xs.filter((x): x is string => !!x))].join('、');
}
function buildRiskNotes(persona: typeof mockPersona): RiskNote[] {
if (!SHOW_RISK_NOTES) return [];
const notes: RiskNote[] = [];
const d = (persona.features.find((f) => f.key === PersonaFeatureKey.CONTRAINDICATION)?.data ??
null) as ContraindicationData | null;
const bans = (d?.labels ?? []).filter(Boolean);
if (bans.length > 0) {
const detail = (d?.detail ?? []).map((x) =>
x.source?.occurredAt ? `${x.label} · ${x.source.occurredAt}` : x.label,
);
notes.push({
title: bans.join(' / '),
detail: joinZh(detail),
// 全文原话留 hover —— 一行放不下「有放射治疗史,三周前进行颈淋巴结清扫(舌癌淋巴结转移)」
tooltip:
(d?.detail ?? [])
.map((x) => `${x.source?.occurredAt ? `${x.source.occurredAt} ` : ''}${x.phrase ?? x.reason ?? x.label}`)
.join('\n') || undefined,
});
}
// ⬆ 以后:投诉记录 / 退费纠纷 / 医疗争议 —— 各自 push 一条 RiskNote 即可(现无数据)
return notes;
}
// ──────────────────────────────────────────
// KeyFactsCard — 关键事实(5 行) // KeyFactsCard — 关键事实(5 行)
// 主治医生 · 累计消费 · 上次到诊 · 首次就诊 · 联系人 // 主治医生 · 累计消费 · 上次到诊 · 首次就诊 · 联系人
// //
...@@ -1708,6 +1782,13 @@ function KeyFactsCard({ ...@@ -1708,6 +1782,13 @@ function KeyFactsCard({
return { value: '-', hint: '' }; return { value: '-', hint: '' };
})(); })();
// ─ 风险提示位 ─ 关键事实卡底部的独立区块。
// 为什么不混进标签云:标签云里禁忌跟「急迫等级/特别关注」同为 rose 底色、同排平铺,
// 客服扫不出来;而这是**打电话前必须先看到**的东西。
// 目前只有画像禁忌一个来源;这个位置是**通用风险位**,以后投诉/退费/纠纷之类
// 接入时按同一形态往 notes 里追加即可(现在还没这些数据,不预建)。
const riskNotes = buildRiskNotes(persona);
// 联系人行已移除 — 监护人改在头部手机号旁标注(身份 姓名)(⚠️ TEST ONLY,见 PatientHeader) // 联系人行已移除 — 监护人改在头部手机号旁标注(身份 姓名)(⚠️ TEST ONLY,见 PatientHeader)
const rows = [ const rows = [
{ label: '主治医生', value: attendingDoctor, hint: mainCategories || '' }, { label: '主治医生', value: attendingDoctor, hint: mainCategories || '' },
...@@ -1741,6 +1822,25 @@ function KeyFactsCard({ ...@@ -1741,6 +1822,25 @@ function KeyFactsCard({
</span> </span>
</div> </div>
))} ))}
{riskNotes.length > 0 && (
<div className="mt-1.5 space-y-1 rounded border border-rose-200 bg-rose-50/70 px-2 py-1.5">
{riskNotes.map((n) => (
<div key={n.title} className="flex items-start gap-1.5 text-[11px]" title={n.tooltip}>
<span className={`flex-none leading-4 ${n.muted ? 'text-rose-400' : 'text-rose-600'}`}>⚠</span>
<div className="min-w-0 flex-1">
<div className={`leading-4 ${n.muted ? 'text-rose-700/80' : 'text-rose-800 font-medium'}`}>
{n.title}
</div>
{/* 具体原因必须直接露出,不能只在 hover 里 —— 只给分类不给依据,
客服无从判断,标签就会被当噪音忽略(旧版 evidence.factIds 为空就是这个下场) */}
{n.detail && (
<div className="text-[10px] leading-4 text-rose-700/70">{n.detail}</div>
)}
</div>
</div>
))}
</div>
)}
</div> </div>
</SidebarCard> </SidebarCard>
); );
......
import { z } from 'zod';
/**
* 临床信号(clinical_signals)—— 从病历自由文本抽出的结构化旁注
*
* 【为什么写在源 fact 的 content 里,而不是新建一个衍生 fact 类型】
* FactType 的设计纪律(enums/index.ts §FactType)明确写着:
* 「fact 只装"源事件",不做衍生 fact 派生 — 衍生信息留在源 fact 的 content JSONB 里」
* 信号是对**同一条病历**的结构化解读,不是新事件 —— 它跟着源 fact 的生命周期走:
* 病历被订正 → transaction 重放 → 信号自动重抽;病历作废 → status 变更,信号随之失效。
* 若另立 fact 类型,就要自己维护一套「源 fact 变了信号怎么办」,凭空造出一致性问题。
*
* 【为什么 valid_until 在抽取时就算好】
* 时效规则(妊娠 280 天 / 哺乳 180 天 / 放疗终身)只活在 clinical-signals.yaml 一处。
* 下游两个消费者各自只做 `valid_until == null || valid_until > now` 这一个比较:
* - 画像(TS):contraindication.feature.ts
* - 召回闸门(SQL,预留):一行 NOT EXISTS,不必在 SQL 里重写一遍时效规则
* 逻辑不分叉 —— 与 ⑤g 未来回访闸「把判断下沉到数据」是同一个手法。
*/
/** 信号影响的治疗域。按**病因**建模、按治疗域映射 —— 一个抗凝药同时禁种植/拔牙/牙周手术。 */
export const TreatmentDomain = {
IMPLANT: 'implant', // 种植
SURGERY: 'surgery', // 外科(拔牙 / 翻瓣 / 植骨)
ANESTHESIA: 'anesthesia', // 局部麻醉
ORTHODONTIC: 'orthodontic', // 正畸
XRAY: 'xray', // 放射检查
} as const;
export type TreatmentDomain = (typeof TreatmentDomain)[keyof typeof TreatmentDomain];
export const TreatmentDomainSchema = z.enum(['implant', 'surgery', 'anesthesia', 'orthodontic', 'xray']);
/**
* 信号严重度 —— 决定下游怎么用,是整个设计的收束点。
* absolute 绝对禁忌:打禁忌标签;将来接召回闸门挡的就是这一档
* relative 相对禁忌:打标签 + 话术警示,**不挡召回**(能不能做由医生按身体条件评估)
* check 核查项 :只提示「预约前确认 X」,不叫禁忌 —— 定量条件无数据的一律落这档
*/
export const SignalSeverity = {
ABSOLUTE: 'absolute',
RELATIVE: 'relative',
CHECK: 'check',
} as const;
export type SignalSeverity = (typeof SignalSeverity)[keyof typeof SignalSeverity];
export const SignalSeveritySchema = z.enum(['absolute', 'relative', 'check']);
/**
* 单条抽取出的信号。写进 `patient_facts.content.clinical_signals[]`。
*
* ⚠️ `negated` 是三态,不是布尔:
* false — 阳性(患者有)
* true — 明确否定(「否认药物过敏史」)。**阴性证据也存** —— 它能区分「没记录」和
* 「医生问过、患者否认」,话术里可以说「上次医生记录您没有药物过敏」;
* 也是防止下游某个消费者把「没查到」误当「没有」的护栏。
* null — 不可判。源数据本身没有结论(医生没删模板里的「否认/自述有」斜杠,生产 116 条)。
* 照常入库供人工复核,但**不产禁忌**。PAC 不替宿主兜底。
*/
export const ClinicalSignalSchema = z.object({
/** 字典里的信号码,如 anticoagulant */
code: z.string().min(1),
/** 中文标签,如「长期抗凝/抗血小板药物」 */
label: z.string().min(1),
/** 命中的原文片段 —— 证据。详情页据此展示医生原话,不是 PAC 的转述 */
phrase: z.string(),
/** 原文中的字符区间 [start, end),用于前端高亮 */
span: z.tuple([z.number().int(), z.number().int()]),
/** 命中所在的 content 字段名(past_history / name_zh …)—— 溯源到具体哪一段 */
field: z.string().min(1),
/** 三态,见上 */
negated: z.boolean().nullable(),
severity: SignalSeveritySchema,
contraindicates: z.array(TreatmentDomainSchema),
/**
* 硬失效日(ISO date)。null = 不会自行失效(终身型如放疗,或 review 型)。
* 已过期的信号**照样入库** —— 2016 年那条「哺乳期」是真发生过的事实,不该被抹掉;
* 由消费方按 `validUntil > now` 过滤。这是「只讲事实」与「别用过期事实下结论」的分工。
*/
validUntil: z.string().nullable(),
/**
* 复评日(ISO date)。过了这天状态可能已变(停药 / 牙周已控制)→ 消费方把它**降级为
* check**,而不是让它消失。「消失」会让客服以为患者从没有过这个病史,那是丢事实。
*
* ⚠️ 存日期而不是 `needsReview: boolean` —— 布尔值依赖计算时刻,写进库当天就开始变质。
*/
reviewAfter: z.string().nullable(),
/** 抽取器版本(clinical-signals@2026-07-27)。字典改版后可按版本定位待重扫的 fact */
extractedBy: z.string().min(1),
});
export type ClinicalSignal = z.infer<typeof ClinicalSignalSchema>;
/** content.clinical_signals 的数组形态。字段**可选** —— 没命中任何信号的 fact 不写这个 key,
* content hash 不变 → 不产生无谓的 fact 版本波。 */
export const ClinicalSignalsSchema = z.array(ClinicalSignalSchema);
/** 治疗域 → 业务侧「X 禁忌」标签。业务方的四分类在这里才出现,不进字典。 */
export const CONTRAINDICATION_LABEL: Record<TreatmentDomain, string> = {
implant: '种植禁忌',
surgery: '手术禁忌',
anesthesia: '麻醉禁忌',
orthodontic: '正畸禁忌',
xray: '拍片禁忌',
};
/** 展示顺序 —— 风险从高到低,前端与话术共用,避免两处各排各的 */
export const CONTRAINDICATION_DOMAIN_ORDER: TreatmentDomain[] = [
'surgery',
'implant',
'anesthesia',
'orthodontic',
'xray',
];
...@@ -4,4 +4,5 @@ export * from './utils'; ...@@ -4,4 +4,5 @@ export * from './utils';
export * from './labels'; export * from './labels';
export * from './canonical-codes'; export * from './canonical-codes';
export * from './persona-feature-specs'; export * from './persona-feature-specs';
export * from './clinical-signals';
export * from './persona-tag-filters'; export * from './persona-tag-filters';
...@@ -546,31 +546,37 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -546,31 +546,37 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
version: 1, version: 1,
}, },
// ── D.2.4 禁忌标签(v1 仅种植年龄;余 Layer C)── // ── D.2.4 禁忌标签(年龄 + 病历临床信号裁决)──
contraindication: { contraindication: {
key: 'contraindication', key: 'contraindication',
nameZh: '禁忌标签', nameZh: '禁忌标签',
tier: 'rule', // v1 规则;完整版需 LLM(Layer C) tier: 'rule', // 规则层:抽取在摄入侧(clinical-signals.yaml),本特征只做裁决
timeSemantics: 'snapshot', // 年龄禁忌随年龄自动解除(validUntil=满19岁) timeSemantics: 'snapshot', // 各类禁忌按自己的时效自动解除(见 algorithm)
labelValues: ['种植禁忌', '正畸禁忌', '麻醉禁忌', '手术禁忌'], labelValues: ['种植禁忌', '正畸禁忌', '麻醉禁忌', '手术禁忌', '拍片禁忌'],
dataSource: '现:patient.birthDate(年龄禁忌);完整版:EMR 既往史/诊断(需 LLM 抽取,Layer C)。宿主未给 health_profile', dataSource:
dataFields: ['health_profile', 'medical_record_tag(既往史+诊断)', 'emr_history'], 'patient.birthDate(年龄禁忌)+ 病历自由文本抽出的 content.clinical_signals(emr_record 既往史/全身情况/现病史/医嘱,diagnosis_record 诊断原文)。宿主未给结构化 health_profile,DW 也无此表',
dataFields: ['patient.birthDate', 'emr_record.clinical_signals', 'diagnosis_record.clinical_signals'],
meaning: '客户因健康/年龄不适合或需谨慎特定治疗;预约/方案前必须核查,影响可行性与安全预警。允许并列', meaning: '客户因健康/年龄不适合或需谨慎特定治疗;预约/方案前必须核查,影响可行性与安全预警。允许并列',
algorithm: [ algorithm: [
'v1 仅实现【种植年龄禁忌:年龄≤18,骨骼发育未完全】(validUntil=满19岁自动解除)。', '两层:摄入时按 data/_shared/dict/clinical-signals.yaml 把病历文本抽成结构化信号(带否定判定 + 到期日),本特征只做裁决。',
'⚠️ 其余禁忌(糖尿病控制不佳/过敏/抗凝/妊娠/放疗…)规则做不了:① 既往史否定泛滥(含"过敏"97%是"否认过敏",naive 匹配=医疗事故);', '严重度三档 —— absolute 绝对禁忌(打标签,将来接召回闸门挡的是这一档)/ relative 相对禁忌(打标签+话术警示,不挡召回,由医生按身体条件评估)/ check 核查项(**不进画像**,只留在 fact 层)。',
'② 量化条件无数据(HbA1c 全量仅10条/血压自由文本);③ 需控制状态/急性期判断+时效窗口 → 留 Layer C(LLM 抽取)。', '时效:妊娠 280 天、哺乳 180 天(产后半年解除)、放疗/抗骨吸收药终身、抗凝药等设复评期(超期仍算禁忌,只在依据行注明"记录较早,建议确认近况")。到期日在抽取时按源病历日期算好,nextBoundaryAt 到期当晚由 02:00 stale-scan 自动重算。',
'⚠️ 定量条件无数据的一律落 check 不判禁忌:糖尿病要 HbA1c(全量仅 10 条)、高血压要收缩压(几乎无人记录)。控制达标的糖尿病可以种植,硬判禁忌医学上是错的;而"有病史但状态未知"对客服不构成可执行信息,故 check 档不出现在画像里,只存在 fact 的 clinical_signals。',
'⚠️ 否定作用域按【句】不按分号 —— 「患者否认有高血压、冠心病;糖尿病、痛风」里分号后的糖尿病仍被否定。生产实测两种切法差 9.4 倍。',
].join('\n'), ].join('\n'),
display: { display: {
subtitle: '治疗安全预警 · 目前只判年龄', subtitle: '治疗安全预警 · 年龄 + 病历既往史/诊断',
rules: [ rules: [
{ label: '种植禁忌', body: '未满 19 岁,颌骨尚未发育完全' }, { label: '种植禁忌', body: '未满 19 岁颌骨未发育完全;或放疗史 / 抗骨吸收药 / 抗凝药 / 妊娠哺乳' },
{ label: '手术禁忌', body: '长期抗凝药、凝血功能障碍、抗骨吸收药、妊娠期 —— 拔牙/翻瓣/植骨前须医生评估' },
{ label: '麻醉禁忌', body: '局麻药物过敏(利多卡因/普鲁卡因等);哮喘为相对禁忌' },
{ label: '正畸禁忌', body: '诊断为重度牙周炎 —— 牙周控制后可矫治,属相对禁忌' },
], ],
group: 'action', group: 'action',
order: 40, order: 40,
}, },
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 2,
}, },
}; };
......
...@@ -185,9 +185,28 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [ ...@@ -185,9 +185,28 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
{ {
key: 'contraindication', key: 'contraindication',
nameZh: '禁忌', nameZh: '禁忌',
dataPath: 'types', /**
* 按【治疗域】筛,不按病因 —— data.domains 而非 data.types。
*
* 客服圈人时想的是「哪些人不能做种植」,不是「哪些人在吃抗骨吸收药」;
* 病因(抗凝药/放疗史/双膦酸盐…)是算法口径,列成筛选项等于把内部实现摊给业务看。
* 病因照常在关键事实卡和画像详情里露出(带医生原话),要查"所有放疗史患者"是另一个
* 场景(风险排查),需要时再加一个独立维度,别混进这里。
*
* 不列「拍片禁忌」:唯一来源是妊娠,而妊娠同时禁种植和手术 —— 勾那两项就已经覆盖,
* 单列只会让面板变长。data.domains 里仍有 xray,详情页照常显示。
*
* 核查项(糖尿病/高血压/骨质疏松/吸烟)不在这里 —— 它们落 data.checks 而非 data.domains,
* 按定义就不是禁忌,放进来会让客服把「问一句」当成「不能治」。
*/
dataPath: 'domains',
isArray: true, isArray: true,
options: [{ value: 'implant_age', zh: '种植年龄禁忌', hint: '未满 19 岁,颌骨尚未发育完全' }], options: [
{ value: 'surgery', zh: '手术禁忌', hint: '拔牙 / 翻瓣 / 植骨 —— 抗凝药、凝血障碍、抗骨吸收药、放疗史、妊娠期' },
{ value: 'implant', zh: '种植禁忌', hint: '未满 19 岁,或放疗史 / 抗骨吸收药 / 抗凝药 / 心血管手术史 / 妊娠哺乳' },
{ value: 'anesthesia', zh: '麻醉禁忌', hint: '局麻药物过敏;哮喘为相对禁忌' },
{ value: 'orthodontic', zh: '正畸禁忌', hint: '诊断为重度牙周炎 —— 牙周控制后可矫治,属相对禁忌' },
],
}, },
{ {
key: 'potential_treatment', key: 'potential_treatment',
......
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