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 { ...@@ -6,6 +6,7 @@ import {
APPT_COMPLAINT_TO_CATEGORY, APPT_COMPLAINT_TO_CATEGORY,
RESTORATION_INELIGIBLE_DX_NAMES, RESTORATION_INELIGIBLE_DX_NAMES,
lookupDxTreatment, lookupDxTreatment,
resolverCategoriesFor,
diagnosisCodeNameZh, diagnosisCodeNameZh,
treatmentCategoryNameZh, treatmentCategoryNameZh,
} from '@pac/types'; } from '@pac/types';
...@@ -229,15 +230,21 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -229,15 +230,21 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
const dxCodes = cfg.dxCodes as readonly string[]; const dxCodes = cfg.dxCodes as readonly string[];
const recCodes = cfg.recCodes as readonly string[]; const recCodes = cfg.recCodes as readonly string[];
const allCodes = [...dxCodes, ...recCodes]; const allCodes = [...dxCodes, ...recCodes];
const excludeCats = rule.categories as readonly string[]; // ⭐ 两个口径分开(单一真理源 canonical-codes):
// expectedCats = rule.categories(窄,主治疗)→ 展示"未启动 X" + 触发预期 + ⑤d 主诉匹配
// W4 末:按 excludeCats 算出对应的预约 complaint 文本(host appointment.complaint_category 字段值) // resolverCats = resolverCategoriesFor(宽,治疗家族)→ ⑤a "已解决" 判定
// APPT_COMPLAINT_TO_CATEGORY 反查:filter complaint where category ∈ excludeCats // 结构码(K02/K03/K08…)= 任何局部结构治疗都算(充填/根管/冠桥/种植/外科/美学/儿牙);
// 例 K07 excludeCats=['orthodontic'] → complaintTexts=['正畸','早矫'] // 牙周/正畸(K05/K06/K07)沿用各自 categories。见 canonical-codes.resolverCategoriesFor。
// K08 excludeCats=['implant','prosthodontic'] → ['种植','修复'] 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 治疗链,不召回 // 用于 ⑤d:sig 之后有 complaint 匹配的 appointment → 患者已 entered 治疗链,不召回
const complaintTexts = Object.entries(APPT_COMPLAINT_TO_CATEGORY) const complaintTexts = Object.entries(APPT_COMPLAINT_TO_CATEGORY)
.filter(([, cat]) => excludeCats.includes(cat)) .filter(([, cat]) => expectedCats.includes(cat))
.map(([text]) => text); .map(([text]) => text);
// 单 patient 收窄(详情页"刷新"):设了 scope.patientId → 只扫该患者,O(全租户)→O(1) // 单 patient 收窄(详情页"刷新"):设了 scope.patientId → 只扫该患者,O(全租户)→O(1)
...@@ -265,13 +272,29 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -265,13 +272,29 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
: Prisma.sql`sig.content->>'tooth_position'`; : Prisma.sql`sig.content->>'tooth_position'`;
// ⭐ 牙位级"按牙相减"(W5:修多牙诊断被部分治疗整体误抑制) // ⭐ 牙位级"按牙相减"(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) => const toothArrSql = (expr: Prisma.Sql) =>
Prisma.sql`array_remove(string_to_array(regexp_replace(${expr}, '[^0-9;]+', ';', 'g'), ';'), '')`; Prisma.sql`array_remove(string_to_array(
// ⑤e 替代治疗的时间方向(诊断之后);per-tooth 码恒用,wholeMouth 码不走此路径 regexp_replace(
const afterDxRtx = Prisma.sql`AND rtx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`; regexp_replace(${expr}, '([0-9]+[A-Ea-e]?)[[:space:]]+[DMOBLPIdmoblpi]+', '\\1', 'g'),
// 该信号"已被解决"的牙位集合 = ⑤a 同类(afterDx)∪ ⑤c 拔除(任意时间)∪ ⑤e 种植/冠桥(afterDx), '[[:space:]]+', '', 'g'),
// 只取有牙位的 actual 治疗。下游用 sig 牙位 − resolved 得"剩余未治牙位"。 ';'), '')`;
// 该信号"已被解决"的牙位集合 = 诊断后同牙做了 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 const afterDxFragRtx = rule.excludeIfEverTreated
? Prisma.empty ? Prisma.empty
: Prisma.sql`AND rtx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`; : Prisma.sql`AND rtx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`;
...@@ -283,11 +306,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -283,11 +306,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
AND rtx.type = 'treatment_record' AND rtx.kind = 'actual' AND rtx.type = 'treatment_record' AND rtx.kind = 'actual'
AND rtx.status IN ('active', 'fulfilled') AND rtx.status IN ('active', 'fulfilled')
AND COALESCE(NULLIF(trim(rtx.content->>'tooth_position'), ''), '') != '' AND COALESCE(NULLIF(trim(rtx.content->>'tooth_position'), ''), '') != ''
AND ( AND rtx.content->>'category' = ANY(${resolverCats}::text[]) -- 治疗家族 resolver(afterDx)
(rtx.content->>'category' = ANY(${excludeCats}::text[]) ${afterDxFragRtx}) -- ⑤a 同类 ${afterDxFragRtx})`;
OR (rtx.content->>'category' = 'surgical') -- ⑤c 拔除(终结,任意时间)
OR (rtx.content->>'category' IN ('implant', 'prosthodontic') ${afterDxRtx}) -- ⑤e 替代定性
))`;
// ╔═════════════════════════════════════════════════════════════════════╗ // ╔═════════════════════════════════════════════════════════════════════╗
// ║ 召回 SQL 完整解读(initiation = 潜在治疗新链召回) ║ // ║ 召回 SQL 完整解读(initiation = 潜在治疗新链召回) ║
...@@ -331,8 +351,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -331,8 +351,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ║ ┌─ ⑤b 排除:患者已有未来预约 ────────────────────────────────┐ ║ // ║ ┌─ ⑤b 排除:患者已有未来预约 ────────────────────────────────┐ ║
// ║ │ tx.type = 'treatment_record' AND tx.kind = 'actual' │ ║ // ║ │ tx.type = 'treatment_record' AND tx.kind = 'actual' │ ║
// ║ │ tx.status IN ('active','fulfilled') (完成的 actual 是 fulfilled)│ ║ // ║ │ tx.status IN ('active','fulfilled') (完成的 actual 是 fulfilled)│ ║
// ║ │ tx.content->>'category' = ANY(excludeCats) │ ║ // ║ │ tx.content->>'category' = ANY(resolverCats) │ ║
// ║ │ excludeCats 来自 DxMap.K08.categories = ['implant','prostho']│ ║ // ║ │ resolverCats = 治疗家族(结构码=局部结构治疗全集;牙周/正畸沿用)│ ║
// ║ │ tx.occurred_at >= sig.occurred_at ⭐ 时间方向 │ ║ // ║ │ tx.occurred_at >= sig.occurred_at ⭐ 时间方向 │ ║
// ║ │ 只算"诊断之后才启动"的治疗,历史旧治疗(可能是另一颗牙)不算 │ ║ // ║ │ 只算"诊断之后才启动"的治疗,历史旧治疗(可能是另一颗牙)不算 │ ║
// ║ │ │ ║ // ║ │ │ ║
...@@ -406,7 +426,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -406,7 +426,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
AND tx.type = 'treatment_record' AND tx.type = 'treatment_record'
AND tx.kind = 'actual' AND tx.kind = 'actual'
AND tx.status IN ('active', 'fulfilled') AND tx.status IN ('active', 'fulfilled')
AND tx.content->>'category' = ANY(${excludeCats}::text[]) AND tx.content->>'category' = ANY(${resolverCats}::text[]) -- 治疗家族 resolver(全口码 = rule.categories)
${afterDxFrag} ${afterDxFrag}
-- 全口信号 sigToothExpr=NULL → 下条恒真 → category 级排除(忽略 dx 自带牙位) -- 全口信号 sigToothExpr=NULL → 下条恒真 → category 级排除(忽略 dx 自带牙位)
AND COALESCE(NULLIF(trim(${sigToothExpr}), ''), '') = '' AND COALESCE(NULLIF(trim(${sigToothExpr}), ''), '') = ''
...@@ -501,7 +521,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -501,7 +521,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
patientId, patientId,
patientExternalId: r.patient_external_id, patientExternalId: r.patient_external_id,
// reason 文本兜底(AI prompt / 调试用,前端不依赖此字段) // 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, priorityScore: score,
goal: cfg.goal, goal: cfg.goal,
recommendedRole: 'staff', recommendedRole: 'staff',
...@@ -527,7 +547,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -527,7 +547,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
daysSince: r.days_since, daysSince: r.days_since,
// 不可变锚点:展示层据此实时算天数,跟治疗链断口同源(避免快照随天数陈旧 → "388/389"漂移) // 不可变锚点:展示层据此实时算天数,跟治疗链断口同源(避免快照随天数陈旧 → "388/389"漂移)
signalOccurredAt: r.signal_occurred_at?.toISOString() ?? null, signalOccurredAt: r.signal_occurred_at?.toISOString() ?? null,
expectedCategories: [...excludeCats], expectedCategories: [...expectedCats],
}, },
priorityBreakdown: breakdown, priorityBreakdown: breakdown,
}); });
......
...@@ -22,6 +22,7 @@ export function Drawer({ ...@@ -22,6 +22,7 @@ export function Drawer({
persona, persona,
summaries, summaries,
reason, reason,
reasons,
facts, facts,
fmtRel, fmtRel,
summaryOverride, summaryOverride,
...@@ -38,6 +39,8 @@ export function Drawer({ ...@@ -38,6 +39,8 @@ export function Drawer({
treatment_chain: { content: string; generatedAt: Date }; treatment_chain: { content: string; generatedAt: Date };
}; };
reason: PlanReason; reason: PlanReason;
/// 生产召回结果全量(plan.reasons)— ToothTimeline 对账用
reasons: PlanReason[];
/// v2.1:所有 active fact(FactsTimeline + ImageDrawer 用) /// v2.1:所有 active fact(FactsTimeline + ImageDrawer 用)
facts: AdaptedFact[]; facts: AdaptedFact[];
fmtRel: (d: Date) => string; fmtRel: (d: Date) => string;
...@@ -91,8 +94,8 @@ export function Drawer({ ...@@ -91,8 +94,8 @@ export function Drawer({
width = 'w-[640px]'; width = 'w-[640px]';
} else if (kind === 'teeth') { } else if (kind === 'teeth') {
title = '牙位事实'; title = '牙位事实';
subtitle = '每颗牙 / 全口治疗线 · 时间倒序'; subtitle = '每颗牙 / 全口治疗线 · 时间倒序 · oracle 召回对账';
body = <ToothTimeline facts={facts} />; body = <ToothTimeline facts={facts} reasons={reasons} />;
width = 'w-[560px]'; width = 'w-[560px]';
} else if (kind === 'persona') { } else if (kind === 'persona') {
title = '患者画像'; title = '患者画像';
......
...@@ -200,14 +200,14 @@ export function PlanDetailApp({ ...@@ -200,14 +200,14 @@ export function PlanDetailApp({
// 是否已有话术内容(没生成过 → 空态提示,不显示默认 demo / 空标题) // 是否已有话术内容(没生成过 → 空态提示,不显示默认 demo / 空标题)
const hasScriptContent = displayedSections.some((s) => s.markdown.trim().length > 0); 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( const visibleReasons = useMemo(
() => () => [...reasons].sort((a, b) => b.priorityScore - a.priorityScore),
reasons [reasons],
.filter((r) => !reasonAltClosed(r, chains))
.sort((a, b) => b.priorityScore - a.priorityScore),
[reasons, chains],
); );
// 本次聚焦的应治未治项(priorityScore 最高那条 = 话术讲的那个)的 诊断 + 目标治疗 标签 // 本次聚焦的应治未治项(priorityScore 最高那条 = 话术讲的那个)的 诊断 + 目标治疗 标签
...@@ -490,6 +490,7 @@ export function PlanDetailApp({ ...@@ -490,6 +490,7 @@ export function PlanDetailApp({
persona={persona} persona={persona}
summaries={summaries} summaries={summaries}
reason={focusedReason ?? reasons[0]!} reason={focusedReason ?? reasons[0]!}
reasons={reasons}
facts={facts} facts={facts}
fmtRel={fmtRel} fmtRel={fmtRel}
summaryOverride={summaryOverride} summaryOverride={summaryOverride}
...@@ -1067,43 +1068,6 @@ function IdentityCard({ ...@@ -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 拆分; // WhyCard — 召回原因列表(W3 末:plan_reasons 按 sub_key 拆分;
// 每行用 signals JSON + @pac/types 字典翻译富文本渲染,关键字高亮。reason 文本仅作 signals 缺失时 fallback) // 每行用 signals JSON + @pac/types 字典翻译富文本渲染,关键字高亮。reason 文本仅作 signals 缺失时 fallback)
// ────────────────────────────────────────── // ──────────────────────────────────────────
...@@ -1111,7 +1075,7 @@ function WhyCard({ ...@@ -1111,7 +1075,7 @@ function WhyCard({
visibleReasons, visibleReasons,
onOpenMedical, onOpenMedical,
}: { }: {
// 已在父层按 (诊断码 + 牙位) 对齐过滤替代闭环(见 reasonAltClosed),这里只渲染。 // 召回算法产出的全部 reason(治疗链已弃用,不再做替代闭环二次过滤),这里只渲染。
// 跟下方 诊断/目标治疗 标签共用同一份,口径统一。 // 跟下方 诊断/目标治疗 标签共用同一份,口径统一。
visibleReasons: PlanReason[]; visibleReasons: PlanReason[];
onOpenMedical: () => void; onOpenMedical: () => void;
......
...@@ -3,6 +3,14 @@ ...@@ -3,6 +3,14 @@
import { diagnosisCodeNameZh, treatmentCategoryNameZh } from '@pac/types'; import { diagnosisCodeNameZh, treatmentCategoryNameZh } from '@pac/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { AdaptedFact } from './adapt-data'; import type { AdaptedFact } from './adapt-data';
import type { PlanReason } from './mock-data';
import {
runRecallOracle,
reconcile,
verdictMeta,
type OracleVerdict,
type ReconRow,
} from './recall-oracle';
/** /**
* ToothTimeline — 每颗牙(+ 全口治疗线)的事实时间轴 * ToothTimeline — 每颗牙(+ 全口治疗线)的事实时间轴
...@@ -23,12 +31,31 @@ const WHOLE_PERIO = '全口 · 牙周'; ...@@ -23,12 +31,31 @@ const WHOLE_PERIO = '全口 · 牙周';
const WHOLE_ORTHO = '全口 · 正畸'; const WHOLE_ORTHO = '全口 · 正畸';
const WHOLE_OTHER = '全口 · 其他'; 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)); const clinical = facts.filter((f) => CLINICAL_TYPES.has(f.type));
if (clinical.length === 0) { if (clinical.length === 0) {
return <div className="text-center py-12 text-sm text-slate-400">无牙位事实</div>; 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 lanes = new Map<string, AdaptedFact[]>();
const push = (k: string, f: AdaptedFact) => { const push = (k: string, f: AdaptedFact) => {
...@@ -68,11 +95,13 @@ export function ToothTimeline({ facts }: { facts: AdaptedFact[] }) { ...@@ -68,11 +95,13 @@ export function ToothTimeline({ facts }: { facts: AdaptedFact[] }) {
return ( return (
<div className="space-y-2.5"> <div className="space-y-2.5">
<ReconPanel recon={recon} />
{orderedKeys.map((k) => { {orderedKeys.map((k) => {
const rows = [...lanes.get(k)!].sort( const rows = [...lanes.get(k)!].sort(
(a, b) => tkey(b) - tkey(a) || typeRank(b) - typeRank(a), (a, b) => tkey(b) - tkey(a) || typeRank(b) - typeRank(a),
); );
const isWhole = k.startsWith('全口'); const isWhole = k.startsWith('全口');
const laneVerdicts = verdictsByLane.get(k) ?? [];
return ( return (
<div key={k} className="rounded-lg border border-slate-200 overflow-hidden"> <div key={k} className="rounded-lg border border-slate-200 overflow-hidden">
<div <div
...@@ -82,6 +111,20 @@ export function ToothTimeline({ facts }: { facts: AdaptedFact[] }) { ...@@ -82,6 +111,20 @@ export function ToothTimeline({ facts }: { facts: AdaptedFact[] }) {
)} )}
> >
<span className="tabular-nums">{isWhole ? k : `牙位 ${k}`}</span> <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> <span className="ml-auto text-[10px] font-normal text-slate-400">{rows.length}</span>
</div> </div>
<div className="divide-y divide-slate-50"> <div className="divide-y divide-slate-50">
...@@ -96,6 +139,80 @@ export function ToothTimeline({ facts }: { facts: AdaptedFact[] }) { ...@@ -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 }) { function ToothFactRow({ fact }: { fact: AdaptedFact }) {
const tIso = fact.occurredAt ?? fact.plannedFor; const tIso = fact.occurredAt ?? fact.plannedFor;
const planned = !fact.occurredAt && !!fact.plannedFor; const planned = !fact.occurredAt && !!fact.plannedFor;
......
...@@ -245,6 +245,68 @@ export function lookupDxTreatment(code: string): DxTreatmentRule | undefined { ...@@ -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 // 治疗状态(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