Files
petal/BUILD_PLAN.md
prosolis 0fa70979a0 Phase 5: voice consistency pass
Tier-1 voice-consistency pass: whole-document LLM review surfacing passages
that read tonally out of place (formal/over-polished/paraphrased-too-closely),
as honey-decorated `voice` flags with no correction (awareness-only).

- internal/llm/voice.go: RunVoice sends the whole document (no TruncateDoc),
  MaxTokens 2048, 20s per-doc floor (VoiceInterval). Standalone voice prompt
  in prompts.go (not bundled with the grammar checkpoint, per spec).
- internal/suggestions: POST /api/docs/:id/voice. replacePending is now
  family-scoped (pendingScope) so grammar and voice never clobber each other's
  pending flags; both passes return the unified pending set. check/voice share
  one runPass helper. TestVoicePassCoexists covers both directions.
- Frontend: api.voiceDoc, useCheckpoint voicing/runVoice, honey "Check my
  voice" toolbar pill, breathing honey dot in StatusBar.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 21:16:53 -07:00

15 KiB

Petal — Build Plan & Progress

Multi-session build. Source of truth for what's done and what's next. Update the checkboxes as work completes. petal-spec.md is the design spec; this file tracks execution.

Decisions locked in (see also memory: petal-design-north-star)

  • Auth deferred — no Authentik yet. Seed a single hardcoded local user (id = "local"); keep the user_id column so auth drops in later without a schema migration.
  • Copyleaks deferred — needs a public webhook; skip Tier-2 plagiarism until there's a public endpoint. Tier-1 voice-consistency (local) is in scope.
  • Traefik/deploy deferred — local dev first.
  • LLM: Qwen 3.5 (256K context) on 64GB dual-GPU. Grammar checkpoint cap ~10K tokens (latency guard); voice pass sends whole document.
    • Reasoning models: the Ollama client sends "think": false on every request. Qwen 3.5 is a reasoning model — left on, it streams chain-of-thought into a separate thinking field and exhausts num_predict before emitting any answer in content (empty response). Non-thinking models ignore the flag. (Validated on deployment hardware 2026-06-25.)
  • Suggestion anchoring: resolve by original string in ProseMirror coords at render time; stored from_pos/to_pos are plaintext offsets for server-side use only. (Spec Note #6.)
  • Aesthetic is an acceptance criterion: pretty, warm, Chinese-woman-friendly; CJK fonts first-class.

Phases

Phase 0 — Foundation / scaffold

  • git init, .gitignore, remote → gitea.parodia.dev/drwily/petal
  • Go module (go.mod), directory skeleton per spec
  • internal/config env loading (local-dev defaults; auth/copyleaks fields kept for later)
  • Vite + React 19 + TS + Tailwind v4 scaffold in web/ (design tokens in @theme, Google fonts)
  • Frontend embedded via web/embed.go (go:embed all:dist) + SPA handler in cmd/server/main.go
  • Dev workflow documented in README; .env.example added
  • Verified end-to-end: binary serves /api/health + embedded SPA + SPA fallback

Phase 1 — Data layer

  • SQLite (modernc) init + migrations (internal/db/db.go) — versioned schema_migrations runner, WAL + foreign keys, single writer conn
  • Models: User, Document, Suggestion (internal/db/models.go) — + type/status constants
  • Seed hardcoded local user (idempotent on startup)
  • Schema includes voice in suggestions type CHECK (full spec schema incl. plagiarism_reports, to avoid a later migration)
  • db.Open wired into cmd/server/main.go; db_test.go covers migrate/seed idempotency, CHECK constraint, FK cascade

Phase 2 — Document CRUD + auto-save ← first "it works" milestone

  • Doc handlers: list/create/get/update/delete (internal/docs/handlers.go) — chi sub-router mounted at /api/docs, all scoped to local user, partial-update via COALESCE so rename and full save share one PUT; handlers_test.go covers the lifecycle
  • Frontend DocList sidebar (create/rename/delete) — DocList/DocListItem, optimistic title/word-count patching
  • Tiptap EditorCore (StarterKit, Underline, TextAlign, Placeholder, CharacterCount) + inline Toolbar (B/I/U, H1/H2, bullets, align)
  • useAutoSave (1.5s debounce) → PUT /api/docs/:id, with saveNow() flush before doc switch/create
  • StatusBar: word count + save status (Editing→Saving→Saved, fades after 3s)
  • Keep content (Tiptap JSON) and content_text (plain) in sync on save — editor emits both + word_count together

Phase 3 — LLM grammar checkpoint

  • LLMClient interface + factory (internal/llm/client.go) — chat-model fallback in factory; doc/history truncation helpers
  • vllm.go (OpenAI-compat), ollama.go (native) — both behind interface; Complete + Stream; no client-level timeout (ctx deadline for Complete, open stream for SSE)
  • checkpoint.go (30s/doc RateLimiter), prompts.go — brace-matched JSON salvage from model output, empty-original drop, type normalization
  • POST /api/docs/:id/check (+ GET /api/docs/:id/suggestions, POST /api/suggestions/:id/{accept,dismiss}) in internal/suggestions; replaces pending set per check, leaves accepted/rejected as history; throttled checks return current set
  • useCheckpoint (4s debounce) + breathing rose checkpoint dot in StatusBar
  • SuggestionHighlight (ProseMirror decorations, re-anchored by original string on every doc change — not stored marks) + SuggestionCard (accept applies replacement in-editor then PATCHes; dismiss)
  • Suggestion colors: grammar=mint, phrasing=peach, idiom=lavender, clarity=sky (honey reserved for voice)

Phase 4 — Ask Petal (conversational follow-up)

  • POST /api/suggestions/:id/chat SSE streaming; server-side context injection — internal/suggestions/chat.go loads the suggestion + parent doc in one user-scoped query, extracts the \n\n-bounded paragraph around from_pos (falls back to truncated doc when from_pos == -1), injects it via AskPetalSystemPrompt, streams event: token/event: done SSE frames (JSON-encoded data so token newlines don't break framing). LLM-unreachable returns a clean 502 before any SSE headers; unknown suggestion 404s.
  • AskPetal component, token-by-token render, no persistence — AskPetal.tsx holds the whole conversation in component state (cleared on close), pre-seeds Petal's first bubble with the suggestion explanation, streams via streamSuggestionChat (fetch + ReadableStream, not EventSource). SuggestionCard gains an "Ask Petal " pill; the card pins open (hover-close suppressed, click-away to dismiss) while the panel is expanded.
  • CJK font fallbacks on chat bubbles (spec Note #17) — bubbles + input use the 'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC' stack (the user asks in Mandarin); applied to the chat surface only, not the serif editor body.
  • internal/llm/chat.go: StreamAskPetal (max_tokens 512, temp 0.7, rep 1.15, top_p 0.92, stop \n\n\n) reusing the existing AskPetalSystemPrompt + TrimHistory. Backend stays interface-only; the SSE handler never touches a concrete client.

Phase 5 — Voice consistency pass (Tier 1)

  • POST /api/docs/:id/voice, whole-document (no TruncateDoc), explicit "Check my voice 🍯" toolbar action — internal/llm/voice.go (RunVoice, VoiceInterval 20s floor, MaxTokens 2048), voiceSystemPrompt/VoiceMessages in prompts.go (standalone — not bundled with the grammar checkpoint per spec)
  • voice suggestion type, honey decoration — type already in schema/CSS; voice flags carry replacement: null → stored "", SuggestionCard hides the diff row + Accept (Dismiss only)
  • Family-scoped pending sets: grammar and voice are independent passes sharing the suggestions table. replacePending now scopes its DELETE by family (pendingScope: grammar = type != 'voice', voice = type = 'voice'), so neither pass wipes the other's pending flags. Both /check and /voice return the unified pending set (grammar + voice) so the client never drops one family's highlights when the other refreshes (also fixes a latent throttle-vs-success inconsistency).
  • Frontend: api.voiceDoc, useCheckpoint gains voicing/runVoice (shares the run-token guard), Toolbar honey "Check my voice 🍯" pill (loading→"Reading…"), StatusBar breathing honey dot "Reading your voice…". check/voice collapsed into a shared runPass server-side.
  • Tests: TestVoicePassCoexists (grammar+voice coexist, unified response, null→"" replacement, voice re-run scoped). go build/vet/test clean, tsc clean, vite build OK; live smoke vs a fake vLLM (voice anchored at 62, grammar preserved, unified list [grammar, voice]).
  • Known limitation (carried from Phase 3): findRange anchors within a single textblock, so a voice passage spanning a paragraph break (\n\n) won't decorate. Model passages usually sit within one paragraph; multi-block anchoring is deferred.

Phase 6 — Design system & polish

  • Full pastel tokens, Nunito + Lora + JetBrains Mono
  • Shape language, shadows, transitions
  • Signature animations (suggestion fade-float, accept confetti, breathing checkpoint dot)
  • Distraction-free mode

Phase 7 — Spell check

  • nspell browser-side (en-US), vendor dictionaries

Deferred (post-v1-local)

  • Authentik OIDC auth + session middleware
  • Copyleaks Tier-2 + webhook HMAC
  • Dockerfile, docker-compose, Traefik, deploy to write.parodia.dev

Session log

  • 2026-06-25: Spec reviewed & amended (voice/grammar decoupled, ctx cap, routes, voice DB type, honey color, string-anchoring). Build plan created.
  • 2026-06-25: Phase 0 complete. Go module + chi server, config loader, React/Vite/Tailwind-v4 scaffold with full design tokens, frontend embedded & served by the binary, verified end-to-end. Toolchain: Go 1.24.4, Node 22, npm 10. Next: Phase 1 (data layer) — SQLite via modernc, models, seed local user.
  • 2026-06-25: Phase 1 complete. internal/db package: modernc.org/sqlite (pulled go toolchain → 1.25), Open() does mkdir + WAL/foreign-keys DSN + versioned migration runner + idempotent local-user seed. Models with type/status constants. Wired into main.go; tests pass (migrate/seed idempotency, CHECK reject, FK cascade). Verified server boots and writes petal.db. Next: Phase 2 (document CRUD + auto-save) — first "it works" milestone.
  • 2026-06-25: Phase 2 complete. Backend internal/docs: chi sub-router (list/create/get/update/delete) mounted at /api/docs, local-user scoped, RETURNING on create, COALESCE partial-update (one PUT serves rename + full save), 404/400 JSON errors; handlers_test.go walks the full lifecycle. Frontend: api/client.ts, useAutoSave (1.5s debounce + saveNow flush), EditorCore (Tiptap StarterKit/Underline/TextAlign/Placeholder/CharacterCount) + Toolbar, DocList/DocListItem, StatusBar, rewritten App.tsx orchestrating load/select/create/delete with optimistic sidebar patching. .petal-prose styles (Lora body, Nunito headings). tsc clean, vite build OK, go build OK; smoke-tested full CRUD incl. CJK title round-trip + SPA serve. Next: Phase 3 (LLM grammar checkpoint).
  • 2026-06-25: Phase 3 complete. Backend internal/llm: LLMClient interface + factory (vLLM OpenAI-compat + Ollama native, both Complete/Stream), prompts.go (checkpoint + Ask Petal templates), checkpoint.go (brace-matched JSON salvage, per-doc 30s RateLimiter, doc/history truncation). internal/suggestions: /api/docs/:id/check + :id/suggestions + /api/suggestions/:id/{accept,dismiss}; each check replaces the pending set in a tx (accepted/rejected kept as history), throttled checks return the current set, positions located by strings.Index (advisory only). Frontend: useCheckpoint (4s debounce, loads existing on doc open, run-token guards stale responses), SuggestionHighlight Tiptap extension rendering ProseMirror decorations re-anchored by original string on every doc change (precise textblock offset→PM-pos mapping, handles inline atoms), SuggestionCard (type-colored tag, original→replacement diff, accept applies replacement in-editor + PATCHes, hover-bridge with close delay), breathing rose checkpoint dot in StatusBar, suggestion fade-float + breathe CSS. Tests: llm parse/rate-limit/truncate, suggestions full flow + rate-limit over httptest with a stub client. go build/vet/test clean, tsc clean, vite build OK; end-to-end smoke-tested against a fake vLLM endpoint (anchoring verified: I has→0:5, two apple→6:15) and 502 path when LLM unreachable. Next: Phase 4 (Ask Petal SSE chat).
  • 2026-06-25: Phase 5 complete. Tier-1 voice-consistency pass. Backend: internal/llm/voice.go (RunVoice — whole document, no TruncateDoc, MaxTokens 2048, VoiceInterval 20s per-doc floor), standalone voiceSystemPrompt/VoiceMessages (not bundled with the grammar checkpoint). internal/suggestions: POST /api/docs/:id/voice route; check/voice collapsed into a shared runPass(limiter, pass, scope); pendingScope makes replacePending family-aware (grammar deletes type != 'voice', voice deletes type = 'voice'), so the two passes never clobber each other's pending flags; both endpoints now return the unified pending set (also fixed a latent throttle-returns-full-set vs success-returns-batch inconsistency). Frontend: api.voiceDoc, useCheckpointvoicing/runVoice (shared run-token guard, reset on doc switch), honey "Check my voice 🍯" pill in Toolbar (→ "Reading…" while in flight), breathing honey dot + "Reading your voice…" in StatusBar. Voice flags' replacement: null round-trips to ""; SuggestionCard already hides the diff row + Accept for those. Tests: TestVoicePassCoexists (coexistence both directions, unified response, null→"" replacement). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs a fake vLLM: grammar check → grammar flag; voice pass → unified [grammar@0, voice@62 (empty replacement)], grammar preserved. Known limitation: findRange is single-textblock, so a voice passage crossing a \n\n paragraph break won't decorate (deferred). Next: Phase 6 (design system & polish).
  • 2026-06-25: Phase 4 complete. Backend: internal/llm/chat.go (StreamAskPetal — conversational sampling params, reuses AskPetalSystemPrompt/TrimHistory), internal/suggestions/chat.go (POST /api/suggestions/:id/chat — one user-scoped join loads the suggestion + parent content_text, surroundingParagraph extracts the \n\n-bounded paragraph at from_pos with whole-doc fallback, streams event: token/event: done SSE frames with JSON-encoded data, X-Accel-Buffering: no, real http.Flusher per chunk; LLM-down → 502 before SSE headers, unknown id → 404). Handler imports the interface only. Frontend: streamSuggestionChat (fetch + ReadableStream SSE parser, abortable), AskPetal.tsx (in-component history — no persistence, pre-seeded first bubble, rose/lavender bubbles, CJK font stack per Note #17, streaming caret), SuggestionCard "Ask Petal " pill that pins the card open (hover-close suppressed, click-away closes) and widens it to 340px. Tests: chat_test.go (streamed-text concat + done event, server-side context injection asserted on the system message, sampling params, 404, surroundingParagraph unit). go build/vet/test clean, tsc clean, vite build OK. Live SSE smoke test against a fake streaming vLLM (fresh ports 8077/8088 — a pre-existing dev petal on :8099 left untouched): tokens flushed individually through the chi middleware stack, done terminator, 502 on LLM-down, 404 on unknown suggestion all verified. Next: Phase 5 (voice consistency pass, Tier 1).