Commit 51581f58 by luoqi

fix(persona): 禁忌筛选改按治疗域四类 + 风险位直接露出真实原因

业务反馈两条,都是我做偏了:

── ① 筛选按四类,不按病因 ──
原来列的是 12 个病因(抗凝药/放疗史/抗骨吸收药…),那是算法口径。
客服圈人时想的是「哪些人不能做种植」,不是「哪些人在吃抗骨吸收药」——
把内部实现摊给业务看,面板还长。改成 data.domains:手术/种植/麻醉/正畸禁忌。
病因照常在关键事实卡与画像详情里露出(带医生原话);要按病因查是风险排查场景,
需要时另加维度,别混进圈人面板。
不列「拍片禁忌」:唯一来源是妊娠,而妊娠同时禁种植和手术,勾那两项已覆盖。

── ② 风险位直接露出真实原因 ──
原来只显示「手术禁忌 / 种植禁忌」,凭什么要 hover 才知道 —— 这正是我自己在设计里
写的「客服不知道凭什么就会当噪音忽略」,结果自己没落实。现在:
     手术禁忌 / 种植禁忌
       放疗史 · 2022-12-02
病因+日期直接成行,全文原话仍留 hover(一行放不下「有放射治疗史,三周前进行
颈淋巴结清扫(舌癌淋巴结转移)」)。
同时把这块抽成通用 RiskNote 形态 —— 业务提到这个位置以后要放投诉之类,
现在没数据不预建,但形状留好:再来一个来源就是往 notes 里 push 一条。

──  顺带发现一个部署坑(本地实测) ──
reparse 只重算**事实变了**的患者。年龄型禁忌那批人事实没动 → 画像没重算 →
data.domains 还是 null → 新筛选完全筛不到他们(生产是 7 万人)。
本地实测:普通 recompute-persona 被水位闸 noop=13260/13268 全跳过;
加 --force 后 success=1804 refreshed=9536,domains 才落库,筛选立刻从
10,458 人收敛到 3 人(= 库里那 3 个局麻药物过敏患者,逐个核对一致)。

