Commit 2111418b by luoqi

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

parents 887069e5 89dac080
-- 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]));
}
...@@ -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(
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({
......
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