Commit 59038480 by luoqi

feat(recall+ingest+ui): 深窝沟去信号 / 处置→治疗 / 按牙相减召回 / 牙位事实抽屉

后端 · 召回 & 摄入口径
- diagnosis.yaml:深窝沟/脱矿/牙本质敏感/牙菌斑堆积/食物嵌塞 摘出疾病码(预防/风险/症状,
  落 code=null 不召、病历照原文显示;菌斑性龈炎等真病灶保留)
- 处置(EMR.dispose)→ actual 治疗:treat_plan 空时拆 dispose,message union 进 treatment_actual_rows,
  复用现有 keyword_mapping 分类 + treatment.parser(不加 assembler/parser/置信度)。
  filter `empty` 谓词升级:空壳 JSON 数组([{treatName:""}] 占位行)也算空 → 正确去 dispose 找治疗。
  treatment_actual keyword 顺序修:窝沟封闭提到充填前、牙周去裸"洁牙"(避开"清洁牙面"误命中)。
- 召回按牙相减(修多牙诊断被部分治疗整体误抑制):
  scenario LATERAL 算 sig_teeth / resolved_teeth(⑤a同类∪⑤c拔除∪⑤e替代,按牙)/ remaining;
  有牙位信号 → 剩余未治牙位非空才召、reason 牙位=剩余;全口信号沿用 category 级。
  例 龚靖舜 浅龋@16;26;46;36 补了16 → 召 caries_no_filling@26;36;46(不再整簇误压)。

