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() }