@cyber-dash-tech/revela 0.11.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.
@@ -17,6 +17,8 @@ import {
17
17
  latestReviewSnapshotForTarget,
18
18
  } from "./workspace-state/review-snapshots"
19
19
  import { WORKSPACE_STATE_FILE, type RenderTarget, type ReviewSnapshot, type WorkspaceAction } from "./workspace-state/types"
20
+ import { normalizeCanonicalNarrativeState, normalizeNarrativeState } from "./narrative-state/normalize"
21
+ import type { NarrativeStateV1 } from "./narrative-state/types"
20
22
 
21
23
  export const DECKS_STATE_FILE = WORKSPACE_STATE_FILE
22
24
 
@@ -28,6 +30,7 @@ export type NarrativeRole = "context" | "tension" | "evidence" | "recommendation
28
30
  export interface DecksState {
29
31
  version: 1
30
32
  activeDeck?: string
33
+ narrative?: NarrativeStateV1
31
34
  workspace: {
32
35
  brief?: string
33
36
  sourceMaterials: SourceMaterial[]
@@ -349,15 +352,15 @@ export function createDeckSpec(input: Partial<DeckSpec> & { slug: string }): Dec
349
352
  }
350
353
 
351
354
  export function readDecksState(workspaceRoot: string): DecksState {
352
- return readWorkspaceState(workspaceRoot, { fileName: DECKS_STATE_FILE, normalize: normalizeDecksState })
355
+ return readWorkspaceState(workspaceRoot, { fileName: DECKS_STATE_FILE, normalize: normalizeDecksStateWithNarrative })
353
356
  }
354
357
 
355
358
  export function writeDecksState(workspaceRoot: string, state: DecksState): void {
356
- writeWorkspaceState(workspaceRoot, state, { fileName: DECKS_STATE_FILE, normalize: normalizeDecksState })
359
+ writeWorkspaceState(workspaceRoot, state, { fileName: DECKS_STATE_FILE, normalize: normalizeDecksStateWithNarrative })
357
360
  }
358
361
 
359
362
  export function readOrCreateDecksState(workspaceRoot: string): DecksState {
360
- return readOrCreateWorkspaceState(workspaceRoot, createEmptyDecksState, { fileName: DECKS_STATE_FILE, normalize: normalizeDecksState })
363
+ return readOrCreateWorkspaceState(workspaceRoot, createEmptyDecksState, { fileName: DECKS_STATE_FILE, normalize: normalizeDecksStateWithNarrative })
361
364
  }
362
365
 
363
366
  export function upsertDeck(state: DecksState, input: Partial<DeckSpec> & { slug: string }): DecksState {
@@ -717,6 +720,7 @@ function normalizeDecksState(input: DecksState): DecksState {
717
720
  const state: DecksState = {
718
721
  version: 1,
719
722
  activeDeck: input.activeDeck ? normalizeSlug(input.activeDeck) : undefined,
723
+ narrative: normalizeCanonicalNarrativeState(input.narrative, input.activeDeck || "workspace"),
720
724
  workspace: {
721
725
  brief: input.workspace?.brief,
722
726
  sourceMaterials: input.workspace?.sourceMaterials ?? [],
@@ -745,6 +749,12 @@ function normalizeDecksState(input: DecksState): DecksState {
745
749
  return state
746
750
  }
747
751
 
752
+ function normalizeDecksStateWithNarrative(input: DecksState): DecksState {
753
+ const state = normalizeDecksState(input)
754
+ if (!state.narrative && currentDeckKey(state)) state.narrative = normalizeNarrativeState(state)
755
+ return state
756
+ }
757
+
748
758
  function currentDeckKey(state: DecksState): string | undefined {
749
759
  if (state.activeDeck && state.decks[state.activeDeck]) return state.activeDeck
750
760
  const keys = Object.keys(state.decks)
@@ -0,0 +1,52 @@
1
+ import { createHash } from "crypto"
2
+ import type { NarrativeStateV1 } from "./types"
3
+
4
+ export function stableNarrativeId(seed: string): string {
5
+ return `narrative:${stableHash(seed || "workspace")}`
6
+ }
7
+
8
+ export function stableClaimId(text: string): string {
9
+ return `claim:${stableHash(text)}`
10
+ }
11
+
12
+ export function stableEvidenceId(claimId: string, seed: string): string {
13
+ return `evidence:${claimId}:${stableHash(seed)}`
14
+ }
15
+
16
+ export function stableObjectionId(text: string): string {
17
+ return `objection:${stableHash(text)}`
18
+ }
19
+
20
+ export function stableRiskId(text: string): string {
21
+ return `risk:${stableHash(text)}`
22
+ }
23
+
24
+ export function computeNarrativeHash(narrative: NarrativeStateV1): string {
25
+ return stableHash(stableStringify({
26
+ version: narrative.version,
27
+ id: narrative.id,
28
+ audience: narrative.audience,
29
+ decision: narrative.decision,
30
+ thesis: narrative.thesis,
31
+ claims: narrative.claims,
32
+ evidenceBindings: narrative.evidenceBindings,
33
+ objections: narrative.objections,
34
+ risks: narrative.risks,
35
+ }))
36
+ }
37
+
38
+ export function stableHash(input: unknown): string {
39
+ const text = typeof input === "string" ? input : stableStringify(input)
40
+ return createHash("sha1").update(text).digest("hex").slice(0, 12)
41
+ }
42
+
43
+ function stableStringify(value: unknown): string {
44
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
45
+ if (value && typeof value === "object") {
46
+ const entries = Object.entries(value as Record<string, unknown>)
47
+ .filter(([, item]) => item !== undefined)
48
+ .sort(([a], [b]) => a.localeCompare(b))
49
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`).join(",")}}`
50
+ }
51
+ return JSON.stringify(value)
52
+ }
@@ -0,0 +1,307 @@
1
+ import type { DeckSpec, DecksState, EvidenceRef, SlideSpec } from "../decks-state"
2
+ import {
3
+ stableClaimId,
4
+ stableEvidenceId,
5
+ stableNarrativeId,
6
+ stableObjectionId,
7
+ stableRiskId,
8
+ } from "./hash"
9
+ import type {
10
+ AudienceIntent,
11
+ DecisionIntent,
12
+ NarrativeClaim,
13
+ NarrativeClaimKind,
14
+ NarrativeEvidenceBinding,
15
+ NarrativeEvidenceStatus,
16
+ NarrativeObjection,
17
+ NarrativeRisk,
18
+ NarrativeStateV1,
19
+ NarrativeStatus,
20
+ NarrativeThesis,
21
+ } from "./types"
22
+
23
+ const MIGRATED_UPDATED_AT = "1970-01-01T00:00:00.000Z"
24
+
25
+ export function normalizeCanonicalNarrativeState(input: Partial<NarrativeStateV1> | undefined, seed = "workspace"): NarrativeStateV1 | undefined {
26
+ if (!input) return undefined
27
+ const id = input.id?.trim() || stableNarrativeId(seed)
28
+ const claims = dedupeById((input.claims ?? []).map(normalizeClaim).filter((claim): claim is NarrativeClaim => Boolean(claim)))
29
+ const evidenceBindings = dedupeById((input.evidenceBindings ?? []).map((binding) => normalizeEvidenceBinding(binding, claims)).filter((binding): binding is NarrativeEvidenceBinding => Boolean(binding)))
30
+ return {
31
+ version: 1,
32
+ id,
33
+ status: normalizeStatus(input.status),
34
+ audience: normalizeAudience(input.audience),
35
+ decision: normalizeDecision(input.decision),
36
+ thesis: normalizeThesis(input.thesis),
37
+ claims: claims.map((claim) => ({ ...claim, evidenceStatus: evidenceStatusForClaim(claim, evidenceBindings) })),
38
+ evidenceBindings,
39
+ objections: dedupeById((input.objections ?? []).map(normalizeObjection).filter((objection): objection is NarrativeObjection => Boolean(objection))),
40
+ risks: dedupeById((input.risks ?? []).map(normalizeRisk).filter((risk): risk is NarrativeRisk => Boolean(risk))),
41
+ approvals: input.approvals ?? [],
42
+ updatedAt: input.updatedAt || MIGRATED_UPDATED_AT,
43
+ }
44
+ }
45
+
46
+ export function normalizeNarrativeState(state: DecksState): NarrativeStateV1 {
47
+ const deck = activeDeck(state)
48
+ const existing = normalizeCanonicalNarrativeState(state.narrative, deck?.slug ?? state.activeDeck ?? "workspace")
49
+ if (existing && hasCanonicalNarrativeContent(existing)) return existing
50
+ return migrateDeckNarrative(deck, state.activeDeck ?? "workspace")
51
+ }
52
+
53
+ function migrateDeckNarrative(deck: DeckSpec | undefined, seed: string): NarrativeStateV1 {
54
+ const brief = deck?.narrativeBrief
55
+ const id = stableNarrativeId(deck?.slug || seed)
56
+ const claims = migrateClaims(deck)
57
+ const evidenceBindings = migrateEvidenceBindings(deck, claims)
58
+ const withEvidenceStatus = claims.map((claim) => ({ ...claim, evidenceStatus: evidenceStatusForClaim(claim, evidenceBindings) }))
59
+ return {
60
+ version: 1,
61
+ id,
62
+ status: "draft",
63
+ audience: {
64
+ primary: clean(deck?.audience),
65
+ beliefBefore: clean(brief?.audienceBeliefBefore),
66
+ beliefAfter: clean(brief?.audienceBeliefAfter),
67
+ },
68
+ decision: {
69
+ action: clean(brief?.decisionOrAction),
70
+ decisionType: inferDecisionType(brief?.decisionOrAction),
71
+ },
72
+ thesis: migrateThesis(deck),
73
+ claims: withEvidenceStatus,
74
+ evidenceBindings,
75
+ objections: (brief?.objections ?? []).map((text) => ({ id: stableObjectionId(text), text, priority: "medium" as const })),
76
+ risks: (brief?.risks ?? []).map((text) => ({ id: stableRiskId(text), text, severity: "medium" as const })),
77
+ approvals: [],
78
+ updatedAt: MIGRATED_UPDATED_AT,
79
+ }
80
+ }
81
+
82
+ function migrateClaims(deck: DeckSpec | undefined): NarrativeClaim[] {
83
+ const claims: NarrativeClaim[] = []
84
+ for (const text of deck?.narrativeBrief?.keyClaims ?? []) {
85
+ pushClaim(claims, {
86
+ id: stableClaimId(text),
87
+ kind: "recommendation",
88
+ text,
89
+ importance: "central",
90
+ evidenceRequired: true,
91
+ evidenceStatus: "missing",
92
+ })
93
+ }
94
+
95
+ for (const slide of deck?.slides ?? []) {
96
+ for (const item of slideClaimTexts(slide)) {
97
+ pushClaim(claims, {
98
+ id: stableClaimId(item.text),
99
+ kind: claimKindFromSlide(slide),
100
+ text: item.text,
101
+ importance: item.origin === "title" || item.origin === "purpose" ? "background" : "supporting",
102
+ evidenceRequired: isEvidenceRequiredText(item.text, slide),
103
+ evidenceStatus: "missing",
104
+ })
105
+ }
106
+ }
107
+ return claims
108
+ }
109
+
110
+ function migrateEvidenceBindings(deck: DeckSpec | undefined, claims: NarrativeClaim[]): NarrativeEvidenceBinding[] {
111
+ const bindings: NarrativeEvidenceBinding[] = []
112
+ for (const slide of deck?.slides ?? []) {
113
+ const slideClaims = slideClaimTexts(slide)
114
+ .map((item) => claims.find((claim) => claim.text === item.text))
115
+ .filter((claim): claim is NarrativeClaim => Boolean(claim))
116
+ const targetClaims = slideClaims.length > 0 ? slideClaims : claims.filter((claim) => claim.importance === "central")
117
+ for (const evidence of slide.evidence ?? []) {
118
+ for (const claim of targetClaims) {
119
+ const binding = evidenceToBinding(evidence, claim.id)
120
+ if (binding) pushBinding(bindings, binding)
121
+ }
122
+ }
123
+ }
124
+ return bindings
125
+ }
126
+
127
+ function evidenceToBinding(evidence: EvidenceRef, claimId: string): NarrativeEvidenceBinding | undefined {
128
+ const source = clean(evidence.source || evidence.sourcePath || evidence.findingsFile || evidence.url)
129
+ if (!source) return undefined
130
+ const seed = [source, evidence.sourcePath, evidence.findingsFile, evidence.quote, evidence.location, evidence.page, evidence.url, evidence.caveat].filter(Boolean).join("|")
131
+ return {
132
+ id: stableEvidenceId(claimId, seed),
133
+ claimId,
134
+ source,
135
+ sourcePath: clean(evidence.sourcePath),
136
+ findingsFile: clean(evidence.findingsFile),
137
+ quote: clean(evidence.quote),
138
+ location: clean(evidence.location || evidence.page),
139
+ url: clean(evidence.url),
140
+ caveat: clean(evidence.caveat),
141
+ strength: evidence.quote || evidence.location || evidence.page || evidence.url || evidence.findingsFile || evidence.sourcePath ? "partial" : "weak",
142
+ }
143
+ }
144
+
145
+ function slideClaimTexts(slide: SlideSpec): Array<{ origin: string; text: string }> {
146
+ return [
147
+ { origin: "title", text: clean(slide.title) },
148
+ { origin: "purpose", text: clean(slide.purpose) },
149
+ { origin: "headline", text: clean(slide.content?.headline) },
150
+ ...(slide.content?.body ?? []).map((text) => ({ origin: "body", text: clean(text) })),
151
+ ...(slide.content?.bullets ?? []).map((text) => ({ origin: "bullet", text: clean(text) })),
152
+ ].filter((item) => item.text.length > 0)
153
+ }
154
+
155
+ function migrateThesis(deck: DeckSpec | undefined): NarrativeThesis | undefined {
156
+ const statement = clean(deck?.narrativeBrief?.narrativeArc) || clean(deck?.goal)
157
+ if (!statement) return undefined
158
+ return { id: `thesis:${stableClaimId(statement).replace(/^claim:/, "")}`, statement, confidence: "medium" }
159
+ }
160
+
161
+ function normalizeAudience(input: Partial<AudienceIntent> | undefined): AudienceIntent {
162
+ return {
163
+ primary: clean(input?.primary),
164
+ secondary: (input?.secondary ?? []).map(clean).filter(Boolean),
165
+ beliefBefore: clean(input?.beliefBefore),
166
+ beliefAfter: clean(input?.beliefAfter),
167
+ decisionContext: clean(input?.decisionContext),
168
+ successCriteria: (input?.successCriteria ?? []).map(clean).filter(Boolean),
169
+ }
170
+ }
171
+
172
+ function normalizeDecision(input: Partial<DecisionIntent> | undefined): DecisionIntent {
173
+ return {
174
+ action: clean(input?.action),
175
+ owner: clean(input?.owner),
176
+ deadline: clean(input?.deadline),
177
+ decisionType: input?.decisionType,
178
+ consequenceOfNoDecision: clean(input?.consequenceOfNoDecision),
179
+ }
180
+ }
181
+
182
+ function normalizeThesis(input: Partial<NarrativeThesis> | undefined): NarrativeThesis | undefined {
183
+ const statement = clean(input?.statement)
184
+ if (!statement) return undefined
185
+ return {
186
+ id: input?.id?.trim() || `thesis:${stableClaimId(statement).replace(/^claim:/, "")}`,
187
+ statement,
188
+ confidence: input?.confidence ?? "medium",
189
+ caveat: clean(input?.caveat),
190
+ }
191
+ }
192
+
193
+ function normalizeClaim(input: Partial<NarrativeClaim>): NarrativeClaim | undefined {
194
+ const text = clean(input.text)
195
+ if (!text) return undefined
196
+ return {
197
+ id: input.id?.trim() || stableClaimId(text),
198
+ kind: input.kind ?? "evidence",
199
+ text,
200
+ importance: input.importance ?? "supporting",
201
+ evidenceRequired: input.evidenceRequired ?? true,
202
+ evidenceStatus: input.evidenceStatus ?? "missing",
203
+ supportedScope: clean(input.supportedScope),
204
+ unsupportedScope: clean(input.unsupportedScope),
205
+ caveats: (input.caveats ?? []).map(clean).filter(Boolean),
206
+ }
207
+ }
208
+
209
+ function normalizeEvidenceBinding(input: Partial<NarrativeEvidenceBinding>, claims: NarrativeClaim[]): NarrativeEvidenceBinding | undefined {
210
+ const source = clean(input.source || input.sourcePath || input.findingsFile || input.url)
211
+ const claimId = clean(input.claimId)
212
+ if (!source || !claimId || !claims.some((claim) => claim.id === claimId)) return undefined
213
+ const seed = [source, input.sourcePath, input.findingsFile, input.quote, input.location, input.url, input.caveat].filter(Boolean).join("|")
214
+ return {
215
+ id: input.id?.trim() || stableEvidenceId(claimId, seed),
216
+ claimId,
217
+ source,
218
+ sourcePath: clean(input.sourcePath),
219
+ findingsFile: clean(input.findingsFile),
220
+ quote: clean(input.quote),
221
+ location: clean(input.location),
222
+ url: clean(input.url),
223
+ caveat: clean(input.caveat),
224
+ supportScope: clean(input.supportScope),
225
+ unsupportedScope: clean(input.unsupportedScope),
226
+ strength: input.strength ?? "weak",
227
+ }
228
+ }
229
+
230
+ function normalizeObjection(input: Partial<NarrativeObjection>): NarrativeObjection | undefined {
231
+ const text = clean(input.text)
232
+ if (!text) return undefined
233
+ return { id: input.id?.trim() || stableObjectionId(text), text, claimId: clean(input.claimId), priority: input.priority ?? "medium", response: clean(input.response) }
234
+ }
235
+
236
+ function normalizeRisk(input: Partial<NarrativeRisk>): NarrativeRisk | undefined {
237
+ const text = clean(input.text)
238
+ if (!text) return undefined
239
+ return { id: input.id?.trim() || stableRiskId(text), text, claimId: clean(input.claimId), severity: input.severity ?? "medium", mitigation: clean(input.mitigation) }
240
+ }
241
+
242
+ function evidenceStatusForClaim(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): NarrativeEvidenceStatus {
243
+ if (!claim.evidenceRequired) return "not_required"
244
+ const claimBindings = bindings.filter((binding) => binding.claimId === claim.id)
245
+ if (claimBindings.some((binding) => binding.strength === "strong")) return "supported"
246
+ if (claimBindings.some((binding) => binding.strength === "partial")) return "partial"
247
+ if (claimBindings.some((binding) => binding.strength === "weak")) return "weak"
248
+ return "missing"
249
+ }
250
+
251
+ function claimKindFromSlide(slide: SlideSpec): NarrativeClaimKind {
252
+ if (slide.narrativeRole === "recommendation") return "recommendation"
253
+ if (slide.narrativeRole === "risk") return "risk"
254
+ if (slide.narrativeRole === "ask") return "ask"
255
+ if (slide.narrativeRole === "tension") return "problem"
256
+ if (slide.narrativeRole === "context") return "context"
257
+ return "evidence"
258
+ }
259
+
260
+ function isEvidenceRequiredText(text: string, slide: SlideSpec): boolean {
261
+ if (slide.narrativeRole === "ask" || slide.narrativeRole === "close" || slide.narrativeRole === "appendix") return false
262
+ return /\d|%|\$|market|growth|cagr|tam|risk|recommend|approve|should|must|increase|decrease|增长|市场|风险|建议|投资|批准/i.test(text)
263
+ }
264
+
265
+ function inferDecisionType(action: string | undefined): DecisionIntent["decisionType"] {
266
+ const text = clean(action).toLowerCase()
267
+ if (!text) return undefined
268
+ if (/approve|批准/.test(text)) return "approve"
269
+ if (/invest|投资/.test(text)) return "invest"
270
+ if (/prioriti[sz]e|优先/.test(text)) return "prioritize"
271
+ if (/align|共识/.test(text)) return "align"
272
+ if (/choose|select|选择/.test(text)) return "choose"
273
+ if (/understand|理解/.test(text)) return "understand"
274
+ return "other"
275
+ }
276
+
277
+ function normalizeStatus(status: NarrativeStatus | undefined): NarrativeStatus {
278
+ return status ?? "draft"
279
+ }
280
+
281
+ function activeDeck(state: DecksState): DeckSpec | undefined {
282
+ if (state.activeDeck && state.decks[state.activeDeck]) return state.decks[state.activeDeck]
283
+ const keys = Object.keys(state.decks ?? {})
284
+ return keys.length === 1 ? state.decks[keys[0]] : undefined
285
+ }
286
+
287
+ function hasCanonicalNarrativeContent(narrative: NarrativeStateV1): boolean {
288
+ return Boolean(narrative.audience.primary || narrative.audience.beliefBefore || narrative.audience.beliefAfter || narrative.decision.action || narrative.thesis || narrative.claims.length > 0)
289
+ }
290
+
291
+ function pushClaim(claims: NarrativeClaim[], claim: NarrativeClaim): void {
292
+ if (claims.some((item) => item.text === claim.text)) return
293
+ claims.push(claim)
294
+ }
295
+
296
+ function pushBinding(bindings: NarrativeEvidenceBinding[], binding: NarrativeEvidenceBinding): void {
297
+ if (bindings.some((item) => item.id === binding.id)) return
298
+ bindings.push(binding)
299
+ }
300
+
301
+ function dedupeById<T extends { id: string }>(items: T[]): T[] {
302
+ return [...new Map(items.map((item) => [item.id, item])).values()]
303
+ }
304
+
305
+ function clean(value: string | undefined): string {
306
+ return value?.trim() ?? ""
307
+ }
@@ -0,0 +1,14 @@
1
+ import type { NarrativeBrief } from "../decks-state"
2
+ import type { NarrativeStateV1 } from "./types"
3
+
4
+ export function narrativeToBrief(narrative: NarrativeStateV1): NarrativeBrief {
5
+ return {
6
+ audienceBeliefBefore: narrative.audience.beliefBefore || undefined,
7
+ audienceBeliefAfter: narrative.audience.beliefAfter || undefined,
8
+ decisionOrAction: narrative.decision.action || undefined,
9
+ narrativeArc: narrative.thesis?.statement,
10
+ keyClaims: narrative.claims.filter((claim) => claim.importance === "central").map((claim) => claim.text),
11
+ objections: narrative.objections.map((objection) => objection.text),
12
+ risks: narrative.risks.map((risk) => risk.text),
13
+ }
14
+ }