Phase 3: LLM grammar checkpoint

Backend (internal/llm): backend-agnostic LLMClient interface + factory
with vLLM (OpenAI-compat) and Ollama (native) clients, each Complete +
Stream. prompts.go holds the checkpoint and Ask Petal templates;
checkpoint.go salvages JSON from model output (brace-matched), enforces a
per-doc 30s RateLimiter, and truncates the doc to a latency cap.

internal/suggestions: POST /api/docs/:id/check runs a checkpoint and
replaces the doc's pending suggestions in one tx (accepted/rejected kept
as history); GET /api/docs/:id/suggestions lists pending;
POST /api/suggestions/:id/{accept,dismiss} resolves one. Throttled checks
return the current set rather than erroring.

Frontend: useCheckpoint (4s debounce, loads existing on open, stale-guard
tokens); SuggestionHighlight renders ProseMirror decorations re-anchored
by the `original` string on every doc change (not stored marks), with
precise textblock-offset→PM-position mapping; SuggestionCard shows the
type tag + diff + explanation and applies the replacement in-editor on
accept; breathing rose checkpoint dot in the StatusBar; fade-float +
breathe animations.

Tests: llm parse/rate-limit/truncate; suggestions full flow + rate-limit
over httptest with a stub client. Smoke-tested end-to-end against a fake
vLLM endpoint (anchoring verified) and the LLM-unreachable 502 path.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-25 20:45:30 -07:00
parent 5e00cdce88
commit a4069d5755
18 changed files with 1640 additions and 19 deletions

143
internal/llm/checkpoint.go Normal file
View File

@@ -0,0 +1,143 @@
package llm
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
)
// CheckpointInterval is the minimum time between grammar checkpoints for a
// single document. The frontend debounces at 4s; this is the server-side floor
// that protects the inference endpoint from rapid repeat checks.
const CheckpointInterval = 30 * time.Second
// RawSuggestion is one item as the model emits it. Positions are resolved
// server-side from Original; the frontend re-anchors by string at render time.
type RawSuggestion struct {
Original string `json:"original"`
Replacement string `json:"replacement"`
Explanation string `json:"explanation"`
Type string `json:"type"`
}
// checkpointResponse is the top-level JSON shape the checkpoint prompt requests.
type checkpointResponse struct {
Suggestions []RawSuggestion `json:"suggestions"`
}
// RunCheckpoint sends the grammar checkpoint and parses the JSON result. It
// applies the latency-guard truncation and the checkpoint sampling parameters
// from the spec.
func RunCheckpoint(ctx context.Context, client LLMClient, contentText string) ([]RawSuggestion, error) {
raw, err := client.Complete(ctx, CompletionRequest{
Messages: CheckpointMessages(TruncateDoc(contentText)),
MaxTokens: 1024,
Temperature: 0.3,
RepetitionPenalty: 1.15,
TopP: 0.9,
Stop: []string{"```", "\n\n\n\n"},
})
if err != nil {
return nil, err
}
return ParseCheckpoint(raw)
}
// ParseCheckpoint extracts the suggestions array from a model response. Smaller
// models sometimes wrap JSON in prose or markdown fences despite instructions,
// so we salvage the outermost {...} object before decoding.
func ParseCheckpoint(raw string) ([]RawSuggestion, error) {
jsonText := extractJSONObject(raw)
if jsonText == "" {
return nil, fmt.Errorf("checkpoint: no JSON object in model output: %q", truncateForError(raw))
}
var parsed checkpointResponse
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
return nil, fmt.Errorf("checkpoint: parse JSON: %w (got %q)", err, truncateForError(jsonText))
}
// Drop items the model returned with an empty original — they can't be
// anchored — and normalize whitespace the model may have echoed.
out := parsed.Suggestions[:0]
for _, s := range parsed.Suggestions {
s.Original = strings.TrimSpace(s.Original)
s.Replacement = strings.TrimSpace(s.Replacement)
if s.Original == "" {
continue
}
out = append(out, s)
}
return out, nil
}
// extractJSONObject returns the substring from the first '{' to its matching
// closing '}', or "" if none. Tolerates fences/preamble around the object.
func extractJSONObject(s string) string {
start := strings.IndexByte(s, '{')
if start < 0 {
return ""
}
depth := 0
inString := false
escaped := false
for i := start; i < len(s); i++ {
c := s[i]
switch {
case escaped:
escaped = false
case c == '\\' && inString:
escaped = true
case c == '"':
inString = !inString
case inString:
// ignore braces inside strings
case c == '{':
depth++
case c == '}':
depth--
if depth == 0 {
return s[start : i+1]
}
}
}
return ""
}
func truncateForError(s string) string {
const max = 200
if len(s) > max {
return s[:max] + "…"
}
return s
}
// RateLimiter enforces a minimum interval between checkpoints per document.
// Concurrency-safe; one instance is shared across requests.
type RateLimiter struct {
mu sync.Mutex
interval time.Duration
last map[string]time.Time
}
// NewRateLimiter constructs a limiter with the given per-document minimum gap.
func NewRateLimiter(interval time.Duration) *RateLimiter {
return &RateLimiter{interval: interval, last: make(map[string]time.Time)}
}
// Allow reports whether a checkpoint may run for docID now. When allowed it
// records the time and returns (true, 0); when throttled it returns
// (false, retryAfter) where retryAfter is the wait until the next allowed run.
func (rl *RateLimiter) Allow(docID string) (bool, time.Duration) {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
if last, ok := rl.last[docID]; ok {
if elapsed := now.Sub(last); elapsed < rl.interval {
return false, rl.interval - elapsed
}
}
rl.last[docID] = now
return true, 0
}

