6 Commits

Author SHA1 Message Date
prosolis
49e84278d5 Merge feat/calm-suggestions: drop no-op suggestions + suppress fickle re-edits
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-27 00:18:47 -07:00
prosolis
9d2501a625 Suppress fickle re-edits of sentences the user already settled
Suppression keyed on the exact original->replacement pair, which the
model routinely sidestepped: it reverses an accepted edit (reverse
pair), re-polishes its own accepted output (new original == accepted
replacement), and the editor's smart-quote churn ("..." -> '...')
defeated even a byte-exact match. Result: a few sentences got nudged
back and forth pass after pass.

Replace the exact-pair actionedKeys with a suppressor that compares
under a normalization folding all quote variants and collapsing
whitespace, and drops a fresh suggestion when it re-touches an
already-settled span: same edit re-proposed, an original the user
already accepted/dismissed, the model re-touching its own accepted
output, or a multi-word sub-clause contained in an accepted span.

Tradeoff: once a sentence is accepted/dismissed it won't be re-flagged
until its text changes — stability over marginal improvement, the right
call for the calm ESL persona.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-27 00:18:23 -07:00
prosolis
08b801e752 Drop no-op suggestions where replacement equals original
The checkpoint model sometimes "flags" a correct sentence and echoes it
verbatim as the replacement, producing a card whose before/after are
identical and whose explanation says it's already fine — a suggestion
that suggests nothing. ParseCheckpoint already dropped empty-original
items; also drop these no-ops. Awareness-only families (voice) carry an
empty replacement, so the guard only fires on the edit families.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-27 00:18:10 -07:00
prosolis
dd1dd7879b Merge feat/spelling-popover: right-click corrections + robust word resolution 2026-06-26 22:23:42 -07:00
prosolis
cb8f132d43 Spelling popover: right-click corrections + robust word resolution
Resolve the misspelled word from the click/right-click position and the
checker rather than from the .petal-misspelling DOM span: clicking a word
moves the caret into it, which fires the decoration rebuild that
deliberately un-underlines the caret word, so the span is already gone by
the time the handler runs. Factored into openMisspellAt(pos), which only
opens when the checker actually flags the word, and is gated to clicks
inside .petal-prose so a click on a floating card doesn't resolve a word
hidden behind it.

Right-click now offers spelling corrections first on a misspelled word
(the familiar "did you mean" gesture), falling back to word lookup
otherwise.

AskPetal: focus the input with preventScroll so opening the card doesn't
jump the document to the top.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 20:54:47 -07:00
prosolis
cea25a3ebc Merge feat/mechanics-deterministic-pass: deterministic mechanics suggestion family
Reuse the companion prose.ts rules as the single deterministic detector;
applyable rules feed 'mechanics' suggestion cards via a persist-only
endpoint, awareness rules stay companion bubbles. Mechanics wins span
collisions with LLM cards. Migration 0008 adds the type.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 20:44:00 -07:00
6 changed files with 246 additions and 59 deletions

View File

@@ -89,6 +89,14 @@ func ParseCheckpoint(raw string) ([]RawSuggestion, error) {
if s.Original == "" { if s.Original == "" {
continue continue
} }
// Drop no-ops: the model sometimes "flags" a correct sentence and echoes
// it verbatim as the replacement, producing a card whose before/after are
// identical and whose explanation says it's already fine — a suggestion
// that suggests nothing. Awareness-only families (voice) carry an empty
// replacement, so this only ever fires on the edit families.
if s.Replacement != "" && s.Original == s.Replacement {
continue
}
out = append(out, s) out = append(out, s)
} }
return out, nil return out, nil

View File

