Skip to content

feat(SPEC-022): post-paste AXObserver + correction candidate persistence - #32

Open
larryxiao wants to merge 1 commit into
mainfrom
feat/spec-022-correction-store
Open

larryxiao wants to merge 1 commit into
mainfrom
feat/spec-022-correction-store

Conversation

@larryxiao

Copy link
Copy Markdown
Owner

SPEC

SPEC-022 PR-A (Custom dictionary auto-learn from user corrections) — §2.2 post-paste observer and §2.3/2.4 candidate persistence only. PR-B (nudge) and PR-C (export) are out of scope.

Milestone

M2

Why

Most users immediately fix small Whisper errors in-place ("cloud code" → "Claude code") and OpenQuack throws that signal away today. PR-A captures it silently: after a successful paste-at-cursor we watch the focused field for the user's edit, diff it against the transcript, and persist the (wrong → right) pair. Once a pair has been seen count >= 3 times, PR-B will offer a one-tap "Add to dictionary" nudge — but nothing user-facing changes in this PR.

Change

Sources/OpenQuackKit/Dictionary/ (new folder):

  • CorrectionCandidate.swift — the SPEC §2.3 struct. wrong, right, count, lastSeen, suppressedUntil?. dedupeKey lowercases both sides so casing variants collapse.
  • CorrectionCandidateStore.swiftactor-backed JSON store at ~/Library/Application Support/OpenQuack/correction_candidates.json. Injectable fileURL for tests. record(_:) dedupes/merges counts, advances lastSeen, caps at 500 entries with eviction by lastSeen ascending.
  • CorrectionDiff.swift — pure extractCorrections(rawTranscript:committedText:). Word-by-word positional alignment (SPEC explicitly OKs simple alignment over Myers). Drops: identical tokens, case-only diffs (avoids "i" → "I" flooding the store), pairs with case-insensitive Levenshtein > 3, and the small built-in stop-word set from SPEC §2.3.
  • PostPasteCorrectionObserver.swift — one paste-event, one observer. Snapshots focused element + registers kAXValueChangedNotification on it and kAXFocusedUIElementChangedNotification system-wide. A DispatchSourceTimer enforces the 60 s deadline. All three paths (focus-change, timer, race) funnel through a single fire(finalFieldValue:) guarded by a didFire latch — observers/timer/source removed in cleanupAfterFire(). The "edited segment" is the LCP-trimmed tail of both the transcript and the field so token alignment stays honest when the user edited past the prefix. Fields without kAXValueAttribute silently no-op (SPEC out-of-scope item).

Sources/OpenQuackApp/OpenQuackApp.swift — single hook at the live-dictation paste seam (line ~651). After a pasted == true from PasteService.paste, we instantiate one PostPasteCorrectionObserver on the main actor and call start(transcript:). No changes to PasteService itself; no callback signatures added.

Tests in Tests/OpenQuackKitTests/:

  • CorrectionCandidateStoreTests — load/save, persistence across instances, case-insensitive dedupe + count merge, distinct right values stay separate, 500-cap eviction by lastSeen ascending, observer test-seam happy path.
  • CorrectionDiffTests — single + multiple substitutions in order, identical / empty strings, case-only filter, case-folded edit distance, edit-distance boundary and over-threshold, stop-word filter on wrong side, timestamp propagation, segment-extraction helper.

Tests

  • unit tests added: CorrectionCandidateStoreTests (8), CorrectionDiffTests (15)
  • DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift build && swift test green locally — 76 tests, 0 failures
  • (manual) end-to-end smoke: dictate "cloud code", let it paste into a TextEdit doc, edit to "Claude code", click elsewhere, check ~/Library/Application Support/OpenQuack/correction_candidates.json

Privacy impact

Privacy contract is preserved.

  • The observer is scoped to one element, one paste event; it self-unregisters on focus-change or 60 s. No continuous keylogging.
  • The store writes locally only; no network calls added.
  • Only (wrong, right, count, lastSeen, suppressedUntil?) quadruples persist — no audio, no full transcripts, no timestamps beyond lastSeen.
  • Fields that don't expose kAXValueAttribute (password fields, some Electron apps) silently no-op.

Out of scope

  • PR-B (≥3 occurrences "Add to dictionary" nudge) — suppressedUntil is persisted by this PR for PR-B's "Not now" path.
  • PR-C (Settings → GitHub issue export).
  • Wiring the observer at the recovery-flow paste call site (OpenQuackApp.swift:844): the recovery path auto-pastes old recordings on launch when the user isn't actively in the field. Not the same user-intent signal as a live dictation edit.
  • AX-event end-to-end test: a real AXObserver event can't be driven in CI, so the observer class exposes testOnlyFire(transcript:finalFieldValue:) for the diff → store wiring and the AX-registration itself is manual-test-only.

🤖 Generated with Claude Code

Captures the silent training signal of in-place edits to pasted transcripts
(SPEC-022 PR-A). After a successful paste-at-cursor, attach a one-shot
AXObserver to the focused field; on focus-change or 60s timeout, diff the
final value against the pasted transcript token-by-token and append surviving
substitutions to ~/Library/Application Support/OpenQuack/correction_candidates.json
(deduped, capped at 500, oldest-lastSeen-evicted).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@larryxiao
larryxiao marked this pull request as ready for review May 13, 2026 16:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant