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
360 lines
12 KiB
Go
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())
|
|
}
|