@@ -37,6 +37,25 @@ func TestParseCheckpoint(t *testing.T) {
raw: "I could not find any issues!", raw: "I could not find any issues!",
wantErr: true, wantErr: true,
}, },
{
// A correct sentence echoed verbatim as its own replacement is a card
// that suggests nothing — dropped. The second item is a real edit.
name: "drops no-op where replacement equals original",
raw: `{"suggestions":[{"original":"It wasn't the most graceful morning.","replacement":"It wasn't the most graceful morning.","explanation":"used correctly here","type":"idiom"},{"original":"teh","replacement":"the","explanation":"typo","type":"grammar"}]}`,
want: 1,
},
{
// Whitespace-only difference is still a no-op once both are trimmed.
name: "drops no-op after whitespace trim",
raw: `{"suggestions":[{"original":"the cat sat","replacement":" the cat sat ","explanation":"already fine","type":"grammar"}]}`,
want: 0,
},
{
// Awareness-only voice flags carry an empty replacement and must survive.
name: "keeps awareness-only flag with empty replacement",
raw: `{"suggestions":[{"original":"a long run-on passage","replacement":"","explanation":"this drifts from your voice","type":"voice"}]}`,
want: 1,
},
{ {
name: "braces inside string values", name: "braces inside string values",
raw: `{"suggestions":[{"original":"use {x}","replacement":"use x","explanation":"drop the braces","type":"clarity"}]}`, raw: `{"suggestions":[{"original":"use {x}","replacement":"use x","explanation":"drop the braces","type":"clarity"}]}`,

View File

@@ -155,7 +155,7 @@ func (h *Handler) replaceMechanics(docID string, findings []mechanicsFinding) er
return err return err
} }
actioned, err := actionedKeys(tx, docID) sup, err := buildSuppressor(tx, docID)
if err != nil { if err != nil {
return err return err
} }
@@ -164,7 +164,7 @@ func (h *Handler) replaceMechanics(docID string, findings []mechanicsFinding) er
if f.From < 0 || f.To <= f.From || strings.TrimSpace(f.Original) == "" { if f.From < 0 || f.To <= f.From || strings.TrimSpace(f.Original) == "" {
continue // malformed span — the client re-anchors by string anyway continue // malformed span — the client re-anchors by string anyway
} }
if _, seen := actioned[suggestionKey(f.Original, f.Replacement)]; seen { if sup.suppressed(f.Original, f.Replacement) {
continue continue
} }
if _, err := tx.Exec( if _, err := tx.Exec(
@@ -289,13 +289,12 @@ var (
// fresh batch in a single transaction. Accepted/rejected suggestions and the // fresh batch in a single transaction. Accepted/rejected suggestions and the
// other family's pending rows are left untouched. // other family's pending rows are left untouched.
// //
// Suggestions the user already accepted or dismissed are suppressed from the // Suggestions touching a sentence the user already settled are suppressed from
// fresh batch: the model has no memory between passes, so without this it would // the fresh batch (see suppressor): not just the identical edit re-proposed, but
// re-propose the identical edit on the very next checkpoint — re-nagging a // reversals and re-polishing of the model's own just-accepted output — the
// sentence the user already resolved. This matters most when an accept silently // "fickle, keeps going back and forth on a few sentences" behavior. The model has
// no-ops (the `original` text couldn't be anchored in the editor, so the doc // no memory between passes, so without this it re-opens resolved sentences every
// never changed): the sentence is unaltered, yet the user shouldn't see the same // checkpoint.
// card again.
func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion, scope pendingScope) error { func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion, scope pendingScope) error {
tx, err := h.DB.Begin() tx, err := h.DB.Begin()
if err != nil { if err != nil {
@@ -310,13 +309,13 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
return err return err
} }
actioned, err := actionedKeys(tx, docID) sup, err := buildSuppressor(tx, docID)
if err != nil { if err != nil {
return err return err
} }
for _, s := range raw { for _, s := range raw {
if _, seen := actioned[suggestionKey(s.Original, s.Replacement)]; seen { if sup.suppressed(s.Original, s.Replacement) {
continue continue
} }
typ := scope.forceType typ := scope.forceType
@@ -336,37 +335,131 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
return tx.Commit() return tx.Commit()
} }
// actionedKeys returns the set of original→replacement keys the user has already // dedupQuoteReplacer folds every straight/curly single- and double-quote variant
// accepted or rejected for this document, so a fresh checkpoint can skip // (and backtick/acute accent) onto one canonical character. The editor and the
// re-proposing them. Keyed on the trimmed original+replacement pair, matching the // model both rewrite quotes between passes — a sentence accepted with "…" comes
// normalization ParseCheckpoint applies, so a genuinely different edit on the same // back flagged with '…' — so without folding, byte-identical text reads as a
// sentence is not suppressed. // different edit and the suppression below misses it. (This normalization is for
func actionedKeys(tx *sql.Tx, docID string) (map[string]struct{}, error) { // 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( rows, err := tx.Query(
`SELECT original, replacement FROM suggestions `SELECT original, replacement, status FROM suggestions
WHERE doc_id = ? AND status IN (?, ?)`, WHERE doc_id = ? AND status IN (?, ?)`,
docID, db.SuggestionStatusAccepted, db.SuggestionStatusRejected, docID, db.SuggestionStatusAccepted, db.SuggestionStatusRejected,
) )
if err != nil { if err != nil {
return nil, err return suppressor{}, err
} }
defer rows.Close() defer rows.Close()
keys := make(map[string]struct{}) s := suppressor{
for rows.Next() { pairs: make(map[string]struct{}),
var original, replacement string actionedOrig: make(map[string]struct{}),
if err := rows.Scan(&original, &replacement); err != nil { acceptedRepl: make(map[string]struct{}),
return nil, err
}
keys[suggestionKey(original, replacement)] = struct{}{}
} }
return keys, rows.Err() for rows.Next() {
} var original, replacement, status string
if err := rows.Scan(&original, &replacement, &status); err != nil {
// suggestionKey is the dedup identity for a suggestion: its trimmed original and return suppressor{}, err
// replacement text. Two suggestions with the same key are "the same edit." }
func suggestionKey(original, replacement string) string { o := normalizeForDedup(original)
return strings.TrimSpace(original) + "\x00" + strings.TrimSpace(replacement) 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 // listForDoc returns the document's current pending suggestions (used when the

View File

@@ -165,6 +165,48 @@ func TestResolvedSuggestionsNotReproposed(t *testing.T) {
} }
} }
// TestFickleEditsSuppressed proves the suppressor kills the "keeps going back and
// forth on a few sentences" behavior seen live on the Missing Key doc: a later
// pass that (a) reverses an edit the user just accepted — even with the editor's
// double→single quote churn that defeats a byte-exact match — or (b) re-polishes
// the model's own accepted output. A genuinely new edit on a fresh sentence still
// survives.
func TestFickleEditsSuppressed(t *testing.T) {
client := &stubClient{response: `{"suggestions":[
{"original":"He left \"early,\" because of the rain.","replacement":"He left \"early,\" due to the rain.","explanation":"smoother","type":"phrasing"},
{"original":"The cat always have a calm face.","replacement":"The cat always has a calm face.","explanation":"agreement","type":"grammar"}
]}`}
srv, docID, h := newTestServer(t, client)
h.Limit = llm.NewRateLimiter(0)
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
var got []db.Suggestion
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if len(got) != 2 {
t.Fatalf("first pass: want 2, got %d", len(got))
}
for _, s := range got {
do(t, srv, http.MethodPost, "/suggestions/"+s.ID+"/accept", "")
}
// Reversal of the first accept (note the " → ' quote churn) and a re-polish of
// the second accept must both be dropped; only the unrelated edit survives.
client.response = `{"suggestions":[
{"original":"He left 'early,' due to the rain.","replacement":"He left 'early,' because of the rain.","explanation":"reverts the accepted edit","type":"phrasing"},
{"original":"The cat always has a calm face.","replacement":"The cat always seems to have a calm face.","explanation":"re-polishes accepted output","type":"phrasing"},
{"original":"due to the rain","replacement":"because of the rain","explanation":"sub-clause reversal of the accepted span","type":"phrasing"},
{"original":"She drived home.","replacement":"She drove home.","explanation":"past tense","type":"grammar"}
]}`
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
var again []db.Suggestion
if err := json.Unmarshal(rec.Body.Bytes(), &again); err != nil {
t.Fatalf("decode: %v", err)
}
if len(again) != 1 || again[0].Original != "She drived home." {
t.Fatalf("want only the new edit to survive, got %d: %+v", len(again), again)
}
}
// TestVoicePassCoexists proves the voice pass and the grammar checkpoint own // TestVoicePassCoexists proves the voice pass and the grammar checkpoint own
// disjoint pending families: running one never wipes the other's flags, and // disjoint pending families: running one never wipes the other's flags, and
// each endpoint returns the unified pending set. // each endpoint returns the unified pending set.

View File

@@ -34,9 +34,12 @@ export function AskPetal({ suggestionId, explanation }: Props) {
if (el) el.scrollTop = el.scrollHeight if (el) el.scrollTop = el.scrollHeight
}, [messages]) }, [messages])
// Focus the input when the panel opens. // Focus the input when the panel opens. preventScroll: the card is already on
// screen as an absolutely-positioned overlay, and a default focus() would make
// the browser scroll its ancestor to "reveal" the input — jumping the document
// to the top.
useEffect(() => { useEffect(() => {
inputRef.current?.focus() inputRef.current?.focus({ preventScroll: true })
}, []) }, [])
// Fetch the Chinese translation of the explanation to seed the first bubble. // Fetch the Chinese translation of the explanation to seed the first bubble.
@@ -95,7 +98,7 @@ export function AskPetal({ suggestionId, explanation }: Props) {
console.error('Ask Petal chat failed:', err) console.error('Ask Petal chat failed:', err)
} finally { } finally {
setStreaming(false) setStreaming(false)
inputRef.current?.focus() inputRef.current?.focus({ preventScroll: true })
} }
} }

