Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
2111418b
Commit
2111418b
authored
Jul 27, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: feat/contraindication-model → main(禁忌能力:病历抽临床信号 + 三档裁决 + 四类圈人 + FRIDAY push 索引)
parents
887069e5
89dac080
Hide whitespace changes
Inline
Side-by-side
Showing
21 changed files
with
1705 additions
and
66 deletions
+1705
-66
apps/pac-service/data/_shared/dict/clinical-signals.yaml
+242
-0
apps/pac-service/prisma/migrations/20260727070000_add_patient_transactions_sync_log_id_idx/migration.sql
+33
-0
apps/pac-service/prisma/schema.prisma
+10
-0
apps/pac-service/src/app.module.ts
+3
-0
apps/pac-service/src/modules/ai/calls/draft-plan-script/shared/fact-block.ts
+10
-1
apps/pac-service/src/modules/clinical-signals/clinical-signal.service.ts
+100
-0
apps/pac-service/src/modules/clinical-signals/clinical-signals.module.ts
+16
-0
apps/pac-service/src/modules/clinical-signals/signal-dict.ts
+98
-0
apps/pac-service/src/modules/clinical-signals/signal-extractor.ts
+217
-0
apps/pac-service/src/modules/persona/features/contraindication.feature.ts
+193
-34
apps/pac-service/src/modules/sync/pipeline/fact-content-schemas.ts
+23
-0
apps/pac-service/src/modules/sync/pipeline/parser-pipeline.service.ts
+20
-7
apps/pac-service/tests/clinical-signal-extractor.spec.ts
+227
-0
apps/pac-service/tests/persona-contraindication.spec.ts
+215
-0
apps/pac-web/src/components/plan-detail/fact-label.ts
+46
-8
apps/pac-web/src/components/plan-detail/persona-detail-list.tsx
+2
-2
apps/pac-web/src/components/plan-detail/plan-detail-app.tsx
+100
-0
packages/types/src/clinical-signals.ts
+110
-0
packages/types/src/index.ts
+1
-0
packages/types/src/persona-feature-specs.ts
+18
-12
packages/types/src/persona-tag-filters.ts
+21
-2
No files found.
apps/pac-service/data/_shared/dict/clinical-signals.yaml
0 → 100644
View file @
2111418b
# ═══════════════════════════════════════════════════════════
# 共享字典 · 临床信号抽取(既往史 / 诊断原文 → 结构化 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 不造数据只讲事实」在本模块的具体落法。
# ─────────────────────────────────────────────────────────
apps/pac-service/prisma/migrations/20260727070000_add_patient_transactions_sync_log_id_idx/migration.sql
0 → 100644
View file @
2111418b
-- 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"
);
apps/pac-service/prisma/schema.prisma
View file @
2111418b
...
@@ -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
行患者主档耗时
74
s
,
EXPLAIN
为
///
`
Parallel
Seq
Scan
on
patient_transactions
(
cost
=
0.00
.
.2150645.78
)`,
///
表
1251
万行
/
33
GB
;
生产同表
2407
万行
/
64
GB
(
更慢
)
。
///
对方
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
+
建索引
)
...
...
apps/pac-service/src/app.module.ts
View file @
2111418b
...
@@ -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
,
...
...
apps/pac-service/src/modules/ai/calls/draft-plan-script/shared/fact-block.ts
View file @
2111418b
...
@@ -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: '据分群定语气:重要/高价值→更上心维系、可适度尊享;低活跃→先唤醒别硬推' },
...
...
apps/pac-service/src/modules/clinical-signals/clinical-signal.service.ts
0 → 100644
View file @
2111418b
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
);
}
}
apps/pac-service/src/modules/clinical-signals/clinical-signals.module.ts
0 → 100644
View file @
2111418b
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
{}
apps/pac-service/src/modules/clinical-signals/signal-dict.ts
0 → 100644
View file @
2111418b
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
;
}
apps/pac-service/src/modules/clinical-signals/signal-extractor.ts
0 → 100644
View file @
2111418b
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
]));
}
apps/pac-service/src/modules/persona/features/contraindication.feature.ts
View file @
2111418b
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
>
=
[
ContraindicationFeatureExtractor
.
IMPLANT_MIN_AGE
,
nextAgeBoundary
(
ctx
.
patient
.
birthDate
,
ctx
.
now
,
[
]);
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
));
}
}
apps/pac-service/src/modules/sync/pipeline/fact-content-schemas.ts
View file @
2111418b
...
@@ -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 抽取消费)
...
...
apps/pac-service/src/modules/sync/pipeline/parser-pipeline.service.ts
View file @
2111418b
...
@@ -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
(
transaction
:
item
.
transaction
,
parser
.
parse
({
canonicalRow
:
item
.
canonicalRow
,
transaction
:
item
.
transaction
,
});
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
({
...
...
apps/pac-service/tests/clinical-signal-extractor.spec.ts
0 → 100644
View file @
2111418b
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
);
});
});
apps/pac-service/tests/persona-contraindication.spec.ts
0 → 100644
View file @
2111418b
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
();
});
});
apps/pac-web/src/components/plan-detail/fact-label.ts
View file @
2111418b
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
:
...
...
apps/pac-web/src/components/plan-detail/persona-detail-list.tsx
View file @
2111418b
...
@@ -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
>
...
...
apps/pac-web/src/components/plan-detail/plan-detail-app.tsx
View file @
2111418b
...
@@ -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>
);
);
...
...
packages/types/src/clinical-signals.ts
0 → 100644
View file @
2111418b
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'
,
];
packages/types/src/index.ts
View file @
2111418b
...
@@ -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'
;
packages/types/src/persona-feature-specs.ts
View file @
2111418b
...
@@ -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
,
},
},
};
};
...
...
packages/types/src/persona-tag-filters.ts
View file @
2111418b
...
@@ -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'
,
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment