Commit 4c3f9c58 by luoqi

fix(plan-detail): 深度/标准话术刷新丢失 + 主治医生误显影像AI

- parseScriptMarkdownToSections 改通用 H2 解析:原只认稳健 4 固定标题(开场白/...),
  深度/标准的自由标题反 parse 全落空 → plan_scripts 存了内容但刷新显示'尚未生成'。
  现按任意 ## 标题切段(稳健固定标题映射已知 id,标准/深度自由标题用 s{n}+原标题),三档通用。
- KeyFactsCard 主治医生:影像AI(image_ai 触发诊断)不当主治医生显示,退回真实最高频医生
  (排除影像AI/image_ai),真无人类医生 → '—'。话术侧 extractPrimaryDoctor 本就排除,一致。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 5816aca8
...@@ -567,42 +567,36 @@ const SECTION_META: Record< ...@@ -567,42 +567,36 @@ const SECTION_META: Record<
}; };
function parseScriptMarkdownToSections(md: string) { function parseScriptMarkdownToSections(md: string) {
// 按 H2 切分:`^## (xxx)$` 之后到下一个 H2 之间为内容 // ⭐ 通用 H2 切分:每个 `## 标题` 起一段,到下一个 H2 之间为内容。
const ids: Array<'opening' | 'informMissed' | 'reviewAdvice' | 'closing'> = [ // - 稳健档:4 个固定标题(开场白/告知应治未治/复查建议/结束回访语)→ 映射到已知 id + 固定 label。
'opening', // - 标准/深度档:自由标题(段数不定)→ id=`s{序号}`、label=原标题。
'informMissed', // 旧实现只认稳健 4 固定标题 → 深度/标准的自由标题全 currentId=null、内容丢弃 → 刷新后空白
'reviewAdvice', // (plan_scripts 存了内容,但反 parse 解不出 → 前端"尚未生成参考话术")。本版按任意 H2 解析,三档通用。
'closing', // H2 之前的前言(`> 患者:… · 语气:…`)在首个 H2 前,自然跳过。
]; if (!md) return [];
const result = ids.map((id) => ({
id,
label: SECTION_META[id].label,
durationHint: SECTION_META[id].durationHint,
markdown: '',
}));
if (!md) return result;
const lines = md.split('\n'); const lines = md.split('\n');
let currentId: 'opening' | 'informMissed' | 'reviewAdvice' | 'closing' | null = null; const sections: Array<{ id: string; label: string; durationHint: string; markdown: string }> = [];
const buf: Record<string, string[]> = {}; let cur: { id: string; label: string; durationHint: string; buf: string[] } | null = null;
let idx = 0;
const flush = () => {
if (cur) sections.push({ id: cur.id, label: cur.label, durationHint: cur.durationHint, markdown: cur.buf.join('\n').trim() });
};
for (const line of lines) { for (const line of lines) {
const m = /^##\s+(.+?)\s*$/.exec(line); const m = /^##\s+(.+?)\s*$/.exec(line);
if (m) { if (m) {
flush();
const head = (m[1] ?? '').trim(); const head = (m[1] ?? '').trim();
const matched = SECTION_HEAD_TO_ID[head]; const fixedId = SECTION_HEAD_TO_ID[head];
currentId = matched ?? null; cur = fixedId
if (currentId) buf[currentId] = []; ? { id: fixedId, label: SECTION_META[fixedId].label, durationHint: SECTION_META[fixedId].durationHint, buf: [] }
: { id: `s${idx}`, label: head, durationHint: '', buf: [] };
idx++;
continue; continue;
} }
if (currentId) { if (cur) cur.buf.push(line);
buf[currentId] = buf[currentId] ?? [];
buf[currentId]!.push(line);
}
}
for (const sec of result) {
sec.markdown = (buf[sec.id] ?? []).join('\n').trim();
} }
return result; flush();
return sections;
} }
function serializeFact(f: { function serializeFact(f: {
......
...@@ -1190,33 +1190,33 @@ function KeyFactsCard({ ...@@ -1190,33 +1190,33 @@ function KeyFactsCard({
onOpenDetail: () => void; onOpenDetail: () => void;
onOpenTeeth: () => void; onOpenTeeth: () => void;
}) { }) {
// ─ 主治医生(口径 A)─ = 触发本次召回的诊断的医生(focusedReason 的证据 fact)。 // ─ 主治医生(口径 A)─ = 触发本次召回的诊断的【真实】医生(focusedReason 证据 fact)。
// 而非"全量最高频医生"——召回是冲某诊断来的,该露出做出该诊断的医生。 // 而非"全量最高频医生"——召回是冲某诊断来的,该露出做出该诊断的医生。
// 兜底:① 触发诊断只来自影像 AI(无人类医生)→ '影像AI'; // ⚠️ 影像 AI 诊断(code_source=image_ai,doctor=影像AI)不是真人,不当主治医生显示:
// ② 无 focusedReason(异常)→ 退回全量最高频医生;③ 都没有 → '—'。 // 退回"全量最高频【真实】医生"(排除影像AI);真没人类医生过 → '—'。
// (避免缺牙这种常被影像 AI 触发的召回把"主治医生"显示成"影像AI"。)
const attendingDoctor = (() => { const attendingDoctor = (() => {
const ev = focusedReason?.evidence ?? []; const ev = focusedReason?.evidence ?? [];
if (ev.length > 0) { if (ev.length > 0) {
const evidenceIds = new Set(ev.map((e) => e.id)); const evidenceIds = new Set(ev.map((e) => e.id));
let sawImageDx = false;
for (const f of facts) { for (const f of facts) {
if (f.type !== 'diagnosis_record' || !evidenceIds.has(f.id)) continue; if (f.type !== 'diagnosis_record' || !evidenceIds.has(f.id)) continue;
const c = f.content as Record<string, unknown> | null; const c = f.content as Record<string, unknown> | null;
const name = c?.doctor_name ? String(c.doctor_name) : ''; const name = c?.doctor_name ? String(c.doctor_name).trim() : '';
if (name) return name; // A:触发诊断的医生 if (name && name !== '影像AI') return name; // A:触发诊断的真实医生
if (String(c?.code_source ?? '') === 'image_ai') sawImageDx = true;
} }
if (sawImageDx) return '影像AI'; // 只被影像分析诊断,无人类主诊 // 触发诊断只来自影像 AI / 无人类医生 → 不显示"影像AI",落到下方真实医生兜底
} }
// 兜底:全量 fact 最高频医生(同 chain-composer.buildDoctorMap) // 兜底:全量 fact 最高频【真实】医生(排除影像 AI;同 chain-composer.buildDoctorMap)
const idCount = new Map<string, number>(); const idCount = new Map<string, number>();
const idToName = new Map<string, string>(); const idToName = new Map<string, string>();
for (const f of facts) { for (const f of facts) {
const c = f.content as Record<string, unknown> | null; const c = f.content as Record<string, unknown> | null;
const id = c?.doctor_id ? String(c.doctor_id) : ''; const id = c?.doctor_id ? String(c.doctor_id) : '';
if (!id) continue; const name = c?.doctor_name ? String(c.doctor_name).trim() : '';
if (!id || name === '影像AI' || String(c?.code_source ?? '') === 'image_ai') continue;
idCount.set(id, (idCount.get(id) ?? 0) + 1); idCount.set(id, (idCount.get(id) ?? 0) + 1);
if (c?.doctor_name) idToName.set(id, String(c.doctor_name)); if (name) idToName.set(id, name);
} }
if (idCount.size === 0) return '—'; if (idCount.size === 0) return '—';
const topId = [...idCount.entries()].sort((a, b) => b[1] - a[1])[0]![0]; const topId = [...idCount.entries()].sort((a, b) => b[1] - a[1])[0]![0];
......
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