Commit 961328bd by luoqi

fix: chain.target 按 (code, tooth) 联合对齐 SQL reason(林兆星 K05 误判)

林兆星 case:
  K05 SQL 召回(无 actual periodontic) ✓
  chain.status=entered(1 年前 fulfilled "牙周" 预约) — 客观真实
  矛盾:旧逻辑 plan-aggregate 只对 discovered chain 改 target → K05 entered target=false
        → UI 不 ★ → 客服看不到该召回

修(plan-aggregate.assemble):
  ① target 计算覆盖所有 status(不限 discovered)
  ② 用 (code + tooth) 联合 key 匹配,不再仅 code:
     reason 有 tooth → chain.code 同 + tooth overlap 才 ★
     reason 无 tooth(全口诊断 K05) → 任何 code 同的 chain ★
  → K08 林兆星 4 条 chain,只 17 那条 ★(SQL 召回 K08@17),其他 K08 不 ★ ✓

前端(chain-viz.chainStatusVisual):
  target=true 时优先返回 "★ 潜在新链",压过 chain.status(entered/ongoing)
  原因:SQL 是真理 — 该召回的就显示 ★,即使历史预约让 chain composer 算 entered
       chain.status 仍展示真实状态(S2 节点显示历史预约),但顶部 badge 优先 ★

