Surface every outstanding suggestion as a card in the right-hand
whitespace, vertically aligned to the text it flags — so the writer sees
the whole queue at once instead of hovering each highlight. Cards stack
with collision avoidance, link both ways with their highlight (hover/click
↔ soft text wash, driven through the decoration plugin so it survives
edit repaints), and carry the same Accept / Dismiss / Ask Petal actions.
The rail is a progressive enhancement: it mounts only when there's room
beside the editor, otherwise the existing inline hover card is unchanged.
Stacked cards that reach the bottom-right corner tuck behind the
companion mascot (z-order).
When a card is expanded, the Ask Petal bubble now opens with the
Simplified-Chinese translation of the explanation (the English stays in
the card body) instead of repeating the same text twice — a new
POST /api/suggestions/{id}/translate one-shot LLM endpoint, loaded
lazily on open with an English fallback.
Verified live against the local LLM via the uitest harness: rail
stacking, hover↔text wash, expand/Ask Petal, accept-from-rail, narrow
fallback, and the Mandarin bubble.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
340 lines
11 KiB
Go
340 lines
11 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
|
|
}
|
|
|
|
// 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)
|
|
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)
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// 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:
|
|
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())
|
|
}
|