@cyber-dash-tech/revela 0.10.0 → 0.12.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 (44) hide show
  1. package/README.md +54 -28
  2. package/README.zh-CN.md +54 -28
  3. package/lib/commands/designs.ts +2 -2
  4. package/lib/commands/domains.ts +2 -2
  5. package/lib/commands/enable.ts +19 -19
  6. package/lib/commands/help.ts +5 -3
  7. package/lib/commands/init.ts +30 -19
  8. package/lib/commands/inspect.ts +1 -1
  9. package/lib/commands/pdf.ts +33 -5
  10. package/lib/commands/pptx.ts +14 -9
  11. package/lib/commands/refine.ts +1 -1
  12. package/lib/commands/review.ts +115 -1
  13. package/lib/deck-html/contract.ts +252 -0
  14. package/lib/decks-state.ts +111 -28
  15. package/lib/document-materials/extract.ts +20 -0
  16. package/lib/edit/resolve-deck.ts +13 -2
  17. package/lib/inspect/open.ts +3 -1
  18. package/lib/narrative-state/hash.ts +52 -0
  19. package/lib/narrative-state/normalize.ts +307 -0
  20. package/lib/narrative-state/project-compat.ts +14 -0
  21. package/lib/narrative-state/readiness.ts +289 -0
  22. package/lib/narrative-state/render-plan.ts +207 -0
  23. package/lib/narrative-state/types.ts +139 -0
  24. package/lib/prompt-builder.ts +59 -26
  25. package/lib/qa/export-gate.ts +8 -1
  26. package/lib/refine/open.ts +3 -1
  27. package/lib/workspace-state/actions.ts +71 -0
  28. package/lib/workspace-state/compat.ts +10 -0
  29. package/lib/workspace-state/evidence-status.ts +267 -0
  30. package/lib/workspace-state/graph.ts +544 -0
  31. package/lib/workspace-state/render-targets.ts +182 -0
  32. package/lib/workspace-state/rendered-artifacts.ts +43 -0
  33. package/lib/workspace-state/repository.ts +43 -0
  34. package/lib/workspace-state/research-attachments.ts +130 -0
  35. package/lib/workspace-state/review-snapshots.ts +127 -0
  36. package/lib/workspace-state/types.ts +122 -0
  37. package/package.json +1 -1
  38. package/plugin.ts +53 -3
  39. package/skill/NARRATIVE_SKILL.md +64 -0
  40. package/tools/decks.ts +233 -6
  41. package/tools/pdf.ts +9 -1
  42. package/tools/pptx.ts +10 -0
  43. package/tools/research-save.ts +15 -0
  44. package/tools/workspace-scan.ts +29 -1
