From 0fa70979a0189fd325e8bee427ba3dcd42ad9a58 Mon Sep 17 00:00:00 2001
From: prosolis <5590409+prosolis@users.noreply.github.com>
Date: Thu, 25 Jun 2026 21:16:53 -0700
Subject: [PATCH] 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
---
BUILD_PLAN.md | 11 +-
internal/llm/prompts.go | 38 ++++++
internal/llm/voice.go | 32 ++++++
internal/suggestions/chat_test.go | 4 +-
internal/suggestions/handlers.go | 128 ++++++++++++++-------
internal/suggestions/handlers_test.go | 87 +++++++++++++-
web/src/App.tsx | 11 +-
web/src/api/client.ts | 5 +
web/src/components/Editor/EditorCore.tsx | 17 ++-
web/src/components/StatusBar/StatusBar.tsx | 16 ++-
web/src/components/Toolbar/Toolbar.tsx | 27 ++++-
web/src/hooks/useCheckpoint.ts | 25 +++-
12 files changed, 342 insertions(+), 59 deletions(-)
create mode 100644 internal/llm/voice.go
diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md
index 3e9f04a..014458a 100644
--- a/BUILD_PLAN.md
+++ b/BUILD_PLAN.md
@@ -52,9 +52,13 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
- [x] 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, slow cadence / explicit action
-- [ ] `voice` suggestion type, honey decoration (`--color-honey`)
+### Phase 5 — Voice consistency pass (Tier 1) ✅
+- [x] `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)
+- [x] `voice` suggestion type, honey decoration — type already in schema/CSS; voice flags carry `replacement: null` → stored `""`, `SuggestionCard` hides the diff row + Accept (Dismiss only)
+- [x] **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).
+- [x] 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
@@ -76,4 +80,5 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
- 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`, `useCheckpoint` → `voicing`/`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).**
diff --git a/internal/llm/prompts.go b/internal/llm/prompts.go
index 72867c7..caed85b 100644
--- a/internal/llm/prompts.go
+++ b/internal/llm/prompts.go
@@ -33,6 +33,44 @@ func CheckpointMessages(contentText string) []Message {
}
}
+// voiceSystemPrompt drives the Tier-1 voice-consistency pass. It is a distinct
+// pass from the grammar checkpoint (spec: "do not bundle them") — the model
+// reads the whole document to learn the writer's natural voice, then flags
+// passages that read as tonally out of place. `replacement` is null: these are
+// awareness-only, with no correction to apply.
+const voiceSystemPrompt = `You are a warm, encouraging writing assistant helping someone who speaks English as a second language. ` +
+ `You are reviewing a COMPLETE document for VOICE CONSISTENCY only — not grammar.
+
+Read the whole document to learn the writer's natural voice, then identify any passages (2 or more sentences) ` +
+ `that feel tonally inconsistent with the surrounding writing — unusually formal, unusually polished, or phrased ` +
+ `in a way that differs from the writer's established voice elsewhere in the document. These often signal text ` +
+ `that was paraphrased too closely from another source. Do not flag the first paragraph (there is no baseline yet). ` +
+ `Do not flag grammar or spelling mistakes — only voice.
+
+Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
+{
+ "suggestions": [
+ {
+ "original": "exact passage from the document that feels inconsistent",
+ "replacement": null,
+ "explanation": "friendly one-sentence note, e.g. 'This passage sounds more formal than the rest of your writing — worth reviewing.'",
+ "type": "voice"
+ }
+ ]
+}
+
+If the voice is consistent throughout, return: {"suggestions": []}`
+
+// VoiceMessages builds the message array for a voice-consistency pass. Unlike
+// the checkpoint, the caller passes the WHOLE document (no truncation) — voice
+// consistency is judged against the established voice everywhere else.
+func VoiceMessages(contentText string) []Message {
+ return []Message{
+ {Role: "system", Content: voiceSystemPrompt},
+ {Role: "user", Content: contentText},
+ }
+}
+
// askPetalSystemTemplate is the Ask Petal tutor prompt. The suggestion context
// is interpolated in; the user's own messages are appended after this system
// turn by the caller.
diff --git a/internal/llm/voice.go b/internal/llm/voice.go
new file mode 100644
index 0000000..14d06f0
--- /dev/null
+++ b/internal/llm/voice.go
@@ -0,0 +1,32 @@
+package llm
+
+import (
+ "context"
+ "time"
+)
+
+// VoiceInterval is the minimum gap between voice-consistency passes for one
+// document. The pass is whole-document and slow, and it runs on an explicit
+// user action rather than a typing cadence, so this floor only guards the
+// inference endpoint against the button being mashed.
+const VoiceInterval = 20 * time.Second
+
+// RunVoice sends the WHOLE document — deliberately NOT TruncateDoc'd, since
+// voice consistency is judged against the established voice everywhere else —
+// for a Tier-1 voice pass and parses the JSON result. It reuses the checkpoint's
+// tolerant parser and a larger token budget, since one pass may flag several
+// passages. Each flag carries a null replacement (awareness-only).
+func RunVoice(ctx context.Context, client LLMClient, contentText string) ([]RawSuggestion, error) {
+ raw, err := client.Complete(ctx, CompletionRequest{
+ Messages: VoiceMessages(contentText),
+ MaxTokens: 2048,
+ Temperature: 0.3,
+ RepetitionPenalty: 1.15,
+ TopP: 0.9,
+ Stop: []string{"```", "\n\n\n\n"},
+ })
+ if err != nil {
+ return nil, err
+ }
+ return ParseCheckpoint(raw)
+}
diff --git a/internal/suggestions/chat_test.go b/internal/suggestions/chat_test.go
index f04473d..76d1a83 100644
--- a/internal/suggestions/chat_test.go
+++ b/internal/suggestions/chat_test.go
@@ -41,7 +41,7 @@ func TestAskPetalChat(t *testing.T) {
checkpoint: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"}]}`,
tokens: []string{"Because ", "\"I\" ", "takes ", "\"have\"."},
}
- srv, docID := newTestServer(t, client)
+ srv, docID, _ := newTestServer(t, client)
// Seed one suggestion via the checkpoint route, then grab its id.
do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
@@ -102,7 +102,7 @@ func TestAskPetalChat(t *testing.T) {
func TestAskPetalChatNotFound(t *testing.T) {
client := &streamClient{checkpoint: `{"suggestions":[]}`}
- srv, _ := newTestServer(t, client)
+ srv, _, _ := newTestServer(t, client)
rec := do(t, srv, http.MethodPost, "/suggestions/does-not-exist/chat",
`{"messages":[{"role":"user","content":"hi"}]}`)
if rec.Code != http.StatusNotFound {
diff --git a/internal/suggestions/handlers.go b/internal/suggestions/handlers.go
index 4eb4a0d..42be992 100644
--- a/internal/suggestions/handlers.go
+++ b/internal/suggestions/handlers.go
@@ -6,6 +6,7 @@
package suggestions
import (
+ "context"
"database/sql"
"encoding/json"
"errors"
@@ -18,26 +19,31 @@ import (
"gitea.parodia.dev/drwily/petal/internal/llm"
)
-// Handler holds the dependencies for the checkpoint + suggestion routes.
+// Handler holds the dependencies for the checkpoint + suggestion routes. The
+// grammar checkpoint and the voice pass each get their own per-document rate
+// limiter — they are independent passes with different cadences.
type Handler struct {
- DB *db.DB
- Client llm.LLMClient
- Limit *llm.RateLimiter
+ DB *db.DB
+ Client llm.LLMClient
+ Limit *llm.RateLimiter // grammar checkpoint floor
+ VoiceLimit *llm.RateLimiter // voice-consistency floor
}
-// New constructs a Handler with a per-document checkpoint rate limiter.
+// New constructs a Handler with per-document checkpoint and voice rate limiters.
func New(database *db.DB, client llm.LLMClient) *Handler {
return &Handler{
- DB: database,
- Client: client,
- Limit: llm.NewRateLimiter(llm.CheckpointInterval),
+ DB: database,
+ Client: client,
+ Limit: llm.NewRateLimiter(llm.CheckpointInterval),
+ VoiceLimit: llm.NewRateLimiter(llm.VoiceInterval),
}
}
-// RegisterDocRoutes adds the document-scoped routes (check + list) onto the
-// existing /api/docs router so they share its base path.
+// RegisterDocRoutes adds the document-scoped routes (check + voice + list) onto
+// the existing /api/docs router so they share its base path.
func (h *Handler) RegisterDocRoutes(r chi.Router) {
r.Post("/{id}/check", h.check)
+ r.Post("/{id}/voice", h.voice)
r.Get("/{id}/suggestions", h.listForDoc)
}
@@ -51,9 +57,26 @@ func (h *Handler) Routes() chi.Router {
return r
}
-// check runs a grammar checkpoint over the document and returns the fresh
-// pending suggestions. It is rate-limited per document (429 when too soon).
+// check runs a grammar checkpoint over the document. Fast, typing-cadence pass.
func (h *Handler) check(w http.ResponseWriter, r *http.Request) {
+ h.runPass(w, r, h.Limit, llm.RunCheckpoint, grammarScope)
+}
+
+// voice runs a Tier-1 voice-consistency pass over the whole document. Slow,
+// explicit-action pass; replaces only the pending voice flags.
+func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
+ h.runPass(w, r, h.VoiceLimit, llm.RunVoice, voiceScope)
+}
+
+// pass is the signature shared by the grammar checkpoint and the voice pass:
+// given the document text it returns the model's raw suggestions.
+type pass func(ctx context.Context, client llm.LLMClient, contentText string) ([]llm.RawSuggestion, error)
+
+// runPass is the shared body for both LLM passes. It loads the document text,
+// enforces the pass's per-document rate limit, runs the model, swaps in the
+// fresh batch scoped to this family, and returns the document's FULL pending set
+// (both families) so the client always renders a unified picture.
+func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.RateLimiter, run pass, scope pendingScope) {
docID := chi.URLParam(r, "id")
var contentText string
@@ -70,13 +93,13 @@ func (h *Handler) check(w http.ResponseWriter, r *http.Request) {
return
}
- // Nothing to check on an empty document — skip the LLM round-trip.
+ // Nothing to analyze on an empty document — skip the LLM round-trip.
if strings.TrimSpace(contentText) == "" {
writeJSON(w, http.StatusOK, []db.Suggestion{})
return
}
- if ok, _ := h.Limit.Allow(docID); !ok {
+ if ok, _ := limiter.Allow(docID); !ok {
// Throttled: return the existing pending set unchanged rather than an
// error, so the frontend keeps showing current suggestions.
existing, err := h.fetchPending(docID)
@@ -88,60 +111,77 @@ func (h *Handler) check(w http.ResponseWriter, r *http.Request) {
return
}
- raw, err := llm.RunCheckpoint(r.Context(), h.Client, contentText)
+ raw, err := run(r.Context(), h.Client, contentText)
if err != nil {
- errorJSON(w, http.StatusBadGateway, "checkpoint failed: "+err.Error())
+ errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
return
}
- saved, err := h.replacePending(docID, contentText, raw)
+ if err := h.replacePending(docID, contentText, raw, scope); err != nil {
+ serverError(w, err)
+ return
+ }
+
+ // Return the unified pending set (grammar + voice), not just this batch, so
+ // a grammar check never drops the voice highlights from the client and the
+ // throttle path above stays consistent with the success path.
+ out, err := h.fetchPending(docID)
if err != nil {
serverError(w, err)
return
}
- writeJSON(w, http.StatusOK, saved)
+ writeJSON(w, http.StatusOK, out)
}
-// replacePending swaps a document's pending suggestions for a fresh batch in one
-// transaction. Accepted/rejected suggestions are left untouched (history).
-func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion) ([]db.Suggestion, error) {
+// pendingScope describes how one LLM pass touches the shared suggestions table:
+// which family of pending rows it replaces, and the type to stamp on the rows it
+// inserts. The grammar checkpoint and voice pass each own a disjoint family, so
+// running one never disturbs the other's pending flags.
+type pendingScope struct {
+ deleteWhere string // extra WHERE clause scoping the DELETE to this family
+ forceType string // if set, every inserted row gets this type; else normalizeType
+}
+
+var (
+ // grammarScope owns the grammar/phrasing/idiom/clarity flags (everything but voice).
+ grammarScope = pendingScope{deleteWhere: "type != 'voice'", forceType: ""}
+ // voiceScope owns the voice flags only.
+ voiceScope = pendingScope{deleteWhere: "type = 'voice'", forceType: db.SuggestionTypeVoice}
+)
+
+// replacePending swaps a document's pending suggestions within one family for a
+// fresh batch in a single transaction. Accepted/rejected suggestions and the
+// other family's pending rows are left untouched.
+func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion, scope pendingScope) error {
tx, err := h.DB.Begin()
if err != nil {
- return nil, err
+ return err
}
defer tx.Rollback()
if _, err := tx.Exec(
- `DELETE FROM suggestions WHERE doc_id = ? AND status = ?`,
+ `DELETE FROM suggestions WHERE doc_id = ? AND status = ? AND `+scope.deleteWhere,
docID, db.SuggestionStatusPending,
); err != nil {
- return nil, err
+ return err
}
- out := []db.Suggestion{}
for _, s := range raw {
- typ := normalizeType(s.Type)
- from, to := locate(contentText, s.Original)
- var saved db.Suggestion
- err := tx.QueryRow(
- `INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
- VALUES (?, ?, ?, ?, ?, ?, ?)
- RETURNING id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at`,
- docID, from, to, s.Original, s.Replacement, s.Explanation, typ,
- ).Scan(
- &saved.ID, &saved.DocID, &saved.FromPos, &saved.ToPos, &saved.Original,
- &saved.Replacement, &saved.Explanation, &saved.Type, &saved.Status, &saved.CreatedAt,
- )
- if err != nil {
- return nil, err
+ typ := scope.forceType
+ if typ == "" {
+ typ = normalizeType(s.Type)
+ }
+ from, to := locate(contentText, s.Original)
+ if _, err := tx.Exec(
+ `INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ docID, from, to, s.Original, s.Replacement, s.Explanation, typ,
+ ); err != nil {
+ return err
}
- out = append(out, saved)
}
- if err := tx.Commit(); err != nil {
- return nil, err
- }
- return out, nil
+ return tx.Commit()
}
// listForDoc returns the document's current pending suggestions (used when the
diff --git a/internal/suggestions/handlers_test.go b/internal/suggestions/handlers_test.go
index feb3c45..3be21a0 100644
--- a/internal/suggestions/handlers_test.go
+++ b/internal/suggestions/handlers_test.go
@@ -34,7 +34,7 @@ func (s *stubClient) Stream(_ context.Context, _ llm.CompletionRequest) (<-chan
// newTestServer wires a real DB, a seeded document, and the suggestion routes
// (both the doc-scoped and the per-suggestion mounts) onto one router.
-func newTestServer(t *testing.T, client llm.LLMClient) (http.Handler, string) {
+func newTestServer(t *testing.T, client llm.LLMClient) (http.Handler, string, *Handler) {
t.Helper()
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
@@ -56,7 +56,7 @@ func newTestServer(t *testing.T, client llm.LLMClient) (http.Handler, string) {
r := chi.NewRouter()
r.Route("/docs", func(dr chi.Router) { h.RegisterDocRoutes(dr) })
r.Mount("/suggestions", h.Routes())
- return r, docID
+ return r, docID, h
}
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
@@ -77,7 +77,7 @@ func TestCheckpointFlow(t *testing.T) {
{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"},
{"original":"two apple","replacement":"two apples","explanation":"plural noun","type":"grammar"}
]}`}
- srv, docID := newTestServer(t, client)
+ srv, docID, _ := newTestServer(t, client)
// Run a checkpoint → two pending suggestions, with positions located.
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
@@ -127,9 +127,88 @@ func TestCheckpointFlow(t *testing.T) {
}
}
+// TestVoicePassCoexists proves the voice pass and the grammar checkpoint own
+// disjoint pending families: running one never wipes the other's flags, and
+// each endpoint returns the unified pending set.
+func TestVoicePassCoexists(t *testing.T) {
+ // One client serves both passes; the prompt distinguishes them but the stub
+ // just echoes whatever we set before each call.
+ client := &switchClient{}
+ srv, docID, h := newTestServer(t, client)
+ // Zero the voice floor so the test can re-run the pass without waiting out
+ // the real 20s interval; the family-scoping logic is what's under test here.
+ h.VoiceLimit = llm.NewRateLimiter(0)
+
+ // Grammar checkpoint first → one grammar flag.
+ client.response = `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"agreement","type":"grammar"}]}`
+ rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
+ }
+
+ // Voice pass next → one voice flag (null replacement). It must NOT remove the
+ // grammar flag; the response is the unified set of both.
+ client.response = `{"suggestions":[{"original":"two apple","replacement":null,"explanation":"sounds more formal","type":"voice"}]}`
+ rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/voice", "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("voice: code=%d body=%s", rec.Code, rec.Body)
+ }
+ var got []db.Suggestion
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(got) != 2 {
+ t.Fatalf("voice response should carry both families, got %d: %+v", len(got), got)
+ }
+ var grammar, voice *db.Suggestion
+ for i := range got {
+ switch got[i].Type {
+ case db.SuggestionTypeGrammar:
+ grammar = &got[i]
+ case db.SuggestionTypeVoice:
+ voice = &got[i]
+ }
+ }
+ if grammar == nil || voice == nil {
+ t.Fatalf("want one grammar + one voice flag, got %+v", got)
+ }
+ if voice.Replacement != "" {
+ t.Fatalf("voice flag should have empty replacement, got %q", voice.Replacement)
+ }
+
+ // A fresh voice pass that finds nothing replaces only the voice flag; the
+ // grammar one survives.
+ client.response = `{"suggestions":[]}`
+ rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/voice", "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("second voice: code=%d body=%s", rec.Code, rec.Body)
+ }
+ _ = json.Unmarshal(rec.Body.Bytes(), &got)
+ if len(got) != 1 || got[0].Type != db.SuggestionTypeGrammar {
+ t.Fatalf("after clearing voice, only the grammar flag should remain, got %+v", got)
+ }
+}
+
+// switchClient returns a response that can be swapped between calls.
+type switchClient struct {
+ response string
+ calls int
+}
+
+func (s *switchClient) Complete(_ context.Context, _ llm.CompletionRequest) (string, error) {
+ s.calls++
+ return s.response, nil
+}
+
+func (s *switchClient) Stream(_ context.Context, _ llm.CompletionRequest) (<-chan string, error) {
+ ch := make(chan string)
+ close(ch)
+ return ch, nil
+}
+
func TestCheckpointRateLimit(t *testing.T) {
client := &stubClient{response: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"x","type":"grammar"}]}`}
- srv, docID := newTestServer(t, client)
+ srv, docID, _ := newTestServer(t, client)
// First check runs the model.
do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 4d88d81..f24140b 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -17,7 +17,9 @@ export default function App() {
const {
suggestions,
checking,
+ voicing,
schedule: scheduleCheckpoint,
+ runVoice,
removeSuggestion,
} = useCheckpoint(currentDoc?.id ?? null)
@@ -185,10 +187,17 @@ export default function App() {
suggestions={suggestions}
onAccept={handleAccept}
onDismiss={handleDismiss}
+ onVoiceCheck={runVoice}
+ voicing={voicing}
/>
-