Skip to content

docs: Synthesis Document Generation — implementation plan (#027)#3

Merged
Dev-020 merged 14 commits into
mainfrom
feat/synthesis-document-plan
May 11, 2026
Merged

docs: Synthesis Document Generation — implementation plan (#027)#3
Dev-020 merged 14 commits into
mainfrom
feat/synthesis-document-plan

Conversation

@Dev-020
Copy link
Copy Markdown
Owner

@Dev-020 Dev-020 commented May 11, 2026

Summary

Pipeline design

Phase 0  [Human]     Add source anchor node to canvas (reference/entity type)
Phase 1  [No AI]     Build edge map from influencedByIndices graph
Phase 2a [AI Call A] ──────────────────────────────────────── \
  Decontextualize each node → self-contained statement          ├─→ Phase 2c [AI Call C] → Phase 3 → Vault
Phase 2b [AI Call B] ──────────────────────────────────────── /
  Cluster nodes into named sections (lightweight model)
Phase 4  [Human]     Review output against source via external AI tools

Calls A and B run in parallel. Call C is sequential after both complete.

Key design decisions

  • Source anchor node (Phase 0) gives the AI a structural reference for what the notes are based on — added manually by the user, no code needed
  • Decontextualization (Call A) rewrites each sparse node into a self-contained statement using only its graph neighbors and source anchor — no external knowledge fill
  • Semantic clustering (Call B) groups notes by meaning rather than graph topology; uses a lightweight model since output is only node ID groupings
  • Synthesis (Call C) writes section intros and expounding prompts — open Socratic questions that push thinking beyond the notes, not recall questions
  • Human review (Phase 4) requires no code: the output is a standard .md file any AI tool with vault access can annotate and correct

Files changed

File Change
BACKLOG.md Redesign mskayyali#27, update mskayyali#28, close mskayyali#22mskayyali#26
docs/synthesis-document-plan.md Full implementation spec (new)

No implementation code in this PR — spec only. Implementation follows in a separate PR.

🤖 Generated with Claude Code

Dev-020 and others added 14 commits May 11, 2026 18:24
…skayyali#28)

- Redesign mskayyali#27 from "Study Guide Export" to "Synthesis Document
  Generation" with a three-phase AI pipeline (decontextualize,
  cluster, synthesize) plus human-owned phases 0 and 4
- Add docs/synthesis-document-plan.md with full implementation spec:
  data types, prompt structures, file breakdown, open questions
- Update mskayyali#28 to reflect that Phase 4 (human review) requires no
  plugin code — handled externally via Claude Code / Gemini CLI
- Close mskayyali#22mskayyali#26 in Completed section (all shipped in plugin PR #2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three-phase AI pipeline that consolidates sparse nodespace notes into
a structured, contextualized markdown document:

- lib/synthesis.ts
  - buildEdgeMap(): Phase 1 — serialises block graph from
    influencedBy edges; separates reference nodes as source anchors
  - callDecontextualize(): Call A — rewrites each node into a
    self-contained statement using only its graph neighbours and
    source anchor as context (no external knowledge fill)
  - callCluster(): Call B — groups nodes into named sections by
    semantic meaning; post-processes to ensure every ID is assigned
    to exactly one section, missing nodes go to "General"
  - callSynthesize(): Call C — writes section headings, intros,
    expounding prompts (Socratic, not recall), and gap markers;
    nodeIds merged from clustering step by index
  - generateSynthesisDocument(): orchestrates Phases 1–3 with
    calls A+B in parallel then call C sequential
- lib/synthesis-export.ts
  - renderSynthesisDocument(): Phase 3 renderer — YAML frontmatter,
    self-contained statements with source attribution, expounding
    prompts and gaps as Obsidian callout blocks
- components/vim-input.tsx
  - Add "Synthesis document" action to command palette
  - pluginOnly: false (web-app only for now; plugin port later)
- app/page.tsx
  - Handle "synthesis-doc" command: captures project snapshot
    synchronously then runs async pipeline, downloads result
  - synthesisStatus state drives a spinner toast during generation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous setProjects(prev => ...) trick for reading state is not
reliable in React 18 concurrent mode — the updater is not guaranteed
to run before the subsequent if (blocks) check.

projectsRef.current is already the established pattern in this file
for reading the latest project state synchronously inside callbacks
(see enrichBlock, generateGhost, moveBlock, copyBlock). Use it here
for consistency and correctness.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Log first 1000 chars of raw response before parsing so parse
  failures show what the AI actually returned
- Wrap parse error to include the raw response start in the message
- Normalise common alternative top-level keys (data, synthesis,
  overview, description) so models that wrap the output don't fail
- Error message now includes the actual keys the AI returned, making
  the next debugging round self-diagnosing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On any JSON parse or shape error in callSynthesize, download the
raw response as synthesis-call-c-raw.txt instead of logging to
console. Makes the actual AI output inspectable without DevTools.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Gemini returned the sections array directly at the top level instead
of wrapping it in { summary, sections }. Keys 0,1,2,3,4 in the error
confirm this — Object.keys on an array returns its indices.

Now detects Array.isArray(parsed) and uses the array as sections
directly with an empty summary, rather than failing the shape check.
Also widens the object-path to check data/synthesis as section keys
and overview/description as summary keys.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pipeline changes:
- Replace single callSynthesize with callSynthesizeCluster (Call C×N):
  parallel per-cluster synthesis, each receiving full nodespace context
  (all statements + annotations + document structure) steered to its
  target section. Parallel processes instructed not to explain or
  prompt about other sections.
- Add callPolish (Call D): optional sequential editorial pass over
  the rendered draft markdown, forbidden from touching source
  attributions or callout blocks
- generateSynthesisDocument now emits structured ProgressEvents
  (phase_start, phase_done, clusters_known, error) and returns
  CallTiming[] for the footer; Call D handled by page.tsx
- SynthesisSection gains sectionSynthesis (flowing prose paragraphs)
- SynthesisOutline drops top-level summary (no Call D for that)

Renderer changes (synthesis-export.ts):
- sectionSynthesis prose leads each section
- Source notes moved to collapsible [!note]- callout
- Timing table in collapsible [!info]- footer on both files
- renderPolishedDocument() appends timing footer to Call D output
- isPolished flag tags frontmatter and title

New components:
- SynthesisConfirmDialog: pre-generation modal showing source anchors
  (or warning if none), node count, polish toggle, and warnings about
  node snapshot, token costs, and time estimates
- SynthesisProgressPanel: clickable bottom pill that opens a progress
  dialog with live per-call status, elapsed timers for running calls,
  parallel/sequential grouping, and completion summary

page.tsx wiring:
- synthesis-doc command opens confirm dialog (no immediate generation)
- startSynthesis(enablePolish) captures project snapshot, runs
  pipeline, downloads raw .md, optionally runs Call D and downloads
  polished .md
- Synthesis state replaces old synthesisStatus string with structured
  CallTiming[] and isActive/isDialogOpen booleans

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
LLMs generating LaTeX inside JSON strings sometimes emit single
backslashes (\mathbb, \frac, \cong) which are invalid JSON escape
sequences. JSON.parse throws on \m, \c etc. — only \", \, \/, \n,
\r, \t, \b, \f, \uXXXX are legal.

Fix: on first parse failure, apply a regex that escapes any backslash
not already part of a valid JSON escape sequence, then retry. The
outer catch still saves the raw dump if the fixed parse also fails.

Regex: /\(?!["\/bfnrtu])/g — matches \ not followed by a JSON
escape character, replaces with \. Verified against all four raw
dump files from the test run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous implementation checked for '[' first. Synthesis responses
are JSON objects that contain arrays (expandingPrompts, gaps). When
Gemini returned without a fenced code block, extractJson found the '['
inside expandingPrompts and sliced from there to the last ']', producing
a broken substring like '["p1","p2"],\n  "gaps":["g1"]'. Both JSON.parse
attempts then failed. The dump showed the original raw string (which was
always valid) because dumpRawResponse is called with raw, not jsonStr.

Fix: find which delimiter appears first in the text and use that.
- Object responses (synthesis): '{' appears before any '[' in the JSON body
- Array responses (decontextualize, cluster): '[' is the first char
This correctly handles both without affecting the other callers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The pill previously stayed visible indefinitely after synthesis
completed. Now shows a small X on the right side of the pill when
isActive=false, allowing the user to dismiss it. The pill is also
reset (undismissed) when a new synthesis starts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Raw file: total of all Call A/B/C timings.
Polished file: total including Call D, injected into the polished
markdown's existing frontmatter (replaces if already present from
a previous run, inserts before closing --- if absent).

The two files now have different generated_in values reflecting
when each actually completed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summing all timing durations gives inflated numbers because parallel
calls (A+B together, all Cs together) run simultaneously. The sum
counted each worker's time independently, e.g. 5m 6s vs the actual
~2m 29s the user waited.

Fix: capture generationStart = Date.now() at the top of startSynthesis
and compute wall-clock elapsed at each download point:
- raw file:     Date.now() - generationStart  (after all Cs complete)
- polished file: Date.now() - generationStart  (after Call D completes)

Both renderers now take wallClockMs? as an explicit parameter rather
than deriving from the timing array.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- renderTimingTable now takes wallClockMs? and uses it for the Total
  row label ("Total (wall-clock)"), consistent with the generated_in
  frontmatter field. Both renderSynthesisDocument and
  renderPolishedDocument pass their wallClockMs through.
- Remove clusters_known from ProgressEvent type and its emission in
  callCluster — the event was never consumed by the UI handler since
  Call C phase_start events fire immediately after B completes,
  making the signal redundant.
- Update BACKLOG mskayyali#27 to reflect web-app implementation is complete;
  plugin port remains open as future work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Transport layer:
- lib/synthesis.ts: export SynthesisCallFn type; rename internal callAI
  to defaultCallAI; all call functions (callDecontextualize, callCluster,
  callSynthesizeCluster, callPolish, generateSynthesisDocument) accept
  optional callFn/injectedConfig so the plugin can swap fetch/api/ai
  for requestUrl/spawnCLI without duplicating any prompt or parsing logic
- plugin/src/ai-adapter.ts: add makeSynthesisCallFn(plugin) which returns
  a SynthesisCallFn — routes Gemini CLI through spawnCLI("gemini",
  ["--policy","simple"], combinedPrompt), all other providers via
  requestUrl() directly to the provider (no /api/ai proxy needed)

UI components:
- SynthesisConfirmDialog: add container? prop; render via createPortal
  when provided so it scopes to the Obsidian leaf instead of document.body
- SynthesisProgressPanel: same container?/portal treatment

Plugin wiring (plugin/src/view.tsx):
- NodepadView gains synthesisTriggerRef and triggerSynthesis() method
  (called from the command, shows Notice if no canvas is open)
- NodepadApp gains full synthesis state (same as page.tsx) wired to
  synthesisTriggerRef so the command opens the confirm dialog
- startSynthesis() runs the pipeline with makeSynthesisCallFn and
  writes output via app.vault.create() + workspace.getLeaf().openFile()
  — files appear in the same folder as the canvas, opened immediately
  in a new tab; no browser download
- Error surfaced via Obsidian Notice instead of console-only

Command (plugin/src/main.ts):
- Register "Generate Synthesis Document" with checkCallback that
  requires an active NodepadView

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant