// 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) r.Get("/{id}/settled", h.listSettled) } // 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"` // Which family this offline finding belongs to. Empty (the historical shape) // means mechanics; the miscollocation rules send 'collocation' so a chunk the // rule pack caught is indistinguishable from one the coach caught — same // family, same rail, and the same planting into the garden on accept. Type string `json:"type"` } // localType maps a client-supplied family onto the two an offline rule may claim. // Anything else — including the empty string older clients send — is mechanics, // so a stray label can never smuggle a row into an LLM family and survive that // pass's DELETE. func localType(t string) string { if strings.ToLower(strings.TrimSpace(t)) == db.SuggestionTypeCollocation { return db.SuggestionTypeCollocation } return db.SuggestionTypeMechanics } // 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 brings the document's pending offline rows in line with 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. // // A finding the detector still reports keeps its existing row — same id, same // created_at — and only its offsets move. This pass fires 250 ms after a // keystroke, so deleting and re-inserting the family would hand every card a new // identity several times a sentence: the rail would remount, a card expanded for // Ask Petal would collapse under her, and the arrival chime would re-fire. // // The scope is *source*, not type: the rule pack owns both the mechanics family // and its share of the collocation family, and every run is a full recompute of // the document. Scoping by type instead would strand offline collocations the // current text no longer warrants — the one row nobody would ever replace. func (h *Handler) replaceMechanics(docID string, findings []mechanicsFinding) error { tx, err := h.DB.Begin() if err != nil { return err } defer tx.Rollback() existing, err := loadPending(tx, docID, "source = '"+db.SuggestionSourceLocal+"'") if err != nil { return err } index := indexByEdit(existing) sup, err := buildSuppressor(tx, docID) if err != nil { return err } kept := make(map[string]bool, len(existing)) 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 } typ := localType(f.Type) if row, ok := index.take(f.Original, f.Replacement, f.From); ok { kept[row.id] = true if err := reposition(tx, row, f.From, f.To, ""); err != nil { return err } continue } if _, err := tx.Exec( `INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, docID, f.From, f.To, f.Original, f.Replacement, f.Explanation, typ, db.SuggestionSourceLocal, ); err != nil { return err } } // Whatever the detector no longer reports, she has fixed. for _, row := range existing { if kept[row.id] { continue } if _, err := tx.Exec(`DELETE FROM suggestions WHERE id = ?`, row.id); 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 languages this document // is to be corrected and explained in, it returns the model's raw suggestions. // The voice pass ignores the tone (see llm.RunVoice) and the collocation coach // reads only the writer's pair language, but one signature keeps runPass free of // special cases. type pass func(ctx context.Context, client llm.LLMClient, contentText, tone string, t llm.Target) ([]llm.RawSuggestion, error) // targetFor resolves the two language decisions for one pass over one document. // // They read different state on purpose. What gets *corrected* follows the // document, because Portuguese prose wants Portuguese corrections. What language // the correction is *explained* in follows the writer — the half of her pair she // is not learning — because an explanation is teaching, and teaching lands in the // language she reads most easily. A native Portuguese speaker practising English // gets Portuguese explained in Portuguese; a native English speaker learning // French gets French explained in English. Neither is trapped: the other language // stays one tap away, in both directions. // // An English document keeps the pre-Phase-28 behaviour exactly — explained in // English, with her language on the Ask Petal / translate taps — which is the // path every account today is on. // // The direction lookup costs nothing today: `learnerPairs` is {"zh"}, so fr, es // and pt-PT accounts are all learning_en and their non-learned half *is* the pair // language. This rule therefore produces "explain in the document's language" for // every writer who currently exists. It is written out anyway to stop the // coincidence being baked into the prompts, the way "English is the language // being learned" was baked into pair_lang before migration 0016. func targetFor(pairLang, direction, docLang string) llm.Target { pair := llm.LangFor(pairLang) if normalizeDocLang(docLang) != docLangPair { return llm.EnglishTarget(pair) } explain := pair if direction == auth.DirectionLearningPair { explain = llm.English } return llm.Target{Correct: pair, Explain: explain, Pair: pair} } // 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 and direction ride along with the document // rather than in a second query: they are read from the same row-scoped // lookup that already proves she owns this document. `doc_lang` is the // previous language verdict, which the new one needs (hysteresis). var contentText, tone, pairLang, direction, prevLang string err := h.DB.QueryRow( `SELECT d.content_text, d.tone, d.doc_lang, COALESCE(u.pair_lang, ''), COALESCE(u.direction, '') FROM documents d JOIN users u ON u.id = d.user_id WHERE d.id = ? AND d.user_id = ?`, docID, userID, ).Scan(&contentText, &tone, &prevLang, &pairLang, &direction) 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. The // family's rows go with the text they were about. if strings.TrimSpace(contentText) == "" { if err := h.reconcilePending(docID, contentText, pairLang, nil, scope, nil, nil, false); err != nil { httputil.ServerError(w, err) return } out, err := h.fetchPending(userID, docID) if err != nil { httputil.ServerError(w, err) return } httputil.WriteJSON(w, http.StatusOK, out) return } // What language is this document in, and so what language should its cards be // written in? Computed from the whole content_text — never from `askText`, // which on a chunked pass is only the sentences that changed, and would put an // English card in a Portuguese journal the moment she edits its one English // line. docLang := documentLang(contentText, pairLang, prevLang) if docLang != normalizeDocLang(prevLang) { if _, err := h.DB.Exec( `UPDATE documents SET doc_lang = ? WHERE id = ? AND user_id = ?`, docLang, docID, userID, ); err != nil { httputil.ServerError(w, err) return } } target := targetFor(pairLang, direction, docLang) // Decide what to ask about before spending anything: a chunked pass asks only // about the sentences that changed since it last read the document, and when // none did it doesn't call the model at all — nor consume its rate-limit slot, // so the next real edit isn't throttled by a check that had nothing to do. // // Only a chunked pass consults that record, so only it needs the tone folded // into a sentence's identity — and, next to it, the language verdict. A // document that flips language changes every sentence's identity, so its // old-language cards are re-checked rather than left sitting there in a // language the rest of the document no longer speaks. salt := "" if scope.chunked { salt = tone + "\x00" + docLang } chunks := splitChunks(contentText, salt) askText, fresh := contentText, chunks if scope.chunked { checked, err := h.checkedChunks(docID, scope.family) if err != nil { httputil.ServerError(w, err) return } changed := changedChunks(chunks, checked) if len(changed) == 0 { // Every sentence has already been read. Drop the rows whose sentence is // gone, keep the rest exactly as they are, and answer immediately. if err := h.reconcilePending(docID, contentText, pairLang, nil, scope, chunks, nil, false); err != nil { httputil.ServerError(w, err) return } out, err := h.fetchPending(userID, docID) if err != nil { httputil.ServerError(w, err) return } httputil.WriteJSON(w, http.StatusOK, out) return } // When every sentence is new — a first pass, a paste, a tone switch — hand // over the document verbatim so the model reads it with its paragraphing // intact. Otherwise send just the delta, one sentence per line. if len(changed) < len(hashSet(chunks)) { askText, fresh = joinChunks(changed), changed } } 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, askText, tone, target) 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.UpstreamError(w, "pass", err) return } // A whole-document pass re-read everything, so every one of its rows is up for // re-proposal; a chunked pass only puts the sentences it asked about in play. if err := h.reconcilePending(docID, contentText, pairLang, raw, scope, chunks, fresh, !scope.chunked); 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 this pass to its own family forceType string // if set, every inserted row gets this type; else normalizeType // family keys the sentences this pass has already read (see checked_chunks). family string // chunked passes re-read only the sentences that changed. True for the typing- // cadence grammar checkpoint, which fires constantly and must feel still; // false for the explicit whole-document passes, where she pressed a button // asking for a fresh read of everything. chunked bool } // Every scope below is confined to source='llm'. The offline rule pack owns its // own rows and recomputes them on each edit (see replaceMechanics); its findings // must survive all three model passes — including the collocation coach, which // now shares the collocation family with it. var ( // grammarScope owns the grammar/phrasing/idiom/clarity flags — everything but // the other self-owned families (voice, collocation), which run on their own // cadence/pass and must survive a grammar checkpoint. Notably the offline pass // writes its rows in the same /check request just before this pass reconciles, // so the source clause is also what keeps them alive. grammarScope = pendingScope{ deleteWhere: "source = 'llm' AND type NOT IN ('voice','collocation')", family: "grammar", chunked: true, } // voiceScope owns the model's voice flags only. Voice is a property of the // document as a whole — a sentence isn't inconsistent with itself — so this // pass always reads everything. voiceScope = pendingScope{ deleteWhere: "source = 'llm' AND type = 'voice'", forceType: db.SuggestionTypeVoice, family: "voice", } // collocationScope owns the model's collocation flags only — the rule pack's // share of the same family is left standing. collocationScope = pendingScope{ deleteWhere: "source = 'llm' AND type = 'collocation'", forceType: db.SuggestionTypeCollocation, family: "collocation", } ) // 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) } // listSettled returns the normalized originals of every edit the user has // already accepted or dismissed on this document — the same spans buildSuppressor // drops on the server, handed to the client so its instant rule-pack pass can // drop them too. // // Without this the offline half of the loop has no memory. The rule pack detects // from the text alone and re-runs 250 ms after a keystroke, so a dismissed "the // the" comes straight back the moment she types anywhere in the document; the // server's reply then removes it again. That flicker is the visible symptom, but // the real one is worse: with the server unreachable — the case the rule pack // exists for — the reply never comes and a card she dismissed simply stays. // // Only the originals are sent. Replacements are the model's words, not hers, and // the client only needs to answer "has she settled this span?" func (h *Handler) listSettled(w http.ResponseWriter, r *http.Request) { out, err := h.fetchSettled(auth.UserID(r.Context()), chi.URLParam(r, "id")) if err != nil { httputil.ServerError(w, err) return } httputil.WriteJSON(w, http.StatusOK, settledResponse{Originals: out}) } // settledResponse wraps the list so the endpoint can grow a second field without // breaking a client that reads a bare array. type settledResponse struct { Originals []string `json:"originals"` } // fetchSettled loads the distinct normalized originals of the document's actioned // rows. Scoped through documents for the same reason fetchPending is: an original // is a quotation of her writing. func (h *Handler) fetchSettled(userID, docID string) ([]string, error) { rows, err := h.DB.Query( `SELECT DISTINCT s.original FROM suggestions s JOIN documents d ON d.id = s.doc_id WHERE s.doc_id = ? AND d.user_id = ? AND s.status IN (?, ?)`, docID, userID, db.SuggestionStatusAccepted, db.SuggestionStatusRejected, ) if err != nil { return nil, err } defer rows.Close() // DISTINCT is on the raw text; normalizing can collapse two rows into one, so // dedupe again on this side to keep the payload honest. seen := map[string]struct{}{} out := []string{} for rows.Next() { var original string if err := rows.Scan(&original); err != nil { return nil, err } norm := normalizeForDedup(original) if norm == "" { continue } if _, dup := seen[norm]; dup { continue } seen[norm] = struct{}{} out = append(out, norm) } if err := rows.Err(); err != nil { return nil, err } return out, nil } // 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.source, 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.Source, &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 offline rule pack and the model: // when a local finding and an LLM suggestion fight over the same characters, the // local one 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 is why the split is by // source rather than by type — an offline miscollocation is as exact as an // offline comma, and the coach's fuzzy version of the same chunk shouldn't // double up next to it. // // 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.Source == db.SuggestionSourceLocal && 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.Source != db.SuggestionSourceLocal && 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 offline 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. // // 'translate' is absent on purpose, and stays absent even though the type now // exists: it is decided from the span (see language.go), never taken from the // model. A model that volunteers the label anyway lands on grammar here and is // then promoted — or not — on the evidence. 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 } }