前端
- 关键事实卡加「牙位 →」抽屉:每颗牙 + 全口(牙周/正畸/其他)泳道,时间倒序、零推断展示。
- 事实时间轴 + 牙位抽屉:同一时刻按类型排序,随整体倒序(治疗→计划→建议→影像→诊断)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent a4973449
......@@ -73,7 +73,9 @@ enum_mapping:
中龋: K02
深龋: K02
继发龋: K02
深窝沟: K02 # W4 末补,DW 5045 hits;窝沟早期龋兆,临床归 K02
# ⚠️ 深窝沟 故意不映射(落 _default → code=null,不召、病历照原文显示):
# 它是龋【风险】(窝沟深易蛀,无龋洞),对症是窝沟封闭(preventive 预防),
# 不属于"应治未治(龋洞没补)"召回范畴。归任何疾病码都会误召(预防治疗满足不了"已治"闸)。
# K04 牙髓 / 根尖周
牙髓炎: K04
慢性牙髓炎: K04
......@@ -177,14 +179,15 @@ enum_mapping:
复杂冠折: K03
根折: K03
牙外伤: K03
脱矿: K03 # 早期牙齿硬组织脱矿
牙本质敏感: K03
# ⚠️ 脱矿 / 牙本质敏感 故意不映射(落 _default → code=null):
# 脱矿=早期龋【风险】(对症涂氟,预防)、牙本质敏感=【症状】(对症脱敏,非修复缺损)。
# 跟已 null 的"牙釉质脱矿/釉质脱矿"口径统一,均不作"应治未治"召回信号。
# K05 牙周 / 牙龈(补 host 真实高频用词)
牙周病: K05
全口慢性牙周炎: K05
萌出性龈炎: K05 # 实际牙龈炎,临床路径同 K05
牙菌斑堆积: K05
# ⚠️ 牙菌斑堆积 不映射(卫生/菌斑【风险】,对症洁治/宣教=预防);菌斑性龈炎是真龈炎,保留 K05
# K06 牙龈 / 牙槽嵴疾患(原本走 _default → code=null,W3 末归 K06)
牙周脓肿: K06
......@@ -204,7 +207,7 @@ enum_mapping:
龈息肉: K06
根分叉病变: K06 # K05.x 并发,但临床上独立标
重度根分叉病变: K06
食物嵌塞: K06 # ICD 实际归 K07.6,但临床走 periodontic 处置 → 归 K06 路径
# ⚠️ 食物嵌塞 不映射(【症状】,对症调牙合/牙周处置,非"应治未治"病灶)→ 落 _default code=null
附着丧失: K06
附着龈缺如: K06
牙槽骨缺损: K06
......
......@@ -449,9 +449,12 @@ keyword_mapping:
- { value: orthodontic, any: [正畸, 矫治, 矫正, 托槽, 保持器, 粘附件, 隐适美, 隐形矫, 扩弓] }
- { value: cosmetic, any: [贴面, 漂白, 美白] }
- { value: prosthodontic, any: [, , 义齿, 修复体, 桩核, 桩冠, 戴牙, 全瓷, 烤瓷, 重新粘接] }
# 窝沟封闭提到 restorative 前:玻璃离子常作封闭剂材料("玻璃离子窝沟封闭"≠充填)
- { value: preventive, any: [窝沟封闭, 封闭剂, 点隙封闭] }
- { value: restorative, any: [充填, 补牙, 树脂, 玻璃离子, 嵌体, 垫底] }
- { value: periodontic, any: [洁治, 洗牙, 洁牙, 龈上, 龈下, 刮治, 牙周, 喷砂, 细洁] }
- { value: preventive, any: [涂氟, 窝沟封闭, 封闭, 防龋, OHI, 口腔卫生宣教] }
# 牙周去裸"洁牙"(避开自由文本"清洁牙面"误命中);洁牙/全口洁牙等精确词由上方 enum_mapping 兜
- { value: periodontic, any: [洁治, 洗牙, 龈上, 龈下, 刮治, 牙周, 喷砂, 细洁] }
- { value: preventive, any: [涂氟, 防龋, OHI, 口腔卫生宣教] }
- { value: surgical, any: [拔除, 拔牙, 切开, 翻瓣, 切除, 系带, 脓肿, 囊肿] }
- { value: pediatric, any: [乳牙, 儿童, 年轻恒牙] }
......
......@@ -513,6 +513,52 @@ transforms:
from: treat_name
value: ''
# ── C.9 dispose(处置自由文本)→ 补 actual 治疗(仅 treat_plan 空时)──
# 背景:~15% EMR 只有处置、treat_plan 空,处置里其实已做治疗(充填/拔除/根管…)。
# 思路(最简):treat_plan 空时拆 dispose,把 message 当作 treat_name **直接 union 进
# treatment_actual_rows** → 复用 treatment_actual.yaml 的 keyword_mapping(含词分类)
# + treatment.parser → 落普通 treatment_completed。不加 assembler/parser/置信度。
# gate(treat_plan 空,含 "[]")避免与结构化重复(全量实测 treat_plan 非空时处置 ~63% 是同次重复叙述)。
- kind: filter
input: fact_emr_treatment_out
output: _emr_dispose_only
where:
treat_plan: { empty: true }
- kind: split_json_array
input: _emr_dispose_only
output: _dispose_raw
array_field: dispose
parent_keys:
emr_id: id
patient_id: patient_id
organization_id: organization_id
brand: brand
rq: rq
user_id: user_id
doctor_name: doctor_name
created_date: created_date
updated_date: updated_date
element_fields:
treat_name: message # message 当 treat_name:既给 keyword_mapping 分类,又作 subtype
tooth_position: toothPosition
where:
treat_name: { not_empty: true }
- kind: derive
input: _dispose_raw
output: _dispose_tx
fields:
treat_external_id:
op: concat
parts: ['${emr_id}', '|disp|', '${tooth_position}', '|', '${treat_name}']
category_raw: # = message;treatment_actual.yaml enum 未命中 → keyword_mapping 含词分类
op: default
from: treat_name
value: ''
# union 进现有 treatment_actual_rows → 走同一 assembler + parser,落 treatment_completed
- kind: union
inputs: ['treatment_actual_rows', '_dispose_tx']
output: treatment_actual_rows
# ── E. EMR.file_url JSON 拆行 → 影像 metadata(image_record) ──
# file_url 元素:{ check_name(影像类型), file_url(存储路径), created_gmt_at(拍摄时间) }
# PAC 不持文件本体,只落 metadata:类型 / 时间 / 关联接诊 → "拍过 CBCT=种植意向" 类信号
......
......@@ -264,6 +264,31 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
? Prisma.sql`NULL::text`
: Prisma.sql`sig.content->>'tooth_position'`;
// ⭐ 牙位级"按牙相减"(W5:修多牙诊断被部分治疗整体误抑制)
// 牙位字符串 → 数字牙位数组(剥牙面后缀):"16;26 B;36" → {16,26,36}
const toothArrSql = (expr: Prisma.Sql) =>
Prisma.sql`array_remove(string_to_array(regexp_replace(${expr}, '[^0-9;]+', ';', 'g'), ';'), '')`;
// ⑤e 替代治疗的时间方向(诊断之后);per-tooth 码恒用,wholeMouth 码不走此路径
const afterDxRtx = Prisma.sql`AND rtx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`;
// 该信号"已被解决"的牙位集合 = ⑤a 同类(afterDx)∪ ⑤c 拔除(任意时间)∪ ⑤e 种植/冠桥(afterDx),
// 只取有牙位的 actual 治疗。下游用 sig 牙位 − resolved 得"剩余未治牙位"。
const afterDxFragRtx = rule.excludeIfEverTreated
? Prisma.empty
: Prisma.sql`AND rtx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`;
const resolvedTeethSql = Prisma.sql`
(SELECT COALESCE(array_agg(DISTINCT rtt), ARRAY[]::text[])
FROM patient_facts rtx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rtx.content->>'tooth_position'`)}) AS rtt
WHERE rtx.patient_id = p.id
AND rtx.type = 'treatment_record' AND rtx.kind = 'actual'
AND rtx.status IN ('active', 'fulfilled')
AND COALESCE(NULLIF(trim(rtx.content->>'tooth_position'), ''), '') != ''
AND (
(rtx.content->>'category' = ANY(${excludeCats}::text[]) ${afterDxFragRtx}) -- ⑤a 同类
OR (rtx.content->>'category' = 'surgical') -- ⑤c 拔除(终结,任意时间)
OR (rtx.content->>'category' IN ('implant', 'prosthodontic') ${afterDxRtx}) -- ⑤e 替代定性
))`;
// ╔═════════════════════════════════════════════════════════════════════╗
// ║ 召回 SQL 完整解读(initiation = 潜在治疗新链召回) ║
// ║ ║
......@@ -332,7 +357,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
sig.id AS signal_fact_id,
sig.type AS signal_type,
sig.content->>'code' AS signal_code,
${sigToothExpr} AS tooth, -- ⭐ wholeMouth 码忽略 dx 牙位(见上 sigToothExpr)
-- ⭐ 牙位级相减:有牙位信号 → 输出"剩余未治牙位"(诊断牙位 − 已治牙位);
-- 全口信号(sig_teeth 空)→ 原样(NULL,下游归 whole cluster)
CASE WHEN cardinality(lat.sig_teeth) = 0
THEN ${sigToothExpr}
ELSE array_to_string(lat.remaining_teeth, ';') END AS tooth,
sig.content->>'extracted_by' AS extracted_by,
sig.content->>'confidence' AS confidence,
sig.clinic_id AS clinic_id,
......@@ -341,6 +370,16 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
FROM patients p
JOIN patient_profiles pp ON pp.patient_id = p.id
JOIN patient_facts sig ON sig.patient_id = p.id
-- ⭐ 按牙相减:算 sig 牙位 / 已解决牙位 / 剩余未治牙位(供 tooth 输出 + ⑤a 闸)
LEFT JOIN LATERAL (
SELECT st AS sig_teeth, rt AS resolved_teeth,
(SELECT COALESCE(array_agg(x), ARRAY[]::text[])
FROM unnest(st) AS x WHERE x <> ALL(rt)) AS remaining_teeth
FROM (
SELECT COALESCE(${toothArrSql(sigToothExpr)}, ARRAY[]::text[]) AS st,
${resolvedTeethSql} AS rt
) base
) lat ON true
WHERE p.host_id = ${scope.hostId}::uuid -- ① 隔离闸
AND p.tenant_id = ${scope.tenantId} -- ① 隔离闸
${patientFilter} -- 单刷收窄(可空)
......@@ -355,65 +394,28 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
-- ④' 临床语义剔除:废用牙/无功能牙(host 映射到 K08,但牙还在无功能 → 该拔/观察,非修复对象)
-- 不进种植召回。仅这些 name_zh 受影响(它们只在 K08),其余子场景诊断不含此名 → 无副作用。
AND COALESCE(sig.content->>'name_zh', '') <> ALL(${RESTORATION_INELIGIBLE_NAMES}::text[])
AND NOT EXISTS ( -- ⑤a 排除:同类 actual 治疗(已开始做)
SELECT 1 FROM patient_facts tx
WHERE tx.patient_id = p.id
AND tx.type = 'treatment_record'
AND tx.kind = 'actual'
AND tx.status IN ('active', 'fulfilled') -- actual 完成 status=fulfilled
AND tx.content->>'category' = ANY(${excludeCats}::text[])
${afterDxFrag} -- ⭐ 时间方向:诊断之后(excludeIfEverTreated 码忽略,见 afterDxFrag)
-- W4 末升级:牙位级 overlap(详见上面 ⑤a 注释 box)
AND (
-- 信号无牙位(全口诊断如 K05)→ patient/category 级排除,跟现状一致
-- ⭐ wholeMouth 码 sigToothExpr=NULL → 此条恒真 → 走 category 级排除(忽略 dx 自带牙位)
COALESCE(NULLIF(trim(${sigToothExpr}), ''), '') = ''
-- actual 无牙位 → 仅 periodontic/orthodontic(整牙弓治疗,无牙位是常态:实测
-- 牙周 134:1、正畸 19:1 压倒性无牙位)才视为"全口覆盖"→ 排除该 category 所有信号。
-- surgical/restorative/prosthodontic 等 per-tooth 类目(实测压倒性有牙位)的无牙位 actual
-- 多是录入缺失或急性处置(如切开引流/无牙位拔除),不代表全口都治了 → 不当覆盖,
-- 要求有牙位 + 牙位交集(走下面分支)。否则一次无牙位外科处置会误杀同患者所有同类召回。
OR (
COALESCE(NULLIF(trim(tx.content->>'tooth_position'), ''), '') = ''
AND tx.content->>'category' IN ('periodontic', 'orthodontic')
)
-- 双方都有牙位 → tooth array overlap
-- 牙位字符串形如 "15;24;26" / "15 B;24 B"(B/L/M 等牙面后缀)
-- 1. regexp_replace 把非数字非分号字符(空格/B/L/M 牙面)替换成 ; → "15;24;26;"
-- 2. string_to_array(...,';') → 含空字符串元素 {"15","24","26",""}
-- 3. array_remove(...,'') 去掉空元素 → {"15","24","26"}
-- 4. && PG array overlap 操作符,任一元素相同即 true
-- ⚠️ 必须 array_remove '' — 否则两个 array 都含空字符串时 '' = '' 误返 true
OR array_remove(
string_to_array(regexp_replace(${sigToothExpr}, '[^0-9;]+', ';', 'g'), ';'),
''
) && array_remove(
string_to_array(regexp_replace(tx.content->>'tooth_position', '[^0-9;]+', ';', 'g'), ';'),
''
)
-- ⑤a 牙位级排除(W5 按牙相减,修"多牙诊断被部分治疗整体误抑制"):
-- 全口信号(sig_teeth 空,如 K05/K07)→ 沿用 category 级 NOT EXISTS(做过同类治疗即排)
-- 有牙位信号 → "剩余未治牙位"非空才召(⑤a 同类 / ⑤c 拔除 / ⑤e 替代 已折进 resolved_teeth 按牙相减)
-- 例 龚靖舜 浅龋@16;26;46;36 只补了 16 → remaining={26,36,46} 非空 → 仍召这三颗
AND (
CASE WHEN cardinality(lat.sig_teeth) = 0 THEN
NOT EXISTS (
SELECT 1 FROM patient_facts tx
WHERE tx.patient_id = p.id
AND tx.type = 'treatment_record'
AND tx.kind = 'actual'
AND tx.status IN ('active', 'fulfilled')
AND tx.content->>'category' = ANY(${excludeCats}::text[])
${afterDxFrag}
-- 全口信号 sigToothExpr=NULL → 下条恒真 → category 级排除(忽略 dx 自带牙位)
AND COALESCE(NULLIF(trim(${sigToothExpr}), ''), '') = ''
)
ELSE
cardinality(lat.remaining_teeth) > 0
END
)
AND NOT EXISTS ( -- ⑤c 排除:同牙位拔除 actual(W4 末)
-- 临床原则:拔除是任何牙病的终结处理 — 拔了就不需要任何后续治疗(根管/充填/牙周等)
-- 例:陈化冰 K05 17;18 同日拔了 17;18 → 不该召牙周;K04 46 拔了 → 不该召根管
-- 不限时间方向(拔了就拔了,后期 host 写"牙列缺失" 类诊断只是记录现状,不该召)
-- 仅对**有具体牙位的信号**生效(全口诊断 K05 不走这里,避免任何拔牙误排全口)
SELECT 1 FROM patient_facts surg
WHERE surg.patient_id = p.id
AND surg.type = 'treatment_record'
AND surg.kind = 'actual'
AND surg.status IN ('active', 'fulfilled')
AND surg.content->>'category' = 'surgical'
-- ⭐ wholeMouth 码 sigToothExpr=NULL → 此守卫恒假 → ⑤c 不生效(单颗拔除不终结全口病)
AND COALESCE(NULLIF(trim(${sigToothExpr}), ''), '') != '' -- 信号必须有牙位
AND array_remove(
string_to_array(regexp_replace(${sigToothExpr}, '[^0-9;]+', ';', 'g'), ';'),
''
) && array_remove(
string_to_array(regexp_replace(surg.content->>'tooth_position', '[^0-9;]+', ';', 'g'), ';'),
''
)
)
-- (⑤c 同牙位拔除 已折进上面 resolved_teeth 的 surgical 分支 — 拔了的牙从 remaining 减掉)
AND NOT EXISTS ( -- ⑤b 排除:患者已有未来预约
-- 召回目的 = 让客服建预约。患者已经有未来预约 → 客服不需要再 push,医生到诊现场处理即可
-- 不按 complaint_category 匹配(陆伟根:K07 诊断 + 修复预约 → 反正会来诊)
......@@ -440,31 +442,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
WHERE trim(c) = ANY(${complaintTexts}::text[])
)
)
AND NOT EXISTS ( -- ⑤e 排除:同牙位"替代性定性治疗"(种植/冠桥)
-- 镜像 chain-composer.markAlternativeClosed:同一颗牙诊断后做了 implant / prosthodontic
-- (种植 / 冠桥 = definitive 替代修复)→ 该牙已定性处理,原诊断(根管/充填/牙体损伤等)
-- 召回失去意义。例 韩雷:K04@11 但 11 已戴冠(prosthodontic)→ 治疗链替代闭环,
-- 旧召回 SQL 只看 endodontic actual 漏掉 → 误召;此闸补齐"召回 ↔ 治疗链"口径。
-- 拔除(surgical)已由 ⑤c 覆盖;K08 的 implant/prosthodontic 已在 ⑤a(同类目)— 此处冗余无害。
-- 仅对有具体牙位信号生效(全口诊断不走);要求 actual 有牙位 + 牙位交集 + 诊断之后。
SELECT 1 FROM patient_facts alt
WHERE alt.patient_id = p.id
AND alt.type = 'treatment_record'
AND alt.kind = 'actual'
AND alt.status IN ('active', 'fulfilled')
AND alt.content->>'category' IN ('implant', 'prosthodontic')
AND alt.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for) -- 诊断之后
-- ⭐ wholeMouth 码 sigToothExpr=NULL → 此守卫恒假 → ⑤e 不生效(单颗冠桥不终结全口病)
AND COALESCE(NULLIF(trim(${sigToothExpr}), ''), '') != '' -- 信号有牙位
AND COALESCE(NULLIF(trim(alt.content->>'tooth_position'), ''), '') != '' -- actual 有牙位
AND array_remove(
string_to_array(regexp_replace(${sigToothExpr}, '[^0-9;]+', ';', 'g'), ';'),
''
) && array_remove(
string_to_array(regexp_replace(alt.content->>'tooth_position', '[^0-9;]+', ';', 'g'), ';'),
''
)
)
-- (⑤e 同牙位替代治疗 种植/冠桥 已折进上面 resolved_teeth 的 implant/prosthodontic 分支 — 诊断后做了的从 remaining 减掉)
AND NOT EXISTS ( -- ⑤f 就诊冷静:近 N 天到过诊 → 别催刚来过的人(防打扰)
-- 患者级、与具体信号无关:最近一次到诊(encounter/emr)在 N 天内 → 本轮不召。
-- 锚"最近到诊"而非"诊断日" → 补 cooldown 漏的"旧诊断 + 近期又来过"场景。
......
......@@ -24,11 +24,42 @@ export function runFilter(op: FilterOp, rows: Row[]): { rows: Row[]; dropped: nu
return { rows: out, dropped };
}
/// "空"判定(给 where.empty 用):null/""/空白/"[]",或【空壳 JSON 数组】——
/// 数组元素全无实质内容(所有非 *Bak base64、非数组的标量字段都空)。
/// host EMR 常见占位行 [{treatName:"", toothPositionBak:"<base64>"}] 视为空,用于 gate "无结构化治疗"。
function isBlankValue(v: unknown): boolean {
if (isEmptyText(v)) return true;
if (typeof v !== 'string') return false;
const t = v.trim();
if (t === '[]') return true;
if (!t.startsWith('[')) return false;
try {
const arr = JSON.parse(t);
if (!Array.isArray(arr)) return false;
const hasContent = arr.some(
(el) =>
el &&
typeof el === 'object' &&
Object.entries(el as Record<string, unknown>).some(
([k, val]) => !k.endsWith('Bak') && typeof val === 'string' && val.trim() !== '',
),
);
return !hasContent;
} catch {
return false;
}
}
function passAll(row: Row, where: Record<string, WhereRule>): boolean {
for (const [field, rule] of Object.entries(where)) {
const v = row[field];
if ('not_empty' in rule) {
if (isEmptyText(v)) return false;
} else if ('empty' in rule) {
// 空:null/""/空白/"[]" 或【空壳 JSON 数组】(元素全无实质内容,如 host 占位行
// [{treatName:"",toothPosition:null,toothPositionBak:"<base64>"}])。
// 用于 gate "无结构化治疗" — treat_plan 空壳时也应去 dispose 找治疗。
if (!isBlankValue(v)) return false;
} else if ('in' in rule) {
const set = new Set(rule.in.map((x) => String(x)));
if (!set.has(String(v))) return false;
......
......@@ -72,6 +72,7 @@ export type ProjectOp = z.infer<typeof ProjectOpSchema>;
*/
export const WhereRuleSchema = z.union([
z.object({ not_empty: z.literal(true) }),
z.object({ empty: z.literal(true) }), // 空:null/""/"[]"(空 JSON 数组也算空)— 用于 gate "无结构化字段"
z.object({ in: z.array(z.union([z.string(), z.number()])).min(1) }),
z.object({ equals: z.union([z.string(), z.number()]) }),
]);
......
......@@ -6,12 +6,13 @@ import { AIStamp, Chip, MD, tone } from './shared';
import { ChainDetailView } from './chain-viz';
import { EmrSoapView } from './emr-soap-view';
import { FactsTimeline } from './facts-timeline';
import { ToothTimeline } from './tooth-timeline';
import { cleanPersonaValue } from './persona-display';
import { PersonaFeatureHover } from './persona-feature-hover';
import type { Chain, PersonaFeature, PlanReason } from './mock-data';
import type { AdaptedFact } from './adapt-data';
export type DrawerKind = 'chain-detail' | 'medical' | 'image' | 'facts' | 'persona' | null;
export type DrawerKind = 'chain-detail' | 'medical' | 'image' | 'facts' | 'teeth' | 'persona' | null;
export function Drawer({
open,
......@@ -88,6 +89,11 @@ export function Drawer({
subtitle = '按时间倒序';
body = <FactsTimeline facts={facts} />;
width = 'w-[640px]';
} else if (kind === 'teeth') {
title = '牙位事实';
subtitle = '每颗牙 / 全口治疗线 · 时间倒序';
body = <ToothTimeline facts={facts} />;
width = 'w-[560px]';
} else if (kind === 'persona') {
title = '患者画像';
subtitle = `更新于 ${fmtRel(persona.computedAt)} · ${persona.features.length} 项画像`;
......
......@@ -68,12 +68,21 @@ export function FactsTimeline({ facts }: { facts: AdaptedFact[] }) {
const t = f.occurredAt ?? f.plannedFor;
return t ? new Date(t).getTime() : -Infinity;
};
// 临床顺序 rank:诊断 1 → 影像 2 → 建议 3 → 计划 4 → 治疗 5。
// 整体时间倒序(新→旧),同一时刻也跟着倒:治疗 → 计划 → 建议 → 影像 → 诊断。
const typeRank = (f: AdaptedFact) => {
if (f.type === 'diagnosis_record') return 1;
if (f.type === 'image_record') return 2;
if (f.type === 'recommendation_record') return 3;
if (f.type === 'treatment_record') return f.kind === 'planned' ? 4 : 5;
return 6;
};
const sorted = useMemo(
() =>
[...facts]
.filter((f) => selected.has(f.type))
.filter((f) => !toothFilter || factTeeth(f).includes(toothFilter))
.sort((a, b) => tkey(b) - tkey(a)),
.sort((a, b) => tkey(b) - tkey(a) || typeRank(b) - typeRank(a)),
[facts, selected, toothFilter],
);
......
......@@ -318,6 +318,7 @@ export function PlanDetailApp({
persona={persona}
facts={facts}
onOpenDetail={() => setDrawerOpen('facts')}
onOpenTeeth={() => setDrawerOpen('teeth')}
/>
<TreatmentHistoryCard facts={facts} />
{/* 历史联系 — 预留卡片(以后接客服联系记录) */}
......@@ -1186,11 +1187,13 @@ function KeyFactsCard({
persona,
facts,
onOpenDetail,
onOpenTeeth,
}: {
patient: typeof mockPatient;
persona: typeof mockPersona;
facts: AdaptedFact[];
onOpenDetail: () => void;
onOpenTeeth: () => void;
}) {
// ─ 主治医生 ─ 取 facts 中 doctor_id 出现频次最高的,并解析 doctor_name
// 同 chain-composer.buildDoctorMap 逻辑同源:同 patient (id,name) 双全的 fact 学一遍 map
......@@ -1258,9 +1261,14 @@ function KeyFactsCard({
<SidebarCard
title="关键事实"
action={
<button onClick={onOpenDetail} className="text-[10.5px] text-teal-700 hover:underline">
详情 →
</button>
<span className="inline-flex items-center gap-2.5">
<button onClick={onOpenTeeth} className="text-[10.5px] text-teal-700 hover:underline">
牙位 →
</button>
<button onClick={onOpenDetail} className="text-[10.5px] text-teal-700 hover:underline">
详情 →
</button>
</span>
}
>
<div className="space-y-1">
......
'use client';
import { diagnosisCodeNameZh, treatmentCategoryNameZh } from '@pac/types';
import { cn } from '@/lib/utils';
import type { AdaptedFact } from './adapt-data';
/**
* ToothTimeline — 每颗牙(+ 全口治疗线)的事实时间轴
*
* 设计:零推断、忠实展示。把临床 fact 按"治疗问题"分泳道:
* - 按牙的病 → 泳道键 = 牙位(多牙位 fact 拆进每颗牙)
* - 全口的病 → 泳道键 = 治疗线类别(牙周 / 正畸 / 全口·其他),按 category/诊断码分桶
* 每条泳道内 = 时间倒序的事实流。不做 lifecycle / 合并 / 替代闭环(那些归召回 SQL)。
*/
const CLINICAL_TYPES = new Set([
'diagnosis_record',
'treatment_record',
'recommendation_record',
'image_record',
]);
const WHOLE_PERIO = '全口 · 牙周';
const WHOLE_ORTHO = '全口 · 正畸';
const WHOLE_OTHER = '全口 · 其他';
export function ToothTimeline({ facts }: { facts: AdaptedFact[] }) {
const clinical = facts.filter((f) => CLINICAL_TYPES.has(f.type));
if (clinical.length === 0) {
return <div className="text-center py-12 text-sm text-slate-400">无牙位事实</div>;
}
// 分桶
const lanes = new Map<string, AdaptedFact[]>();
const push = (k: string, f: AdaptedFact) => {
const arr = lanes.get(k) ?? [];
arr.push(f);
lanes.set(k, arr);
};
for (const f of clinical) {
const teeth = factTeeth(f);
if (teeth.length > 0) {
for (const t of teeth) push(t, f);
} else {
push(wholeMouthLane(f), f);
}
}
// 排序:牙位泳道(数值序)→ 牙周 → 正畸 → 全口其他
const toothKeys = [...lanes.keys()]
.filter((k) => ![WHOLE_PERIO, WHOLE_ORTHO, WHOLE_OTHER].includes(k))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const wholeKeys = [WHOLE_PERIO, WHOLE_ORTHO, WHOLE_OTHER].filter((k) => lanes.has(k));
const orderedKeys = [...toothKeys, ...wholeKeys];
const tkey = (f: AdaptedFact) => {
const t = f.occurredAt ?? f.plannedFor;
return t ? new Date(t).getTime() : -Infinity;
};
// 临床顺序 rank:诊断 1 → 影像 2 → 建议 3 → 计划 4 → 治疗 5。
// 整体时间倒序(新→旧),同一时刻也跟着倒:治疗 → 计划 → 建议 → 影像 → 诊断。
const typeRank = (f: AdaptedFact) => {
if (f.type === 'diagnosis_record') return 1;
if (f.type === 'image_record') return 2;
if (f.type === 'recommendation_record') return 3;
if (f.type === 'treatment_record') return f.kind === 'planned' ? 4 : 5;
return 6;
};
return (
<div className="space-y-2.5">
{orderedKeys.map((k) => {
const rows = [...lanes.get(k)!].sort(
(a, b) => tkey(b) - tkey(a) || typeRank(b) - typeRank(a),
);
const isWhole = k.startsWith('全口');
return (
<div key={k} className="rounded-lg border border-slate-200 overflow-hidden">
<div
className={cn(
'flex items-center gap-2 px-2.5 py-1.5 text-[12px] font-semibold border-b border-slate-100',
isWhole ? 'bg-amber-50 text-amber-800' : 'bg-slate-50 text-slate-700',
)}
>
<span className="tabular-nums">{isWhole ? k : `牙位 ${k}`}</span>
<span className="ml-auto text-[10px] font-normal text-slate-400">{rows.length}</span>
</div>
<div className="divide-y divide-slate-50">
{rows.map((f) => (
<ToothFactRow key={`${k}-${f.id}`} fact={f} />
))}
</div>
</div>
);
})}
</div>
);
}
function ToothFactRow({ fact }: { fact: AdaptedFact }) {
const tIso = fact.occurredAt ?? fact.plannedFor;
const planned = !fact.occurredAt && !!fact.plannedFor;
const dateStr = tIso
? (planned ? '约 ' : '') + new Date(tIso).toLocaleDateString('zh-CN').replace(/\//g, '.')
: '—';
const { badge, badgeTone, text } = factLabel(fact);
return (
<div className="flex items-baseline gap-2 px-2.5 py-1.5 text-[12px]">
<span className="w-[74px] flex-none tabular-nums text-[10.5px] text-slate-400">{dateStr}</span>
<span
className={cn(
'flex-none px-1.5 py-px rounded text-[10px] font-medium',
badgeTone,
)}
>
{badge}
</span>
<span className="flex-1 min-w-0 text-slate-800 leading-snug" title={text}>
{text}
</span>
</div>
);
}
function factLabel(f: AdaptedFact): { badge: string; badgeTone: string; text: string } {
const c = (f.content ?? {}) as Record<string, unknown>;
switch (f.type) {
case 'diagnosis_record': {
const code = String(c.code ?? '');
const name = String(c.name_zh ?? c.name ?? '');
const std = code ? diagnosisCodeNameZh(code) : '';
const text = std && name && std !== name ? `${std} · ${name}` : std || name || '诊断';
return { badge: '诊断', badgeTone: 'bg-rose-50 text-rose-700', text };
}
case 'treatment_record': {
const cat = String(c.category ?? '');
const sub = String(c.subtype ?? '');
const catZh = cat ? treatmentCategoryNameZh(cat) : '';
const text = sub || catZh || '治疗';
const isPlanned = f.kind === 'planned';
return {
badge: isPlanned ? '计划' : catZh || '治疗',
badgeTone: isPlanned ? 'bg-slate-100 text-slate-500' : 'bg-teal-50 text-teal-700',
text: catZh && sub ? `${text}` : text,
};
}
case 'recommendation_record': {
const name = String(c.name ?? '');
const code = String(c.code ?? '');
return {
badge: '建议',
badgeTone: 'bg-amber-50 text-amber-700',
text: name || (code ? diagnosisCodeNameZh(code) : '') || '医生建议',
};
}
case 'image_record': {
const mod = String(c.modality ?? '');
const modZh = MODALITY_ZH[mod] ?? mod ?? '影像';
return { badge: '影像', badgeTone: 'bg-slate-100 text-slate-500', text: modZh };
}
default:
return { badge: '事实', badgeTone: 'bg-slate-100 text-slate-500', text: f.title ?? f.type };
}
}
const MODALITY_ZH: Record<string, string> = {
pa: '根尖片',
bw: '咬翼片',
pano: '曲面断层',
cbct: 'CBCT',
intraoral_photo: '口内照片',
};
/// 牙位归一(剥面/方位后缀,保留牙位 base);跟 facts-timeline 同义
function toothBase(t: string): string {
const m = /^(\d{1,2}[A-E]?)/i.exec(t.trim());
return m ? m[1]!.toUpperCase() : t.trim().toUpperCase();
}
function factTeeth(f: AdaptedFact): string[] {
const c = (f.content ?? {}) as Record<string, unknown>;
const tp = c.tooth_positions;
const raw = Array.isArray(tp) ? tp.join(';') : String(c.tooth_position ?? tp ?? '');
return raw
.split(/[;,]+/)
.map((s) => s.trim())
.filter(Boolean)
.map(toothBase);
}
/// 全口事实分桶:牙周(periodontic/K05)/ 正畸(orthodontic/K07)/ 其他(预防/检查…)
function wholeMouthLane(f: AdaptedFact): string {
const c = (f.content ?? {}) as Record<string, unknown>;
const cat = String(c.category ?? '');
const code = String(c.code ?? '');
if (cat === 'periodontic' || code === 'K05') return WHOLE_PERIO;
if (cat === 'orthodontic' || code === 'K07') return WHOLE_ORTHO;
return WHOLE_OTHER;
}
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