@cyber-dash-tech/revela 0.8.9 → 0.10.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 +29 -4
- package/README.zh-CN.md +29 -4
- package/designs/monet/DESIGN.md +9 -9
- package/designs/starter/DESIGN.md +8 -8
- package/designs/summit/DESIGN.md +9 -9
- package/lib/agents/narrative-reviewer-prompt.ts +143 -0
- package/lib/commands/help.ts +2 -0
- package/lib/commands/inspect.ts +23 -0
- package/lib/commands/refine.ts +26 -0
- package/lib/commands/review.ts +18 -6
- package/lib/decks-state.ts +601 -6
- package/lib/inspect/open.ts +61 -0
- package/lib/inspect/prompt.ts +32 -0
- package/lib/inspect/request.ts +70 -0
- package/lib/inspect/requests.ts +86 -0
- package/lib/inspect/server.ts +1063 -0
- package/lib/inspect/slide-index.ts +12 -0
- package/lib/inspection-context/compile.ts +346 -0
- package/lib/inspection-context/match.ts +169 -0
- package/lib/inspection-context/project.ts +263 -0
- package/lib/inspection-context/result.ts +160 -0
- package/lib/refine/open.ts +68 -0
- package/lib/refine/server.ts +1581 -0
- package/package.json +1 -1
- package/plugin.ts +47 -2
- package/skill/SKILL.md +46 -8
- package/tools/decks.ts +23 -2
- package/tools/inspection-context.ts +22 -0
- package/tools/inspection-result.ts +63 -0
package/lib/decks-state.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs"
|
|
2
|
+
import { createHash } from "crypto"
|
|
2
3
|
import { basename, dirname, join, resolve } from "path"
|
|
3
4
|
|
|
4
5
|
export const DECKS_STATE_FILE = "DECKS.json"
|
|
@@ -58,6 +59,7 @@ export interface DeckSpec {
|
|
|
58
59
|
audience?: string
|
|
59
60
|
language?: string
|
|
60
61
|
outputPath: string
|
|
62
|
+
narrativeBrief?: NarrativeBrief
|
|
61
63
|
theme: {
|
|
62
64
|
design?: string
|
|
63
65
|
domain?: string
|
|
@@ -73,6 +75,16 @@ export interface DeckSpec {
|
|
|
73
75
|
}
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
export interface NarrativeBrief {
|
|
79
|
+
audienceBeliefBefore?: string
|
|
80
|
+
audienceBeliefAfter?: string
|
|
81
|
+
decisionOrAction?: string
|
|
82
|
+
narrativeArc?: string
|
|
83
|
+
keyClaims: string[]
|
|
84
|
+
objections: string[]
|
|
85
|
+
risks: string[]
|
|
86
|
+
}
|
|
87
|
+
|
|
76
88
|
export interface RequiredInputs {
|
|
77
89
|
topicClarified: boolean
|
|
78
90
|
audienceClarified: boolean
|
|
@@ -150,6 +162,7 @@ export interface DeckStateReadinessResult {
|
|
|
150
162
|
blockers: string[]
|
|
151
163
|
warnings: string[]
|
|
152
164
|
issues: ReadinessIssue[]
|
|
165
|
+
evidenceCandidates?: EvidenceBindingCandidate[]
|
|
153
166
|
}
|
|
154
167
|
|
|
155
168
|
export type ReadinessSeverity = "blocker" | "warning"
|
|
@@ -171,10 +184,70 @@ export interface ReadinessIssue {
|
|
|
171
184
|
slideIndex?: number
|
|
172
185
|
slideTitle?: string
|
|
173
186
|
claimText?: string
|
|
187
|
+
evidenceCandidates?: EvidenceBindingCandidate[]
|
|
188
|
+
evidenceCandidateSearch?: EvidenceCandidateSearchDiagnostic
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface EvidenceBindingCandidate {
|
|
192
|
+
candidateId: string
|
|
193
|
+
slideIndex: number
|
|
194
|
+
slideTitle?: string
|
|
195
|
+
claimText?: string
|
|
196
|
+
source: string
|
|
197
|
+
findingsFile?: string
|
|
198
|
+
sourcePath?: string
|
|
199
|
+
location?: string
|
|
200
|
+
quote?: string
|
|
201
|
+
caveat?: string
|
|
202
|
+
supportScope: string[]
|
|
203
|
+
supportStrength: "partial" | "strong"
|
|
204
|
+
sourceKind?: "researchPlan" | "researchesFallback"
|
|
205
|
+
evidenceDraft?: EvidenceRef
|
|
206
|
+
unsupportedScope?: string[]
|
|
207
|
+
recommendedRewrite?: string
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface EvidenceCandidateSearchDiagnostic {
|
|
211
|
+
queryTokens: string[]
|
|
212
|
+
researchPlanFindingsSearched: string[]
|
|
213
|
+
fallbackResearchFilesSearched: string[]
|
|
214
|
+
fallbackResearchFilesSkipped: string[]
|
|
215
|
+
nearMisses: EvidenceCandidateNearMiss[]
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface EvidenceCandidateNearMiss {
|
|
219
|
+
findingsFile: string
|
|
220
|
+
sourceKind: "researchPlan" | "researchesFallback"
|
|
221
|
+
bestScore: number
|
|
222
|
+
threshold: number
|
|
223
|
+
supportScope: string[]
|
|
224
|
+
quote?: string
|
|
225
|
+
reason: string
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface ApplyEvidenceCandidatesResult {
|
|
229
|
+
applied: AppliedEvidenceCandidate[]
|
|
230
|
+
skipped: SkippedEvidenceCandidate[]
|
|
231
|
+
nextReviewNeeded: boolean
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface AppliedEvidenceCandidate {
|
|
235
|
+
candidateId: string
|
|
236
|
+
slideIndex: number
|
|
237
|
+
evidence: EvidenceRef
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface SkippedEvidenceCandidate {
|
|
241
|
+
candidateId: string
|
|
242
|
+
reason: string
|
|
174
243
|
}
|
|
175
244
|
|
|
176
245
|
const SOURCE_TRACE_ACTION = "Add slide evidence with source plus source trace such as findingsFile or sourcePath, and quote, location, url, or caveat where available; otherwise reframe the claim as an explicit assumption/opinion."
|
|
177
246
|
|
|
247
|
+
export interface ReviewDeckStateOptions {
|
|
248
|
+
workspaceRoot?: string
|
|
249
|
+
}
|
|
250
|
+
|
|
178
251
|
export function decksStatePath(workspaceRoot: string): string {
|
|
179
252
|
return join(workspaceRoot, DECKS_STATE_FILE)
|
|
180
253
|
}
|
|
@@ -242,6 +315,7 @@ export function createDeckSpec(input: Partial<DeckSpec> & { slug: string }): Dec
|
|
|
242
315
|
audience: input.audience,
|
|
243
316
|
language: input.language,
|
|
244
317
|
outputPath: normalizeDeckPath(input.outputPath || `decks/${slug}.html`),
|
|
318
|
+
narrativeBrief: normalizeNarrativeBrief(input.narrativeBrief),
|
|
245
319
|
theme: input.theme ?? {},
|
|
246
320
|
requiredInputs: defaultRequiredInputs(input.requiredInputs),
|
|
247
321
|
researchPlan: input.researchPlan ?? [],
|
|
@@ -299,7 +373,65 @@ export function upsertSlides(state: DecksState, slug: string, slides: SlideSpec[
|
|
|
299
373
|
return normalized
|
|
300
374
|
}
|
|
301
375
|
|
|
302
|
-
export function
|
|
376
|
+
export function applyEvidenceCandidates(state: DecksState, candidateIds: string[], options: ReviewDeckStateOptions = {}): { state: DecksState; result: ApplyEvidenceCandidatesResult } {
|
|
377
|
+
const normalized = normalizeDecksState(state)
|
|
378
|
+
const ids = [...new Set(candidateIds.map((id) => id.trim()).filter(Boolean))]
|
|
379
|
+
const applied: AppliedEvidenceCandidate[] = []
|
|
380
|
+
const skipped: SkippedEvidenceCandidate[] = []
|
|
381
|
+
const key = currentDeckKey(normalized)
|
|
382
|
+
const deck = key ? normalized.decks[key] : undefined
|
|
383
|
+
|
|
384
|
+
if (!deck) {
|
|
385
|
+
return {
|
|
386
|
+
state: normalized,
|
|
387
|
+
result: {
|
|
388
|
+
applied,
|
|
389
|
+
skipped: ids.map((candidateId) => ({ candidateId, reason: `No active deck exists in ${DECKS_STATE_FILE}.` })),
|
|
390
|
+
nextReviewNeeded: false,
|
|
391
|
+
},
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const review = reviewDeckState(normalized, deck.slug, options)
|
|
396
|
+
const byId = new Map((review.result.evidenceCandidates ?? []).map((candidate) => [candidate.candidateId, candidate]))
|
|
397
|
+
const next = normalizeDecksState(review.state)
|
|
398
|
+
const nextDeck = next.decks[deck.slug]
|
|
399
|
+
|
|
400
|
+
for (const candidateId of ids) {
|
|
401
|
+
const candidate = byId.get(candidateId)
|
|
402
|
+
if (!candidate) {
|
|
403
|
+
skipped.push({ candidateId, reason: "Candidate was not found in the current review result." })
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
406
|
+
if (!candidate.evidenceDraft) {
|
|
407
|
+
skipped.push({ candidateId, reason: "Candidate has no evidenceDraft to apply." })
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
const slide = nextDeck.slides.find((item) => item.index === candidate.slideIndex)
|
|
411
|
+
if (!slide) {
|
|
412
|
+
skipped.push({ candidateId, reason: `Slide ${candidate.slideIndex} no longer exists.` })
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
const evidence = cleanEvidenceRef(candidate.evidenceDraft)
|
|
416
|
+
if (slide.evidence.some((item) => sameEvidenceRef(item, evidence))) {
|
|
417
|
+
skipped.push({ candidateId, reason: `Slide ${candidate.slideIndex} already has this evidence record.` })
|
|
418
|
+
continue
|
|
419
|
+
}
|
|
420
|
+
slide.evidence.push(evidence)
|
|
421
|
+
applied.push({ candidateId, slideIndex: candidate.slideIndex, evidence })
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
state: next,
|
|
426
|
+
result: {
|
|
427
|
+
applied,
|
|
428
|
+
skipped,
|
|
429
|
+
nextReviewNeeded: applied.length > 0,
|
|
430
|
+
},
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function reviewDeckState(state: DecksState, slug?: string, options: ReviewDeckStateOptions = {}): { state: DecksState; result: DeckStateReadinessResult } {
|
|
303
435
|
const normalized = normalizeDecksState(state)
|
|
304
436
|
const key = normalizeSlug(slug || currentDeckKey(normalized) || "")
|
|
305
437
|
const deck = key ? normalized.decks[key] : undefined
|
|
@@ -323,9 +455,10 @@ export function reviewDeckState(state: DecksState, slug?: string): { state: Deck
|
|
|
323
455
|
}
|
|
324
456
|
}
|
|
325
457
|
|
|
326
|
-
const issues = computeDeckReadinessIssues(deck, normalized.workspace)
|
|
458
|
+
const issues = computeDeckReadinessIssues(deck, normalized.workspace, options)
|
|
327
459
|
const blockers = issues.filter((issue) => issue.severity === "blocker").map((issue) => issue.message)
|
|
328
460
|
const warnings = issues.filter((issue) => issue.severity === "warning").map((issue) => issue.message)
|
|
461
|
+
const evidenceCandidates = issues.flatMap((issue) => issue.evidenceCandidates ?? [])
|
|
329
462
|
deck.writeReadiness = {
|
|
330
463
|
status: blockers.length === 0 ? "ready" : "blocked",
|
|
331
464
|
blockers,
|
|
@@ -344,6 +477,7 @@ export function reviewDeckState(state: DecksState, slug?: string): { state: Deck
|
|
|
344
477
|
blockers,
|
|
345
478
|
warnings,
|
|
346
479
|
issues,
|
|
480
|
+
evidenceCandidates,
|
|
347
481
|
},
|
|
348
482
|
}
|
|
349
483
|
}
|
|
@@ -449,6 +583,7 @@ export function buildDecksStatePromptLayer(workspaceRoot: string, maxChars = 140
|
|
|
449
583
|
let text = JSON.stringify(compact, null, 2)
|
|
450
584
|
if (text.length > maxChars) text = text.slice(0, maxChars).trimEnd() + "\n[DECKS.json state truncated for prompt size.]"
|
|
451
585
|
return `---\n\n# Revela Workspace State From ${DECKS_STATE_FILE}\n\n\`\`\`json\n${text}\n\`\`\`\n\nRules for this state layer:\n- Treat ${DECKS_STATE_FILE} as the source of truth for the single current deck's specs, slide plan, and write readiness.\n- The decks map is compatibility storage; operate only on the current workspace deck.\n- Do not edit ${DECKS_STATE_FILE} directly; use the revela-decks tool.\n- Before writing decks/*.html, the current deck must have writeReadiness.status=ready and a complete slide spec, and its outputPath must match the target file.`
|
|
586
|
+
return `---\n\n# Revela Workspace State From ${DECKS_STATE_FILE}\n\n\`\`\`json\n${text}\n\`\`\`\n\nRules for this state layer:\n- Treat ${DECKS_STATE_FILE} as the source of truth for the single current deck's specs, slide plan, evidence, and write readiness.\n- The decks map is compatibility storage; operate only on the current workspace deck.\n- ${DECKS_STATE_FILE} deck slides use 1-based \`slides[].index\` values. Render every HTML \`<section class="slide">\` with a matching 1-based \`data-slide-index\` attribute, and do not use 0-based \`data-index\` as slide identity.\n- Do not edit ${DECKS_STATE_FILE} directly; use the revela-decks tool.\n- Before writing decks/*.html, the current deck must have writeReadiness.status=ready and a complete slide spec, and its outputPath must match the target file.`
|
|
452
587
|
}
|
|
453
588
|
|
|
454
589
|
function compactWorkspaceForPrompt(workspace: DecksState["workspace"]): DecksState["workspace"] {
|
|
@@ -468,6 +603,7 @@ function compactWorkspaceForPrompt(workspace: DecksState["workspace"]): DecksSta
|
|
|
468
603
|
function compactDeckForPrompt(deck: DeckSpec): DeckSpec {
|
|
469
604
|
return {
|
|
470
605
|
...deck,
|
|
606
|
+
narrativeBrief: compactNarrativeBriefForPrompt(deck.narrativeBrief),
|
|
471
607
|
slides: deck.slides.map((slide) => ({
|
|
472
608
|
...slide,
|
|
473
609
|
content: {
|
|
@@ -480,6 +616,19 @@ function compactDeckForPrompt(deck: DeckSpec): DeckSpec {
|
|
|
480
616
|
}
|
|
481
617
|
}
|
|
482
618
|
|
|
619
|
+
function compactNarrativeBriefForPrompt(brief: NarrativeBrief | undefined): NarrativeBrief | undefined {
|
|
620
|
+
if (!brief) return undefined
|
|
621
|
+
return {
|
|
622
|
+
audienceBeliefBefore: truncatePromptText(brief.audienceBeliefBefore),
|
|
623
|
+
audienceBeliefAfter: truncatePromptText(brief.audienceBeliefAfter),
|
|
624
|
+
decisionOrAction: truncatePromptText(brief.decisionOrAction),
|
|
625
|
+
narrativeArc: truncatePromptText(brief.narrativeArc),
|
|
626
|
+
keyClaims: brief.keyClaims.map((claim) => truncatePromptText(claim)).filter(Boolean) as string[],
|
|
627
|
+
objections: brief.objections.map((objection) => truncatePromptText(objection)).filter(Boolean) as string[],
|
|
628
|
+
risks: brief.risks.map((risk) => truncatePromptText(risk)).filter(Boolean) as string[],
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
483
632
|
function compactEvidenceForPrompt(evidence: EvidenceRef): EvidenceRef {
|
|
484
633
|
return {
|
|
485
634
|
...evidence,
|
|
@@ -536,7 +685,7 @@ function currentDeckBlocker(state: DecksState): string {
|
|
|
536
685
|
return `${DECKS_STATE_FILE} contains multiple deck records and no activeDeck. Select one current deck explicitly or move extra decks to separate workspaces.`
|
|
537
686
|
}
|
|
538
687
|
|
|
539
|
-
function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["workspace"]): ReadinessIssue[] {
|
|
688
|
+
function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["workspace"], options: ReviewDeckStateOptions = {}): ReadinessIssue[] {
|
|
540
689
|
const issues: ReadinessIssue[] = []
|
|
541
690
|
if (!deck.goal.trim()) issues.push(blockerIssue("missing_slide_spec", "Deck goal is missing", "Set the deck goal through revela-decks upsertDeck."))
|
|
542
691
|
if (!isDeckHtmlPath(deck.outputPath)) {
|
|
@@ -566,12 +715,18 @@ function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["works
|
|
|
566
715
|
if (!hasSlideContent(slide)) issues.push(blockerIssue("missing_slide_spec", `Slide ${slide.index} content is missing`, "Add structured headline/body/bullets/data content to the slide spec.", slideRef))
|
|
567
716
|
|
|
568
717
|
const claim = findEvidenceSensitiveClaim(slide)
|
|
569
|
-
if (claim && slide.evidence.length === 0) {
|
|
718
|
+
if (claim && slide.evidence.length === 0 && !isNavigationSlide(slide)) {
|
|
719
|
+
const { candidates: evidenceCandidates, search: evidenceCandidateSearch } = findEvidenceBindingCandidates(deck, slide, claim, options)
|
|
570
720
|
issues.push(blockerIssue(
|
|
571
721
|
"missing_evidence",
|
|
572
722
|
`Slide ${slide.index} has an evidence-sensitive claim without evidence: ${claim}`,
|
|
573
723
|
SOURCE_TRACE_ACTION,
|
|
574
|
-
{
|
|
724
|
+
{
|
|
725
|
+
...slideRef,
|
|
726
|
+
claimText: claim,
|
|
727
|
+
evidenceCandidates: evidenceCandidates.length > 0 ? evidenceCandidates : undefined,
|
|
728
|
+
evidenceCandidateSearch,
|
|
729
|
+
},
|
|
575
730
|
))
|
|
576
731
|
} else if (claim && slide.evidence.some((item) => !hasEvidenceDetail(item))) {
|
|
577
732
|
issues.push(warningIssue(
|
|
@@ -598,6 +753,7 @@ function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["works
|
|
|
598
753
|
const hasNeededResearch = deck.researchPlan.some((axis) => axis.needed && axis.status !== "skipped")
|
|
599
754
|
for (const material of workspace.sourceMaterials ?? []) {
|
|
600
755
|
if (material.status !== "discovered") continue
|
|
756
|
+
if (isIgnorableSourceMaterial(material.path)) continue
|
|
601
757
|
const message = `Source material ${material.path} has been identified but not extracted, summarized, or researched`
|
|
602
758
|
if (hasNeededResearch) {
|
|
603
759
|
issues.push(blockerIssue(
|
|
@@ -617,10 +773,348 @@ function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["works
|
|
|
617
773
|
return issues
|
|
618
774
|
}
|
|
619
775
|
|
|
776
|
+
function findEvidenceBindingCandidates(deck: DeckSpec, slide: SlideSpec, claimText: string, options: ReviewDeckStateOptions): { candidates: EvidenceBindingCandidate[]; search?: EvidenceCandidateSearchDiagnostic } {
|
|
777
|
+
if (!options.workspaceRoot) return { candidates: [] }
|
|
778
|
+
const queryText = slideSearchText(slide)
|
|
779
|
+
const queryTokens = meaningfulTokens(queryText)
|
|
780
|
+
if (queryTokens.length === 0) return { candidates: [] }
|
|
781
|
+
|
|
782
|
+
const candidates: EvidenceBindingCandidate[] = []
|
|
783
|
+
const search: EvidenceCandidateSearchDiagnostic = {
|
|
784
|
+
queryTokens,
|
|
785
|
+
researchPlanFindingsSearched: [],
|
|
786
|
+
fallbackResearchFilesSearched: [],
|
|
787
|
+
fallbackResearchFilesSkipped: [],
|
|
788
|
+
nearMisses: [],
|
|
789
|
+
}
|
|
790
|
+
const planFindings = new Set<string>()
|
|
791
|
+
for (const axis of deck.researchPlan) {
|
|
792
|
+
if (!axis.needed || (axis.status !== "done" && axis.status !== "read") || !axis.findingsFile?.trim()) continue
|
|
793
|
+
const normalizedFindingsFile = normalizePath(axis.findingsFile)
|
|
794
|
+
planFindings.add(normalizedFindingsFile)
|
|
795
|
+
search.researchPlanFindingsSearched.push(normalizedFindingsFile)
|
|
796
|
+
const findingsPath = safeWorkspacePath(options.workspaceRoot, axis.findingsFile)
|
|
797
|
+
if (!findingsPath || !existsSync(findingsPath)) continue
|
|
798
|
+
const text = readTextPrefix(findingsPath, 100_000)
|
|
799
|
+
if (!text.trim()) continue
|
|
800
|
+
const result = candidateFromFindingsFile({
|
|
801
|
+
slide,
|
|
802
|
+
claimText,
|
|
803
|
+
queryTokens,
|
|
804
|
+
findingsFile: normalizedFindingsFile,
|
|
805
|
+
text,
|
|
806
|
+
sourceKind: "researchPlan",
|
|
807
|
+
})
|
|
808
|
+
if (result.candidate) candidates.push(result.candidate)
|
|
809
|
+
else if (result.nearMiss) search.nearMisses.push(result.nearMiss)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (candidates.length === 0) {
|
|
813
|
+
for (const findingsFile of listWorkspaceResearchFindings(options.workspaceRoot, planFindings)) {
|
|
814
|
+
search.fallbackResearchFilesSearched.push(findingsFile)
|
|
815
|
+
const findingsPath = safeWorkspacePath(options.workspaceRoot, findingsFile)
|
|
816
|
+
if (!findingsPath || !existsSync(findingsPath)) {
|
|
817
|
+
search.fallbackResearchFilesSkipped.push(findingsFile)
|
|
818
|
+
continue
|
|
819
|
+
}
|
|
820
|
+
const text = readTextPrefix(findingsPath, 100_000)
|
|
821
|
+
if (!text.trim()) {
|
|
822
|
+
search.fallbackResearchFilesSkipped.push(findingsFile)
|
|
823
|
+
continue
|
|
824
|
+
}
|
|
825
|
+
const result = candidateFromFindingsFile({
|
|
826
|
+
slide,
|
|
827
|
+
claimText,
|
|
828
|
+
queryTokens,
|
|
829
|
+
findingsFile,
|
|
830
|
+
text,
|
|
831
|
+
sourceKind: "researchesFallback",
|
|
832
|
+
})
|
|
833
|
+
if (result.candidate) candidates.push(result.candidate)
|
|
834
|
+
else if (result.nearMiss) search.nearMisses.push(result.nearMiss)
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
search.nearMisses = search.nearMisses
|
|
839
|
+
.sort((a, b) => b.bestScore - a.bestScore)
|
|
840
|
+
.slice(0, 5)
|
|
841
|
+
return {
|
|
842
|
+
candidates: candidates
|
|
843
|
+
.sort((a, b) => b.supportScope.length - a.supportScope.length)
|
|
844
|
+
.slice(0, 3),
|
|
845
|
+
search,
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function candidateFromFindingsFile({
|
|
850
|
+
slide,
|
|
851
|
+
claimText,
|
|
852
|
+
queryTokens,
|
|
853
|
+
findingsFile,
|
|
854
|
+
text,
|
|
855
|
+
sourceKind,
|
|
856
|
+
}: {
|
|
857
|
+
slide: SlideSpec
|
|
858
|
+
claimText: string
|
|
859
|
+
queryTokens: string[]
|
|
860
|
+
findingsFile: string
|
|
861
|
+
text: string
|
|
862
|
+
sourceKind: "researchPlan" | "researchesFallback"
|
|
863
|
+
}): { candidate?: EvidenceBindingCandidate; nearMiss?: EvidenceCandidateNearMiss } {
|
|
864
|
+
const lines = extractFindingsLines(text)
|
|
865
|
+
let best: { line: string; scope: string[]; score: number } | undefined
|
|
866
|
+
for (const line of lines) {
|
|
867
|
+
const normalizedLine = line.toLowerCase()
|
|
868
|
+
const scope = queryTokens.filter((token) => normalizedLine.includes(token))
|
|
869
|
+
const phraseScore = importantPhrases(slide).filter((phrase) => normalizedLine.includes(phrase)).length * 2
|
|
870
|
+
const score = scope.length + phraseScore
|
|
871
|
+
if (!best || score > best.score) best = { line, scope, score }
|
|
872
|
+
}
|
|
873
|
+
if (!best || best.score <= 0) return {}
|
|
874
|
+
|
|
875
|
+
const threshold = 2
|
|
876
|
+
const supportScope = [...new Set(best.scope)].slice(0, 8)
|
|
877
|
+
if (best.score < threshold) {
|
|
878
|
+
return {
|
|
879
|
+
nearMiss: {
|
|
880
|
+
findingsFile,
|
|
881
|
+
sourceKind,
|
|
882
|
+
bestScore: best.score,
|
|
883
|
+
threshold,
|
|
884
|
+
supportScope,
|
|
885
|
+
quote: best.line,
|
|
886
|
+
reason: `Best matching line scored ${best.score}, below binding threshold ${threshold}.`,
|
|
887
|
+
},
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const sourcePath = extractSourcePath(text)
|
|
892
|
+
const coverage = supportScope.length / Math.max(1, queryTokens.length)
|
|
893
|
+
const supportStrength = best.score >= Math.min(5, Math.max(3, queryTokens.length)) && coverage >= 0.5 ? "strong" : "partial"
|
|
894
|
+
const unsupportedScope = unsupportedClaimScope(slide, best.line).slice(0, 5)
|
|
895
|
+
const caveats = []
|
|
896
|
+
if (supportStrength === "partial") {
|
|
897
|
+
caveats.push("Candidate support is partial. Bind only the matched claim scope; do not use it to support unrelated future-state or recommendation claims on the same slide.")
|
|
898
|
+
}
|
|
899
|
+
if (sourceKind === "researchesFallback") {
|
|
900
|
+
caveats.push("Candidate was discovered from researches/ fallback and is not referenced by researchPlan; confirm relevance before binding it into slide evidence.")
|
|
901
|
+
}
|
|
902
|
+
if (unsupportedScope.length > 0) {
|
|
903
|
+
caveats.push(`Unsupported claim scope: ${unsupportedScope.join("; ")}.`)
|
|
904
|
+
}
|
|
905
|
+
const caveat = caveats.length > 0 ? caveats.join(" ") : undefined
|
|
906
|
+
const evidenceDraft: EvidenceRef = {
|
|
907
|
+
source: sourcePath || findingsFile,
|
|
908
|
+
findingsFile,
|
|
909
|
+
sourcePath,
|
|
910
|
+
location: "research findings excerpt",
|
|
911
|
+
quote: best.line,
|
|
912
|
+
caveat,
|
|
913
|
+
}
|
|
914
|
+
return {
|
|
915
|
+
candidate: {
|
|
916
|
+
candidateId: evidenceCandidateId(slide.index, findingsFile, best.line, supportScope),
|
|
917
|
+
slideIndex: slide.index,
|
|
918
|
+
slideTitle: slide.title,
|
|
919
|
+
claimText,
|
|
920
|
+
source: sourcePath || findingsFile,
|
|
921
|
+
findingsFile,
|
|
922
|
+
sourcePath,
|
|
923
|
+
location: "research findings excerpt",
|
|
924
|
+
quote: best.line,
|
|
925
|
+
caveat,
|
|
926
|
+
supportScope,
|
|
927
|
+
supportStrength,
|
|
928
|
+
sourceKind,
|
|
929
|
+
evidenceDraft,
|
|
930
|
+
unsupportedScope,
|
|
931
|
+
recommendedRewrite: recommendedEvidenceRewrite(supportScope, unsupportedScope),
|
|
932
|
+
},
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function unsupportedClaimScope(slide: SlideSpec, supportedLine: string): string[] {
|
|
937
|
+
const normalizedLine = supportedLine.toLowerCase()
|
|
938
|
+
const phrases = [slide.purpose, slide.content?.headline, ...(slide.content?.bullets ?? [])]
|
|
939
|
+
.map((item) => cleanMarkdownText(item ?? ""))
|
|
940
|
+
.filter((item) => item.length >= 8)
|
|
941
|
+
|
|
942
|
+
return [...new Set(phrases.filter((phrase) => {
|
|
943
|
+
const normalizedPhrase = phrase.toLowerCase()
|
|
944
|
+
return FUTURE_STATE_SCOPE_PATTERN.test(normalizedPhrase) && !normalizedLine.includes(normalizedPhrase)
|
|
945
|
+
}))]
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function recommendedEvidenceRewrite(supportScope: string[], unsupportedScope: string[]): string | undefined {
|
|
949
|
+
if (unsupportedScope.length === 0) return undefined
|
|
950
|
+
const supported = supportScope.length > 0 ? supportScope.join(", ") : "the quoted current-state support"
|
|
951
|
+
return `Bind this evidence only to the supported scope (${supported}). Reframe unsupported scope as internal synthesis, target-state hypothesis, or a separately sourced claim: ${unsupportedScope.join("; ")}.`
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function evidenceCandidateId(slideIndex: number, findingsFile: string, quote: string, supportScope: string[]): string {
|
|
955
|
+
const hash = createHash("sha1")
|
|
956
|
+
.update(JSON.stringify({ slideIndex, findingsFile, quote, supportScope }))
|
|
957
|
+
.digest("hex")
|
|
958
|
+
.slice(0, 8)
|
|
959
|
+
return `s${slideIndex}-${hash}`
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function cleanEvidenceRef(evidence: EvidenceRef): EvidenceRef {
|
|
963
|
+
const cleaned: EvidenceRef = { source: cleanMarkdownText(evidence.source) }
|
|
964
|
+
for (const key of ["quote", "page", "url", "sourcePath", "location", "findingsFile", "caveat", "extractedTextPath", "extractedManifestPath"] as const) {
|
|
965
|
+
const value = cleanOptionalText(evidence[key])
|
|
966
|
+
if (value) cleaned[key] = value
|
|
967
|
+
}
|
|
968
|
+
return cleaned
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function sameEvidenceRef(a: EvidenceRef, b: EvidenceRef): boolean {
|
|
972
|
+
return normalizeEvidenceComparable(a) === normalizeEvidenceComparable(b)
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function normalizeEvidenceComparable(evidence: EvidenceRef): string {
|
|
976
|
+
const cleaned = cleanEvidenceRef(evidence)
|
|
977
|
+
return JSON.stringify({
|
|
978
|
+
source: cleaned.source,
|
|
979
|
+
findingsFile: cleaned.findingsFile,
|
|
980
|
+
sourcePath: cleaned.sourcePath,
|
|
981
|
+
quote: cleaned.quote,
|
|
982
|
+
location: cleaned.location,
|
|
983
|
+
caveat: cleaned.caveat,
|
|
984
|
+
})
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function listWorkspaceResearchFindings(workspaceRoot: string, exclude: Set<string>): string[] {
|
|
988
|
+
const researchRoot = safeWorkspacePath(workspaceRoot, "researches")
|
|
989
|
+
if (!researchRoot || !existsSync(researchRoot)) return []
|
|
990
|
+
const files: string[] = []
|
|
991
|
+
collectMarkdownFiles(researchRoot, files, 0)
|
|
992
|
+
return files
|
|
993
|
+
.map((file) => normalizePath(file.slice(resolve(workspaceRoot).length + 1)))
|
|
994
|
+
.filter((file) => file.startsWith("researches/") && !exclude.has(file))
|
|
995
|
+
.slice(0, 50)
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function collectMarkdownFiles(dir: string, output: string[], depth: number): void {
|
|
999
|
+
if (depth > 4) return
|
|
1000
|
+
let entries: string[]
|
|
1001
|
+
try {
|
|
1002
|
+
entries = readdirSync(dir)
|
|
1003
|
+
} catch {
|
|
1004
|
+
return
|
|
1005
|
+
}
|
|
1006
|
+
for (const entry of entries) {
|
|
1007
|
+
const fullPath = join(dir, entry)
|
|
1008
|
+
let stat
|
|
1009
|
+
try {
|
|
1010
|
+
stat = statSync(fullPath)
|
|
1011
|
+
} catch {
|
|
1012
|
+
continue
|
|
1013
|
+
}
|
|
1014
|
+
if (stat.isDirectory()) {
|
|
1015
|
+
collectMarkdownFiles(fullPath, output, depth + 1)
|
|
1016
|
+
} else if (stat.isFile() && entry.endsWith(".md")) {
|
|
1017
|
+
output.push(fullPath)
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function safeWorkspacePath(workspaceRoot: string, relativePath: string): string | undefined {
|
|
1023
|
+
const root = resolve(workspaceRoot)
|
|
1024
|
+
const target = resolve(root, relativePath)
|
|
1025
|
+
if (target !== root && !target.startsWith(root + "/")) return undefined
|
|
1026
|
+
return target
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function readTextPrefix(filePath: string, maxChars: number): string {
|
|
1030
|
+
try {
|
|
1031
|
+
return readFileSync(filePath, "utf-8").slice(0, maxChars)
|
|
1032
|
+
} catch {
|
|
1033
|
+
return ""
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function extractFindingsLines(text: string): string[] {
|
|
1038
|
+
return text
|
|
1039
|
+
.split(/\r?\n/)
|
|
1040
|
+
.map((line) => line.replace(/^\s*(?:[-*+]\s+|\d+\.\s+|>\s*)/, "").trim())
|
|
1041
|
+
.filter((line) => line.length >= 24 && !/^---$/.test(line) && !/^#/.test(line))
|
|
1042
|
+
.slice(0, 300)
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function extractSourcePath(text: string): string | undefined {
|
|
1046
|
+
const sourceLine = text.split(/\r?\n/).find((line) => /^\s*(?:[-*+]\s*)?(?:source|来源)\s*:/i.test(line))
|
|
1047
|
+
if (!sourceLine) return undefined
|
|
1048
|
+
return cleanMarkdownText(sourceLine.replace(/^\s*(?:[-*+]\s*)?(?:source|来源)\s*:\s*/i, "")) || undefined
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function meaningfulTokens(text: string): string[] {
|
|
1052
|
+
const normalized = text.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, " ")
|
|
1053
|
+
const latin = normalized
|
|
1054
|
+
.split(/\s+/)
|
|
1055
|
+
.map((token) => token.trim())
|
|
1056
|
+
.filter((token) => token.length >= 4 && !EVIDENCE_BINDING_STOPWORDS.has(token))
|
|
1057
|
+
const chinese = Array.from(normalized.matchAll(/[\u4e00-\u9fa5]{2,}/g), (match) => match[0])
|
|
1058
|
+
return [...new Set([...latin, ...chinese])].slice(0, 40)
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function importantPhrases(slide: SlideSpec): string[] {
|
|
1062
|
+
return [slide.title, slide.content?.headline, ...(slide.content?.bullets ?? [])]
|
|
1063
|
+
.map((item) => item?.trim().toLowerCase())
|
|
1064
|
+
.filter((item): item is string => Boolean(item && item.length >= 8 && item.length <= 80))
|
|
1065
|
+
}
|
|
1066
|
+
|
|
620
1067
|
function computeNarrativeReadinessIssues(deck: DeckSpec): ReadinessIssue[] {
|
|
621
1068
|
const issues: ReadinessIssue[] = []
|
|
622
1069
|
const slides = deck.slides.filter((slide) => slide.index > 0).sort((a, b) => a.index - b.index)
|
|
623
1070
|
if (slides.length === 0) return issues
|
|
1071
|
+
const decisionOriented = isDecisionOrientedDeck(deck, slides)
|
|
1072
|
+
|
|
1073
|
+
if (decisionOriented && !hasNarrativeBriefContent(deck.narrativeBrief)) {
|
|
1074
|
+
issues.push(warningIssue(
|
|
1075
|
+
"narrative_gap",
|
|
1076
|
+
"Narrative brief is missing for a decision-oriented deck",
|
|
1077
|
+
"Add a 0.9 narrativeBrief with audience belief before/after, decisionOrAction, narrativeArc, keyClaims, objections, and risks so review can compile the deck against explicit story intent.",
|
|
1078
|
+
))
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (decisionOriented && deck.narrativeBrief) {
|
|
1082
|
+
if (!deck.narrativeBrief.audienceBeliefAfter?.trim()) {
|
|
1083
|
+
issues.push(warningIssue(
|
|
1084
|
+
"narrative_gap",
|
|
1085
|
+
"Narrative brief is missing the intended audience belief after the deck",
|
|
1086
|
+
"Set narrativeBrief.audienceBeliefAfter so the deck can be reviewed against the belief change it is meant to create.",
|
|
1087
|
+
))
|
|
1088
|
+
}
|
|
1089
|
+
if (!deck.narrativeBrief.decisionOrAction?.trim() && slides.some((slide) => isAskSlide(slide) || isRecommendationSlide(slide))) {
|
|
1090
|
+
issues.push(warningIssue(
|
|
1091
|
+
"narrative_gap",
|
|
1092
|
+
"Narrative brief is missing the decision or action the deck should drive",
|
|
1093
|
+
"Set narrativeBrief.decisionOrAction so recommendation and ask slides have an explicit communication target.",
|
|
1094
|
+
))
|
|
1095
|
+
}
|
|
1096
|
+
if (deck.narrativeBrief.keyClaims.length === 0 && slides.some(isRecommendationSlide)) {
|
|
1097
|
+
issues.push(warningIssue(
|
|
1098
|
+
"narrative_gap",
|
|
1099
|
+
"Narrative brief has no key claims for the recommendation to prove",
|
|
1100
|
+
"Add narrativeBrief.keyClaims that capture the main claims the deck must support with slide evidence.",
|
|
1101
|
+
))
|
|
1102
|
+
}
|
|
1103
|
+
if (deck.narrativeBrief.objections.length === 0 && slides.some((slide) => isAskSlide(slide) || isRecommendationSlide(slide))) {
|
|
1104
|
+
issues.push(warningIssue(
|
|
1105
|
+
"narrative_gap",
|
|
1106
|
+
"Narrative brief has no stakeholder objections to handle",
|
|
1107
|
+
"Add likely objections or questions to narrativeBrief.objections so the story can anticipate resistance before the ask.",
|
|
1108
|
+
))
|
|
1109
|
+
}
|
|
1110
|
+
if (deck.narrativeBrief.risks.length === 0 && slides.some(isRecommendationSlide)) {
|
|
1111
|
+
issues.push(warningIssue(
|
|
1112
|
+
"narrative_gap",
|
|
1113
|
+
"Narrative brief has no risks, assumptions, or tradeoffs for the recommendation",
|
|
1114
|
+
"Add risks, assumptions, caveats, or tradeoffs to narrativeBrief.risks so the recommendation does not overclaim certainty.",
|
|
1115
|
+
))
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
624
1118
|
|
|
625
1119
|
if (slides.length >= 4 && slides.every((slide) => !slide.narrativeRole)) {
|
|
626
1120
|
issues.push(warningIssue(
|
|
@@ -700,6 +1194,26 @@ function computeNarrativeReadinessIssues(deck: DeckSpec): ReadinessIssue[] {
|
|
|
700
1194
|
return issues
|
|
701
1195
|
}
|
|
702
1196
|
|
|
1197
|
+
function isDecisionOrientedDeck(deck: DeckSpec, slides: SlideSpec[]): boolean {
|
|
1198
|
+
return Boolean(
|
|
1199
|
+
deck.narrativeBrief?.decisionOrAction?.trim() ||
|
|
1200
|
+
slides.some((slide) => isAskSlide(slide) || isRecommendationSlide(slide)) ||
|
|
1201
|
+
/\b(decision|approve|approval|recommend(?:ation)?|go\/?no-go|action)\b|决策|批准|建议|行动/.test(deck.goal.toLowerCase()),
|
|
1202
|
+
)
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function hasNarrativeBriefContent(brief: NarrativeBrief | undefined): boolean {
|
|
1206
|
+
return Boolean(
|
|
1207
|
+
brief?.audienceBeliefBefore?.trim() ||
|
|
1208
|
+
brief?.audienceBeliefAfter?.trim() ||
|
|
1209
|
+
brief?.decisionOrAction?.trim() ||
|
|
1210
|
+
brief?.narrativeArc?.trim() ||
|
|
1211
|
+
brief?.keyClaims.length ||
|
|
1212
|
+
brief?.objections.length ||
|
|
1213
|
+
brief?.risks.length,
|
|
1214
|
+
)
|
|
1215
|
+
}
|
|
1216
|
+
|
|
703
1217
|
function blockerIssue(type: ReadinessIssueType, message: string, suggestedAction: string, extra: Partial<ReadinessIssue> = {}): ReadinessIssue {
|
|
704
1218
|
return { type, severity: "blocker", message, suggestedAction, ...extra }
|
|
705
1219
|
}
|
|
@@ -749,6 +1263,25 @@ function hasClearEnding(slides: SlideSpec[]): boolean {
|
|
|
749
1263
|
return finalSlides.some((slide) => slide.narrativeRole === "recommendation" || slide.narrativeRole === "ask" || slide.narrativeRole === "close" || /\b(so what|takeaway|recommend(?:ation)?|decision|ask|next step|conclusion|close)\b|结论|建议|决策|请求|下一步|收尾|总结/.test(slideSearchText(slide)))
|
|
750
1264
|
}
|
|
751
1265
|
|
|
1266
|
+
function isNavigationSlide(slide: SlideSpec): boolean {
|
|
1267
|
+
const text = slideSearchText(slide)
|
|
1268
|
+
return slide.layout === "toc" || /\b(table of contents|agenda|contents|outline|section guide)\b|目录|议程|大纲/.test(text)
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function isIgnorableSourceMaterial(path: string): boolean {
|
|
1272
|
+
const normalized = normalizePath(path).replace(/^\.\//, "")
|
|
1273
|
+
const name = basename(normalized)
|
|
1274
|
+
return Boolean(
|
|
1275
|
+
name.startsWith("~$") ||
|
|
1276
|
+
/^(AGENTS|README(?:\.zh-CN)?|DECKS)\.md$/.test(name) ||
|
|
1277
|
+
name === DECKS_STATE_FILE ||
|
|
1278
|
+
normalized.startsWith("decks/") ||
|
|
1279
|
+
normalized.startsWith("researches/") ||
|
|
1280
|
+
normalized.startsWith("assets/") ||
|
|
1281
|
+
normalized.startsWith(".opencode/"),
|
|
1282
|
+
)
|
|
1283
|
+
}
|
|
1284
|
+
|
|
752
1285
|
function slideSearchText(slide: SlideSpec): string {
|
|
753
1286
|
return [
|
|
754
1287
|
slide.title,
|
|
@@ -825,6 +1358,36 @@ const EVIDENCE_SENSITIVE_TERMS = [
|
|
|
825
1358
|
/可扩展/,
|
|
826
1359
|
]
|
|
827
1360
|
|
|
1361
|
+
const FUTURE_STATE_SCOPE_PATTERN = /\b(20\d{2}|future|target-state|end state|roadmap|pathway|architecture|capabilit(?:y|ies)|autonomy|autonomous|self-organizing|ecosystem|ai manufacturing os|ai brain|digital workers|closed-loop|orchestration)\b|未来|目标态|路线图|架构|能力|自治|自组织|生态|智能体|闭环/
|
|
1362
|
+
|
|
1363
|
+
const EVIDENCE_BINDING_STOPWORDS = new Set([
|
|
1364
|
+
"about",
|
|
1365
|
+
"after",
|
|
1366
|
+
"again",
|
|
1367
|
+
"also",
|
|
1368
|
+
"before",
|
|
1369
|
+
"between",
|
|
1370
|
+
"could",
|
|
1371
|
+
"deck",
|
|
1372
|
+
"from",
|
|
1373
|
+
"have",
|
|
1374
|
+
"into",
|
|
1375
|
+
"must",
|
|
1376
|
+
"only",
|
|
1377
|
+
"over",
|
|
1378
|
+
"page",
|
|
1379
|
+
"roadmap",
|
|
1380
|
+
"show",
|
|
1381
|
+
"slide",
|
|
1382
|
+
"that",
|
|
1383
|
+
"their",
|
|
1384
|
+
"there",
|
|
1385
|
+
"this",
|
|
1386
|
+
"through",
|
|
1387
|
+
"with",
|
|
1388
|
+
"would",
|
|
1389
|
+
])
|
|
1390
|
+
|
|
828
1391
|
function normalizeSlides(slides: SlideSpec[]): SlideSpec[] {
|
|
829
1392
|
return slides
|
|
830
1393
|
.map((slide) => ({
|
|
@@ -839,6 +1402,38 @@ function normalizeSlides(slides: SlideSpec[]): SlideSpec[] {
|
|
|
839
1402
|
.sort((a, b) => a.index - b.index)
|
|
840
1403
|
}
|
|
841
1404
|
|
|
1405
|
+
function normalizeNarrativeBrief(brief: NarrativeBrief | undefined): NarrativeBrief | undefined {
|
|
1406
|
+
if (!brief) return undefined
|
|
1407
|
+
const normalized: NarrativeBrief = {
|
|
1408
|
+
audienceBeliefBefore: cleanOptionalText(brief.audienceBeliefBefore),
|
|
1409
|
+
audienceBeliefAfter: cleanOptionalText(brief.audienceBeliefAfter),
|
|
1410
|
+
decisionOrAction: cleanOptionalText(brief.decisionOrAction),
|
|
1411
|
+
narrativeArc: cleanOptionalText(brief.narrativeArc),
|
|
1412
|
+
keyClaims: normalizeTextList(brief.keyClaims),
|
|
1413
|
+
objections: normalizeTextList(brief.objections),
|
|
1414
|
+
risks: normalizeTextList(brief.risks),
|
|
1415
|
+
}
|
|
1416
|
+
if (
|
|
1417
|
+
!normalized.audienceBeliefBefore &&
|
|
1418
|
+
!normalized.audienceBeliefAfter &&
|
|
1419
|
+
!normalized.decisionOrAction &&
|
|
1420
|
+
!normalized.narrativeArc &&
|
|
1421
|
+
normalized.keyClaims.length === 0 &&
|
|
1422
|
+
normalized.objections.length === 0 &&
|
|
1423
|
+
normalized.risks.length === 0
|
|
1424
|
+
) return undefined
|
|
1425
|
+
return normalized
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function normalizeTextList(values: string[] | undefined): string[] {
|
|
1429
|
+
return [...new Set((values ?? []).map(cleanOptionalText).filter(Boolean) as string[])]
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function cleanOptionalText(value: string | undefined): string | undefined {
|
|
1433
|
+
const text = String(value ?? "").trim()
|
|
1434
|
+
return text || undefined
|
|
1435
|
+
}
|
|
1436
|
+
|
|
842
1437
|
function hasSlideContent(slide: SlideSpec): boolean {
|
|
843
1438
|
const content = slide.content ?? {}
|
|
844
1439
|
return Boolean(
|