@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.
Files changed (37) hide show
  1. package/README.md +16 -16
  2. package/README.zh-CN.md +16 -16
  3. package/lib/commands/brief.ts +63 -0
  4. package/lib/commands/edit.ts +7 -5
  5. package/lib/commands/help.ts +5 -3
  6. package/lib/commands/inspect.ts +7 -5
  7. package/lib/commands/narrative.ts +160 -0
  8. package/lib/decks-state.ts +33 -0
  9. package/lib/edit/prompt.ts +3 -0
  10. package/lib/inspect/prompt.ts +15 -2
  11. package/lib/inspect/requests.ts +21 -2
  12. package/lib/inspection-context/compile.ts +230 -10
  13. package/lib/inspection-context/match.ts +71 -1
  14. package/lib/inspection-context/project.ts +131 -8
  15. package/lib/inspection-context/result.ts +183 -0
  16. package/lib/narrative-state/coverage.ts +100 -0
  17. package/lib/narrative-state/display.ts +219 -0
  18. package/lib/narrative-state/executive-brief.ts +246 -0
  19. package/lib/narrative-state/hash.ts +9 -0
  20. package/lib/narrative-state/map-html.ts +348 -0
  21. package/lib/narrative-state/map.ts +282 -0
  22. package/lib/narrative-state/normalize.ts +54 -0
  23. package/lib/narrative-state/queries.ts +434 -0
  24. package/lib/narrative-state/readiness.ts +71 -1
  25. package/lib/narrative-state/render-plan.ts +44 -1
  26. package/lib/narrative-state/research-gaps.ts +191 -0
  27. package/lib/narrative-state/types.ts +33 -0
  28. package/lib/refine/server.ts +91 -13
  29. package/lib/workspace-state/evidence-status.ts +21 -1
  30. package/lib/workspace-state/graph.ts +56 -2
  31. package/lib/workspace-state/types.ts +10 -1
  32. package/package.json +1 -1
  33. package/plugin.ts +33 -2
  34. package/tools/decks.ts +86 -1
  35. package/tools/edit.ts +10 -8
  36. package/tools/inspection-result.ts +37 -0
  37. package/tools/narrative-view.ts +84 -0
@@ -4,23 +4,31 @@ export function buildInspectionPrompt(input: {
4
4
  requestId: string
5
5
  file: string
6
6
  projection: InspectionPromptProjection
7
+ language?: string
7
8
  }): string {
9
+ const language = normalizeInspectLanguage(input.language)
8
10
  return `A user selected slide content in Revela Evidence Inspector. The selection may contain one referenced element, a whole slide, or multiple referenced elements selected with Cmd/Ctrl-click.
9
11
 
10
12
  Target file: ${input.file}
11
13
  Inspection request id: ${input.requestId}
14
+ Display language: ${language}
12
15
 
13
- Use the structured projection below to produce the final inspector cards. This is LLM judgment with grounded boundaries: answer the selected object's purpose and source credibility only. Do not edit files. Do not mutate DECKS.json. Do not invent sources, quotes, URLs, page references, caveats, or evidence not present in the projection.
16
+ Use the structured projection below to produce the final inspector cards. This is LLM judgment with grounded boundaries: explain the selected object's narrative reading context, bounded exploratory reading context, purpose, and source credibility only. Do not edit files. Do not mutate DECKS.json. Do not invent claim ids, evidence binding ids, sources, quotes, URLs, page references, caveats, objections, risks, artifact coverage, or evidence not present in the projection.
17
+
18
+ Language boundary: the selected display language affects only human-readable card copy. Preserve all claim ids, canonical claim ids, evidence binding ids, source paths, findings files, URLs, numbers, quoted/source facts, caveats, artifact ids, and coverage statuses exactly as grounded in the projection. If the display language is Auto, use projection.deck.language when available; otherwise follow the user's/browser context or default to English.
14
19
 
15
20
  Return the result only by calling the \`revela-inspection-result\` tool with this request id. Do not answer in chat.
16
21
 
17
22
  Required card model:
23
+ - Narrative Reading: when the projection includes a matched claim, preserve its claim id, canonical claim id, evidence binding ids, supported scope, unsupported scope, caveats, related objections, related risks, and artifact coverage. Artifact coverage must come only from projection.cards.artifacts; do not invent where a claim appears or whether an artifact is stale/current/partial/missing. If canonical narrative linkage is missing, say so and fall back to the matched slide claim; do not invent canonical ids.
24
+ - Candidate boundary: when projection.match.claim is absent but projection.match.candidateClaims is present, explain the selected child element only within those candidate claim boundaries. You may describe that the child element functions as a detail, prerequisite, source note, risk cue, or evidence cue inside the slide, but you must not select one candidate claim id by semantic guess. If projection.match.confidence is none or candidateClaims is empty, explain the mapping gap instead of inventing a plausible claim.
25
+ - Exploratory Reading: provide bounded, non-official reading cues for objection prep, audience reframing, appendix leads, and meeting prep only from the projection. Mark official as false. Keep missing evidence, caveats, unsupported scope, and stale artifacts visible. Do not make exploratory text sound like approved artifact content, and do not turn this into chat or a fix plan.
18
26
  - Purpose: explain why this selected content appears here, what job it serves in the slide purpose, narrative role, deck goal, audience, or narrative brief, and why it matters.
19
27
  - Source: if the selection contains a factual claim, number, comparison, conclusion, or recommendation, judge source credibility. Use not_needed for structural, transitional, or purely explanatory content that does not need evidence. Include source trace, warnings, gaps, and caveats here.
20
28
 
21
29
  Boundaries:
22
30
  - Do not hunt for problems. If it works, say it works.
23
- - Do not recommend edits or fixes; this inspector view only explains purpose and source credibility.
31
+ - Do not recommend edits or fixes; this inspector view only explains narrative context, bounded exploratory reading context, purpose, and source credibility.
24
32
  - Do not turn every caveat into a problem.
25
33
  - If confidence is low, use unclear or unknown instead of pretending certainty.
26
34
 
@@ -30,3 +38,8 @@ Projection JSON:
30
38
  ${JSON.stringify(input.projection, null, 2)}
31
39
  \`\`\``
32
40
  }