所以上线顺序必须是:reparse → recompute-persona **--force**。
--force 的注释里本就记着同类事故(「reparse 后的全量重算被这道闸 noop 掉
325,979 人」),我差点重蹈。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 99e01d42
...@@ -1630,6 +1630,64 @@ interface ContraindicationData { ...@@ -1630,6 +1630,64 @@ interface ContraindicationData {
checks?: ContraindicationEntry[]; checks?: ContraindicationEntry[];
} }
/**
* 关键事实卡底部「风险提示位」的一条。
* 目前只有画像禁忌产它;这是**通用形态** —— 以后投诉 / 退费纠纷 / 医疗争议接入时
* 按同一形状追加,渲染不用改。
*/
interface RiskNote {
/** 一行标题,如「手术禁忌 / 种植禁忌」 */
title: string;
/** 具体依据,如「放疗史 · 2022-12-02」—— 必须直接露出,不能只塞 tooltip */
detail?: string;
/** hover 补充(病历原文全文) */
tooltip?: string;
/** true = 次要提示(核查项),弱化显示;false = 硬风险 */
muted?: boolean;
}
/** 中文顿号连接,去重去空 */
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[] {
const notes: RiskNote[] = [];
const d = (persona.features.find((f) => f.key === PersonaFeatureKey.CONTRAINDICATION)?.data ??
null) as ContraindicationData | null;
if (d) {
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,
});
}
const checks = d.checks ?? [];
if (checks.length > 0) {
notes.push({
title: `待核查:${joinZh(checks.map((c) => c.label))}`,
detail: joinZh(
checks.map((c) => (c.source?.occurredAt ? `记录于 ${c.source.occurredAt}` : null)),
),
tooltip: checks
.map((c) => `${c.source?.occurredAt ? `${c.source.occurredAt} ` : ''}${c.phrase ?? c.label}`)
.join('\n') || undefined,
muted: true,
});
}
}
// ⬆ 以后:投诉记录 / 退费纠纷 / 医疗争议 —— 各自 push 一条 RiskNote 即可(现无数据)
return notes;
}
// ────────────────────────────────────────── // ──────────────────────────────────────────
// KeyFactsCard — 关键事实(5 行) // KeyFactsCard — 关键事实(5 行)
// 主治医生 · 累计消费 · 上次到诊 · 首次就诊 · 联系人 // 主治医生 · 累计消费 · 上次到诊 · 首次就诊 · 联系人
...@@ -1727,27 +1785,12 @@ function KeyFactsCard({ ...@@ -1727,27 +1785,12 @@ function KeyFactsCard({
return { value: '-', hint: '' }; return { value: '-', hint: '' };
})(); })();
// ─ 安全提示 ─ contraindication 画像特征的结构化 data。 // ─ 风险提示位 ─ 关键事实卡底部的独立区块。
// 为什么单独一栏而不是混进标签云:标签云里禁忌跟「急迫等级/特别关注」同为 rose 底色、 // 为什么不混进标签云:标签云里禁忌跟「急迫等级/特别关注」同为 rose 底色、同排平铺,
// 同排平铺,客服扫不出来;而这是**打电话前必须先看到**的东西。 // 客服扫不出来;而这是**打电话前必须先看到**的东西。
// 禁忌与核查项分开显示 —— 核查项(有糖尿病史但控制状态未知)按定义不是禁忌, // 目前只有画像禁忌一个来源;这个位置是**通用风险位**,以后投诉/退费/纠纷之类
// 混在一起会让客服把「问一句」当成「不能治」。 // 接入时按同一形态往 notes 里追加即可(现在还没这些数据,不预建)。
const safety = (() => { const riskNotes = buildRiskNotes(persona);
const d = (persona.features.find((f) => f.key === PersonaFeatureKey.CONTRAINDICATION)?.data ??
null) as ContraindicationData | null;
if (!d) return null;
const bans = (d.labels ?? []).filter(Boolean);
const checks = [...new Set((d.checks ?? []).map((c) => c.label).filter(Boolean))];
if (bans.length === 0 && checks.length === 0) return null;
// tooltip 给医生原话 + 记录日期 —— 客服要能看到「凭什么」,否则标签只会被当噪音忽略
const evidence = (d.detail ?? [])
.map((x) => {
const when = x.source?.occurredAt ? `${x.source.occurredAt} ` : '';
return `${when}${x.phrase ?? x.reason ?? x.label}`;
})
.filter(Boolean);
return { bans, checks, evidence };
})();
// 联系人行已移除 — 监护人改在头部手机号旁标注(身份 姓名)(⚠️ TEST ONLY,见 PatientHeader) // 联系人行已移除 — 监护人改在头部手机号旁标注(身份 姓名)(⚠️ TEST ONLY,见 PatientHeader)
const rows = [ const rows = [
...@@ -1782,26 +1825,23 @@ function KeyFactsCard({ ...@@ -1782,26 +1825,23 @@ function KeyFactsCard({
</span> </span>
</div> </div>
))} ))}
{safety && ( {riskNotes.length > 0 && (
<div <div className="mt-1.5 space-y-1 rounded border border-rose-200 bg-rose-50/70 px-2 py-1.5">
className="mt-1.5 rounded border border-rose-200 bg-rose-50/70 px-2 py-1.5" {riskNotes.map((n) => (
title={safety.evidence.length ? `病历原文:\n${safety.evidence.join('\n')}` : undefined} <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="flex items-start gap-1.5 text-[11px]"> <div className="min-w-0 flex-1">
<span className="text-rose-600 flex-none leading-4">⚠</span> <div className={`leading-4 ${n.muted ? 'text-rose-700/80' : 'text-rose-800 font-medium'}`}>
<div className="min-w-0 flex-1 space-y-0.5"> {n.title}
{safety.bans.length > 0 && (
<div className="text-rose-800 font-medium leading-4">
{safety.bans.join(' / ')}
</div>
)}
{safety.checks.length > 0 && (
<div className="text-rose-700/80 text-[10px] leading-4">
待核查:{safety.checks.join('、')}
</div> </div>
{/* 具体原因必须直接露出,不能只在 hover 里 —— 只给分类不给依据,
客服无从判断,标签就会被当噪音忽略(旧版 evidence.factIds 为空就是这个下场) */}
{n.detail && (
<div className="text-[10px] leading-4 text-rose-700/70">{n.detail}</div>
)} )}
</div> </div>
</div> </div>
))}
</div> </div>
)} )}
</div> </div>
......
...@@ -185,23 +185,27 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [ ...@@ -185,23 +185,27 @@ 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,
// 只列**绝对/相对禁忌**;核查项(糖尿病/高血压/骨质疏松/吸烟)不在这里 ——
// 它们落在 data.checks 而非 data.types,按定义就不是禁忌,放进禁忌筛选会误导圈人。
options: [ options: [
{ value: 'implant_age', zh: '种植年龄禁忌', hint: '未满 19 岁,颌骨尚未发育完全' }, { value: 'surgery', zh: '手术禁忌', hint: '拔牙 / 翻瓣 / 植骨 —— 抗凝药、凝血障碍、抗骨吸收药、放疗史、妊娠期' },
{ value: 'anticoagulant', zh: '长期抗凝药', hint: '出血风险 —— 拔牙/种植/牙周手术前须医生评估停药' }, { value: 'implant', zh: '种植禁忌', hint: '未满 19 岁,或放疗史 / 抗骨吸收药 / 抗凝药 / 心血管手术史 / 妊娠哺乳' },
{ value: 'coagulopathy', zh: '凝血功能障碍', hint: '血友病 / 血小板减少等' }, { value: 'anesthesia', zh: '麻醉禁忌', hint: '局麻药物过敏;哮喘为相对禁忌' },
{ value: 'antiresorptive', zh: '抗骨吸收药物', hint: '双膦酸盐/地舒单抗 —— 颌骨坏死风险,终身' }, { value: 'orthodontic', zh: '正畸禁忌', hint: '诊断为重度牙周炎 —— 牙周控制后可矫治,属相对禁忌' },
{ value: 'radiotherapy', zh: '放疗史', hint: '颌骨放射性骨坏死风险,终身' },
{ value: 'chemotherapy', zh: '化疗史', hint: '相对禁忌,一年内需评估' },
{ value: 'cardiac_implant', zh: '心脏植入物/心血管手术史', hint: '起搏器/支架/搭桥 —— 手术应激风险' },
{ value: 'pregnancy', zh: '妊娠期', hint: '含拍片禁忌;记录日起 280 天自动解除' },
{ value: 'lactation', zh: '哺乳期', hint: '记录日起 180 天自动解除(产后半年)' },
{ value: 'local_anesthetic_allergy', zh: '局麻药物过敏', hint: '利多卡因/普鲁卡因等' },
{ value: 'asthma', zh: '哮喘', hint: '麻醉相对禁忌' },
{ value: 'severe_periodontitis', zh: '重度牙周炎', hint: '正畸相对禁忌 —— 牙周控制后可矫治' },
], ],
}, },
{ {
......
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