@cyber-dash-tech/revela 0.9.0 → 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/commands/help.ts +2 -0
- package/lib/commands/inspect.ts +23 -0
- package/lib/commands/refine.ts +26 -0
- package/lib/commands/review.ts +8 -2
- package/lib/decks-state.ts +476 -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 +22 -0
- package/skill/SKILL.md +10 -5
- package/tools/decks.ts +12 -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"
|
|
@@ -161,6 +162,7 @@ export interface DeckStateReadinessResult {
|
|
|
161
162
|
blockers: string[]
|
|
162
163
|
warnings: string[]
|
|
163
164
|
issues: ReadinessIssue[]
|
|
165
|
+
evidenceCandidates?: EvidenceBindingCandidate[]
|
|
164
166
|
}
|
|
165
167
|
|
|
166
168
|
export type ReadinessSeverity = "blocker" | "warning"
|
|
@@ -182,10 +184,70 @@ export interface ReadinessIssue {
|
|
|
182
184
|
slideIndex?: number
|
|
183
185
|
slideTitle?: string
|
|
184
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
|
|
185
243
|
}
|
|
186
244
|
|
|
187
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."
|
|
188
246
|
|
|
247
|
+
export interface ReviewDeckStateOptions {
|
|
248
|
+
workspaceRoot?: string
|
|
249
|
+
}
|
|
250
|
+
|
|
189
251
|
export function decksStatePath(workspaceRoot: string): string {
|
|
190
252
|
return join(workspaceRoot, DECKS_STATE_FILE)
|
|
191
253
|
}
|
|
@@ -311,7 +373,65 @@ export function upsertSlides(state: DecksState, slug: string, slides: SlideSpec[
|
|
|
311
373
|
return normalized
|
|
312
374
|
}
|
|
313
375
|
|
|
314
|
-
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 } {
|
|
315
435
|
const normalized = normalizeDecksState(state)
|
|
316
436
|
const key = normalizeSlug(slug || currentDeckKey(normalized) || "")
|
|
317
437
|
const deck = key ? normalized.decks[key] : undefined
|
|
@@ -335,9 +455,10 @@ export function reviewDeckState(state: DecksState, slug?: string): { state: Deck
|
|
|
335
455
|
}
|
|
336
456
|
}
|
|
337
457
|
|
|
338
|
-
const issues = computeDeckReadinessIssues(deck, normalized.workspace)
|
|
458
|
+
const issues = computeDeckReadinessIssues(deck, normalized.workspace, options)
|
|
339
459
|
const blockers = issues.filter((issue) => issue.severity === "blocker").map((issue) => issue.message)
|
|
340
460
|
const warnings = issues.filter((issue) => issue.severity === "warning").map((issue) => issue.message)
|
|
461
|
+
const evidenceCandidates = issues.flatMap((issue) => issue.evidenceCandidates ?? [])
|
|
341
462
|
deck.writeReadiness = {
|
|
342
463
|
status: blockers.length === 0 ? "ready" : "blocked",
|
|
343
464
|
blockers,
|
|
@@ -356,6 +477,7 @@ export function reviewDeckState(state: DecksState, slug?: string): { state: Deck
|
|
|
356
477
|
blockers,
|
|
357
478
|
warnings,
|
|
358
479
|
issues,
|
|
480
|
+
evidenceCandidates,
|
|
359
481
|
},
|
|
360
482
|
}
|
|
361
483
|
}
|
|
@@ -461,6 +583,7 @@ export function buildDecksStatePromptLayer(workspaceRoot: string, maxChars = 140
|
|
|
461
583
|
let text = JSON.stringify(compact, null, 2)
|
|
462
584
|
if (text.length > maxChars) text = text.slice(0, maxChars).trimEnd() + "\n[DECKS.json state truncated for prompt size.]"
|
|
463
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.`
|
|
464
587
|
}
|
|
465
588
|
|
|
466
589
|
function compactWorkspaceForPrompt(workspace: DecksState["workspace"]): DecksState["workspace"] {
|
|
@@ -562,7 +685,7 @@ function currentDeckBlocker(state: DecksState): string {
|
|
|
562
685
|
return `${DECKS_STATE_FILE} contains multiple deck records and no activeDeck. Select one current deck explicitly or move extra decks to separate workspaces.`
|
|
563
686
|
}
|
|
564
687
|
|
|
565
|
-
function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["workspace"]): ReadinessIssue[] {
|
|
688
|
+
function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["workspace"], options: ReviewDeckStateOptions = {}): ReadinessIssue[] {
|
|
566
689
|
const issues: ReadinessIssue[] = []
|
|
567
690
|
if (!deck.goal.trim()) issues.push(blockerIssue("missing_slide_spec", "Deck goal is missing", "Set the deck goal through revela-decks upsertDeck."))
|
|
568
691
|
if (!isDeckHtmlPath(deck.outputPath)) {
|
|
@@ -592,12 +715,18 @@ function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["works
|
|
|
592
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))
|
|
593
716
|
|
|
594
717
|
const claim = findEvidenceSensitiveClaim(slide)
|
|
595
|
-
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)
|
|
596
720
|
issues.push(blockerIssue(
|
|
597
721
|
"missing_evidence",
|
|
598
722
|
`Slide ${slide.index} has an evidence-sensitive claim without evidence: ${claim}`,
|
|
599
723
|
SOURCE_TRACE_ACTION,
|
|
600
|
-
{
|
|
724
|
+
{
|
|
725
|
+
...slideRef,
|
|
726
|
+
claimText: claim,
|
|
727
|
+
evidenceCandidates: evidenceCandidates.length > 0 ? evidenceCandidates : undefined,
|
|
728
|
+
evidenceCandidateSearch,
|
|
729
|
+
},
|
|
601
730
|
))
|
|
602
731
|
} else if (claim && slide.evidence.some((item) => !hasEvidenceDetail(item))) {
|
|
603
732
|
issues.push(warningIssue(
|
|
@@ -624,6 +753,7 @@ function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["works
|
|
|
624
753
|
const hasNeededResearch = deck.researchPlan.some((axis) => axis.needed && axis.status !== "skipped")
|
|
625
754
|
for (const material of workspace.sourceMaterials ?? []) {
|
|
626
755
|
if (material.status !== "discovered") continue
|
|
756
|
+
if (isIgnorableSourceMaterial(material.path)) continue
|
|
627
757
|
const message = `Source material ${material.path} has been identified but not extracted, summarized, or researched`
|
|
628
758
|
if (hasNeededResearch) {
|
|
629
759
|
issues.push(blockerIssue(
|
|
@@ -643,6 +773,297 @@ function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["works
|
|
|
643
773
|
return issues
|
|
644
774
|
}
|
|
645
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
|
+
|
|
646
1067
|
function computeNarrativeReadinessIssues(deck: DeckSpec): ReadinessIssue[] {
|
|
647
1068
|
const issues: ReadinessIssue[] = []
|
|
648
1069
|
const slides = deck.slides.filter((slide) => slide.index > 0).sort((a, b) => a.index - b.index)
|
|
@@ -842,6 +1263,25 @@ function hasClearEnding(slides: SlideSpec[]): boolean {
|
|
|
842
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)))
|
|
843
1264
|
}
|
|
844
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
|
+
|
|
845
1285
|
function slideSearchText(slide: SlideSpec): string {
|
|
846
1286
|
return [
|
|
847
1287
|
slide.title,
|
|
@@ -918,6 +1358,36 @@ const EVIDENCE_SENSITIVE_TERMS = [
|
|
|
918
1358
|
/可扩展/,
|
|
919
1359
|
]
|
|
920
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
|
+
|
|
921
1391
|
function normalizeSlides(slides: SlideSpec[]): SlideSpec[] {
|
|
922
1392
|
return slides
|
|
923
1393
|
.map((slide) => ({
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync } from "fs"
|
|
2
|
+
import { ACTIVE_PROMPT_FILE } from "../config"
|
|
3
|
+
import { ctx } from "../ctx"
|
|
4
|
+
import { seedBuiltinDesigns } from "../design/designs"
|
|
5
|
+
import { seedBuiltinDomains } from "../domain/domains"
|
|
6
|
+
import { ensureEditableDeckState } from "../edit/deck-state"
|
|
7
|
+
import { openUrl } from "../edit/open"
|
|
8
|
+
import { resolveEditableDeck, type EditableDeck } from "../edit/resolve-deck"
|
|
9
|
+
import { buildPrompt } from "../prompt-builder"
|
|
10
|
+
import { startInspectServer } from "./server"
|
|
11
|
+
|
|
12
|
+
export interface OpenInspectDeckResult {
|
|
13
|
+
deck: EditableDeck
|
|
14
|
+
url: string
|
|
15
|
+
source: string
|
|
16
|
+
stateNote: string
|
|
17
|
+
preflightChanged: boolean
|
|
18
|
+
reusedSession: boolean
|
|
19
|
+
openedBrowser: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface OpenInspectDeckOptions {
|
|
23
|
+
client: any
|
|
24
|
+
sessionID: string
|
|
25
|
+
workspaceRoot: string
|
|
26
|
+
openBrowser?: boolean
|
|
27
|
+
openUrl?: (url: string) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function openInspectDeck(target: string, options: OpenInspectDeckOptions): OpenInspectDeckResult {
|
|
31
|
+
const deck = resolveEditableDeck(options.workspaceRoot, target)
|
|
32
|
+
const preflight = ensureEditableDeckState(options.workspaceRoot, deck)
|
|
33
|
+
|
|
34
|
+
ctx.enabled = true
|
|
35
|
+
if (!existsSync(ACTIVE_PROMPT_FILE)) {
|
|
36
|
+
seedBuiltinDesigns()
|
|
37
|
+
seedBuiltinDomains()
|
|
38
|
+
buildPrompt()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const inspectServer = startInspectServer()
|
|
42
|
+
const session = inspectServer.getOrCreateSession({
|
|
43
|
+
client: options.client,
|
|
44
|
+
sessionID: options.sessionID,
|
|
45
|
+
workspaceRoot: options.workspaceRoot,
|
|
46
|
+
deck,
|
|
47
|
+
})
|
|
48
|
+
const url = `${inspectServer.baseUrl}/inspect?token=${encodeURIComponent(session.token)}`
|
|
49
|
+
const shouldOpen = options.openBrowser !== false
|
|
50
|
+
if (shouldOpen) (options.openUrl ?? openUrl)(url)
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
deck,
|
|
54
|
+
url,
|
|
55
|
+
source: deck.source === "decks-state" ? "DECKS.json" : deck.source === "file-path" ? "file path" : "fallback path",
|
|
56
|
+
stateNote: preflight.changed ? "Deck state was prepared in DECKS.json for inspection." : "Deck state already points to this inspection target.",
|
|
57
|
+
preflightChanged: preflight.changed,
|
|
58
|
+
reusedSession: session.reused,
|
|
59
|
+
openedBrowser: shouldOpen,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { InspectionPromptProjection } from "../inspection-context/project"
|
|
2
|
+
|
|
3
|
+
export function buildInspectionPrompt(input: {
|
|
4
|
+
requestId: string
|
|
5
|
+
file: string
|
|
6
|
+
projection: InspectionPromptProjection
|
|
7
|
+
}): string {
|
|
8
|
+
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
|
+
|
|
10
|
+
Target file: ${input.file}
|
|
11
|
+
Inspection request id: ${input.requestId}
|
|
12
|
+
|
|
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.
|
|
14
|
+
|
|
15
|
+
Return the result only by calling the \`revela-inspection-result\` tool with this request id. Do not answer in chat.
|
|
16
|
+
|
|
17
|
+
Required card model:
|
|
18
|
+
- 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
|
+
- 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
|
+
|
|
21
|
+
Boundaries:
|
|
22
|
+
- 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.
|
|
24
|
+
- Do not turn every caveat into a problem.
|
|
25
|
+
- If confidence is low, use unclear or unknown instead of pretending certainty.
|
|
26
|
+
|
|
27
|
+
Projection JSON:
|
|
28
|
+
|
|
29
|
+
\`\`\`json
|
|
30
|
+
${JSON.stringify(input.projection, null, 2)}
|
|
31
|
+
\`\`\``
|
|
32
|
+
}
|