@cyber-dash-tech/revela 0.10.0 → 0.11.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.
- package/README.md +25 -5
- package/README.zh-CN.md +25 -5
- package/lib/commands/inspect.ts +1 -1
- package/lib/commands/pdf.ts +33 -5
- package/lib/commands/pptx.ts +14 -9
- package/lib/commands/refine.ts +1 -1
- package/lib/deck-html/contract.ts +252 -0
- package/lib/decks-state.ts +101 -28
- package/lib/document-materials/extract.ts +20 -0
- package/lib/edit/resolve-deck.ts +13 -2
- package/lib/inspect/open.ts +3 -1
- package/lib/qa/export-gate.ts +8 -1
- package/lib/refine/open.ts +3 -1
- package/lib/workspace-state/actions.ts +71 -0
- package/lib/workspace-state/compat.ts +10 -0
- package/lib/workspace-state/evidence-status.ts +267 -0
- package/lib/workspace-state/graph.ts +426 -0
- package/lib/workspace-state/render-targets.ts +182 -0
- package/lib/workspace-state/rendered-artifacts.ts +43 -0
- package/lib/workspace-state/repository.ts +43 -0
- package/lib/workspace-state/research-attachments.ts +130 -0
- package/lib/workspace-state/review-snapshots.ts +127 -0
- package/lib/workspace-state/types.ts +119 -0
- package/package.json +1 -1
- package/plugin.ts +26 -1
- package/tools/decks.ts +54 -5
- package/tools/pdf.ts +9 -1
- package/tools/pptx.ts +10 -0
- package/tools/research-save.ts +15 -0
- package/tools/workspace-scan.ts +15 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyEvidenceCandidates,
|
|
3
|
+
hasDecksState,
|
|
4
|
+
readDecksState,
|
|
5
|
+
reviewDeckState,
|
|
6
|
+
writeDecksState,
|
|
7
|
+
type AppliedEvidenceCandidate,
|
|
8
|
+
type DecksState,
|
|
9
|
+
type EvidenceBindingCandidate,
|
|
10
|
+
type EvidenceCandidateSearchDiagnostic,
|
|
11
|
+
type EvidenceRef,
|
|
12
|
+
type ReadinessIssue,
|
|
13
|
+
type SkippedEvidenceCandidate,
|
|
14
|
+
} from "../decks-state"
|
|
15
|
+
import { compileInspectionContext, type InspectionEvidenceTrace, type InspectionGap } from "../inspection-context/compile"
|
|
16
|
+
import { matchInspectionElement, type InspectionElementMatch, type InspectionElementSnapshot, type InspectionMatchConfidence } from "../inspection-context/match"
|
|
17
|
+
import { recordWorkspaceAction } from "./actions"
|
|
18
|
+
import type { WorkspaceAction } from "./types"
|
|
19
|
+
|
|
20
|
+
export interface EvidenceStatusForSelection {
|
|
21
|
+
version: 1
|
|
22
|
+
selection: EvidenceStatusSelection
|
|
23
|
+
match: EvidenceStatusMatch
|
|
24
|
+
boundEvidence: EvidenceStatusEvidence[]
|
|
25
|
+
gaps: EvidenceStatusGap[]
|
|
26
|
+
candidateEvidence: EvidenceStatusCandidate[]
|
|
27
|
+
searchDiagnostics: EvidenceCandidateSearchDiagnostic[]
|
|
28
|
+
actionTrace: EvidenceStatusActionTrace[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface EvidenceStatusSelection {
|
|
32
|
+
slideIndex?: number
|
|
33
|
+
selectedText?: string
|
|
34
|
+
scope?: InspectionElementSnapshot["scope"]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface EvidenceStatusMatch {
|
|
38
|
+
confidence: InspectionMatchConfidence
|
|
39
|
+
reason: string
|
|
40
|
+
slideIndex?: number
|
|
41
|
+
slideTitle?: string
|
|
42
|
+
claimId?: string
|
|
43
|
+
claimText?: string
|
|
44
|
+
claimEvidenceSensitive?: boolean
|
|
45
|
+
claimEvidenceSupport?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface EvidenceStatusEvidence extends EvidenceRef {
|
|
49
|
+
slideIndex: number
|
|
50
|
+
slideTitle: string
|
|
51
|
+
hasDetail: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface EvidenceStatusGap {
|
|
55
|
+
type: InspectionGap["type"]
|
|
56
|
+
slideIndex: number
|
|
57
|
+
slideTitle: string
|
|
58
|
+
claimText: string
|
|
59
|
+
message: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface EvidenceStatusCandidate extends EvidenceBindingCandidate {
|
|
63
|
+
relevant: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface EvidenceStatusActionTrace {
|
|
67
|
+
id: string
|
|
68
|
+
type: WorkspaceAction["type"]
|
|
69
|
+
timestamp: string
|
|
70
|
+
actor?: string
|
|
71
|
+
status: WorkspaceAction["status"]
|
|
72
|
+
summary?: string
|
|
73
|
+
inputs?: Record<string, unknown>
|
|
74
|
+
outputs?: Record<string, unknown>
|
|
75
|
+
nodeIds?: string[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ApplyEvidenceBindingsResult {
|
|
79
|
+
applied: AppliedEvidenceCandidate[]
|
|
80
|
+
skipped: SkippedEvidenceCandidate[]
|
|
81
|
+
nextReviewNeeded: boolean
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getEvidenceStatusForSelection(workspaceRoot: string, snapshot: InspectionElementSnapshot, options: { slug?: string } = {}): EvidenceStatusForSelection {
|
|
85
|
+
if (!hasDecksState(workspaceRoot)) throw new Error("DECKS.json is required before checking evidence status. Run /revela init first.")
|
|
86
|
+
const state = readDecksState(workspaceRoot)
|
|
87
|
+
return getEvidenceStatusInState(state, snapshot, { ...options, workspaceRoot })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function getEvidenceStatusInState(
|
|
91
|
+
state: DecksState,
|
|
92
|
+
snapshot: InspectionElementSnapshot,
|
|
93
|
+
options: { workspaceRoot?: string; slug?: string } = {},
|
|
94
|
+
): EvidenceStatusForSelection {
|
|
95
|
+
const normalizedSnapshot = normalizeEvidenceSnapshot(snapshot)
|
|
96
|
+
const context = compileInspectionContext(state, options.slug)
|
|
97
|
+
const match = matchInspectionElement(context, normalizedSnapshot)
|
|
98
|
+
const reviewed = reviewDeckState(state, context.slug, { workspaceRoot: options.workspaceRoot })
|
|
99
|
+
const candidates = relevantCandidates(reviewed.result.evidenceCandidates ?? [], match)
|
|
100
|
+
const diagnostics = relevantSearchDiagnostics(reviewed.result.issues, match)
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
version: 1,
|
|
104
|
+
selection: {
|
|
105
|
+
slideIndex: normalizedSnapshot.slideIndex,
|
|
106
|
+
selectedText: normalizedSnapshot.selectedText || normalizedSnapshot.text,
|
|
107
|
+
scope: normalizedSnapshot.scope,
|
|
108
|
+
},
|
|
109
|
+
match: projectMatch(match),
|
|
110
|
+
boundEvidence: match.evidence.map(projectEvidence),
|
|
111
|
+
gaps: evidenceStatusGaps(match, reviewed.result.issues),
|
|
112
|
+
candidateEvidence: candidates,
|
|
113
|
+
searchDiagnostics: diagnostics,
|
|
114
|
+
actionTrace: actionTraceForMatch(state.actions ?? [], match),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function applyEvidenceBindings(workspaceRoot: string, candidateIds: string[]): ApplyEvidenceBindingsResult {
|
|
119
|
+
if (!hasDecksState(workspaceRoot)) throw new Error("DECKS.json is required before applying evidence bindings. Run /revela init first.")
|
|
120
|
+
const ids = [...new Set(candidateIds.map((id) => id.trim()).filter(Boolean))]
|
|
121
|
+
if (ids.length === 0) throw new Error("candidateIds are required for evidence binding application.")
|
|
122
|
+
|
|
123
|
+
const state = readDecksState(workspaceRoot)
|
|
124
|
+
const applied = applyEvidenceCandidates(state, ids, { workspaceRoot })
|
|
125
|
+
recordEvidenceBindingAction(applied.state, ids, applied.result)
|
|
126
|
+
writeDecksState(workspaceRoot, applied.state)
|
|
127
|
+
return applied.result
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function recordEvidenceBindingAction(state: DecksState, candidateIds: string[], result: ApplyEvidenceBindingsResult): DecksState {
|
|
131
|
+
return recordWorkspaceAction(state, {
|
|
132
|
+
type: "evidence.binding_applied",
|
|
133
|
+
actor: "revela-decks",
|
|
134
|
+
inputs: { candidateIds },
|
|
135
|
+
outputs: {
|
|
136
|
+
applied: result.applied.map((item) => ({ candidateId: item.candidateId, slideIndex: item.slideIndex, evidence: item.evidence })),
|
|
137
|
+
skipped: result.skipped,
|
|
138
|
+
nextReviewNeeded: result.nextReviewNeeded,
|
|
139
|
+
},
|
|
140
|
+
status: result.applied.length > 0 ? "success" : "skipped",
|
|
141
|
+
summary: `Applied ${result.applied.length} evidence candidate${result.applied.length === 1 ? "" : "s"}.`,
|
|
142
|
+
nodeIds: result.applied.map((item) => `slide:${item.slideIndex}`),
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizeEvidenceSnapshot(snapshot: InspectionElementSnapshot): InspectionElementSnapshot {
|
|
147
|
+
const selectedText = snapshot.selectedText || snapshot.text
|
|
148
|
+
return {
|
|
149
|
+
...snapshot,
|
|
150
|
+
text: snapshot.text || selectedText,
|
|
151
|
+
selectedText,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function projectMatch(match: InspectionElementMatch): EvidenceStatusMatch {
|
|
156
|
+
return {
|
|
157
|
+
confidence: match.confidence,
|
|
158
|
+
reason: match.reason,
|
|
159
|
+
slideIndex: match.slide?.index,
|
|
160
|
+
slideTitle: match.slide?.title,
|
|
161
|
+
claimId: match.claim?.id,
|
|
162
|
+
claimText: match.claim?.text,
|
|
163
|
+
claimEvidenceSensitive: match.claim?.evidenceSensitive,
|
|
164
|
+
claimEvidenceSupport: match.claim?.evidenceSupport,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function projectEvidence(trace: InspectionEvidenceTrace): EvidenceStatusEvidence {
|
|
169
|
+
return {
|
|
170
|
+
...trace,
|
|
171
|
+
slideIndex: trace.slideIndex,
|
|
172
|
+
slideTitle: trace.slideTitle,
|
|
173
|
+
hasDetail: trace.hasDetail,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function projectGap(gap: InspectionGap): EvidenceStatusGap {
|
|
178
|
+
return {
|
|
179
|
+
type: gap.type,
|
|
180
|
+
slideIndex: gap.slideIndex,
|
|
181
|
+
slideTitle: gap.slideTitle,
|
|
182
|
+
claimText: gap.claimText,
|
|
183
|
+
message: gap.message,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function evidenceStatusGaps(match: InspectionElementMatch, issues: ReadinessIssue[]): EvidenceStatusGap[] {
|
|
188
|
+
const gaps = match.gaps.map(projectGap)
|
|
189
|
+
const slideIndex = match.slide?.index
|
|
190
|
+
for (const issue of issues) {
|
|
191
|
+
if (issue.slideIndex !== slideIndex) continue
|
|
192
|
+
if (typeof issue.slideIndex !== "number") continue
|
|
193
|
+
if (issue.type !== "missing_evidence" && issue.type !== "weak_evidence") continue
|
|
194
|
+
if (gaps.some((gap) => gap.type === issue.type && gap.claimText === issue.claimText)) continue
|
|
195
|
+
gaps.push({
|
|
196
|
+
type: issue.type,
|
|
197
|
+
slideIndex: issue.slideIndex,
|
|
198
|
+
slideTitle: issue.slideTitle ?? match.slide?.title ?? "",
|
|
199
|
+
claimText: issue.claimText ?? match.claim?.text ?? "",
|
|
200
|
+
message: issue.message,
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
return gaps
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function relevantCandidates(candidates: EvidenceBindingCandidate[], match: InspectionElementMatch): EvidenceStatusCandidate[] {
|
|
207
|
+
const slideIndex = match.slide?.index
|
|
208
|
+
return candidates
|
|
209
|
+
.filter((candidate) => candidate.slideIndex === slideIndex)
|
|
210
|
+
.map((candidate) => ({
|
|
211
|
+
...candidate,
|
|
212
|
+
relevant: candidateRelevantToMatch(candidate, match),
|
|
213
|
+
}))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function candidateRelevantToMatch(candidate: EvidenceBindingCandidate, match: InspectionElementMatch): boolean {
|
|
217
|
+
const claimText = normalizeText(match.claim?.text)
|
|
218
|
+
if (!claimText || !candidate.claimText) return true
|
|
219
|
+
const candidateClaim = normalizeText(candidate.claimText)
|
|
220
|
+
if (candidateClaim === claimText || candidateClaim.includes(claimText) || claimText.includes(candidateClaim)) return true
|
|
221
|
+
const quote = normalizeText(candidate.quote)
|
|
222
|
+
if (quote.includes(claimText)) return true
|
|
223
|
+
return (candidate.supportScope ?? []).some((scope) => {
|
|
224
|
+
const normalizedScope = normalizeText(scope)
|
|
225
|
+
return normalizedScope === claimText || normalizedScope.includes(claimText) || claimText.includes(normalizedScope)
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function relevantSearchDiagnostics(issues: ReadinessIssue[], match: InspectionElementMatch): EvidenceCandidateSearchDiagnostic[] {
|
|
230
|
+
const slideIndex = match.slide?.index
|
|
231
|
+
return issues
|
|
232
|
+
.filter((issue) => issue.slideIndex === slideIndex && issue.evidenceCandidateSearch)
|
|
233
|
+
.map((issue) => issue.evidenceCandidateSearch!)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function actionTraceForMatch(actions: WorkspaceAction[], match: InspectionElementMatch): EvidenceStatusActionTrace[] {
|
|
237
|
+
const slideNodeId = match.slide ? `slide:${match.slide.index}` : undefined
|
|
238
|
+
const evidenceKeys = new Set(match.evidence.flatMap((item) => [item.source, item.sourcePath, item.findingsFile].filter((value): value is string => Boolean(value))))
|
|
239
|
+
return actions
|
|
240
|
+
.filter((action) => actionRelevantToMatch(action, slideNodeId, evidenceKeys))
|
|
241
|
+
.slice(-12)
|
|
242
|
+
.map((action) => ({
|
|
243
|
+
id: action.id,
|
|
244
|
+
type: action.type,
|
|
245
|
+
timestamp: action.timestamp,
|
|
246
|
+
actor: action.actor,
|
|
247
|
+
status: action.status,
|
|
248
|
+
summary: action.summary,
|
|
249
|
+
inputs: action.inputs,
|
|
250
|
+
outputs: action.outputs,
|
|
251
|
+
nodeIds: action.nodeIds,
|
|
252
|
+
}))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function actionRelevantToMatch(action: WorkspaceAction, slideNodeId: string | undefined, evidenceKeys: Set<string>): boolean {
|
|
256
|
+
if (slideNodeId && action.nodeIds?.includes(slideNodeId)) return true
|
|
257
|
+
if (action.type === "evidence.binding_applied" || action.type === "evidence.candidate_generated") return true
|
|
258
|
+
const text = JSON.stringify({ inputs: action.inputs, outputs: action.outputs, nodeIds: action.nodeIds })
|
|
259
|
+
for (const key of evidenceKeys) {
|
|
260
|
+
if (key && text.includes(key)) return true
|
|
261
|
+
}
|
|
262
|
+
return false
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function normalizeText(value: string | undefined): string {
|
|
266
|
+
return String(value ?? "").replace(/\s+/g, " ").trim().toLowerCase()
|
|
267
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { createHash } from "crypto"
|
|
2
|
+
import type { DeckSpec, DecksState, EvidenceRef, NarrativeBrief, ResearchAxis, SlideSpec, SourceMaterial } from "../decks-state"
|
|
3
|
+
import { renderTargetId } from "./render-targets"
|
|
4
|
+
import type { GraphEdge, GraphEdgeType, GraphNode, GraphNodeType, RenderTarget, WorkspaceGraph } from "./types"
|
|
5
|
+
|
|
6
|
+
export interface ProjectWorkspaceGraphOptions {
|
|
7
|
+
slug?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface GraphBuilder {
|
|
11
|
+
nodes: Map<string, GraphNode>
|
|
12
|
+
edges: Map<string, GraphEdge>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function projectWorkspaceGraph(state: DecksState, options: ProjectWorkspaceGraphOptions = {}): WorkspaceGraph {
|
|
16
|
+
const deck = activeDeck(state, options.slug)
|
|
17
|
+
const builder: GraphBuilder = { nodes: new Map(), edges: new Map() }
|
|
18
|
+
|
|
19
|
+
for (const material of state.workspace.sourceMaterials ?? []) addSourceMaterial(builder, material)
|
|
20
|
+
for (const axis of deck.researchPlan ?? []) addResearchFinding(builder, axis)
|
|
21
|
+
|
|
22
|
+
const narrativeId = addNarrative(builder, deck)
|
|
23
|
+
for (const slide of deck.slides.slice().sort((a, b) => a.index - b.index)) addSlide(builder, slide)
|
|
24
|
+
for (const slide of deck.slides.slice().sort((a, b) => a.index - b.index)) addSlideClaimsAndEvidence(builder, slide)
|
|
25
|
+
const targets = renderTargetsForDeck(state, deck)
|
|
26
|
+
for (const target of targets) addArtifact(builder, deck, target, narrativeId, targets)
|
|
27
|
+
|
|
28
|
+
return normalizeGraph(builder)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function activeDeck(state: DecksState, slug?: string): DeckSpec {
|
|
32
|
+
const key = slug || state.activeDeck || (Object.keys(state.decks).length === 1 ? Object.keys(state.decks)[0] : undefined)
|
|
33
|
+
if (!key || !state.decks[key]) throw new Error("No active deck is available for workspace graph projection.")
|
|
34
|
+
return state.decks[key]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function addSourceMaterial(builder: GraphBuilder, material: SourceMaterial): void {
|
|
38
|
+
const sourceId = sourceNodeId(material.path || material.fingerprint || "unknown-source")
|
|
39
|
+
addNode(builder, {
|
|
40
|
+
id: sourceId,
|
|
41
|
+
type: "source",
|
|
42
|
+
label: material.path,
|
|
43
|
+
data: compactData({
|
|
44
|
+
path: material.path,
|
|
45
|
+
type: material.type,
|
|
46
|
+
size: material.size,
|
|
47
|
+
fingerprint: material.fingerprint,
|
|
48
|
+
status: material.status,
|
|
49
|
+
summary: material.summary,
|
|
50
|
+
bestUsedFor: material.bestUsedFor,
|
|
51
|
+
}),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const extraction = material.extraction
|
|
55
|
+
if (!extraction) return
|
|
56
|
+
const extractionKey = extraction.manifestPath || extraction.textPath || extraction.cacheDir
|
|
57
|
+
if (!extractionKey) return
|
|
58
|
+
|
|
59
|
+
const extractionId = extractionNodeId(extractionKey)
|
|
60
|
+
addNode(builder, {
|
|
61
|
+
id: extractionId,
|
|
62
|
+
type: "extraction",
|
|
63
|
+
label: extractionKey,
|
|
64
|
+
data: compactData(extraction),
|
|
65
|
+
})
|
|
66
|
+
addEdge(builder, "extracted_as", sourceId, extractionId)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function addResearchFinding(builder: GraphBuilder, axis: ResearchAxis): void {
|
|
70
|
+
if (!axis.findingsFile?.trim()) return
|
|
71
|
+
const id = findingNodeId(axis.findingsFile)
|
|
72
|
+
addNode(builder, {
|
|
73
|
+
id,
|
|
74
|
+
type: "finding",
|
|
75
|
+
label: axis.findingsFile,
|
|
76
|
+
data: compactData({
|
|
77
|
+
axis: axis.axis,
|
|
78
|
+
needed: axis.needed,
|
|
79
|
+
status: axis.status,
|
|
80
|
+
findingsFile: axis.findingsFile,
|
|
81
|
+
notes: axis.notes,
|
|
82
|
+
sourceKind: "researchPlan",
|
|
83
|
+
}),
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function addNarrative(builder: GraphBuilder, deck: DeckSpec): string | undefined {
|
|
88
|
+
const brief = deck.narrativeBrief
|
|
89
|
+
if (!hasNarrativeBrief(brief)) return undefined
|
|
90
|
+
|
|
91
|
+
const narrativeId = `narrative:${stableHash(deck.slug)}`
|
|
92
|
+
addNode(builder, {
|
|
93
|
+
id: narrativeId,
|
|
94
|
+
type: "narrativeIntent",
|
|
95
|
+
label: deck.goal || deck.slug,
|
|
96
|
+
data: compactData({
|
|
97
|
+
goal: deck.goal,
|
|
98
|
+
audience: deck.audience,
|
|
99
|
+
language: deck.language,
|
|
100
|
+
audienceBeliefBefore: brief?.audienceBeliefBefore,
|
|
101
|
+
audienceBeliefAfter: brief?.audienceBeliefAfter,
|
|
102
|
+
decisionOrAction: brief?.decisionOrAction,
|
|
103
|
+
narrativeArc: brief?.narrativeArc,
|
|
104
|
+
keyClaims: brief?.keyClaims,
|
|
105
|
+
}),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
for (const objection of brief?.objections ?? []) {
|
|
109
|
+
const objectionId = `objection:${stableHash(objection)}`
|
|
110
|
+
addNode(builder, { id: objectionId, type: "objection", label: objection, data: { text: objection } })
|
|
111
|
+
addEdge(builder, "contains", narrativeId, objectionId)
|
|
112
|
+
addEdge(builder, "challenges", objectionId, narrativeId)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const risk of brief?.risks ?? []) {
|
|
116
|
+
const riskId = `risk:${stableHash(risk)}`
|
|
117
|
+
addNode(builder, { id: riskId, type: "risk", label: risk, data: { text: risk } })
|
|
118
|
+
addEdge(builder, "contains", narrativeId, riskId)
|
|
119
|
+
addEdge(builder, "constrained_by", narrativeId, riskId)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return narrativeId
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function addSlide(builder: GraphBuilder, slide: SlideSpec): void {
|
|
126
|
+
addNode(builder, {
|
|
127
|
+
id: slideNodeId(slide.index),
|
|
128
|
+
type: "slide",
|
|
129
|
+
label: slide.title,
|
|
130
|
+
data: compactData({
|
|
131
|
+
index: slide.index,
|
|
132
|
+
title: slide.title,
|
|
133
|
+
purpose: slide.purpose,
|
|
134
|
+
narrativeRole: slide.narrativeRole,
|
|
135
|
+
layout: slide.layout,
|
|
136
|
+
components: slide.components,
|
|
137
|
+
status: slide.status,
|
|
138
|
+
}),
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function addSlideClaimsAndEvidence(builder: GraphBuilder, slide: SlideSpec): void {
|
|
143
|
+
const slideId = slideNodeId(slide.index)
|
|
144
|
+
const claims = claimCandidates(slide)
|
|
145
|
+
const claimIds = claims.map((claim) => addClaim(builder, slide, claim))
|
|
146
|
+
|
|
147
|
+
for (const claimId of claimIds) {
|
|
148
|
+
addEdge(builder, "contains", slideId, claimId)
|
|
149
|
+
addEdge(builder, "appears_in", claimId, slideId)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const evidence of slide.evidence ?? []) {
|
|
153
|
+
const supportId = addEvidenceSupportNode(builder, evidence)
|
|
154
|
+
for (const claimId of claimIds) {
|
|
155
|
+
addEdge(builder, "supports", supportId, claimId, compactData({
|
|
156
|
+
slideIndex: slide.index,
|
|
157
|
+
detailLevel: hasEvidenceDetail(evidence) ? "detailed" : "weak",
|
|
158
|
+
source: evidence.source,
|
|
159
|
+
quote: evidence.quote,
|
|
160
|
+
page: evidence.page,
|
|
161
|
+
url: evidence.url,
|
|
162
|
+
sourcePath: evidence.sourcePath,
|
|
163
|
+
location: evidence.location,
|
|
164
|
+
findingsFile: evidence.findingsFile,
|
|
165
|
+
caveat: evidence.caveat,
|
|
166
|
+
extractedTextPath: evidence.extractedTextPath,
|
|
167
|
+
extractedManifestPath: evidence.extractedManifestPath,
|
|
168
|
+
}))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function addClaim(builder: GraphBuilder, slide: SlideSpec, claim: { origin: string; text: string }): string {
|
|
174
|
+
const id = claimNodeId(slide.index, claim.text)
|
|
175
|
+
addNode(builder, {
|
|
176
|
+
id,
|
|
177
|
+
type: "claim",
|
|
178
|
+
label: claim.text,
|
|
179
|
+
data: compactData({
|
|
180
|
+
slideIndex: slide.index,
|
|
181
|
+
slideTitle: slide.title,
|
|
182
|
+
origin: claim.origin,
|
|
183
|
+
text: claim.text,
|
|
184
|
+
}),
|
|
185
|
+
})
|
|
186
|
+
return id
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function addEvidenceSupportNode(builder: GraphBuilder, evidence: EvidenceRef): string {
|
|
190
|
+
if (evidence.findingsFile?.trim()) {
|
|
191
|
+
const id = findingNodeId(evidence.findingsFile)
|
|
192
|
+
addNode(builder, {
|
|
193
|
+
id,
|
|
194
|
+
type: "finding",
|
|
195
|
+
label: evidence.findingsFile,
|
|
196
|
+
data: compactData({ findingsFile: evidence.findingsFile, source: evidence.source, quote: evidence.quote, location: evidence.location, caveat: evidence.caveat }),
|
|
197
|
+
})
|
|
198
|
+
return id
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const sourceKey = evidence.sourcePath || evidence.source || evidence.url || "unknown-evidence-source"
|
|
202
|
+
const id = sourceNodeId(sourceKey)
|
|
203
|
+
addNode(builder, {
|
|
204
|
+
id,
|
|
205
|
+
type: "source",
|
|
206
|
+
label: sourceKey,
|
|
207
|
+
data: compactData({ source: evidence.source, sourcePath: evidence.sourcePath, url: evidence.url }),
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
if (evidence.extractedTextPath || evidence.extractedManifestPath) {
|
|
211
|
+
const extractionKey = evidence.extractedTextPath || evidence.extractedManifestPath
|
|
212
|
+
const extractionId = extractionNodeId(extractionKey ?? sourceKey)
|
|
213
|
+
addNode(builder, {
|
|
214
|
+
id: extractionId,
|
|
215
|
+
type: "extraction",
|
|
216
|
+
label: extractionKey,
|
|
217
|
+
data: compactData({ textPath: evidence.extractedTextPath, manifestPath: evidence.extractedManifestPath }),
|
|
218
|
+
})
|
|
219
|
+
addEdge(builder, "extracted_as", id, extractionId)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return id
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function addArtifact(builder: GraphBuilder, deck: DeckSpec, target: RenderTarget, narrativeId: string | undefined, targets: RenderTarget[]): void {
|
|
226
|
+
const artifactId = artifactNodeId(target.outputPath ?? deck.outputPath)
|
|
227
|
+
addNode(builder, {
|
|
228
|
+
id: artifactId,
|
|
229
|
+
type: "artifact",
|
|
230
|
+
label: target.outputPath ?? deck.outputPath,
|
|
231
|
+
data: compactData({
|
|
232
|
+
renderTargetId: target.id,
|
|
233
|
+
type: target.type,
|
|
234
|
+
outputPath: target.outputPath ?? deck.outputPath,
|
|
235
|
+
slug: deck.slug,
|
|
236
|
+
status: deck.status,
|
|
237
|
+
artifactVersion: target.artifactVersion,
|
|
238
|
+
contractStatus: target.contractStatus,
|
|
239
|
+
}),
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
if (narrativeId) addEdge(builder, "renders_from", artifactId, narrativeId)
|
|
243
|
+
const sourceNodeIds = target.sourceNodeIds.length > 0 ? target.sourceNodeIds : deck.slides.map((slide) => slideNodeId(slide.index))
|
|
244
|
+
for (const sourceNodeId of sourceNodeIds) addEdge(builder, "renders_from", artifactId, resolveRenderSourceNodeId(sourceNodeId, targets))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function renderTargetsForDeck(state: DecksState, deck: DeckSpec): RenderTarget[] {
|
|
248
|
+
const deckOutputPath = normalizePath(deck.outputPath)
|
|
249
|
+
const htmlTargetId = renderTargetId("html_deck", deckOutputPath)
|
|
250
|
+
const htmlArtifactId = artifactNodeId(deckOutputPath)
|
|
251
|
+
const targets = (state.renderTargets ?? []).filter((target) => {
|
|
252
|
+
if (target.id === htmlTargetId) return true
|
|
253
|
+
if (target.type === "html_deck") return normalizePath(target.outputPath ?? "") === deckOutputPath
|
|
254
|
+
const data = target.data ?? {}
|
|
255
|
+
return data.sourceTargetId === htmlTargetId ||
|
|
256
|
+
data.sourceOutputPath === deckOutputPath ||
|
|
257
|
+
target.sourceNodeIds.includes(htmlTargetId) ||
|
|
258
|
+
target.sourceNodeIds.includes(htmlArtifactId)
|
|
259
|
+
})
|
|
260
|
+
const htmlTarget = targets.find((target) => target.id === htmlTargetId) ?? fallbackHtmlDeckRenderTarget(deck)
|
|
261
|
+
if (!targets.some((target) => target.id === htmlTarget.id)) targets.push(htmlTarget)
|
|
262
|
+
return targets.sort((a, b) => a.id.localeCompare(b.id))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function resolveRenderSourceNodeId(sourceNodeId: string, targets: RenderTarget[]): string {
|
|
266
|
+
if (!sourceNodeId.startsWith("target:")) return sourceNodeId
|
|
267
|
+
const target = targets.find((item) => item.id === sourceNodeId)
|
|
268
|
+
return target ? artifactNodeId(target.outputPath ?? target.id) : sourceNodeId
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function fallbackHtmlDeckRenderTarget(deck: DeckSpec): RenderTarget {
|
|
272
|
+
return {
|
|
273
|
+
id: renderTargetId("html_deck", deck.outputPath),
|
|
274
|
+
type: "html_deck",
|
|
275
|
+
outputPath: deck.outputPath,
|
|
276
|
+
sourceNodeIds: deck.slides.map((slide) => slideNodeId(slide.index)),
|
|
277
|
+
contractStatus: "unknown",
|
|
278
|
+
data: { slug: deck.slug, compatibilityOutputPath: deck.outputPath },
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function claimCandidates(slide: SlideSpec): Array<{ origin: string; text: string }> {
|
|
283
|
+
const claims: Array<{ origin: string; text: string }> = []
|
|
284
|
+
pushClaim(claims, "title", slide.title)
|
|
285
|
+
pushClaim(claims, "purpose", slide.purpose)
|
|
286
|
+
pushClaim(claims, "headline", slide.content?.headline)
|
|
287
|
+
for (const item of slide.content?.body ?? []) pushClaim(claims, "body", item)
|
|
288
|
+
for (const item of slide.content?.bullets ?? []) pushClaim(claims, "bullet", item)
|
|
289
|
+
return claims
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function pushClaim(claims: Array<{ origin: string; text: string }>, origin: string, text: string | undefined): void {
|
|
293
|
+
const value = cleanOptionalText(text)
|
|
294
|
+
if (!value) return
|
|
295
|
+
if (claims.some((claim) => claim.text === value)) return
|
|
296
|
+
claims.push({ origin, text: value })
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function hasNarrativeBrief(brief: NarrativeBrief | undefined): boolean {
|
|
300
|
+
return Boolean(
|
|
301
|
+
brief?.audienceBeliefBefore?.trim() ||
|
|
302
|
+
brief?.audienceBeliefAfter?.trim() ||
|
|
303
|
+
brief?.decisionOrAction?.trim() ||
|
|
304
|
+
brief?.narrativeArc?.trim() ||
|
|
305
|
+
brief?.keyClaims.length ||
|
|
306
|
+
brief?.objections.length ||
|
|
307
|
+
brief?.risks.length,
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function hasEvidenceDetail(evidence: EvidenceRef): boolean {
|
|
312
|
+
return Boolean(
|
|
313
|
+
evidence.quote?.trim() ||
|
|
314
|
+
evidence.page?.trim() ||
|
|
315
|
+
evidence.location?.trim() ||
|
|
316
|
+
evidence.url?.trim() ||
|
|
317
|
+
evidence.findingsFile?.trim() ||
|
|
318
|
+
evidence.sourcePath?.trim() ||
|
|
319
|
+
evidence.extractedTextPath?.trim(),
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function addNode(builder: GraphBuilder, node: GraphNode): void {
|
|
324
|
+
const existing = builder.nodes.get(node.id)
|
|
325
|
+
if (!existing) {
|
|
326
|
+
builder.nodes.set(node.id, cleanNode(node))
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
builder.nodes.set(node.id, cleanNode({
|
|
330
|
+
...existing,
|
|
331
|
+
label: existing.label || node.label,
|
|
332
|
+
data: compactData({ ...(existing.data ?? {}), ...(node.data ?? {}) }),
|
|
333
|
+
}))
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function addEdge(builder: GraphBuilder, type: GraphEdgeType, from: string, to: string, data?: Record<string, unknown>): void {
|
|
337
|
+
const cleanedData = compactData(data ?? {})
|
|
338
|
+
const edge: GraphEdge = {
|
|
339
|
+
id: edgeId(type, from, to, cleanedData),
|
|
340
|
+
type,
|
|
341
|
+
from,
|
|
342
|
+
to,
|
|
343
|
+
...(Object.keys(cleanedData).length > 0 ? { data: cleanedData } : {}),
|
|
344
|
+
}
|
|
345
|
+
builder.edges.set(edge.id, edge)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function normalizeGraph(builder: GraphBuilder): WorkspaceGraph {
|
|
349
|
+
const nodes = [...builder.nodes.values()].sort((a, b) => a.id.localeCompare(b.id))
|
|
350
|
+
return {
|
|
351
|
+
nodes: Object.fromEntries(nodes.map((node) => [node.id, node])),
|
|
352
|
+
edges: [...builder.edges.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function cleanNode(node: GraphNode): GraphNode {
|
|
357
|
+
const data = compactData(node.data ?? {})
|
|
358
|
+
return {
|
|
359
|
+
id: node.id,
|
|
360
|
+
type: node.type as GraphNodeType,
|
|
361
|
+
...(node.label ? { label: node.label } : {}),
|
|
362
|
+
...(Object.keys(data).length > 0 ? { data } : {}),
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function compactData(input: Record<string, unknown>): Record<string, unknown> {
|
|
367
|
+
const output: Record<string, unknown> = {}
|
|
368
|
+
for (const [key, value] of Object.entries(input)) {
|
|
369
|
+
if (value === undefined || value === null) continue
|
|
370
|
+
if (typeof value === "string" && value.trim() === "") continue
|
|
371
|
+
if (Array.isArray(value) && value.length === 0) continue
|
|
372
|
+
output[key] = value
|
|
373
|
+
}
|
|
374
|
+
return output
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function sourceNodeId(value: string): string {
|
|
378
|
+
return `source:${stablePathOrHash(value)}`
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function extractionNodeId(value: string): string {
|
|
382
|
+
return `extraction:${stablePathOrHash(value)}`
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function findingNodeId(value: string): string {
|
|
386
|
+
return `finding:${stablePathOrHash(value)}`
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function claimNodeId(slideIndex: number, text: string): string {
|
|
390
|
+
return `claim:${slideIndex}:${stableHash(normalizeText(text))}`
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function slideNodeId(index: number): string {
|
|
394
|
+
return `slide:${index}`
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function artifactNodeId(outputPath: string): string {
|
|
398
|
+
return `artifact:${stablePathOrHash(outputPath)}`
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function edgeId(type: GraphEdgeType, from: string, to: string, data: Record<string, unknown>): string {
|
|
402
|
+
return `edge:${type}:${stableHash(JSON.stringify({ from, to, data }))}`
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function stablePathOrHash(value: string): string {
|
|
406
|
+
const normalized = normalizePath(value)
|
|
407
|
+
if (/^[a-z0-9._/-]+$/i.test(normalized) && normalized.length <= 80) return normalized
|
|
408
|
+
return stableHash(normalized)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function stableHash(value: string): string {
|
|
412
|
+
return createHash("sha1").update(value).digest("hex").slice(0, 12)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function normalizePath(value: string): string {
|
|
416
|
+
return value.trim().replace(/\\/g, "/").replace(/^\.\//, "")
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function normalizeText(value: string): string {
|
|
420
|
+
return value.trim().replace(/\s+/g, " ").toLowerCase()
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function cleanOptionalText(value: string | undefined): string | undefined {
|
|
424
|
+
const text = String(value ?? "").trim()
|
|
425
|
+
return text || undefined
|
|
426
|
+
}
|