Commit 9416620a by luoqi

fix: S2 严格化 — planned treatment 不再触发 entered 状态

路遥 case(N=4 chains):
  改前:K01 智齿拔除 / K08 种植 因 EMR.plan 有医生计划 → status=entered(误)
  改后:status=discovered(正确)— 患者未预约/付款/到诊
        plannedHint 仍展示 "延期种植术 · 已开计划(待执行)" 给客服暗示

W2/W3 旧版用 planned treatment 当 S2 fallback,跟 collectS2Facts 顶部注释
"医生侧动作 ≠ 患者承诺" 自相矛盾。W4 末彻底清理。

S2 真信号(只 1 路):
  appointment.complaint_category 匹配 → 患者主动预约(挂号/约时间/到店)

planned treatment 信息没浪费:
  - 不进 s2Hits(不升 status=entered)
  - 在 S2 node 渲染时作为 plannedHint 展示 "已开计划(待执行)" + done=false
  - 客服看到"医生计划是 XX 但患者还没动" — 仍属召回目标

副作用预期:
  - 召回率会上升(之前误升 entered 的现在回 discovered,被召回)
  - 准确率上升(真没动作的患者被正确召回)

新加 helper:findPlannedTreatmentHint(category, byType, s1AnchorTime)
  从 byType.treatment 找同 category 的 planned(s1 之后,最早一条)— 纯展示用

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
parent 3e31eb58
...@@ -574,20 +574,21 @@ function collectS1Facts(category: string, byType: FactsByType): ChainComposeInpu ...@@ -574,20 +574,21 @@ function collectS1Facts(category: string, byType: FactsByType): ChainComposeInpu
return out; return out;
} }
/// S2 = S1 之后的"进入治疗链" 强信号 — **预约主诉类别**(W3 末重写) /// S2 = S1 之后的"进入治疗链" 强信号 — **必须是患者主动行为**(W4 末严格化)
/// ///
/// 旧版用 planned treatment(医生录入治疗计划)作 S2 信号,问题: /// 历史演进:
/// - 医生侧动作 ≠ 患者承诺(医生 3 年开 SRP 计划,患者每次只洁牙拒绝 SRP) /// - W2 旧版:planned treatment 当 S2 → 王辉 case 证伪("医生开计划 ≠ 患者承诺")
/// - 实例:王辉 K05 慢性牙龈炎 + 3 次 planned 龈上洁治术 → 旧版 entered,实际患者一次没做 SRP /// - W3 末:加预约 complaint_category 强信号,planned 降为 fallback
/// - W4 末:fallback 完全移除 — 患者真没动作不应误升 status=entered
/// 路遥 K01 智齿拔除 / K08 种植 都只有医生 planned,患者未预约/未付款 → 应留 discovered
/// ///
/// 新版只看 **patient 主动预约同 category** (appointment.complaint_category 命中): /// 当前 S2 信号(只有 1 个,患者主动):
/// - "牙周" 预约 → S2 命中 K05 牙周链 /// appointment 主诉类别匹配 — "牙周"/"种植"/"正畸" 等 complaint_category 命中 category
/// - "种植" 预约 → S2 命中 K08 种植链 /// - 预约是患者主动行为(挂号/约时间/到店),代理"真意向" 比"医生侧计划" 准
/// - "常规" / "拔牙" / 其他不匹配 → 不命中 /// - 桶 tooth 不约束(挂号阶段无牙位字段),按 category 粗判
/// - 预约是患者主动行为(挂号、约时间、来店),代理"真意向" 比"医生侧计划" 准
/// ///
/// 桶 tooth 不做约束:预约 complaint_category 无牙位字段(挂号阶段不细到牙),按 category 粗判即可 /// planned treatment 信息没浪费:在 S2 node 渲染时作为"医生已开计划"hint 展示(done=false),
/// (path 牙位精度走后续 S3 actual treatment 阶段) /// 让客服看到"医生计划是 XX 但患者还没动" — 但 status 仍是 discovered,需要被召回。
function collectS2Facts( function collectS2Facts(
category: string, category: string,
byType: FactsByType, byType: FactsByType,
...@@ -602,15 +603,12 @@ function collectS2Facts( ...@@ -602,15 +603,12 @@ function collectS2Facts(
const t = effectiveTime(f); const t = effectiveTime(f);
return !!(t && t.getTime() >= s1AnchorTime.getTime()); return !!(t && t.getTime() >= s1AnchorTime.getTime());
}; };
// 预约时间上界:appointment 必须早于 S3 第一笔 actual(先约后做);planned 不约束(医生随时开下一步计划)
const passesUpper = (f: ChainComposeInputFact): boolean => { const passesUpper = (f: ChainComposeInputFact): boolean => {
if (!apptUpperTime || f.type !== FactType.APPOINTMENT_RECORD) return true; if (!apptUpperTime) return true;
const t = effectiveTime(f); const t = effectiveTime(f);
return !!(t && t.getTime() < apptUpperTime.getTime()); return !!(t && t.getTime() < apptUpperTime.getTime());
}; };
const out: ChainComposeInputFact[] = []; const out: ChainComposeInputFact[] = [];
// ① 预约 complaint_category 命中(强信号 — 患者主动预约)
for (const appt of byType.appointment) { for (const appt of byType.appointment) {
if (appt.status === 'cancelled') continue; if (appt.status === 'cancelled') continue;
if (!passesLower(appt) || !passesUpper(appt)) continue; if (!passesLower(appt) || !passesUpper(appt)) continue;
...@@ -620,19 +618,30 @@ function collectS2Facts( ...@@ -620,19 +618,30 @@ function collectS2Facts(
out.push(appt); out.push(appt);
} }
} }
if (out.length > 0) return out; return out;
}
// ② fallback: planned treatment(同 category) — 弱信号(医生计划随时可开,治疗中也可开下一步) /// W4 末:S2 node 展示用 — 找同 category 的 planned treatment(s1 之后)
// 不受 apptUpperTime 约束 — "做完一项,医生开下一项" 也是合法 S2 信号(显示给客服看) /// status 不升 entered,但客服看到"医生已开计划但未执行"的提示
// 例:路遥 K05 首诊当天 actual 洁治 + 同日 planned 牙周刮治术 → 显示"已开计划·牙周刮治术" function findPlannedTreatmentHint(
category: string,
byType: FactsByType,
s1AnchorTime: Date | null,
): ChainComposeInputFact | null {
let best: ChainComposeInputFact | null = null;
for (const tx of byType.treatment) { for (const tx of byType.treatment) {
if (tx.kind !== FactKind.PLANNED) continue; if (tx.kind !== FactKind.PLANNED) continue;
const tc = tx.content as Record<string, unknown>; const tc = tx.content as Record<string, unknown>;
const cat = String(tc.category ?? ''); if (String(tc.category ?? '') !== category) continue;
if (cat !== category || !passesLower(tx)) continue; if (s1AnchorTime) {
out.push(tx); const t = effectiveTime(tx);
if (!t || t.getTime() < s1AnchorTime.getTime()) continue;
}
const t = effectiveTime(tx);
const bt = best ? effectiveTime(best) : null;
if (!best || (t && (!bt || t.getTime() < bt.getTime()))) best = tx;
} }
return out; return best;
} }
/// 牙位串规整化 — 用于桶分 key:同 N 颗牙不同顺序("17;47;37" / "47;17;37")合 1 桶。 /// 牙位串规整化 — 用于桶分 key:同 N 颗牙不同顺序("17;47;37" / "47;17;37")合 1 桶。
...@@ -849,46 +858,53 @@ function buildStageNodes(opts: { ...@@ -849,46 +858,53 @@ function buildStageNodes(opts: {
})(); })();
// ─── S2 进入治疗链 ───────────────────────────────── // ─── S2 进入治疗链 ─────────────────────────────────
// ⭐ done 状态独立判定:不能只看 currentStage>=2(那样 S3→S2 直接跳的患者也假打 ✓) // W4 末:S2 done 标准严格化 — **必须有患者主动行为**(预约 complaint 匹配 / 付款 / 到诊)
// 真正 done 必须有 s2Earliest 信号(预约 complaint_category 命中);否则即使 stage=3 也显示"跳过" // ❌ 不再把"医生 EMR 开了 planned treatment" 算 S2 done(医生动作 ≠ 患者承诺)
// 临床场景:急诊补牙 / 检查发现龋直接当场修(S1→S3 直跳,没经过预约修复主诉) — 这种 S2 是真没命中 // ✅ planned treatment 仅作为 hint 展示("医生计划是 XX 但患者还没动"),done=false
//
// 临床场景:
// - 急诊补牙 / 检查发现龋直接当场修(S1→S3 直跳,没经过预约修复主诉)→ "直接执行"
// - 医生开了计划但患者没行动 → "已开计划(待执行)"提示客服
const plannedHint = findPlannedTreatmentHint(category, byType, s1Earliest?.occurredAt ?? null);
const s2Node: ChainNode = (() => { const s2Node: ChainNode = (() => {
const s2Done = !!s2Earliest; const s2Done = !!s2Earliest;
const n: ChainNode = { stage: 2, at: '—', done: s2Done, current: cur(2), missing: !s2Done }; const n: ChainNode = { stage: 2, at: '—', done: s2Done, current: cur(2), missing: !s2Done };
if (!s2Earliest) { if (s2Earliest) {
// S2 无信号:看 status 决定文案 // S2 真命中(患者主动行为)
if (status === 'discovered') { const c = s2Earliest.content as Record<string, unknown>;
// 未启动治疗,医生建议什么(rose hint) if (s2Earliest.type === FactType.APPOINTMENT_RECORD) {
n.title = '尚未启动'; const complaint = String(c.complaint_text ?? c.complaint_category ?? '').trim();
n.hint = `建议${expectedHint}`; n.title = '预约就诊';
n.detail = complaint ? shortLabel(complaint) : '已主动预约';
n.doctor = resolveDoctorName(s2Earliest, doctorMap);
} else if (s2Earliest.type === FactType.PAYMENT_RECORD) {
const amount = Number(c.amount_cents ?? 0);
n.title = '已付款';
n.detail = formatYuan(amount);
} else { } else {
// S3+ 状态但 S2 没命中 → 患者跳过预约直接来诊(常见于急诊处理 / 复诊连带补牙) n.title = '已进入';
n.title = '直接执行';
n.detail = '未经预约';
} }
n.at = fmt(effectiveTime(s2Earliest));
return n; return n;
} }
const c = s2Earliest.content as Record<string, unknown>; // S2 无命中(患者未行动)— 看 status 决定文案
if (s2Earliest.type === FactType.APPOINTMENT_RECORD) { if (status === 'discovered') {
// 预约 complaint_category 命中:显示"预约 + 主诉文本" if (plannedHint) {
const complaint = String(c.complaint_text ?? c.complaint_category ?? '').trim(); // 医生已开计划但患者还没动 — 给客服暗示,但不打 done ✓
n.title = '预约就诊'; const pc = plannedHint.content as Record<string, unknown>;
n.detail = complaint ? shortLabel(complaint) : '已主动预约'; n.title = shortLabel(String(pc.subtype ?? '') || '医生计划');
n.doctor = resolveDoctorName(s2Earliest, doctorMap); n.detail = '已开计划(待执行)';
} else if (s2Earliest.type === FactType.TREATMENT_RECORD) { n.doctor = resolveDoctorName(plannedHint, doctorMap);
// (兜底)planned 治疗:显示 subtype + "计划" n.at = fmt(effectiveTime(plannedHint));
n.title = shortLabel(String(c.subtype ?? '') || '治疗计划'); } else {
n.detail = '已开计划'; n.title = '尚未启动';
n.doctor = resolveDoctorName(s2Earliest, doctorMap); n.hint = `建议${expectedHint}`;
} else if (s2Earliest.type === FactType.PAYMENT_RECORD) { }
// (兜底)大额 payment:显示"已付款 ¥X"
const amount = Number(c.amount_cents ?? 0);
n.title = '已付款';
n.detail = formatYuan(amount);
} else { } else {
n.title = '已进入'; // S3+ 状态但 S2 没命中 → 患者跳过预约直接来诊(常见于急诊/复诊连带)
n.title = '直接执行';
n.detail = '未经预约';
} }
n.at = fmt(effectiveTime(s2Earliest));
return n; return n;
})(); })();
......
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