View File

@@ -0,0 +1,93 @@
package llm
import (
"testing"
"time"
)
func TestParseCheckpoint(t *testing.T) {
tests := []struct {
name string
raw string
want int
wantErr bool
}{
{
name: "clean json",
raw: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"}]}`,
want: 1,
},
{
name: "wrapped in markdown fence",
raw: "Here you go:\n```json\n{\"suggestions\":[{\"original\":\"a apple\",\"replacement\":\"an apple\",\"explanation\":\"use an before a vowel\",\"type\":\"grammar\"}]}\n```",
want: 1,
},
{
name: "empty suggestions",
raw: `{"suggestions":[]}`,
want: 0,
},
{
name: "drops items with empty original",
raw: `{"suggestions":[{"original":"","replacement":"x","explanation":"e","type":"grammar"},{"original":"teh","replacement":"the","explanation":"typo","type":"grammar"}]}`,
want: 1,
},
{
name: "no json at all",
raw: "I could not find any issues!",
wantErr: true,
},
{
name: "braces inside string values",
raw: `{"suggestions":[{"original":"use {x}","replacement":"use x","explanation":"drop the braces","type":"clarity"}]}`,
want: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseCheckpoint(tt.raw)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got none")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != tt.want {
t.Fatalf("got %d suggestions, want %d", len(got), tt.want)
}
})
}
}
func TestRateLimiter(t *testing.T) {
rl := NewRateLimiter(30 * time.Second)
if ok, _ := rl.Allow("doc1"); !ok {
t.Fatal("first call should be allowed")
}
if ok, retry := rl.Allow("doc1"); ok || retry <= 0 {
t.Fatalf("immediate second call should be throttled, got ok=%v retry=%v", ok, retry)
}
// A different document is independent.
if ok, _ := rl.Allow("doc2"); !ok {
t.Fatal("different doc should be allowed")
}
}
func TestTruncateDoc(t *testing.T) {
short := "hello"
if got := TruncateDoc(short); got != short {
t.Fatalf("short doc should be unchanged")
}
long := make([]byte, maxDocChars+500)
for i := range long {
long[i] = 'a'
}
got := TruncateDoc(string(long))
if len(got) != maxDocChars {
t.Fatalf("truncated length = %d, want %d", len(got), maxDocChars)
}
}

110
internal/llm/client.go Normal file
View File

