import type { NarrativeMap, NarrativeMapClaim, NarrativeMapClaimRelation, NarrativeMapResearchGap } from "./map" import { emptyDisplayModel, isChineseLanguage, isJapaneseLanguage, relationKey, type ValidatedNarrativeDisplayModel } from "./display" interface FlowNode { id: string claim: NarrativeMapClaim title: string displayCard?: ReturnType claimHtml: string claimPanelTitle: string claimPanelSubtitle: string initialDetailId?: string initialDetailHtml: string initialDetailTitle: string initialDetailSubtitle: string detailTemplates: string } export function renderNarrativeMapHtml(map: NarrativeMap, display?: ValidatedNarrativeDisplayModel): string { return renderNarrativeMapHtmlWithDisplay(map, display) } export function renderNarrativeMapHtmlWithDisplay(map: NarrativeMap, display: ValidatedNarrativeDisplayModel = emptyDisplayModel("en")): string { const title = `Revela Claim Flow - ${map.snapshot.narrativeHash}` const nodes = buildFlowNodes(map, display) const initial = nodes[0] const inferredCount = map.claimRelations.filter((relation) => relation.inferred).length const pageTitle = display.pageTitle ?? valueOrFallback(map.snapshot.thesis, map.snapshot.decisionAction || "Narrative claim flow") const summaryLine = display.summaryLine ?? "Select a claim to read its evidence and gaps. Evidence cards show what the source says, why it supports the claim, and where it came from." return ` ${escapeHtml(title)}

${escapeHtml(display.labels.eyebrow)}

${escapeHtml(pageTitle)}

${escapeHtml(summaryLine)}

${escapeHtml(systemTerm("approval", display))}: ${escapeHtml(localizeValue(map.snapshot.approval, display))} ${escapeHtml(display.labels.status)}: ${escapeHtml(localizeValue(map.snapshot.status, display))} ${escapeHtml(systemTerm("claims", display))}: ${nodes.length} ${escapeHtml(systemTerm("relations", display))}: ${map.claimRelations.length}${inferredCount > 0 ? ` (${inferredCount} ${escapeHtml(systemTerm("inferred", display))})` : ""}

${escapeHtml(display.labels.claimFlow)}

${escapeHtml(display.labels.flowNote)}

${nodes.length ? nodes.map((node, index) => renderStep(node, map, display, index, index === 0)).join("") : emptyCard(display.labels.claimFlow, display.labels.noClaims)}
${nodes.map((node) => `${node.detailTemplates}`).join("")}
` } function buildFlowNodes(map: NarrativeMap, display: ValidatedNarrativeDisplayModel): FlowNode[] { return allClaims(map).map((claim) => { const panel = claimEvidencePanel(claim, map, display) return { id: nodeId(claim.id), claim, title: display.claimCards.get(claim.id)?.displayTitle ?? claim.text, displayCard: display.claimCards.get(claim.id), claimHtml: panel.html, claimPanelTitle: panel.title, claimPanelSubtitle: panel.subtitle, initialDetailId: panel.initialDetailId, initialDetailHtml: panel.initialDetailHtml, initialDetailTitle: panel.initialDetailTitle, initialDetailSubtitle: panel.initialDetailSubtitle, detailTemplates: panel.detailTemplates, } }) } function renderStep(node: FlowNode, map: NarrativeMap, display: ValidatedNarrativeDisplayModel, index: number, active: boolean): string { const outgoing = map.claimRelations.filter((relation) => relation.fromClaimId === node.claim.id) return `
${index + 1}
` } function renderDisplayCardSummary(card: FlowNode["displayCard"], display: ValidatedNarrativeDisplayModel): string { if (!card) return "" const labels = sectionLabels(display) const rows: Array<[string, string | undefined]> = [ [labels.role, card.roleLabel], [labels.narrativeJob, card.narrativeJob], [labels.evidenceSummary, card.evidenceSummary], ] if (rows.length === 0) return "" return `${rows.filter(([, value]) => Boolean(value)).map(([label, value]) => `${escapeHtml(value)}`).join("")}` } function renderOutgoingRelation(relation: NarrativeMapClaimRelation, display: ValidatedNarrativeDisplayModel): string { const label = relationDisplayLabel(relation, display, true) const target = displayClaimText(relation.toClaimId, relation.toClaimText, display) const rationale = relationDisplayRationale(relation, display) return `${escapeHtml(label)}${escapeHtml(systemTerm("to", display))}: ${escapeHtml(shorten(target, 120))}${rationale ? `${escapeHtml(systemTerm("rationale", display))}: ${escapeHtml(rationale)}` : ""}` } function claimEvidencePanel(claim: NarrativeMapClaim, map: NarrativeMap, display: ValidatedNarrativeDisplayModel): { title: string; subtitle: string; html: string; initialDetailId?: string; initialDetailHtml: string; initialDetailTitle: string; initialDetailSubtitle: string; detailTemplates: string } { const gaps = relatedGaps(claim, map) const card = display.claimCards.get(claim.id) const evidenceItems = claim.evidence.map((evidence) => ({ id: itemDomId("evidence", evidence.id), kind: "evidence" as const, title: display.labels.linkedGaps, subtitle: evidence.source || evidence.id, html: linkedGapsPanel(evidence, gaps.filter((gap) => gap.evidenceBindingIds?.includes(evidence.id)), display), card: evidenceCard(evidence, display, card), })) const gapItems = gaps.map((gap) => ({ id: itemDomId("gap", gap.id), kind: "gap" as const, title: display.labels.selectedGap, subtitle: `${localizeValue(gap.status, display)} / ${localizeValue(gap.priority, display)}`, html: gapDetail(gap, display), card: gapCard(gap, display), })) const items = [...evidenceItems, ...gapItems] const initial = items[0] const title = gaps.length ? `${display.labels.evidenceList} / ${display.labels.gaps}` : display.labels.evidenceList const statusLine = [`${claim.evidence.length} ${display.labels.evidenceList}`, gaps.length ? `${gaps.length} ${display.labels.gaps}` : undefined].filter(Boolean).join(" / ") const html = [ `
`, claim.evidence.length ? `
${escapeHtml(display.labels.evidenceList)}
${evidenceItems.map((item) => item.card).join("")}` : `

${escapeHtml(display.labels.evidenceList)}

${escapeHtml(display.labels.noEvidence)}

`, gaps.length ? `
${escapeHtml(display.labels.gaps)}
${gapItems.map((item) => item.card).join("")}` : "", `
`, ].join("") return { title, subtitle: statusLine || display.labels.selectEvidencePrompt, html, initialDetailId: initial?.id, initialDetailHtml: initial?.html ?? emptyCard(display.labels.selectedEvidence, display.labels.selectEvidencePrompt), initialDetailTitle: initial?.title ?? display.labels.selectedEvidence, initialDetailSubtitle: initial?.subtitle ?? display.labels.selectEvidencePrompt, detailTemplates: items.map((item) => ``).join(""), } } function evidenceCard(evidence: NarrativeMapClaim["evidence"][number], display: ValidatedNarrativeDisplayModel, card: ReturnType): string { const id = itemDomId("evidence", evidence.id) const description = evidence.quote || evidence.source || evidence.location || evidence.supportScope || display.labels.evidence const why = card?.supportRationale || card?.supportedScope || evidence.supportScope const sources = sourceItems(evidence, display) return `` } function gapCard(gap: NarrativeMapResearchGap, display: ValidatedNarrativeDisplayModel): string { const id = itemDomId("gap", gap.id) const question = displayGapQuestion(gap, display) return `` } function linkedGapsPanel(evidence: NarrativeMapClaim["evidence"][number], gaps: NarrativeMapResearchGap[], display: ValidatedNarrativeDisplayModel): string { if (!gaps.length) return emptyCard(display.labels.linkedGaps, display.labels.noLinkedGaps) return `
${gaps.map((gap) => gapCard(gap, display)).join("")}
` } function gapDetail(gap: NarrativeMapResearchGap, display: ValidatedNarrativeDisplayModel): string { return detailCards([ [display.labels.gap, displayGapQuestion(gap, display)], [display.labels.status, `${localizeValue(gap.status, display)} / ${localizeValue(gap.priority, display)}`], [systemTerm("target", display), `${gap.targetType}${gap.targetId ? `: ${gap.targetId}` : ""}`], ...optionalRows(systemTerm("findingsFile", display), gap.findingsFile), ...optionalRows(systemTerm("evidenceBindingIds", display), gap.evidenceBindingIds?.join(", ")), ...optionalRows(systemTerm("notes", display), gap.notes), ]) } function relatedGaps(claim: NarrativeMapClaim, map: NarrativeMap): NarrativeMapResearchGap[] { const evidenceIds = new Set(claim.evidence.map((evidence) => evidence.id)) const seen = new Set() return map.researchGaps.filter((gap) => { const matchesClaim = gap.targetType === "claim" && gap.targetId === claim.id const matchesEvidence = (gap.evidenceBindingIds ?? []).some((id) => evidenceIds.has(id)) if ((!matchesClaim && !matchesEvidence) || seen.has(gap.id)) return false seen.add(gap.id) return true }) } function optionalRows(label: string, value: string | undefined): Array<[string, string]> { return value ? [[label, value]] : [] } function sourceItems(evidence: NarrativeMapClaim["evidence"][number], display: ValidatedNarrativeDisplayModel): string[] { const items = [evidence.source, evidence.location, evidence.findingsFile, evidence.sourcePath, ...splitSourceUrls(evidence.url)] .map((value) => value?.trim()) .filter((value): value is string => Boolean(value)) const unique = Array.from(new Set(items)) return unique.length ? unique : [display.labels.none] } function splitSourceUrls(value: string | undefined): string[] { if (!value?.trim()) return [] const urls = value.match(/https?:\/\/[^\s,;]+/g) if (urls?.length) return urls return value.split(/\s*[;·]\s*/).filter(Boolean) } function displayGapQuestion(gap: NarrativeMapResearchGap, display: ValidatedNarrativeDisplayModel): string { return display.researchGapCards.get(gap.id)?.displayQuestion ?? gap.question } function isLocalizedDisplay(display: ValidatedNarrativeDisplayModel): boolean { return display.language !== "en" } function displayClaimText(claimId: string, fallback: string | undefined, display: ValidatedNarrativeDisplayModel): string { return display.claimCards.get(claimId)?.displayTitle ?? fallback ?? claimId } function relationDisplayLabel(relation: NarrativeMapClaimRelation, display: ValidatedNarrativeDisplayModel, _includeInferred: boolean): string { if (relation.inferred) return inferredRelationLabel(display) const label = display.relations.get(relationKey(relation))?.displayLabel ?? localizeValue(relation.relation, display) return label } function relationDisplayRationale(relation: NarrativeMapClaimRelation, display: ValidatedNarrativeDisplayModel): string | undefined { const displayRationale = display.relations.get(relationKey(relation))?.displayRationale if (displayRationale) return displayRationale if (relation.inferred) return inferredRationale(display) if (relation.rationale?.trim() && isLocalizedDisplay(display)) return undefined return relation.rationale ?? missingRationale(display) } function inferredRationale(display: ValidatedNarrativeDisplayModel): string { if (isChineseLanguage(display.language)) return "仅表示两个主张在当前叙事顺序中相邻;系统未判断因果、支撑或依赖关系。需要在 claimRelations 中写入客观 rationale 后才算确认。" if (isJapaneseLanguage(display.language)) return "現在のナラティブ順序で隣接していることだけを示します。因果、裏付け、依存関係は判断していません。確認するには claimRelations に客観的な rationale を記録してください。" return "Only indicates that the two claims are adjacent in the current narrative order; the system has not judged causality, support, or dependency. Record objective rationale in claimRelations to confirm it." } function inferredRelationLabel(display: ValidatedNarrativeDisplayModel): string { if (isChineseLanguage(display.language)) return "未确认顺序提示" if (isJapaneseLanguage(display.language)) return "未確認の順序メモ" return "unconfirmed order note" } function missingRationale(display: ValidatedNarrativeDisplayModel): string { if (isChineseLanguage(display.language)) return "因果依据未记录。" if (isJapaneseLanguage(display.language)) return "因果関係の根拠は記録されていません。" return "Causal rationale is not recorded." } function detailCards(rows: Array<[string, string] | [string, string, boolean]>): string { return rows.map(([label, value, raw]) => `

${escapeHtml(label)}

${raw ? value : allowBreaks(value)}

`).join("") } function emptyCard(label: string, value: string): string { return `

${escapeHtml(label)}

${escapeHtml(value)}

` } function allClaims(map: NarrativeMap): NarrativeMapClaim[] { return map.claimFlow.length > 0 ? map.claimFlow : map.claims.supported.concat(map.claims.partial, map.claims.weak, map.claims.missing, map.claims.not_required) } function sectionLabels(display: ValidatedNarrativeDisplayModel): Record { if (isChineseLanguage(display.language)) return { role: "角色", narrativeJob: "叙事任务", evidenceSummary: "证据摘要", riskOrGapSummary: "风险/缺口" } if (isJapaneseLanguage(display.language)) return { role: "役割", narrativeJob: "ナラティブ上の役割", evidenceSummary: "根拠の要約", riskOrGapSummary: "リスク/ギャップ" } return { role: "Role", narrativeJob: "Narrative job", evidenceSummary: "Evidence summary", riskOrGapSummary: "Risk / gap" } } function systemTerm(term: string, display: ValidatedNarrativeDisplayModel): string { const zh: Record = { approval: "审批", claims: "主张", relations: "关系", inferred: "未确认", relation: "关系", from: "来自", to: "指向", rationale: "说明", supportRationale: "支撑逻辑", strength: "强度", priority: "优先级", findingsFile: "研究文件", sourcePath: "来源文件", url: "链接", location: "位置", quote: "引用", caveat: "注意事项", target: "目标", evidenceBindingIds: "论据 ID", notes: "备注", artifacts: "产物", attention: "需关注", none: display.labels.none } const ja: Record = { approval: "承認", claims: "クレーム", relations: "関係", inferred: "未確認", relation: "関係", from: "起点", to: "終点", rationale: "理由", supportRationale: "裏付けの論理", strength: "強度", priority: "優先度", findingsFile: "調査ファイル", sourcePath: "出典ファイル", url: "URL", location: "場所", quote: "引用", caveat: "留意点", target: "対象", evidenceBindingIds: "根拠ID", notes: "メモ", artifacts: "成果物", attention: "要確認", none: display.labels.none } const en: Record = { approval: "approval", claims: "claims", relations: "relations", inferred: "unconfirmed", relation: "relation", from: "from", to: "to", rationale: "rationale", supportRationale: "why this supports the claim", strength: "strength", priority: "priority", findingsFile: "findings file", sourcePath: "source path", url: "URL", location: "location", quote: "quote", caveat: "caveat", target: "target", evidenceBindingIds: "evidence binding IDs", notes: "notes", artifacts: "artifacts", attention: "need attention", none: display.labels.none } return (isChineseLanguage(display.language) ? zh : isJapaneseLanguage(display.language) ? ja : en)[term] ?? term } function localizeValue(value: string, display: ValidatedNarrativeDisplayModel): string { if (value === "no_target") return isChineseLanguage(display.language) ? "无 render target" : isJapaneseLanguage(display.language) ? "render target なし" : "no target" const zh: Record = { current: "当前", stale: "已过期", missing: "缺失", approved: "已批准", ready_for_approval: "待批准", needs_research: "需要研究", needs_user_confirmation: "需要用户确认", blocked: "受阻", draft: "草稿", supported: "已支持", partial: "部分支持", weak: "弱支持", not_required: "无需证据", central: "核心", supporting: "支撑", background: "背景", context: "背景", problem: "问题", opportunity: "机会", evidence: "证据", recommendation: "建议", risk: "风险", assumption: "假设", ask: "请求", leads_to: "推进到", supports: "支持", depends_on: "依赖", contrasts_with: "对比", constrains: "约束", answers: "回应", strong: "强", medium: "中", low: "低", } const ja: Record = { current: "現行", stale: "古い", missing: "不足", approved: "承認済み", ready_for_approval: "承認待ち", needs_research: "調査が必要", needs_user_confirmation: "ユーザー確認が必要", blocked: "ブロック", draft: "下書き", supported: "裏付けあり", partial: "一部裏付け", weak: "弱い裏付け", not_required: "根拠不要", central: "中心", supporting: "補助", background: "背景", context: "文脈", problem: "課題", opportunity: "機会", evidence: "根拠", recommendation: "提案", risk: "リスク", assumption: "仮定", ask: "依頼", leads_to: "つながる", supports: "支える", depends_on: "依存", contrasts_with: "対比", constrains: "制約", answers: "答える", strong: "強", medium: "中", low: "低", } const table = isChineseLanguage(display.language) ? zh : isJapaneseLanguage(display.language) ? ja : {} return table[value] ?? value } function nodeId(id: string): string { return `claim-${id}`.replace(/[^a-zA-Z0-9._-]+/g, "-") } function itemDomId(prefix: string, id: string): string { return `${prefix}-${id}`.replace(/[^a-zA-Z0-9._-]+/g, "-") } function shorten(value: string | undefined, max: number): string { const text = valueOrFallback(value, "-") return text.length > max ? `${text.slice(0, max - 1)}...` : text } function valueOrFallback(value: string | undefined, fallback: string): string { return value?.trim() || fallback } function allowBreaks(value: string): string { return escapeHtml(value).replace(/<br>/g, "
") } function escapeHtml(value: string | undefined): string { return (value ?? "").replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", "\"": """ }[ch] ?? ch)) } function escapeAttr(value: string | undefined): string { return escapeHtml(value).replace(/[^a-zA-Z0-9_-]/g, "_") } function escapeJs(value: string | undefined): string { return escapeHtml(value).replace(/[\\'`$]/g, (ch) => ({ "\\": "\\\\", "'": "\\'", "`": "\\`", "$": "\\$" }[ch] ?? ch)) }