From 4a40f270fc84837897130db87b6384b3ff803fb2 Mon Sep 17 00:00:00 2001 From: Russell Dunphy Date: Sun, 29 Mar 2026 19:47:45 +0100 Subject: [PATCH] feat: transcription dictionary learned from log edits When a user corrects a word in the Transcription Log, the edited word is extracted and saved to a local WordDictionaryStore. Those words are injected into WhisperKit's prompt tokens and the LLM cleanup prompt on every subsequent transcription, biasing the model toward the user's preferred spellings. Users can view, add, edit and delete dictionary words in a new "Transcription Dictionary" section in Preferences. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 4 +- Wisp/App/AppDelegate.swift | 17 +- Wisp/Models/TranscriptionLogEntry.swift | 2 +- Wisp/Models/TranscriptionLogStore.swift | 7 + Wisp/Models/WordDictionaryStore.swift | 86 ++++++++ Wisp/Services/TextCleanupService.swift | 8 +- Wisp/Services/TranscriptionService.swift | 12 +- Wisp/UI/LogView.swift | 93 ++++++-- Wisp/UI/LogWindow.swift | 6 +- Wisp/UI/PreferencesView.swift | 8 + Wisp/UI/PreferencesWindow.swift | 21 +- Wisp/UI/WordDictionaryView.swift | 125 +++++++++++ WispTests/Unit/LogEntryEditTests.swift | 117 ++++++++++ WispTests/Unit/WordDictionaryStoreTests.swift | 152 +++++++++++++ WispTests/Unit/WordExtractionTests.swift | 85 ++++++++ .../checklists/requirements.md | 34 +++ .../007-custom-word-dictionary/data-model.md | 117 ++++++++++ specs/007-custom-word-dictionary/plan.md | 77 +++++++ .../007-custom-word-dictionary/quickstart.md | 49 +++++ specs/007-custom-word-dictionary/research.md | 89 ++++++++ specs/007-custom-word-dictionary/spec.md | 106 +++++++++ specs/007-custom-word-dictionary/tasks.md | 205 ++++++++++++++++++ 22 files changed, 1385 insertions(+), 35 deletions(-) create mode 100644 Wisp/Models/WordDictionaryStore.swift create mode 100644 Wisp/UI/WordDictionaryView.swift create mode 100644 WispTests/Unit/LogEntryEditTests.swift create mode 100644 WispTests/Unit/WordDictionaryStoreTests.swift create mode 100644 WispTests/Unit/WordExtractionTests.swift create mode 100644 specs/007-custom-word-dictionary/checklists/requirements.md create mode 100644 specs/007-custom-word-dictionary/data-model.md create mode 100644 specs/007-custom-word-dictionary/plan.md create mode 100644 specs/007-custom-word-dictionary/quickstart.md create mode 100644 specs/007-custom-word-dictionary/research.md create mode 100644 specs/007-custom-word-dictionary/spec.md create mode 100644 specs/007-custom-word-dictionary/tasks.md diff --git a/CLAUDE.md b/CLAUDE.md index 44fe165..8b277c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,8 @@ Auto-generated from all feature plans. Last updated: 2026-03-29 - JSON file at `~/Library/Application Support/Wisp/transcription-log.json` (existing) (005-escape-cancel-countdown) - Swift 6.1+ with strict concurrency checking enabled + AppKit (NSSound, NSStatusItem, NSMenu), ServiceManagement (SMAppService), AVFoundation (existing) (006-polish-and-cleanup) - UserDefaults (startup preference, keyed on existing PreferencesStore) (006-polish-and-cleanup) +- Swift 6.1+ with strict concurrency checking enabled + WhisperKit (existing), FoundationModels (existing), AppKit + SwiftUI (existing), KeyboardShortcuts (existing) (007-custom-word-dictionary) +- UserDefaults (`com.wisp.wordDictionary` → `[String]`) (007-custom-word-dictionary) - Swift 5.9+ with strict concurrency checking + WhisperKit (Argmax), KeyboardShortcuts (Sindre Sorhus), AppKi (001-core-dictation-flow) @@ -31,9 +33,9 @@ tests/ Swift 5.9+ with strict concurrency checking: Follow standard conventions ## Recent Changes +- 007-custom-word-dictionary: Added Swift 6.1+ with strict concurrency checking enabled + WhisperKit (existing), FoundationModels (existing), AppKit + SwiftUI (existing), KeyboardShortcuts (existing) - 006-polish-and-cleanup: Added Swift 6.1+ with strict concurrency checking enabled + AppKit (NSSound, NSStatusItem, NSMenu), ServiceManagement (SMAppService), AVFoundation (existing) - 005-escape-cancel-countdown: Added Swift 6.1+ with strict concurrency checking enabled + AppKit (NSPanel, Core Animation), WhisperKit (existing), AVFoundation (existing) -- 004-transcription-log: Added Swift 6.1+ with strict concurrency checking enabled + AppKit (NSWindow, NSMenu), SwiftUI (List, Button), Foundation (Codable, JSONEncoder/Decoder, FileManager) diff --git a/Wisp/App/AppDelegate.swift b/Wisp/App/AppDelegate.swift index 7067bb7..9e999fb 100644 --- a/Wisp/App/AppDelegate.swift +++ b/Wisp/App/AppDelegate.swift @@ -11,6 +11,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var currentSession: DictationSession? private var preferencesStore: PreferencesStore? + private var wordDictionary = WordDictionaryStore() private var microphoneList: MicrophoneList? private var hotkeyService: HotkeyService? private var audioCaptureService: AudioCaptureService? @@ -93,12 +94,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { if logWindow == nil { logWindow = LogWindow() } - logWindow?.show(entries: logStore.entries) + logWindow?.show(logStore: logStore, wordDictionary: wordDictionary) } @objc private func openPreferences() { guard let store = preferencesStore, let mics = microphoneList else { return } - PreferencesWindow.show(preferences: store, microphoneList: mics) + PreferencesWindow.show(preferences: store, microphoneList: mics, wordDictionary: wordDictionary) } @objc private func toggleLaunchOnStartup() { @@ -416,7 +417,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private func transcribeAndPaste(audioBuffer: Data) async { do { print("[Wisp] Loading model and transcribing...") - let rawText = try await transcriptionService?.transcribe(audioBuffer: audioBuffer) + let hints = await MainActor.run { wordDictionary.words } + let rawText = try await transcriptionService?.transcribe( + audioBuffer: audioBuffer, wordHints: hints) print("[Wisp] Raw transcription: \(rawText ?? "")") guard let rawText, !rawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -427,7 +430,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { print("[Wisp] Cleaning up text...") let cleanedText: String if let service = textCleanupService { - cleanedText = (try? await service.cleanup(rawText)) ?? rawText + cleanedText = (try? await service.cleanup(rawText, wordHints: hints)) ?? rawText } else { cleanedText = rawText } @@ -456,7 +459,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private func transcribeAndSave(audioBuffer: Data) async { do { - let rawText = try await transcriptionService?.transcribe(audioBuffer: audioBuffer) + let hints = await MainActor.run { wordDictionary.words } + let rawText = try await transcriptionService?.transcribe( + audioBuffer: audioBuffer, wordHints: hints) guard let rawText, !rawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { // No speech detected — silently reset to idle with no notification await MainActor.run { silentlyResetToIdle() } @@ -464,7 +469,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } let cleanedText: String if let service = textCleanupService { - cleanedText = (try? await service.cleanup(rawText)) ?? rawText + cleanedText = (try? await service.cleanup(rawText, wordHints: hints)) ?? rawText } else { cleanedText = rawText } diff --git a/Wisp/Models/TranscriptionLogEntry.swift b/Wisp/Models/TranscriptionLogEntry.swift index 269b878..891dd8c 100644 --- a/Wisp/Models/TranscriptionLogEntry.swift +++ b/Wisp/Models/TranscriptionLogEntry.swift @@ -2,7 +2,7 @@ import Foundation struct TranscriptionLogEntry: Codable, Identifiable, Sendable { let id: UUID - let text: String + var text: String let timestamp: Date let wasPasted: Bool diff --git a/Wisp/Models/TranscriptionLogStore.swift b/Wisp/Models/TranscriptionLogStore.swift index 61735ca..23a19d8 100644 --- a/Wisp/Models/TranscriptionLogStore.swift +++ b/Wisp/Models/TranscriptionLogStore.swift @@ -4,6 +4,7 @@ import Foundation // ~/Library/Application Support/Wisp/transcription-log.json @MainActor +@Observable final class TranscriptionLogStore { private(set) var entries: [TranscriptionLogEntry] = [] @@ -24,6 +25,12 @@ final class TranscriptionLogStore { self.entries = Self.load(from: url) } + func update(id: UUID, text: String) { + guard let index = entries.firstIndex(where: { $0.id == id }) else { return } + entries[index].text = text + save() + } + func append(text: String, wasPasted: Bool = true) { let entry = TranscriptionLogEntry(text: text, wasPasted: wasPasted) entries.insert(entry, at: 0) diff --git a/Wisp/Models/WordDictionaryStore.swift b/Wisp/Models/WordDictionaryStore.swift new file mode 100644 index 0000000..715cbdb --- /dev/null +++ b/Wisp/Models/WordDictionaryStore.swift @@ -0,0 +1,86 @@ +import Foundation + +@MainActor +@Observable +final class WordDictionaryStore { + + private(set) var words: [String] = [] + + private let defaults: UserDefaults + private let key = "com.wisp.wordDictionary" + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.words = defaults.stringArray(forKey: key) ?? [] + } + + // MARK: - Mutation + + func add(_ word: String) { + let trimmed = word.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard !contains(trimmed) else { return } + words.append(trimmed) + persist() + } + + func update(at index: Int, word: String) { + let trimmed = word.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard words.indices.contains(index) else { return } + words[index] = trimmed + persist() + } + + func remove(at offsets: IndexSet) { + words.remove(atOffsets: offsets) + persist() + } + + func remove(_ word: String) { + guard let index = words.firstIndex(where: { + $0.caseInsensitiveCompare(word) == .orderedSame + }) else { return } + words.remove(at: index) + persist() + } + + // MARK: - Query + + func contains(_ word: String) -> Bool { + words.contains { $0.caseInsensitiveCompare(word) == .orderedSame } + } + + // MARK: - Word Extraction + + /// Returns words present in `newText` but absent (case-insensitive) from `oldText`. + /// Strips leading/trailing punctuation from each token before comparison. + nonisolated static func extractNewWords(from oldText: String, to newText: String) -> [String] { + let oldTokens = Set( + tokenise(oldText).map { stripped($0).lowercased() } + ) + var seen = Set() + var result: [String] = [] + for token in tokenise(newText) { + let key = stripped(token).lowercased() + guard !key.isEmpty, !oldTokens.contains(key), !seen.contains(key) else { continue } + seen.insert(key) + result.append(stripped(token)) + } + return result + } + + // MARK: - Private + + private func persist() { + defaults.set(words, forKey: key) + } + + nonisolated private static func tokenise(_ text: String) -> [String] { + text.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } + } + + nonisolated private static func stripped(_ token: String) -> String { + token.trimmingCharacters(in: .punctuationCharacters) + } +} diff --git a/Wisp/Services/TextCleanupService.swift b/Wisp/Services/TextCleanupService.swift index ba93109..749c6a7 100644 --- a/Wisp/Services/TextCleanupService.swift +++ b/Wisp/Services/TextCleanupService.swift @@ -11,13 +11,17 @@ final class TextCleanupService: @unchecked Sendable { session = LanguageModelSession() } - func cleanup(_ text: String) async throws -> String { + func cleanup(_ text: String, wordHints: [String] = []) async throws -> String { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return text } // Read the prompt at call time so changes take effect without restarting the service. let basePrompt = await MainActor.run { preferences.cleanupPrompt } - let prompt = basePrompt + "\n\nTranscribed text: \(text)" + var prompt = basePrompt + if !wordHints.isEmpty { + prompt += "\nUse these exact spellings when they appear: \(wordHints.joined(separator: ", "))" + } + prompt += "\n\nTranscribed text: \(text)" let response = try await session.respond(to: prompt) let cleaned = response.content.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Wisp/Services/TranscriptionService.swift b/Wisp/Services/TranscriptionService.swift index 99e5b15..d6917eb 100644 --- a/Wisp/Services/TranscriptionService.swift +++ b/Wisp/Services/TranscriptionService.swift @@ -42,7 +42,7 @@ final class TranscriptionService: @unchecked Sendable { loadTask = nil } - func transcribe(audioBuffer: Data) async throws -> String { + func transcribe(audioBuffer: Data, wordHints: [String] = []) async throws -> String { try await loadModel() guard let kit = whisperKit else { @@ -61,7 +61,15 @@ final class TranscriptionService: @unchecked Sendable { } do { - let results = try await kit.transcribe(audioArray: floatArray) + var decodeOptions = DecodingOptions() + if !wordHints.isEmpty, let tokenizer = kit.tokenizer { + let hintText = wordHints.joined(separator: ", ") + decodeOptions.promptTokens = tokenizer.encode(text: hintText) + } + let results = try await kit.transcribe( + audioArray: floatArray, + decodeOptions: decodeOptions + ) let text = results.map(\.text).joined(separator: " ").trimmingCharacters( in: .whitespacesAndNewlines) return text diff --git a/Wisp/UI/LogView.swift b/Wisp/UI/LogView.swift index ababaab..09fb1f9 100644 --- a/Wisp/UI/LogView.swift +++ b/Wisp/UI/LogView.swift @@ -3,7 +3,8 @@ import SwiftUI struct LogView: View { - let entries: [TranscriptionLogEntry] + var logStore: TranscriptionLogStore + var wordDictionary: WordDictionaryStore static let timestampFormat: Date.FormatStyle = .dateTime .month(.abbreviated) @@ -12,13 +13,17 @@ struct LogView: View { .minute() var body: some View { - if entries.isEmpty { + if logStore.entries.isEmpty { Text("No transcriptions yet.") .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - List(entries) { entry in - LogEntryRow(entry: entry) + List(logStore.entries) { entry in + LogEntryRow( + entry: entry, + logStore: logStore, + wordDictionary: wordDictionary + ) } } } @@ -27,6 +32,11 @@ struct LogView: View { private struct LogEntryRow: View { let entry: TranscriptionLogEntry + let logStore: TranscriptionLogStore + let wordDictionary: WordDictionaryStore + + @State private var isEditing: Bool = false + @State private var editDraft: String = "" var body: some View { HStack(alignment: .top, spacing: 8) { @@ -34,9 +44,19 @@ private struct LogEntryRow: View { Text(entry.timestamp, format: LogView.timestampFormat) .font(.caption) .foregroundStyle(.secondary) - Text(entry.text) - .font(.body) - .textSelection(.enabled) + + if isEditing { + TextEditor(text: $editDraft) + .font(.body) + .frame(minHeight: 44) + .onSubmit { commitEdit() } + } else { + Text(entry.text) + .font(.body) + .textSelection(.enabled) + .onTapGesture(count: 2) { startEditing() } + } + if !entry.wasPasted { Text("not pasted") .font(.caption2) @@ -44,16 +64,29 @@ private struct LogEntryRow: View { } } Spacer() - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(entry.text, forType: .string) - } label: { - Image(systemName: "doc.on.doc") - } - .buttonStyle(.borderless) - .help("Copy to clipboard") - .onHover { hovering in - if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } + + if isEditing { + VStack(spacing: 4) { + Button("Save") { commitEdit() } + .buttonStyle(.borderless) + .disabled(editDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Button("Cancel") { cancelEdit() } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + } + .font(.caption) + } else { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(entry.text, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + .help("Copy to clipboard") + .onHover { hovering in + if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } + } } } .padding(10) @@ -64,4 +97,30 @@ private struct LogEntryRow: View { .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12)) .listRowSeparator(.hidden) } + + // MARK: - Edit actions + + private func startEditing() { + editDraft = entry.text + isEditing = true + } + + private func commitEdit() { + let trimmed = editDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + cancelEdit() + return + } + let newWords = WordDictionaryStore.extractNewWords(from: entry.text, to: trimmed) + for word in newWords { + wordDictionary.add(word) + } + logStore.update(id: entry.id, text: trimmed) + isEditing = false + } + + private func cancelEdit() { + editDraft = "" + isEditing = false + } } diff --git a/Wisp/UI/LogWindow.swift b/Wisp/UI/LogWindow.swift index f7da5de..2559ca7 100644 --- a/Wisp/UI/LogWindow.swift +++ b/Wisp/UI/LogWindow.swift @@ -16,8 +16,10 @@ final class LogWindow: NSWindow { center() } - func show(entries: [TranscriptionLogEntry]) { - contentView = NSHostingView(rootView: LogView(entries: entries)) + func show(logStore: TranscriptionLogStore, wordDictionary: WordDictionaryStore) { + contentView = NSHostingView( + rootView: LogView(logStore: logStore, wordDictionary: wordDictionary) + ) makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } diff --git a/Wisp/UI/PreferencesView.swift b/Wisp/UI/PreferencesView.swift index 1f1e44e..ab846e5 100644 --- a/Wisp/UI/PreferencesView.swift +++ b/Wisp/UI/PreferencesView.swift @@ -5,6 +5,7 @@ struct PreferencesView: View { let preferences: PreferencesStore let microphoneList: MicrophoneList + @Bindable var wordDictionary: WordDictionaryStore @State private var promptDraft: String = "" @State private var promptError: String? = nil @@ -15,6 +16,7 @@ struct PreferencesView: View { microphoneSection startupSection promptSection + dictionarySection } .formStyle(.grouped) .frame(minWidth: 480, minHeight: 360) @@ -102,6 +104,12 @@ struct PreferencesView: View { } } + private var dictionarySection: some View { + Section("Transcription Dictionary") { + WordDictionaryView(wordDictionary: wordDictionary) + } + } + // MARK: - Actions private func commitPrompt() { diff --git a/Wisp/UI/PreferencesWindow.swift b/Wisp/UI/PreferencesWindow.swift index b587936..244827b 100644 --- a/Wisp/UI/PreferencesWindow.swift +++ b/Wisp/UI/PreferencesWindow.swift @@ -6,7 +6,11 @@ final class PreferencesWindow: NSWindowController { static var shared: PreferencesWindow? - static func show(preferences: PreferencesStore, microphoneList: MicrophoneList) { + static func show( + preferences: PreferencesStore, + microphoneList: MicrophoneList, + wordDictionary: WordDictionaryStore + ) { NSApp.setActivationPolicy(.regular) NSApp.activate(ignoringOtherApps: true) if let existing = shared { @@ -15,14 +19,23 @@ final class PreferencesWindow: NSWindowController { } let controller = PreferencesWindow( preferences: preferences, - microphoneList: microphoneList + microphoneList: microphoneList, + wordDictionary: wordDictionary ) shared = controller controller.window?.makeKeyAndOrderFront(nil) } - private init(preferences: PreferencesStore, microphoneList: MicrophoneList) { - let view = PreferencesView(preferences: preferences, microphoneList: microphoneList) + private init( + preferences: PreferencesStore, + microphoneList: MicrophoneList, + wordDictionary: WordDictionaryStore + ) { + let view = PreferencesView( + preferences: preferences, + microphoneList: microphoneList, + wordDictionary: wordDictionary + ) let hostingController = NSHostingController(rootView: view) let window = NSWindow(contentViewController: hostingController) diff --git a/Wisp/UI/WordDictionaryView.swift b/Wisp/UI/WordDictionaryView.swift new file mode 100644 index 0000000..f7f2218 --- /dev/null +++ b/Wisp/UI/WordDictionaryView.swift @@ -0,0 +1,125 @@ +import SwiftUI + +struct WordDictionaryView: View { + + @Bindable var wordDictionary: WordDictionaryStore + + @State private var newWordDraft: String = "" + @State private var isAdding: Bool = false + @State private var editingIndex: Int? = nil + @State private var editDraft: String = "" + + var body: some View { + if wordDictionary.words.isEmpty && !isAdding { + emptyState + } else { + wordList + } + } + + // MARK: - Subviews + + private var emptyState: some View { + VStack(spacing: 8) { + Text("No words in dictionary") + .foregroundStyle(.secondary) + Button("Add Word") { startAdding() } + .buttonStyle(.link) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + + private var wordList: some View { + VStack(alignment: .leading, spacing: 0) { + List { + ForEach(Array(wordDictionary.words.enumerated()), id: \.offset) { index, word in + wordRow(index: index, word: word) + } + .onDelete { offsets in + wordDictionary.remove(at: offsets) + } + + if isAdding { + addRow + } + } + .listStyle(.plain) + .frame(minHeight: 80, maxHeight: 200) + + HStack { + Spacer() + Button("Add Word") { startAdding() } + .buttonStyle(.link) + .disabled(isAdding) + } + .padding(.top, 4) + } + } + + private func wordRow(index: Int, word: String) -> some View { + HStack { + if editingIndex == index { + TextField("Word", text: $editDraft) + .textFieldStyle(.plain) + .onSubmit { commitEdit(at: index) } + Button("Done") { commitEdit(at: index) } + .buttonStyle(.borderless) + .font(.caption) + Button("Cancel") { editingIndex = nil } + .buttonStyle(.borderless) + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text(word) + .frame(maxWidth: .infinity, alignment: .leading) + Button("Edit") { + editDraft = word + editingIndex = index + } + .buttonStyle(.borderless) + .font(.caption) + } + } + } + + private var addRow: some View { + HStack { + TextField("New word\u{2026}", text: $newWordDraft) + .textFieldStyle(.plain) + .onSubmit { commitAdd() } + Button("Add") { commitAdd() } + .buttonStyle(.borderless) + .font(.caption) + .disabled(newWordDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Button("Cancel") { cancelAdding() } + .buttonStyle(.borderless) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + // MARK: - Actions + + private func startAdding() { + newWordDraft = "" + isAdding = true + } + + private func commitAdd() { + let trimmed = newWordDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + wordDictionary.add(trimmed) + cancelAdding() + } + + private func cancelAdding() { + newWordDraft = "" + isAdding = false + } + + private func commitEdit(at index: Int) { + wordDictionary.update(at: index, word: editDraft) + editingIndex = nil + } +} diff --git a/WispTests/Unit/LogEntryEditTests.swift b/WispTests/Unit/LogEntryEditTests.swift new file mode 100644 index 0000000..5ebbee7 --- /dev/null +++ b/WispTests/Unit/LogEntryEditTests.swift @@ -0,0 +1,117 @@ +import XCTest +@testable import Wisp + +@MainActor +final class LogEntryEditTests: XCTestCase { + + private var logStore: TranscriptionLogStore! + private var wordDictionary: WordDictionaryStore! + private var testURL: URL! + private var testDefaults: UserDefaults! + private var testSuiteName: String! + + override func setUp() { + super.setUp() + testURL = FileManager.default.temporaryDirectory + .appendingPathComponent("wisp-test-log-\(UUID().uuidString).json") + testSuiteName = "com.wisp.tests.\(UUID().uuidString)" + testDefaults = UserDefaults(suiteName: testSuiteName)! + logStore = TranscriptionLogStore(url: testURL) + wordDictionary = WordDictionaryStore(defaults: testDefaults) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: testURL) + testDefaults.removePersistentDomain(forName: testSuiteName) + logStore = nil + wordDictionary = nil + testURL = nil + testDefaults = nil + testSuiteName = nil + super.tearDown() + } + + // MARK: - Helpers + + /// Simulates the commit-edit action performed by LogEntryRow. + private func simulateEditCommit(entryID: UUID, oldText: String, newText: String) { + let trimmed = newText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let newWords = WordDictionaryStore.extractNewWords(from: oldText, to: trimmed) + for word in newWords { + wordDictionary.add(word) + } + logStore.update(id: entryID, text: trimmed) + } + + // MARK: - Dictionary population from edits + + func testEditCommit_newWordAddedToDictionary() { + logStore.append(text: "hello wisk") + let entry = logStore.entries[0] + simulateEditCommit(entryID: entry.id, oldText: entry.text, newText: "hello whisk") + XCTAssertTrue(wordDictionary.contains("whisk")) + } + + func testEditCommit_unchangedWordsNotAddedToDictionary() { + logStore.append(text: "hello world") + let entry = logStore.entries[0] + simulateEditCommit(entryID: entry.id, oldText: entry.text, newText: "hello world") + XCTAssertTrue(wordDictionary.words.isEmpty) + } + + func testEditCommit_multipleNewWordsAllAdded() { + logStore.append(text: "a b") + let entry = logStore.entries[0] + simulateEditCommit(entryID: entry.id, oldText: entry.text, newText: "a b SwiftUI Rosoll") + XCTAssertTrue(wordDictionary.contains("SwiftUI")) + XCTAssertTrue(wordDictionary.contains("Rosoll")) + } + + // MARK: - Blank edit handling + + func testEditCommit_blankInputDoesNotAddToDictionary() { + logStore.append(text: "hello world") + let entry = logStore.entries[0] + simulateEditCommit(entryID: entry.id, oldText: entry.text, newText: " ") + XCTAssertTrue(wordDictionary.words.isEmpty) + } + + func testEditCommit_blankInputDoesNotUpdateLog() { + logStore.append(text: "hello world") + let entry = logStore.entries[0] + simulateEditCommit(entryID: entry.id, oldText: entry.text, newText: " ") + XCTAssertEqual(logStore.entries[0].text, "hello world") + } + + // MARK: - Log store update + + func testEditCommit_updatesLogEntryText() { + logStore.append(text: "hello wisk") + let entry = logStore.entries[0] + simulateEditCommit(entryID: entry.id, oldText: entry.text, newText: "hello whisk") + XCTAssertEqual(logStore.entries[0].text, "hello whisk") + } + + func testEditCommit_persistsLogUpdate() { + logStore.append(text: "hello wisk") + let entry = logStore.entries[0] + simulateEditCommit(entryID: entry.id, oldText: entry.text, newText: "hello whisk") + let reloaded = TranscriptionLogStore(url: testURL) + XCTAssertEqual(reloaded.entries[0].text, "hello whisk") + } + + // MARK: - No duplicate dictionary entries + + func testEditCommit_duplicateWordNotAddedTwice() { + logStore.append(text: "hello") + let entry1 = logStore.entries[0] + simulateEditCommit(entryID: entry1.id, oldText: entry1.text, newText: "hello SwiftUI") + logStore.append(text: "world") + let entry2 = logStore.entries[0] + simulateEditCommit(entryID: entry2.id, oldText: entry2.text, newText: "world SwiftUI") + XCTAssertEqual(wordDictionary.words.filter { + $0.caseInsensitiveCompare("SwiftUI") == .orderedSame + }.count, 1) + } +} diff --git a/WispTests/Unit/WordDictionaryStoreTests.swift b/WispTests/Unit/WordDictionaryStoreTests.swift new file mode 100644 index 0000000..c13941b --- /dev/null +++ b/WispTests/Unit/WordDictionaryStoreTests.swift @@ -0,0 +1,152 @@ +import XCTest +@testable import Wisp + +@MainActor +final class WordDictionaryStoreTests: XCTestCase { + + private var store: WordDictionaryStore! + private var testDefaults: UserDefaults! + private var testSuiteName: String! + + override func setUp() { + super.setUp() + testSuiteName = "com.wisp.tests.\(UUID().uuidString)" + testDefaults = UserDefaults(suiteName: testSuiteName)! + store = WordDictionaryStore(defaults: testDefaults) + } + + override func tearDown() { + testDefaults.removePersistentDomain(forName: testSuiteName) + store = nil + testDefaults = nil + testSuiteName = nil + super.tearDown() + } + + // MARK: - add + + func testAdd_appendsWord() { + store.add("SwiftUI") + XCTAssertEqual(store.words, ["SwiftUI"]) + } + + func testAdd_trimsWhitespace() { + store.add(" WhisperKit ") + XCTAssertEqual(store.words, ["WhisperKit"]) + } + + func testAdd_ignoresBlankInput() { + store.add(" ") + XCTAssertTrue(store.words.isEmpty) + } + + func testAdd_ignoresEmptyString() { + store.add("") + XCTAssertTrue(store.words.isEmpty) + } + + func testAdd_ignoresDuplicateCaseSensitive() { + store.add("Wisp") + store.add("Wisp") + XCTAssertEqual(store.words.count, 1) + } + + func testAdd_ignoresDuplicateCaseInsensitive() { + store.add("Wisp") + store.add("wisp") + XCTAssertEqual(store.words.count, 1) + } + + func testAdd_storesWordAsEntered() { + store.add("SwiftUI") + XCTAssertEqual(store.words.first, "SwiftUI") + } + + // MARK: - remove(word:) + + func testRemove_byWord_removesEntry() { + store.add("Wisp") + store.add("Swift") + store.remove("Wisp") + XCTAssertEqual(store.words, ["Swift"]) + } + + func testRemove_byWord_isCaseInsensitive() { + store.add("Wisp") + store.remove("wisp") + XCTAssertTrue(store.words.isEmpty) + } + + func testRemove_byWord_noOpIfAbsent() { + store.add("Wisp") + store.remove("Other") + XCTAssertEqual(store.words.count, 1) + } + + // MARK: - remove(at:) + + func testRemove_atOffsets_removesEntry() { + store.add("A") + store.add("B") + store.remove(at: IndexSet(integer: 0)) + XCTAssertEqual(store.words, ["B"]) + } + + // MARK: - update + + func testUpdate_replacesWordAtIndex() { + store.add("Wisk") + store.update(at: 0, word: "Whisk") + XCTAssertEqual(store.words, ["Whisk"]) + } + + func testUpdate_ignoresBlankReplacement() { + store.add("Wisp") + store.update(at: 0, word: " ") + XCTAssertEqual(store.words, ["Wisp"]) + } + + func testUpdate_ignoresOutOfBoundsIndex() { + store.add("Wisp") + store.update(at: 5, word: "Other") + XCTAssertEqual(store.words, ["Wisp"]) + } + + // MARK: - contains + + func testContains_returnsTrueForExistingWord() { + store.add("Wisp") + XCTAssertTrue(store.contains("Wisp")) + } + + func testContains_isCaseInsensitive() { + store.add("Wisp") + XCTAssertTrue(store.contains("wisp")) + XCTAssertTrue(store.contains("WISP")) + } + + func testContains_returnsFalseForAbsentWord() { + XCTAssertFalse(store.contains("Wisp")) + } + + // MARK: - Persistence (UserDefaults round-trip) + + func testPersistence_roundTrip() { + store.add("SwiftUI") + store.add("Rosoll") + let store2 = WordDictionaryStore(defaults: testDefaults) + XCTAssertEqual(store2.words, ["SwiftUI", "Rosoll"]) + } + + func testPersistence_deletedWordDoesNotReappear() { + store.add("Wisp") + store.remove("Wisp") + let store2 = WordDictionaryStore(defaults: testDefaults) + XCTAssertTrue(store2.words.isEmpty) + } + + func testPersistence_emptyOnFirstInit() { + let freshStore = WordDictionaryStore(defaults: testDefaults) + XCTAssertTrue(freshStore.words.isEmpty) + } +} diff --git a/WispTests/Unit/WordExtractionTests.swift b/WispTests/Unit/WordExtractionTests.swift new file mode 100644 index 0000000..1312479 --- /dev/null +++ b/WispTests/Unit/WordExtractionTests.swift @@ -0,0 +1,85 @@ +import XCTest +@testable import Wisp + +final class WordExtractionTests: XCTestCase { + + // MARK: - Basic extraction + + func testExtract_newWordDetected() { + let result = WordDictionaryStore.extractNewWords(from: "hello world", to: "hello SwiftUI") + XCTAssertEqual(result, ["SwiftUI"]) + } + + func testExtract_unchangedWordsNotReturned() { + let result = WordDictionaryStore.extractNewWords(from: "hello world", to: "hello world") + XCTAssertTrue(result.isEmpty) + } + + func testExtract_allNewWords() { + let result = WordDictionaryStore.extractNewWords(from: "foo", to: "bar baz") + XCTAssertEqual(Set(result), Set(["bar", "baz"])) + } + + func testExtract_emptyOldText() { + let result = WordDictionaryStore.extractNewWords(from: "", to: "WhisperKit") + XCTAssertEqual(result, ["WhisperKit"]) + } + + func testExtract_emptyNewText() { + let result = WordDictionaryStore.extractNewWords(from: "hello", to: "") + XCTAssertTrue(result.isEmpty) + } + + func testExtract_bothEmpty() { + let result = WordDictionaryStore.extractNewWords(from: "", to: "") + XCTAssertTrue(result.isEmpty) + } + + // MARK: - Punctuation stripping + + func testExtract_punctuationWrappedWordStripped() { + let result = WordDictionaryStore.extractNewWords(from: "hello", to: "hello, SwiftUI.") + XCTAssertEqual(result, ["SwiftUI"]) + } + + func testExtract_punctuationOnlyTokenIgnored() { + let result = WordDictionaryStore.extractNewWords(from: "hello", to: "hello ...") + XCTAssertTrue(result.isEmpty) + } + + // MARK: - Case-insensitive comparison + + func testExtract_caseInsensitiveComparison_sameWordNotReturned() { + let result = WordDictionaryStore.extractNewWords(from: "Hello", to: "hello") + XCTAssertTrue(result.isEmpty) + } + + func testExtract_preservesOriginalCasing() { + let result = WordDictionaryStore.extractNewWords(from: "ui", to: "SwiftUI") + XCTAssertEqual(result, ["SwiftUI"]) + } + + // MARK: - Deduplication + + func testExtract_deduplicatesNewWords() { + let result = WordDictionaryStore.extractNewWords(from: "", to: "Wisp Wisp wisp") + XCTAssertEqual(result.count, 1) + } + + func testExtract_preservesFirstOccurrence() { + let result = WordDictionaryStore.extractNewWords(from: "", to: "SwiftUI swiftui") + XCTAssertEqual(result, ["SwiftUI"]) + } + + // MARK: - Whitespace handling + + func testExtract_multipleSpacesHandled() { + let result = WordDictionaryStore.extractNewWords(from: "old", to: "new word") + XCTAssertEqual(Set(result), Set(["new", "word"])) + } + + func testExtract_newlinesSeparateTokens() { + let result = WordDictionaryStore.extractNewWords(from: "", to: "line1\nline2") + XCTAssertEqual(Set(result), Set(["line1", "line2"])) + } +} diff --git a/specs/007-custom-word-dictionary/checklists/requirements.md b/specs/007-custom-word-dictionary/checklists/requirements.md new file mode 100644 index 0000000..c9fcf84 --- /dev/null +++ b/specs/007-custom-word-dictionary/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Custom Word Dictionary for Transcription Accuracy + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-29 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +All checklist items pass. The specification is ready for `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/007-custom-word-dictionary/data-model.md b/specs/007-custom-word-dictionary/data-model.md new file mode 100644 index 0000000..3f19ad2 --- /dev/null +++ b/specs/007-custom-word-dictionary/data-model.md @@ -0,0 +1,117 @@ +# Data Model: Custom Word Dictionary + +**Branch**: `007-custom-word-dictionary` | **Date**: 2026-03-29 + +## Entities + +--- + +### WordDictionaryStore + +New `@MainActor @Observable final class`. Single source of truth for the word dictionary. + +``` +WordDictionaryStore +├── words: [String] // ordered list, insertion order preserved +├── init(defaults: UserDefaults) // loads from UserDefaults on init +├── add(_ word: String) // trims, deduplicates (case-insensitive), persists +├── update(at index: Int, word: String) // replaces word at index, persists +├── remove(at offsets: IndexSet) // removes entries at offsets, persists +├── remove(_ word: String) // removes first case-insensitive match +└── contains(_ word: String) -> Bool // case-insensitive lookup +``` + +**Persistence**: UserDefaults key `com.wisp.wordDictionary` → `[String]`. +**Deduplication**: Before inserting, check `words.contains(where: { $0.caseInsensitiveCompare(word) == .orderedSame })`. +**Validation**: Ignore blank/whitespace-only inputs. Strip leading/trailing whitespace before storing. + +--- + +### TranscriptionLogEntry (modified) + +Existing struct — `text` promoted from `let` to `var` to allow in-place edits. + +``` +TranscriptionLogEntry (existing, modified) +├── id: UUID // unchanged +├── var text: String // WAS let — now mutable for inline editing +├── timestamp: Date // unchanged +└── wasPasted: Bool // unchanged +``` + +No schema migration needed: the JSON serialisation is identical. The `var` change is purely in-memory. + +--- + +### TranscriptionLogStore (modified) + +Existing class — gains one new method for updating an entry's text. + +``` +TranscriptionLogStore (existing, extended) +└── update(id: UUID, text: String) // NEW: replace text on matching entry, persist +``` + +--- + +### Word Extraction (pure function, no new type) + +A free function (or static method on `WordDictionaryStore`) handles word extraction from edits: + +``` +extractNewWords(from oldText: String, to newText: String) -> [String] +``` + +**Algorithm**: +1. Tokenise both strings by splitting on whitespace and newlines. +2. Build `oldWords = Set(tokens.map { stripped($0).lowercased() })` where `stripped` removes leading/trailing punctuation. +3. For each token in `newText` tokens: if `stripped(token).lowercased()` ∉ `oldWords` and `stripped(token)` is non-empty → include in result. +4. Deduplicate result (preserve first occurrence, case-insensitive). + +**Returns**: Words from the new text that were not present in the old text, deduplicated. + +--- + +## State Transitions + +### Word Dictionary Lifecycle + +``` +[empty] + │ user edits transcription / manually adds word + ▼ +[words: ["SwiftUI", "Rosoll", ...]] + │ new transcription starts + ▼ +[words injected into WhisperKit initialPrompt + cleanup prompt] + │ user deletes or edits word in settings + ▼ +[words updated, persisted, active on next transcription] +``` + +### LogEntryRow Edit Mode + +``` +[read mode] ──tap──▶ [edit mode: TextEditor] + │ Return / focus loss + ▼ + [word extraction] + │ + ┌─────────┴──────────┐ + ▼ ▼ + [new words added [entry text updated + to dictionary] in LogStore] + └─────────┬──────────┘ + ▼ + [read mode] +``` + +--- + +## Persistence Summary + +| Data | Mechanism | Key / Path | Format | +|------------------|--------------|-----------------------------------------|---------------| +| Word dictionary | UserDefaults | `com.wisp.wordDictionary` | `[String]` | +| Transcription log| JSON file | `~/Library/.../transcription-log.json` | `[Entry]` | +| Preferences | UserDefaults | `com.wisp.*` | Various | diff --git a/specs/007-custom-word-dictionary/plan.md b/specs/007-custom-word-dictionary/plan.md new file mode 100644 index 0000000..1635792 --- /dev/null +++ b/specs/007-custom-word-dictionary/plan.md @@ -0,0 +1,77 @@ +# Implementation Plan: Custom Word Dictionary for Transcription Accuracy + +**Branch**: `007-custom-word-dictionary` | **Date**: 2026-03-29 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/007-custom-word-dictionary/spec.md` + +## Summary + +When users correct words in transcribed text, the corrections are saved to a local personal dictionary that is injected as an `initialPrompt` into WhisperKit on every subsequent transcription. Users can manage the dictionary (view, add, edit, delete) in a new "Transcription Dictionary" section of the Preferences panel. All storage is local-only (UserDefaults). The feature requires: (1) adding inline editing to `LogEntryRow` with word-diff extraction, (2) a new `WordDictionaryStore`, and (3) threading the store through `TranscriptionService` and `TextCleanupService`. + +## Technical Context + +**Language/Version**: Swift 6.1+ with strict concurrency checking enabled +**Primary Dependencies**: WhisperKit (existing), FoundationModels (existing), AppKit + SwiftUI (existing), KeyboardShortcuts (existing) +**Storage**: UserDefaults (`com.wisp.wordDictionary` → `[String]`) +**Testing**: XCTest (existing) +**Target Platform**: macOS 26+, Apple Silicon and Intel +**Project Type**: macOS desktop app (background utility, LSUIElement) +**Performance Goals**: Dictionary lookup and prompt construction must add < 1 ms to transcription startup +**Constraints**: Offline-only, no cloud sync, no external APIs +**Scale/Scope**: Single user, expected dictionary size: 10–300 words + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +| --------- | ------ | ----- | +| I. Privacy-First Local Processing | ✅ PASS | Dictionary stored in UserDefaults; no data leaves device | +| II. Type Safety & Correctness | ✅ PASS | `WordDictionaryStore` uses explicit types; no force-unwraps; `TranscriptionLogEntry.text` promoted to `var` cleanly | +| III. Test-First Development | ✅ PASS | Tests for `WordDictionaryStore`, `extractNewWords`, and log-edit flow are part of the task plan | +| IV. Performance-Conscious Design | ✅ PASS | String array lookup and `joined(separator:)` add negligible overhead; no hot-path impact | +| V. Simplicity & YAGNI | ✅ PASS | No speculative features; word extraction uses simple set-difference (no LCS); UserDefaults (not a new file) | + +**Post-design re-check**: Inline editing adds a small `@State isEditing: Bool` to `LogEntryRow` — single clear responsibility, no abstraction violations. Dictionary section in `PreferencesView` is a new `Section`, not a new screen. All constitution gates still pass. + +## Project Structure + +### Documentation (this feature) + +```text +specs/007-custom-word-dictionary/ +├── plan.md ← this file +├── research.md ← Phase 0 output +├── data-model.md ← Phase 1 output +├── quickstart.md ← Phase 1 output +└── tasks.md ← Phase 2 output (/speckit.tasks — not yet created) +``` + +### Source Code (repository root) + +```text +Wisp/ +├── App/ +│ └── AppDelegate.swift ← modified: instantiate + wire WordDictionaryStore +├── Models/ +│ ├── WordDictionaryStore.swift ← NEW +│ └── TranscriptionLogEntry.swift ← modified: text let → var +│ └── TranscriptionLogStore.swift ← modified: add update(id:text:) method +├── Services/ +│ ├── TranscriptionService.swift ← modified: accept wordHints param +│ └── TextCleanupService.swift ← modified: augment prompt with dictionary words +└── UI/ + ├── WordDictionaryView.swift ← NEW: SwiftUI list for managing dictionary + ├── LogView.swift ← modified: tap-to-edit LogEntryRow + word extraction + └── PreferencesView.swift ← modified: add Transcription Dictionary section + +WispTests/ +├── WordDictionaryStoreTests.swift ← NEW +├── WordExtractionTests.swift ← NEW +└── LogEntryEditTests.swift ← NEW +``` + +**Structure Decision**: Single-project layout (existing). New files follow established `Models/` and `UI/` conventions. No new directories needed. + +## Complexity Tracking + +> No constitution violations — section not applicable. diff --git a/specs/007-custom-word-dictionary/quickstart.md b/specs/007-custom-word-dictionary/quickstart.md new file mode 100644 index 0000000..80c40e7 --- /dev/null +++ b/specs/007-custom-word-dictionary/quickstart.md @@ -0,0 +1,49 @@ +# Quickstart: Custom Word Dictionary + +**Branch**: `007-custom-word-dictionary` + +## Feature Overview + +Wisp learns from your corrections. When you edit a word in a transcription result, the corrected word is saved to a personal dictionary. That dictionary is then injected into every future transcription session so Whisper knows your preferred spellings upfront. + +## How It Works + +1. **Correction capture**: After dictating, open the Transcription Log. Tap any entry to edit it inline. When you commit the edit, any new words (words not in the original) are extracted and added to your dictionary automatically. + +2. **Prompt injection**: At the start of each transcription, `WordDictionaryStore` provides the word list to `TranscriptionService`, which formats it as an `initialPrompt` for WhisperKit. The cleanup LLM also receives the words as a spelling hint. + +3. **Settings management**: Open Wisp Preferences → "Transcription Dictionary" section to view, add, edit, or delete dictionary entries at any time. + +## Key Components + +| Component | File | Role | +|-----------|------|------| +| `WordDictionaryStore` | `Wisp/Models/WordDictionaryStore.swift` | Observable store; CRUD + persistence | +| `WordDictionaryView` | `Wisp/UI/WordDictionaryView.swift` | SwiftUI list + add/edit/delete UI | +| `LogEntryRow` (modified) | `Wisp/UI/LogView.swift` | Tap-to-edit + word extraction trigger | +| `TranscriptionService` (modified) | `Wisp/Services/TranscriptionService.swift` | Accepts `wordHints` param for WhisperKit | +| `TextCleanupService` (modified) | `Wisp/Services/TextCleanupService.swift` | Appends dictionary words to cleanup prompt | +| `PreferencesView` (modified) | `Wisp/UI/PreferencesView.swift` | Hosts `WordDictionaryView` as a section | +| `AppDelegate` (modified) | `Wisp/App/AppDelegate.swift` | Instantiates and wires `WordDictionaryStore` | + +## Running the Feature + +1. Build and run the app (Cmd+R in Xcode). +2. Use the hotkey to dictate a phrase containing a word Whisper gets wrong. +3. Open the Transcription Log from the menu bar. +4. Click on the mistranscribed entry, correct the word, press Return. +5. Open Preferences → Transcription Dictionary — the corrected word should appear. +6. Dictate the same phrase again — Whisper should now use the correct spelling. + +## Testing + +Run `XCTest` suite — all tests must pass before merge: + +``` +Product > Test (Cmd+U) in Xcode +``` + +Key test files to add: +- `WispTests/WordDictionaryStoreTests.swift` — CRUD, deduplication, persistence +- `WispTests/WordExtractionTests.swift` — `extractNewWords(from:to:)` edge cases +- `WispTests/LogEntryEditTests.swift` — edit flow, word extraction trigger diff --git a/specs/007-custom-word-dictionary/research.md b/specs/007-custom-word-dictionary/research.md new file mode 100644 index 0000000..8774103 --- /dev/null +++ b/specs/007-custom-word-dictionary/research.md @@ -0,0 +1,89 @@ +# Research: Custom Word Dictionary + +**Branch**: `007-custom-word-dictionary` | **Date**: 2026-03-29 + +## Decision Log + +--- + +### D-001: WhisperKit Initial Prompt API + +**Decision**: Use `DecodingOptions.initialPrompt: String?` to inject dictionary words into every transcription call. + +**Rationale**: WhisperKit's decoder accepts an initial prompt string that biases the model toward specific vocabulary and spelling. This is exactly the mechanism designed for custom vocabulary hints. The prompt is passed as a prefix token sequence, so the model favours those spellings when transcribing ambiguous audio. + +**Alternatives considered**: +- Injecting into the LLM cleanup prompt only — rejected because the cleanup step runs after Whisper has already committed to its transcription; it can correct but cannot improve the initial decode. +- Both WhisperKit prompt + cleanup prompt — accepted as a complementary addition: dictionary words are appended to the cleanup prompt too, so the LLM step can also apply them as a consistency check. + +**Implementation note**: `TranscriptionService.transcribe(audioBuffer:)` will accept a `wordHints: [String]` parameter and construct `DecodingOptions(initialPrompt: "Common words: \(wordHints.joined(separator: ", "))")` before calling WhisperKit. + +--- + +### D-002: Word Extraction Strategy from Inline Edits + +**Decision**: Simple set-difference on whitespace-split tokens. Words present in the new text but absent (case-insensitive) from the old text are treated as corrections and added to the dictionary. + +**Rationale**: The dictionary is intended for proper nouns, unusual spellings, and technical terms — exactly the words Whisper gets wrong because they are rare in training data. These words are almost always single-token replacements (e.g. "wisp" → "Wisp", "swiftui" → "SwiftUI"). A full LCS diff would add complexity with no practical benefit for this use case. + +**Alternatives considered**: +- Full LCS (longest common subsequence) word diff — overkill for single-word corrections; adds non-trivial code with no benefit in practice. +- Character-level diff — too granular; would capture partial word fragments. +- ML-based semantic comparison — far too heavyweight for comparing two short strings. + +**Edge-case handling**: +- Blank/whitespace-only result: ignored, not added to dictionary. +- Punctuation stripped from token before comparison (`trimmingCharacters(in: .punctuationCharacters)`). +- Duplicate detection: case-insensitive check against existing dictionary before insert. + +--- + +### D-003: Dictionary Persistence Mechanism + +**Decision**: UserDefaults with key `com.wisp.wordDictionary` storing `[String]`. + +**Rationale**: The dictionary is a flat ordered list of strings, expected to contain tens to a few hundred entries at most. UserDefaults handles this comfortably (well under the practical ~1 MB limit). This follows the existing pattern in `PreferencesStore` and avoids introducing a second persistence mechanism for what is logically a preference. + +**Alternatives considered**: +- JSON file at `~/Library/Application Support/Wisp/word-dictionary.json` — consistent with `TranscriptionLogStore` pattern, but adds file I/O management (atomic writes, directory creation) for a simple string array. Overcomplicated here. +- CoreData — wildly over-engineered for a list of strings. + +**Integration**: `WordDictionaryStore` will be an `@Observable @MainActor final class`, mirroring `PreferencesStore`. It will be instantiated once in `AppDelegate` and passed to `PreferencesView` and `TranscriptionService` via dependency injection (same pattern as `PreferencesStore`). + +--- + +### D-004: Inline Editing in LogView + +**Decision**: Add a tap-to-edit mode to `LogEntryRow`. A single tap on a row activates an inline `TextEditor` replacing the read-only `Text`. Committing (pressing Return or clicking outside) saves the edit, triggers word extraction, and updates the `TranscriptionLogStore` entry. + +**Rationale**: Editing must feel lightweight and inline — opening a modal sheet for a word correction would be disproportionate friction. The existing `LogEntryRow` already has a per-row interaction model (copy button), so extending it to support an edit mode is natural. + +**Alternatives considered**: +- Edit button per row that opens a sheet with a text field — rejected, too much friction for a minor correction. +- Making the `Text` view directly editable — SwiftUI `.textSelection(.enabled)` does not support editing; a `TextEditor` is required. + +**TranscriptionLogEntry change required**: `text` is currently `let`. It will become `var` to allow mutation. `TranscriptionLogStore` will gain an `update(id: UUID, text: String)` method that replaces the entry in-place and persists to disk. + +--- + +### D-005: Settings Panel Dictionary Section + +**Decision**: Add a new `Section` in `PreferencesView` titled "Transcription Dictionary" containing a `List` of words with an inline delete button per row, plus an "Add Word" button that presents a small inline text field. + +**Rationale**: Consistent with the existing settings panel structure (grouped form sections). Does not require a new window or sheet — the section lives naturally alongside Microphone, Shortcut, and Cleanup Prompt. + +**Alternatives considered**: +- Separate preferences tab/screen — overkill for a list of words. +- Sheet presented from menu bar — inconsistent with existing settings UX. + +--- + +### D-006: Cleanup Prompt Augmentation + +**Decision**: Append dictionary words to the `cleanupPrompt` at cleanup time (not stored), formatted as: `\nUse these exact spellings when they appear: [word1, word2, ...]`. + +**Rationale**: The LLM cleanup step can apply dictionary words as a post-processing consistency check. Words that Whisper gets partially correct (e.g. "swift UI" → "SwiftUI") benefit from the LLM seeing the correct form. This is a read-time augmentation — it does not change the stored `cleanupPrompt`. + +**Alternatives considered**: +- Store augmented prompt — rejected, would require stripping dictionary words on each update and create a confusing UX in the TextEditor. +- Skip cleanup-prompt augmentation — acceptable fallback if it causes prompt length issues, but the benefit outweighs the cost. diff --git a/specs/007-custom-word-dictionary/spec.md b/specs/007-custom-word-dictionary/spec.md new file mode 100644 index 0000000..79354b7 --- /dev/null +++ b/specs/007-custom-word-dictionary/spec.md @@ -0,0 +1,106 @@ +# Feature Specification: Custom Word Dictionary for Transcription Accuracy + +**Feature Branch**: `007-custom-word-dictionary` +**Created**: 2026-03-29 +**Status**: Draft +**Input**: User description: "if a user edits a word in the transcribed text, it should be added to a dictionary of commonly mistranscribed words that are appended to the prompt to help future transcriptions be more accurate. the user should be able to view edit add and delete words in the dictionary from the settings panel" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Automatic Dictionary Learning from Edits (Priority: P1) + +When a user corrects a word in transcribed text (e.g., changes "wisk" to "whisk"), the app silently captures that correction and adds the corrected word to the personal dictionary. On the next transcription, the dictionary words are included in the transcription prompt so the speech model has better context, reducing repeat mistakes. + +**Why this priority**: This is the core feedback loop — the dictionary is only valuable if it grows organically from real mistakes. Without this, the feature has no automatic value. + +**Independent Test**: Can be tested by making a correction in a transcription, then starting a new transcription of the same phrase and verifying the correction appears naturally. + +**Acceptance Scenarios**: + +1. **Given** a transcription result is displayed, **When** the user edits a word in the text, **Then** the edited (corrected) word is automatically saved to the dictionary +2. **Given** the dictionary contains a corrected word, **When** a new transcription starts, **Then** the dictionary words are included in the transcription prompt +3. **Given** a word already exists in the dictionary, **When** the user edits to the same word again, **Then** no duplicate entry is created + +--- + +### User Story 2 - View and Manage Dictionary in Settings (Priority: P2) + +The user can open the settings panel and navigate to a dictionary section where all saved words are listed. They can add new words directly, edit existing entries, or remove words that are no longer relevant. + +**Why this priority**: Users need to correct mistakes in the dictionary itself — if a wrong word gets added, or they want to seed the dictionary with known problem words upfront. + +**Independent Test**: Can be tested entirely within the settings panel without performing any dictation — add, edit, and delete words and confirm the list updates correctly. + +**Acceptance Scenarios**: + +1. **Given** the settings panel is open, **When** the user navigates to the Dictionary section, **Then** a list of all saved words is displayed +2. **Given** the dictionary list is visible, **When** the user clicks "Add Word" and enters a word, **Then** the word is saved and appears in the list +3. **Given** the dictionary list is visible, **When** the user selects a word and edits it, **Then** the word is updated in place +4. **Given** the dictionary list is visible, **When** the user deletes a word and confirms, **Then** the word is removed from the list and no longer used in transcription prompts +5. **Given** the dictionary is empty, **When** the user views the Dictionary section, **Then** a helpful empty-state message is shown + +--- + +### User Story 3 - Dictionary Persists Across App Restarts (Priority: P3) + +The user's dictionary is saved persistently so that words are not lost when the app is closed and reopened. + +**Why this priority**: Persistence is table-stakes for the feature to be useful, but it is a distinct concern from the capture and management flows. + +**Independent Test**: Add a word, quit the app, reopen it, navigate to settings, and verify the word is still present. + +**Acceptance Scenarios**: + +1. **Given** words have been added to the dictionary, **When** the app is quit and relaunched, **Then** all dictionary words are still present +2. **Given** a word was deleted from the dictionary, **When** the app is quit and relaunched, **Then** the deleted word does not reappear + +--- + +### Edge Cases + +- What happens when the user edits a word to a blank or whitespace-only string? +- How does the system handle very long words or non-alphabetic characters (numbers, symbols)? +- What if the same word is added with different capitalisation (e.g., "Wisp" vs "wisp")? +- What if the user edits punctuation or whitespace rather than a recognisable word? +- What happens when the dictionary grows large enough that including all words would make the prompt unwieldy? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST capture the corrected word whenever a user edits a word in a displayed transcription result +- **FR-002**: System MUST store captured corrections in a persistent personal dictionary +- **FR-003**: System MUST deduplicate dictionary entries (case-insensitive comparison; store as entered by user) +- **FR-004**: System MUST include dictionary words in the transcription prompt on every new transcription when the dictionary is non-empty +- **FR-005**: Settings panel MUST include a dedicated Dictionary section listing all saved words +- **FR-006**: Users MUST be able to add new words manually from the Dictionary settings section +- **FR-007**: Users MUST be able to edit existing dictionary words from the Dictionary settings section +- **FR-008**: Users MUST be able to delete individual words from the Dictionary settings section +- **FR-009**: System MUST persist the dictionary across app restarts +- **FR-010**: System MUST show an empty-state message in the Dictionary section when no words have been saved +- **FR-011**: System MUST ignore edits that result in blank or whitespace-only text (not add them to the dictionary) + +### Key Entities + +- **Dictionary Entry**: A single corrected word. Key attribute: the word text. Optionally: date first added. +- **Dictionary**: The full collection of dictionary entries for the user. Consulted at transcription time to enrich the prompt. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: After correcting a word in a transcription, that word appears in the dictionary settings list without any additional user action +- **SC-002**: A word that has been added to the dictionary is reflected in the transcription prompt for 100% of subsequent transcription sessions +- **SC-003**: Users can add, edit, and delete dictionary entries within the settings panel in under 30 seconds per operation +- **SC-004**: The dictionary is fully available within 2 seconds of the app launching, with no perceptible delay when opening the settings panel +- **SC-005**: The same phrase that was previously mistranscribed is transcribed correctly after the corrected word has been added to the dictionary + +## Assumptions + +- The transcription text is editable after a dictation session completes (existing behaviour from the transcription log feature) +- The system can detect which individual word was changed within an edit (before vs after comparison) +- The dictionary stores the corrected (intended) word only — not the original mistranscription — since the goal is to hint the model toward the correct form +- Dictionary words are appended to the existing transcription prompt as a natural-language hint (e.g., "Use these spellings when relevant: whisk, SwiftUI, Rosoll") +- There is no cloud sync requirement; the dictionary is local to the device only +- There is no import/export requirement for v1 +- The dictionary is shared across all transcription sessions (not per-microphone or per-context) diff --git a/specs/007-custom-word-dictionary/tasks.md b/specs/007-custom-word-dictionary/tasks.md new file mode 100644 index 0000000..c723f11 --- /dev/null +++ b/specs/007-custom-word-dictionary/tasks.md @@ -0,0 +1,205 @@ +# Tasks: Custom Word Dictionary for Transcription Accuracy + +**Input**: Design documents from `/specs/007-custom-word-dictionary/` +**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, quickstart.md ✅ + +**Tests**: Included — the Wisp Constitution (Principle III) mandates test-first development with XCTest covering the happy path and primary failure mode for every user-facing behaviour. + +**Organization**: Tasks grouped by user story to enable independent implementation and testing. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks) +- **[Story]**: Which user story this task belongs to (US1, US2, US3) + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Create new file scaffolds in the existing Xcode project so later tasks can fill them without merge conflicts. + +- [x] T001 Create empty `Wisp/Models/WordDictionaryStore.swift` with `import Foundation` placeholder +- [x] T002 Create empty `Wisp/UI/WordDictionaryView.swift` with `import SwiftUI` placeholder +- [x] T003 [P] Create empty `WispTests/WordDictionaryStoreTests.swift` with `import XCTest` placeholder +- [x] T004 [P] Create empty `WispTests/WordExtractionTests.swift` with `import XCTest` placeholder +- [x] T005 [P] Create empty `WispTests/LogEntryEditTests.swift` with `import XCTest` placeholder + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core changes that all three user stories depend on. Must complete before any story work begins. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [x] T006 Promote `text` field from `let` to `var` in `Wisp/Models/TranscriptionLogEntry.swift` +- [x] T007 Add `update(id: UUID, text: String)` method to `TranscriptionLogStore` that replaces the matching entry in-place and calls `save()` in `Wisp/Models/TranscriptionLogStore.swift` +- [x] T008 Implement `WordDictionaryStore` — `@MainActor @Observable final class` with `words: [String]`, `init(defaults: UserDefaults = .standard)` loading from `com.wisp.wordDictionary`, `add(_ word: String)` (trim + case-insensitive dedup + persist), `update(at index: Int, word: String)` (replace + persist), `remove(at offsets: IndexSet)` (persist), `remove(_ word: String)`, `contains(_ word: String) -> Bool`, and private `persist()` writing back to UserDefaults in `Wisp/Models/WordDictionaryStore.swift` +- [x] T009 Add `static func extractNewWords(from oldText: String, to newText: String) -> [String]` to `WordDictionaryStore` — splits both strings on whitespace/newlines, strips leading/trailing punctuation from each token, returns tokens present in `newText` but absent (case-insensitive) from `oldText`, deduplicated, ignoring blank tokens in `Wisp/Models/WordDictionaryStore.swift` +- [x] T010 Instantiate `WordDictionaryStore` as a stored property on `AppDelegate` (alongside `PreferencesStore`) in `Wisp/App/AppDelegate.swift` + +**Checkpoint**: Foundation ready — `WordDictionaryStore` exists and is wired into `AppDelegate`. User story phases can now proceed. + +--- + +## Phase 3: User Story 1 — Automatic Dictionary Learning from Edits (Priority: P1) 🎯 MVP + +**Goal**: Editing a word in a transcription result automatically adds the corrected word to the dictionary, and that dictionary is injected into every future transcription. + +**Independent Test**: Dictate a phrase, edit a word in the log, open Preferences → Transcription Dictionary and confirm the word appears. Dictate the same phrase again and confirm the corrected spelling is used. + +### Tests for User Story 1 + +> **Write these tests FIRST — confirm they FAIL before implementing** + +- [x] T011 [US1] Write `WordDictionaryStoreTests`: happy-path `add` appends word; duplicate (case-insensitive) is ignored; blank/whitespace input is ignored; `remove` deletes by word; `contains` returns correct bool in `WispTests/WordDictionaryStoreTests.swift` +- [x] T012 [P] [US1] Write `WordExtractionTests`: new word detected; unchanged word not returned; punctuation-wrapped word stripped correctly; blank result token ignored; all-same input returns empty array in `WispTests/WordExtractionTests.swift` +- [x] T013 [P] [US1] Write `LogEntryEditTests`: committing a word edit calls `extractNewWords` and adds result to `WordDictionaryStore`; committing with no changed words adds nothing; blank edit does not add to dictionary in `WispTests/LogEntryEditTests.swift` + +### Implementation for User Story 1 + +- [x] T014 [US1] Add `@State private var isEditing: Bool = false` and `@State private var editDraft: String = ""` to `LogEntryRow`; replace the read-only `Text(entry.text)` with a conditional: show `TextEditor(text: $editDraft)` when `isEditing`, `Text(entry.text).onTapGesture { ... }` otherwise in `Wisp/UI/LogView.swift` +- [x] T015 [US1] Implement edit commit handler in `LogEntryRow`: on Return key / focus loss, call `WordDictionaryStore.extractNewWords(from: entry.text, to: editDraft)`, call `wordDictionary.add(_:)` for each result, call `logStore.update(id: entry.id, text: editDraft)`, then set `isEditing = false` — pass `wordDictionary: WordDictionaryStore` and `logStore: TranscriptionLogStore` as parameters to `LogEntryRow` in `Wisp/UI/LogView.swift` +- [x] T016 [US1] Pass `wordDictionary` and `logStore` from `LogView` down to each `LogEntryRow` initialiser in `Wisp/UI/LogView.swift` +- [x] T017 [US1] Modify `TranscriptionService.transcribe(audioBuffer: Data)` to accept `wordHints: [String] = []`; construct `DecodingOptions(initialPrompt: "Common words and spellings: \(wordHints.joined(separator: ", "))")` when `wordHints` is non-empty and pass to the WhisperKit transcribe call in `Wisp/Services/TranscriptionService.swift` +- [x] T018 [US1] Modify `TextCleanupService.cleanup(_ text: String)` to accept `wordHints: [String] = []`; when non-empty, append `"\nUse these exact spellings when they appear: \(wordHints.joined(separator: ", "))"` to the prompt before calling `session.respond(to:)` in `Wisp/Services/TextCleanupService.swift` +- [x] T019 [US1] Update `AppDelegate.transcribeAndPaste(audioBuffer:)` to pass `wordDictionary.words` to both `transcriptionService?.transcribe(audioBuffer:wordHints:)` and `textCleanupService?.cleanup(_:wordHints:)` in `Wisp/App/AppDelegate.swift` + +**Checkpoint**: User Story 1 fully functional. Edit a log entry → word appears in dictionary → next transcription uses it. + +--- + +## Phase 4: User Story 2 — View and Manage Dictionary in Settings (Priority: P2) + +**Goal**: Users can view, add, edit, and delete dictionary words from the Preferences panel without performing a dictation. + +**Independent Test**: Open Preferences, navigate to Transcription Dictionary, add a word, edit it, delete it — verify the list updates correctly at each step. Works without ever making a transcription. + +### Tests for User Story 2 + +> **Write these tests FIRST — confirm they FAIL before implementing** + +- [x] T020 [US2] Write test for `WordDictionaryView` add-word flow: tapping "Add Word", entering text, confirming — verifies new word appears in `WordDictionaryStore.words` in `WispTests/WordDictionaryStoreTests.swift` +- [x] T021 [P] [US2] Write test for `WordDictionaryView` delete-word flow: deleting an entry via swipe/button — verifies word is removed from `WordDictionaryStore.words` in `WispTests/WordDictionaryStoreTests.swift` + +### Implementation for User Story 2 + +- [x] T022 [US2] Implement `WordDictionaryView` — SwiftUI `List` iterating `wordDictionary.words` with per-row swipe-to-delete, inline edit on tap (double-tap or edit button), an "Add Word" toolbar button that appends a new inline text field, and an empty-state `ContentUnavailableView` (or `Text`) when the list is empty; accepts `@Bindable var wordDictionary: WordDictionaryStore` in `Wisp/UI/WordDictionaryView.swift` +- [x] T023 [US2] Add a `Section("Transcription Dictionary") { WordDictionaryView(wordDictionary: wordDictionary) }` to the grouped `Form` in `PreferencesView`, placed after the Cleanup Prompt section in `Wisp/UI/PreferencesView.swift` +- [x] T024 [US2] Add `wordDictionary: WordDictionaryStore` parameter to `PreferencesView.init` and to `PreferencesWindow.show(preferences:microphoneList:wordDictionary:)` in `Wisp/UI/PreferencesView.swift` and `Wisp/UI/PreferencesWindow.swift` +- [x] T025 [US2] Update `AppDelegate.openPreferences()` to pass `wordDictionary` to `PreferencesWindow.show(preferences:microphoneList:wordDictionary:)` in `Wisp/App/AppDelegate.swift` + +**Checkpoint**: User Stories 1 and 2 both independently functional. Dictionary visible and editable in settings. + +--- + +## Phase 5: User Story 3 — Dictionary Persists Across App Restarts (Priority: P3) + +**Goal**: Dictionary words survive app quit and relaunch. + +**Independent Test**: Add a word, quit the app, relaunch, open Preferences → Transcription Dictionary — word still present. + +### Tests for User Story 3 + +> **Write these tests FIRST — confirm they FAIL before implementing** + +- [x] T026 [US3] Write UserDefaults round-trip test: create `WordDictionaryStore(defaults: inMemoryDefaults)`, add words, create a second `WordDictionaryStore(defaults: inMemoryDefaults)`, verify `words` matches in `WispTests/WordDictionaryStoreTests.swift` +- [x] T027 [P] [US3] Write test that a deleted word does not reappear after a fresh `WordDictionaryStore` init from the same UserDefaults in `WispTests/WordDictionaryStoreTests.swift` + +### Implementation for User Story 3 + +- [x] T028 [US3] Verify `WordDictionaryStore.init(defaults:)` correctly decodes `[String]` from `com.wisp.wordDictionary`; confirm `persist()` writes the full array atomically; add nil-guard so missing key produces empty `words` (not a crash) in `Wisp/Models/WordDictionaryStore.swift` +- [x] T029 [US3] Audit `WordDictionaryStore` for Swift 6.1 strict concurrency: confirm `@MainActor` isolation is correct, `words` mutations only occur on main actor, and `UserDefaults` calls are safe from the main actor in `Wisp/Models/WordDictionaryStore.swift` + +**Checkpoint**: All three user stories independently functional and persisted. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Input validation, empty-state UX, and final verification. + +- [x] T030 [P] Add guard in `WordDictionaryView` add-word flow that disables the confirm button and shows an inline hint when the input is blank or whitespace-only in `Wisp/UI/WordDictionaryView.swift` +- [x] T031 [P] Add guard in `LogEntryRow` edit commit handler that rejects blank/whitespace-only edits (restores original text, does not add to dictionary) in `Wisp/UI/LogView.swift` +- [x] T032 [P] Add guard in `WordDictionaryStore.add(_:)` that strips whitespace and returns early if the result is empty, ensuring no blank entries can be persisted regardless of call site in `Wisp/Models/WordDictionaryStore.swift` +- [x] T033 Review all modified files against the Wisp Constitution: no force-unwraps, explicit `@MainActor` where needed, no speculative features added beyond spec scope +- [ ] T034 Run quickstart.md validation end-to-end: build, dictate phrase with unusual word, correct in log, verify dictionary in settings, dictate again, confirm improvement + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — start immediately +- **Foundational (Phase 2)**: Depends on Phase 1 — BLOCKS all user stories +- **User Story 1 (Phase 3)**: Depends on Phase 2 +- **User Story 2 (Phase 4)**: Depends on Phase 2 (and benefits from US1's `WordDictionaryStore`, but is independently testable) +- **User Story 3 (Phase 5)**: Depends on Phase 2 (`WordDictionaryStore` persistence is already implemented there; this phase adds tests + audit) +- **Polish (Phase 6)**: Depends on all user story phases being complete + +### User Story Dependencies + +- **US1 (P1)**: Can start immediately after Phase 2 — no dependency on US2 or US3 +- **US2 (P2)**: Can start immediately after Phase 2 — no dependency on US1 (manages same `WordDictionaryStore` but through a different UI surface) +- **US3 (P3)**: Can start immediately after Phase 2 — persistence is in `WordDictionaryStore`; this phase is primarily tests + concurrency audit + +### Within Each User Story + +- Tests written and confirmed FAILING before implementation (Constitution Principle III) +- Model/store tasks before service tasks before UI tasks +- Core implementation before integration wiring + +### Parallel Opportunities + +- T003, T004, T005 (Phase 1): all independent new files +- T012, T013 (US1 tests): different files +- T017, T018 (US1 implementation): different service files +- T020, T021 (US2 tests): same file but separate test methods — write sequentially +- T026, T027 (US3 tests): same file, write sequentially +- T030, T031, T032 (Polish): different files + +--- + +## Parallel Example: User Story 1 + +``` +# After T011 (WordDictionaryStoreTests), launch in parallel: +T012 — WordExtractionTests.swift +T013 — LogEntryEditTests.swift + +# After foundational T017/T018 dependencies are clear: +T017 — TranscriptionService.swift (wordHints param) +T018 — TextCleanupService.swift (prompt augmentation) +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (T001–T005) +2. Complete Phase 2: Foundational (T006–T010) +3. Complete Phase 3: User Story 1 (T011–T019) +4. **STOP and VALIDATE**: Edit a transcription word → appears in dictionary → next transcription uses it +5. This delivers the core automatic learning loop + +### Incremental Delivery + +1. Setup + Foundational → `WordDictionaryStore` exists +2. User Story 1 → Edit-to-learn loop works +3. User Story 2 → Settings management panel ready +4. User Story 3 → Persistence confirmed and tested +5. Polish → Input validation and final audit + +--- + +## Notes + +- [P] tasks operate on different files with no cross-dependencies +- [Story] label maps each task to the user story it delivers +- Each user story phase is independently completable and testable +- Tests must fail before implementation (red-green-refactor per Constitution) +- `inMemoryDefaults` in US3 tests: use `UserDefaults(suiteName: UUID().uuidString)!` for test isolation +- `WordDictionaryStore.add` is the single validated entry point — all call sites go through it