@@ -0,0 +1,544 @@
1
+ import { createHash } from "crypto"
2
+ import type { DeckSpec, DecksState, EvidenceRef, NarrativeBrief, ResearchAxis, SlideSpec, SourceMaterial } from "../decks-state"
3
+ import type { NarrativeEvidenceBinding, NarrativeStateV1 } from "../narrative-state/types"
4
+ import { renderTargetId } from "./render-targets"
5
+ import type { GraphEdge, GraphEdgeType, GraphNode, GraphNodeType, RenderTarget, WorkspaceGraph } from "./types"
6
+
7
+ export interface ProjectWorkspaceGraphOptions {
8
+ slug?: string
9
+ }
10
+
11
+ interface GraphBuilder {
12
+ nodes: Map<string, GraphNode>
13
+ edges: Map<string, GraphEdge>
14
+ }
15
+
16
+ export function projectWorkspaceGraph(state: DecksState, options: ProjectWorkspaceGraphOptions = {}): WorkspaceGraph {
17
+ const deck = activeDeck(state, options.slug)
18
+ const builder: GraphBuilder = { nodes: new Map(), edges: new Map() }
19
+
20
+ for (const material of state.workspace.sourceMaterials ?? []) addSourceMaterial(builder, material)
21
+ for (const axis of deck.researchPlan ?? []) addResearchFinding(builder, axis)
22
+
23
+ const narrativeId = addNarrative(builder, state, deck)
24
+ for (const slide of deck.slides.slice().sort((a, b) => a.index - b.index)) addSlide(builder, slide)
25
+ for (const slide of deck.slides.slice().sort((a, b) => a.index - b.index)) addSlideClaimsAndEvidence(builder, slide)
26
+ const targets = renderTargetsForDeck(state, deck)
27
+ for (const target of targets) addArtifact(builder, deck, target, narrativeId, targets)
28
+
29
+ return normalizeGraph(builder)
30
+ }
31
+
32
+ function activeDeck(state: DecksState, slug?: string): DeckSpec {
33
+ const key = slug || state.activeDeck || (Object.keys(state.decks).length === 1 ? Object.keys(state.decks)[0] : undefined)
34
+ if (!key || !state.decks[key]) throw new Error("No active deck is available for workspace graph projection.")
35
+ return state.decks[key]
36
+ }
37
+
38
+ function addSourceMaterial(builder: GraphBuilder, material: SourceMaterial): void {
39
+ const sourceId = sourceNodeId(material.path || material.fingerprint || "unknown-source")
40
+ addNode(builder, {
41
+ id: sourceId,
42
+ type: "source",
43
+ label: material.path,
44
+ data: compactData({
45
+ path: material.path,
46
+ type: material.type,
47
+ size: material.size,
48
+ fingerprint: material.fingerprint,
49
+ status: material.status,
50
+ summary: material.summary,
51
+ bestUsedFor: material.bestUsedFor,
52
+ }),
53
+ })
54
+
55
+ const extraction = material.extraction
56
+ if (!extraction) return
57
+ const extractionKey = extraction.manifestPath || extraction.textPath || extraction.cacheDir
58
+ if (!extractionKey) return
59
+
60
+ const extractionId = extractionNodeId(extractionKey)
61
+ addNode(builder, {
62
+ id: extractionId,
63
+ type: "extraction",
64
+ label: extractionKey,
65
+ data: compactData(extraction),
66
+ })
67
+ addEdge(builder, "extracted_as", sourceId, extractionId)
68
+ }
69
+
70
+ function addResearchFinding(builder: GraphBuilder, axis: ResearchAxis): void {
71
+ if (!axis.findingsFile?.trim()) return
72
+ const id = findingNodeId(axis.findingsFile)
73
+ addNode(builder, {
74
+ id,
75
+ type: "finding",
76
+ label: axis.findingsFile,
77
+ data: compactData({
78
+ axis: axis.axis,
79
+ needed: axis.needed,
80
+ status: axis.status,
81
+ findingsFile: axis.findingsFile,
82
+ notes: axis.notes,
83
+ sourceKind: "researchPlan",
84
+ }),
85
+ })
86
+ }
87
+
88
+ function addNarrative(builder: GraphBuilder, state: DecksState, deck: DeckSpec): string | undefined {
89
+ if (hasCanonicalNarrative(state.narrative)) return addCanonicalNarrative(builder, state.narrative, deck)
90
+
91
+ const brief = deck.narrativeBrief
92
+ if (!hasNarrativeBrief(brief)) return undefined
93
+
94
+ const narrativeId = `narrative:${stableHash(deck.slug)}`
95
+ addNode(builder, {
96
+ id: narrativeId,
97
+ type: "narrativeIntent",
98
+ label: deck.goal || deck.slug,
99
+ data: compactData({
100
+ goal: deck.goal,
101
+ audience: deck.audience,
102
+ language: deck.language,
103
+ audienceBeliefBefore: brief?.audienceBeliefBefore,
104
+ audienceBeliefAfter: brief?.audienceBeliefAfter,
105
+ decisionOrAction: brief?.decisionOrAction,
106
+ narrativeArc: brief?.narrativeArc,
107
+ keyClaims: brief?.keyClaims,
108
+ }),
109
+ })
110
+
111
+ for (const objection of brief?.objections ?? []) {
112
+ const objectionId = `objection:${stableHash(objection)}`
113
+ addNode(builder, { id: objectionId, type: "objection", label: objection, data: { text: objection } })
114
+ addEdge(builder, "contains", narrativeId, objectionId)
115
+ addEdge(builder, "challenges", objectionId, narrativeId)
116
+ }
117
+
118
+ for (const risk of brief?.risks ?? []) {
119
+ const riskId = `risk:${stableHash(risk)}`
120
+ addNode(builder, { id: riskId, type: "risk", label: risk, data: { text: risk } })
121
+ addEdge(builder, "contains", narrativeId, riskId)
122
+ addEdge(builder, "constrained_by", narrativeId, riskId)
123
+ }
124
+
125
+ return narrativeId
126
+ }
127
+
128
+ function addCanonicalNarrative(builder: GraphBuilder, narrative: NarrativeStateV1, deck: DeckSpec): string {
129
+ addNode(builder, {
130
+ id: narrative.id,
131
+ type: "narrativeIntent",
132
+ label: narrative.thesis?.statement || deck.goal || narrative.id,
133
+ data: compactData({
134
+ status: narrative.status,
135
+ goal: deck.goal,
136
+ audience: narrative.audience.primary || deck.audience,
137
+ language: deck.language,
138
+ beliefBefore: narrative.audience.beliefBefore,
139
+ beliefAfter: narrative.audience.beliefAfter,
140
+ decisionOrAction: narrative.decision.action,
141
+ thesis: narrative.thesis?.statement,
142
+ }),
143
+ })
144
+
145
+ for (const claim of narrative.claims) {
146
+ addNode(builder, {
147
+ id: claim.id,
148
+ type: "claim",
149
+ label: claim.text,
150
+ data: compactData({
151
+ text: claim.text,
152
+ kind: claim.kind,
153
+ importance: claim.importance,
154
+ evidenceRequired: claim.evidenceRequired,
155
+ evidenceStatus: claim.evidenceStatus,
156
+ supportedScope: claim.supportedScope,
157
+ unsupportedScope: claim.unsupportedScope,
158
+ caveats: claim.caveats,
159
+ source: "canonicalNarrative",
160
+ }),
161
+ })
162
+ addEdge(builder, "contains", narrative.id, claim.id)
163
+ }
164
+
165
+ for (const binding of narrative.evidenceBindings) {
166
+ const supportId = addNarrativeEvidenceSupportNode(builder, binding)
167
+ addEdge(builder, "supports", supportId, binding.claimId, compactData({
168
+ strength: binding.strength,
169
+ source: binding.source,
170
+ quote: binding.quote,
171
+ url: binding.url,
172
+ sourcePath: binding.sourcePath,
173
+ location: binding.location,
174
+ findingsFile: binding.findingsFile,
175
+ caveat: binding.caveat,
176
+ supportScope: binding.supportScope,
177
+ unsupportedScope: binding.unsupportedScope,
178
+ }))
179
+ }
180
+
181
+ for (const objection of narrative.objections) {
182
+ addNode(builder, {
183
+ id: objection.id,
184
+ type: "objection",
185
+ label: objection.text,
186
+ data: compactData({ text: objection.text, priority: objection.priority, response: objection.response }),
187
+ })
188
+ addEdge(builder, "contains", narrative.id, objection.id)
189
+ addEdge(builder, "challenges", objection.id, objection.claimId || narrative.id)
190
+ }
191
+
192
+ for (const risk of narrative.risks) {
193
+ addNode(builder, {
194
+ id: risk.id,
195
+ type: "risk",
196
+ label: risk.text,
197
+ data: compactData({ text: risk.text, severity: risk.severity, mitigation: risk.mitigation }),
198
+ })
199
+ addEdge(builder, "contains", narrative.id, risk.id)
200
+ addEdge(builder, "constrained_by", risk.claimId || narrative.id, risk.id)
201
+ }
202
+
203
+ return narrative.id
204
+ }
205
+
206
+ function addSlide(builder: GraphBuilder, slide: SlideSpec): void {
207
+ addNode(builder, {
208
+ id: slideNodeId(slide.index),
209
+ type: "slide",
210
+ label: slide.title,
211
+ data: compactData({
212
+ index: slide.index,
213
+ title: slide.title,
214
+ purpose: slide.purpose,
215
+ narrativeRole: slide.narrativeRole,
216
+ layout: slide.layout,
217
+ components: slide.components,
218
+ status: slide.status,
219
+ }),
220
+ })
221
+ }
222
+
223
+ function addSlideClaimsAndEvidence(builder: GraphBuilder, slide: SlideSpec): void {
224
+ const slideId = slideNodeId(slide.index)
225
+ const claims = claimCandidates(slide)
226
+ const claimIds = claims.map((claim) => addClaim(builder, slide, claim))
227
+
228
+ for (const claimId of claimIds) {
229
+ addEdge(builder, "contains", slideId, claimId)
230
+ addEdge(builder, "appears_in", claimId, slideId)
231
+ }
232
+
233
+ for (const evidence of slide.evidence ?? []) {
234
+ const supportId = addEvidenceSupportNode(builder, evidence)
235
+ for (const claimId of claimIds) {
236
+ addEdge(builder, "supports", supportId, claimId, compactData({
237
+ slideIndex: slide.index,
238
+ detailLevel: hasEvidenceDetail(evidence) ? "detailed" : "weak",
239
+ source: evidence.source,
240
+ quote: evidence.quote,
241
+ page: evidence.page,
242
+ url: evidence.url,
243
+ sourcePath: evidence.sourcePath,
244
+ location: evidence.location,
245
+ findingsFile: evidence.findingsFile,
246
+ caveat: evidence.caveat,
247
+ extractedTextPath: evidence.extractedTextPath,
248
+ extractedManifestPath: evidence.extractedManifestPath,
249
+ }))
250
+ }
251
+ }
252
+ }
253
+
254
+ function addClaim(builder: GraphBuilder, slide: SlideSpec, claim: { origin: string; text: string }): string {
255
+ const id = claimNodeId(slide.index, claim.text)
256
+ addNode(builder, {
257
+ id,
258
+ type: "claim",
259
+ label: claim.text,
260
+ data: compactData({
261
+ slideIndex: slide.index,
262
+ slideTitle: slide.title,
263
+ origin: claim.origin,
264
+ text: claim.text,
265
+ }),
266
+ })
267
+ return id
268
+ }
269
+
270
+ function addEvidenceSupportNode(builder: GraphBuilder, evidence: EvidenceRef): string {
271
+ if (evidence.findingsFile?.trim()) {
272
+ const id = findingNodeId(evidence.findingsFile)
273
+ addNode(builder, {
274
+ id,
275
+ type: "finding",
276
+ label: evidence.findingsFile,
277
+ data: compactData({ findingsFile: evidence.findingsFile, source: evidence.source, quote: evidence.quote, location: evidence.location, caveat: evidence.caveat }),
278
+ })
279
+ return id
280
+ }
281
+
282
+ const sourceKey = evidence.sourcePath || evidence.source || evidence.url || "unknown-evidence-source"
283
+ const id = sourceNodeId(sourceKey)
284
+ addNode(builder, {
285
+ id,
286
+ type: "source",
287
+ label: sourceKey,
288
+ data: compactData({ source: evidence.source, sourcePath: evidence.sourcePath, url: evidence.url }),
289
+ })
290
+
291
+ if (evidence.extractedTextPath || evidence.extractedManifestPath) {
292
+ const extractionKey = evidence.extractedTextPath || evidence.extractedManifestPath
293
+ const extractionId = extractionNodeId(extractionKey ?? sourceKey)
294
+ addNode(builder, {
295
+ id: extractionId,
296
+ type: "extraction",
297
+ label: extractionKey,
298
+ data: compactData({ textPath: evidence.extractedTextPath, manifestPath: evidence.extractedManifestPath }),
299
+ })
300
+ addEdge(builder, "extracted_as", id, extractionId)
301
+ }
302
+
303
+ return id
304
+ }
305
+
306
+ function addNarrativeEvidenceSupportNode(builder: GraphBuilder, binding: NarrativeEvidenceBinding): string {
307
+ if (binding.findingsFile?.trim()) {
308
+ const id = findingNodeId(binding.findingsFile)
309
+ addNode(builder, {
310
+ id,
311
+ type: "finding",
312
+ label: binding.findingsFile,
313
+ data: compactData({ findingsFile: binding.findingsFile, source: binding.source, quote: binding.quote, location: binding.location, caveat: binding.caveat }),
314
+ })
315
+ return id
316
+ }
317
+
318
+ const sourceKey = binding.sourcePath || binding.source || binding.url || "unknown-narrative-evidence-source"
319
+ const id = sourceNodeId(sourceKey)
320
+ addNode(builder, {
321
+ id,
322
+ type: "source",
323
+ label: sourceKey,
324
+ data: compactData({ source: binding.source, sourcePath: binding.sourcePath, url: binding.url }),
325
+ })
326
+ return id
327
+ }
328
+
329
+ function addArtifact(builder: GraphBuilder, deck: DeckSpec, target: RenderTarget, narrativeId: string | undefined, targets: RenderTarget[]): void {
330
+ const artifactId = artifactNodeId(target.outputPath ?? deck.outputPath)
331
+ addNode(builder, {
332
+ id: artifactId,
333
+ type: "artifact",
334
+ label: target.outputPath ?? deck.outputPath,
335
+ data: compactData({
336
+ renderTargetId: target.id,
337
+ type: target.type,
338
+ outputPath: target.outputPath ?? deck.outputPath,
339
+ slug: deck.slug,
340
+ status: deck.status,
341
+ artifactVersion: target.artifactVersion,
342
+ contractStatus: target.contractStatus,
343
+ }),
344
+ })
345
+
346
+ if (narrativeId) addEdge(builder, "renders_from", artifactId, narrativeId)
347
+ const sourceNodeIds = target.sourceNodeIds.length > 0 ? target.sourceNodeIds : deck.slides.map((slide) => slideNodeId(slide.index))
348
+ for (const sourceNodeId of sourceNodeIds) addEdge(builder, "renders_from", artifactId, resolveRenderSourceNodeId(sourceNodeId, targets))
349
+ }
350
+
351
+ function renderTargetsForDeck(state: DecksState, deck: DeckSpec): RenderTarget[] {
352
+ const deckOutputPath = normalizePath(deck.outputPath)
353
+ const htmlTargetId = renderTargetId("html_deck", deckOutputPath)
354
+ const htmlArtifactId = artifactNodeId(deckOutputPath)
355
+ const targets = (state.renderTargets ?? []).filter((target) => {
356
+ if (target.id === htmlTargetId) return true
357
+ if (target.type === "html_deck") return normalizePath(target.outputPath ?? "") === deckOutputPath
358
+ const data = target.data ?? {}
359
+ return data.sourceTargetId === htmlTargetId ||
360
+ data.sourceOutputPath === deckOutputPath ||
361
+ target.sourceNodeIds.includes(htmlTargetId) ||
362
+ target.sourceNodeIds.includes(htmlArtifactId)
363
+ })
364
+ const htmlTarget = targets.find((target) => target.id === htmlTargetId) ?? fallbackHtmlDeckRenderTarget(deck)
365
+ if (!targets.some((target) => target.id === htmlTarget.id)) targets.push(htmlTarget)
366
+ return targets.sort((a, b) => a.id.localeCompare(b.id))
367
+ }
368
+
369
+ function resolveRenderSourceNodeId(sourceNodeId: string, targets: RenderTarget[]): string {
370
+ if (!sourceNodeId.startsWith("target:")) return sourceNodeId
371
+ const target = targets.find((item) => item.id === sourceNodeId)
372
+ return target ? artifactNodeId(target.outputPath ?? target.id) : sourceNodeId
373
+ }
374
+
375
+ function fallbackHtmlDeckRenderTarget(deck: DeckSpec): RenderTarget {
376
+ return {
377
+ id: renderTargetId("html_deck", deck.outputPath),
378
+ type: "html_deck",
379
+ outputPath: deck.outputPath,
380
+ sourceNodeIds: deck.slides.map((slide) => slideNodeId(slide.index)),
381
+ contractStatus: "unknown",
382
+ data: { slug: deck.slug, compatibilityOutputPath: deck.outputPath },
383
+ }
384
+ }
385
+
386
+ function claimCandidates(slide: SlideSpec): Array<{ origin: string; text: string }> {
387
+ const claims: Array<{ origin: string; text: string }> = []
388
+ pushClaim(claims, "title", slide.title)
389
+ pushClaim(claims, "purpose", slide.purpose)
390
+ pushClaim(claims, "headline", slide.content?.headline)
391
+ for (const item of slide.content?.body ?? []) pushClaim(claims, "body", item)
392
+ for (const item of slide.content?.bullets ?? []) pushClaim(claims, "bullet", item)
393
+ return claims
394
+ }
395
+
396
+ function pushClaim(claims: Array<{ origin: string; text: string }>, origin: string, text: string | undefined): void {
397
+ const value = cleanOptionalText(text)
398
+ if (!value) return
399
+ if (claims.some((claim) => claim.text === value)) return
400
+ claims.push({ origin, text: value })
401
+ }
402
+
403
+ function hasNarrativeBrief(brief: NarrativeBrief | undefined): boolean {
404
+ return Boolean(
405
+ brief?.audienceBeliefBefore?.trim() ||
406
+ brief?.audienceBeliefAfter?.trim() ||
407
+ brief?.decisionOrAction?.trim() ||
408
+ brief?.narrativeArc?.trim() ||
409
+ brief?.keyClaims.length ||
410
+ brief?.objections.length ||
411
+ brief?.risks.length,
412
+ )
413
+ }
414
+
415
+ function hasCanonicalNarrative(narrative: NarrativeStateV1 | undefined): narrative is NarrativeStateV1 {
416
+ return Boolean(
417
+ narrative?.audience.primary?.trim() ||
418
+ narrative?.audience.beliefBefore?.trim() ||
419
+ narrative?.audience.beliefAfter?.trim() ||
420
+ narrative?.decision.action?.trim() ||
421
+ narrative?.thesis?.statement?.trim() ||
422
+ narrative?.claims.length ||
423
+ narrative?.evidenceBindings.length ||
424
+ narrative?.objections.length ||
425
+ narrative?.risks.length,
426
+ )
427
+ }
428
+
429
+ function hasEvidenceDetail(evidence: EvidenceRef): boolean {
430
+ return Boolean(
431
+ evidence.quote?.trim() ||
432
+ evidence.page?.trim() ||
433
+ evidence.location?.trim() ||
434
+ evidence.url?.trim() ||
435
+ evidence.findingsFile?.trim() ||
436
+ evidence.sourcePath?.trim() ||
437
+ evidence.extractedTextPath?.trim(),
438
+ )
439
+ }
440
+
441
+ function addNode(builder: GraphBuilder, node: GraphNode): void {
442
+ const existing = builder.nodes.get(node.id)
443
+ if (!existing) {
444
+ builder.nodes.set(node.id, cleanNode(node))
445
+ return
446
+ }
447
+ builder.nodes.set(node.id, cleanNode({
448
+ ...existing,
449
+ label: existing.label || node.label,
450
+ data: compactData({ ...(existing.data ?? {}), ...(node.data ?? {}) }),
451
+ }))
452
+ }
453
+
454
+ function addEdge(builder: GraphBuilder, type: GraphEdgeType, from: string, to: string, data?: Record<string, unknown>): void {
455
+ const cleanedData = compactData(data ?? {})
456
+ const edge: GraphEdge = {
457
+ id: edgeId(type, from, to, cleanedData),
458
+ type,
459
+ from,
460
+ to,
461
+ ...(Object.keys(cleanedData).length > 0 ? { data: cleanedData } : {}),
462
+ }
463
+ builder.edges.set(edge.id, edge)
464
+ }
465
+
466
+ function normalizeGraph(builder: GraphBuilder): WorkspaceGraph {
467
+ const nodes = [...builder.nodes.values()].sort((a, b) => a.id.localeCompare(b.id))
468
+ return {
469
+ nodes: Object.fromEntries(nodes.map((node) => [node.id, node])),
470
+ edges: [...builder.edges.values()].sort((a, b) => a.id.localeCompare(b.id)),
471
+ }
472
+ }
473
+
474
+ function cleanNode(node: GraphNode): GraphNode {
475
+ const data = compactData(node.data ?? {})
476
+ return {
477
+ id: node.id,
478
+ type: node.type as GraphNodeType,
479
+ ...(node.label ? { label: node.label } : {}),
480
+ ...(Object.keys(data).length > 0 ? { data } : {}),
481
+ }
482
+ }
483
+
484
+ function compactData(input: Record<string, unknown>): Record<string, unknown> {
485
+ const output: Record<string, unknown> = {}
486
+ for (const [key, value] of Object.entries(input)) {
487
+ if (value === undefined || value === null) continue
488
+ if (typeof value === "string" && value.trim() === "") continue
489
+ if (Array.isArray(value) && value.length === 0) continue
490
+ output[key] = value
491
+ }
492
+ return output
493
+ }
494
+
495
+ function sourceNodeId(value: string): string {
496
+ return `source:${stablePathOrHash(value)}`
497
+ }
498
+
499
+ function extractionNodeId(value: string): string {
500
+ return `extraction:${stablePathOrHash(value)}`
501
+ }
502
+
503
+ function findingNodeId(value: string): string {
504
+ return `finding:${stablePathOrHash(value)}`
505
+ }
506
+
507
+ function claimNodeId(slideIndex: number, text: string): string {
508
+ return `claim:${slideIndex}:${stableHash(normalizeText(text))}`
509
+ }
510
+
511
+ function slideNodeId(index: number): string {
512
+ return `slide:${index}`
513
+ }
514
+
515
+ function artifactNodeId(outputPath: string): string {
516
+ return `artifact:${stablePathOrHash(outputPath)}`
517
+ }
518
+
519
+ function edgeId(type: GraphEdgeType, from: string, to: string, data: Record<string, unknown>): string {
520
+ return `edge:${type}:${stableHash(JSON.stringify({ from, to, data }))}`
521
+ }
522
+
523
+ function stablePathOrHash(value: string): string {
524
+ const normalized = normalizePath(value)
525
+ if (/^[a-z0-9._/-]+$/i.test(normalized) && normalized.length <= 80) return normalized
526
+ return stableHash(normalized)
527
+ }
528
+
529
+ function stableHash(value: string): string {
530
+ return createHash("sha1").update(value).digest("hex").slice(0, 12)
531
+ }
532
+
533
+ function normalizePath(value: string): string {
534
+ return value.trim().replace(/\\/g, "/").replace(/^\.\//, "")
535
+ }
536
+
537
+ function normalizeText(value: string): string {
538
+ return value.trim().replace(/\s+/g, " ").toLowerCase()
539
+ }
540
+
541
+ function cleanOptionalText(value: string | undefined): string | undefined {
542
+ const text = String(value ?? "").trim()
543
+ return text || undefined
544
+ }