@@ -0,0 +1,110 @@
// Package llm wraps the local inference endpoint behind a backend-agnostic
// interface. Two concrete backends — vLLM (OpenAI-compatible) and Ollama
// (native) — implement LLMClient; the rest of the app (the grammar checkpoint,
// Ask Petal) calls the interface only and never knows which one is active.
package llm
import (
"context"
"time"
"gitea.parodia.dev/drwily/petal/internal/config"
)
// Message is one chat turn sent to the model.
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
// CompletionRequest is the backend-neutral request shape. Each backend maps
// these fields onto its own wire format (see the parameter cheatsheet in the
// spec). Zero-valued sampling fields fall back to backend defaults.
type CompletionRequest struct {
Messages []Message
MaxTokens int
Temperature float64
RepetitionPenalty float64
TopP float64
Stop []string
Stream bool
}
// LLMClient is the single surface handler code depends on.
type LLMClient interface {
// Complete returns the full response in one shot (used for the checkpoint
// JSON, which we want whole before parsing).
Complete(ctx context.Context, req CompletionRequest) (string, error)
// Stream returns a channel of text chunks (used for Ask Petal SSE). The
// channel closes when generation ends; on a mid-stream error it closes
// early and the error is reported via the returned error of a future call.
Stream(ctx context.Context, req CompletionRequest) (<-chan string, error)
}
// Truncation caps. These guard checkpoint latency (prefill time scales with
// input length), not the model window — Qwen 3.5 ships 256K, far larger.
const (
// maxDocChars ~ 10,000 tokens at ~4 chars/token. Grammar checkpoint only;
// the voice pass sends the whole document uncut.
maxDocChars = 40000
// maxHistoryMsgs caps the rolling Ask Petal history (5 turns); oldest pairs
// drop first.
maxHistoryMsgs = 10
)
// TruncateDoc keeps the recent end of a document — the user is actively writing
// there — when it exceeds the grammar-checkpoint latency cap. Most documents
// fit uncut. The voice pass deliberately does NOT call this.
func TruncateDoc(contentText string) string {
if len(contentText) > maxDocChars {
return contentText[len(contentText)-maxDocChars:]
}
return contentText
}
// TrimHistory keeps the most recent maxHistoryMsgs messages, dropping the
// oldest first so a long Ask Petal chat stays within budget.
func TrimHistory(msgs []Message) []Message {
if len(msgs) > maxHistoryMsgs {
return msgs[len(msgs)-maxHistoryMsgs:]
}
return msgs
}
// NewLLMClient selects a backend from config. LLM_CHAT_MODEL falls back to
// LLM_MODEL here, in the factory, so handlers never deal with the fallback.
func NewLLMClient(cfg *config.Config) LLMClient {
chatModel := cfg.LLMChatModel
if chatModel == "" {
chatModel = cfg.LLMModel
}
base := backend{
endpoint: cfg.LLMEndpoint,
checkpointModel: cfg.LLMModel,
chatModel: chatModel,
timeout: cfg.LLMTimeout,
}
switch cfg.LLMBackend {
case "ollama":
return &OllamaClient{base}
default: // "vllm"
return &VLLMClient{base}
}
}
// backend holds the fields shared by both concrete clients.
type backend struct {
endpoint string
checkpointModel string // LLM_MODEL — small/fast checkpoint model
chatModel string // LLM_CHAT_MODEL (or LLM_MODEL) — Ask Petal model
timeout time.Duration
}
// model picks the right model id for a request. Streaming requests are Ask
// Petal (chat); one-shot Complete calls are the grammar checkpoint.
func (b backend) model(req CompletionRequest) string {
if req.Stream {
return b.chatModel
}
return b.checkpointModel
}

124
internal/llm/ollama.go Normal file
View File

