@cyber-dash-tech/revela 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +35 -29
  2. package/README.zh-CN.md +35 -29
  3. package/lib/commands/brief.ts +63 -0
  4. package/lib/commands/designs.ts +2 -2
  5. package/lib/commands/domains.ts +2 -2
  6. package/lib/commands/enable.ts +19 -19
  7. package/lib/commands/help.ts +7 -3
  8. package/lib/commands/init.ts +30 -19
  9. package/lib/commands/narrative.ts +160 -0
  10. package/lib/commands/review.ts +115 -1
  11. package/lib/decks-state.ts +46 -3
  12. package/lib/edit/prompt.ts +3 -0
  13. package/lib/inspection-context/compile.ts +159 -5
  14. package/lib/inspection-context/project.ts +20 -0
  15. package/lib/narrative-state/coverage.ts +100 -0
  16. package/lib/narrative-state/display.ts +219 -0
  17. package/lib/narrative-state/executive-brief.ts +246 -0
  18. package/lib/narrative-state/hash.ts +61 -0
  19. package/lib/narrative-state/map-html.ts +348 -0
  20. package/lib/narrative-state/map.ts +282 -0
  21. package/lib/narrative-state/normalize.ts +361 -0
  22. package/lib/narrative-state/project-compat.ts +14 -0
  23. package/lib/narrative-state/queries.ts +433 -0
  24. package/lib/narrative-state/readiness.ts +359 -0
  25. package/lib/narrative-state/render-plan.ts +250 -0
  26. package/lib/narrative-state/research-gaps.ts +191 -0
  27. package/lib/narrative-state/types.ts +172 -0
  28. package/lib/prompt-builder.ts +59 -26
  29. package/lib/workspace-state/evidence-status.ts +21 -1
  30. package/lib/workspace-state/graph.ts +174 -2
  31. package/lib/workspace-state/types.ts +13 -1
  32. package/package.json +1 -1
  33. package/plugin.ts +58 -2
  34. package/skill/NARRATIVE_SKILL.md +64 -0
  35. package/tools/decks.ts +265 -2
  36. package/tools/narrative-view.ts +84 -0
  37. package/tools/workspace-scan.ts +14 -1
@@ -1,6 +1,7 @@
1
1
  import type { DeckSpec, DecksState, EvidenceRef, NarrativeBrief, NarrativeRole, SlideSpec, SourceMaterial } from "../decks-state"
2
+ import type { NarrativeClaim, NarrativeEvidenceBinding, NarrativeStateV1 } from "../narrative-state/types"
2
3
 
3
- export type InspectionClaimOrigin = "title" | "headline" | "body" | "bullet" | "purpose"
4
+ export type InspectionClaimOrigin = "narrative" | "title" | "headline" | "body" | "bullet" | "purpose"
4
5
  export type InspectionGapType = "missing_evidence" | "weak_evidence"
5
6
  export type InspectionEvidenceSupport = "supported" | "weak" | "unknown"
6
7
 