41
+
42
+ function normalizeInspectLanguage(language: string | undefined): string {
43
+ const value = typeof language === "string" ? language.trim() : ""
44
+ return value || "Auto"
45
+ }
@@ -1,5 +1,5 @@
1
1
  import type { InspectionPromptProjection } from "../inspection-context/project"
2
- import type { InspectionResult } from "../inspection-context/result"
2
+ import { buildDeterministicInspectionResult, type InspectionResult } from "../inspection-context/result"
3
3
 
4
4
  export type InspectRequestStatus = "pending" | "completed" | "failed" | "expired"
5
5
 
@@ -53,11 +53,30 @@ export function completeInspectRequest(requestId: string, result: InspectionResu
53
53
  if (!request) throw new Error(`Unknown inspection request: ${requestId}`)
54
54
  if (request.status !== "pending") throw new Error(`Inspection request is not pending: ${request.status}`)
55
55
  request.status = "completed"
56
- request.result = { ...result, requestId }
56
+ request.result = normalizeInspectionResult(request.projection, result, requestId)
57
57
  request.updatedAt = Date.now()
58
58
  return request
59
59
  }
60
60
 
61
+ function normalizeInspectionResult(
62
+ projection: InspectionPromptProjection,
63
+ result: InspectionResult,
64
+ requestId: string,
65
+ ): InspectionResult {
66
+ const deterministic = buildDeterministicInspectionResult(projection, { requestId })
67
+ return {
68
+ ...result,
69
+ requestId,
70
+ cards: {
71
+ reading: result.cards.reading ?? deterministic.cards.reading,
72
+ exploratory: result.cards.exploratory ?? deterministic.cards.exploratory,
73
+ purpose: result.cards.purpose,
74
+ source: result.cards.source,
75
+ },
76
+ stale: result.stale ?? deterministic.stale,
77
+ }
78
+ }
79
+
61
80
  export function failInspectRequest(requestId: string, error: string): PendingInspectRequest | undefined {
62
81
  const request = getInspectRequest(requestId)
63
82
  if (!request || request.status !== "pending") return request
@@ -1,6 +1,8 @@
1
1
  import type { DeckSpec, DecksState, EvidenceRef, NarrativeBrief, NarrativeRole, SlideSpec, SourceMaterial } from "../decks-state"
2
+ import { getArtifactClaimRefs, type ArtifactClaimRef, type ClaimSlideRef } from "../narrative-state/queries"
3
+ import type { NarrativeClaim, NarrativeEvidenceBinding, NarrativeStateV1 } from "../narrative-state/types"
2
4
 
3
- export type InspectionClaimOrigin = "title" | "headline" | "body" | "bullet" | "purpose"
5
+ export type InspectionClaimOrigin = "narrative" | "title" | "headline" | "body" | "bullet" | "purpose"
4
6
  export type InspectionGapType = "missing_evidence" | "weak_evidence"
5
7
  export type InspectionEvidenceSupport = "supported" | "weak" | "unknown"
6
8
 
@@ -13,11 +15,19 @@ export interface InspectionContext {
13
15
  outputPath: string
14
16
  narrativeBrief?: NarrativeBrief
15
17
  sourceMaterials: InspectionSourceMaterial[]
18
+ narrative?: InspectionNarrativeStateContext
16
19
  slides: InspectionSlideContext[]
17
20
  gaps: InspectionGap[]
18
21
  appendixCandidates: InspectionAppendixCandidate[]
19
22
  objectionContext: InspectionNarrativeContext[]
20
23
  riskContext: InspectionNarrativeContext[]
24
+ artifactCoverage: InspectionArtifactCoverage[]
25
+ }
26
+
27
+ export interface InspectionNarrativeStateContext {
28
+ id: string
29
+ status: string
30
+ claimCount: number
21
31
  }
22
32
 
23
33
  export interface InspectionSourceMaterial extends SourceMaterial {
@@ -46,6 +56,7 @@ export interface InspectionSlideText {
46
56
 
47
57
  export interface InspectionClaimCandidate {
48
58
  id: string
59
+ canonicalClaimId?: string
49
60
  slideIndex: number
50
61
  slideTitle: string
51
62
  origin: InspectionClaimOrigin
@@ -54,9 +65,18 @@ export interface InspectionClaimCandidate {
54
65
  evidenceSupport: InspectionEvidenceSupport
55
66
  evidence: InspectionEvidenceTrace[]
56
67
  gaps: InspectionGap[]
68
+ evidenceBindingIds: string[]
69
+ supportedScope?: string
70
+ unsupportedScope?: string
71
+ caveats: string[]
57
72
  }
58
73
 
59
74
  export interface InspectionEvidenceTrace extends EvidenceRef {
75
+ evidenceBindingId?: string
76
+ claimId?: string
77
+ supportScope?: string
78
+ unsupportedScope?: string
79
+ strength?: NarrativeEvidenceBinding["strength"]
60
80
  slideIndex: number
61
81
  slideTitle: string
62
82
  hasDetail: boolean
@@ -80,19 +100,44 @@ export interface InspectionAppendixCandidate {
80
100
 
81
101
  export interface InspectionNarrativeContext {
82
102
  text: string
83
- source: "narrativeBrief" | "slide"
103
+ source: "narrative" | "narrativeBrief" | "slide"
84
104
  slideIndex?: number
85
105
  slideTitle?: string
86
106
  }
87
107
 
108
+ export interface InspectionArtifactCoverage {
109
+ artifactId: string
110
+ type: ArtifactClaimRef["type"]
111
+ outputPath?: string
112
+ coverageStatus: ArtifactClaimRef["coverageStatus"]
113
+ claimIds: string[]
114
+ affectedClaimIds: string[]
115
+ missingClaimIds: string[]
116
+ stale: boolean
117
+ staleReason?: string
118
+ staleReasons: string[]
119
+ note?: string
120
+ slideRefs: InspectionArtifactSlideRef[]
121
+ }
122
+
123
+ export interface InspectionArtifactSlideRef {
124
+ claimId: string
125
+ slideIndex: number
126
+ slideTitle: string
127
+ role: ClaimSlideRef["role"]
128
+ match: ClaimSlideRef["match"]
129
+ location: string
130
+ }
131
+
88
132
  export function compileInspectionContext(state: DecksState, slug?: string): InspectionContext {
89
133
  const deck = activeDeck(state, slug)
134
+ const narrative = state.narrative
90
135
  const evidence = collectEvidence(deck)
91
136
  const sourceMaterials = compileSourceMaterials(state.workspace.sourceMaterials ?? [], evidence)
92
137
  const slides = deck.slides
93
138
  .slice()
94
139
  .sort((a, b) => a.index - b.index)
95
- .map((slide) => compileSlide(slide))
140
+ .map((slide) => compileSlide(slide, narrative))
96
141
  const gaps = slides.flatMap((slide) => slide.claims.flatMap((claim) => claim.gaps))
97
142
 
98
143
  return {
@@ -103,24 +148,56 @@ export function compileInspectionContext(state: DecksState, slug?: string): Insp
103
148
  language: deck.language,
104
149
  outputPath: deck.outputPath,
105
150
  narrativeBrief: deck.narrativeBrief,
151
+ narrative: narrative ? { id: narrative.id, status: narrative.status, claimCount: narrative.claims.length } : undefined,
106
152
  sourceMaterials,
107
153
  slides,
108
154
  gaps,
109
155
  appendixCandidates: compileAppendixCandidates(slides),
110
- objectionContext: compileNarrativeList(deck, "objections"),
111
- riskContext: compileNarrativeList(deck, "risks"),
156
+ objectionContext: compileNarrativeList(deck, "objections", narrative),
157
+ riskContext: compileNarrativeList(deck, "risks", narrative),
158
+ artifactCoverage: compileArtifactCoverage(state),
112
159
  }
113
160
  }
114
161
 
162
+ function compileArtifactCoverage(state: DecksState): InspectionArtifactCoverage[] {
163
+ return getArtifactClaimRefs(state).map((artifact) => ({
164
+ artifactId: artifact.artifactId,
165
+ type: artifact.type,
166
+ outputPath: artifact.outputPath,
167
+ coverageStatus: artifact.coverageStatus,
168
+ claimIds: artifact.claimIds,
169
+ affectedClaimIds: artifact.affectedClaimIds,
170
+ missingClaimIds: artifact.missingClaimIds,
171
+ stale: artifact.stale,
172
+ staleReason: artifact.staleReason,
173
+ staleReasons: artifact.staleReasons,
174
+ note: artifact.note,
175
+ slideRefs: artifact.slideRefs.map((ref) => ({
176
+ claimId: ref.claimId,
177
+ slideIndex: ref.slideIndex,
178
+ slideTitle: ref.slideTitle,
179
+ role: ref.role,
180
+ match: ref.match,
181
+ location: ref.location,
182
+ })),
183
+ }))
184
+ }
185
+
115
186
  function activeDeck(state: DecksState, slug?: string): DeckSpec {
116
187
  const key = slug || state.activeDeck || (Object.keys(state.decks).length === 1 ? Object.keys(state.decks)[0] : undefined)
117
188
  if (!key || !state.decks[key]) throw new Error("No active deck is available for inspection context compilation.")
118
189
  return state.decks[key]
119
190
  }
120
191
 
121
- function compileSlide(slide: SlideSpec): InspectionSlideContext {
192
+ function compileSlide(slide: SlideSpec, narrative: NarrativeStateV1 | undefined): InspectionSlideContext {
122
193
  const evidence = slide.evidence.map((item) => compileEvidence(slide, item))
123
- const claims = claimCandidates(slide).map((claim, position) => compileClaim(slide, claim, position, evidence))
194
+ const canonicalClaims = narrative ? canonicalClaimCandidates(slide, narrative, evidence) : []
195
+ const canonicalText = new Set(canonicalClaims.map((claim) => normalizeText(claim.text)))
196
+ const heuristicClaims = claimCandidates(slide)
197
+ .filter((claim) => !canonicalText.has(normalizeText(claim.text)))
198
+ .map((claim, position) => compileClaim(slide, claim, position, evidence))
199
+ const claims = [...canonicalClaims, ...heuristicClaims]
200
+ const claimCaveats = canonicalClaims.flatMap((claim) => claim.caveats)
124
201
  return {
125
202
  index: slide.index,
126
203
  title: slide.title,
@@ -136,7 +213,61 @@ function compileSlide(slide: SlideSpec): InspectionSlideContext {
136
213
  },
137
214
  claims,
138
215
  evidence,
139
- caveats: evidence.map((item) => item.caveat).filter((item): item is string => Boolean(item?.trim())),
216
+ caveats: dedupeText([
217
+ ...evidence.map((item) => item.caveat).filter((item): item is string => Boolean(item?.trim())),
218
+ ...claimCaveats,
219
+ ]),
220
+ }
221
+ }
222
+
223
+ function canonicalClaimCandidates(slide: SlideSpec, narrative: NarrativeStateV1, slideEvidence: InspectionEvidenceTrace[]): InspectionClaimCandidate[] {
224
+ const claimRefs = slide.claimRefs ?? []
225
+ const metadataClaimIds = new Set([
226
+ ...claimRefs.map((ref) => ref.claimId),
227
+ ...(slide.claimIds ?? []),
228
+ ].filter(Boolean))
229
+ const evidenceBindingIds = new Set(slide.evidenceBindingIds ?? [])
230
+ for (const binding of narrative.evidenceBindings) {
231
+ if (evidenceBindingIds.has(binding.id)) metadataClaimIds.add(binding.claimId)
232
+ }
233
+
234
+ return narrative.claims
235
+ .filter((claim) => metadataClaimIds.has(claim.id))
236
+ .map((claim) => compileCanonicalClaim(slide, claim, narrative.evidenceBindings, slideEvidence, evidenceBindingIds))
237
+ }
238
+
239
+ function compileCanonicalClaim(
240
+ slide: SlideSpec,
241
+ claim: NarrativeClaim,
242
+ bindings: NarrativeEvidenceBinding[],
243
+ slideEvidence: InspectionEvidenceTrace[],
244
+ slideEvidenceBindingIds: Set<string>,
245
+ ): InspectionClaimCandidate {
246
+ const allClaimBindings = bindings.filter((binding) => binding.claimId === claim.id)
247
+ const selectedBindings = allClaimBindings.filter((binding) => slideEvidenceBindingIds.size === 0 || slideEvidenceBindingIds.has(binding.id))
248
+ const evidenceBindings = selectedBindings.length > 0 ? selectedBindings : allClaimBindings
249
+ const evidence = evidenceBindings.length > 0
250
+ ? evidenceBindings.map((binding) => compileEvidenceBinding(slide, binding))
251
+ : slideEvidence
252
+ const gaps = canonicalClaimGaps(slide, claim, evidence)
253
+ return {
254
+ id: claim.id,
255
+ canonicalClaimId: claim.id,
256
+ slideIndex: slide.index,
257
+ slideTitle: slide.title,
258
+ origin: "narrative",
259
+ text: claim.text,
260
+ evidenceSensitive: claim.evidenceRequired || isEvidenceSensitiveClaim(claim.text),
261
+ evidenceSupport: narrativeEvidenceSupport(claim, evidence),
262
+ evidence,
263
+ gaps,
264
+ evidenceBindingIds: evidenceBindings.map((binding) => binding.id),
265
+ supportedScope: claim.supportedScope,
266
+ unsupportedScope: claim.unsupportedScope,
267
+ caveats: dedupeText([
268
+ ...(claim.caveats ?? []),
269
+ ...evidenceBindings.map((binding) => binding.caveat).filter((item): item is string => Boolean(item?.trim())),
270
+ ]),
140
271
  }
141
272
  }
142
273
 
@@ -159,9 +290,36 @@ function compileClaim(
159
290
  evidenceSupport: evidenceSupport(evidence),
160
291
  evidence,
161
292
  gaps,
293
+ evidenceBindingIds: [],
294
+ caveats: [],
162
295
  }
163
296
  }
164
297
 
298
+ function canonicalClaimGaps(slide: SlideSpec, claim: NarrativeClaim, evidence: InspectionEvidenceTrace[]): InspectionGap[] {
299
+ if (!claim.evidenceRequired) return []
300
+ if (claim.evidenceStatus === "missing" || evidence.length === 0) {
301
+ return [{
302
+ type: "missing_evidence",
303
+ slideIndex: slide.index,
304
+ slideTitle: slide.title,
305
+ claimId: claim.id,
306
+ claimText: claim.text,
307
+ message: "Canonical narrative claim requires evidence but has no bound evidence trace.",
308
+ }]
309
+ }
310
+ if (claim.evidenceStatus === "weak" || evidence.some((item) => !item.hasDetail)) {
311
+ return [{
312
+ type: "weak_evidence",
313
+ slideIndex: slide.index,
314
+ slideTitle: slide.title,
315
+ claimId: claim.id,
316
+ claimText: claim.text,
317
+ message: "Canonical narrative claim has weak or source-only evidence trace.",
318
+ }]
319
+ }
320
+ return []
321
+ }
322
+
165
323
  function claimGaps(slide: SlideSpec, claimId: string, claimText: string, evidence: InspectionEvidenceTrace[]): InspectionGap[] {
166
324
  if (evidence.length === 0) {
167
325
  return [{
@@ -192,6 +350,12 @@ function evidenceSupport(evidence: InspectionEvidenceTrace[]): InspectionEvidenc
192
350
  return "supported"
193
351
  }
194
352
 
353
+ function narrativeEvidenceSupport(claim: NarrativeClaim, evidence: InspectionEvidenceTrace[]): InspectionEvidenceSupport {
354
+ if (claim.evidenceStatus === "supported" || claim.evidenceStatus === "not_required") return "supported"
355
+ if (claim.evidenceStatus === "partial" || claim.evidenceStatus === "weak") return "weak"
356
+ return evidenceSupport(evidence)
357
+ }
358
+
195
359
  function claimCandidates(slide: SlideSpec): Array<{ origin: InspectionClaimOrigin; text: string }> {
196
360
  const claims: Array<{ origin: InspectionClaimOrigin; text: string }> = []
197
361
  pushClaim(claims, "title", slide.title)
@@ -218,6 +382,29 @@ function compileEvidence(slide: SlideSpec, evidence: EvidenceRef): InspectionEvi
218
382
  }
219
383
  }
220
384
 
385
+ function compileEvidenceBinding(slide: SlideSpec, binding: NarrativeEvidenceBinding): InspectionEvidenceTrace {
386
+ const evidence: EvidenceRef = {
387
+ source: binding.source,
388
+ sourcePath: binding.sourcePath,
389
+ findingsFile: binding.findingsFile,
390
+ quote: binding.quote,
391
+ location: binding.location,
392
+ url: binding.url,
393
+ caveat: binding.caveat,
394
+ }
395
+ return {
396
+ ...evidence,
397
+ evidenceBindingId: binding.id,
398
+ claimId: binding.claimId,
399
+ supportScope: binding.supportScope,
400
+ unsupportedScope: binding.unsupportedScope,
401
+ strength: binding.strength,
402
+ slideIndex: slide.index,
403
+ slideTitle: slide.title,
404
+ hasDetail: hasEvidenceDetail(evidence),
405
+ }
406
+ }
407
+
221
408
  function collectEvidence(deck: DeckSpec): InspectionEvidenceTrace[] {
222
409
  return deck.slides.flatMap((slide) => slide.evidence.map((item) => compileEvidence(slide, item)))
223
410
  }
@@ -253,7 +440,10 @@ function appendixReason(slide: InspectionSlideContext): string {
253
440
  return "Slide has recorded evidence that may be useful for source excerpts or backup detail."
254
441
  }
255
442
 
256
- function compileNarrativeList(deck: DeckSpec, key: "objections" | "risks"): InspectionNarrativeContext[] {
443
+ function compileNarrativeList(deck: DeckSpec, key: "objections" | "risks", narrative: NarrativeStateV1 | undefined): InspectionNarrativeContext[] {
444
+ const fromNarrative = key === "objections"
445
+ ? (narrative?.objections ?? []).map((item) => ({ text: item.text, source: "narrative" as const }))
446
+ : (narrative?.risks ?? []).map((item) => ({ text: item.text, source: "narrative" as const }))
257
447
  const fromBrief = (deck.narrativeBrief?.[key] ?? []).map((text) => ({ text, source: "narrativeBrief" as const }))
258
448
  const role = key === "risks" ? "risk" : undefined
259
449
  const fromSlides = deck.slides
@@ -264,7 +454,19 @@ function compileNarrativeList(deck: DeckSpec, key: "objections" | "risks"): Insp
264
454
  slideIndex: slide.index,
265
455
  slideTitle: slide.title,
266
456
  })))
267
- return [...fromBrief, ...fromSlides]
457
+ return dedupeNarrativeContext([...fromNarrative, ...fromBrief, ...fromSlides])
458
+ }
459
+
460
+ function dedupeNarrativeContext(values: InspectionNarrativeContext[]): InspectionNarrativeContext[] {
461
+ const seen = new Set<string>()
462
+ const result: InspectionNarrativeContext[] = []
463
+ for (const value of values) {
464
+ const key = `${normalizeText(value.text)}:${value.slideIndex ?? "global"}`
465
+ if (seen.has(key)) continue
466
+ seen.add(key)
467
+ result.push(value)
468
+ }
469
+ return result
268
470
  }
269
471
 
270
472
  function slideTextList(slide: SlideSpec): string[] {
@@ -303,6 +505,24 @@ function cleanOptionalText(value: string | undefined): string | undefined {
303
505
  return text || undefined
304
506
  }
305
507
 
508
+ function dedupeText(values: string[]): string[] {
509
+ const seen = new Set<string>()
510
+ const result: string[] = []
511
+ for (const value of values) {
512
+ const cleaned = cleanOptionalText(value)
513
+ if (!cleaned) continue
514
+ const key = normalizeText(cleaned)
515
+ if (seen.has(key)) continue
516
+ seen.add(key)
517
+ result.push(cleaned)
518
+ }
519
+ return result
520
+ }
521
+
522
+ function normalizeText(value: string | undefined): string {
523
+ return value?.replace(/\s+/g, " ").trim().toLowerCase() ?? ""
524
+ }
525
+
306
526
  const EVIDENCE_SENSITIVE_TERMS = [
307
527
  /\bmarket size\b/,
308
528
  /\bcagr\b/,
@@ -45,6 +45,7 @@ export interface InspectionElementSnapshot {
45
45
  export interface InspectionElementMatch {
46
46
  slide?: InspectionSlideContext
47
47
  claim?: InspectionClaimCandidate
48
+ candidateClaims?: InspectionClaimCandidate[]
48
49
  evidence: InspectionEvidenceTrace[]
49
50
  gaps: InspectionGap[]
50
51
  caveats: string[]
@@ -55,8 +56,12 @@ export interface InspectionElementMatch {
55
56
 
56
57
  export function matchInspectionElement(context: InspectionContext, snapshot: InspectionElementSnapshot): InspectionElementMatch {
57
58
  const selectedText = normalizeText(snapshot.text)
59
+ const surroundingText = normalizeText(snapshot.nearbyText || snapshot.outerHTMLExcerpt)
58
60
  const candidateSlides = candidateSlidesForSnapshot(context, snapshot)
59
61
 
62
+ const anchoredClaim = findAnchoredClaimMatch(candidateSlides, snapshot)
63
+ if (anchoredClaim) return claimMatch(context, anchoredClaim.slide, anchoredClaim.claim, "high", "Matched explicit claim anchor from selection snapshot.")
64
+
60
65
  if (selectedText) {
61
66
  const exactClaim = findClaimMatch(candidateSlides, selectedText, "exact")
62
67
  if (exactClaim) return claimMatch(context, exactClaim.slide, exactClaim.claim, "high", "Exact normalized text match.")
@@ -77,8 +82,27 @@ export function matchInspectionElement(context: InspectionContext, snapshot: Ins
77
82
  }
78
83
  }
79
84
 
85
+ if (surroundingText && surroundingText !== selectedText) {
86
+ const contextualClaim = findClaimMatch(candidateSlides, surroundingText, "contains")
87
+ if (contextualClaim) return claimMatch(context, contextualClaim.slide, contextualClaim.claim, "medium", "Matched claim using surrounding slide context.")
88
+ }
89
+
80
90
  const slide = candidateSlides[0]
81
- if (slide) return slideMatch(context, slide, snapshot.slideIndex ? "medium" : "low", snapshot.slideIndex ? "Matched by slideIndex only." : "No claim text matched; returning first candidate slide.")
91
+ if (slide) {
92
+ const canonicalClaims = slide.claims.filter((claim) => claim.canonicalClaimId || claim.origin === "narrative")
93
+ if (canonicalClaims.length === 1) {
94
+ return claimMatch(context, slide, canonicalClaims[0], "medium", "Selected element matched the slide; the slide has one canonical narrative claim candidate.", canonicalClaims)
95
+ }
96
+ return slideMatch(
97
+ context,
98
+ slide,
99
+ snapshot.slideIndex ? "medium" : "low",
100
+ canonicalClaims.length > 1
101
+ ? "Matched slide only; multiple canonical claim candidates are available, so no claim id was chosen by semantic guess."
102
+ : snapshot.slideIndex ? "Matched by slideIndex only." : "No claim text matched; returning first candidate slide.",
103
+ canonicalClaims,
104
+ )
105
+ }
82
106
 
83
107
  return {
84
108
  evidence: [],
@@ -114,6 +138,37 @@ function findClaimMatch(
114
138
  return undefined
115
139
  }
116
140
 
141
+ function findAnchoredClaimMatch(
142
+ slides: InspectionSlideContext[],
143
+ snapshot: InspectionElementSnapshot,
144
+ ): { slide: InspectionSlideContext; claim: InspectionClaimCandidate } | undefined {
145
+ const claimIds = explicitClaimIds(snapshot)
146
+ if (claimIds.length === 0) return undefined
147
+ for (const slide of slides) {
148
+ for (const claim of slide.claims) {
149
+ const ids = [claim.id, claim.canonicalClaimId].filter((item): item is string => Boolean(item))
150
+ if (ids.some((id) => claimIds.includes(id))) return { slide, claim }
151
+ }
152
+ }
153
+ return undefined
154
+ }
155
+
156
+ function explicitClaimIds(snapshot: InspectionElementSnapshot): string[] {
157
+ const values = [
158
+ snapshot.selector,
159
+ snapshot.domPath,
160
+ snapshot.outerHTMLExcerpt,
161
+ ...(snapshot.elements ?? []).flatMap((item) => [item.selector, item.domPath, item.outerHTMLExcerpt]),
162
+ ]
163
+ const ids: string[] = []
164
+ for (const value of values) {
165
+ if (!value) continue
166
+ for (const match of value.matchAll(/data-claim-id\s*=\s*["']([^"']+)["']/gi)) ids.push(match[1])
167
+ for (const match of value.matchAll(/data-claim-id=([^\]\s>"']+)/gi)) ids.push(match[1])
168
+ }
169
+ return dedupe(ids.map((item) => item.trim()).filter(Boolean))
170
+ }
171
+
117
172
  function conservativeContains(claimText: string, selectedText: string): boolean {
118
173
  if (selectedText.length < 12 && claimText.length < 12) return false
119
174
  return claimText.includes(selectedText) || selectedText.includes(claimText)
@@ -125,10 +180,12 @@ function claimMatch(
125
180
  claim: InspectionClaimCandidate,
126
181
  confidence: InspectionMatchConfidence,
127
182
  reason: string,
183
+ candidateClaims: InspectionClaimCandidate[] = [],
128
184
  ): InspectionElementMatch {
129
185
  return {
130
186
  slide,
131
187
  claim,
188
+ candidateClaims: candidateClaims.length ? candidateClaims : [claim],
132
189
  evidence: claim.evidence,
133
190
  gaps: claim.gaps,
134
191
  caveats: slide.caveats,
@@ -143,9 +200,11 @@ function slideMatch(
143
200
  slide: InspectionSlideContext,
144
201
  confidence: InspectionMatchConfidence,
145
202
  reason: string,
203
+ candidateClaims: InspectionClaimCandidate[] = [],
146
204
  ): InspectionElementMatch {
147
205
  return {
148
206
  slide,
207
+ candidateClaims,
149
208
  evidence: slide.evidence,
150
209
  gaps: slide.claims.flatMap((claim) => claim.gaps),
151
210
  caveats: slide.caveats,
@@ -167,3 +226,14 @@ function normalizeText(text: string | undefined): string {
167
226
  .trim()
168
227
  .toLowerCase()
169
228
  }
229
+
230
+ function dedupe(values: string[]): string[] {
231
+ const seen = new Set<string>()
232
+ const result: string[] = []
233
+ for (const value of values) {
234
+ if (seen.has(value)) continue
235
+ seen.add(value)
236
+ result.push(value)
237
+ }
238
+ return result
239
+ }