@@ -0,0 +1,124 @@
package llm
import (
"bufio"
"context"
"encoding/json"
"fmt"
"net/http"
)
// OllamaClient talks to Ollama's native /api/chat endpoint. Selected when
// LLM_BACKEND=ollama.
type OllamaClient struct {
backend
}
// ollamaRequest mirrors Ollama's /api/chat body. Sampling parameters live under
// "options" (see the cheatsheet in the spec).
type ollamaRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream"`
Options ollamaOptions `json:"options"`
}
type ollamaOptions struct {
NumPredict int `json:"num_predict"`
Temperature float64 `json:"temperature"`
RepeatPenalty float64 `json:"repeat_penalty"`
TopP float64 `json:"top_p"`
Stop []string `json:"stop,omitempty"`
}
func (c *OllamaClient) body(req CompletionRequest) ollamaRequest {
return ollamaRequest{
Model: c.model(req),
Messages: req.Messages,
Stream: req.Stream,
Options: ollamaOptions{
NumPredict: req.MaxTokens,
Temperature: req.Temperature,
RepeatPenalty: req.RepetitionPenalty,
TopP: req.TopP,
Stop: req.Stop,
},
}
}
// Complete sends a non-streaming request and returns the full message content.
func (c *OllamaClient) Complete(ctx context.Context, req CompletionRequest) (string, error) {
req.Stream = false
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
resp, err := c.post(ctx, c.body(req))
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", httpError("ollama", resp)
}
var out struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("ollama: decode response: %w", err)
}
return out.Message.Content, nil
}
// Stream sends a streaming request. Ollama emits newline-delimited JSON objects;
// we forward each message.content and stop when done:true.
func (c *OllamaClient) Stream(ctx context.Context, req CompletionRequest) (<-chan string, error) {
req.Stream = true
resp, err := c.post(ctx, c.body(req))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
return nil, httpError("ollama", resp)
}
ch := make(chan string)
go func() {
defer close(ch)
defer resp.Body.Close()
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var chunk struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
Done bool `json:"done"`
}
if err := json.Unmarshal(line, &chunk); err != nil {
continue
}
if chunk.Message.Content != "" {
select {
case ch <- chunk.Message.Content:
case <-ctx.Done():
return
}
}
if chunk.Done {
return
}
}
}()
return ch, nil
}
func (c *OllamaClient) post(ctx context.Context, body any) (*http.Response, error) {
return postJSON(ctx, c.endpoint+"/api/chat", body)
}

63
internal/llm/prompts.go Normal file
View File

