Commit 6d201fdf by luoqi

feat(recall): 治疗家族 resolver + 拔除 afterDx + 乳牙归一 + oracle 对账工具

召回核心(scenario + canonical-codes):
- 治疗家族 resolver(resolverCategoriesFor):11 张码白名单 → 结构家族一张表 +
  牙周/正畸沿用。结构码(K00-04/08/09)= 任何局部结构治疗算已治(充填/根管/冠桥/
  种植/外科/美学/儿牙);刻意排除 牙周/正畸/预防/复查流程 → 不跨病误销。
  修李梦维 1B 贴面误召;差分实测 18 例补判全合理、0 误销。
- ⑤c 拔除改 afterDx:拔除只终结诊断前/同时的病;诊断在拔除后=新信号(不压)。
- toothArrSql 乳牙归一:对齐 toothSet 只剥"空格+牙面字母",保留 Palmer 字母。
  修旧实现把 1D/1E 塌成象限 1 的错标 + 整象限过度相减漏召(差分 26 患者)。
- expectedCats(窄,展示"未启动 X")与 resolverCats(宽,判已解决)拆开。

前端对账工具(差分验证):
- recall-oracle.ts:召回算法独立第二实现(按单牙时序状态机),共享家族配置、
  判定逻辑独立 → 与生产 SQL 差分比对,分歧即 bug 捕获点。
- tooth-timeline:顶部对账面板(✓一致/仅生产/仅oracle)+ 每泳道召回徽标。
- 去掉治疗链判断(plan-detail-app reasonAltClosed 过滤)——治疗链弃用第一步,
  召回显示统一收口到「召回算法 + 牙位事实」。