View File

@@ -580,9 +580,37 @@ export function EditorCore({
[onDismiss, closeCard], [onDismiss, closeCard],
) )
// Click a red-underlined word to open its spelling popover, anchored under the // openMisspellAt resolves the word at a document position and, if the checker
// word. posAtCoords→wordAt resolves the exact PM span (robust to the same // flags it as misspelled, opens the spelling popover anchored under the word.
// misspelling appearing elsewhere); nspell supplies the corrections. // Returns true if it opened a card. We resolve the word from coordinates and
// the checker rather than from the `.petal-misspelling` DOM span: clicking a
// word moves the caret into it, which fires the decoration rebuild that
// deliberately un-underlines the caret word (see SpellCheck's caret exemption),
// so by the time a click/contextmenu handler runs the span is already gone.
const openMisspellAt = useCallback(
(pos: number): boolean => {
if (!editor || !spellChecker) return false
const range = wordAt(editor.state.doc, pos)
if (!range || spellChecker.correct(range.word)) return false
const wrapper = wrapperRef.current
if (!wrapper) return false
const start = editor.view.coordsAtPos(range.from)
const end = editor.view.coordsAtPos(range.to)
const wrapRect = wrapper.getBoundingClientRect()
const cardWidth = 240
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - cardWidth))
const top = end.bottom - wrapRect.top + 6
// Opening a spelling popover supersedes any AI-suggestion or lookup card.
closeCard()
setWordInfo(null)
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
return true
},
[editor, spellChecker, closeCard],
)
// Click a misspelled word to open its spelling popover, anchored under the
// word; nspell supplies the corrections.
// //
// Tapping an AI-suggestion highlight also opens its card here — on touch there's // Tapping an AI-suggestion highlight also opens its card here — on touch there's
// no hover, so the tap is the only way in (mouse users still get hover). // no hover, so the tap is the only way in (mouse users still get hover).
@@ -603,26 +631,16 @@ export function EditorCore({
} }
return return
} }
if (!editor || !spellChecker) return if (!editor) return
const target = (e.target as HTMLElement).closest('.petal-misspelling') as HTMLElement | null // Only act on clicks in the editor text itself — the floating cards/popovers
if (!target) return // are children of this same wrapper, and a click on one shouldn't resolve a
const wrapper = wrapperRef.current // (hidden) word behind it.
if (!wrapper) return if (!(e.target as HTMLElement).closest('.petal-prose')) return
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY }) const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
if (!coords) return if (!coords) return
const range = wordAt(editor.state.doc, coords.pos) openMisspellAt(coords.pos)
if (!range) return
const elRect = target.getBoundingClientRect()
const wrapRect = wrapper.getBoundingClientRect()
const cardWidth = 240
const left = Math.max(0, Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth))
const top = elRect.bottom - wrapRect.top + 6
// Opening a spelling popover supersedes any AI-suggestion hover card.
closeCard()
setWordInfo(null)
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
}, },
[editor, spellChecker, closeCard, openCardFor, railEnabled], [editor, openMisspellAt, openCardFor, railEnabled],
) )
const replaceMisspelling = useCallback( const replaceMisspelling = useCallback(
@@ -757,8 +775,10 @@ export function EditorCore({
.catch((err) => console.error('vocab save failed', err)) .catch((err) => console.error('vocab save failed', err))
}, [wordInfo, exampleAt, docId]) }, [wordInfo, exampleAt, docId])
// Right-click a word to look it up. Right-clicking off any word falls through // Right-click a misspelled word for spelling corrections (the familiar "did you
// to the native menu (so copy/paste-by-menu still works — see the Selection fix). // mean" gesture); right-click any other word to look it up. Right-clicking off
// any word falls through to the native menu (so copy/paste-by-menu still works
// — see the Selection fix).
const handleContextMenu = useCallback( const handleContextMenu = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (!editor) return if (!editor) return
@@ -766,9 +786,11 @@ export function EditorCore({
if (!coords) return if (!coords) return
if (!wordAt(editor.state.doc, coords.pos)) return if (!wordAt(editor.state.doc, coords.pos)) return
e.preventDefault() e.preventDefault()
// A misspelled word offers corrections first; otherwise look it up.
if (openMisspellAt(coords.pos)) return
openWordLookup(coords.pos) openWordLookup(coords.pos)
}, },
[editor, openWordLookup], [editor, openMisspellAt, openWordLookup],
) )
// Touch has no hover or right-click, so a long-press (~500ms without moving) // Touch has no hover or right-click, so a long-press (~500ms without moving)