@@ -0,0 +1,63 @@
package llm
import "fmt"
// checkpointSystemPrompt is the grammar-checkpoint instruction. It asks for
// strict JSON (no fences, no preamble) so Complete's output parses directly.
const checkpointSystemPrompt = `You are a warm, encouraging writing assistant helping someone who speaks English as a second language. ` +
`Analyze the text below and identify up to 5 issues: grammar errors, unnatural phrasing, ` +
`incorrect idiom usage, or unclear sentences that are common ESL patterns.
Be specific, friendly, and explain WHY each suggestion improves the writing.
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
{
"suggestions": [
{
"original": "exact text from the document that needs fixing",
"replacement": "corrected version",
"explanation": "friendly one-sentence explanation",
"type": "grammar|phrasing|idiom|clarity"
}
]
}
If the writing looks good, return: {"suggestions": []}`
// CheckpointMessages builds the message array for a grammar checkpoint over the
// given (already-truncated) document text.
func CheckpointMessages(contentText string) []Message {
return []Message{
{Role: "system", Content: checkpointSystemPrompt},
{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.
const askPetalSystemTemplate = `You are Petal, a warm and patient English writing tutor helping someone who is learning English ` +
`as a second language. You are currently discussing a specific writing suggestion.
Suggestion context:
- Original text: "%s"
- Suggested replacement: "%s"
- Issue type: %s
- Initial explanation: "%s"
- Surrounding paragraph: "%s"
The user wants to understand this suggestion better. Detect the language of the user's message ` +
`and respond in that same language. If they write in Mandarin Chinese, respond entirely in ` +
`Mandarin. If they write in English, respond in English. Never mix languages in a single response.
Explain clearly and kindly. Use simple language appropriate to the user's message. Give examples ` +
`when helpful. If they ask "why" (or "为什么"), explain the grammar rule or idiom behind it. ` +
`If they suggest an alternative phrasing, evaluate it honestly.
Keep responses concise (2-4 sentences). This is a chat, not an essay. Be encouraging — ` +
`learning a language is hard and they're doing great.`
// AskPetalSystemPrompt fills the tutor prompt with one suggestion's context.
func AskPetalSystemPrompt(original, replacement, suggestionType, explanation, paragraph string) string {
return fmt.Sprintf(askPetalSystemTemplate, original, replacement, suggestionType, explanation, paragraph)
}

156
internal/llm/vllm.go Normal file
View File

@@ -0,0 +1,156 @@
package llm
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// VLLMClient talks to an OpenAI-compatible /v1/chat/completions endpoint
// (vLLM's API server). It is the default backend.
type VLLMClient struct {
backend
}
// vllmRequest is the OpenAI-compatible request body. repetition_penalty is a
// vLLM extension to the OpenAI schema, which vLLM accepts.
type vllmRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature"`
RepetitionPenalty float64 `json:"repetition_penalty"`
TopP float64 `json:"top_p"`
Stop []string `json:"stop,omitempty"`
Stream bool `json:"stream"`
}
func (c *VLLMClient) body(req CompletionRequest) vllmRequest {
return vllmRequest{
Model: c.model(req),
Messages: req.Messages,
MaxTokens: req.MaxTokens,
Temperature: req.Temperature,
RepetitionPenalty: req.RepetitionPenalty,
TopP: req.TopP,
Stop: req.Stop,
Stream: req.Stream,
}
}
// Complete sends a non-streaming request and returns the full message content.
func (c *VLLMClient) Complete(ctx context.Context, req CompletionRequest) (string, error) {
req.Stream = false
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
resp, err := c.post(ctx, c.body(req))
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", httpError("vllm", resp)
}
var out struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("vllm: decode response: %w", err)
}
if len(out.Choices) == 0 {
return "", fmt.Errorf("vllm: empty choices in response")
}
return out.Choices[0].Message.Content, nil
}
// Stream sends a streaming request and emits delta content chunks on the
// returned channel, which closes when the stream ends ([DONE]) or ctx is done.
func (c *VLLMClient) Stream(ctx context.Context, req CompletionRequest) (<-chan string, error) {
req.Stream = true
resp, err := c.post(ctx, c.body(req))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
return nil, httpError("vllm", resp)
}
ch := make(chan string)
go func() {
defer close(ch)
defer resp.Body.Close()
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
return
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue // skip keep-alives / malformed frames
}
if len(chunk.Choices) == 0 {
continue
}
if text := chunk.Choices[0].Delta.Content; text != "" {
select {
case ch <- text:
case <-ctx.Done():
return
}
}
}
}()
return ch, nil
}
func (c *VLLMClient) post(ctx context.Context, body any) (*http.Response, error) {
return postJSON(ctx, c.endpoint+"/v1/chat/completions", body)
}
// --- shared HTTP helpers (used by both backends) ---------------------------
// llmHTTP has no client-level timeout: Complete bounds itself with a context
// deadline (LLM_TIMEOUT), and streaming requests must stay open as long as the
// model is generating. Cancellation flows through the request context.
var llmHTTP = &http.Client{}
func postJSON(ctx context.Context, url string, body any) (*http.Response, error) {
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return llmHTTP.Do(req)
}
func httpError(backend string, resp *http.Response) error {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("%s: %s: %s", backend, resp.Status, strings.TrimSpace(string(b)))
}

View File

