Stop regenerating the world on every check
A card vanishing and coming back seconds later, with different words, was never about latency: every pass deleted its whole family and re-inserted it, so each round minted new row ids. The rail keys on suggestion.id, so a full remount was guaranteed — new id, new created_at (hence the re-fired chime), and a fresh explanation from a model that re-reasons every time it is asked. One unchanged mistake carried three different explanations in a single sitting. Passes now reconcile instead of replace. A re-proposed edit keeps its row: its id, its created_at, and the wording she has already read. And the grammar checkpoint stops asking about sentences nobody touched — the document is split into hashed sentences, checked_chunks records which ones a family has read, and only the difference is sent. When nothing changed it doesn't call the model at all, and doesn't spend its rate-limit slot on having done nothing. The tone is part of a sentence's identity: cached advice was written for the old register, so switching doc type re-reads every line. replaceMechanics reconciles too, which mattered more than expected — the rule pack fires 250 ms after a keystroke, so it was re-minting every local card's id several times a sentence. Only the grammar checkpoint is chunked. Voice is a property of the whole document, and the collocation coach is a button she pressed asking for a fresh read. No client change was needed; stable ids were the whole of it. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
@@ -160,16 +160,21 @@ func (h *Handler) mechanics(w http.ResponseWriter, r *http.Request) {
|
||||
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.
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
// 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 {
|
||||
@@ -177,18 +182,18 @@ func (h *Handler) replaceMechanics(docID string, findings []mechanicsFinding) er
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM suggestions WHERE doc_id = ? AND status = ? AND source = ?`,
|
||||
docID, db.SuggestionStatusPending, db.SuggestionSourceLocal,
|
||||
); err != nil {
|
||||
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
|
||||
@@ -196,16 +201,34 @@ func (h *Handler) replaceMechanics(docID string, findings []mechanicsFinding) er
|
||||
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,
|
||||
localType(f.Type), db.SuggestionSourceLocal,
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -258,12 +281,65 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
||||
return
|
||||
}
|
||||
|
||||
// Nothing to analyze on an empty document — skip the LLM round-trip.
|
||||
// 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) == "" {
|
||||
httputil.WriteJSON(w, http.StatusOK, []db.Suggestion{})
|
||||
if err := h.reconcilePending(docID, contentText, 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, 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
|
||||
@@ -277,7 +353,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := run(r.Context(), h.Client, contentText, tone, llm.LangFor(pairLang))
|
||||
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
|
||||
@@ -287,7 +363,9 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.replacePending(docID, contentText, raw, scope); err != nil {
|
||||
// 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, raw, scope, chunks, fresh, !scope.chunked); err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -308,78 +386,49 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
||||
// 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
|
||||
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 replaces
|
||||
// its own rows wholesale on each edit (see replaceMechanics) and its findings
|
||||
// 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 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}
|
||||
// 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}
|
||||
collocationScope = pendingScope{
|
||||
deleteWhere: "source = 'llm' AND type = 'collocation'",
|
||||
forceType: db.SuggestionTypeCollocation,
|
||||
family: "collocation",
|
||||
}
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user