Phase 28's step (b): both remaining items are about direction, and both had
a wrong answer that looked right.
isTranslation could not simply be read backwards. readsAsEnglish is a
deliberately low bar — Latin letters, not swamped by another script — which
every Portuguese sentence clears as easily as English does, so swapping its
two halves would have called every genuine Portuguese correction inside a
Portuguese document a translation. The flipped direction uses sentenceLang
from doclang.go instead, where English has its own curated marker list and
has to out-evidence the pair language to win. The English-document path is
untouched; reconcilePending carries the verdict to ask the question the
right way round.
The tap-through's whole observable change is a model call that stops
happening. /suggestions/{id}/translate now recovers the explanation's
language by re-running targetFor rather than assuming the pair, which gives
today's answer everywhere except the case that was broken: the Portuguese
writer whose explanation already arrived in Portuguese, previously
round-tripped through the model into Portuguese again. It answers "" there,
and the client's existing `res.translation.trim() || explanation` fallback
seeds the bubble with the explanation itself — no frontend change at all.
It deliberately does not render that explanation into English on the
grounds that English is technically the other half: an unasked-for
rendering into the language she is practising is noise, not a seed.
Tests pin both directions of the detector, with Portuguese-in-Portuguese as
the case the file exists for, plus four handler tests through the real
/check and /translate paths — including the skipped seed asserting the
model was never called, and the learning_pair zh learner whose English
explanation still renders into Chinese.
Left of the phase: (c) the garden's language tagging and read-aloud.
Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
904 lines
35 KiB
Go
904 lines
35 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)
|
||
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
|
||
}
|
||
|
||
// 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.
|
||
//
|
||
// Decided before the empty-document exit so every reconcile below is told the
|
||
// same verdict. An emptied document has nothing to go on and holds whatever it
|
||
// said last (see documentLang), which is what keeps a Portuguese journal
|
||
// Portuguese while she clears it to start the entry again.
|
||
docLang := documentLang(contentText, pairLang, prevLang)
|
||
|
||
// 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, docLang, 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
|
||
}
|
||
|
||
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, docLang, 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, docLang, 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
|
||
}
|
||
}
|