@@ -0,0 +1,246 @@
// Package suggestions implements the grammar-checkpoint endpoint and the
// accept/dismiss surface for LLM-proposed edits. Checkpoints run a single LLM
// pass over a document and persist the resulting pending suggestions; the
// frontend re-anchors each one by its `original` string at render time, so the
// stored positions are advisory only (spec Note #6).
package suggestions
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
// Handler holds the dependencies for the checkpoint + suggestion routes.
type Handler struct {
DB *db.DB
Client llm.LLMClient
Limit *llm.RateLimiter
}
// New constructs a Handler with a per-document checkpoint rate limiter.
func New(database *db.DB, client llm.LLMClient) *Handler {
return &Handler{
DB: database,
Client: client,
Limit: llm.NewRateLimiter(llm.CheckpointInterval),
}
}
// RegisterDocRoutes adds the document-scoped routes (check + 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.Get("/{id}/suggestions", h.listForDoc)
}
// Routes returns the router mounted at /api/suggestions for per-suggestion
// actions.
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Post("/{id}/accept", h.accept)
r.Post("/{id}/dismiss", h.dismiss)
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).
func (h *Handler) check(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
var contentText string
err := h.DB.QueryRow(
`SELECT content_text FROM documents WHERE id = ? AND user_id = ?`,
docID, db.LocalUserID,
).Scan(&contentText)
if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "document not found")
return
}
if err != nil {
serverError(w, err)
return
}
// Nothing to check 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 {
// Throttled: return the existing pending set unchanged rather than an
// error, so the frontend keeps showing current suggestions.
existing, err := h.fetchPending(docID)
if err != nil {
serverError(w, err)
return
}
writeJSON(w, http.StatusOK, existing)
return
}
raw, err := llm.RunCheckpoint(r.Context(), h.Client, contentText)
if err != nil {
errorJSON(w, http.StatusBadGateway, "checkpoint failed: "+err.Error())
return
}
saved, err := h.replacePending(docID, contentText, raw)
if err != nil {
serverError(w, err)
return
}
writeJSON(w, http.StatusOK, saved)
}
// 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) {
tx, err := h.DB.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
if _, err := tx.Exec(
`DELETE FROM suggestions WHERE doc_id = ? AND status = ?`,
docID, db.SuggestionStatusPending,
); err != nil {
return nil, 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
}
out = append(out, saved)
}
if err := tx.Commit(); err != nil {
return nil, err
}
return out, nil
}
// listForDoc returns the document's current pending suggestions (used when the
// editor loads a document, before any new checkpoint fires).
func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) {
out, err := h.fetchPending(chi.URLParam(r, "id"))
if err != nil {
serverError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (h *Handler) fetchPending(docID string) ([]db.Suggestion, error) {
rows, err := h.DB.Query(
`SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at
FROM suggestions
WHERE doc_id = ? AND status = ?
ORDER BY from_pos ASC, created_at ASC`,
docID, db.SuggestionStatusPending,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []db.Suggestion{}
for rows.Next() {
var s db.Suggestion
if err := rows.Scan(
&s.ID, &s.DocID, &s.FromPos, &s.ToPos, &s.Original, &s.Replacement,
&s.Explanation, &s.Type, &s.Status, &s.CreatedAt,
); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// accept marks a suggestion accepted (the client applies the replacement text).
func (h *Handler) accept(w http.ResponseWriter, r *http.Request) {
h.setStatus(w, r, db.SuggestionStatusAccepted)
}
// dismiss marks a suggestion rejected.
func (h *Handler) dismiss(w http.ResponseWriter, r *http.Request) {
h.setStatus(w, r, db.SuggestionStatusRejected)
}
func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status string) {
res, err := h.DB.Exec(
`UPDATE suggestions SET status = ? WHERE id = ? AND status = ?`,
status, chi.URLParam(r, "id"), db.SuggestionStatusPending,
)
if err != nil {
serverError(w, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
errorJSON(w, http.StatusNotFound, "pending suggestion not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
// locate finds the plaintext offsets of original within contentText. Returns
// (-1, -1) when not found; the frontend anchors by string regardless, so a miss
// here is non-fatal.
func locate(contentText, original string) (int, int) {
idx := strings.Index(contentText, original)
if idx < 0 {
return -1, -1
}
return idx, idx + len(original)
}
// normalizeType maps the model's type string onto a valid suggestion type,
// defaulting unknown values to grammar so a stray label never trips the CHECK.
func normalizeType(t string) string {
switch strings.ToLower(strings.TrimSpace(t)) {
case db.SuggestionTypeGrammar, db.SuggestionTypePhrasing, db.SuggestionTypeIdiom, db.SuggestionTypeClarity:
return strings.ToLower(strings.TrimSpace(t))
default:
return db.SuggestionTypeGrammar
}
}
// --- response helpers -------------------------------------------------------
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func errorJSON(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func serverError(w http.ResponseWriter, err error) {
errorJSON(w, http.StatusInternalServerError, err.Error())
}

View File

@@ -0,0 +1,142 @@
package suggestions
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
// stubClient returns a canned checkpoint response; it never touches the network.
type stubClient struct {
response string
calls int
}
func (s *stubClient) Complete(_ context.Context, _ llm.CompletionRequest) (string, error) {
s.calls++
return s.response, nil
}
func (s *stubClient) Stream(_ context.Context, _ llm.CompletionRequest) (<-chan string, error) {
ch := make(chan string)
close(ch)
return ch, nil
}
// 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) {
t.Helper()
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
// Seed a document with some content to check.
var docID string
err = database.QueryRow(
`INSERT INTO documents (user_id, content_text) VALUES (?, ?) RETURNING id`,
db.LocalUserID, "I has two apple.",
).Scan(&docID)
if err != nil {
t.Fatalf("seed doc: %v", err)
}
h := New(database, client)
r := chi.NewRouter()
r.Route("/docs", func(dr chi.Router) { h.RegisterDocRoutes(dr) })
r.Mount("/suggestions", h.Routes())
return r, docID
}
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
var r *http.Request
if body != "" {
r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
} else {
r = httptest.NewRequest(method, path, nil)
}
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, r)
return rec
}
func TestCheckpointFlow(t *testing.T) {
client := &stubClient{response: `{"suggestions":[
{"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)
// Run a checkpoint → two pending suggestions, with positions located.
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)
}
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("want 2 suggestions, got %d: %+v", len(got), got)
}
if got[0].Original != "I has" || got[0].FromPos != 0 {
t.Fatalf("first suggestion anchoring wrong: %+v", got[0])
}
if got[0].Status != db.SuggestionStatusPending {
t.Fatalf("new suggestion should be pending: %+v", got[0])
}
// Listing returns the same pending set.
rec = do(t, srv, http.MethodGet, "/docs/"+docID+"/suggestions", "")
var listed []db.Suggestion
_ = json.Unmarshal(rec.Body.Bytes(), &listed)
if len(listed) != 2 {
t.Fatalf("list pending: want 2, got %d", len(listed))
}
// Accept the first, dismiss the second.
if rec = do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/accept", ""); rec.Code != http.StatusNoContent {
t.Fatalf("accept: code=%d body=%s", rec.Code, rec.Body)
}
if rec = do(t, srv, http.MethodPost, "/suggestions/"+got[1].ID+"/dismiss", ""); rec.Code != http.StatusNoContent {
t.Fatalf("dismiss: code=%d body=%s", rec.Code, rec.Body)
}
// Pending list is now empty (accepted/dismissed are excluded).
rec = do(t, srv, http.MethodGet, "/docs/"+docID+"/suggestions", "")
_ = json.Unmarshal(rec.Body.Bytes(), &listed)
if len(listed) != 0 {
t.Fatalf("pending after resolve: want 0, got %d", len(listed))
}
// Re-accepting an already-resolved suggestion 404s.
if rec = do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/accept", ""); rec.Code != http.StatusNotFound {
t.Fatalf("re-accept: want 404, got %d", rec.Code)
}
}
func TestCheckpointRateLimit(t *testing.T) {
client := &stubClient{response: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"x","type":"grammar"}]}`}
srv, docID := newTestServer(t, client)
// First check runs the model.
do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
// Immediate second check is throttled — returns the existing set without
// hitting the model again.
do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
if client.calls != 1 {
t.Fatalf("rate limit should suppress the second model call, got %d calls", client.calls)
}
}