Commit 354eaecd by luoqi

fix(plan-detail): 召回理由替代闭环抑制按 (诊断码+牙位) 对齐,修复同牙不同病程误杀

WhyCard 的替代闭环抑制原先只按牙位收集 altCoveredTeeth,把某条
alternativeClosedBy 链覆盖的牙位上【所有】召回理由都 drop。同一颗牙若
存在不同病程会互相误杀:

  韩滨 11 号牙:2024 龋齿(K02)→修复 已闭环(altClosedBy),
  误删了 2025 缺牙(K08)→种植 的召回理由(分 85,本应是主理由),
  WhyCard 退而显示 perio(分 47)→ 与治疗链「种植修复·11 潜在新链」口径对不上。

修复:按 (诊断码 + 牙位 overlap) 把 reason 对齐到【它自己】那条
altClosedBy 链才抑制(reasonAltClosed)。K02 闭环不再误杀 K08 召回;
而 K04 根管被同牙后续替代关闭(同一条 K04 链的 altClosedBy)仍正确抑制。

- mock-data / plan-detail-types: Chain 加 code 字段;adapt-data 透传 code
- visibleReasons 上提到父组件,WhyCard 召回理由卡 + 诊断/目标治疗标签 +
  TopBar 共用同一份,口径统一(避免卡片显示 A、标签显示 B)
- 真实数据验证:韩滨主理由 => missing_tooth@11(缺失牙·11),与治疗链一致

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent e199bcdf
......@@ -80,6 +80,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
latestDxName: c.latestDxName,
latestDxAdvice: c.latestDxAdvice,
lifecycleNoteZh: c.lifecycleNoteZh,
code: c.code,
toothPosition: c.toothPosition,
alternativeClosedBy: c.alternativeClosedBy,
nodes: c.nodes.map((n) => ({
......
......@@ -107,6 +107,8 @@ export type Chain = {
latestDxAdvice?: string;
/// 生命周期提示("终身维护(永不闭环)" 等)— UI tooltip
lifecycleNoteZh?: string;
/// 诊断码(K00–K09)— 跨链对齐 reason↔chain 用(WhyCard 替代闭环抑制按 code+牙位对齐)
code?: string;
/// 桶牙位(cross-chain alternative-closed 判定用,UI 不直接展示)
toothPosition?: string;
/// "替代治疗已覆盖"原因 chain.name(K04 后续 K08+种植覆盖等)
......
......@@ -191,11 +191,18 @@ export function PlanDetailApp({
// 是否已有话术内容(没生成过 → 空态提示,不显示默认 demo / 空标题)
const hasScriptContent = displayedSections.some((s) => s.markdown.trim().length > 0);
// 本次聚焦的应治未治项(priorityScore 最高那条 = 话术讲的那个)的 诊断 + 目标治疗 标签
const focusedReason = useMemo(
() => [...reasons].sort((a, b) => b.priorityScore - a.priorityScore)[0],
[reasons],
// 可见召回原因 = 过滤掉"已被同病程替代方案闭环覆盖"的 reason(按 code+牙位对齐,见 reasonAltClosed)
// WhyCard 召回理由卡 + 下面的 诊断/目标治疗 标签 都用这同一份,口径统一(避免卡片显示 A、标签显示 B)
const visibleReasons = useMemo(
() =>
reasons
.filter((r) => !reasonAltClosed(r, chains))
.sort((a, b) => b.priorityScore - a.priorityScore),
[reasons, chains],
);
// 本次聚焦的应治未治项(priorityScore 最高那条 = 话术讲的那个)的 诊断 + 目标治疗 标签
const focusedReason = visibleReasons[0];
const focusDiagnosis = (() => {
const code = focusedReason?.signals?.triggers?.find((t) => /^K\d/i.test(t.code ?? ''))?.code;
return code ? diagnosisCodeNameZh(code) : null;
......@@ -259,7 +266,7 @@ export function PlanDetailApp({
{banner}
<TopBar
plan={plan}
reason={reasons[0]!}
reason={focusedReason ?? reasons[0]!}
patientId={patient.id}
onRefreshAggregate={onRefreshAggregate}
showToast={showToast}
......@@ -282,8 +289,7 @@ export function PlanDetailApp({
}
/>
<WhyCard
reasons={reasons}
chains={chains}
visibleReasons={visibleReasons}
onOpenMedical={() => setDrawerOpen('medical')}
/>
<SidebarCard
......@@ -475,7 +481,7 @@ export function PlanDetailApp({
chains={chains}
persona={persona}
summaries={summaries}
reason={reasons[0]!}
reason={focusedReason ?? reasons[0]!}
facts={facts}
fmtRel={fmtRel}
summaryOverride={summaryOverride}
......@@ -898,31 +904,54 @@ 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)
// ──────────────────────────────────────────
function WhyCard({
reasons,
chains,
visibleReasons,
onOpenMedical,
}: {
reasons: PlanReason[];
chains: typeof mockChains;
// 已在父层按 (诊断码 + 牙位) 对齐过滤替代闭环(见 reasonAltClosed),这里只渲染。
// 跟下方 诊断/目标治疗 标签共用同一份,口径统一。
visibleReasons: PlanReason[];
onOpenMedical: () => void;
}) {
// 替代闭环联动:若 reason.toothPosition 命中任一 chain.alternativeClosedBy(同牙位被后续替代方案覆盖),
// 该 reason 已无召回意义 — 直接从列表 drop,不显示。
// 治疗链全景里那条 chain 自带 amber "已被替代" chip,客服需要时去全景看,WhyCard 不再重复噪音。
// (scenario SQL 是 patient 级粗粒度,跨 chain alternativeClosedBy 只在展示层 chain-composer 跑,故过滤只在 UI 侧做)
const altCoveredTeeth = new Set<string>();
for (const c of chains) {
if (!c.alternativeClosedBy || !c.toothPosition) continue;
altCoveredTeeth.add(c.toothPosition);
}
const visibleReasons = reasons.filter((r) => {
const tooth = r.signals?.toothPosition ?? '';
return !tooth || !altCoveredTeeth.has(tooth);
});
if (visibleReasons.length === 0) {
return (
<SidebarCard
......
......@@ -130,6 +130,8 @@ export type PlanDetailData = {
latestDxAdvice?: string;
/// 生命周期提示("终身维护(永不闭环)" 等)
lifecycleNoteZh?: string;
/// 诊断码(K00–K09)— WhyCard 替代闭环抑制按 code+牙位对齐 reason↔chain
code?: string;
/// 桶牙位(cross-chain alternative-closed 判定用,UI 不直接展示)
toothPosition?: string;
/// "替代治疗已覆盖"原因 chain.name(K04 后续 K08+种植覆盖等)
......
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