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:
@@ -0,0 +1,293 @@
|
||||
package suggestions
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||
)
|
||||
|
||||
// Reconciliation replaces the old "delete the family, insert the new batch"
|
||||
// shape of every pass. A suggestion the pass proposes again is the *same*
|
||||
// suggestion: it keeps its row, and therefore its id, its created_at and — most
|
||||
// visibly — the explanation it was first given. The model re-words its reasoning
|
||||
// every time it is asked, so re-inserting meant one unchanged mistake carried
|
||||
// three different explanations in a single sitting.
|
||||
//
|
||||
// The id is what the frontend keys its cards on, so a stable id is also what
|
||||
// keeps the rail from emptying and refilling, a card from collapsing mid-read,
|
||||
// and the arrival chime from re-firing for advice she has already seen.
|
||||
|
||||
// pendingRow is the part of an existing pending suggestion reconciliation cares
|
||||
// about.
|
||||
type pendingRow struct {
|
||||
id string
|
||||
original string
|
||||
replacement string
|
||||
chunkHash string
|
||||
from int
|
||||
}
|
||||
|
||||
// loadPending reads the pending rows a pass owns. `where` is the pass's own
|
||||
// scoping clause (by source, and for the model passes by family) — the same
|
||||
// fragment that used to scope its DELETE.
|
||||
func loadPending(tx *sql.Tx, docID, where string) ([]pendingRow, error) {
|
||||
rows, err := tx.Query(
|
||||
`SELECT id, original, replacement, chunk_hash, from_pos FROM suggestions
|
||||
WHERE doc_id = ? AND status = ? AND `+where,
|
||||
docID, db.SuggestionStatusPending,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []pendingRow
|
||||
for rows.Next() {
|
||||
var r pendingRow
|
||||
if err := rows.Scan(&r.id, &r.original, &r.replacement, &r.chunkHash, &r.from); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// editKey identifies an edit by what it proposes, not where: "this exact change
|
||||
// to this exact text". Normalized like the suppression comparisons, so the
|
||||
// editor's quote rewriting and a reflowed paragraph don't read as a new edit.
|
||||
func editKey(original, replacement string) string {
|
||||
return normalizeForDedup(original) + "\x00" + normalizeForDedup(replacement)
|
||||
}
|
||||
|
||||
// editIndex matches freshly proposed edits against the rows already standing.
|
||||
type editIndex struct {
|
||||
rows []pendingRow
|
||||
used []bool
|
||||
byKey map[string][]int
|
||||
}
|
||||
|
||||
func indexByEdit(rows []pendingRow) *editIndex {
|
||||
idx := &editIndex{rows: rows, used: make([]bool, len(rows)), byKey: map[string][]int{}}
|
||||
for i, r := range rows {
|
||||
k := editKey(r.original, r.replacement)
|
||||
idx.byKey[k] = append(idx.byKey[k], i)
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// take claims the standing row for this edit, if there is one. When a document
|
||||
// repeats the same mistake, `near` (the fresh span's start) picks the closest
|
||||
// standing row, so two identical cards keep their own identities instead of
|
||||
// trading them whenever the text between them grows.
|
||||
func (i *editIndex) take(original, replacement string, near int) (pendingRow, bool) {
|
||||
best, bestDist := -1, 0
|
||||
for _, n := range i.byKey[editKey(original, replacement)] {
|
||||
if i.used[n] {
|
||||
continue
|
||||
}
|
||||
d := i.rows[n].from - near
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
if best < 0 || d < bestDist {
|
||||
best, bestDist = n, d
|
||||
}
|
||||
}
|
||||
if best < 0 {
|
||||
return pendingRow{}, false
|
||||
}
|
||||
i.used[best] = true
|
||||
return i.rows[best], true
|
||||
}
|
||||
|
||||
// reposition updates the advisory offsets (and the sentence a row belongs to)
|
||||
// without touching anything the writer can see. The frontend re-anchors by
|
||||
// string at render time, so these only matter for the local-vs-model span
|
||||
// arbitration in dedupeSpans.
|
||||
func reposition(tx *sql.Tx, row pendingRow, from, to int, chunkHash string) error {
|
||||
if row.from == from && row.chunkHash == chunkHash {
|
||||
return nil
|
||||
}
|
||||
_, err := tx.Exec(
|
||||
`UPDATE suggestions SET from_pos = ?, to_pos = ?, chunk_hash = ? WHERE id = ?`,
|
||||
from, to, chunkHash, row.id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// reconcilePending brings a model pass's family in line with what it just
|
||||
// proposed, sentence by sentence:
|
||||
//
|
||||
// - A row on a sentence this pass didn't ask about is kept untouched — that
|
||||
// is the whole point of chunking. Only its offsets are refreshed.
|
||||
// - A row on a sentence that no longer exists in the document is dropped: she
|
||||
// rewrote or deleted it.
|
||||
// - A row on a sentence the pass *did* ask about survives only if the model
|
||||
// proposed the same edit again, in which case it keeps its identity.
|
||||
//
|
||||
// `fresh` names the sentences the model was asked about (nil when it wasn't
|
||||
// called at all). inPlayAll marks the whole-document passes — voice and the
|
||||
// collocation coach — where every row is up for re-proposal because the model
|
||||
// just re-read everything.
|
||||
func (h *Handler) reconcilePending(
|
||||
docID, contentText string,
|
||||
raw []llm.RawSuggestion,
|
||||
scope pendingScope,
|
||||
chunks, fresh []chunk,
|
||||
inPlayAll bool,
|
||||
) error {
|
||||
tx, err := h.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
existing, err := loadPending(tx, docID, scope.deleteWhere)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
present := hashSet(chunks)
|
||||
asked := hashSet(fresh)
|
||||
modelRan := inPlayAll || fresh != nil
|
||||
|
||||
// Sentences to hand back to the model next time, because a row we were
|
||||
// caching on them turned out to be unanchorable (see below).
|
||||
reopen := map[string]bool{}
|
||||
|
||||
var inPlay []pendingRow
|
||||
for _, r := range existing {
|
||||
switch {
|
||||
// A row whose sentence we can't name is never cached — it is re-examined
|
||||
// whenever the model speaks, and left alone when it doesn't.
|
||||
case inPlayAll, r.chunkHash == "" && modelRan, asked[r.chunkHash]:
|
||||
inPlay = append(inPlay, r)
|
||||
case r.chunkHash != "" && !present[r.chunkHash]:
|
||||
if _, err := tx.Exec(`DELETE FROM suggestions WHERE id = ?`, r.id); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
// Untouched sentence: keep the card exactly as she last saw it.
|
||||
from, to := locate(contentText, r.original)
|
||||
if from < 0 {
|
||||
// The sentence is unchanged in substance but the quoted span no
|
||||
// longer matches byte for byte — a quote mark the editor rewrote
|
||||
// inside it, say. The frontend anchors by that string, so this card
|
||||
// can't be shown; drop it and let the sentence be read again rather
|
||||
// than cache advice nobody can see.
|
||||
reopen[r.chunkHash] = true
|
||||
if _, err := tx.Exec(`DELETE FROM suggestions WHERE id = ?`, r.id); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := reposition(tx, r, from, to, r.chunkHash); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for h := range reopen {
|
||||
delete(present, h)
|
||||
}
|
||||
|
||||
sup, err := buildSuppressor(tx, docID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
index := indexByEdit(inPlay)
|
||||
kept := make(map[string]bool, len(inPlay))
|
||||
for _, s := range raw {
|
||||
if sup.suppressed(s.Original, s.Replacement) {
|
||||
continue
|
||||
}
|
||||
from, to := locate(contentText, s.Original)
|
||||
// Attribute the finding to a sentence the model was actually shown before
|
||||
// falling back to the whole document: a short span ("the the") can occur in
|
||||
// two sentences, and crediting it to the cached one would drop it as advice
|
||||
// we already have.
|
||||
hash := chunkFor(s.Original, fresh)
|
||||
if hash == "" {
|
||||
hash = chunkFor(s.Original, chunks)
|
||||
}
|
||||
// A sentence we didn't ask about already has whatever advice it deserves.
|
||||
// The model can't normally quote one — it was only shown the delta — but if
|
||||
// it wanders there anyway, the cached card stands rather than gaining a
|
||||
// twin.
|
||||
if !inPlayAll && hash != "" && present[hash] && !asked[hash] {
|
||||
continue
|
||||
}
|
||||
if row, ok := index.take(s.Original, s.Replacement, from); ok {
|
||||
kept[row.id] = true
|
||||
if err := reposition(tx, row, from, to, hash); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
typ := scope.forceType
|
||||
if typ == "" {
|
||||
typ = normalizeType(s.Type)
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type, source, chunk_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
docID, from, to, s.Original, s.Replacement, s.Explanation, typ, db.SuggestionSourceLLM, hash,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Asked about and not proposed again: the model has changed its mind, or she
|
||||
// has fixed it.
|
||||
for _, r := range inPlay {
|
||||
if kept[r.id] {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM suggestions WHERE id = ?`, r.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Record the sentences this family has now read. Every sentence still in the
|
||||
// document has been read by *some* pass: the ones just asked about now, the
|
||||
// rest in an earlier round.
|
||||
if scope.chunked {
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM checked_chunks WHERE doc_id = ? AND family = ?`, docID, scope.family,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
for h := range present {
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO checked_chunks (doc_id, family, hash) VALUES (?, ?, ?)`,
|
||||
docID, scope.family, h,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// checkedChunks loads the sentences a family read on its last pass.
|
||||
func (h *Handler) checkedChunks(docID, family string) (map[string]bool, error) {
|
||||
rows, err := h.DB.Query(
|
||||
`SELECT hash FROM checked_chunks WHERE doc_id = ? AND family = ?`, docID, family,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var hash string
|
||||
if err := rows.Scan(&hash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[hash] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user