Suggestions: stop re-nagging resolved edits + fix typographic anchoring

Two fixes for the "accept Petal's change, then it nags about the same
sentence moments later" report:

- replacePending now suppresses any freshly-generated suggestion whose
  original->replacement matches one the user already accepted or
  dismissed for that doc. The model has no memory between passes, so
  without this it re-proposes the identical edit on the next checkpoint.

- findRange anchored suggestions by exact string match, which missed
  whenever the model echoed an `original` with plain ASCII (straight
  quotes, --, ...) while the document held the Typography-converted
  glyphs (curly quotes, em-dash, single-char ellipsis). The miss meant
  no highlight AND a silent no-op on accept, which then fed the re-nag
  above. foldTypography canonicalizes those variants (with a source
  index map for length changes) so matching survives the mismatch.

Covered by a server-side regression test (accept+dismiss then re-check
returns nothing) and frontend unit tests for the fold and anchoring.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 10:35:57 -07:00
parent 3ac6382696
commit 6783ce7a51
4 changed files with 213 additions and 6 deletions

View File

@@ -154,6 +154,14 @@ var (
// 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 the user already accepted or dismissed are suppressed from the
// fresh batch: the model has no memory between passes, so without this it would
// re-propose the identical edit on the very next checkpoint — re-nagging a
// sentence the user already resolved. This matters most when an accept silently
// no-ops (the `original` text couldn't be anchored in the editor, so the doc
// never changed): the sentence is unaltered, yet the user shouldn't see the same
// card again.
func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion, scope pendingScope) error {
tx, err := h.DB.Begin()
if err != nil {
@@ -168,7 +176,15 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
return err
}
actioned, err := actionedKeys(tx, docID)
if err != nil {
return err
}
for _, s := range raw {
if _, seen := actioned[suggestionKey(s.Original, s.Replacement)]; seen {
continue
}
typ := scope.forceType
if typ == "" {
typ = normalizeType(s.Type)
@@ -186,6 +202,39 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
return tx.Commit()
}
// actionedKeys returns the set of original→replacement keys the user has already
// accepted or rejected for this document, so a fresh checkpoint can skip
// re-proposing them. Keyed on the trimmed original+replacement pair, matching the
// normalization ParseCheckpoint applies, so a genuinely different edit on the same
// sentence is not suppressed.
func actionedKeys(tx *sql.Tx, docID string) (map[string]struct{}, error) {
rows, err := tx.Query(
`SELECT original, replacement FROM suggestions
WHERE doc_id = ? AND status IN (?, ?)`,
docID, db.SuggestionStatusAccepted, db.SuggestionStatusRejected,
)
if err != nil {
return nil, err
}
defer rows.Close()
keys := make(map[string]struct{})
for rows.Next() {
var original, replacement string
if err := rows.Scan(&original, &replacement); err != nil {
return nil, err
}
keys[suggestionKey(original, replacement)] = struct{}{}
}
return keys, rows.Err()
}
// suggestionKey is the dedup identity for a suggestion: its trimmed original and
// replacement text. Two suggestions with the same key are "the same edit."
func suggestionKey(original, replacement string) string {
return strings.TrimSpace(original) + "\x00" + strings.TrimSpace(replacement)
}
// 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) {