验证(林兆星):
  3 个 SQL reasons:K08@17 / K05@全口 / K03@46;47;48
  对应 3 个 chain ★:
    ★ 牙体修复·46;47;48 (K03)
    ★ 牙周治疗·全口 (K05,entered+target=true)
    ★ 种植修复·17 (K08)
  非 ★ chain:K08 其他牙位(已做种植 SQL 排除)+ 外科 closed

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
parent cadbe1d6
...@@ -58,21 +58,43 @@ export class PlanAggregateService { ...@@ -58,21 +58,43 @@ export class PlanAggregateService {
// v2.1:chain-composer 读独立 diagnosis_record / treatment_record / recommendation_record, // v2.1:chain-composer 读独立 diagnosis_record / treatment_record / recommendation_record,
// 传所有 facts,内部按 type 分组(encounter_record 已只元数据) // 传所有 facts,内部按 type 分组(encounter_record 已只元数据)
const chains = this.chainComposer.compose(facts); const chains = this.chainComposer.compose(facts);
// ⭐ SQL 为准:chain ★ 严格按 plan_reasons.signal_code 对齐(W3 末) // ⭐ SQL 为准:chain ★ 按 plan_reasons (code, tooth) 联合对齐(W4 末)
// chain-composer 只产链客观状态;真正"该召回 ★" 由 scenario SQL 决定 //
// 规则:discovered chain.code === reason.signals.triggers[0].code(K00/K01/.../K09 严格匹配) // 规则:reason 是 (code + tooth) tuple → 找 chain.code 相同 + tooth overlap
// ★ 集合 = SQL reason 集合,数量完全一致(陆伟根 K07 reason → 只 K07 chain ★;K08 chain 不 ★) // 林兆星案例:
const reasonCodes = new Set<string>(); // K08 reason tooth=17(SQL 召回的是这颗未做)
// chain 有 4 条 K08:11;12;21;22 / 15;16;24 / 17 / 未标注
// 正确 target:只有 chain 17 应 ★(其他都已做 actual implant,SQL ⑤a 排除)
// K05 reason tooth=null(全口诊断)→ 任何 K05 chain 都对齐(只 1 条牙周·全口)
//
// 1 年前 fulfilled 预约让 chain 算 entered 也照样 ★(SQL 是真理 — 没坚持治疗就该召回)
const reasonKeys = new Set<string>(); // key = "code|tooth" or "code|*"(全口)
for (const r of plan?.reasons ?? []) { for (const r of plan?.reasons ?? []) {
const sigs = (r.signals ?? {}) as { triggers?: Array<{ code?: string }> }; const sigs = (r.signals ?? {}) as { triggers?: Array<{ code?: string }>; toothPosition?: string | null };
const tooth = (sigs.toothPosition ?? '').trim();
for (const t of sigs.triggers ?? []) { for (const t of sigs.triggers ?? []) {
if (t.code) reasonCodes.add(t.code); if (!t.code) continue;
reasonKeys.add(`${t.code}|${tooth || '*'}`);
} }
} }
const toothOverlap = (a: string, b: string): boolean => {
if (!a || !b) return false;
const A = new Set(a.split(';').map((s) => s.replace(/[^0-9]/g, '').trim()).filter(Boolean));
const B = b.split(';').map((s) => s.replace(/[^0-9]/g, '').trim()).filter(Boolean);
return B.some((t) => A.has(t));
};
for (const c of chains) { for (const c of chains) {
if (c.status === 'discovered') { if (!c.code) { c.target = false; continue; }
c.target = !!(c.code && reasonCodes.has(c.code)); let hit = false;
for (const key of reasonKeys) {
const [code, tooth] = key.split('|');
if (code !== c.code) continue;
// reason 无牙位(全口诊断 K05)→ 任何 chain.code 同就命中
if (tooth === '*') { hit = true; break; }
// reason 有牙位 → 跟 chain.toothPosition overlap
if (c.toothPosition && toothOverlap(tooth!, c.toothPosition)) { hit = true; break; }
} }
c.target = hit;
} }
return { return {
......
...@@ -47,7 +47,13 @@ type ChainStatusVisual = { ...@@ -47,7 +47,13 @@ type ChainStatusVisual = {
icon: string; // ★ / ⏵ / ↻ / ✓ icon: string; // ★ / ⏵ / ↻ / ✓
tone: 'rose' | 'amber' | 'sky' | 'emerald'; tone: 'rose' | 'amber' | 'sky' | 'emerald';
}; };
function chainStatusVisual(chain: Pick<Chain, 'status' | 'currentStage'>): ChainStatusVisual { function chainStatusVisual(chain: Pick<Chain, 'status' | 'currentStage' | 'target'>): ChainStatusVisual {
// W4 末:SQL 为准 — target=true(SQL 召回了)优先显示"潜在新链",不管 chain 内部 status
// 临床场景:林兆星 K05 — 1 年前 fulfilled "牙周"预约让 chain 算 entered,但 SQL 召回该 K05
// 因为 1 年没坚持治疗;target 是 SQL 真理,优先级最高
if (chain.target && chain.status !== 'closed') {
return { short: '潜在新链', long: '★ 潜在新链', icon: '★', tone: 'rose' };
}
if (chain.status === 'closed') return { short: '已闭环', long: '✓ 已闭环', icon: '✓', tone: 'emerald' }; if (chain.status === 'closed') return { short: '已闭环', long: '✓ 已闭环', icon: '✓', tone: 'emerald' };
if (chain.status === 'entered') return { short: '已进入', long: '⏵ 已进入', icon: '⏵', tone: 'amber' }; if (chain.status === 'entered') return { short: '已进入', long: '⏵ 已进入', icon: '⏵', tone: 'amber' };
if (chain.status === 'discovered') return { short: '潜在新链', long: '★ 潜在新链', icon: '★', tone: 'rose' }; if (chain.status === 'discovered') return { short: '潜在新链', long: '★ 潜在新链', icon: '★', tone: 'rose' };
...@@ -57,8 +63,8 @@ function chainStatusVisual(chain: Pick<Chain, 'status' | 'currentStage'>): Chain ...@@ -57,8 +63,8 @@ function chainStatusVisual(chain: Pick<Chain, 'status' | 'currentStage'>): Chain
} }
// 5 状态徽章(W3 末从 3 态升级)— 后端 chain-composer 5 阶段引擎产物 // 5 状态徽章(W3 末从 3 态升级)— 后端 chain-composer 5 阶段引擎产物
function ChainStatusBadge({ status, currentStage }: { status: Chain['status']; currentStage: Chain['currentStage'] }) { function ChainStatusBadge({ status, currentStage, target }: { status: Chain['status']; currentStage: Chain['currentStage']; target?: boolean }) {
const v = chainStatusVisual({ status, currentStage }); const v = chainStatusVisual({ status, currentStage, target });
return ( return (
<Chip tone={v.tone} size="xs" icon={v.tone !== 'emerald'}> <Chip tone={v.tone} size="xs" icon={v.tone !== 'emerald'}>
{v.short} {v.short}
......
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