Files
petal/internal/suggestions/chat_test.go
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

149 lines
4.7 KiB
Go

package suggestions
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
// streamClient serves a canned checkpoint (so a suggestion can be seeded through
// the public /check route) and a fixed token stream for chat. It records the
// last streamed request so a test can assert on the injected system context.
type streamClient struct {
checkpoint string
tokens []string
lastStream llm.CompletionRequest
}
func (c *streamClient) Complete(_ context.Context, _ llm.CompletionRequest) (string, error) {
return c.checkpoint, nil
}
func (c *streamClient) Stream(_ context.Context, req llm.CompletionRequest) (<-chan string, error) {
c.lastStream = req
ch := make(chan string)
go func() {
defer close(ch)
for _, t := range c.tokens {
ch <- t
}
}()
return ch, nil
}
func TestAskPetalChat(t *testing.T) {
client := &streamClient{
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)
// Seed one suggestion via the checkpoint route, then grab its id.
do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
listed := do(t, srv, http.MethodGet, "/docs/"+docID+"/suggestions", "")
var sugs []db.Suggestion
if err := json.Unmarshal(listed.Body.Bytes(), &sugs); err != nil {
t.Fatalf("decode suggestions: %v", err)
}
if len(sugs) != 1 {
t.Fatalf("seed: want 1 suggestion, got %d", len(sugs))
}
sugID := sugs[0].ID
rec := do(t, srv, http.MethodPost, "/suggestions/"+sugID+"/chat",
`{"messages":[{"role":"user","content":"why is this wrong?"}]}`)
if rec.Code != http.StatusOK {
t.Fatalf("chat: code=%d body=%s", rec.Code, rec.Body)
}
if ct := rec.Header().Get("Content-Type"); ct != "text/event-stream" {
t.Fatalf("chat: content-type=%q, want text/event-stream", ct)
}
// The streamed tokens should arrive concatenated across data: events, and the
// stream should terminate with a done event.
body := rec.Body.String()
got := collectSSEText(body)
if want := "Because \"I\" takes \"have\"."; got != want {
t.Fatalf("streamed text = %q, want %q", got, want)
}
if !strings.Contains(body, "event: done") {
t.Fatalf("chat stream missing done event:\n%s", body)
}
// Context injection: the system prompt (first message) must carry the
// suggestion's original text and the surrounding paragraph — loaded
// server-side, never from the client.
msgs := client.lastStream.Messages
if len(msgs) < 2 || msgs[0].Role != "system" {
t.Fatalf("expected a leading system message, got %+v", msgs)
}
sys := msgs[0].Content
if !strings.Contains(sys, "I has") {
t.Fatalf("system prompt missing original text:\n%s", sys)
}
if !strings.Contains(sys, "I has two apple.") {
t.Fatalf("system prompt missing surrounding paragraph:\n%s", sys)
}
// The client's user message rides after the system turn.
if last := msgs[len(msgs)-1]; last.Role != "user" || last.Content != "why is this wrong?" {
t.Fatalf("user message not forwarded: %+v", last)
}
// Chat sampling parameters from the spec.
if client.lastStream.MaxTokens != 512 || client.lastStream.Temperature != 0.7 {
t.Fatalf("unexpected chat params: %+v", client.lastStream)
}
}
func TestAskPetalChatNotFound(t *testing.T) {
client := &streamClient{checkpoint: `{"suggestions":[]}`}
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 {
t.Fatalf("want 404 for unknown suggestion, got %d", rec.Code)
}
}
func TestSurroundingParagraph(t *testing.T) {
text := "First paragraph here.\n\nThe target sentence lives here.\n\nThird paragraph."
from := strings.Index(text, "target")
got := surroundingParagraph(text, from)
if got != "The target sentence lives here." {
t.Fatalf("paragraph = %q", got)
}
// Unknown offset falls back to the (trimmed) document.
if got := surroundingParagraph(text, -1); !strings.Contains(got, "First paragraph") {
t.Fatalf("fallback paragraph = %q", got)
}
}
// collectSSEText concatenates the JSON text fields from every `event: token`
// frame in an SSE body.
func collectSSEText(body string) string {
var b strings.Builder
for _, frame := range strings.Split(body, "\n\n") {
if !strings.Contains(frame, "event: token") {
continue
}
for _, line := range strings.Split(frame, "\n") {
data, ok := strings.CutPrefix(line, "data: ")
if !ok {
continue
}
var payload struct {
Text string `json:"text"`
}
_ = json.Unmarshal([]byte(data), &payload)
b.WriteString(payload.Text)
}
}
return b.String()
}