She reaches for her own language mid-sentence when English won't come, and Petal already handled it: it found the span and rendered it into English. It just filed the result as a Clarity fix, so the pair model's flagship moment read as tidying up her Chinese. The type is now derived from the span rather than asked of the model. A type is structural, and a model that re-reasons every pass would drift between labels for a sentence nobody had touched — the instability the last session spent itself removing. The label the model volunteers is still ignored. Only the grammar checkpoint can be promoted. A pass with a forced type owns its family: voice reads paragraphs for tone and its rows carry no replacement, so a "translation" there would be a card offering nothing to accept. zh is a different script and counting Han runes is close to certain. The Latin pairs share an alphabet with English and get none of that, so they fall back to function words and need two before Petal claims anything — with every word that is also English left out, even the common ones. The heuristic is justified by how cheap being wrong is: it changes a coloured pill, and nothing else. The pill is the one bilingual type name in the rail. Every other type stays English because those are the terms she is learning; this card's whole subject is her own language. And it stops truncating its two lines — elsewhere the diff is a word and the explanation is what she reads, but here the two sentences are the card. Two things only the running page could report. The inline underline was invisible: the decoration carries a per-type class and the base rule is a transparent border, so a type with no colour rule gets no mark at all. And at 1517×810 with the document list open there is no rail — the margin is 258 where railEnabled wants 348 — so what she gets is the inline hover card. Item 7 is written the other way round. Migration 0015 rebuilds the suggestions table for the CHECK, which makes it the first one here that could quietly drop her rows; there is a test that carries every column, both timestamps and both indexes across it. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
772 lines
30 KiB
Go
772 lines
30 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"`
|
||
// 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 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. 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
|
||
}
|
||
|
||
// 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.
|
||
salt := ""
|
||
if scope.chunked {
|
||
salt = tone
|
||
}
|
||
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, 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.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)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|