@cyber-dash-tech/revela 0.12.0 → 0.14.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 +16 -16
- package/README.zh-CN.md +16 -16
- package/lib/commands/brief.ts +63 -0
- package/lib/commands/edit.ts +7 -5
- package/lib/commands/help.ts +5 -3
- package/lib/commands/inspect.ts +7 -5
- package/lib/commands/narrative.ts +160 -0
- package/lib/decks-state.ts +33 -0
- package/lib/edit/prompt.ts +3 -0
- package/lib/inspect/prompt.ts +15 -2
- package/lib/inspect/requests.ts +21 -2
- package/lib/inspection-context/compile.ts +230 -10
- package/lib/inspection-context/match.ts +71 -1
- package/lib/inspection-context/project.ts +131 -8
- package/lib/inspection-context/result.ts +183 -0
- package/lib/narrative-state/coverage.ts +100 -0
- package/lib/narrative-state/display.ts +219 -0
- package/lib/narrative-state/executive-brief.ts +246 -0
- package/lib/narrative-state/hash.ts +9 -0
- package/lib/narrative-state/map-html.ts +348 -0
- package/lib/narrative-state/map.ts +282 -0
- package/lib/narrative-state/normalize.ts +54 -0
- package/lib/narrative-state/queries.ts +434 -0
- package/lib/narrative-state/readiness.ts +71 -1
- package/lib/narrative-state/render-plan.ts +44 -1
- package/lib/narrative-state/research-gaps.ts +191 -0
- package/lib/narrative-state/types.ts +33 -0
- package/lib/refine/server.ts +91 -13
- package/lib/workspace-state/evidence-status.ts +21 -1
- package/lib/workspace-state/graph.ts +56 -2
- package/lib/workspace-state/types.ts +10 -1
- package/package.json +1 -1
- package/plugin.ts +33 -2
- package/tools/decks.ts +86 -1
- package/tools/edit.ts +10 -8
- package/tools/inspection-result.ts +37 -0
- package/tools/narrative-view.ts +84 -0
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { DeckSpec, DecksState, EvidenceRef, SlideSpec } from "../decks-state"
|
|
2
2
|
import {
|
|
3
3
|
stableClaimId,
|
|
4
|
+
stableClaimRelationId,
|
|
4
5
|
stableEvidenceId,
|
|
5
6
|
stableNarrativeId,
|
|
6
7
|
stableObjectionId,
|
|
8
|
+
stableResearchGapId,
|
|
7
9
|
stableRiskId,
|
|
8
10
|
} from "./hash"
|
|
9
11
|
import type {
|
|
@@ -11,9 +13,12 @@ import type {
|
|
|
11
13
|
DecisionIntent,
|
|
12
14
|
NarrativeClaim,
|
|
13
15
|
NarrativeClaimKind,
|
|
16
|
+
NarrativeClaimRelation,
|
|
17
|
+
NarrativeClaimRelationType,
|
|
14
18
|
NarrativeEvidenceBinding,
|
|
15
19
|
NarrativeEvidenceStatus,
|
|
16
20
|
NarrativeObjection,
|
|
21
|
+
NarrativeResearchGap,
|
|
17
22
|
NarrativeRisk,
|
|
18
23
|
NarrativeStateV1,
|
|
19
24
|
NarrativeStatus,
|
|
@@ -26,6 +31,7 @@ export function normalizeCanonicalNarrativeState(input: Partial<NarrativeStateV1
|
|
|
26
31
|
if (!input) return undefined
|
|
27
32
|
const id = input.id?.trim() || stableNarrativeId(seed)
|
|
28
33
|
const claims = dedupeById((input.claims ?? []).map(normalizeClaim).filter((claim): claim is NarrativeClaim => Boolean(claim)))
|
|
34
|
+
const claimRelations = dedupeById((input.claimRelations ?? []).map((relation) => normalizeClaimRelation(relation, claims)).filter((relation): relation is NarrativeClaimRelation => Boolean(relation)))
|
|
29
35
|
const evidenceBindings = dedupeById((input.evidenceBindings ?? []).map((binding) => normalizeEvidenceBinding(binding, claims)).filter((binding): binding is NarrativeEvidenceBinding => Boolean(binding)))
|
|
30
36
|
return {
|
|
31
37
|
version: 1,
|
|
@@ -35,9 +41,11 @@ export function normalizeCanonicalNarrativeState(input: Partial<NarrativeStateV1
|
|
|
35
41
|
decision: normalizeDecision(input.decision),
|
|
36
42
|
thesis: normalizeThesis(input.thesis),
|
|
37
43
|
claims: claims.map((claim) => ({ ...claim, evidenceStatus: evidenceStatusForClaim(claim, evidenceBindings) })),
|
|
44
|
+
claimRelations,
|
|
38
45
|
evidenceBindings,
|
|
39
46
|
objections: dedupeById((input.objections ?? []).map(normalizeObjection).filter((objection): objection is NarrativeObjection => Boolean(objection))),
|
|
40
47
|
risks: dedupeById((input.risks ?? []).map(normalizeRisk).filter((risk): risk is NarrativeRisk => Boolean(risk))),
|
|
48
|
+
researchGaps: dedupeById((input.researchGaps ?? []).map(normalizeResearchGap).filter((gap): gap is NarrativeResearchGap => Boolean(gap))),
|
|
41
49
|
approvals: input.approvals ?? [],
|
|
42
50
|
updatedAt: input.updatedAt || MIGRATED_UPDATED_AT,
|
|
43
51
|
}
|
|
@@ -71,9 +79,11 @@ function migrateDeckNarrative(deck: DeckSpec | undefined, seed: string): Narrati
|
|
|
71
79
|
},
|
|
72
80
|
thesis: migrateThesis(deck),
|
|
73
81
|
claims: withEvidenceStatus,
|
|
82
|
+
claimRelations: [],
|
|
74
83
|
evidenceBindings,
|
|
75
84
|
objections: (brief?.objections ?? []).map((text) => ({ id: stableObjectionId(text), text, priority: "medium" as const })),
|
|
76
85
|
risks: (brief?.risks ?? []).map((text) => ({ id: stableRiskId(text), text, severity: "medium" as const })),
|
|
86
|
+
researchGaps: [],
|
|
77
87
|
approvals: [],
|
|
78
88
|
updatedAt: MIGRATED_UPDATED_AT,
|
|
79
89
|
}
|
|
@@ -206,6 +216,26 @@ function normalizeClaim(input: Partial<NarrativeClaim>): NarrativeClaim | undefi
|
|
|
206
216
|
}
|
|
207
217
|
}
|
|
208
218
|
|
|
219
|
+
function normalizeClaimRelation(input: Partial<NarrativeClaimRelation>, claims: NarrativeClaim[]): NarrativeClaimRelation | undefined {
|
|
220
|
+
const fromClaimId = clean(input.fromClaimId)
|
|
221
|
+
const toClaimId = clean(input.toClaimId)
|
|
222
|
+
if (!fromClaimId || !toClaimId || fromClaimId === toClaimId) return undefined
|
|
223
|
+
if (!claims.some((claim) => claim.id === fromClaimId) || !claims.some((claim) => claim.id === toClaimId)) return undefined
|
|
224
|
+
const relation = normalizeClaimRelationType(input.relation)
|
|
225
|
+
return {
|
|
226
|
+
id: input.id?.trim() || stableClaimRelationId(fromClaimId, toClaimId, relation),
|
|
227
|
+
fromClaimId,
|
|
228
|
+
toClaimId,
|
|
229
|
+
relation,
|
|
230
|
+
rationale: clean(input.rationale),
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function normalizeClaimRelationType(input: NarrativeClaimRelationType | undefined): NarrativeClaimRelationType {
|
|
235
|
+
if (input === "supports" || input === "depends_on" || input === "contrasts_with" || input === "constrains" || input === "answers") return input
|
|
236
|
+
return "leads_to"
|
|
237
|
+
}
|
|
238
|
+
|
|
209
239
|
function normalizeEvidenceBinding(input: Partial<NarrativeEvidenceBinding>, claims: NarrativeClaim[]): NarrativeEvidenceBinding | undefined {
|
|
210
240
|
const source = clean(input.source || input.sourcePath || input.findingsFile || input.url)
|
|
211
241
|
const claimId = clean(input.claimId)
|
|
@@ -239,6 +269,30 @@ function normalizeRisk(input: Partial<NarrativeRisk>): NarrativeRisk | undefined
|
|
|
239
269
|
return { id: input.id?.trim() || stableRiskId(text), text, claimId: clean(input.claimId), severity: input.severity ?? "medium", mitigation: clean(input.mitigation) }
|
|
240
270
|
}
|
|
241
271
|
|
|
272
|
+
function normalizeResearchGap(input: Partial<NarrativeResearchGap>): NarrativeResearchGap | undefined {
|
|
273
|
+
const question = clean(input.question)
|
|
274
|
+
if (!question) return undefined
|
|
275
|
+
const targetType = input.targetType ?? "narrative"
|
|
276
|
+
const targetId = clean(input.targetId)
|
|
277
|
+
const now = clean(input.updatedAt) || clean(input.createdAt) || MIGRATED_UPDATED_AT
|
|
278
|
+
const status = input.status ?? "open"
|
|
279
|
+
return {
|
|
280
|
+
id: input.id?.trim() || stableResearchGapId([targetType, targetId, question].filter(Boolean).join("|")),
|
|
281
|
+
targetType,
|
|
282
|
+
targetId,
|
|
283
|
+
question,
|
|
284
|
+
status,
|
|
285
|
+
priority: input.priority ?? "medium",
|
|
286
|
+
findingsFile: clean(input.findingsFile),
|
|
287
|
+
evidenceBindingIds: (input.evidenceBindingIds ?? []).map(clean).filter(Boolean),
|
|
288
|
+
createdFromIssueType: input.createdFromIssueType,
|
|
289
|
+
notes: clean(input.notes),
|
|
290
|
+
createdAt: clean(input.createdAt) || now,
|
|
291
|
+
updatedAt: now,
|
|
292
|
+
closedAt: status === "closed" ? clean(input.closedAt) || now : clean(input.closedAt),
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
242
296
|
function evidenceStatusForClaim(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): NarrativeEvidenceStatus {
|
|
243
297
|
if (!claim.evidenceRequired) return "not_required"
|
|
244
298
|
const claimBindings = bindings.filter((binding) => binding.claimId === claim.id)
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import type { DeckSpec, DecksState, EvidenceRef, SlideClaimRefRole, SlideSpec } from "../decks-state"
|
|
2
|
+
import { projectWorkspaceGraph } from "../workspace-state/graph"
|
|
3
|
+
import { artifactNodeIdForRenderTarget } from "../workspace-state/render-targets"
|
|
4
|
+
import type { GraphEdge, RenderTarget } from "../workspace-state/types"
|
|
5
|
+
import { computeNarrativeHash } from "./hash"
|
|
6
|
+
import { normalizeNarrativeState } from "./normalize"
|
|
7
|
+
import type { NarrativeClaim, NarrativeEvidenceBinding, NarrativeStateV1 } from "./types"
|
|
8
|
+
|
|
9
|
+
export interface ClaimEvidenceBoard {
|
|
10
|
+
version: 1
|
|
11
|
+
claims: Record<NarrativeClaim["evidenceStatus"], ClaimEvidenceRecord[]>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ClaimEvidenceRecord {
|
|
15
|
+
id: string
|
|
16
|
+
text: string
|
|
17
|
+
kind: NarrativeClaim["kind"]
|
|
18
|
+
importance: NarrativeClaim["importance"]
|
|
19
|
+
evidenceRequired: boolean
|
|
20
|
+
evidenceStatus: NarrativeClaim["evidenceStatus"]
|
|
21
|
+
supportedScope?: string
|
|
22
|
+
unsupportedScope?: string
|
|
23
|
+
caveats: string[]
|
|
24
|
+
evidence: ClaimEvidenceBindingRecord[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ClaimEvidenceBindingRecord {
|
|
28
|
+
id: string
|
|
29
|
+
claimId: string
|
|
30
|
+
source: string
|
|
31
|
+
findingsFile?: string
|
|
32
|
+
sourcePath?: string
|
|
33
|
+
quote?: string
|
|
34
|
+
location?: string
|
|
35
|
+
url?: string
|
|
36
|
+
caveat?: string
|
|
37
|
+
supportScope?: string
|
|
38
|
+
unsupportedScope?: string
|
|
39
|
+
strength: NarrativeEvidenceBinding["strength"]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SourceClaimIndexRecord {
|
|
43
|
+
sourceKey: string
|
|
44
|
+
source: string
|
|
45
|
+
findingsFile?: string
|
|
46
|
+
sourcePath?: string
|
|
47
|
+
url?: string
|
|
48
|
+
claims: Array<{
|
|
49
|
+
claimId: string
|
|
50
|
+
claimText: string
|
|
51
|
+
evidenceId: string
|
|
52
|
+
strength: NarrativeEvidenceBinding["strength"]
|
|
53
|
+
supportScope?: string
|
|
54
|
+
unsupportedScope?: string
|
|
55
|
+
caveat?: string
|
|
56
|
+
}>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ObjectionRiskClaimIndex {
|
|
60
|
+
objections: Array<{
|
|
61
|
+
id: string
|
|
62
|
+
text: string
|
|
63
|
+
claimId?: string
|
|
64
|
+
claimText?: string
|
|
65
|
+
priority: "high" | "medium" | "low"
|
|
66
|
+
response?: string
|
|
67
|
+
}>
|
|
68
|
+
risks: Array<{
|
|
69
|
+
id: string
|
|
70
|
+
text: string
|
|
71
|
+
claimId?: string
|
|
72
|
+
claimText?: string
|
|
73
|
+
severity: "high" | "medium" | "low"
|
|
74
|
+
mitigation?: string
|
|
75
|
+
}>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ArtifactClaimRef {
|
|
79
|
+
artifactId: string
|
|
80
|
+
type: RenderTarget["type"]
|
|
81
|
+
outputPath?: string
|
|
82
|
+
contractStatus?: RenderTarget["contractStatus"]
|
|
83
|
+
sourceNodeIds: string[]
|
|
84
|
+
claimIds: string[]
|
|
85
|
+
narrativeIds: string[]
|
|
86
|
+
slideRefs: ClaimSlideRef[]
|
|
87
|
+
coverageStatus: "current" | "stale" | "partial" | "missing"
|
|
88
|
+
affectedClaimIds: string[]
|
|
89
|
+
missingClaimIds: string[]
|
|
90
|
+
staleReasons: string[]
|
|
91
|
+
stale: boolean
|
|
92
|
+
staleReason?: string
|
|
93
|
+
note?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface ClaimSlideRef {
|
|
97
|
+
claimId: string
|
|
98
|
+
claimText: string
|
|
99
|
+
slideIndex: number
|
|
100
|
+
slideTitle: string
|
|
101
|
+
match: "content" | "evidence" | "metadata"
|
|
102
|
+
role: SlideClaimRefRole
|
|
103
|
+
location: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function getClaimEvidenceBoard(state: DecksState): ClaimEvidenceBoard {
|
|
107
|
+
const narrative = canonicalNarrative(state)
|
|
108
|
+
const evidenceByClaim = groupEvidenceByClaim(narrative.evidenceBindings)
|
|
109
|
+
const claims: ClaimEvidenceBoard["claims"] = {
|
|
110
|
+
supported: [],
|
|
111
|
+
partial: [],
|
|
112
|
+
weak: [],
|
|
113
|
+
missing: [],
|
|
114
|
+
not_required: [],
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const claim of narrative.claims) {
|
|
118
|
+
claims[claim.evidenceStatus].push({
|
|
119
|
+
id: claim.id,
|
|
120
|
+
text: claim.text,
|
|
121
|
+
kind: claim.kind,
|
|
122
|
+
importance: claim.importance,
|
|
123
|
+
evidenceRequired: claim.evidenceRequired,
|
|
124
|
+
evidenceStatus: claim.evidenceStatus,
|
|
125
|
+
supportedScope: claim.supportedScope,
|
|
126
|
+
unsupportedScope: claim.unsupportedScope,
|
|
127
|
+
caveats: claim.caveats ?? [],
|
|
128
|
+
evidence: evidenceByClaim.get(claim.id) ?? [],
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const group of Object.values(claims)) group.sort((a, b) => claimSortValue(a) - claimSortValue(b) || a.text.localeCompare(b.text))
|
|
133
|
+
return { version: 1, claims }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function getSourceClaimIndex(state: DecksState): SourceClaimIndexRecord[] {
|
|
137
|
+
const narrative = canonicalNarrative(state)
|
|
138
|
+
const claimTextById = new Map(narrative.claims.map((claim) => [claim.id, claim.text]))
|
|
139
|
+
const grouped = new Map<string, SourceClaimIndexRecord>()
|
|
140
|
+
|
|
141
|
+
for (const binding of narrative.evidenceBindings) {
|
|
142
|
+
const sourceKey = binding.findingsFile || binding.sourcePath || binding.url || binding.source
|
|
143
|
+
if (!sourceKey) continue
|
|
144
|
+
const existing = grouped.get(sourceKey) ?? {
|
|
145
|
+
sourceKey,
|
|
146
|
+
source: binding.source,
|
|
147
|
+
findingsFile: binding.findingsFile,
|
|
148
|
+
sourcePath: binding.sourcePath,
|
|
149
|
+
url: binding.url,
|
|
150
|
+
claims: [],
|
|
151
|
+
}
|
|
152
|
+
existing.claims.push({
|
|
153
|
+
claimId: binding.claimId,
|
|
154
|
+
claimText: claimTextById.get(binding.claimId) ?? binding.claimId,
|
|
155
|
+
evidenceId: binding.id,
|
|
156
|
+
strength: binding.strength,
|
|
157
|
+
supportScope: binding.supportScope,
|
|
158
|
+
unsupportedScope: binding.unsupportedScope,
|
|
159
|
+
caveat: binding.caveat,
|
|
160
|
+
})
|
|
161
|
+
grouped.set(sourceKey, existing)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return [...grouped.values()]
|
|
165
|
+
.map((item) => ({ ...item, claims: item.claims.sort((a, b) => a.claimText.localeCompare(b.claimText)) }))
|
|
166
|
+
.sort((a, b) => a.sourceKey.localeCompare(b.sourceKey))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function getObjectionRiskClaimIndex(state: DecksState): ObjectionRiskClaimIndex {
|
|
170
|
+
const narrative = canonicalNarrative(state)
|
|
171
|
+
const claimTextById = new Map(narrative.claims.map((claim) => [claim.id, claim.text]))
|
|
172
|
+
return {
|
|
173
|
+
objections: narrative.objections.map((objection) => ({
|
|
174
|
+
id: objection.id,
|
|
175
|
+
text: objection.text,
|
|
176
|
+
claimId: objection.claimId,
|
|
177
|
+
claimText: objection.claimId ? claimTextById.get(objection.claimId) : undefined,
|
|
178
|
+
priority: objection.priority,
|
|
179
|
+
response: objection.response,
|
|
180
|
+
})),
|
|
181
|
+
risks: narrative.risks.map((risk) => ({
|
|
182
|
+
id: risk.id,
|
|
183
|
+
text: risk.text,
|
|
184
|
+
claimId: risk.claimId,
|
|
185
|
+
claimText: risk.claimId ? claimTextById.get(risk.claimId) : undefined,
|
|
186
|
+
severity: risk.severity,
|
|
187
|
+
mitigation: risk.mitigation,
|
|
188
|
+
})),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function getArtifactClaimRefs(state: DecksState): ArtifactClaimRef[] {
|
|
193
|
+
const narrative = canonicalNarrative(state)
|
|
194
|
+
const narrativeHash = computeNarrativeHash(narrative)
|
|
195
|
+
const deck = maybeActiveDeck(state)
|
|
196
|
+
const slideRefs = deck ? getClaimSlideRefs({ ...state, narrative }, deck) : []
|
|
197
|
+
const slideClaimIds = [...new Set(slideRefs.map((ref) => ref.claimId))].sort()
|
|
198
|
+
const rendersFromByArtifact = deck ? rendersFromIndex(projectWorkspaceGraph({ ...state, narrative }).edges) : new Map<string, string[]>()
|
|
199
|
+
const claimIds = new Set(narrative.claims.map((claim) => claim.id))
|
|
200
|
+
const requiredClaimIds = narrative.claims
|
|
201
|
+
.filter((claim) => claim.importance === "central" || claim.evidenceRequired)
|
|
202
|
+
.map((claim) => claim.id)
|
|
203
|
+
const narrativeIds = new Set([narrative.id])
|
|
204
|
+
const htmlCoverageByPath = new Map<string, ClaimSlideRef[]>()
|
|
205
|
+
|
|
206
|
+
return (state.renderTargets ?? [])
|
|
207
|
+
.map((target) => {
|
|
208
|
+
const artifactId = artifactNodeIdForRenderTarget(target)
|
|
209
|
+
const rendersFrom = rendersFromByArtifact.get(artifactId) ?? []
|
|
210
|
+
const targetSlideRefs = claimSlideRefsForTarget(target, slideRefs, htmlCoverageByPath)
|
|
211
|
+
const artifactClaimIds = [...new Set([...target.sourceNodeIds, ...rendersFrom, ...targetSlideRefs.map((ref) => ref.claimId), ...slideClaimIds].filter((id) => claimIds.has(id)))].sort()
|
|
212
|
+
const artifactNarrativeIds = [...new Set([...target.sourceNodeIds, ...rendersFrom].filter((id) => narrativeIds.has(id)))].sort()
|
|
213
|
+
const missingClaimIds = requiredClaimIds.filter((id) => !artifactClaimIds.includes(id)).sort()
|
|
214
|
+
const coverage = coverageState(target, narrativeHash, artifactClaimIds, missingClaimIds, artifactNarrativeIds)
|
|
215
|
+
if (target.type === "html_deck" && target.outputPath) htmlCoverageByPath.set(target.outputPath, targetSlideRefs)
|
|
216
|
+
return {
|
|
217
|
+
artifactId,
|
|
218
|
+
type: target.type,
|
|
219
|
+
outputPath: target.outputPath,
|
|
220
|
+
contractStatus: target.contractStatus,
|
|
221
|
+
sourceNodeIds: target.sourceNodeIds ?? [],
|
|
222
|
+
claimIds: artifactClaimIds,
|
|
223
|
+
narrativeIds: artifactNarrativeIds,
|
|
224
|
+
slideRefs: targetSlideRefs,
|
|
225
|
+
coverageStatus: coverage.status,
|
|
226
|
+
affectedClaimIds: coverage.affectedClaimIds,
|
|
227
|
+
missingClaimIds,
|
|
228
|
+
staleReasons: coverage.reasons,
|
|
229
|
+
stale: coverage.status === "stale",
|
|
230
|
+
staleReason: coverage.reasons[0],
|
|
231
|
+
note: coverage.note,
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
.sort((a, b) => artifactSortValue(a.type) - artifactSortValue(b.type) || (a.outputPath ?? a.artifactId).localeCompare(b.outputPath ?? b.artifactId))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function getClaimSlideRefs(state: DecksState, deck: DeckSpec = activeDeck(state)): ClaimSlideRef[] {
|
|
238
|
+
const narrative = canonicalNarrative(state)
|
|
239
|
+
const bindingsByClaim = groupRawEvidenceByClaim(narrative.evidenceBindings)
|
|
240
|
+
const refs: ClaimSlideRef[] = []
|
|
241
|
+
const seen = new Set<string>()
|
|
242
|
+
for (const claim of narrative.claims) {
|
|
243
|
+
for (const slide of deck.slides ?? []) {
|
|
244
|
+
const explicitRef = slide.claimRefs?.find((ref) => ref.claimId === claim.id)
|
|
245
|
+
if (explicitRef) {
|
|
246
|
+
pushSlideRef(refs, seen, claim, slide, "metadata", `claimRefs:${explicitRef.role}`, explicitRef.role)
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
if (slide.claimIds?.includes(claim.id)) {
|
|
250
|
+
pushSlideRef(refs, seen, claim, slide, "metadata", "claimIds", "primary")
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
if (slideEvidenceBindingIdsMatch(slide.evidenceBindingIds ?? [], bindingsByClaim.get(claim.id) ?? [])) {
|
|
254
|
+
pushSlideRef(refs, seen, claim, slide, "metadata", "evidenceBindingIds", "evidence")
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
const contentMatch = slideContentMatch(slide, claim.text)
|
|
258
|
+
if (contentMatch) pushSlideRef(refs, seen, claim, slide, "content", contentMatch, "primary")
|
|
259
|
+
else if (slideEvidenceMatches(slide.evidence ?? [], bindingsByClaim.get(claim.id) ?? [])) pushSlideRef(refs, seen, claim, slide, "evidence", "evidence", "evidence")
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return refs.sort((a, b) => a.slideIndex - b.slideIndex || a.claimText.localeCompare(b.claimText) || a.location.localeCompare(b.location))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function canonicalNarrative(state: DecksState): NarrativeStateV1 {
|
|
266
|
+
return state.narrative ?? normalizeNarrativeState(state)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function activeDeck(state: DecksState): DeckSpec {
|
|
270
|
+
const key = state.activeDeck || (Object.keys(state.decks).length === 1 ? Object.keys(state.decks)[0] : undefined)
|
|
271
|
+
if (!key || !state.decks[key]) throw new Error("No active deck is available for narrative artifact coverage.")
|
|
272
|
+
return state.decks[key]
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function maybeActiveDeck(state: DecksState): DeckSpec | undefined {
|
|
276
|
+
const key = state.activeDeck || (Object.keys(state.decks).length === 1 ? Object.keys(state.decks)[0] : undefined)
|
|
277
|
+
return key ? state.decks[key] : undefined
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function groupEvidenceByClaim(bindings: NarrativeEvidenceBinding[]): Map<string, ClaimEvidenceBindingRecord[]> {
|
|
281
|
+
const grouped = new Map<string, ClaimEvidenceBindingRecord[]>()
|
|
282
|
+
for (const binding of bindings) {
|
|
283
|
+
grouped.set(binding.claimId, [...(grouped.get(binding.claimId) ?? []), {
|
|
284
|
+
id: binding.id,
|
|
285
|
+
claimId: binding.claimId,
|
|
286
|
+
source: binding.source,
|
|
287
|
+
findingsFile: binding.findingsFile,
|
|
288
|
+
sourcePath: binding.sourcePath,
|
|
289
|
+
quote: binding.quote,
|
|
290
|
+
location: binding.location,
|
|
291
|
+
url: binding.url,
|
|
292
|
+
caveat: binding.caveat,
|
|
293
|
+
supportScope: binding.supportScope,
|
|
294
|
+
unsupportedScope: binding.unsupportedScope,
|
|
295
|
+
strength: binding.strength,
|
|
296
|
+
}])
|
|
297
|
+
}
|
|
298
|
+
return grouped
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function groupRawEvidenceByClaim(bindings: NarrativeEvidenceBinding[]): Map<string, NarrativeEvidenceBinding[]> {
|
|
302
|
+
const grouped = new Map<string, NarrativeEvidenceBinding[]>()
|
|
303
|
+
for (const binding of bindings) grouped.set(binding.claimId, [...(grouped.get(binding.claimId) ?? []), binding])
|
|
304
|
+
return grouped
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function slideContentMatch(slide: SlideSpec, claimText: string): string | undefined {
|
|
308
|
+
const claim = normalizeText(claimText)
|
|
309
|
+
if (!claim) return undefined
|
|
310
|
+
const candidates: Array<[string, string | undefined]> = [
|
|
311
|
+
["title", slide.title],
|
|
312
|
+
["purpose", slide.purpose],
|
|
313
|
+
["headline", slide.content?.headline],
|
|
314
|
+
["speakerNotes", slide.content?.speakerNotes],
|
|
315
|
+
...((slide.content?.body ?? []).map((item, index) => [`body:${index + 1}`, item] as [string, string])),
|
|
316
|
+
...((slide.content?.bullets ?? []).map((item, index) => [`bullet:${index + 1}`, item] as [string, string])),
|
|
317
|
+
]
|
|
318
|
+
for (const [location, value] of candidates) {
|
|
319
|
+
const normalized = normalizeText(value)
|
|
320
|
+
if (!normalized) continue
|
|
321
|
+
if (normalized === claim || normalized.includes(claim) || claim.includes(normalized)) return location
|
|
322
|
+
}
|
|
323
|
+
return undefined
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function slideEvidenceMatches(evidence: EvidenceRef[], bindings: NarrativeEvidenceBinding[]): boolean {
|
|
327
|
+
return evidence.some((item) => bindings.some((binding) => evidenceMatchesBinding(item, binding)))
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function slideEvidenceBindingIdsMatch(ids: string[], bindings: NarrativeEvidenceBinding[]): boolean {
|
|
331
|
+
const slideIds = new Set(ids)
|
|
332
|
+
return bindings.some((binding) => slideIds.has(binding.id))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function evidenceMatchesBinding(evidence: EvidenceRef, binding: NarrativeEvidenceBinding): boolean {
|
|
336
|
+
return Boolean(
|
|
337
|
+
evidence.findingsFile && evidence.findingsFile === binding.findingsFile ||
|
|
338
|
+
evidence.sourcePath && evidence.sourcePath === binding.sourcePath ||
|
|
339
|
+
evidence.url && evidence.url === binding.url ||
|
|
340
|
+
evidence.quote && binding.quote && normalizeText(evidence.quote) === normalizeText(binding.quote) ||
|
|
341
|
+
evidence.source && normalizeText(evidence.source) === normalizeText(binding.source),
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function pushSlideRef(refs: ClaimSlideRef[], seen: Set<string>, claim: NarrativeClaim, slide: SlideSpec, match: ClaimSlideRef["match"], location: string, role: SlideClaimRefRole): void {
|
|
346
|
+
const key = `${claim.id}:${slide.index}:${match}:${location}:${role}`
|
|
347
|
+
if (seen.has(key)) return
|
|
348
|
+
seen.add(key)
|
|
349
|
+
refs.push({ claimId: claim.id, claimText: claim.text, slideIndex: slide.index, slideTitle: slide.title, match, role, location })
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function claimSlideRefsForTarget(target: RenderTarget, currentHtmlRefs: ClaimSlideRef[], htmlCoverageByPath: Map<string, ClaimSlideRef[]>): ClaimSlideRef[] {
|
|
353
|
+
const stored = parseStoredClaimSlideRefs(target)
|
|
354
|
+
if (stored.length > 0) return stored
|
|
355
|
+
if (target.type === "brief" || target.type === "executive_brief") return []
|
|
356
|
+
if (target.type === "html_deck") return currentHtmlRefs
|
|
357
|
+
const sourceOutputPath = typeof target.data?.sourceOutputPath === "string" ? target.data.sourceOutputPath : undefined
|
|
358
|
+
if (sourceOutputPath) return htmlCoverageByPath.get(sourceOutputPath) ?? currentHtmlRefs
|
|
359
|
+
return currentHtmlRefs
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function parseStoredClaimSlideRefs(target: RenderTarget): ClaimSlideRef[] {
|
|
363
|
+
const value = target.data?.claimSlideRefs
|
|
364
|
+
if (!Array.isArray(value)) return []
|
|
365
|
+
return value.flatMap((item): ClaimSlideRef[] => {
|
|
366
|
+
if (!item || typeof item !== "object") return []
|
|
367
|
+
const record = item as Partial<ClaimSlideRef>
|
|
368
|
+
if (!record.claimId || !record.claimText || typeof record.slideIndex !== "number" || !record.slideTitle) return []
|
|
369
|
+
return [{
|
|
370
|
+
claimId: record.claimId,
|
|
371
|
+
claimText: record.claimText,
|
|
372
|
+
slideIndex: record.slideIndex,
|
|
373
|
+
slideTitle: record.slideTitle,
|
|
374
|
+
match: record.match ?? "metadata",
|
|
375
|
+
role: record.role ?? "supporting",
|
|
376
|
+
location: record.location ?? "metadata",
|
|
377
|
+
}]
|
|
378
|
+
}).sort((a, b) => a.slideIndex - b.slideIndex || a.claimText.localeCompare(b.claimText))
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function coverageState(
|
|
382
|
+
target: RenderTarget,
|
|
383
|
+
narrativeHash: string,
|
|
384
|
+
artifactClaimIds: string[],
|
|
385
|
+
missingClaimIds: string[],
|
|
386
|
+
artifactNarrativeIds: string[],
|
|
387
|
+
): { status: ArtifactClaimRef["coverageStatus"]; reasons: string[]; affectedClaimIds: string[]; note?: string } {
|
|
388
|
+
const targetHash = typeof target.data?.narrativeHash === "string" ? target.data.narrativeHash : undefined
|
|
389
|
+
const reasons: string[] = []
|
|
390
|
+
if (targetHash && targetHash !== narrativeHash) reasons.push("Narrative hash changed after this artifact coverage was recorded.")
|
|
391
|
+
if (!targetHash) reasons.push("Artifact does not record the narrative hash used for coverage.")
|
|
392
|
+
if (artifactClaimIds.length === 0) reasons.push("No claim-to-slide coverage is recorded or inferred for this artifact.")
|
|
393
|
+
if (missingClaimIds.length > 0) reasons.push(`Artifact does not cover ${missingClaimIds.length} central or evidence-required claim${missingClaimIds.length === 1 ? "" : "s"}.`)
|
|
394
|
+
if (artifactNarrativeIds.length === 0 && target.type !== "pdf" && target.type !== "pptx") reasons.push("Artifact is not linked to the current canonical narrative.")
|
|
395
|
+
|
|
396
|
+
if (targetHash && targetHash !== narrativeHash) {
|
|
397
|
+
return {
|
|
398
|
+
status: "stale",
|
|
399
|
+
reasons,
|
|
400
|
+
affectedClaimIds: artifactClaimIds.length > 0 ? artifactClaimIds : missingClaimIds,
|
|
401
|
+
note: undefined,
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (artifactClaimIds.length === 0) return { status: "missing", reasons, affectedClaimIds: [], note: reasons[0] }
|
|
405
|
+
if (!targetHash || missingClaimIds.length > 0) return { status: "partial", reasons, affectedClaimIds: missingClaimIds, note: reasons[0] }
|
|
406
|
+
return { status: "current", reasons: [], affectedClaimIds: [], note: undefined }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function rendersFromIndex(edges: GraphEdge[]): Map<string, string[]> {
|
|
410
|
+
const grouped = new Map<string, string[]>()
|
|
411
|
+
for (const edge of edges) {
|
|
412
|
+
if (edge.type !== "renders_from" || !edge.from.startsWith("artifact:")) continue
|
|
413
|
+
grouped.set(edge.from, [...(grouped.get(edge.from) ?? []), edge.to])
|
|
414
|
+
}
|
|
415
|
+
return grouped
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function claimSortValue(claim: Pick<ClaimEvidenceRecord, "importance">): number {
|
|
419
|
+
if (claim.importance === "central") return 0
|
|
420
|
+
if (claim.importance === "supporting") return 1
|
|
421
|
+
return 2
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function artifactSortValue(type: RenderTarget["type"]): number {
|
|
425
|
+
if (type === "html_deck") return 0
|
|
426
|
+
if (type === "pdf") return 1
|
|
427
|
+
if (type === "pptx") return 2
|
|
428
|
+
if (type === "executive_brief" || type === "brief") return 3
|
|
429
|
+
return 3
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function normalizeText(value: string | undefined): string {
|
|
433
|
+
return value?.replace(/\s+/g, " ").trim().toLowerCase() ?? ""
|
|
434
|
+
}
|
|
@@ -150,6 +150,8 @@ function computeNarrativeReadiness(narrative: NarrativeStateV1, state: DecksStat
|
|
|
150
150
|
const centralClaims = narrative.claims.filter((claim) => claim.importance === "central")
|
|
151
151
|
if (isDecisionOriented(narrative) && centralClaims.length === 0) add(blocker("claim_chain_gap", "Decision-oriented narrative has no central claims.", "Add one to three central claims that the narrative must prove."))
|
|
152
152
|
if (centralClaims.length > 4) add(warning("claim_chain_gap", "Narrative has many central claims.", "Tighten the claim chain to the few claims the audience must believe."))
|
|
153
|
+
if (needsClaimRelations(narrative)) add(warning("claim_chain_gap", "Narrative claim progression is not explicit.", "Add claimRelations so the narrative flow shows how claims lead to, support, constrain, or answer each other."))
|
|
154
|
+
for (const relationIssue of claimRelationIssues(narrative)) add(relationIssue)
|
|
153
155
|
|
|
154
156
|
for (const claim of narrative.claims) {
|
|
155
157
|
if (!claim.evidenceRequired) continue
|
|
@@ -191,7 +193,8 @@ function computeNarrativeReadiness(narrative: NarrativeStateV1, state: DecksStat
|
|
|
191
193
|
const path = typeof action.outputs?.path === "string" ? action.outputs.path : undefined
|
|
192
194
|
if (!path) continue
|
|
193
195
|
const attached = Object.values(state.decks ?? {}).some((deck) => deck.researchPlan.some((axis) => axis.findingsFile === path))
|
|
194
|
-
|
|
196
|
+
const boundToNarrative = narrative.evidenceBindings.some((binding) => binding.findingsFile === path)
|
|
197
|
+
if (!attached && !boundToNarrative && !isVisualOrMediaFindings(action.inputs?.axis, path)) add({
|
|
195
198
|
type: "research_findings_unattached",
|
|
196
199
|
severity: "warning",
|
|
197
200
|
message: `Research findings are saved but not attached: ${path}`,
|
|
@@ -200,6 +203,20 @@ function computeNarrativeReadiness(narrative: NarrativeStateV1, state: DecksStat
|
|
|
200
203
|
})
|
|
201
204
|
}
|
|
202
205
|
|
|
206
|
+
for (const gap of narrative.researchGaps ?? []) {
|
|
207
|
+
if (gap.status === "closed" || gap.status === "evidence_bound") continue
|
|
208
|
+
add({
|
|
209
|
+
type: "research_gap_open",
|
|
210
|
+
severity: "warning",
|
|
211
|
+
message: `Research gap is ${gap.status}: ${gap.question}`,
|
|
212
|
+
suggestedAction: gap.status === "open" || gap.status === "in_progress"
|
|
213
|
+
? "Save findings, attach them to the research plan, and bind evidence before closing the gap."
|
|
214
|
+
: "Bind specific evidence from the findings before treating the gap as resolved.",
|
|
215
|
+
claimId: gap.targetType === "claim" ? gap.targetId : undefined,
|
|
216
|
+
source: gap.findingsFile,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
203
220
|
const approval = approvalState(narrative, narrativeHash)
|
|
204
221
|
if (!approval.current) add({
|
|
205
222
|
type: approval.stale ? "approval_stale" : "approval_missing",
|
|
@@ -243,6 +260,11 @@ function approvalState(narrative: NarrativeStateV1, narrativeHash: string): Narr
|
|
|
243
260
|
return { current: Boolean(latest && latest.narrativeHash === narrativeHash), stale: Boolean(latest && latest.narrativeHash !== narrativeHash), latest }
|
|
244
261
|
}
|
|
245
262
|
|
|
263
|
+
function isVisualOrMediaFindings(axis: unknown, path: string): boolean {
|
|
264
|
+
const value = `${typeof axis === "string" ? axis : ""} ${path}`.toLowerCase()
|
|
265
|
+
return /(^|[-_/\s])(image|images|media|asset|assets|visual|visuals|logo|logos|screenshot|screenshots)([-_/\s.]|$)/.test(value)
|
|
266
|
+
}
|
|
267
|
+
|
|
246
268
|
function nextActions(issues: NarrativeReadinessIssue[], approvalCurrent: boolean): string[] {
|
|
247
269
|
const blockers = issues.filter((issue) => issue.severity === "blocker")
|
|
248
270
|
if (blockers.length > 0) return unique(blockers.map((issue) => issue.suggestedAction))
|
|
@@ -259,6 +281,54 @@ function hasRecommendation(narrative: NarrativeStateV1): boolean {
|
|
|
259
281
|
return narrative.claims.some((claim) => claim.kind === "recommendation" || claim.kind === "ask") || /recommend|approve|invest|prioriti[sz]e|建议|批准|投资|优先/i.test(narrative.decision.action)
|
|
260
282
|
}
|
|
261
283
|
|
|
284
|
+
function needsClaimRelations(narrative: NarrativeStateV1): boolean {
|
|
285
|
+
if (!isDecisionOriented(narrative)) return false
|
|
286
|
+
if ((narrative.claimRelations ?? []).length > 0) return false
|
|
287
|
+
const flowClaims = narrative.claims.filter((claim) => claim.importance === "central" || claim.kind === "recommendation" || claim.kind === "ask")
|
|
288
|
+
return flowClaims.length > 1
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function claimRelationIssues(narrative: NarrativeStateV1): NarrativeReadinessIssue[] {
|
|
292
|
+
const issues: NarrativeReadinessIssue[] = []
|
|
293
|
+
const claimsById = new Map(narrative.claims.map((claim) => [claim.id, claim]))
|
|
294
|
+
for (const relation of narrative.claimRelations ?? []) {
|
|
295
|
+
const fromClaim = claimsById.get(relation.fromClaimId)
|
|
296
|
+
const toClaim = claimsById.get(relation.toClaimId)
|
|
297
|
+
if (!relation.rationale?.trim()) {
|
|
298
|
+
issues.push({
|
|
299
|
+
type: "claim_chain_gap",
|
|
300
|
+
severity: "warning",
|
|
301
|
+
message: "Claim relation lacks objective causal rationale.",
|
|
302
|
+
suggestedAction: "Add a factual causal bridge explaining what the source claim establishes and why the target claim follows within the evidence boundary.",
|
|
303
|
+
claimId: relation.toClaimId,
|
|
304
|
+
claimText: toClaim?.text,
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
if (relationMayOverextendEvidence(relation.relation) && fromClaim && toClaim && weakSourceForCausalBridge(fromClaim, toClaim)) {
|
|
308
|
+
issues.push({
|
|
309
|
+
type: "claim_chain_gap",
|
|
310
|
+
severity: "warning",
|
|
311
|
+
message: "Claim relation may overextend the source claim's evidence boundary.",
|
|
312
|
+
suggestedAction: "Narrow the target claim, add stronger evidence, or rewrite the relation rationale so it objectively reflects the supported scope.",
|
|
313
|
+
claimId: relation.toClaimId,
|
|
314
|
+
claimText: toClaim?.text,
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return issues
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function relationMayOverextendEvidence(relation: string): boolean {
|
|
322
|
+
return relation === "supports" || relation === "leads_to" || relation === "depends_on"
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function weakSourceForCausalBridge(fromClaim: NarrativeClaim, toClaim: NarrativeClaim): boolean {
|
|
326
|
+
if (!fromClaim.evidenceRequired) return false
|
|
327
|
+
const targetNeedsObjectiveBridge = toClaim.importance === "central" || toClaim.kind === "recommendation" || toClaim.kind === "ask"
|
|
328
|
+
if (!targetNeedsObjectiveBridge) return false
|
|
329
|
+
return fromClaim.evidenceStatus === "missing" || fromClaim.evidenceStatus === "weak" || fromClaim.evidenceStatus === "partial" || Boolean(fromClaim.unsupportedScope)
|
|
330
|
+
}
|
|
331
|
+
|
|
262
332
|
function hasRiskHandling(narrative: NarrativeStateV1): boolean {
|
|
263
333
|
return narrative.risks.length > 0 || narrative.claims.some((claim) => claim.kind === "risk" || claim.kind === "assumption" || claim.caveats?.length || claim.unsupportedScope) || narrative.evidenceBindings.some((binding) => binding.caveat || binding.unsupportedScope)
|
|
264
334
|
}
|