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} /> - + ) : (
req(`/docs/${id}/check`, { method: 'POST' }), + // Voice-consistency pass: whole-document, explicit-action, slower. Returns the + // unified pending set too. Rate-limited per document server-side. + voiceDoc: (id: string) => req(`/docs/${id}/voice`, { method: 'POST' }), // Pending suggestions for a doc, loaded when the editor opens it. listSuggestions: (id: string) => req(`/docs/${id}/suggestions`), acceptSuggestion: (id: string) => diff --git a/web/src/components/Editor/EditorCore.tsx b/web/src/components/Editor/EditorCore.tsx index 2ccdedd..0c78ae6 100644 --- a/web/src/components/Editor/EditorCore.tsx +++ b/web/src/components/Editor/EditorCore.tsx @@ -26,6 +26,10 @@ interface Props { suggestions: Suggestion[] onAccept: (s: Suggestion) => void onDismiss: (s: Suggestion) => void + // Triggers the whole-document voice-consistency pass; `voicing` is true while + // it runs (drives the toolbar button's loading state). + onVoiceCheck: () => void + voicing: boolean } // parseDoc turns the stored content string into a Tiptap doc node, tolerating @@ -50,7 +54,16 @@ interface HoverState { // EditorCore is the Tiptap instance: StarterKit formatting plus underline, text // alignment, a placeholder, character counting, and the suggestion-highlight // decoration layer. Hovering a highlight opens its SuggestionCard. -export function EditorCore({ docId, initialContent, onChange, suggestions, onAccept, onDismiss }: Props) { +export function EditorCore({ + docId, + initialContent, + onChange, + suggestions, + onAccept, + onDismiss, + onVoiceCheck, + voicing, +}: Props) { const wrapperRef = useRef(null) const [hover, setHover] = useState(null) // Delays card close so the pointer can travel from highlight to card. @@ -195,7 +208,7 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc return (
- +
= { @@ -18,7 +20,7 @@ const SAVE_LABEL: Record = { // StatusBar is the slim footer: word count on the left, save state and the // grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose // circle that breathes while a check is in flight (spec → Signature animations). -export function StatusBar({ wordCount, saveStatus, checking }: Props) { +export function StatusBar({ wordCount, saveStatus, checking, voicing }: Props) { const label = SAVE_LABEL[saveStatus] return (
)} + {voicing && ( + <> + · + + + Reading your voice… + + + )} {label && ( <> · diff --git a/web/src/components/Toolbar/Toolbar.tsx b/web/src/components/Toolbar/Toolbar.tsx index 7865652..495058a 100644 --- a/web/src/components/Toolbar/Toolbar.tsx +++ b/web/src/components/Toolbar/Toolbar.tsx @@ -3,6 +3,9 @@ import { useEditorState } from '@tiptap/react' interface Props { editor: Editor | null + // Runs the whole-document voice-consistency pass; `voicing` shows its progress. + onVoiceCheck: () => void + voicing: boolean } // A formatting button. `active` gets the rose pill treatment so the writer can @@ -47,7 +50,7 @@ const Divider = () => ( // Toolbar renders inline formatting controls bound to the live Tiptap editor. // useEditorState subscribes to just the flags it reads, so the buttons reflect // the current selection without re-rendering the whole tree on every keystroke. -export function Toolbar({ editor }: Props) { +export function Toolbar({ editor, onVoiceCheck, voicing }: Props) { const state = useEditorState({ editor, selector: ({ editor }) => @@ -134,6 +137,28 @@ export function Toolbar({ editor }: Props) { > ⇥ + +
) } diff --git a/web/src/hooks/useCheckpoint.ts b/web/src/hooks/useCheckpoint.ts index 68baadf..df22f74 100644 --- a/web/src/hooks/useCheckpoint.ts +++ b/web/src/hooks/useCheckpoint.ts @@ -11,6 +11,8 @@ const DEBOUNCE_MS = 4000 export function useCheckpoint(docId: string | null) { const [suggestions, setSuggestions] = useState([]) const [checking, setChecking] = useState(false) + // True while a whole-document voice pass is in flight (explicit user action). + const [voicing, setVoicing] = useState(false) const debounceRef = useRef>(undefined) const docIdRef = useRef(docId) @@ -36,6 +38,26 @@ export function useCheckpoint(docId: string | null) { } }, []) + // Run the voice-consistency pass now (explicit "Check my voice" action). Like + // runCheck it returns the unified pending set, so grammar highlights survive. + // Shares the run token so navigating away discards a late voice response. + const runVoice = useCallback(async () => { + const id = docIdRef.current + if (!id) return + const run = ++runRef.current + setVoicing(true) + try { + const full = await api.voiceDoc(id) + if (run === runRef.current && id === docIdRef.current) { + setSuggestions(full) + } + } catch (err) { + console.error('voice pass failed', err) + } finally { + if (run === runRef.current) setVoicing(false) + } + }, []) + // Call on every edit; schedules a check 4s after typing settles. const schedule = useCallback(() => { clearTimeout(debounceRef.current) @@ -49,6 +71,7 @@ export function useCheckpoint(docId: string | null) { runRef.current++ setSuggestions([]) setChecking(false) + setVoicing(false) if (!docId) return let cancelled = false void (async () => { @@ -71,5 +94,5 @@ export function useCheckpoint(docId: string | null) { setSuggestions((prev) => prev.filter((s) => s.id !== id)) }, []) - return { suggestions, checking, schedule, removeSuggestion } + return { suggestions, checking, voicing, schedule, runVoice, removeSuggestion } }