本地差分全量验证:605 一致 / 0 分歧。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 59038480
......@@ -6,6 +6,7 @@ import {
APPT_COMPLAINT_TO_CATEGORY,
RESTORATION_INELIGIBLE_DX_NAMES,
lookupDxTreatment,
resolverCategoriesFor,
diagnosisCodeNameZh,
treatmentCategoryNameZh,
} from '@pac/types';
......@@ -229,15 +230,21 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
const dxCodes = cfg.dxCodes as readonly string[];
const recCodes = cfg.recCodes as readonly string[];
const allCodes = [...dxCodes, ...recCodes];
const excludeCats = rule.categories as readonly string[];
// W4 末:按 excludeCats 算出对应的预约 complaint 文本(host appointment.complaint_category 字段值)
// APPT_COMPLAINT_TO_CATEGORY 反查:filter complaint where category ∈ excludeCats
// 例 K07 excludeCats=['orthodontic'] → complaintTexts=['正畸','早矫']
// K08 excludeCats=['implant','prosthodontic'] → ['种植','修复']
// ⭐ 两个口径分开(单一真理源 canonical-codes):
// expectedCats = rule.categories(窄,主治疗)→ 展示"未启动 X" + 触发预期 + ⑤d 主诉匹配
// resolverCats = resolverCategoriesFor(宽,治疗家族)→ ⑤a "已解决" 判定
// 结构码(K02/K03/K08…)= 任何局部结构治疗都算(充填/根管/冠桥/种植/外科/美学/儿牙);
// 牙周/正畸(K05/K06/K07)沿用各自 categories。见 canonical-codes.resolverCategoriesFor。
const expectedCats = rule.categories as readonly string[];
const resolverCats = resolverCategoriesFor(cfg.primaryCode) as readonly string[];
// W4 末:按 expectedCats 算出对应的预约 complaint 文本(host appointment.complaint_category 字段值)
// APPT_COMPLAINT_TO_CATEGORY 反查:filter complaint where category ∈ expectedCats
// 例 K07 expectedCats=['orthodontic'] → complaintTexts=['正畸','早矫']
// K08 expectedCats=['implant','prosthodontic'] → ['种植','修复']
// 用于 ⑤d:sig 之后有 complaint 匹配的 appointment → 患者已 entered 治疗链,不召回
const complaintTexts = Object.entries(APPT_COMPLAINT_TO_CATEGORY)
.filter(([, cat]) => excludeCats.includes(cat))
.filter(([, cat]) => expectedCats.includes(cat))
.map(([text]) => text);
// 单 patient 收窄(详情页"刷新"):设了 scope.patientId → 只扫该患者,O(全租户)→O(1)
......@@ -265,13 +272,29 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
: Prisma.sql`sig.content->>'tooth_position'`;
// ⭐ 牙位级"按牙相减"(W5:修多牙诊断被部分治疗整体误抑制)
// 牙位字符串 → 数字牙位数组(剥牙面后缀):"16;26 B;36" → {16,26,36}
// 牙位字符串 → 牙位数组,**只剥"空格+牙面字母"后缀,保留 FDI 数字 & Palmer 乳牙字母**。
// 口径必须跟单一真理源 toothSet(tooth-position.util.ts)严格一致 —— 否则乳牙塌缩。
// ⚠️ 旧实现 `regexp_replace(expr,'[^0-9;]+',';')` 把所有字母无差别剥掉,
// 乳牙 "1D;1E" 全塌成 "1"(象限)→ 错标 + 整象限过度相减漏召(差分测试 26 患者命中)。
// 现:① 剥 "牙位base + 空格 + 牙面字母"(同 normalizeToothPosition 的 SURFACE_SUFFIX_RE)
// ② 去残留空格 ③ 按 ';' 拆。例:
// "1D;1E;2D" → {1D,1E,2D}(乳牙保留) "1D OD;17 D" → {1D,17}(剥面) "16;46" → {16,46}
// 注:JS 模板里反向引用要写 '\\1'(传到 SQL 为 '\1');用 POSIX [[:space:]] 避开 \s 转义坑。
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 得"剩余未治牙位"。
Prisma.sql`array_remove(string_to_array(
regexp_replace(
regexp_replace(${expr}, '([0-9]+[A-Ea-e]?)[[:space:]]+[DMOBLPIdmoblpi]+', '\\1', 'g'),
'[[:space:]]+', '', 'g'),
';'), '')`;
// 该信号"已被解决"的牙位集合 = 诊断后同牙做了 resolverCats 家族里任一治疗(afterDx)。
// ⭐ 治疗家族 resolver(单一真理源 canonical-codes.resolverCategoriesFor):
// 结构码 resolverCats = 局部结构治疗全集(充填/嵌体/冠桥/种植/牙髓/外科/美学/儿牙)—
// 已涵盖旧 ⑤a同类 + ⑤c拔除(surgical)+ ⑤e替代(implant/prostho),三分支收成一条。
// 新增:贴面(cosmetic)/ 根管(endodontic)/ 充填(restorative)治龋等过去漏判的也算已治(李梦维 1B)。
// 刻意不含 periodontic/orthodontic/preventive/review → 不被洗牙/刮治/流程跨病误销。
// 牙周/正畸码 resolverCats = rule.categories(牙周只认牙周/外科,不被结构治疗误销)。
// ⚠️ afterDx(治疗 ≥ 诊断 才终结):拔除只终结它之前/同时的病;诊断在拔除【之后】=新信号(不压)。
// excludeIfEverTreated 码(K05/K07 全口长疗程)忽略时间方向 → 曾做过即解决。
const afterDxFragRtx = rule.excludeIfEverTreated
? Prisma.empty
: Prisma.sql`AND rtx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`;
......@@ -283,11 +306,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
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 替代定性
))`;
AND rtx.content->>'category' = ANY(${resolverCats}::text[]) -- 治疗家族 resolver(afterDx)
${afterDxFragRtx})`;
// ╔═════════════════════════════════════════════════════════════════════╗
// ║ 召回 SQL 完整解读(initiation = 潜在治疗新链召回) ║
......@@ -331,8 +351,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ║ ┌─ ⑤b 排除:患者已有未来预约 ────────────────────────────────┐ ║
// ║ │ tx.type = 'treatment_record' AND tx.kind = 'actual' │ ║
// ║ │ tx.status IN ('active','fulfilled') (完成的 actual 是 fulfilled)│ ║
// ║ │ tx.content->>'category' = ANY(excludeCats) │ ║
// ║ │ excludeCats 来自 DxMap.K08.categories = ['implant','prostho']│ ║
// ║ │ tx.content->>'category' = ANY(resolverCats) │ ║
// ║ │ resolverCats = 治疗家族(结构码=局部结构治疗全集;牙周/正畸沿用)│ ║
// ║ │ tx.occurred_at >= sig.occurred_at ⭐ 时间方向 │ ║
// ║ │ 只算"诊断之后才启动"的治疗,历史旧治疗(可能是另一颗牙)不算 │ ║
// ║ │ │ ║
......@@ -406,7 +426,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
AND tx.type = 'treatment_record'
AND tx.kind = 'actual'
AND tx.status IN ('active', 'fulfilled')
AND tx.content->>'category' = ANY(${excludeCats}::text[])
AND tx.content->>'category' = ANY(${resolverCats}::text[]) -- 治疗家族 resolver(全口码 = rule.categories)
${afterDxFrag}
-- 全口信号 sigToothExpr=NULL → 下条恒真 → category 级排除(忽略 dx 自带牙位)
AND COALESCE(NULLIF(trim(${sigToothExpr}), ''), '') = ''
......@@ -501,7 +521,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
patientId,
patientExternalId: r.patient_external_id,
// reason 文本兜底(AI prompt / 调试用,前端不依赖此字段)
reason: `${cfg.label}${toothStr}${diagnosisCodeNameZh(r.signal_code)}${sourceStr} ${r.days_since} 天前,未启动${excludeCats.map(treatmentCategoryNameZh).join(' / ')}`,
reason: `${cfg.label}${toothStr}${diagnosisCodeNameZh(r.signal_code)}${sourceStr} ${r.days_since} 天前,未启动${expectedCats.map(treatmentCategoryNameZh).join(' / ')}`,
priorityScore: score,
goal: cfg.goal,
recommendedRole: 'staff',
......@@ -527,7 +547,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
daysSince: r.days_since,
// 不可变锚点:展示层据此实时算天数,跟治疗链断口同源(避免快照随天数陈旧 → "388/389"漂移)
signalOccurredAt: r.signal_occurred_at?.toISOString() ?? null,
expectedCategories: [...excludeCats],
expectedCategories: [...expectedCats],
},
priorityBreakdown: breakdown,
});
......
......@@ -22,6 +22,7 @@ export function Drawer({
persona,
summaries,
reason,
reasons,
facts,
fmtRel,
summaryOverride,
......@@ -38,6 +39,8 @@ export function Drawer({
treatment_chain: { content: string; generatedAt: Date };
};
reason: PlanReason;
/// 生产召回结果全量(plan.reasons)— ToothTimeline 对账用
reasons: PlanReason[];
/// v2.1:所有 active fact(FactsTimeline + ImageDrawer 用)
facts: AdaptedFact[];
fmtRel: (d: Date) => string;
......@@ -91,8 +94,8 @@ export function Drawer({
width = 'w-[640px]';
} else if (kind === 'teeth') {
title = '牙位事实';
subtitle = '每颗牙 / 全口治疗线 · 时间倒序';
body = <ToothTimeline facts={facts} />;
subtitle = '每颗牙 / 全口治疗线 · 时间倒序 · oracle 召回对账';
body = <ToothTimeline facts={facts} reasons={reasons} />;
width = 'w-[560px]';
} else if (kind === 'persona') {
title = '患者画像';
......
......@@ -200,14 +200,14 @@ export function PlanDetailApp({
// 是否已有话术内容(没生成过 → 空态提示,不显示默认 demo / 空标题)
const hasScriptContent = displayedSections.some((s) => s.markdown.trim().length > 0);
// 可见召回原因 = 过滤掉"已被同病程替代方案闭环覆盖"的 reason(按 code+牙位对齐,见 reasonAltClosed)
// WhyCard 召回理由卡 + 下面的 诊断/目标治疗 标签 都用这同一份,口径统一(避免卡片显示 A、标签显示 B)
// 可见召回原因 = 召回算法产出的全集,按优先级排序。
// ⭐ 治疗链已弃用:不再用链(reasonAltClosed)来判断/过滤显示哪些 reason。
// 口径统一收口到「召回算法 + 牙位事实」单一来源 —— 召回判定什么,这里就显示什么
// (避免链与召回背离:李梦维 1B 乳牙滞留贴面,链判"替代闭环"误删,而召回算法仍召 → 错隐藏)。
// "是否该召 1B" 这类问题改由召回算法本身回答(如 K00 是否认 cosmetic),不再借链做二次抑制。
const visibleReasons = useMemo(
() =>
reasons
.filter((r) => !reasonAltClosed(r, chains))
.sort((a, b) => b.priorityScore - a.priorityScore),
[reasons, chains],
() => [...reasons].sort((a, b) => b.priorityScore - a.priorityScore),
[reasons],
);
// 本次聚焦的应治未治项(priorityScore 最高那条 = 话术讲的那个)的 诊断 + 目标治疗 标签
......@@ -490,6 +490,7 @@ export function PlanDetailApp({
persona={persona}
summaries={summaries}
reason={focusedReason ?? reasons[0]!}
reasons={reasons}
facts={facts}
fmtRel={fmtRel}
summaryOverride={summaryOverride}
......@@ -1067,43 +1068,6 @@ function IdentityCard({
);
}
// ──────────────────────────────────────────
// 牙位串 "11;12;21" / "18;48;" / "1D OD" → 牙位 base 集合(剥面后缀,跟后端 toothSet 同义)
function parseTeeth(s?: string | null): string[] {
return (s ?? '')
.split(/[;,\s]+/)
.map((t) => t.trim())
.filter(Boolean)
.map((t) => {
// 剥面/方位后缀(M/O/D/B/L/MOD…),保留牙位 base("11M"→"11";"1D"乳牙保留)
const m = /^(\d{1,2}[A-E]?)/i.exec(t);
return m ? m[1]!.toUpperCase() : t.toUpperCase();
});
}
// 替代闭环跨链抑制:某 reason 是否"已被替代方案覆盖,无召回意义"。
// ⚠️ 必须按 (诊断码 + 牙位) 把 reason 对齐到【它自己】那条 altClosedBy 链 —— 只看牙位会误杀:
// 韩滨案例:2024 龋齿(K02·11)→修复 已闭环,误删了 2025 缺牙(K08·11)→种植 的召回。
// 而 K04 根管被同牙 K08+种植替代关闭(code 不同但属于同一条 K04 链的 altClosedBy)→ 正确抑制。
function reasonAltClosed(
reason: PlanReason,
chains: typeof mockChains,
): boolean {
const rTeeth = parseTeeth(reason.signals?.toothPosition);
const rCodes = new Set(
(reason.signals?.triggers ?? []).map((t) => t.code).filter(Boolean) as string[],
);
return chains.some((c) => {
if (!c.alternativeClosedBy) return false;
// code 对齐:reason 与 chain 必须是同一诊断码(不同病程不互相抑制)
if (c.code && rCodes.size > 0 && !rCodes.has(c.code)) return false;
const cTeeth = parseTeeth(c.toothPosition);
// reason 全口(无牙位)→ 仅按 code 对齐;否则要求牙位有交集
if (rTeeth.length === 0) return true;
return cTeeth.some((t) => rTeeth.includes(t));
});
}
// WhyCard — 召回原因列表(W3 末:plan_reasons 按 sub_key 拆分;
// 每行用 signals JSON + @pac/types 字典翻译富文本渲染,关键字高亮。reason 文本仅作 signals 缺失时 fallback)
// ──────────────────────────────────────────
......@@ -1111,7 +1075,7 @@ function WhyCard({
visibleReasons,
onOpenMedical,
}: {
// 已在父层按 (诊断码 + 牙位) 对齐过滤替代闭环(见 reasonAltClosed),这里只渲染。
// 召回算法产出的全部 reason(治疗链已弃用,不再做替代闭环二次过滤),这里只渲染。
// 跟下方 诊断/目标治疗 标签共用同一份,口径统一。
visibleReasons: PlanReason[];
onOpenMedical: () => void;
......
/**
* recall-oracle —— 召回算法的**独立第二实现**(对抗 / 差分验证用)
*
* 目的:用一套**完全不同范式**的实现重算"这颗牙该不该召回",跟生产 SQL
* (treatment-initiation-recall.scenario.ts)的结论比对。两边一致才可信,
* 分歧处即 bug 捕获点(可能是生产误召/漏召,也可能是本 oracle 写错)。
*
* ⚠️ 对抗的纪律线(务必遵守,否则对抗失效):
* - 可以共享:**配置数据** = DiagnosisTreatmentMap / 废用牙名单 / 主诉映射(单一真理源,
* 本就该两边引同一份,改窗口不漂移)。
* - 绝不共享:**判定逻辑本身**。生产是 SQL 集合运算(array overlap / LATERAL 按牙相减 /
* union-find 聚类);本 oracle 故意用**按单颗牙的时间序状态机**(chronological walk),
* 心智模型不同 → 同一个 bug 不会同时出现在两边互相掩护。
* - 本文件照**算法语义**写,不照 SQL 逐行翻译。
*
* 覆盖的召回门控(对齐 scenario.ts 注释编号):
* ③ 信号(diagnosis/recommendation + 召回码) ④ cooldown 下界(per-code)
* ④' 废用牙剔除 ⑤a 同类实治(afterDx)
* ⑤c 同牙拔除(afterDx) ⑤e 同牙替代定性(种植/冠 afterDx)
* ⑤b 未来预约 ⑤d 主诉匹配预约(afterSig)
* ⑤f 14 天就诊冷静 按牙相减 / wholeMouth(K05/K07)
*
* 不覆盖(患者级硬闸,plan 存在即已通过,不在单牙粒度判):
* ① 隔离(host/tenant) ② 合规(active/DNC/deceased)
*/
import {
DiagnosisTreatmentMap,
APPT_COMPLAINT_TO_CATEGORY,
isRestorationIneligibleDxName,
resolverCategoriesFor,
treatmentCategoryNameZh,
} from '@pac/types';
import type { AdaptedFact } from './adapt-data';
export const WHOLE_PERIO = '全口 · 牙周';
export const WHOLE_ORTHO = '全口 · 正畸';
export const WHOLE_OTHER = '全口 · 其他';
const POST_VISIT_COOLDOWN_DAYS = 14;
/// 召回码 → 子场景(subKey + 取 rule 用的 primaryCode)。
/// 跟 scenario.ts 的 SUB_SCENARIOS 一一对应(配置同源,允许共享)。
const CODE_TO_SUB: Record<string, { subKey: string; primary: string; label: string }> = {
K08: { subKey: 'missing_tooth', primary: 'K08', label: '缺失牙修复' },
IMPLANT_RECOMMENDED: { subKey: 'missing_tooth', primary: 'K08', label: '缺失牙修复' },
K07: { subKey: 'ortho_no_consult', primary: 'K07', label: '正畸矫治' },
ORTHO_CONSULT_RECOMMENDED: { subKey: 'ortho_no_consult', primary: 'K07', label: '正畸矫治' },
K04: { subKey: 'endo_no_rct', primary: 'K04', label: '根管治疗' },
RCT_RECOMMENDED: { subKey: 'endo_no_rct', primary: 'K04', label: '根管治疗' },
K05: { subKey: 'perio_no_srp', primary: 'K05', label: '牙周治疗' },
SRP_RECOMMENDED: { subKey: 'perio_no_srp', primary: 'K05', label: '牙周治疗' },
K02: { subKey: 'caries_no_filling', primary: 'K02', label: '龋齿充填' },
FILLING_RECOMMENDED: { subKey: 'caries_no_filling', primary: 'K02', label: '龋齿充填' },
K03: { subKey: 'hard_tissue_damage', primary: 'K03', label: '牙体修复' },
HARD_TISSUE_REPAIR_RECOMMENDED: { subKey: 'hard_tissue_damage', primary: 'K03', label: '牙体修复' },
CROWN_RECOMMENDED: { subKey: 'hard_tissue_damage', primary: 'K03', label: '牙体修复' },
K06: { subKey: 'gum_alveolar_lesion', primary: 'K06', label: '牙龈/牙槽嵴' },
GUM_TREATMENT_RECOMMENDED: { subKey: 'gum_alveolar_lesion', primary: 'K06', label: '牙龈/牙槽嵴' },
K01: { subKey: 'impacted_tooth', primary: 'K01', label: '阻生牙拔除' },
EXTRACTION_RECOMMENDED: { subKey: 'impacted_tooth', primary: 'K01', label: '阻生牙拔除' },
K09: { subKey: 'jaw_cyst', primary: 'K09', label: '颌骨囊肿' },
JAW_CYST_REMOVAL_RECOMMENDED: { subKey: 'jaw_cyst', primary: 'K09', label: '颌骨囊肿' },
K00: { subKey: 'development_eruption', primary: 'K00', label: '发育/萌出' },
ERUPTION_INTERVENTION_RECOMMENDED: { subKey: 'development_eruption', primary: 'K00', label: '发育/萌出' },
};
export type OracleVerdictKind =
| 'recall' // 应召回
| 'resolved' // 已被同类/拔除/替代治疗解决
| 'cooldown' // 诊断未过冷静期
| 'ineligible' // 废用牙/无功能牙,非修复对象
| 'suppressed'; // 被患者级闸压制(未来预约/主诉预约/近期到诊)
export type OracleVerdict = {
laneKey: string; // 牙位 toothBase(如 "26")或全口泳道名(WHOLE_PERIO 等)— 跟 ToothTimeline 泳道同空间
isWhole: boolean;
subKey: string; // 'caries_no_filling' …
subLabel: string; // '龋齿充填'
code: string; // 'K02'
signalFactId: string;
signalDate: string | null; // ISO
daysSince: number | null;
kind: OracleVerdictKind;
detail: string; // 人读判定理由
};
const VERDICT_META: Record<OracleVerdictKind, { zh: string; tone: string }> = {
recall: { zh: '应召回', tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' },
resolved: { zh: '已解决', tone: 'bg-slate-100 text-slate-500 border-slate-200' },
cooldown: { zh: '考虑期', tone: 'bg-amber-50 text-amber-700 border-amber-200' },
ineligible: { zh: '非修复', tone: 'bg-slate-100 text-slate-400 border-slate-200' },
suppressed: { zh: '被压制', tone: 'bg-slate-100 text-slate-500 border-slate-200' },
};
export function verdictMeta(kind: OracleVerdictKind) {
return VERDICT_META[kind];
}
// ── 牙位归一(独立实现,跟 tooth-timeline 同义:剥牙面后缀,保留牙位 base)──
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(c: Record<string, unknown>): string[] {
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);
}
type Rule = {
categories: readonly string[];
cooldownDays: number;
wholeMouth?: boolean;
excludeIfEverTreated?: boolean;
};
/// 反查:哪些 host 主诉文本(complaint_category 值)对应这些治疗类别 → 给 ⑤d 用
function complaintTextsFor(cats: readonly string[]): string[] {
return Object.entries(APPT_COMPLAINT_TO_CATEGORY)
.filter(([, v]) => cats.includes(v))
.map(([text]) => text);
}
/**
* 独立 oracle 主入口:对一个患者的全部 fact 跑一遍,产出每颗牙(+ 全口泳道)的召回判定。
*/
export function runRecallOracle(facts: AdaptedFact[], now: Date): OracleVerdict[] {
const ms = now.getTime();
const daysAgoMs = (n: number) => ms - n * 86400000;
// ── 一遍扫描,收集治疗 / 预约 / 到诊 / 信号 ──
const treatments: Array<{ teeth: Set<string>; cat: string; t: number; iso: string }> = [];
let hasFutureAppt = false;
let recentVisit = false;
const complaintAppts: Array<{ t: number; cats: Set<string> }> = [];
const signals: Array<{
factId: string;
c: Record<string, unknown>;
code: string;
t: number;
iso: string;
}> = [];
for (const f of facts) {
const c = (f.content ?? {}) as Record<string, unknown>;
if (
f.type === 'treatment_record' &&
f.kind === 'actual' &&
(f.status === 'active' || f.status === 'fulfilled')
) {
const iso = f.occurredAt ?? f.plannedFor;
if (iso) {
treatments.push({
teeth: new Set(factTeeth(c)),
cat: String(c.category ?? ''),
t: new Date(iso).getTime(),
iso,
});
}
} else if (f.type === 'appointment_record') {
const iso = f.plannedFor ?? f.occurredAt;
const at = iso ? new Date(iso).getTime() : null;
if (f.status === 'active' && at != null && at > ms) hasFutureAppt = true;
if ((f.status === 'active' || f.status === 'fulfilled') && at != null) {
const cats = new Set(
String(c.complaint_category ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
);
if (cats.size) complaintAppts.push({ t: at, cats });
}
} else if (f.type === 'encounter_record' || f.type === 'emr_record') {
if (f.occurredAt) {
const vt = new Date(f.occurredAt).getTime();
if (vt > daysAgoMs(POST_VISIT_COOLDOWN_DAYS)) recentVisit = true;
}
}
// 信号(诊断 / 推荐)
if (
(f.type === 'diagnosis_record' || f.type === 'recommendation_record') &&
f.status === 'active'
) {
const code = String(c.code ?? '');
const iso = f.occurredAt ?? f.plannedFor;
if (CODE_TO_SUB[code] && iso) {
signals.push({ factId: f.id, c, code, t: new Date(iso).getTime(), iso });
}
}
}
const out: OracleVerdict[] = [];
for (const sig of signals) {
const sub = CODE_TO_SUB[sig.code]!;
const rule = DiagnosisTreatmentMap[sub.primary as keyof typeof DiagnosisTreatmentMap] as Rule;
const wholeMouth = !!rule.wholeMouth;
// ⭐ 治疗家族 resolver(共享单一真理源 resolverCategoriesFor;判定逻辑下面独立走)
// 结构码 = 局部结构治疗全集(含 cosmetic/endodontic/restorative…);牙周/正畸沿用 rule.categories。
const resolverCats = new Set<string>(resolverCategoriesFor(sub.primary));
const nameZh = String(sig.c.name_zh ?? '');
const daysSince = Math.floor((ms - sig.t) / 86400000);
const inCooldown = sig.t > daysAgoMs(rule.cooldownDays);
const complaintTexts = complaintTextsFor(rule.categories);
const evalUnit = (laneKey: string, isWhole: boolean, toothFilter: string | null) => {
let kind: OracleVerdictKind;
let detail: string;
// ④' 废用牙
if (isRestorationIneligibleDxName(nameZh)) {
kind = 'ineligible';
detail = `「${nameZh}」非修复对象(该拔除/观察)`;
} else {
// 解决者:resolverCats 家族里任一治疗。
// wholeMouth → 任意时间 if excludeIfEverTreated,否则 afterDx;
// 按牙 → afterDx,且落在本颗牙。
let resolver: { cat: string; iso: string } | undefined;
if (wholeMouth) {
resolver = treatments.find(
(tr) =>
resolverCats.has(tr.cat) &&
(rule.excludeIfEverTreated ? true : tr.t >= sig.t),
);
} else {
resolver = treatments.find(
(tr) =>
resolverCats.has(tr.cat) &&
tr.t >= sig.t &&
(toothFilter == null || tr.teeth.has(toothFilter)),
);
}
if (resolver) {
kind = 'resolved';
detail = `诊断后做过 ${treatmentCategoryNameZh(resolver.cat) || resolver.cat}(${resolver.iso.slice(0, 10)})`;
} else if (inCooldown) {
kind = 'cooldown';
detail = `诊断仅 ${daysSince} 天 < 冷静期 ${rule.cooldownDays} 天`;
} else if (hasFutureAppt) {
kind = 'suppressed';
detail = '患者已有未来预约(⑤b)';
} else if (
complaintAppts.some(
(a) => a.t >= sig.t && [...a.cats].some((x) => complaintTexts.includes(x)),
)
) {
kind = 'suppressed';
detail = '诊断后有主诉匹配的预约(⑤d)';
} else if (recentVisit) {
kind = 'suppressed';
detail = `近 ${POST_VISIT_COOLDOWN_DAYS} 天内到过诊(⑤f)`;
} else {
kind = 'recall';
detail = `已 ${daysSince} 天未启动 ${rule.categories.map((c) => treatmentCategoryNameZh(c) || c).join('/')}`;
}
}
out.push({
laneKey,
isWhole,
subKey: sub.subKey,
subLabel: sub.label,
code: sig.code,
signalFactId: sig.factId,
signalDate: sig.iso,
daysSince,
kind,
detail,
});
};
if (wholeMouth) {
const laneKey =
sub.primary === 'K05' ? WHOLE_PERIO : sub.primary === 'K07' ? WHOLE_ORTHO : WHOLE_OTHER;
evalUnit(laneKey, true, null);
} else {
const teeth = [...new Set(factTeeth(sig.c))];
if (teeth.length === 0) {
evalUnit(WHOLE_OTHER, true, null);
} else {
for (const t of teeth) evalUnit(toothBase(t), false, toothBase(t));
}
}
}
return out;
}
// ─────────────────────────────────────────────────────────
// 对账:oracle 判定 vs 生产 plan_reasons
// ─────────────────────────────────────────────────────────
export type ReconMark = 'agree' | 'oracle_only' | 'prod_only';
export type ReconRow = {
key: string; // subKey|laneKey
laneKey: string;
toothLabel: string; // 牙位展示(全口泳道直接用泳道名)
subKey: string;
subLabel: string;
oracle: OracleVerdict | null; // 该单元的 oracle 判定(可能非 recall)
oracleRecall: boolean;
prodRecall: boolean;
mark: ReconMark;
};
/// 生产侧召回单元:从 plan.reasons[].signals 摊平成 (subKey, laneKey)
export type ProdSignal = {
subKey?: string | null;
toothPosition?: string | null;
} | null;
function wholeLaneForSub(subKey: string): string {
if (subKey === 'perio_no_srp') return WHOLE_PERIO;
if (subKey === 'ortho_no_consult') return WHOLE_ORTHO;
return WHOLE_OTHER;
}
export function reconcile(
verdicts: OracleVerdict[],
prodSignals: ProdSignal[],
): { rows: ReconRow[]; agree: number; oracleOnly: number; prodOnly: number } {
// 生产召回 key 集合
const prodSet = new Set<string>();
for (const s of prodSignals) {
if (!s?.subKey) continue;
const teethRaw = (s.toothPosition ?? '').trim();
if (!teethRaw) {
prodSet.add(`${s.subKey}|${wholeLaneForSub(s.subKey)}`);
} else {
for (const t of teethRaw.split(/[;,]+/).map((x) => x.trim()).filter(Boolean)) {
prodSet.add(`${s.subKey}|${toothBase(t)}`);
}
}
}
// oracle 单元(同 (subKey, laneKey) 取最强:recall > suppressed/cooldown/resolved/ineligible)
const rank: Record<OracleVerdictKind, number> = {
recall: 5,
suppressed: 4,
cooldown: 3,
resolved: 2,
ineligible: 1,
};
const oracleByKey = new Map<string, OracleVerdict>();
for (const v of verdicts) {
const k = `${v.subKey}|${v.laneKey}`;
const cur = oracleByKey.get(k);
if (!cur || rank[v.kind] > rank[cur.kind]) oracleByKey.set(k, v);
}
const allKeys = new Set<string>([...prodSet, ...oracleByKey.keys()]);
const rows: ReconRow[] = [];
let agree = 0,
oracleOnly = 0,
prodOnly = 0;
for (const k of allKeys) {
const [subKey, laneKey] = k.split('|') as [string, string];
const oracle = oracleByKey.get(k) ?? null;
const oracleRecall = oracle?.kind === 'recall';
const prodRecall = prodSet.has(k);
let mark: ReconMark;
if (oracleRecall && prodRecall) {
mark = 'agree';
agree++;
} else if (oracleRecall && !prodRecall) {
mark = 'oracle_only';
oracleOnly++;
} else if (!oracleRecall && prodRecall) {
mark = 'prod_only';
prodOnly++;
} else {
// 两边都不召(oracle 判 resolved/cooldown/… 且生产也没召)→ 不算分歧,不进对账行
continue;
}
const isWhole = laneKey.startsWith('全口');
rows.push({
key: k,
laneKey,
toothLabel: isWhole ? laneKey : `牙位 ${laneKey}`,
subKey,
subLabel: oracle?.subLabel ?? CODE_TO_SUB[subKeyToCode(subKey)]?.label ?? subKey,
oracle,
oracleRecall,
prodRecall,
mark,
});
}
// 排序:分歧优先(prod_only / oracle_only)→ agree;同组按牙位
const markOrder: Record<ReconMark, number> = { prod_only: 0, oracle_only: 1, agree: 2 };
rows.sort(
(a, b) =>
markOrder[a.mark] - markOrder[b.mark] ||
a.laneKey.localeCompare(b.laneKey, undefined, { numeric: true }),
);
return { rows, agree, oracleOnly, prodOnly };
}
/// subKey → 任一对应 code(仅用于 prod_only 行兜底 subLabel)
function subKeyToCode(subKey: string): string {
for (const [code, v] of Object.entries(CODE_TO_SUB)) {
if (v.subKey === subKey) return code;
}
return '';
}
export { toothBase as oracleToothBase };
......@@ -3,6 +3,14 @@
import { diagnosisCodeNameZh, treatmentCategoryNameZh } from '@pac/types';
import { cn } from '@/lib/utils';
import type { AdaptedFact } from './adapt-data';
import type { PlanReason } from './mock-data';
import {
runRecallOracle,
reconcile,
verdictMeta,
type OracleVerdict,
type ReconRow,
} from './recall-oracle';
/**
* ToothTimeline — 每颗牙(+ 全口治疗线)的事实时间轴
......@@ -23,12 +31,31 @@ const WHOLE_PERIO = '全口 · 牙周';
const WHOLE_ORTHO = '全口 · 正畸';
const WHOLE_OTHER = '全口 · 其他';
export function ToothTimeline({ facts }: { facts: AdaptedFact[] }) {
export function ToothTimeline({
facts,
reasons = [],
}: {
facts: AdaptedFact[];
/// 生产召回结果(plan.reasons)— 传入则跑独立 oracle 做对账验证
reasons?: PlanReason[];
}) {
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>;
}
// ── 独立 oracle:重算每颗牙/全口的召回判定,跟生产 reasons 对账 ──
const verdicts = runRecallOracle(facts, new Date());
const prodSignals = reasons.map((r) => r.signals ?? null);
const recon = reconcile(verdicts, prodSignals);
// 每条泳道的 oracle 判定(供泳道头展示徽标)
const verdictsByLane = new Map<string, OracleVerdict[]>();
for (const v of verdicts) {
const arr = verdictsByLane.get(v.laneKey) ?? [];
arr.push(v);
verdictsByLane.set(v.laneKey, arr);
}
// 分桶
const lanes = new Map<string, AdaptedFact[]>();
const push = (k: string, f: AdaptedFact) => {
......@@ -68,11 +95,13 @@ export function ToothTimeline({ facts }: { facts: AdaptedFact[] }) {
return (
<div className="space-y-2.5">
<ReconPanel recon={recon} />
{orderedKeys.map((k) => {
const rows = [...lanes.get(k)!].sort(
(a, b) => tkey(b) - tkey(a) || typeRank(b) - typeRank(a),
);
const isWhole = k.startsWith('全口');
const laneVerdicts = verdictsByLane.get(k) ?? [];
return (
<div key={k} className="rounded-lg border border-slate-200 overflow-hidden">
<div
......@@ -82,6 +111,20 @@ export function ToothTimeline({ facts }: { facts: AdaptedFact[] }) {
)}
>
<span className="tabular-nums">{isWhole ? k : `牙位 ${k}`}</span>
{laneVerdicts.map((v, i) => (
<span
key={`${v.subKey}-${i}`}
title={`${v.subLabel} · ${diagnosisCodeNameZh(v.code) || v.code} — ${v.detail}`}
className={cn(
'inline-flex items-center gap-1 px-1.5 py-px rounded border text-[10px] font-medium',
verdictMeta(v.kind).tone,
)}
>
{v.kind === 'recall' && <span className="w-1 h-1 rounded-full bg-emerald-500" />}
{verdictMeta(v.kind).zh}
<span className="font-normal opacity-70">{v.subLabel}</span>
</span>
))}
<span className="ml-auto text-[10px] font-normal text-slate-400">{rows.length}</span>
</div>
<div className="divide-y divide-slate-50">
......@@ -96,6 +139,80 @@ export function ToothTimeline({ facts }: { facts: AdaptedFact[] }) {
);
}
/// 对账面板:oracle(独立第二实现)vs 生产 plan_reasons。分歧 = bug 捕获点。
function ReconPanel({
recon,
}: {
recon: { rows: ReconRow[]; agree: number; oracleOnly: number; prodOnly: number };
}) {
const { rows, agree, oracleOnly, prodOnly } = recon;
const hasDiff = oracleOnly > 0 || prodOnly > 0;
return (
<div
className={cn(
'rounded-lg border overflow-hidden',
hasDiff ? 'border-rose-200' : 'border-emerald-200',
)}
>
<div
className={cn(
'flex items-center gap-2 px-2.5 py-1.5 text-[11.5px] font-semibold border-b',
hasDiff
? 'bg-rose-50 text-rose-800 border-rose-100'
: 'bg-emerald-50 text-emerald-800 border-emerald-100',
)}
>
<span>召回对账</span>
<span className="font-normal text-[10.5px] opacity-70">oracle 独立重算 vs 生产</span>
<span className="ml-auto flex items-center gap-2 tabular-nums">
<span className="text-emerald-700">{agree}</span>
<span className={cn(prodOnly > 0 ? 'text-rose-700' : 'text-slate-400')}>
⚠ 仅生产 {prodOnly}
</span>
<span className={cn(oracleOnly > 0 ? 'text-amber-700' : 'text-slate-400')}>
⚠ 仅oracle {oracleOnly}
</span>
</span>
</div>
{rows.length === 0 ? (
<div className="px-2.5 py-2 text-[11px] text-slate-400">无召回信号(两边均无)</div>
) : (
<div className="divide-y divide-slate-50">
{rows.map((r) => (
<div key={r.key} className="flex items-center gap-2 px-2.5 py-1.5 text-[11.5px]">
<span className="w-[88px] flex-none tabular-nums text-slate-600">{r.toothLabel}</span>
<span className="flex-none text-slate-800">{r.subLabel}</span>
<span className="flex-1 min-w-0 truncate text-[10.5px] text-slate-400" title={r.oracle?.detail ?? ''}>
{r.oracle?.detail ?? '生产召回,oracle 无对应信号(诊断可能已被取代)'}
</span>
<ReconBadge mark={r.mark} />
</div>
))}
</div>
)}
</div>
);
}
function ReconBadge({ mark }: { mark: ReconRow['mark'] }) {
const meta =
mark === 'agree'
? { zh: '✓ 一致', tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }
: mark === 'prod_only'
? { zh: '⚠ 仅生产', tone: 'bg-rose-50 text-rose-700 border-rose-200' }
: { zh: '⚠ 仅oracle', tone: 'bg-amber-50 text-amber-700 border-amber-200' };
return (
<span
className={cn(
'flex-none px-1.5 py-px rounded border text-[10px] font-medium whitespace-nowrap',
meta.tone,
)}
>
{meta.zh}
</span>
);
}
function ToothFactRow({ fact }: { fact: AdaptedFact }) {
const tIso = fact.occurredAt ?? fact.plannedFor;
const planned = !fact.occurredAt && !!fact.plannedFor;
......
......@@ -245,6 +245,68 @@ export function lookupDxTreatment(code: string): DxTreatmentRule | undefined {
}
// =============================================================
// 召回"已解决"判定 —— 治疗家族 resolver(单一真理源)
//
// 背景:旧口径每个诊断码白名单只认 categories 里那几类治疗(K02 只认 restorative),
// 永远列不全 —— 龋齿做了"根管 / 垫底充填 / 贴面"都没被认作已治 → 误召(李梦维 1B 案例)。
// 差分测试(oracle vs 生产)实测:670 个按牙召回单元里,放宽到"任意局部结构治疗"后
// 18 个翻成已解决,人工核对全部合理(根管治龋 / 贴面修牙体 / 乳牙冠…)。
//
// 新口径:**同一颗牙诊断后做了任何【针对该病家族】的治疗 = 已处理**,不再纠结具体哪一类。
// - 结构问题(K00/01/02/03/04/08/09)→ 任何【局部结构治疗】都算:
// 充填 / 嵌体 / 冠桥 / 种植 / 牙髓根管 / 外科 / 美学 / 儿牙
// **刻意排除** periodontic(洁牙/刮治)/ orthodontic(矫治流程)/ preventive / review ——
// 这些是"跨病 / 流程"动作,会误销(洗牙≠补龋、刮治≠修缺牙,实测 17 例误翻转)。
// - 牙周/牙龈(K05/K06)、正畸(K07)→ 维持各自 categories(已正确,牙周只认牙周/外科)。
//
// 形状:从「11 张码表」收成「结构家族一张表 + 牙周/正畸沿用各自」,稳定、可列全、0 误销。
// =============================================================
/// 局部结构治疗 = 结构类诊断的"已解决"判定集(不含 牙周/正畸/预防/复查流程)
export const STRUCTURAL_RESOLVER_CATEGORIES = [
'restorative',
'prosthodontic',
'implant',
'endodontic',
'surgical',
'cosmetic',
'pediatric',
] as const satisfies readonly PACTreatmentCategory[];
/// 走"结构家族 resolver"的诊断码 / 推荐码(其余 K05/K06/K07 等沿用 rule.categories)
const STRUCTURAL_DX_CODES = new Set<string>([
'K00',
'K01',
'K02',
'K03',
'K04',
'K08',
'K09',
'IMPLANT_RECOMMENDED',
'CROWN_RECOMMENDED',
'FILLING_RECOMMENDED',
'EXTRACTION_RECOMMENDED',
'RCT_RECOMMENDED',
'HARD_TISSUE_REPAIR_RECOMMENDED',
'ERUPTION_INTERVENTION_RECOMMENDED',
'JAW_CYST_REMOVAL_RECOMMENDED',
]);
/**
* 召回"已解决"判定:给定诊断/推荐码,返回"算作已处理"的治疗类别集合。
*
* ⚠️ 这是【判定已解决】用的(宽,家族级);跟【展示"未启动 X" + 触发预期】用的
* rule.categories(窄,主治疗)分开 —— 否则客服卡片会从"未启动 充填"变成一堆噪音。
*
* 单一真理源:召回 scenario 的 ⑤a 排除闸 + 牙位事实 oracle 对账 共用此函数,口径不漂移。
*/
export function resolverCategoriesFor(code: string): readonly PACTreatmentCategory[] {
if (STRUCTURAL_DX_CODES.has(code)) return STRUCTURAL_RESOLVER_CATEGORIES;
const rule = lookupDxTreatment(code);
return rule ? rule.categories : [];
}
// =============================================================
// 治疗状态(PACTreatmentStatus)— treatment_record.content.status
// =============================================================
......
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