Files
petal/internal/llm/checkpoint.go
T
prosolis 76dede8856 Correct the language she wrote in, not the one she was practising
Every pass was English-shaped: CheckpointMessages took the text and the tone and
nothing else, so there was never a language decision to get wrong. On the live
build two pt-PT sentences drew no cards at all — Petal read the Portuguese, said
nothing about it, and filed a mechanics note about the one English line.

The rule is two decisions reading different state. What gets corrected follows
the document. What language the explanation is written in follows the writer —
the half of her pair she is not learning, from users.direction — because an
explanation is teaching, and teaching lands in the language she reads most
easily. Those coincide for every account that exists today (learnerPairs is
{"zh"}), which is a fact about the roster and not about the design, so Target
keeps them apart. It carries a third language too: the collocation gloss is
addressed to her rather than to the document, and folding it into Explain would
have quietly moved it into English on every English document.

The document verdict is a proportion, not a presence — one Portuguese quotation
must not flip an English essay. Per sentence, three-way: pair, English, or no
answer. The third value is the load-bearing one; counting the undecided as
English is exactly what would hold a journal of short Portuguese sentences in
English forever, so the Latin pairs needed an englishMarkers list curated against
pt/fr/es as carefully as latinMarkers was curated against English. Hysteresis at
70/40 because a bilingual paragraph would otherwise alternate its cards' language
every few keystrokes, and hysteresis needs a yesterday — hence the column. Plus a
corroboration floor: a ratio computed over "Não. Eu." is 100% of nothing, and a
flip rewrites every card in the document.

The verdict folds into the chunk salt beside the tone, so a document that changes
language re-opens every sentence rather than serving back cards in a language it
no longer speaks.

checkpointSystemPrompt could not simply take a language — it opens by naming the
reader an ESL learner, and appending "explain in Portuguese" hands the model two
contradictory framings. Separate constants, sharing the JSON contract below the
framing. Both carry a "never translate it into English" line, which is the
instruction the model will most want to disobey. The English prompts are
untouched byte for byte, and a golden says so out loud.

Collocation deliberately did not move: its prompt is per-language knowledge, not
framing, and "natives usually say" for Portuguese is a claim Petal cannot back.

Not deployed and not smoked against a real model. The tests drive the real router
and a real DB; what none of them prove is how Qwen behaves on a Portuguese
document, in particular whether the never-translate line holds.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 23:20:53 -07:00

233 lines
8.0 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
// checkpointMaxTokens caps the checkpoint's generated output. Each suggestion
// echoes its original sentence, a replacement, and a friendly explanation, so
// the full JSON for the prompt's "up to 5 issues" runs ~2,000 tokens on a long
// document (measured ~2,007 for a 380-word doc). The old 1,024 cap truncated
// the JSON mid-array — done_reason "length" — leaving an object that never
// closed, so ParseCheckpoint found no parseable JSON and the pass 502'd on
// every long doc. This is a ceiling, not a target: the model stops at its JSON
// close well before it, so the headroom costs nothing on shorter docs.
const checkpointMaxTokens = 4096
// 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, t Target) ([]RawSuggestion, error) {
raw, err := client.Complete(ctx, CompletionRequest{
Messages: CheckpointMessages(TruncateDoc(contentText), tone, t),
MaxTokens: checkpointMaxTokens,
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. When the model
// truncates mid-array (it ran out of output budget) the outer object never
// closes and won't parse — we then recover the suggestion objects that *did*
// complete, so a long document still gets feedback instead of a hard failure.
func ParseCheckpoint(raw string) ([]RawSuggestion, error) {
var suggestions []RawSuggestion
parsed := false
if jsonText := extractJSONObject(raw); jsonText != "" {
var resp checkpointResponse
if err := json.Unmarshal([]byte(jsonText), &resp); err == nil {
suggestions = resp.Suggestions
parsed = true
}
}
if !parsed {
// No closeable object, or it failed to decode (most often a truncated
// array). Salvage the individual suggestion objects that completed.
suggestions = salvageSuggestions(raw)
if suggestions == nil {
return nil, fmt.Errorf("checkpoint: no JSON object in model output: %q", truncateForError(raw))
}
}
// Drop items the model returned with an empty original — they can't be
// anchored — and normalize whitespace the model may have echoed.
out := suggestions[:0]
for _, s := range suggestions {
s.Original = strings.TrimSpace(s.Original)
s.Replacement = strings.TrimSpace(s.Replacement)
if s.Original == "" {
continue
}
// Drop no-ops: the model sometimes "flags" a correct sentence and echoes
// it verbatim as the replacement, producing a card whose before/after are
// identical and whose explanation says it's already fine — a suggestion
// that suggests nothing. Awareness-only families (voice) carry an empty
// replacement, so this only ever fires on the edit families.
if s.Replacement != "" && s.Original == s.Replacement {
continue
}
out = append(out, s)
}
return out, nil
}
// salvageSuggestions recovers as many complete suggestion objects as it can from
// a response whose top-level JSON didn't parse — typically one truncated mid
// "suggestions" array. It scans the array region for brace-balanced {...}
// objects and keeps each that decodes into a RawSuggestion with a non-empty
// original; the final, cut-off object simply never balances and is dropped.
// Returns nil when there is no salvageable array (so the caller can error).
func salvageSuggestions(raw string) []RawSuggestion {
key := strings.Index(raw, `"suggestions"`)
if key < 0 {
return nil
}
lb := strings.IndexByte(raw[key:], '[')
if lb < 0 {
return nil
}
rest := raw[key+lb+1:]
out := []RawSuggestion{}
for {
obj, end := firstBalancedObject(rest)
if end < 0 {
break
}
var s RawSuggestion
if err := json.Unmarshal([]byte(obj), &s); err == nil && strings.TrimSpace(s.Original) != "" {
out = append(out, s)
}
rest = rest[end:]
}
if len(out) == 0 {
return nil
}
return out
}
// 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 {
obj, _ := firstBalancedObject(s)
return obj
}
// firstBalancedObject returns the first brace-balanced {...} substring in s and
// the index in s just past its closing brace, or ("", -1) if no object closes
// (none present, or the only one is truncated). String contents — including
// braces inside quoted values — are skipped so they can't unbalance the count.
func firstBalancedObject(s string) (string, int) {
start := strings.IndexByte(s, '{')
if start < 0 {
return "", -1
}
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], i + 1
}
}
}
return "", -1
}
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, recordedAt); when throttled it returns
// (false, retryAfter, zero) where retryAfter is the wait until the next allowed
// run. recordedAt lets a caller whose pass fails hand the exact timestamp to
// Release so it rolls back only its own slot.
func (rl *RateLimiter) Allow(docID string) (bool, time.Duration, time.Time) {
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, time.Time{}
}
}
rl.last[docID] = now
return true, 0, now
}
// Release rolls back the slot a prior Allow recorded for docID, but only if no
// newer Allow has since claimed it. Called when an LLM pass fails: Allow runs
// before the model call, so without this a failed checkpoint would hold the
// per-document slot for the full interval and a retry (or the frontend's
// auto-retry) would hit the throttle path and get the stale/empty set back
// instead of re-running. `at` is the time the failed Allow returned.
func (rl *RateLimiter) Release(docID string, at time.Time) {
rl.mu.Lock()
defer rl.mu.Unlock()
if last, ok := rl.last[docID]; ok && last.Equal(at) {
delete(rl.last, docID)
}
}