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
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
Reference in New Issue
Block a user