// 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 } // New constructs a Handler with per-document checkpoint and voice 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), } } // 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}/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) 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) } // 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 } if ok, _ := limiter.Allow(docID); !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 { 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 voice). grammarScope = pendingScope{deleteWhere: "type != 'voice'", forceType: ""} // voiceScope owns the voice flags only. voiceScope = pendingScope{deleteWhere: "type = 'voice'", forceType: db.SuggestionTypeVoice} ) // 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. 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 } for _, s := range raw { 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() } // 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: 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()) }