// 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 swaps the document's pending offline rows for the supplied // findings in one transaction, leaving the LLM families and actioned rows // untouched. Findings the user already accepted or dismissed are suppressed (the // detector has no memory between runs), and malformed spans are skipped. // // The DELETE is scoped by *source*, not by 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, so everything it wrote last time goes. 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() if _, err := tx.Exec( `DELETE FROM suggestions WHERE doc_id = ? AND status = ? AND source = ?`, docID, db.SuggestionStatusPending, db.SuggestionSourceLocal, ); err != nil { return err } sup, err := buildSuppressor(tx, docID) if err != nil { return err } for _, f := range findings { if f.From < 0 || f.To <= f.From || strings.TrimSpace(f.Original) == "" { continue // malformed span — the client re-anchors by string anyway } if sup.suppressed(f.Original, f.Replacement) { continue } if _, err := tx.Exec( `INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, docID, f.From, f.To, f.Original, f.Replacement, f.Explanation, localType(f.Type), db.SuggestionSourceLocal, ); err != nil { return err } } return tx.Commit() } // voice runs a Tier-1 voice-consistency pass over the whole document. Slow, // explicit-action pass; replaces only the pending voice flags. func (h *Handler) voice(w http.ResponseWriter, r *http.Request) { h.runPass(w, r, h.VoiceLimit, llm.RunVoice, voiceScope) } // collocation runs the collocation coach over the whole document, flagging // non-native word pairings. Explicit-action pass; replaces only the pending // collocation flags. func (h *Handler) collocation(w http.ResponseWriter, r *http.Request) { h.runPass(w, r, h.CollocationLimit, llm.RunCollocation, collocationScope) } // pass is the signature shared by the grammar checkpoint and the voice pass: // given the document text, the document's tone and the writer's pair language it // returns the model's raw suggestions. The voice pass ignores both extras (see // llm.RunVoice) and the checkpoint ignores the language — only the collocation // coach writes a word of it — but one signature keeps runPass free of special // cases. type pass func(ctx context.Context, client llm.LLMClient, contentText, tone string, lang llm.Lang) ([]llm.RawSuggestion, error) // runPass is the shared body for both LLM passes. It loads the document text, // enforces the pass's per-document rate limit, runs the model, swaps in the // fresh batch scoped to this family, and returns the document's FULL pending set // (both families) so the client always renders a unified picture. func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.RateLimiter, run pass, scope pendingScope) { docID := chi.URLParam(r, "id") userID := auth.UserID(r.Context()) // The writer's pair language rides along with the document rather than in a // second query: it is read from the same row-scoped lookup that already // proves she owns this document. var contentText, tone, pairLang string err := h.DB.QueryRow( `SELECT d.content_text, d.tone, COALESCE(u.pair_lang, '') FROM documents d JOIN users u ON u.id = d.user_id WHERE d.id = ? AND d.user_id = ?`, docID, userID, ).Scan(&contentText, &tone, &pairLang) if errors.Is(err, sql.ErrNoRows) { httputil.ErrorJSON(w, http.StatusNotFound, "document not found") return } if err != nil { httputil.ServerError(w, err) return } // Nothing to analyze on an empty document — skip the LLM round-trip. if strings.TrimSpace(contentText) == "" { httputil.WriteJSON(w, http.StatusOK, []db.Suggestion{}) return } ok, _, slotAt := limiter.Allow(docID) if !ok { // Throttled: return the existing pending set unchanged rather than an // error, so the frontend keeps showing current suggestions. existing, err := h.fetchPending(userID, docID) if err != nil { httputil.ServerError(w, err) return } httputil.WriteJSON(w, http.StatusOK, existing) return } raw, err := run(r.Context(), h.Client, contentText, tone, llm.LangFor(pairLang)) if err != nil { // Allow ran before the model call, so a failed pass would otherwise hold // the per-document slot for the full interval — stranding the frontend's // auto-retry on the throttle path. Release it so a retry can re-run. limiter.Release(docID, slotAt) httputil.UpstreamError(w, "pass", err) return } if err := h.replacePending(docID, contentText, raw, scope); err != nil { httputil.ServerError(w, err) return } // Return the unified pending set (grammar + voice), not just this batch, so // a grammar check never drops the voice highlights from the client and the // throttle path above stays consistent with the success path. out, err := h.fetchPending(userID, docID) if err != nil { httputil.ServerError(w, err) return } httputil.WriteJSON(w, http.StatusOK, out) } // pendingScope describes how one LLM pass touches the shared suggestions table: // which family of pending rows it replaces, and the type to stamp on the rows it // inserts. The grammar checkpoint and voice pass each own a disjoint family, so // running one never disturbs the other's pending flags. type pendingScope struct { deleteWhere string // extra WHERE clause scoping the DELETE to this family forceType string // if set, every inserted row gets this type; else normalizeType } // Every scope below is confined to source='llm'. The offline rule pack replaces // its own rows wholesale on each edit (see replaceMechanics) and 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 DELETE runs, so // the source clause is also what keeps them alive. grammarScope = pendingScope{deleteWhere: "source = 'llm' AND type NOT IN ('voice','collocation')", forceType: ""} // voiceScope owns the model's voice flags only. voiceScope = pendingScope{deleteWhere: "source = 'llm' AND type = 'voice'", forceType: db.SuggestionTypeVoice} // 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} ) // replacePending swaps a document's pending suggestions within one family for a // fresh batch in a single transaction. Accepted/rejected suggestions and the // other family's pending rows are left untouched. // // Suggestions touching a sentence the user already settled are suppressed from // the fresh batch (see suppressor): not just the identical edit re-proposed, but // reversals and re-polishing of the model's own just-accepted output — the // "fickle, keeps going back and forth on a few sentences" behavior. The model has // no memory between passes, so without this it re-opens resolved sentences every // checkpoint. func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion, scope pendingScope) error { tx, err := h.DB.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec( `DELETE FROM suggestions WHERE doc_id = ? AND status = ? AND `+scope.deleteWhere, docID, db.SuggestionStatusPending, ); err != nil { return err } sup, err := buildSuppressor(tx, docID) if err != nil { return err } for _, s := range raw { if sup.suppressed(s.Original, s.Replacement) { continue } typ := scope.forceType if typ == "" { typ = normalizeType(s.Type) } from, to := locate(contentText, s.Original) if _, err := tx.Exec( `INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, docID, from, to, s.Original, s.Replacement, s.Explanation, typ, db.SuggestionSourceLLM, ); err != nil { return err } } return tx.Commit() } // dedupQuoteReplacer folds every straight/curly single- and double-quote variant // (and backtick/acute accent) onto one canonical character. The editor and the // model both rewrite quotes between passes — a sentence accepted with "…" comes // back flagged with '…' — so without folding, byte-identical text reads as a // different edit and the suppression below misses it. (This normalization is for // dedup ONLY; the frontend still anchors on the verbatim `original`.) var dedupQuoteReplacer = strings.NewReplacer( "‘", "'", "’", "'", "‚", "'", "‛", "'", // single curly "“", "'", "”", "'", "„", "'", "″", "'", // double curly "\"", "'", "`", "'", "´", "'", // straight double, backtick, acute ) // normalizeForDedup canonicalizes a string for suppression comparisons: quotes // folded (above) and runs of whitespace collapsed to single spaces (so a reflowed // paragraph still matches). Used only to decide what to suppress, never to alter // stored or rendered text. func normalizeForDedup(s string) string { return strings.Join(strings.Fields(dedupQuoteReplacer.Replace(s)), " ") } // suppressor decides which fresh suggestions to drop because the user has already // settled the sentence they touch. The model has no memory between passes, so on // every checkpoint it re-examines the current text and proposes edits — including // ones that re-open a sentence the user already resolved. Three families of those // are suppressed (all compared under normalizeForDedup): // // - pairs: the identical edit, re-proposed verbatim (the original "accept it, // then it nags again" case; also covers a silent no-op accept that left the // text unchanged). // - actionedOrig: any edit whose original is a span the user already accepted or // dismissed an edit on — "you already decided about this exact sentence." // - acceptedRepl: any edit whose original is text the user accepted AS a // replacement — i.e. the model re-touching its own just-accepted output, which // is how the reversals and endless re-polishing arise (accept "due to the // rain", next pass proposes changing "due to the rain" back). Guarded to // multi-word spans so word-level fixes aren't swept up as collateral. // - acceptedReplList holds the same accepted replacements for a containment // check: the model evades the exact acceptedRepl match by re-flagging a // *sub-clause* of an accepted sentence (flag "she was…due to the rain" instead // of the whole sentence). When one of the new original / an accepted // replacement contains the other and the shorter side is substantial // (>= minContainWords words), it's the same settled span and is dropped. type suppressor struct { pairs map[string]struct{} actionedOrig map[string]struct{} acceptedRepl map[string]struct{} acceptedReplList []string } // minContainWords is the floor for the containment check: the shorter of the two // spans must be at least this many words before a substring relationship counts // as "the same settled text." High enough that an incidental common phrase ("the // rain") can't suppress an unrelated sentence, low enough to catch a re-flagged // clause. const minContainWords = 4 // suppressed reports whether a fresh suggestion should be dropped as already // settled. An empty original is never suppressed here (it can't anchor anyway and // is dropped upstream). func (s suppressor) suppressed(original, replacement string) bool { o := normalizeForDedup(original) if o == "" { return false } if _, ok := s.pairs[o+"\x00"+normalizeForDedup(replacement)]; ok { return true } if _, ok := s.actionedOrig[o]; ok { return true } if strings.ContainsRune(o, ' ') { if _, ok := s.acceptedRepl[o]; ok { return true } } // Containment: the model re-flagged a sub-clause of (or a window around) an // accepted span. Suppress when one contains the other and the shorter side is // a substantial multi-word run. for _, r := range s.acceptedReplList { shorter, longer := o, r if len(r) < len(o) { shorter, longer = r, o } if len(strings.Fields(shorter)) >= minContainWords && strings.Contains(longer, shorter) { return true } } return false } // buildSuppressor loads the document's accepted/rejected edits and indexes them // into the three suppression families described on suppressor. func buildSuppressor(tx *sql.Tx, docID string) (suppressor, error) { rows, err := tx.Query( `SELECT original, replacement, status FROM suggestions WHERE doc_id = ? AND status IN (?, ?)`, docID, db.SuggestionStatusAccepted, db.SuggestionStatusRejected, ) if err != nil { return suppressor{}, err } defer rows.Close() s := suppressor{ pairs: make(map[string]struct{}), actionedOrig: make(map[string]struct{}), acceptedRepl: make(map[string]struct{}), } for rows.Next() { var original, replacement, status string if err := rows.Scan(&original, &replacement, &status); err != nil { return suppressor{}, err } o := normalizeForDedup(original) r := normalizeForDedup(replacement) s.pairs[o+"\x00"+r] = struct{}{} if o != "" { s.actionedOrig[o] = struct{}{} } if status == db.SuggestionStatusAccepted && r != "" { s.acceptedRepl[r] = struct{}{} s.acceptedReplList = append(s.acceptedReplList, r) } } return s, rows.Err() } // listForDoc returns the document's current pending suggestions (used when the // editor loads a document, before any new checkpoint fires). func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) { out, err := h.fetchPending(auth.UserID(r.Context()), chi.URLParam(r, "id")) if err != nil { httputil.ServerError(w, err) return } httputil.WriteJSON(w, http.StatusOK, out) } // fetchPending loads a document's pending suggestions, joined through documents // so the rows are only reachable by the document's owner. A suggestion quotes the // sentence it corrects, so an unscoped read here would leak document text to // anyone holding a doc id. func (h *Handler) fetchPending(userID, docID string) ([]db.Suggestion, error) { rows, err := h.DB.Query( `SELECT s.id, s.doc_id, s.from_pos, s.to_pos, s.original, s.replacement, s.explanation, s.type, s.status, s.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. 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 } }