Two halves of the same idea, both read out of work Petal already records. Planting: an accepted collocation is a learnable chunk, so it becomes a phrase card. The scheduler didn't need to know — a three-word chunk climbs the ladder exactly like a looked-up word. What needed care was deciding what *isn't* a chunk (single words are word choice; a six-word-plus "collocation" is a rewritten sentence, and sentences make miserable flashcards), and that the example must be the *corrected* sentence — the stored draft still holds the phrasing she just left behind. Re-accepting the same chunk leaves the existing card alone rather than resetting a schedule it has been climbing. The whole thing is best-effort: accepting an edit must never fail because a flashcard couldn't be made. The growth journal: kept this month beside kept the month before, the phrasing that stuck, the patterns that faded. The queries were the easy part; the honesty is the feature. "Stuck" needs the phrase in a *second* document, because one document is just the edit where she left it. "Faded" says nothing at all unless she has been writing lately — otherwise a month away from Petal comes back to her as progress, which is the one way this could lie. And a suggestion had to start recording when she *decided* it, not when the model proposed it, so 0012 adds resolved_at and backfills the old rows to their created_at. It lives as a second tab in the garden, and it feeds the kitten: after an accept she now sometimes hears something true of her alone, once per line, half the time, never waited for. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
687 lines
26 KiB
Go
687 lines
26 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"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
|
||
"gitea.parodia.dev/drwily/petal/internal/auth"
|
||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||
"gitea.parodia.dev/drwily/petal/internal/vocab"
|
||
)
|
||
|
||
// 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()
|
||
// The growth journal reads the same table these actions write, so it lives
|
||
// here rather than growing its own mount. A literal segment, so it can never
|
||
// be shadowed by an id.
|
||
r.Get("/growth", h.growth)
|
||
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 belongs to the caller) for clean 404s.
|
||
var exists bool
|
||
err := h.DB.QueryRow(
|
||
`SELECT EXISTS(SELECT 1 FROM documents WHERE id = ? AND user_id = ?)`,
|
||
docID, auth.UserID(r.Context()),
|
||
).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(auth.UserID(r.Context()), 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, the document's tone and the writer's pair language it
|
||
// returns the model's raw suggestions. The voice pass ignores both extras (see
|
||
// llm.RunVoice) and the checkpoint ignores the language — only the collocation
|
||
// coach writes a word of it — but one signature keeps runPass free of special
|
||
// cases.
|
||
type pass func(ctx context.Context, client llm.LLMClient, contentText, tone string, lang llm.Lang) ([]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")
|
||
userID := auth.UserID(r.Context())
|
||
|
||
// The writer's pair language rides along with the document rather than in a
|
||
// second query: it is read from the same row-scoped lookup that already
|
||
// proves she owns this document.
|
||
var contentText, tone, pairLang string
|
||
err := h.DB.QueryRow(
|
||
`SELECT d.content_text, d.tone, COALESCE(u.pair_lang, '')
|
||
FROM documents d
|
||
JOIN users u ON u.id = d.user_id
|
||
WHERE d.id = ? AND d.user_id = ?`,
|
||
docID, userID,
|
||
).Scan(&contentText, &tone, &pairLang)
|
||
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(userID, 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, llm.LangFor(pairLang))
|
||
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(userID, 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(auth.UserID(r.Context()), chi.URLParam(r, "id"))
|
||
if err != nil {
|
||
httputil.ServerError(w, err)
|
||
return
|
||
}
|
||
httputil.WriteJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
// fetchPending loads a document's pending suggestions, joined through documents
|
||
// so the rows are only reachable by the document's owner. A suggestion quotes the
|
||
// sentence it corrects, so an unscoped read here would leak document text to
|
||
// anyone holding a doc id.
|
||
func (h *Handler) fetchPending(userID, docID string) ([]db.Suggestion, error) {
|
||
rows, err := h.DB.Query(
|
||
`SELECT s.id, s.doc_id, s.from_pos, s.to_pos, s.original, s.replacement,
|
||
s.explanation, s.type, s.status, s.created_at
|
||
FROM suggestions s
|
||
JOIN documents d ON d.id = s.doc_id
|
||
WHERE s.doc_id = ? AND d.user_id = ? AND s.status = ?
|
||
ORDER BY s.from_pos ASC, s.created_at ASC`,
|
||
docID, userID, 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)
|
||
}
|
||
|
||
// setStatus accepts or dismisses one suggestion. The doc_id subquery scopes the
|
||
// write to the caller's own documents, so a stray (or guessed) suggestion id
|
||
// can't action a row belonging to another account; an unowned id simply affects
|
||
// no rows and surfaces as a 404.
|
||
func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status string) {
|
||
res, err := h.DB.Exec(
|
||
`UPDATE suggestions SET status = ?, resolved_at = datetime('now')
|
||
WHERE id = ? AND status = ?
|
||
AND doc_id IN (SELECT id FROM documents WHERE user_id = ?)`,
|
||
status, chi.URLParam(r, "id"), db.SuggestionStatusPending,
|
||
auth.UserID(r.Context()),
|
||
)
|
||
if err != nil {
|
||
httputil.ServerError(w, err)
|
||
return
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
httputil.ErrorJSON(w, http.StatusNotFound, "pending suggestion not found")
|
||
return
|
||
}
|
||
if status == db.SuggestionStatusAccepted {
|
||
h.plant(chi.URLParam(r, "id"), auth.UserID(r.Context()))
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// plant grows an accepted collocation into a vocabulary-garden phrase card. It
|
||
// runs after the status write and swallows its own errors: accepting an edit is
|
||
// the thing the writer asked for, and it must not fail — or even feel slower —
|
||
// because a flashcard couldn't be made.
|
||
//
|
||
// Only collocations are planted. The other families correct *this* sentence
|
||
// ("their" → "there", a comma, a clearer clause); a collocation is the one that
|
||
// hands over a reusable chunk, which is the only thing worth reviewing in a week.
|
||
func (h *Handler) plant(id, userID string) {
|
||
var s db.Suggestion
|
||
var contentText string
|
||
err := h.DB.QueryRow(
|
||
`SELECT s.type, s.original, s.replacement, s.explanation, s.doc_id, d.content_text
|
||
FROM suggestions s JOIN documents d ON d.id = s.doc_id
|
||
WHERE s.id = ? AND d.user_id = ?`,
|
||
id, userID,
|
||
).Scan(&s.Type, &s.Original, &s.Replacement, &s.Explanation, &s.DocID, &contentText)
|
||
if err != nil {
|
||
if !errors.Is(err, sql.ErrNoRows) {
|
||
log.Printf("suggestions: could not read %s for planting: %v", id, err)
|
||
}
|
||
return
|
||
}
|
||
if s.Type != db.SuggestionTypeCollocation || strings.TrimSpace(s.Replacement) == "" {
|
||
return
|
||
}
|
||
// The stored text is still the pre-accept draft — the client applies the
|
||
// replacement in the editor. Correct the sentence here so the flashcard
|
||
// quizzes the phrasing she is keeping, not the one she just left behind.
|
||
docID := s.DocID
|
||
if _, err := vocab.Plant(h.DB, userID, vocab.Phrase{
|
||
Text: s.Replacement,
|
||
Meaning: s.Explanation,
|
||
Example: correctedSentence(contentText, s.Original, s.Replacement),
|
||
DocID: &docID,
|
||
}); err != nil {
|
||
log.Printf("suggestions: could not plant %s: %v", id, err)
|
||
}
|
||
}
|
||
|
||
// correctedSentence returns the sentence of contentText containing original,
|
||
// with original swapped for replacement. Returns "" when the original isn't
|
||
// found (the draft moved on) — a card with no example still reviews, just
|
||
// without the cloze, so there's nothing to fall back to and nothing to guess.
|
||
func correctedSentence(contentText, original, replacement string) string {
|
||
idx := strings.Index(contentText, original)
|
||
if original == "" || idx < 0 {
|
||
return ""
|
||
}
|
||
start := strings.LastIndexAny(contentText[:idx], ".!?\n")
|
||
end := strings.IndexAny(contentText[idx+len(original):], ".!?\n")
|
||
if end < 0 {
|
||
end = len(contentText)
|
||
} else {
|
||
end += idx + len(original) + 1 // keep the terminator
|
||
}
|
||
sentence := strings.TrimSpace(contentText[start+1 : end])
|
||
return strings.Replace(sentence, original, replacement, 1)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|