@@ -13,6 +14,7 @@ export interface InspectionContext {
13
14
  outputPath: string
14
15
  narrativeBrief?: NarrativeBrief
15
16
  sourceMaterials: InspectionSourceMaterial[]
17
+ narrative?: InspectionNarrativeStateContext
16
18
  slides: InspectionSlideContext[]
17
19
  gaps: InspectionGap[]
18
20
  appendixCandidates: InspectionAppendixCandidate[]
@@ -20,6 +22,12 @@ export interface InspectionContext {
20
22
  riskContext: InspectionNarrativeContext[]
21
23
  }
22
24
 
25
+ export interface InspectionNarrativeStateContext {
26
+ id: string
27
+ status: string
28
+ claimCount: number
29
+ }
30
+
23
31
  export interface InspectionSourceMaterial extends SourceMaterial {
24
32
  linkedEvidenceCount: number
25
33
  }
@@ -46,6 +54,7 @@ export interface InspectionSlideText {
46
54
 
47
55
  export interface InspectionClaimCandidate {
48
56
  id: string
57
+ canonicalClaimId?: string
49
58
  slideIndex: number
50
59
  slideTitle: string
51
60
  origin: InspectionClaimOrigin
@@ -54,9 +63,18 @@ export interface InspectionClaimCandidate {
54
63
  evidenceSupport: InspectionEvidenceSupport
55
64
  evidence: InspectionEvidenceTrace[]
56
65
  gaps: InspectionGap[]
66
+ evidenceBindingIds: string[]
67
+ supportedScope?: string
68
+ unsupportedScope?: string
69
+ caveats: string[]
57
70
  }
58
71
 
59
72
  export interface InspectionEvidenceTrace extends EvidenceRef {
73
+ evidenceBindingId?: string
74
+ claimId?: string
75
+ supportScope?: string
76
+ unsupportedScope?: string
77
+ strength?: NarrativeEvidenceBinding["strength"]
60
78
  slideIndex: number
61
79
  slideTitle: string
62
80
  hasDetail: boolean
@@ -87,12 +105,13 @@ export interface InspectionNarrativeContext {
87
105
 
88
106
  export function compileInspectionContext(state: DecksState, slug?: string): InspectionContext {
89
107
  const deck = activeDeck(state, slug)
108
+ const narrative = state.narrative
90
109
  const evidence = collectEvidence(deck)
91
110
  const sourceMaterials = compileSourceMaterials(state.workspace.sourceMaterials ?? [], evidence)
92
111
  const slides = deck.slides
93
112
  .slice()
94
113
  .sort((a, b) => a.index - b.index)
95
- .map((slide) => compileSlide(slide))
114
+ .map((slide) => compileSlide(slide, narrative))
96
115
  const gaps = slides.flatMap((slide) => slide.claims.flatMap((claim) => claim.gaps))
97
116
 
98
117
  return {
@@ -103,6 +122,7 @@ export function compileInspectionContext(state: DecksState, slug?: string): Insp
103
122
  language: deck.language,
104
123
  outputPath: deck.outputPath,
105
124
  narrativeBrief: deck.narrativeBrief,
125
+ narrative: narrative ? { id: narrative.id, status: narrative.status, claimCount: narrative.claims.length } : undefined,
106
126
  sourceMaterials,
107
127
  slides,
108
128
  gaps,
@@ -118,9 +138,15 @@ function activeDeck(state: DecksState, slug?: string): DeckSpec {
118
138
  return state.decks[key]
119
139
  }
120
140
 
121
- function compileSlide(slide: SlideSpec): InspectionSlideContext {
141
+ function compileSlide(slide: SlideSpec, narrative: NarrativeStateV1 | undefined): InspectionSlideContext {
122
142
  const evidence = slide.evidence.map((item) => compileEvidence(slide, item))
123
- const claims = claimCandidates(slide).map((claim, position) => compileClaim(slide, claim, position, evidence))
143
+ const canonicalClaims = narrative ? canonicalClaimCandidates(slide, narrative, evidence) : []
144
+ const canonicalText = new Set(canonicalClaims.map((claim) => normalizeText(claim.text)))
145
+ const heuristicClaims = claimCandidates(slide)
146
+ .filter((claim) => !canonicalText.has(normalizeText(claim.text)))
147
+ .map((claim, position) => compileClaim(slide, claim, position, evidence))
148
+ const claims = [...canonicalClaims, ...heuristicClaims]
149
+ const claimCaveats = canonicalClaims.flatMap((claim) => claim.caveats)
124
150
  return {
125
151
  index: slide.index,
126
152
  title: slide.title,
@@ -136,7 +162,61 @@ function compileSlide(slide: SlideSpec): InspectionSlideContext {
136
162
  },
137
163
  claims,
138
164
  evidence,
139
- caveats: evidence.map((item) => item.caveat).filter((item): item is string => Boolean(item?.trim())),
165
+ caveats: dedupeText([
166
+ ...evidence.map((item) => item.caveat).filter((item): item is string => Boolean(item?.trim())),
167
+ ...claimCaveats,
168
+ ]),
169
+ }
170
+ }
171
+
172
+ function canonicalClaimCandidates(slide: SlideSpec, narrative: NarrativeStateV1, slideEvidence: InspectionEvidenceTrace[]): InspectionClaimCandidate[] {
173
+ const claimRefs = slide.claimRefs ?? []
174
+ const metadataClaimIds = new Set([
175
+ ...claimRefs.map((ref) => ref.claimId),
176
+ ...(slide.claimIds ?? []),
177
+ ].filter(Boolean))
178
+ const evidenceBindingIds = new Set(slide.evidenceBindingIds ?? [])
179
+ for (const binding of narrative.evidenceBindings) {
180
+ if (evidenceBindingIds.has(binding.id)) metadataClaimIds.add(binding.claimId)
181
+ }
182
+
183
+ return narrative.claims
184
+ .filter((claim) => metadataClaimIds.has(claim.id))
185
+ .map((claim) => compileCanonicalClaim(slide, claim, narrative.evidenceBindings, slideEvidence, evidenceBindingIds))
186
+ }
187
+
188
+ function compileCanonicalClaim(
189
+ slide: SlideSpec,
190
+ claim: NarrativeClaim,
191
+ bindings: NarrativeEvidenceBinding[],
192
+ slideEvidence: InspectionEvidenceTrace[],
193
+ slideEvidenceBindingIds: Set<string>,
194
+ ): InspectionClaimCandidate {
195
+ const allClaimBindings = bindings.filter((binding) => binding.claimId === claim.id)
196
+ const selectedBindings = allClaimBindings.filter((binding) => slideEvidenceBindingIds.size === 0 || slideEvidenceBindingIds.has(binding.id))
197
+ const evidenceBindings = selectedBindings.length > 0 ? selectedBindings : allClaimBindings
198
+ const evidence = evidenceBindings.length > 0
199
+ ? evidenceBindings.map((binding) => compileEvidenceBinding(slide, binding))
200
+ : slideEvidence
201
+ const gaps = canonicalClaimGaps(slide, claim, evidence)
202
+ return {
203
+ id: claim.id,
204
+ canonicalClaimId: claim.id,
205
+ slideIndex: slide.index,
206
+ slideTitle: slide.title,
207
+ origin: "narrative",
208
+ text: claim.text,
209
+ evidenceSensitive: claim.evidenceRequired || isEvidenceSensitiveClaim(claim.text),
210
+ evidenceSupport: narrativeEvidenceSupport(claim, evidence),
211
+ evidence,
212
+ gaps,
213
+ evidenceBindingIds: evidenceBindings.map((binding) => binding.id),
214
+ supportedScope: claim.supportedScope,
215
+ unsupportedScope: claim.unsupportedScope,
216
+ caveats: dedupeText([
217
+ ...(claim.caveats ?? []),
218
+ ...evidenceBindings.map((binding) => binding.caveat).filter((item): item is string => Boolean(item?.trim())),
219
+ ]),
140
220
  }
141
221
  }
142
222
 
@@ -159,7 +239,34 @@ function compileClaim(
159
239
  evidenceSupport: evidenceSupport(evidence),
160
240
  evidence,
161
241
  gaps,
242
+ evidenceBindingIds: [],
243
+ caveats: [],
244
+ }
245
+ }
246
+
247
+ function canonicalClaimGaps(slide: SlideSpec, claim: NarrativeClaim, evidence: InspectionEvidenceTrace[]): InspectionGap[] {
248
+ if (!claim.evidenceRequired) return []
249
+ if (claim.evidenceStatus === "missing" || evidence.length === 0) {
250
+ return [{
251
+ type: "missing_evidence",
252
+ slideIndex: slide.index,
253
+ slideTitle: slide.title,
254
+ claimId: claim.id,
255
+ claimText: claim.text,
256
+ message: "Canonical narrative claim requires evidence but has no bound evidence trace.",
257
+ }]
162
258
  }
259
+ if (claim.evidenceStatus === "weak" || evidence.some((item) => !item.hasDetail)) {
260
+ return [{
261
+ type: "weak_evidence",
262
+ slideIndex: slide.index,
263
+ slideTitle: slide.title,
264
+ claimId: claim.id,
265
+ claimText: claim.text,
266
+ message: "Canonical narrative claim has weak or source-only evidence trace.",
267
+ }]
268
+ }
269
+ return []
163
270
  }
164
271
 
165
272
  function claimGaps(slide: SlideSpec, claimId: string, claimText: string, evidence: InspectionEvidenceTrace[]): InspectionGap[] {
@@ -192,6 +299,12 @@ function evidenceSupport(evidence: InspectionEvidenceTrace[]): InspectionEvidenc
192
299
  return "supported"
193
300
  }
194
301
 
302
+ function narrativeEvidenceSupport(claim: NarrativeClaim, evidence: InspectionEvidenceTrace[]): InspectionEvidenceSupport {
303
+ if (claim.evidenceStatus === "supported" || claim.evidenceStatus === "not_required") return "supported"
304
+ if (claim.evidenceStatus === "partial" || claim.evidenceStatus === "weak") return "weak"
305
+ return evidenceSupport(evidence)
306
+ }
307
+
195
308
  function claimCandidates(slide: SlideSpec): Array<{ origin: InspectionClaimOrigin; text: string }> {
196
309
  const claims: Array<{ origin: InspectionClaimOrigin; text: string }> = []
197
310
  pushClaim(claims, "title", slide.title)
@@ -218,6 +331,29 @@ function compileEvidence(slide: SlideSpec, evidence: EvidenceRef): InspectionEvi
218
331
  }
219
332
  }
220
333
 
334
+ function compileEvidenceBinding(slide: SlideSpec, binding: NarrativeEvidenceBinding): InspectionEvidenceTrace {
335
+ const evidence: EvidenceRef = {
336
+ source: binding.source,
337
+ sourcePath: binding.sourcePath,
338
+ findingsFile: binding.findingsFile,
339
+ quote: binding.quote,
340
+ location: binding.location,
341
+ url: binding.url,
342
+ caveat: binding.caveat,
343
+ }
344
+ return {
345
+ ...evidence,
346
+ evidenceBindingId: binding.id,
347
+ claimId: binding.claimId,
348
+ supportScope: binding.supportScope,
349
+ unsupportedScope: binding.unsupportedScope,
350
+ strength: binding.strength,
351
+ slideIndex: slide.index,
352
+ slideTitle: slide.title,
353
+ hasDetail: hasEvidenceDetail(evidence),
354
+ }
355
+ }
356
+
221
357
  function collectEvidence(deck: DeckSpec): InspectionEvidenceTrace[] {
222
358
  return deck.slides.flatMap((slide) => slide.evidence.map((item) => compileEvidence(slide, item)))
223
359
  }
@@ -303,6 +439,24 @@ function cleanOptionalText(value: string | undefined): string | undefined {
303
439
  return text || undefined
304
440
  }
305
441
 
442
+ function dedupeText(values: string[]): string[] {
443
+ const seen = new Set<string>()
444
+ const result: string[] = []
445
+ for (const value of values) {
446
+ const cleaned = cleanOptionalText(value)
447
+ if (!cleaned) continue
448
+ const key = normalizeText(cleaned)
449
+ if (seen.has(key)) continue
450
+ seen.add(key)
451
+ result.push(cleaned)
452
+ }
453
+ return result
454
+ }
455
+
456
+ function normalizeText(value: string | undefined): string {
457
+ return value?.replace(/\s+/g, " ").trim().toLowerCase() ?? ""
458
+ }
459
+
306
460
  const EVIDENCE_SENSITIVE_TERMS = [
307
461
  /\bmarket size\b/,
308
462
  /\bcagr\b/,
@@ -50,10 +50,15 @@ export interface InspectionProjectionMatch {
50
50
  }
51
51
  claim?: {
52
52
  id: string
53
+ canonicalClaimId?: string
53
54
  origin: string
54
55
  text: string
55
56
  evidenceSensitive: boolean
56
57
  evidenceSupport: string
58
+ evidenceBindingIds: string[]
59
+ supportedScope?: string
60
+ unsupportedScope?: string
61
+ caveats: string[]
57
62
  }
58
63
  }
59
64
 
@@ -97,6 +102,8 @@ export interface InspectionAppendixProjection {
97
102
 
98
103
  export interface InspectionEvidenceProjectionTrace {
99
104
  source: string
105
+ evidenceBindingId?: string
106
+ claimId?: string
100
107
  sourcePath?: string
101
108
  findingsFile?: string
102
109
  location?: string
@@ -104,6 +111,9 @@ export interface InspectionEvidenceProjectionTrace {
104
111
  url?: string
105
112
  quote?: string
106
113
  caveat?: string
114
+ supportScope?: string
115
+ unsupportedScope?: string
116
+ strength?: string
107
117
  extractedTextPath?: string
108
118
  extractedManifestPath?: string
109
119
  hasDetail: boolean
@@ -163,10 +173,15 @@ export function projectInspectionMatch(
163
173
  claim: claim
164
174
  ? {
165
175
  id: claim.id,
176
+ canonicalClaimId: claim.canonicalClaimId,
166
177
  origin: claim.origin,
167
178
  text: truncate(claim.text, 500),
168
179
  evidenceSensitive: claim.evidenceSensitive,
169
180
  evidenceSupport: claim.evidenceSupport,
181
+ evidenceBindingIds: claim.evidenceBindingIds,
182
+ supportedScope: truncateOptional(claim.supportedScope, 280),
183
+ unsupportedScope: truncateOptional(claim.unsupportedScope, 280),
184
+ caveats: claim.caveats.map((item) => truncate(item, 280)).slice(0, 8),
170
185
  }
171
186
  : undefined,
172
187
  },
@@ -211,6 +226,8 @@ export function projectInspectionMatch(
211
226
  function projectEvidenceTrace(trace: InspectionEvidenceTrace): InspectionEvidenceProjectionTrace {
212
227
  return {
213
228
  source: truncate(trace.source, 180),
229
+ evidenceBindingId: truncateOptional(trace.evidenceBindingId, 160),
230
+ claimId: truncateOptional(trace.claimId, 160),
214
231
  sourcePath: truncateOptional(trace.sourcePath, 220),
215
232
  findingsFile: truncateOptional(trace.findingsFile, 220),
216
233
  location: truncateOptional(trace.location, 120),
@@ -218,6 +235,9 @@ function projectEvidenceTrace(trace: InspectionEvidenceTrace): InspectionEvidenc
218
235
  url: truncateOptional(trace.url, 240),
219
236
  quote: truncateOptional(trace.quote, 500),
220
237
  caveat: truncateOptional(trace.caveat, 280),
238
+ supportScope: truncateOptional(trace.supportScope, 280),
239
+ unsupportedScope: truncateOptional(trace.unsupportedScope, 280),
240
+ strength: trace.strength,
221
241
  extractedTextPath: truncateOptional(trace.extractedTextPath, 220),
222
242
  extractedManifestPath: truncateOptional(trace.extractedManifestPath, 220),
223
243
  hasDetail: trace.hasDetail,
@@ -0,0 +1,100 @@
1
+ import { type DecksState, type SlideClaimRef, type SlideClaimRefRole } from "../decks-state"
2
+ import { recordWorkspaceAction } from "../workspace-state/actions"
3
+ import { ensureActiveHtmlDeckRenderTarget } from "../workspace-state/render-targets"
4
+ import { computeNarrativeHash } from "./hash"
5
+ import { normalizeNarrativeState } from "./normalize"
6
+ import { getClaimSlideRefs, type ClaimSlideRef } from "./queries"
7
+
8
+ export interface BackfillSlideClaimRefsResult {
9
+ updated: boolean
10
+ addedCount: number
11
+ slideCount: number
12
+ narrativeHash: string
13
+ refs: ClaimSlideRef[]
14
+ }
15
+
16
+ export function backfillSlideClaimRefsFromCoverage(state: DecksState): { state: DecksState; result: BackfillSlideClaimRefsResult } {
17
+ const narrative = normalizeNarrativeState(state)
18
+ const narrativeHash = computeNarrativeHash(narrative)
19
+ const deckKey = state.activeDeck || Object.keys(state.decks)[0]
20
+ const deck = deckKey ? state.decks[deckKey] : undefined
21
+ if (!deck) {
22
+ return { state: { ...state, narrative }, result: { updated: false, addedCount: 0, slideCount: 0, narrativeHash, refs: [] } }
23
+ }
24
+
25
+ const refs = getClaimSlideRefs({ ...state, narrative }, deck)
26
+ const refsBySlide = new Map<number, ClaimSlideRef[]>()
27
+ for (const ref of refs) refsBySlide.set(ref.slideIndex, [...(refsBySlide.get(ref.slideIndex) ?? []), ref])
28
+
29
+ let addedCount = 0
30
+ const slides = deck.slides.map((slide) => {
31
+ const existing = [...(slide.claimRefs ?? [])]
32
+ const seen = new Set(existing.map((ref) => `${ref.claimId}:${ref.role}`))
33
+ const additions: SlideClaimRef[] = []
34
+ for (const ref of refsBySlide.get(slide.index) ?? []) {
35
+ const role = backfilledRole(ref.role)
36
+ const key = `${ref.claimId}:${role}`
37
+ if (seen.has(key)) continue
38
+ seen.add(key)
39
+ additions.push({ claimId: ref.claimId, role, note: backfillNote(ref) })
40
+ }
41
+ if (additions.length === 0) return slide
42
+ addedCount += additions.length
43
+ return { ...slide, claimRefs: [...existing, ...additions] }
44
+ })
45
+
46
+ const next: DecksState = {
47
+ ...state,
48
+ narrative,
49
+ decks: {
50
+ ...state.decks,
51
+ [deckKey]: {
52
+ ...deck,
53
+ slides,
54
+ },
55
+ },
56
+ }
57
+
58
+ const updatedRefs = getClaimSlideRefs(next, next.decks[deckKey])
59
+ const htmlTarget = ensureActiveHtmlDeckRenderTarget(next)
60
+ if (htmlTarget) {
61
+ htmlTarget.data = {
62
+ ...(htmlTarget.data ?? {}),
63
+ narrativeId: narrative.id,
64
+ narrativeHash,
65
+ claimSlideRefs: updatedRefs.map((ref) => ({
66
+ claimId: ref.claimId,
67
+ claimText: ref.claimText,
68
+ slideIndex: ref.slideIndex,
69
+ slideTitle: ref.slideTitle,
70
+ match: ref.match,
71
+ role: ref.role,
72
+ location: ref.location,
73
+ })),
74
+ }
75
+ }
76
+
77
+ if (addedCount > 0) {
78
+ recordWorkspaceAction(next, {
79
+ type: "artifact.coverage_backfilled",
80
+ actor: "revela-decks",
81
+ inputs: { activeDeck: deckKey, narrativeId: narrative.id },
82
+ outputs: { addedCount, slideCount: slides.length, narrativeHash },
83
+ status: "success",
84
+ summary: `Backfilled ${addedCount} slide claim reference${addedCount === 1 ? "" : "s"} from current artifact coverage.`,
85
+ nodeIds: [narrative.id, `artifact:${deck.outputPath ?? deckKey}`],
86
+ })
87
+ }
88
+
89
+ return { state: next, result: { updated: addedCount > 0, addedCount, slideCount: slides.length, narrativeHash, refs: updatedRefs } }
90
+ }
91
+
92
+ function backfilledRole(role: SlideClaimRefRole): SlideClaimRefRole {
93
+ return role
94
+ }
95
+
96
+ function backfillNote(ref: ClaimSlideRef): string {
97
+ if (ref.match === "metadata") return `Backfilled from ${ref.location}.`
98
+ if (ref.match === "content") return `Backfilled from content match at ${ref.location}.`
99
+ return "Backfilled from slide evidence trace."
100
+ }
@@ -0,0 +1,219 @@
1
+ import type { NarrativeMap, NarrativeMapClaimRelation } from "./map"
2
+
3
+ export type NarrativeViewLanguage = string
4
+
5
+ export interface NarrativeDisplayModel {
6
+ version: 1
7
+ language: NarrativeViewLanguage
8
+ pageTitle?: string
9
+ summaryLine?: string
10
+ labels?: Partial<NarrativeDisplayLabels>
11
+ claimCards?: NarrativeDisplayClaimCard[]
12
+ relations?: NarrativeDisplayRelation[]
13
+ }
14
+
15
+ export interface NarrativeDisplayLabels {
16
+ eyebrow: string
17
+ claimFlow: string
18
+ flowNote: string
19
+ selectedClaim: string
20
+ claim: string
21
+ claimId: string
22
+ status: string
23
+ supportedScope: string
24
+ unsupportedScope: string
25
+ incomingRelations: string
26
+ outgoingRelations: string
27
+ evidence: string
28
+ objections: string
29
+ risks: string
30
+ researchGaps: string
31
+ coveredSlides: string
32
+ noClaims: string
33
+ none: string
34
+ }
35
+
36
+ export interface NarrativeDisplayClaimCard {
37
+ claimId: string
38
+ displayTitle?: string
39
+ roleLabel?: string
40
+ narrativeJob?: string
41
+ evidenceSummary?: string
42
+ riskOrGapSummary?: string
43
+ }
44
+
45
+ export interface NarrativeDisplayRelation {
46
+ fromClaimId: string
47
+ toClaimId: string
48
+ relation: NarrativeMapClaimRelation["relation"]
49
+ displayLabel?: string
50
+ displayRationale?: string
51
+ }
52
+
53
+ export interface ValidatedNarrativeDisplayModel {
54
+ version: 1
55
+ language: NarrativeViewLanguage
56
+ pageTitle?: string
57
+ summaryLine?: string
58
+ labels: NarrativeDisplayLabels
59
+ claimCards: Map<string, NarrativeDisplayClaimCard>
60
+ relations: Map<string, NarrativeDisplayRelation>
61
+ }
62
+
63
+ export function defaultNarrativeDisplayLabels(language: NarrativeViewLanguage): NarrativeDisplayLabels {
64
+ if (isChineseLanguage(language)) {
65
+ return {
66
+ eyebrow: "只读主张流",
67
+ claimFlow: "主张推进",
68
+ flowNote: "点击主张查看证据、关系、风险、缺口和已覆盖页面。",
69
+ selectedClaim: "当前主张",
70
+ claim: "主张",
71
+ claimId: "主张 ID",
72
+ status: "状态",
73
+ supportedScope: "已支持范围",
74
+ unsupportedScope: "未支持范围",
75
+ incomingRelations: "前置关系",
76
+ outgoingRelations: "后续关系",
77
+ evidence: "证据",
78
+ objections: "反对意见",
79
+ risks: "风险",
80
+ researchGaps: "研究缺口",
81
+ coveredSlides: "已覆盖页面",
82
+ noClaims: "没有记录主张",
83
+ none: "无",
84
+ }
85
+ }
86
+ if (isJapaneseLanguage(language)) {
87
+ return {
88
+ eyebrow: "読み取り専用クレームフロー",
89
+ claimFlow: "クレームフロー",
90
+ flowNote: "クレームをクリックすると、根拠、関係、リスク、ギャップ、該当スライドを確認できます。",
91
+ selectedClaim: "選択中のクレーム",
92
+ claim: "クレーム",
93
+ claimId: "クレーム ID",
94
+ status: "ステータス",
95
+ supportedScope: "裏付けられた範囲",
96
+ unsupportedScope: "未裏付けの範囲",
97
+ incomingRelations: "入力関係",
98
+ outgoingRelations: "出力関係",
99
+ evidence: "根拠",
100
+ objections: "反論",
101
+ risks: "リスク",
102
+ researchGaps: "調査ギャップ",
103
+ coveredSlides: "対応スライド",
104
+ noClaims: "クレームは記録されていません",
105
+ none: "なし",
106
+ }
107
+ }
108
+ return {
109
+ eyebrow: "Read-only claim flow board",
110
+ claimFlow: "Claim Flow",
111
+ flowNote: "Click a claim to inspect support, relation context, gaps, and covered slides.",
112
+ selectedClaim: "Selected claim",
113
+ claim: "Claim",
114
+ claimId: "Claim ID",
115
+ status: "Status",
116
+ supportedScope: "Supported scope",
117
+ unsupportedScope: "Unsupported scope",
118
+ incomingRelations: "Incoming relations",
119
+ outgoingRelations: "Outgoing relations",
120
+ evidence: "Evidence",
121
+ objections: "Objections",
122
+ risks: "Risks",
123
+ researchGaps: "Research gaps",
124
+ coveredSlides: "Covered slides",
125
+ noClaims: "No claims recorded",
126
+ none: "None",
127
+ }
128
+ }
129
+
130
+ export function validateNarrativeDisplayModel(map: NarrativeMap, input: NarrativeDisplayModel | undefined, language: NarrativeViewLanguage): ValidatedNarrativeDisplayModel {
131
+ const defaults = defaultNarrativeDisplayLabels(language)
132
+ if (!input) return emptyDisplayModel(language, defaults)
133
+ if (input.version !== 1) throw new Error("Narrative display model version must be 1.")
134
+ if (input.language !== language) throw new Error(`Narrative display model language must be ${language}.`)
135
+
136
+ const claimIds = new Set(map.claimFlow.map((claim) => claim.id))
137
+ const relationByKey = new Map(map.claimRelations.map((relation) => [relationKey(relation), relation]))
138
+ const claimCards = new Map<string, NarrativeDisplayClaimCard>()
139
+ for (const card of input.claimCards ?? []) {
140
+ if (!claimIds.has(card.claimId)) throw new Error(`Unknown display claimId: ${card.claimId}`)
141
+ claimCards.set(card.claimId, cleanClaimCard(card))
142
+ }
143
+
144
+ const relations = new Map<string, NarrativeDisplayRelation>()
145
+ for (const relation of input.relations ?? []) {
146
+ const key = relationKey(relation)
147
+ const canonical = relationByKey.get(key)
148
+ if (!canonical) throw new Error(`Display relation is not present in the narrative map: ${key}`)
149
+ relations.set(key, cleanRelation(relation, canonical))
150
+ }
151
+
152
+ return {
153
+ version: 1,
154
+ language,
155
+ pageTitle: clean(input.pageTitle),
156
+ summaryLine: clean(input.summaryLine),
157
+ labels: mergeLabels(defaults, input.labels),
158
+ claimCards,
159
+ relations,
160
+ }
161
+ }
162
+
163
+ export function emptyDisplayModel(language: NarrativeViewLanguage, labels = defaultNarrativeDisplayLabels(language)): ValidatedNarrativeDisplayModel {
164
+ return { version: 1, language, labels, claimCards: new Map(), relations: new Map() }
165
+ }
166
+
167
+ export function relationKey(relation: Pick<NarrativeDisplayRelation, "fromClaimId" | "toClaimId" | "relation">): string {
168
+ return `${relation.fromClaimId}\u0000${relation.toClaimId}\u0000${relation.relation}`
169
+ }
170
+
171
+ export function isChineseLanguage(language: string): boolean {
172
+ const normalized = language.trim().toLowerCase()
173
+ return normalized === "zh" || normalized === "zh-cn" || normalized === "cn" || normalized === "chinese" || normalized.includes("中文") || normalized.includes("简体")
174
+ }
175
+
176
+ export function isJapaneseLanguage(language: string): boolean {
177
+ const normalized = language.trim().toLowerCase()
178
+ return normalized === "ja" || normalized === "ja-jp" || normalized === "jp" || normalized === "japanese" || normalized.includes("日本")
179
+ }
180
+
181
+ function mergeLabels(defaults: NarrativeDisplayLabels, overrides: Partial<NarrativeDisplayLabels> | undefined): NarrativeDisplayLabels {
182
+ const merged: NarrativeDisplayLabels = { ...defaults }
183
+ if (!overrides) return merged
184
+ for (const key of Object.keys(defaults) as Array<keyof NarrativeDisplayLabels>) {
185
+ merged[key] = clean(overrides[key]) ?? defaults[key]
186
+ }
187
+ return merged
188
+ }
189
+
190
+ function cleanClaimCard(card: NarrativeDisplayClaimCard): NarrativeDisplayClaimCard {
191
+ return {
192
+ claimId: card.claimId,
193
+ displayTitle: clean(card.displayTitle),
194
+ roleLabel: clean(card.roleLabel),
195
+ narrativeJob: clean(card.narrativeJob),
196
+ evidenceSummary: clean(card.evidenceSummary),
197
+ riskOrGapSummary: clean(card.riskOrGapSummary),
198
+ }
199
+ }
200
+
201
+ function cleanRelation(relation: NarrativeDisplayRelation, canonical: NarrativeMapClaimRelation): NarrativeDisplayRelation {
202
+ const displayLabel = clean(relation.displayLabel)
203
+ const displayRationale = clean(relation.displayRationale)
204
+ if (displayLabel && canonical.inferred) throw new Error("Display label cannot replace inferred, non-canonical claim relation status.")
205
+ if (displayRationale && canonical.inferred) throw new Error("Display rationale cannot replace inferred, non-canonical claim relation rationale.")
206
+ if (displayRationale && !canonical.rationale?.trim()) throw new Error("Display rationale requires canonical claim relation rationale.")
207
+ return {
208
+ fromClaimId: relation.fromClaimId,
209
+ toClaimId: relation.toClaimId,
210
+ relation: relation.relation,
211
+ displayLabel,
212
+ displayRationale,
213
+ }
214
+ }
215
+
216
+ function clean(value: string | undefined): string | undefined {
217
+ const text = value?.trim()
218
+ return text || undefined
219
+ }