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:
143
internal/llm/checkpoint.go
Normal file
143
internal/llm/checkpoint.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user