Files
petal/internal/suggestions/handlers.go
prosolis 8aa437ec82 Phase 12 + 13: collocation coach + vocabulary garden
Phase 12 — collocation coach: a third suggestion family for gentle
"natives usually say…" hints on non-native word pairings, reusing the
existing runPass/pendingScope/rail machinery.
- llm/collocation.go (RunCollocation, 25s floor, reuses ParseCheckpoint)
  + collocationSystemPrompt/CollocationMessages (warm, Mandarin gloss,
  defers grammar/spelling to the grammar family)
- migration 0005 rebuilds the suggestions table to extend the type CHECK
  (SQLite can't ALTER a CHECK)
- collocationScope + CollocationLimit + POST /{id}/collocation
- fix: grammarScope was `type != 'voice'` and would wipe the new
  collocation flags; now `type NOT IN ('voice','collocation')`
- frontend: --color-blossom, "Make it sound natural 🌸" pill,
  collocating/runCollocation in useCheckpoint, StatusBar dot

Phase 13 — vocabulary garden: capture looked-up words and surface them
for gentle spaced repetition.
- new internal/vocab package: migration 0006 (vocab_words, SM-2-lite
  columns, doc_id ON DELETE SET NULL, UNIQUE(user_id,word)),
  scheduler.go (Leitner ladder 1/3/7/16/35 then geometric; gentle
  "again", no streak-shaming), handlers (capture-upsert/list/due/
  review/delete, owner-scoped, SQLite-side datetime math)
- auto-capture on word lookup (dictionary-known words only, captures
  the surrounding sentence + doc_id) + 🤍/💚 toggle on WordCard
- GardenPanel: blossom grid (bloom by reps), flashcard review (sentence
  blanked, flip, again/good/easy, recognition↔production), sleepy-kitten
  footer; opened from a global 🌷 header button

Tests: TestCollocationPassCoexists, vocab scheduler + handlers, db CHECK
extended. go build/vet/test + tsc + vite + vitest (51/51) clean;
migration verified against a copy of the live DB; live backend smoke
walked the full vocab lifecycle + the warm-502 collocation path.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 15:50:25 -07:00

360 lines
12 KiB
Go

// 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 (
"context"
"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. The
// grammar checkpoint and the voice pass each get their own per-document rate
// limiter — they are independent passes with different cadences.
type Handler struct {
DB *db.DB
Client llm.LLMClient
Limit *llm.RateLimiter // grammar checkpoint floor
VoiceLimit *llm.RateLimiter // voice-consistency floor
CollocationLimit *llm.RateLimiter // collocation-coach floor
}
// New constructs a Handler with per-document checkpoint, voice, and collocation
// rate limiters.
func New(database *db.DB, client llm.LLMClient) *Handler {
return &Handler{
DB: database,
Client: client,
Limit: llm.NewRateLimiter(llm.CheckpointInterval),
VoiceLimit: llm.NewRateLimiter(llm.VoiceInterval),
CollocationLimit: llm.NewRateLimiter(llm.CollocationInterval),
}
}
// RegisterDocRoutes adds the document-scoped routes (check + voice + 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.Post("/{id}/voice", h.voice)
r.Post("/{id}/collocation", h.collocation)
r.Post("/{id}/rewrite", h.rewrite)
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)
r.Post("/{id}/chat", h.chat)
r.Post("/{id}/translate", h.translate)
return r
}
// check runs a grammar checkpoint over the document. Fast, typing-cadence pass.
func (h *Handler) check(w http.ResponseWriter, r *http.Request) {
h.runPass(w, r, h.Limit, llm.RunCheckpoint, grammarScope)
}
// voice runs a Tier-1 voice-consistency pass over the whole document. Slow,
// explicit-action pass; replaces only the pending voice flags.
func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
h.runPass(w, r, h.VoiceLimit, llm.RunVoice, voiceScope)
}
// collocation runs the collocation coach over the whole document, flagging
// non-native word pairings. Explicit-action pass; replaces only the pending
// collocation flags.
func (h *Handler) collocation(w http.ResponseWriter, r *http.Request) {
h.runPass(w, r, h.CollocationLimit, llm.RunCollocation, collocationScope)
}
// pass is the signature shared by the grammar checkpoint and the voice pass:
// given the document text and the document's tone it returns the model's raw
// suggestions. The voice pass ignores tone (see llm.RunVoice).
type pass func(ctx context.Context, client llm.LLMClient, contentText, tone string) ([]llm.RawSuggestion, error)
// runPass is the shared body for both LLM passes. It loads the document text,
// enforces the pass's per-document rate limit, runs the model, swaps in the
// fresh batch scoped to this family, and returns the document's FULL pending set
// (both families) so the client always renders a unified picture.
func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.RateLimiter, run pass, scope pendingScope) {
docID := chi.URLParam(r, "id")
var contentText, tone string
err := h.DB.QueryRow(
`SELECT content_text, tone FROM documents WHERE id = ? AND user_id = ?`,
docID, db.LocalUserID,
).Scan(&contentText, &tone)
if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "document not found")
return
}
if err != nil {
serverError(w, err)
return
}
// Nothing to analyze on an empty document — skip the LLM round-trip.
if strings.TrimSpace(contentText) == "" {
writeJSON(w, http.StatusOK, []db.Suggestion{})
return
}
ok, _, slotAt := limiter.Allow(docID)
if !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 := run(r.Context(), h.Client, contentText, tone)
if err != nil {
// Allow ran before the model call, so a failed pass would otherwise hold
// the per-document slot for the full interval — stranding the frontend's
// auto-retry on the throttle path. Release it so a retry can re-run.
limiter.Release(docID, slotAt)
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
return
}
if err := h.replacePending(docID, contentText, raw, scope); err != nil {
serverError(w, err)
return
}
// Return the unified pending set (grammar + voice), not just this batch, so
// a grammar check never drops the voice highlights from the client and the
// throttle path above stays consistent with the success path.
out, err := h.fetchPending(docID)
if err != nil {
serverError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// pendingScope describes how one LLM pass touches the shared suggestions table:
// which family of pending rows it replaces, and the type to stamp on the rows it
// inserts. The grammar checkpoint and voice pass each own a disjoint family, so
// running one never disturbs the other's pending flags.
type pendingScope struct {
deleteWhere string // extra WHERE clause scoping the DELETE to this family
forceType string // if set, every inserted row gets this type; else normalizeType
}
var (
// grammarScope owns the grammar/phrasing/idiom/clarity flags — everything but
// the explicit-action families (voice, collocation), which run on their own
// cadence and must survive a grammar checkpoint.
grammarScope = pendingScope{deleteWhere: "type NOT IN ('voice','collocation')", forceType: ""}
// voiceScope owns the voice flags only.
voiceScope = pendingScope{deleteWhere: "type = 'voice'", forceType: db.SuggestionTypeVoice}
// collocationScope owns the collocation flags only.
collocationScope = pendingScope{deleteWhere: "type = 'collocation'", forceType: db.SuggestionTypeCollocation}
)
// replacePending swaps a document's pending suggestions within one family for a
// fresh batch in a single transaction. Accepted/rejected suggestions and the
// other family's pending rows are left untouched.
//
// Suggestions the user already accepted or dismissed are suppressed from the
// fresh batch: the model has no memory between passes, so without this it would
// re-propose the identical edit on the very next checkpoint — re-nagging a
// sentence the user already resolved. This matters most when an accept silently
// no-ops (the `original` text couldn't be anchored in the editor, so the doc
// never changed): the sentence is unaltered, yet the user shouldn't see the same
// card again.
func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion, scope pendingScope) error {
tx, err := h.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(
`DELETE FROM suggestions WHERE doc_id = ? AND status = ? AND `+scope.deleteWhere,
docID, db.SuggestionStatusPending,
); err != nil {
return err
}
actioned, err := actionedKeys(tx, docID)
if err != nil {
return err
}
for _, s := range raw {
if _, seen := actioned[suggestionKey(s.Original, s.Replacement)]; seen {
continue
}
typ := scope.forceType
if typ == "" {
typ = normalizeType(s.Type)
}
from, to := locate(contentText, s.Original)
if _, err := tx.Exec(
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
docID, from, to, s.Original, s.Replacement, s.Explanation, typ,
); err != nil {
return err
}
}
return tx.Commit()
}
// actionedKeys returns the set of original→replacement keys the user has already
// accepted or rejected for this document, so a fresh checkpoint can skip
// re-proposing them. Keyed on the trimmed original+replacement pair, matching the
// normalization ParseCheckpoint applies, so a genuinely different edit on the same
// sentence is not suppressed.
func actionedKeys(tx *sql.Tx, docID string) (map[string]struct{}, error) {
rows, err := tx.Query(
`SELECT original, replacement FROM suggestions
WHERE doc_id = ? AND status IN (?, ?)`,
docID, db.SuggestionStatusAccepted, db.SuggestionStatusRejected,
)
if err != nil {
return nil, err
}
defer rows.Close()
keys := make(map[string]struct{})
for rows.Next() {
var original, replacement string
if err := rows.Scan(&original, &replacement); err != nil {
return nil, err
}
keys[suggestionKey(original, replacement)] = struct{}{}
}
return keys, rows.Err()
}
// suggestionKey is the dedup identity for a suggestion: its trimmed original and
// replacement text. Two suggestions with the same key are "the same edit."
func suggestionKey(original, replacement string) string {
return strings.TrimSpace(original) + "\x00" + strings.TrimSpace(replacement)
}
// 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, db.SuggestionTypeCollocation:
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())
}