Files
petal/internal/llm/checkpoint.go
prosolis 4c288834c0 Editor: document tone, right-click word lookup, expanded stats
Four enhancements to make the editor fit real school usage:

- Per-document tone (academic/professional/casual/humorous/creative/
  persuasive/general): new documents.tone column (migration 0002), threaded
  through the docs API, a bilingual ToneSelect dropdown on the title row, and
  injected into the grammar-checkpoint LLM prompt so advice fits the register.
  The voice pass stays tone-agnostic.

- Right-click word lookup: a new offline `lexicon` package serves definitions
  (Wordset, modern ESL-friendly glosses) and synonyms (WordNet synsets first,
  then frequency+stopword-ranked Moby for breadth) from gzipped embedded data,
  behind /api/word/{word} with light morphology. The WordCard popover shows the
  definition and tappable synonym pills that swap the word in place.

- Expanded writing stats: clicking the word count opens a StatsPanel with page
  count, sentences, paragraphs, reading time, average word length, word variety,
  and Flesch-Kincaid reading level — all computed client-side.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 23:22:55 -07:00

144 lines
4.2 KiB
Go

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, tone string) ([]RawSuggestion, error) {
raw, err := client.Complete(ctx, CompletionRequest{
Messages: CheckpointMessages(TruncateDoc(contentText), tone),
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
}