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:
@@ -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