// 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" "io" "net/http" "strings" "github.com/go-chi/chi/v5" "gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/httputil" "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}/mechanics", h.mechanics) 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. // The deterministic mechanics family is owned by a separate pass (see mechanics), // detected client-side; a grammar checkpoint leaves those flags untouched. func (h *Handler) check(w http.ResponseWriter, r *http.Request) { h.runPass(w, r, h.Limit, llm.RunCheckpoint, grammarScope) } // mechanicsFinding is one deterministic, rule-based fix detected client-side (see // web Companion/prose.ts). Detection lives in the frontend — the same rules that // power the companion's prose notes — so the server only persists these; it does // not compute them. Offsets are exact plaintext spans from the detector. type mechanicsFinding struct { From int `json:"from"` To int `json:"to"` Original string `json:"original"` Replacement string `json:"replacement"` Explanation string `json:"explanation"` } // maxMechanicsFindings caps a single submission so a runaway client can't flood // the table; far above any realistic count for one document. const maxMechanicsFindings = 500 // mechanics persists the client-detected deterministic fixes as the 'mechanics' // family and returns the document's unified pending set. Free and not rate- // limited — it runs alongside the grammar checkpoint. It mirrors a single LLM // family: it replaces only the pending mechanics rows, honours actioned- // suppression, and (unlike the LLM passes) keeps the detector's exact offsets // rather than re-locating by string, which matters when the same word repeats. func (h *Handler) mechanics(w http.ResponseWriter, r *http.Request) { docID := chi.URLParam(r, "id") // Confirm the document exists (and is the local user's) for clean 404s. var exists bool err := h.DB.QueryRow( `SELECT EXISTS(SELECT 1 FROM documents WHERE id = ? AND user_id = ?)`, docID, db.LocalUserID, ).Scan(&exists) if err != nil { httputil.ServerError(w, err) return } if !exists { httputil.ErrorJSON(w, http.StatusNotFound, "document not found") return } var body struct { Findings []mechanicsFinding `json:"findings"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil { httputil.BadRequest(w, "invalid request body") return } if len(body.Findings) > maxMechanicsFindings { body.Findings = body.Findings[:maxMechanicsFindings] } if err := h.replaceMechanics(docID, body.Findings); err != nil { httputil.ServerError(w, err) return } out, err := h.fetchPending(docID) if err != nil { httputil.ServerError(w, err) return } httputil.WriteJSON(w, http.StatusOK, out) } // replaceMechanics swaps the document's pending mechanics rows for the supplied // findings in one transaction, leaving the LLM families and actioned rows // untouched. Findings the user already accepted or dismissed are suppressed (the // detector has no memory between runs), and malformed spans are skipped. func (h *Handler) replaceMechanics(docID string, findings []mechanicsFinding) 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 type = ?`, docID, db.SuggestionStatusPending, db.SuggestionTypeMechanics, ); err != nil { return err } sup, err := buildSuppressor(tx, docID) if err != nil { return err } for _, f := range findings { if f.From < 0 || f.To <= f.From || strings.TrimSpace(f.Original) == "" { continue // malformed span — the client re-anchors by string anyway } if sup.suppressed(f.Original, f.Replacement) { continue } if _, err := tx.Exec( `INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type) VALUES (?, ?, ?, ?, ?, ?, ?)`, docID, f.From, f.To, f.Original, f.Replacement, f.Explanation, db.SuggestionTypeMechanics, ); err != nil { return err } } return tx.Commit() } // 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) { httputil.ErrorJSON(w, http.StatusNotFound, "document not found") return } if err != nil { httputil.ServerError(w, err) return } // Nothing to analyze on an empty document — skip the LLM round-trip. if strings.TrimSpace(contentText) == "" { httputil.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 { httputil.ServerError(w, err) return } httputil.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) httputil.ErrorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error()) return } if err := h.replacePending(docID, contentText, raw, scope); err != nil { httputil.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 { httputil.ServerError(w, err) return } httputil.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 other self-owned families (voice, collocation, mechanics), which run on // their own cadence/pass and must survive a grammar checkpoint. Notably the // deterministic mechanics pass writes its rows in the same /check request just // before this DELETE runs, so excluding it here is what keeps them alive. grammarScope = pendingScope{deleteWhere: "type NOT IN ('voice','collocation','mechanics')", 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 touching a sentence the user already settled are suppressed from // the fresh batch (see suppressor): not just the identical edit re-proposed, but // reversals and re-polishing of the model's own just-accepted output — the // "fickle, keeps going back and forth on a few sentences" behavior. The model has // no memory between passes, so without this it re-opens resolved sentences every // checkpoint. 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 } sup, err := buildSuppressor(tx, docID) if err != nil { return err } for _, s := range raw { if sup.suppressed(s.Original, s.Replacement) { 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() } // dedupQuoteReplacer folds every straight/curly single- and double-quote variant // (and backtick/acute accent) onto one canonical character. The editor and the // model both rewrite quotes between passes — a sentence accepted with "…" comes // back flagged with '…' — so without folding, byte-identical text reads as a // different edit and the suppression below misses it. (This normalization is for // dedup ONLY; the frontend still anchors on the verbatim `original`.) var dedupQuoteReplacer = strings.NewReplacer( "‘", "'", "’", "'", "‚", "'", "‛", "'", // single curly "“", "'", "”", "'", "„", "'", "″", "'", // double curly "\"", "'", "`", "'", "´", "'", // straight double, backtick, acute ) // normalizeForDedup canonicalizes a string for suppression comparisons: quotes // folded (above) and runs of whitespace collapsed to single spaces (so a reflowed // paragraph still matches). Used only to decide what to suppress, never to alter // stored or rendered text. func normalizeForDedup(s string) string { return strings.Join(strings.Fields(dedupQuoteReplacer.Replace(s)), " ") } // suppressor decides which fresh suggestions to drop because the user has already // settled the sentence they touch. The model has no memory between passes, so on // every checkpoint it re-examines the current text and proposes edits — including // ones that re-open a sentence the user already resolved. Three families of those // are suppressed (all compared under normalizeForDedup): // // - pairs: the identical edit, re-proposed verbatim (the original "accept it, // then it nags again" case; also covers a silent no-op accept that left the // text unchanged). // - actionedOrig: any edit whose original is a span the user already accepted or // dismissed an edit on — "you already decided about this exact sentence." // - acceptedRepl: any edit whose original is text the user accepted AS a // replacement — i.e. the model re-touching its own just-accepted output, which // is how the reversals and endless re-polishing arise (accept "due to the // rain", next pass proposes changing "due to the rain" back). Guarded to // multi-word spans so word-level fixes aren't swept up as collateral. // - acceptedReplList holds the same accepted replacements for a containment // check: the model evades the exact acceptedRepl match by re-flagging a // *sub-clause* of an accepted sentence (flag "she was…due to the rain" instead // of the whole sentence). When one of the new original / an accepted // replacement contains the other and the shorter side is substantial // (>= minContainWords words), it's the same settled span and is dropped. type suppressor struct { pairs map[string]struct{} actionedOrig map[string]struct{} acceptedRepl map[string]struct{} acceptedReplList []string } // minContainWords is the floor for the containment check: the shorter of the two // spans must be at least this many words before a substring relationship counts // as "the same settled text." High enough that an incidental common phrase ("the // rain") can't suppress an unrelated sentence, low enough to catch a re-flagged // clause. const minContainWords = 4 // suppressed reports whether a fresh suggestion should be dropped as already // settled. An empty original is never suppressed here (it can't anchor anyway and // is dropped upstream). func (s suppressor) suppressed(original, replacement string) bool { o := normalizeForDedup(original) if o == "" { return false } if _, ok := s.pairs[o+"\x00"+normalizeForDedup(replacement)]; ok { return true } if _, ok := s.actionedOrig[o]; ok { return true } if strings.ContainsRune(o, ' ') { if _, ok := s.acceptedRepl[o]; ok { return true } } // Containment: the model re-flagged a sub-clause of (or a window around) an // accepted span. Suppress when one contains the other and the shorter side is // a substantial multi-word run. for _, r := range s.acceptedReplList { shorter, longer := o, r if len(r) < len(o) { shorter, longer = r, o } if len(strings.Fields(shorter)) >= minContainWords && strings.Contains(longer, shorter) { return true } } return false } // buildSuppressor loads the document's accepted/rejected edits and indexes them // into the three suppression families described on suppressor. func buildSuppressor(tx *sql.Tx, docID string) (suppressor, error) { rows, err := tx.Query( `SELECT original, replacement, status FROM suggestions WHERE doc_id = ? AND status IN (?, ?)`, docID, db.SuggestionStatusAccepted, db.SuggestionStatusRejected, ) if err != nil { return suppressor{}, err } defer rows.Close() s := suppressor{ pairs: make(map[string]struct{}), actionedOrig: make(map[string]struct{}), acceptedRepl: make(map[string]struct{}), } for rows.Next() { var original, replacement, status string if err := rows.Scan(&original, &replacement, &status); err != nil { return suppressor{}, err } o := normalizeForDedup(original) r := normalizeForDedup(replacement) s.pairs[o+"\x00"+r] = struct{}{} if o != "" { s.actionedOrig[o] = struct{}{} } if status == db.SuggestionStatusAccepted && r != "" { s.acceptedRepl[r] = struct{}{} s.acceptedReplList = append(s.acceptedReplList, r) } } return s, rows.Err() } // 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 { httputil.ServerError(w, err) return } httputil.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) } if err := rows.Err(); err != nil { return nil, err } return dedupeSpans(out), nil } // dedupeSpans resolves collisions between the deterministic mechanics family and // the LLM families: when a mechanics finding and an LLM suggestion fight over the // same characters, mechanics wins and the LLM card is dropped. Its span is exact // (the detector matched it), whereas the LLM positions are only advisory // (re-anchored by string at render), so the precise fix should own the span. // // This deliberately does NOT dedupe LLM-vs-LLM overlaps: voice (awareness-only, // no replacement) and collocation legitimately co-occupy the same span, and that // is intended. Suggestions that never anchored (from_pos < 0) occupy no real span // and are always kept. func dedupeSpans(in []db.Suggestion) []db.Suggestion { type span struct{ from, to int } var claimed []span for _, s := range in { if s.Type == db.SuggestionTypeMechanics && s.FromPos >= 0 { claimed = append(claimed, span{s.FromPos, s.ToPos}) } } if len(claimed) == 0 { return in } out := make([]db.Suggestion, 0, len(in)) for _, s := range in { if s.Type != db.SuggestionTypeMechanics && s.FromPos >= 0 { overlaps := false for _, sp := range claimed { if s.FromPos < sp.to && sp.from < s.ToPos { overlaps = true break } } if overlaps { continue // an exact mechanics fix owns these characters } } out = append(out, s) } return out } // 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 { httputil.ServerError(w, err) return } if n, _ := res.RowsAffected(); n == 0 { httputil.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 } }