diff --git a/Sources/App/DeferredReplacement.swift b/Sources/App/DeferredReplacement.swift index a1db045..52f8ec0 100644 --- a/Sources/App/DeferredReplacement.swift +++ b/Sources/App/DeferredReplacement.swift @@ -22,6 +22,7 @@ struct DeferredReplacement { var state: DeferredReplacementState var message: String var context: InputContext? + let formatKind: TextFormatKind init( rawText: String, @@ -29,6 +30,7 @@ struct DeferredReplacement { targetApp: NSRunningApplication?, message: String, context: InputContext? = nil, + formatKind: TextFormatKind = .plainParagraph, createdAt: Date = Date(), expirationInterval: TimeInterval = DeferredReplacementPolicy.expirationInterval ) { @@ -44,6 +46,7 @@ struct DeferredReplacement { self.state = .formatting self.message = message self.context = context + self.formatKind = formatKind } var targetApplication: NSRunningApplication? { diff --git a/Sources/App/VoicePipeline+CorrectionCapture.swift b/Sources/App/VoicePipeline+CorrectionCapture.swift new file mode 100644 index 0000000..2f511c0 --- /dev/null +++ b/Sources/App/VoicePipeline+CorrectionCapture.swift @@ -0,0 +1,19 @@ +import Foundation + +@MainActor +extension VoicePipeline { + func beginCorrectionCapture( + recordID: UUID, + insertedText: String, + context: InputContext + ) { + guard appState.settings.enableCorrectionLearning, + let seed = textInserter.correctionCaptureSeed( + expectedText: insertedText, + context: context + ) else { + return + } + correctionCapture.start(seed: seed, recordID: recordID) + } +} diff --git a/Sources/App/VoicePipeline+EditCommands.swift b/Sources/App/VoicePipeline+EditCommands.swift index f8615d9..6babcba 100644 --- a/Sources/App/VoicePipeline+EditCommands.swift +++ b/Sources/App/VoicePipeline+EditCommands.swift @@ -100,13 +100,18 @@ extension VoicePipeline { return } - InputHistory.shared.addRecord( + let recordID = InputHistory.shared.addRecord( rawText: raw, processedText: replacementText, wasProcessed: true, context: context ) appState.lastInsertedText = replacementText + beginCorrectionCapture( + recordID: recordID, + insertedText: replacementText, + context: context + ) } private func replaceSelectedText( @@ -142,13 +147,18 @@ extension VoicePipeline { return } - InputHistory.shared.addRecord( + let recordID = InputHistory.shared.addRecord( rawText: raw, processedText: replacementText, wasProcessed: true, context: context ) appState.lastInsertedText = replacementText + beginCorrectionCapture( + recordID: recordID, + insertedText: replacementText, + context: context + ) } private func replacementInputContext( @@ -287,12 +297,17 @@ extension VoicePipeline { return } - InputHistory.shared.addRecord( + let recordID = InputHistory.shared.addRecord( rawText: raw, processedText: rewrittenText, wasProcessed: true, context: context ) appState.lastInsertedText = rewrittenText + beginCorrectionCapture( + recordID: recordID, + insertedText: rewrittenText, + context: context + ) } } diff --git a/Sources/App/VoicePipeline+Processing.swift b/Sources/App/VoicePipeline+Processing.swift index 04d9510..1c44982 100644 --- a/Sources/App/VoicePipeline+Processing.swift +++ b/Sources/App/VoicePipeline+Processing.swift @@ -174,6 +174,8 @@ extension VoicePipeline { memoryWindowMinutes: memoryWindowMinutes, currentContext: inputContext ) + let formatDecision = TextFormatClassifier.classify(text: raw, context: inputContext) + Log.info("[VoicePipeline] format kind \(formatDecision.kind.rawValue) (\(formatDecision.reason.rawValue))") let text = await textProcessor.process( text: raw, options: processingOptions, @@ -181,10 +183,11 @@ extension VoicePipeline { screenImage: screenContext.image, memoryContext: memoryContext, inputContext: inputContext, + formatKind: formatDecision.kind, dictionarySnapshot: dictionarySnapshot ) recordFormattingDuration(started, label: "Smart Format") - return VoicePipelineOutput(text: text, context: inputContext) + return VoicePipelineOutput(text: text, context: inputContext, formatKind: formatDecision.kind) } private func processCommand( @@ -273,19 +276,34 @@ extension VoicePipeline { let wasProcessed = inputMode.isTranslation || settings.outputMode == .processed || settings.outputMode == .command - InputHistory.shared.addRecord( + let recordID = InputHistory.shared.addRecord( rawText: raw, processedText: finalText, wasProcessed: wasProcessed, - context: output.context + context: output.context, + formatKind: output.formatKind ) appState.lastInsertedText = finalText + if !inputMode.isTranslation { + beginCorrectionCapture( + recordID: recordID, + insertedText: finalText, + context: output.context + ) + } } } struct VoicePipelineOutput { let text: String let context: InputContext + let formatKind: TextFormatKind? + + init(text: String, context: InputContext, formatKind: TextFormatKind? = nil) { + self.text = text + self.context = context + self.formatKind = formatKind + } } private enum VoicePipelineStop: Error { diff --git a/Sources/App/VoicePipeline+Replacement.swift b/Sources/App/VoicePipeline+Replacement.swift index 9121bf6..7f96a20 100644 --- a/Sources/App/VoicePipeline+Replacement.swift +++ b/Sources/App/VoicePipeline+Replacement.swift @@ -54,6 +54,7 @@ extension VoicePipeline { inputLanguage: processingOptions.inputLanguage, source: .menuBar ) + let formatDecision = TextFormatClassifier.classify(text: raw, context: quickContext) let ocrTask = screenOCRTask let ocrStartedAt = screenOCRStartedAt screenOCRTask = nil @@ -88,20 +89,27 @@ extension VoicePipeline { return } - InputHistory.shared.addRecord( + let recordID = InputHistory.shared.addRecord( rawText: raw, processedText: quickText, wasProcessed: false, - context: quickContext + context: quickContext, + formatKind: formatDecision.kind ) appState.lastInsertedText = quickText + beginCorrectionCapture( + recordID: recordID, + insertedText: quickText, + context: quickContext + ) let replacement = DeferredReplacement( rawText: raw, insertedText: quickText, targetApp: targetApp, message: L("pipeline.background_formatting"), - context: quickContext + context: quickContext, + formatKind: formatDecision.kind ) appState.pendingReplacement = replacement @@ -131,6 +139,8 @@ extension VoicePipeline { appState.phase = .inserting appState.statusMessage = L("pipeline.replacing") + correctionCapture.finishCurrentSession() + let result = await textInserter.replaceRecentInsertion( text: formattedText, previouslyInserted: replacement.insertedText, @@ -150,11 +160,23 @@ extension VoicePipeline { appState.processedText = formattedText appState.lastInsertedText = formattedText - InputHistory.shared.replaceLatestRecord( + let recordID = InputHistory.shared.replaceLatestRecord( rawText: replacement.rawText, processedText: formattedText, wasProcessed: true, - context: replacement.context + context: replacement.context, + formatKind: replacement.formatKind + ) + beginCorrectionCapture( + recordID: recordID, + insertedText: formattedText, + context: replacement.context ?? InputContext( + appName: replacement.targetAppName, + bundleIdentifier: replacement.targetBundleIdentifier, + outputMode: .processed, + inputLanguage: appState.settings.inputLanguage, + source: .menuBar + ) ) appState.clearPendingReplacement() appState.phase = .done @@ -208,6 +230,7 @@ extension VoicePipeline { screenImage: screenContext.image, memoryContext: memoryContext, inputContext: inputContext, + formatKind: currentReplacement.formatKind, allowsPreparedFallback: false, allowsGuardFallback: false, dictionarySnapshot: dictionarySnapshot diff --git a/Sources/App/VoicePipeline+RewriteLast.swift b/Sources/App/VoicePipeline+RewriteLast.swift index 9094728..532a259 100644 --- a/Sources/App/VoicePipeline+RewriteLast.swift +++ b/Sources/App/VoicePipeline+RewriteLast.swift @@ -67,12 +67,17 @@ extension VoicePipeline { return } - InputHistory.shared.addRecord( + let recordID = InputHistory.shared.addRecord( rawText: raw, processedText: rewrittenText, wasProcessed: true, context: context ) appState.lastInsertedText = rewrittenText + beginCorrectionCapture( + recordID: recordID, + insertedText: rewrittenText, + context: context + ) } } diff --git a/Sources/App/VoicePipeline.swift b/Sources/App/VoicePipeline.swift index 6138a2c..1c553bc 100644 --- a/Sources/App/VoicePipeline.swift +++ b/Sources/App/VoicePipeline.swift @@ -7,6 +7,7 @@ final class VoicePipeline { let soundPlayer = SoundPlayer() let audioCapture = AudioCaptureManager() let textInserter = TextInserter() + let correctionCapture = CorrectionCaptureService() let textProcessor = TextProcessor() let overlay = OverlayPanel() var whisperEngine: WhisperEngine? @@ -77,6 +78,8 @@ final class VoicePipeline { if appState.isDownloading { return } + correctionCapture.finishCurrentSession() + if !(currentEngine?.isReady ?? false) { await ensureEngineLoaded(requestPermission: true) } diff --git a/Sources/Config/AppSettings.swift b/Sources/Config/AppSettings.swift index 949be66..134d0b8 100644 --- a/Sources/Config/AppSettings.swift +++ b/Sources/Config/AppSettings.swift @@ -275,6 +275,7 @@ final class AppSettings: ObservableObject { @Published var historyRetention: HistoryRetention @Published var enableMemory: Bool @Published var memoryWindowMinutes: Int + @Published var enableCorrectionLearning: Bool @Published var useCustomSystemPrompt: Bool @Published var customSystemPrompt: String @Published var useRemoteLLM: Bool @@ -309,7 +310,7 @@ final class AppSettings: ObservableObject { case enableStreamingRecognitionBeta case inputLanguage, translationTargetLanguage case useScreenContext, screenContextMode, enableInstantInsert, hasCompletedOnboarding, uiLanguage, historyRetention - case enableMemory, memoryWindowMinutes + case enableMemory, memoryWindowMinutes, enableCorrectionLearning case useCustomSystemPrompt, customSystemPrompt case useRemoteLLM, remoteProvider, remoteAPIKey, remoteBaseURL, remoteModel case menuBarIcon, appIconAppearance @@ -373,6 +374,7 @@ final class AppSettings: ObservableObject { historyRetention = HistoryRetention(rawValue: ud.string(forKey: Key.historyRetention.rawValue) ?? "") ?? .forever enableMemory = ud.object(forKey: Key.enableMemory.rawValue) as? Bool ?? true memoryWindowMinutes = (ud.integer(forKey: Key.memoryWindowMinutes.rawValue)).nonZeroInt ?? 30 + enableCorrectionLearning = ud.object(forKey: Key.enableCorrectionLearning.rawValue) as? Bool ?? true useCustomSystemPrompt = ud.bool(forKey: Key.useCustomSystemPrompt.rawValue) customSystemPrompt = ud.string(forKey: Key.customSystemPrompt.rawValue) ?? "" useRemoteLLM = ud.bool(forKey: Key.useRemoteLLM.rawValue) @@ -439,6 +441,9 @@ final class AppSettings: ObservableObject { $historyRetention.dropFirst().sink { [defaults] in defaults.set($0.rawValue, forKey: Key.historyRetention.rawValue) }.store(in: &cancellables) $enableMemory.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.enableMemory.rawValue) }.store(in: &cancellables) $memoryWindowMinutes.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.memoryWindowMinutes.rawValue) }.store(in: &cancellables) + $enableCorrectionLearning.dropFirst().sink { + [defaults] in defaults.set($0, forKey: Key.enableCorrectionLearning.rawValue) + }.store(in: &cancellables) $useCustomSystemPrompt.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.useCustomSystemPrompt.rawValue) }.store(in: &cancellables) $customSystemPrompt.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.customSystemPrompt.rawValue) }.store(in: &cancellables) $useRemoteLLM.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.useRemoteLLM.rawValue) }.store(in: &cancellables) diff --git a/Sources/Integration/InputSessionCoordinator+Output.swift b/Sources/Integration/InputSessionCoordinator+Output.swift index 67cd7be..56353a2 100644 --- a/Sources/Integration/InputSessionCoordinator+Output.swift +++ b/Sources/Integration/InputSessionCoordinator+Output.swift @@ -9,6 +9,7 @@ extension InputSessionCoordinator { let memoryWindowMinutes = settings.memoryWindowMinutes let text: String let context: InputContext + let formatKind: TextFormatKind? switch active.mode { case .direct: @@ -19,6 +20,7 @@ extension InputSessionCoordinator { inputLanguage: active.inputLanguage, dictionarySnapshot: dictionarySnapshot ) + formatKind = nil case .processed: let screenContext = await screenContext(from: active) context = inputContext(for: active, screenContext: screenContext.text, mode: .processed) @@ -28,6 +30,8 @@ extension InputSessionCoordinator { memoryWindowMinutes: memoryWindowMinutes, currentContext: context ) + let decision = TextFormatClassifier.classify(text: raw, context: context) + formatKind = decision.kind text = await textProcessor.process( text: raw, options: options, @@ -35,6 +39,7 @@ extension InputSessionCoordinator { screenImage: screenContext.image, memoryContext: memoryContext, inputContext: context, + formatKind: decision.kind, dictionarySnapshot: dictionarySnapshot ) case .command: @@ -46,6 +51,7 @@ extension InputSessionCoordinator { memoryWindowMinutes: memoryWindowMinutes, currentContext: context ) + formatKind = nil text = await textProcessor.processCommand( text: raw, options: options, @@ -66,7 +72,8 @@ extension InputSessionCoordinator { rawText: raw, processedText: text, wasProcessed: active.mode != .direct, - context: context + context: context, + formatKind: formatKind ) return text } diff --git a/Sources/Output/CorrectionCaptureRegion.swift b/Sources/Output/CorrectionCaptureRegion.swift new file mode 100644 index 0000000..aa2ea43 --- /dev/null +++ b/Sources/Output/CorrectionCaptureRegion.swift @@ -0,0 +1,91 @@ +import Foundation + +struct CorrectionCaptureRegionLocator: Equatable, Sendable { + private static let anchorLength = 48 + let originalRange: NSRange + let baselineDocumentLength: Int + let trailingDocumentLength: Int + let prefixAnchor: String + let suffixAnchor: String + + init?(documentText: String, insertedRange: NSRange) { + let document = documentText as NSString + guard insertedRange.location >= 0, + insertedRange.length > 0, + NSMaxRange(insertedRange) <= document.length else { + return nil + } + originalRange = insertedRange + baselineDocumentLength = document.length + trailingDocumentLength = document.length - NSMaxRange(insertedRange) + + let prefixLength = min(Self.anchorLength, insertedRange.location) + prefixAnchor = document.substring(with: NSRange( + location: insertedRange.location - prefixLength, + length: prefixLength + )) + let suffixLength = min(Self.anchorLength, trailingDocumentLength) + suffixAnchor = document.substring(with: NSRange( + location: NSMaxRange(insertedRange), + length: suffixLength + )) + } + + func editedText(in currentText: String) -> String? { + let current = currentText as NSString + let start: Int + if prefixAnchor.isEmpty { + start = originalRange.location + } else { + let expected = max(0, originalRange.location - prefixAnchor.utf16.count) + guard let prefixRange = nearbyRange( + of: prefixAnchor, + in: current, + expectedLocation: expected, + searchRadius: 192 + ) else { return nil } + start = NSMaxRange(prefixRange) + } + + let end: Int + if suffixAnchor.isEmpty { + end = current.length - trailingDocumentLength + } else { + let expected = max(start, NSMaxRange(originalRange) + current.length - baselineDocumentLength) + guard let suffixRange = nearbyRange( + of: suffixAnchor, + in: current, + expectedLocation: expected, + searchRadius: max(512, originalRange.length * 2) + ) else { return nil } + end = suffixRange.location + } + + let length = end - start + guard start >= 0, + length >= 0, + end <= current.length, + length <= max(1_024, originalRange.length * 4 + 256) else { + return nil + } + return current.substring(with: NSRange(location: start, length: length)) + } +} +private extension CorrectionCaptureRegionLocator { + func nearbyRange( + of needle: String, + in text: NSString, + expectedLocation: Int, + searchRadius: Int + ) -> NSRange? { + let lower = max(0, expectedLocation - searchRadius) + let upper = min(text.length, expectedLocation + needle.utf16.count + searchRadius) + guard upper >= lower else { return nil } + let match = text.range( + of: needle, + options: [], + range: NSRange(location: lower, length: upper - lower) + ) + return match.location == NSNotFound ? nil : match + } +} diff --git a/Sources/Output/CorrectionCaptureService.swift b/Sources/Output/CorrectionCaptureService.swift new file mode 100644 index 0000000..ac9e6fd --- /dev/null +++ b/Sources/Output/CorrectionCaptureService.swift @@ -0,0 +1,256 @@ +import AppKit +import ApplicationServices +import Foundation + +struct CorrectionCaptureSeed { + let processIdentifier: pid_t + let element: AXUIElement + let insertedText: String + let locator: CorrectionCaptureRegionLocator + let context: InputContext +} + +@MainActor +final class CorrectionCaptureService { + private static let lifetime: TimeInterval = 60 + private var activeSession: ActiveCorrectionCapture? + private var observer: AXObserver? + private var monitorTask: Task? + private var debounceTask: Task? + + func start(seed: CorrectionCaptureSeed, recordID: UUID) { + finishCurrentSession() + guard AppSettings.shared.enableCorrectionLearning, + CorrectionCapturePrivacyPolicy.isEligible( + bundleIdentifier: seed.context.bundleIdentifier, + appName: seed.context.appName, + element: seed.element + ) else { + return + } + + activeSession = ActiveCorrectionCapture( + seed: seed, + recordID: recordID, + expiresAt: Date().addingTimeInterval(Self.lifetime), + latestFinalText: seed.insertedText + ) + installObserver(for: seed) + startMonitor() + } + + func finishCurrentSession() { + guard AppSettings.shared.enableCorrectionLearning else { + tearDown() + return + } + captureLatestValue() + guard let session = activeSession else { + tearDown() + return + } + if session.latestFinalText != session.seed.insertedText, + let candidate = CorrectionCandidateClassifier.candidate( + inserted: session.seed.insertedText, + userFinal: session.latestFinalText, + sourceRecordID: session.recordID, + languageCode: session.seed.context.inputLanguage.whisperCode + ?? session.seed.context.inputLanguage.rawValue, + bundleIdentifier: session.seed.context.bundleIdentifier + ) { + PersonalDictionary.shared.recordLearnedCandidate(candidate) + Log.info("[CorrectionCapture] learned candidate \(candidate.original.count)->\(candidate.replacement.count) chars") + } + tearDown() + } + + func cancelCurrentSession() { + tearDown() + } + + fileprivate func handleValueChanged(_ element: AXUIElement) { + guard let session = activeSession, CFEqual(session.seed.element, element) else { return } + debounceTask?.cancel() + debounceTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 700_000_000) + guard !Task.isCancelled else { return } + self?.captureLatestValue() + } + } +} + +private extension CorrectionCaptureService { + struct ActiveCorrectionCapture { + let seed: CorrectionCaptureSeed + let recordID: UUID + let expiresAt: Date + var latestFinalText: String + } + + func installObserver(for seed: CorrectionCaptureSeed) { + var createdObserver: AXObserver? + guard AXObserverCreate( + seed.processIdentifier, + correctionCaptureObserverCallback, + &createdObserver + ) == .success, + let createdObserver else { + Log.info("[CorrectionCapture] AX observer unavailable; using bounded polling") + return + } + let pointer = Unmanaged.passUnretained(self).toOpaque() + guard AXObserverAddNotification( + createdObserver, + seed.element, + kAXValueChangedNotification as CFString, + pointer + ) == .success else { + Log.info("[CorrectionCapture] value notification unavailable; using bounded polling") + return + } + observer = createdObserver + CFRunLoopAddSource( + CFRunLoopGetMain(), + AXObserverGetRunLoopSource(createdObserver), + .commonModes + ) + } + + func startMonitor() { + monitorTask?.cancel() + monitorTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 1_000_000_000) + guard !Task.isCancelled, let self, let session = self.activeSession else { return } + guard AppSettings.shared.enableCorrectionLearning else { + self.cancelCurrentSession() + return + } + if Date() >= session.expiresAt { + self.finishCurrentSession() + return + } + let frontPID = NSWorkspace.shared.frontmostApplication?.processIdentifier + if frontPID != session.seed.processIdentifier + || !CorrectionCaptureAX.isFocused(session.seed.element, pid: session.seed.processIdentifier) { + self.finishCurrentSession() + return + } + self.captureLatestValue() + } + } + } + + func captureLatestValue() { + guard var session = activeSession, + let documentText = CorrectionCaptureAX.stringValue( + of: session.seed.element, + attribute: kAXValueAttribute as CFString + ), + let edited = session.seed.locator.editedText(in: documentText), + let associated = CorrectionObservationPolicy.associatedFinalText( + inserted: session.seed.insertedText, + edited: edited + ) else { + return + } + session.latestFinalText = associated + activeSession = session + InputHistory.shared.updateUserFinalText(recordID: session.recordID, text: associated) + } + + func tearDown() { + monitorTask?.cancel() + debounceTask?.cancel() + monitorTask = nil + debounceTask = nil + if let observer, let session = activeSession { + AXObserverRemoveNotification( + observer, + session.seed.element, + kAXValueChangedNotification as CFString + ) + CFRunLoopRemoveSource( + CFRunLoopGetMain(), + AXObserverGetRunLoopSource(observer), + .commonModes + ) + } + observer = nil + activeSession = nil + } +} + +private func correctionCaptureObserverCallback( + _: AXObserver, + element: AXUIElement, + _: CFString, + refcon: UnsafeMutableRawPointer? +) { + guard let refcon else { return } + let service = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + Task { @MainActor in + service.handleValueChanged(element) + } +} + +private enum CorrectionCaptureAX { + static func stringValue(of element: AXUIElement, attribute: CFString) -> String? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute, &value) == .success else { return nil } + return value as? String + } + + static func isFocused(_ element: AXUIElement, pid: pid_t) -> Bool { + let app = AXUIElementCreateApplication(pid) + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue( + app, + kAXFocusedUIElementAttribute as CFString, + &value + ) == .success, + let value, + CFGetTypeID(value) == AXUIElementGetTypeID() else { + return false + } + return CFEqual(element, value) + } +} + +enum CorrectionCapturePrivacyPolicy { + private static let blockedApps = [ + "terminal", "iterm", "warp", "ghostty", "alacritty", "wezterm", + "keychain", "1password", "bitwarden", "keepass", + ] + private static let blockedFieldHints = [ + "address bar", "address and search", "omnibox", "url field", "password", "secure", + ] + + static func isEligible( + bundleIdentifier: String?, + appName: String?, + element: AXUIElement + ) -> Bool { + let appText = [bundleIdentifier, appName] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + + let fieldText = [ + kAXRoleAttribute, + kAXSubroleAttribute, + kAXTitleAttribute, + kAXDescriptionAttribute, + kAXIdentifierAttribute, + ].compactMap { + CorrectionCaptureAX.stringValue(of: element, attribute: $0 as CFString)?.lowercased() + }.joined(separator: " ") + return !isBlocked(appText: appText, fieldText: fieldText) + } + + static func isBlocked(appText: String, fieldText: String) -> Bool { + let normalizedApp = appText.lowercased() + let normalizedField = fieldText.lowercased() + return blockedApps.contains(where: normalizedApp.contains) + || blockedFieldHints.contains(where: normalizedField.contains) + } +} diff --git a/Sources/Output/TextInserter+RecentInsertion.swift b/Sources/Output/TextInserter+RecentInsertion.swift index 23ad3fe..04d7588 100644 --- a/Sources/Output/TextInserter+RecentInsertion.swift +++ b/Sources/Output/TextInserter+RecentInsertion.swift @@ -121,6 +121,35 @@ extension TextInserter { func forgetRecentInsertion() { recentInsertionAnchor = nil } + + func correctionCaptureSeed( + expectedText: String, + context: InputContext + ) -> CorrectionCaptureSeed? { + guard let anchor = recentInsertionAnchor, + anchor.text == expectedText, + let currentText = value(of: anchor.element), + RecentInsertionGuard.isReplacementSafe( + sameTarget: true, + currentSelection: selectedRange(of: anchor.element), + insertedRange: anchor.range, + currentText: currentText, + inserted: expectedText + ), + let locator = CorrectionCaptureRegionLocator( + documentText: currentText, + insertedRange: anchor.range + ) else { + return nil + } + return CorrectionCaptureSeed( + processIdentifier: anchor.processIdentifier, + element: anchor.element, + insertedText: expectedText, + locator: locator, + context: context + ) + } } private extension TextInserter { diff --git a/Sources/Processing/CorrectionCandidateClassifier.swift b/Sources/Processing/CorrectionCandidateClassifier.swift new file mode 100644 index 0000000..cfdb124 --- /dev/null +++ b/Sources/Processing/CorrectionCandidateClassifier.swift @@ -0,0 +1,219 @@ +import Foundation + +struct CorrectionEditDiff: Equatable, Sendable { + let beforeSegment: String + let afterSegment: String + let commonPrefixCount: Int + let commonSuffixCount: Int + + static func between(_ before: String, _ after: String) -> CorrectionEditDiff? { + guard before != after else { return nil } + let beforeCharacters = Array(before) + let afterCharacters = Array(after) + let sharedLimit = min(beforeCharacters.count, afterCharacters.count) + + var prefix = 0 + while prefix < sharedLimit, beforeCharacters[prefix] == afterCharacters[prefix] { + prefix += 1 + } + + var suffix = 0 + while suffix < sharedLimit - prefix, + beforeCharacters[beforeCharacters.count - suffix - 1] + == afterCharacters[afterCharacters.count - suffix - 1] { + suffix += 1 + } + + return CorrectionEditDiff( + beforeSegment: String(beforeCharacters[prefix..<(beforeCharacters.count - suffix)]), + afterSegment: String(afterCharacters[prefix..<(afterCharacters.count - suffix)]), + commonPrefixCount: prefix, + commonSuffixCount: suffix + ) + } +} + +enum CorrectionObservationPolicy { + static func associatedFinalText(inserted: String, edited: String) -> String? { + guard let diff = CorrectionEditDiff.between(inserted, edited) else { return nil } + let insertedCount = inserted.count + let editedCount = edited.count + guard editedCount > 0, + editedCount <= max(256, insertedCount * 3), + diff.beforeSegment.count + diff.afterSegment.count <= max(64, insertedCount) else { + return nil + } + + let isAppend = diff.beforeSegment.isEmpty + && diff.commonPrefixCount == insertedCount + && diff.commonSuffixCount == 0 + let isPrepend = diff.beforeSegment.isEmpty + && diff.commonPrefixCount == 0 + && diff.commonSuffixCount == insertedCount + guard !isAppend, !isPrepend else { return nil } + return edited + } +} + +enum CorrectionCandidateClassifier { + static func candidate( + inserted: String, + userFinal: String, + sourceRecordID: UUID, + languageCode: String?, + bundleIdentifier: String? + ) -> LearnedCorrectionCandidate? { + guard let diff = CorrectionEditDiff.between(inserted, userFinal) else { return nil } + var original = diff.beforeSegment.trimmingCharacters(in: .whitespacesAndNewlines) + var replacement = diff.afterSegment.trimmingCharacters(in: .whitespacesAndNewlines) + guard !original.isEmpty, !replacement.isEmpty else { return nil } + + if containsASCIIWord(original) || containsASCIIWord(replacement) { + let expanded = expandedASCIISegments( + before: inserted, + after: userFinal, + prefixCount: diff.commonPrefixCount, + suffixCount: diff.commonSuffixCount + ) + original = expanded.before.trimmingCharacters(in: .whitespacesAndNewlines) + replacement = expanded.after.trimmingCharacters(in: .whitespacesAndNewlines) + } + + guard isEligibleTerm(original), isEligibleTerm(replacement) else { return nil } + let originalLexical = lexicalForm(original) + let replacementLexical = lexicalForm(replacement) + guard !originalLexical.isEmpty, + !replacementLexical.isEmpty, + originalLexical != replacementLexical else { + return nil + } + + let caseOrSpacingOnly = originalLexical.lowercased() == replacementLexical.lowercased() + let hasMultipleHunks = hasMeaningfulSharedInterior( + diff.beforeSegment, + diff.afterSegment + ) + guard caseOrSpacingOnly || !hasMultipleHunks else { + return nil + } + guard !isCommonFunctionWord(original), !isCommonFunctionWord(replacement) else { return nil } + + return LearnedCorrectionCandidate( + original: original, + replacement: replacement, + confidence: confidence(for: replacement, caseOrSpacingOnly: caseOrSpacingOnly), + sourceRecordID: sourceRecordID, + languageCode: languageCode, + bundleIdentifier: bundleIdentifier + ) + } +} + +private extension CorrectionCandidateClassifier { + static let commonWords: Set = [ + "a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "from", + "he", "her", "his", "i", "in", "is", "it", "me", "my", "of", "on", "or", + "she", "that", "the", "their", "they", "this", "to", "we", "with", "you", "your", + "的", "了", "和", "是", "我", "你", "他", "她", "它", "在", "有", "就", "也", "都", + "而", "及", "与", "着", "或", "一个", "这个", "那个", + ] + + static func isEligibleTerm(_ text: String) -> Bool { + let wordCount = text.split { $0.isWhitespace || $0.isPunctuation }.count + guard text.count <= 48, + wordCount <= 4, + !text.contains("\n"), + !text.contains("\r"), + !text.allSatisfy({ $0.isNumber || $0.isWhitespace || $0.isPunctuation }), + !containsSensitivePattern(text) else { + return false + } + return true + } + + static func containsSensitivePattern(_ text: String) -> Bool { + let patterns = [ + #"(?i)https?://|www\."#, + #"\b[^\s@]+@[^\s@]+\.[^\s@]+\b"#, + #"(?:^|\s)(?:/|~/|\\)[^\s]+"#, + #"(?i)\b(?:api[_-]?key|token|password|secret|bearer)\b"#, + #"\b[A-Fa-f0-9]{24,}\b"#, + ] + return patterns.contains { + text.range(of: $0, options: .regularExpression) != nil + } + } + + static func isCommonFunctionWord(_ text: String) -> Bool { + commonWords.contains(text.lowercased()) + } + + static func lexicalForm(_ text: String) -> String { + String(text.filter { $0.isLetter || $0.isNumber }) + } + + static func containsASCIIWord(_ text: String) -> Bool { + text.contains { $0.isASCIIWord } + } + + static func expandedASCIISegments( + before: String, + after: String, + prefixCount: Int, + suffixCount: Int + ) -> (before: String, after: String) { + let beforeCharacters = Array(before) + let afterCharacters = Array(after) + let beforeRange = expandedASCIIWordRange( + in: beforeCharacters, + start: prefixCount, + end: beforeCharacters.count - suffixCount + ) + let afterRange = expandedASCIIWordRange( + in: afterCharacters, + start: prefixCount, + end: afterCharacters.count - suffixCount + ) + return ( + String(beforeCharacters[beforeRange]), + String(afterCharacters[afterRange]) + ) + } + + static func expandedASCIIWordRange( + in characters: [Character], + start requestedStart: Int, + end requestedEnd: Int + ) -> Range { + var start = max(0, min(requestedStart, characters.count)) + var end = max(start, min(requestedEnd, characters.count)) + while start > 0, characters[start - 1].isASCIIWord { start -= 1 } + while end < characters.count, characters[end].isASCIIWord { end += 1 } + return start.. Bool { + let left = Array(lhs.lowercased()) + let right = Array(rhs.lowercased()) + guard left.count >= 2, right.count >= 2 else { return false } + for start in left.indices { + guard start + 1 < left.count else { continue } + let pair = String(left[start...start + 1]) + if pair.allSatisfy({ $0.isLetter || $0.isNumber }), rhs.lowercased().contains(pair) { + return true + } + } + return false + } + + static func confidence(for replacement: String, caseOrSpacingOnly: Bool) -> Double { + if caseOrSpacingOnly { return 0.98 } + let letters = replacement.filter(\.isLetter) + let hasUppercase = letters.contains(where: { $0.isUppercase }) + let hasLowercase = letters.contains(where: { $0.isLowercase }) + let isAcronym = letters.count >= 2 && letters.allSatisfy(\.isUppercase) + let isCamelCase = hasUppercase && hasLowercase && !replacement.contains(" ") + let hasMixedDigits = replacement.contains(where: \.isNumber) && !letters.isEmpty + return isAcronym || isCamelCase || hasMixedDigits ? 0.95 : 0.82 + } +} diff --git a/Sources/Processing/DictionaryEntry.swift b/Sources/Processing/DictionaryEntry.swift new file mode 100644 index 0000000..742a9e8 --- /dev/null +++ b/Sources/Processing/DictionaryEntry.swift @@ -0,0 +1,91 @@ +import Foundation + +enum DictionaryEntryOrigin: String, Codable, CaseIterable, Sendable { + case manual + case learned +} +enum DictionaryEntryStatus: String, Codable, CaseIterable, Sendable { + case active + case pending +} + +struct DictionaryEntry: Codable, Identifiable, Sendable { + var id: UUID + var original: String + var replacement: String + var enabled: Bool + var origin: DictionaryEntryOrigin + var status: DictionaryEntryStatus + var confidence: Double + var evidenceCount: Int + var createdAt: Date + var lastSeenAt: Date? + var languageCode: String? + var appScopes: [String] + var evidenceRecordIDs: [UUID] + + var isEffective: Bool { + enabled && status == .active + } + + init( + id: UUID = UUID(), + original: String, + replacement: String, + enabled: Bool = true, + origin: DictionaryEntryOrigin = .manual, + status: DictionaryEntryStatus = .active, + confidence: Double = 1, + evidenceCount: Int = 1, + createdAt: Date = Date(), + lastSeenAt: Date? = nil, + languageCode: String? = nil, + appScopes: [String] = [], + evidenceRecordIDs: [UUID] = [] + ) { + self.id = id + self.original = original + self.replacement = replacement + self.enabled = enabled + self.origin = origin + self.status = status + self.confidence = confidence + self.evidenceCount = evidenceCount + self.createdAt = createdAt + self.lastSeenAt = lastSeenAt + self.languageCode = languageCode + self.appScopes = appScopes + self.evidenceRecordIDs = evidenceRecordIDs + } + + private enum CodingKeys: String, CodingKey { + case id, original, replacement, enabled, origin, status, confidence + case evidenceCount, createdAt, lastSeenAt, languageCode, appScopes, evidenceRecordIDs + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + original = try container.decode(String.self, forKey: .original) + replacement = try container.decode(String.self, forKey: .replacement) + enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? true + origin = try container.decodeIfPresent(DictionaryEntryOrigin.self, forKey: .origin) ?? .manual + status = try container.decodeIfPresent(DictionaryEntryStatus.self, forKey: .status) ?? .active + confidence = try container.decodeIfPresent(Double.self, forKey: .confidence) ?? 1 + evidenceCount = try container.decodeIfPresent(Int.self, forKey: .evidenceCount) ?? 1 + createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? .distantPast + lastSeenAt = try container.decodeIfPresent(Date.self, forKey: .lastSeenAt) + languageCode = try container.decodeIfPresent(String.self, forKey: .languageCode) + appScopes = try container.decodeIfPresent([String].self, forKey: .appScopes) ?? [] + evidenceRecordIDs = try container.decodeIfPresent([UUID].self, forKey: .evidenceRecordIDs) ?? [] + } +} + +struct LearnedCorrectionCandidate: Equatable, Sendable { + let original: String + let replacement: String + let confidence: Double + let sourceRecordID: UUID + let languageCode: String? + let bundleIdentifier: String? +} diff --git a/Sources/Processing/InputHistory.swift b/Sources/Processing/InputHistory.swift index 908f68b..44a3a13 100644 --- a/Sources/Processing/InputHistory.swift +++ b/Sources/Processing/InputHistory.swift @@ -9,19 +9,43 @@ struct InputRecord: Codable, Identifiable { let processedCharCount: Int let wasProcessed: Bool let context: InputContext? + let userFinalText: String? + let formatKind: TextFormatKind? - init(rawText: String, processedText: String, wasProcessed: Bool, context: InputContext? = nil) { + var displayText: String { + userFinalText ?? processedText + } + + init( + rawText: String, + processedText: String, + wasProcessed: Bool, + context: InputContext? = nil, + userFinalText: String? = nil, + formatKind: TextFormatKind? = nil + ) { self.init( id: UUID(), date: Date(), rawText: rawText, processedText: processedText, wasProcessed: wasProcessed, - context: context + context: context, + userFinalText: userFinalText, + formatKind: formatKind ) } - init(id: UUID, date: Date, rawText: String, processedText: String, wasProcessed: Bool, context: InputContext? = nil) { + init( + id: UUID, + date: Date, + rawText: String, + processedText: String, + wasProcessed: Bool, + context: InputContext? = nil, + userFinalText: String? = nil, + formatKind: TextFormatKind? = nil + ) { self.id = id self.date = date self.rawText = rawText @@ -30,10 +54,13 @@ struct InputRecord: Codable, Identifiable { self.processedCharCount = processedText.count self.wasProcessed = wasProcessed self.context = context + self.userFinalText = userFinalText + self.formatKind = formatKind } enum CodingKeys: String, CodingKey { case id, date, rawText, processedText, rawCharCount, processedCharCount, wasProcessed, context + case userFinalText, formatKind } init(from decoder: Decoder) throws { @@ -46,6 +73,8 @@ struct InputRecord: Codable, Identifiable { processedCharCount = try container.decodeIfPresent(Int.self, forKey: .processedCharCount) ?? processedText.count wasProcessed = try container.decode(Bool.self, forKey: .wasProcessed) context = try container.decodeIfPresent(InputContext.self, forKey: .context) + userFinalText = try container.decodeIfPresent(String.self, forKey: .userFinalText) + formatKind = try container.decodeIfPresent(TextFormatKind.self, forKey: .formatKind) } func matchesSearch(_ query: String) -> Bool { @@ -55,6 +84,7 @@ struct InputRecord: Codable, Identifiable { return [ rawText, processedText, + userFinalText, context?.appName, context?.bundleIdentifier, context?.windowTitle, @@ -98,20 +128,46 @@ final class InputHistory: ObservableObject { pruneExpired() } - func addRecord(rawText: String, processedText: String, wasProcessed: Bool, context: InputContext? = nil) { - let record = InputRecord(rawText: rawText, processedText: processedText, wasProcessed: wasProcessed, context: context) + @discardableResult + func addRecord( + rawText: String, + processedText: String, + wasProcessed: Bool, + context: InputContext? = nil, + formatKind: TextFormatKind? = nil + ) -> UUID { + let record = InputRecord( + rawText: rawText, + processedText: processedText, + wasProcessed: wasProcessed, + context: context, + formatKind: formatKind + ) records.insert(record, at: 0) if records.count > Self.maxRecords { records = Array(records.prefix(Self.maxRecords)) } pruneExpired() save() + return record.id } - func replaceLatestRecord(rawText: String, processedText: String, wasProcessed: Bool, context: InputContext? = nil) { + @discardableResult + func replaceLatestRecord( + rawText: String, + processedText: String, + wasProcessed: Bool, + context: InputContext? = nil, + formatKind: TextFormatKind? = nil + ) -> UUID { guard let latest = records.first, latest.rawText == rawText else { - addRecord(rawText: rawText, processedText: processedText, wasProcessed: wasProcessed, context: context) - return + return addRecord( + rawText: rawText, + processedText: processedText, + wasProcessed: wasProcessed, + context: context, + formatKind: formatKind + ) } records[0] = InputRecord( @@ -120,7 +176,27 @@ final class InputHistory: ObservableObject { rawText: rawText, processedText: processedText, wasProcessed: wasProcessed, - context: context ?? latest.context + context: context ?? latest.context, + formatKind: formatKind ?? latest.formatKind + ) + save() + return latest.id + } + + func updateUserFinalText(recordID: UUID, text: String) { + guard let index = records.firstIndex(where: { $0.id == recordID }) else { return } + let record = records[index] + let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) + let finalText = normalized.isEmpty || normalized == record.processedText ? nil : normalized + records[index] = InputRecord( + id: record.id, + date: record.date, + rawText: record.rawText, + processedText: record.processedText, + wasProcessed: record.wasProcessed, + context: record.context, + userFinalText: finalText, + formatKind: record.formatKind ) save() } @@ -157,7 +233,7 @@ final class InputHistory: ObservableObject { totalProcessedChars: totalProcessed, charsSaved: max(0, totalRaw - totalProcessed), todayInputs: todayRecords.count, - todayChars: todayRecords.reduce(0) { $0 + $1.processedCharCount }, + todayChars: todayRecords.reduce(0) { $0 + $1.displayText.count }, streakDays: streak ) } diff --git a/Sources/Processing/MemoryStore.swift b/Sources/Processing/MemoryStore.swift index e653ce7..f49442c 100644 --- a/Sources/Processing/MemoryStore.swift +++ b/Sources/Processing/MemoryStore.swift @@ -33,7 +33,7 @@ enum MemoryStore { guard !selected.isEmpty else { return "" } let lines = selected.map { record in - let text = record.wasProcessed ? record.processedText : record.rawText + let text = record.userFinalText ?? (record.wasProcessed ? record.processedText : record.rawText) return "[\(formatTime(record.date))\(formatContext(record.context))] \(text)" } diff --git a/Sources/Processing/PersonalDictionary+Learning.swift b/Sources/Processing/PersonalDictionary+Learning.swift new file mode 100644 index 0000000..abc7d3f --- /dev/null +++ b/Sources/Processing/PersonalDictionary+Learning.swift @@ -0,0 +1,101 @@ +import Foundation + +extension PersonalDictionary { + func clearLearnedEntries() { + entries.removeAll { $0.origin == .learned } + save() + } + + @discardableResult + func recordLearnedCandidate(_ candidate: LearnedCorrectionCandidate) -> UUID? { + removePreviousEvidence(for: candidate) + + if entries.contains(where: { + $0.origin == .manual + && $0.original.caseInsensitiveCompare(candidate.original) == .orderedSame + }) { + save() + return nil + } + + let now = Date() + let hasConflict = entries.contains { + $0.origin == .learned + && $0.original.caseInsensitiveCompare(candidate.original) == .orderedSame + && $0.replacement.caseInsensitiveCompare(candidate.replacement) != .orderedSame + } + if let index = entries.firstIndex(where: { + $0.origin == .learned + && $0.original.caseInsensitiveCompare(candidate.original) == .orderedSame + && $0.replacement.caseInsensitiveCompare(candidate.replacement) == .orderedSame + }) { + merge(candidate, intoEntryAt: index, now: now) + if hasConflict { markLearnedMappingsPending(for: candidate.original) } + save() + return entries[index].id + } + + if hasConflict { markLearnedMappingsPending(for: candidate.original) } + + let entry = DictionaryEntry( + original: candidate.original, + replacement: candidate.replacement, + origin: .learned, + status: candidate.confidence >= 0.92 && !hasConflict ? .active : .pending, + confidence: candidate.confidence, + evidenceCount: 1, + lastSeenAt: now, + languageCode: candidate.languageCode, + appScopes: candidate.bundleIdentifier.map { [$0] } ?? [], + evidenceRecordIDs: [candidate.sourceRecordID] + ) + entries.append(entry) + save() + return entry.id + } +} + +private extension PersonalDictionary { + func merge(_ candidate: LearnedCorrectionCandidate, intoEntryAt index: Int, now: Date) { + if !entries[index].evidenceRecordIDs.contains(candidate.sourceRecordID) { + entries[index].evidenceRecordIDs.append(candidate.sourceRecordID) + } + entries[index].evidenceCount = max( + entries[index].evidenceCount, + entries[index].evidenceRecordIDs.count + ) + entries[index].confidence = max(entries[index].confidence, candidate.confidence) + entries[index].lastSeenAt = now + entries[index].languageCode = candidate.languageCode ?? entries[index].languageCode + if let bundleIdentifier = candidate.bundleIdentifier, + !entries[index].appScopes.contains(bundleIdentifier) { + entries[index].appScopes.append(bundleIdentifier) + } + if entries[index].confidence >= 0.92 || entries[index].evidenceCount >= 2 { + entries[index].status = .active + } + } + + func removePreviousEvidence(for candidate: LearnedCorrectionCandidate) { + for index in entries.indices.reversed() where entries[index].origin == .learned { + guard let evidenceIndex = entries[index].evidenceRecordIDs.firstIndex( + of: candidate.sourceRecordID + ) else { continue } + let sameMapping = entries[index].original.caseInsensitiveCompare(candidate.original) == .orderedSame + && entries[index].replacement.caseInsensitiveCompare(candidate.replacement) == .orderedSame + guard !sameMapping else { continue } + entries[index].evidenceRecordIDs.remove(at: evidenceIndex) + entries[index].evidenceCount = entries[index].evidenceRecordIDs.count + if entries[index].evidenceCount == 0, entries[index].status == .pending { + entries.remove(at: index) + } + } + } + + func markLearnedMappingsPending(for original: String) { + for index in entries.indices where entries[index].origin == .learned + && entries[index].original.caseInsensitiveCompare(original) == .orderedSame { + entries[index].status = .pending + } + } +} diff --git a/Sources/Processing/PersonalDictionary+Transfer.swift b/Sources/Processing/PersonalDictionary+Transfer.swift new file mode 100644 index 0000000..bf4a1d7 --- /dev/null +++ b/Sources/Processing/PersonalDictionary+Transfer.swift @@ -0,0 +1,38 @@ +import Foundation + +extension PersonalDictionary { + func exportData() throws -> Data { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(entries) + } + + @discardableResult + func importEntries(from data: Data) throws -> Int { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let imported = try decoder.decode([DictionaryEntry].self, from: data) + var count = 0 + for entry in imported { + guard !normalizedImportedTerm(entry.original).isEmpty, + !normalizedImportedTerm(entry.replacement).isEmpty else { continue } + if let index = entries.firstIndex(where: { $0.id == entry.id }) { + entries[index] = entry + } else if let index = entries.firstIndex(where: { + $0.original.caseInsensitiveCompare(entry.original) == .orderedSame + }) { + entries[index] = entry + } else { + entries.append(entry) + } + count += 1 + } + save() + return count + } +} +private func normalizedImportedTerm(_ text: String) -> String { + text.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) +} diff --git a/Sources/Processing/PersonalDictionary.swift b/Sources/Processing/PersonalDictionary.swift index c7ac4b3..70200eb 100644 --- a/Sources/Processing/PersonalDictionary.swift +++ b/Sources/Processing/PersonalDictionary.swift @@ -1,12 +1,5 @@ import Foundation -struct DictionaryEntry: Codable, Identifiable, Sendable { - var id = UUID() - var original: String - var replacement: String - var enabled: Bool = true -} - struct EditRule: Codable, Identifiable, Sendable { var id = UUID() var description: String @@ -21,7 +14,7 @@ struct PersonalDictionarySnapshot: Sendable { let rules = entries.enumerated().compactMap { offset, entry -> ReplacementRule? in let original = entry.original let replacement = entry.replacement - guard entry.enabled, + guard entry.isEffective, !original.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } @@ -59,7 +52,7 @@ struct PersonalDictionarySnapshot: Sendable { var activeEntriesDescription: String { entries - .filter(\.enabled) + .filter(\.isEffective) .compactMap { entry -> String? in let original = entry.original.trimmingCharacters(in: .whitespacesAndNewlines) let replacement = entry.replacement.trimmingCharacters(in: .whitespacesAndNewlines) @@ -80,7 +73,7 @@ struct PersonalDictionarySnapshot: Sendable { var protectedTerms: [String] { var seen = Set() return entries.compactMap { entry in - guard entry.enabled else { return nil } + guard entry.isEffective else { return nil } let term = entry.replacement.trimmingCharacters(in: .whitespacesAndNewlines) guard !term.isEmpty, seen.insert(term.lowercased()).inserted else { @@ -130,9 +123,11 @@ final class PersonalDictionary: ObservableObject { private let entriesURL: URL private let rulesURL: URL - private init() { - let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - .appendingPathComponent("OpenType", isDirectory: true) + init(directoryURL: URL? = nil) { + let dir = directoryURL ?? FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first!.appendingPathComponent("OpenType", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) entriesURL = dir.appendingPathComponent("dictionary.json") @@ -156,9 +151,30 @@ final class PersonalDictionary: ObservableObject { PersonalDictionarySnapshot(entries: entries, editRules: editRules) } - func addEntry(original: String, replacement: String) { - entries.append(DictionaryEntry(original: original, replacement: replacement)) + @discardableResult + func addEntry(original: String, replacement: String) -> UUID? { + let original = normalized(original) + let replacement = normalized(replacement) + guard !original.isEmpty, !replacement.isEmpty, original != replacement else { return nil } + + if let index = entries.firstIndex(where: { + $0.original.caseInsensitiveCompare(original) == .orderedSame + }) { + entries[index].original = original + entries[index].replacement = replacement + entries[index].enabled = true + entries[index].origin = .manual + entries[index].status = .active + entries[index].confidence = 1 + entries[index].evidenceCount = max(1, entries[index].evidenceCount) + save() + return entries[index].id + } + + let entry = DictionaryEntry(original: original, replacement: replacement) + entries.append(entry) save() + return entry.id } func removeEntry(at offsets: IndexSet) { @@ -166,6 +182,40 @@ final class PersonalDictionary: ObservableObject { save() } + func removeEntry(id: UUID) { + entries.removeAll { $0.id == id } + save() + } + + func updateEntry(id: UUID, original: String, replacement: String) { + guard let index = entries.firstIndex(where: { $0.id == id }) else { return } + let original = normalized(original) + let replacement = normalized(replacement) + guard !original.isEmpty, !replacement.isEmpty, original != replacement else { return } + entries[index].original = original + entries[index].replacement = replacement + save() + } + + func setEntryEnabled(id: UUID, enabled: Bool) { + guard let index = entries.firstIndex(where: { $0.id == id }) else { return } + entries[index].enabled = enabled + save() + } + + func approveEntry(id: UUID) { + guard let index = entries.firstIndex(where: { $0.id == id }) else { return } + let original = entries[index].original + for otherIndex in entries.indices where otherIndex != index + && entries[otherIndex].origin == .learned + && entries[otherIndex].original.caseInsensitiveCompare(original) == .orderedSame { + entries[otherIndex].status = .pending + } + entries[index].status = .active + entries[index].enabled = true + save() + } + func addRule(description: String) { editRules.append(EditRule(description: description)) save() @@ -178,26 +228,34 @@ final class PersonalDictionary: ObservableObject { func save() { let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] if let data = try? encoder.encode(entries) { - try? data.write(to: entriesURL) + try? data.write(to: entriesURL, options: .atomic) } if let data = try? encoder.encode(editRules) { - try? data.write(to: rulesURL) + try? data.write(to: rulesURL, options: .atomic) } } private func load() { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 if let data = try? Data(contentsOf: entriesURL), - let decoded = try? JSONDecoder().decode([DictionaryEntry].self, from: data) { + let decoded = try? decoder.decode([DictionaryEntry].self, from: data) { entries = decoded } if let data = try? Data(contentsOf: rulesURL), - let decoded = try? JSONDecoder().decode([EditRule].self, from: data) { + let decoded = try? decoder.decode([EditRule].self, from: data) { editRules = decoded } } + private func normalized(_ text: String) -> String { + text.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + } private struct ReplacementRule { diff --git a/Sources/Processing/TextFormatKind.swift b/Sources/Processing/TextFormatKind.swift new file mode 100644 index 0000000..e8d9f74 --- /dev/null +++ b/Sources/Processing/TextFormatKind.swift @@ -0,0 +1,121 @@ +import Foundation + +enum TextFormatKind: String, Codable, CaseIterable, Sendable { + case plainParagraph + case unorderedList + case orderedSteps + case email + case chat + case codeOrTerminal +} + +struct TextFormatDecision: Equatable, Sendable { + enum Reason: String, Sendable { + case explicitSequence + case explicitList + case emailStructure + case emailApplication + case chatApplication + case codeApplication + case defaultParagraph + } + + let kind: TextFormatKind + let reason: Reason +} + +enum TextFormatClassifier { + static func classify(text: String, context: InputContext?) -> TextFormatDecision { + let normalized = text + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let applicationIdentity = [context?.bundleIdentifier, context?.appName] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + + if matchesAny(codeAppPatterns, in: applicationIdentity) { + return TextFormatDecision(kind: .codeOrTerminal, reason: .codeApplication) + } + + if hasExplicitSequence(normalized) { + return TextFormatDecision(kind: .orderedSteps, reason: .explicitSequence) + } + if matchesAny(explicitListPatterns, in: normalized) { + return TextFormatDecision(kind: .unorderedList, reason: .explicitList) + } + if hasEmailStructure(normalized) { + return TextFormatDecision(kind: .email, reason: .emailStructure) + } + + let target = [context?.bundleIdentifier, context?.appName, context?.windowTitle] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + if matchesAny(emailAppPatterns, in: target) { + return TextFormatDecision(kind: .email, reason: .emailApplication) + } + if matchesAny(chatAppPatterns, in: target) { + return TextFormatDecision(kind: .chat, reason: .chatApplication) + } + return TextFormatDecision(kind: .plainParagraph, reason: .defaultParagraph) + } +} + +private extension TextFormatClassifier { + static let explicitListPatterns = [ + "购物清单", "采购清单", "待办清单", "列个清单", "列一下", "清单包括", + "有几件事", "有三件事", "有四件事", "主要包括", "分别是", + "shopping list", "grocery list", "todo list", "to-do list", "the list includes", + "here are the items", "there are three things", "key points are", + "買い物リスト", "チェックリスト", "목록", "장보기 목록", + ] + static let emailAppPatterns = [ + "com.apple.mail", "outlook", "thunderbird", "spark", "airmail", "gmail", + ] + static let chatAppPatterns = [ + "slack", "discord", "messages", "whatsapp", "telegram", "wechat", "weixin", + "lark", "feishu", "teams", "signal", "line", + ] + static let codeAppPatterns = [ + "terminal", "iterm", "warp", "ghostty", "alacritty", "wezterm", "xcode", + "visual studio code", "vscode", "zed", "sublime text", "jetbrains", + ] + + static func hasExplicitSequence(_ text: String) -> Bool { + let groups = [ + ["第一", "首先", "第一步", "一是"], + ["第二", "其次", "第二步", "二是"], + ["first", "firstly", "first step", "step one"], + ["second", "secondly", "second step", "step two"], + ["まず", "第一", "ステップ1"], + ["次に", "第二", "ステップ2"], + ["첫째", "먼저", "1단계"], + ["둘째", "다음", "2단계"], + ] + let chineseOrEnglish = groups[0].contains(where: text.contains) + && groups[1].contains(where: text.contains) + || groups[2].contains(where: { containsWord($0, in: text) }) + && groups[3].contains(where: { containsWord($0, in: text) }) + let japanese = groups[4].contains(where: text.contains) && groups[5].contains(where: text.contains) + let korean = groups[6].contains(where: text.contains) && groups[7].contains(where: text.contains) + return chineseOrEnglish || japanese || korean + } + + static func hasEmailStructure(_ text: String) -> Bool { + let greetings = ["hi ", "hello ", "dear ", "hey ", "你好", "您好", "嗨", "亲爱的", "こんにちは", "안녕하세요"] + let closings = ["thanks", "thank you", "regards", "best", "sincerely", "谢谢", "感谢", "祝好", "此致", "よろしく", "감사합니다"] + return greetings.contains(where: text.hasPrefix) + && closings.contains(where: text.contains) + } + + static func matchesAny(_ patterns: [String], in text: String) -> Bool { + patterns.contains(where: text.contains) + } + + static func containsWord(_ word: String, in text: String) -> Bool { + text.range( + of: "\\b\(NSRegularExpression.escapedPattern(for: word))\\b", + options: [.regularExpression, .caseInsensitive] + ) != nil + } +} diff --git a/Sources/Processing/TextProcessor+PromptConstruction.swift b/Sources/Processing/TextProcessor+PromptConstruction.swift index dfece8c..1274178 100644 --- a/Sources/Processing/TextProcessor+PromptConstruction.swift +++ b/Sources/Processing/TextProcessor+PromptConstruction.swift @@ -25,6 +25,7 @@ extension TextProcessor { screenImageAvailable: Bool, memoryContext: String, inputContext: InputContext?, + formatKind: TextFormatKind? = nil, dictionarySnapshot: PersonalDictionarySnapshot? = nil ) -> String { systemPromptWithPersonalContext( @@ -35,6 +36,7 @@ extension TextProcessor { screenImageAvailable: screenImageAvailable, memoryContext: memoryContext, inputContext: inputContext, + formatKind: formatKind, inputLanguage: options.inputLanguage, useCustomSystemPrompt: options.useCustomSystemPrompt, customSystemPrompt: options.customSystemPrompt diff --git a/Sources/Processing/TextProcessor.swift b/Sources/Processing/TextProcessor.swift index 7e9b678..dfa4193 100644 --- a/Sources/Processing/TextProcessor.swift +++ b/Sources/Processing/TextProcessor.swift @@ -62,6 +62,7 @@ final class TextProcessor { screenImage: CGImage? = nil, memoryContext: String = "", inputContext: InputContext? = nil, + formatKind: TextFormatKind? = nil, allowsPreparedFallback: Bool = TextProcessor.defaultAllowsPreparedFallback, allowsGuardFallback: Bool = true ) async -> String { @@ -76,6 +77,7 @@ final class TextProcessor { screenImage: screenImage, memoryContext: memoryContext, inputContext: inputContext, + formatKind: formatKind, allowsPreparedFallback: allowsPreparedFallback, allowsGuardFallback: allowsGuardFallback ) @@ -88,6 +90,7 @@ final class TextProcessor { screenImage: CGImage? = nil, memoryContext: String = "", inputContext: InputContext? = nil, + formatKind: TextFormatKind? = nil, allowsPreparedFallback: Bool = TextProcessor.defaultAllowsPreparedFallback, allowsGuardFallback: Bool = true, dictionarySnapshot requestedDictionarySnapshot: PersonalDictionarySnapshot? = nil @@ -110,6 +113,7 @@ final class TextProcessor { screenImageAvailable: useScreenImage, memoryContext: memoryContext, inputContext: inputContext, + formatKind: formatKind, dictionarySnapshot: dictionarySnapshot ) @@ -140,6 +144,7 @@ final class TextProcessor { screenImageAvailable: false, memoryContext: memoryContext, inputContext: inputContext, + formatKind: formatKind, dictionarySnapshot: dictionarySnapshot ) result = try await generateText( diff --git a/Sources/Prompts/PromptBuilder.swift b/Sources/Prompts/PromptBuilder.swift index 110e2f9..4a29f77 100644 --- a/Sources/Prompts/PromptBuilder.swift +++ b/Sources/Prompts/PromptBuilder.swift @@ -8,6 +8,7 @@ enum PromptBuilder { screenImageAvailable: Bool = false, memoryContext: String = "", inputContext: InputContext? = nil, + formatKind: TextFormatKind? = nil, inputLanguage: InputLanguage = .chinese, useCustomSystemPrompt: Bool? = nil, customSystemPrompt: String? = nil @@ -21,6 +22,12 @@ enum PromptBuilder { stylePrompt: stylePrompt, inputLanguage: inputLanguage ) + if let formatKind { + parts.append(PromptCatalog.formatContractSection( + kind: formatKind, + inputLanguage: inputLanguage + )) + } parts.append(contentsOf: PromptCatalog.processingContextSections( screenContext: screenContext, screenImageAvailable: screenImageAvailable, diff --git a/Sources/Prompts/PromptCatalog+FormatKind.swift b/Sources/Prompts/PromptCatalog+FormatKind.swift new file mode 100644 index 0000000..79efc95 --- /dev/null +++ b/Sources/Prompts/PromptCatalog+FormatKind.swift @@ -0,0 +1,90 @@ +import Foundation + +extension PromptCatalog { + static func formatContractSection( + kind: TextFormatKind, + inputLanguage: InputLanguage + ) -> String { + switch inputLanguage { + case .auto, .chinese, .cantonese: + return chineseFormatContract(kind) + case .english: + return englishFormatContract(kind) + case .japanese: + return japaneseFormatContract(kind) + case .korean: + return koreanFormatContract(kind) + } + } +} +private extension PromptCatalog { + static func chineseFormatContract(_ kind: TextFormatKind) -> String { + let rule: String + switch kind { + case .plainParagraph: + rule = "输出自然段落;只在话题明显切换时换段,不要列点、编号或添加标题。" + case .unorderedList: + rule = "输出无序清单;每项独立一行并使用“- ”。只有原文明说标题时才保留标题,绝对不要改成编号步骤。" + case .orderedSteps: + rule = "输出有序步骤;每一步独立一行并使用“1. 2. 3.”。保持原文顺序,不新增步骤。" + case .email: + rule = "按邮件排版:称呼、正文自然段、结束语、署名之间换行;原文没说的称呼、结束语或署名不得补写。" + case .chat: + rule = "按聊天消息排版:短句、短段、自然语气;不要套用邮件格式,不要添加标题。" + case .codeOrTerminal: + rule = "按代码或终端文本处理:逐字保护命令、路径、URL、大小写、符号和换行;不要使用智能引号或自然语言列表改写。" + } + return """ + 本次已判定的输出类型:\(kind.rawValue)。严格遵守对应排版契约,不要自行切换类型: + - \(rule) + - 排版只能改变结构和标点,不能增加、删除或改写原文事实。 + """ + } + + static func englishFormatContract(_ kind: TextFormatKind) -> String { + let rule: String + switch kind { + case .plainParagraph: + rule = "Use natural paragraphs. Break only on a clear topic shift; do not add bullets, numbering, or a heading." + case .unorderedList: + rule = "Use an unordered list with one '- ' item per line. Keep a heading only if it was dictated. Never turn the items into numbered steps." + case .orderedSteps: + rule = "Use ordered steps with one '1. 2. 3.' item per line. Preserve the dictated order and add no steps." + case .email: + rule = "Use email layout with separate greeting, body paragraphs, closing, and signature. Never invent a missing greeting, closing, or signature." + case .chat: + rule = "Use compact chat layout with short natural paragraphs. Do not add email conventions or a heading." + case .codeOrTerminal: + rule = "Preserve commands, paths, URLs, casing, symbols, and line breaks exactly. Do not use smart quotes or rewrite code as prose." + } + return """ + Audited output type for this request: \(kind.rawValue). Follow its contract and do not switch types: + - \(rule) + - Formatting may change structure and punctuation only; it must not change the dictated facts. + """ + } + + static func japaneseFormatContract(_ kind: TextFormatKind) -> String { + let rules: [TextFormatKind: String] = [ + .plainParagraph: "自然な段落にし、明確な話題転換だけで改段する。箇条書き、番号、見出しを追加しない。", + .unorderedList: "各項目を「- 」で別行にした番号なしリストにする。口述されていない見出しを追加せず、番号付き手順に変えない。", + .orderedSteps: "各手順を 1. 2. 3. の別行にする。順序を保ち、手順を追加しない。", + .email: "挨拶、本文、結び、署名を改行で分ける。口述されていない要素は追加しない。", + .chat: "短い自然なチャット文にし、メール形式や見出しを追加しない。", + .codeOrTerminal: "コマンド、パス、URL、大文字小文字、記号、改行を正確に保護する。", + ] + return "今回の出力タイプは \(kind.rawValue)。タイプを変更せず、この契約に従う:\(rules[kind] ?? "") 内容の事実は変更しない。" + } + + static func koreanFormatContract(_ kind: TextFormatKind) -> String { + let rules: [TextFormatKind: String] = [ + .plainParagraph: "자연스러운 문단으로 쓰고 명확한 주제 전환에서만 줄을 바꾼다. 목록, 번호, 제목을 추가하지 않는다.", + .unorderedList: "각 항목을 '- '로 시작하는 별도 줄에 쓴다. 말하지 않은 제목을 추가하거나 번호 단계로 바꾸지 않는다.", + .orderedSteps: "각 단계를 1. 2. 3. 별도 줄에 쓴다. 순서를 유지하고 단계를 추가하지 않는다.", + .email: "인사말, 본문, 맺음말, 서명을 줄로 구분한다. 말하지 않은 요소는 추가하지 않는다.", + .chat: "짧고 자연스러운 채팅 문단으로 쓰고 이메일 형식이나 제목을 추가하지 않는다.", + .codeOrTerminal: "명령어, 경로, URL, 대소문자, 기호, 줄바꿈을 정확히 보존한다.", + ] + return "이번 출력 유형은 \(kind.rawValue)이다. 유형을 바꾸지 말고 다음 계약을 따른다: \(rules[kind] ?? "") 받아쓴 사실은 변경하지 않는다." + } +} diff --git a/Sources/Prompts/PromptStylePrompts.swift b/Sources/Prompts/PromptStylePrompts.swift index 4880d89..a449e59 100644 --- a/Sources/Prompts/PromptStylePrompts.swift +++ b/Sources/Prompts/PromptStylePrompts.swift @@ -26,23 +26,23 @@ enum PromptStylePrompts { case (.cantonese, .casual): return "风格:自然粤语、直接。保留粤语口语感和必要语气词,主动修正明显粤语误识别、断句和专有名词;不要默认改成普通话书面中文。" case (.auto, .professional): - return "风格:自动语言专业整理。先判断原文主要语言和混排方式,再做纠错和表达整理。保持原语言;中文、英文、日文、韩文、粤语和中英日韩混排都要自然。只有原文明显是步骤、清单或待办时,才输出 1. 2. 3.。" + return "风格:自动语言专业整理。先判断原文主要语言和混排方式,再做纠错和表达整理。保持原语言;中文、英文、日文、韩文、粤语和中英日韩混排都要自然。无序清单用项目符号,只有明确顺序或步骤时才使用 1. 2. 3.。" case (.chinese, .professional): - return "风格:专业整理。先做忠实纠错,再整理表达。对有明确上下文依据的同音错字、近音错字、专有名词大小写和中英混排要主动修正;把已完整表达的口语整理成自然书面句子,没说完的片段仍保持未完。结构清楚;只有原文明显是步骤、清单或待办时,才输出 1. 2. 3.。" + return "风格:专业整理。先做忠实纠错,再整理表达。对有明确上下文依据的同音错字、近音错字、专有名词大小写和中英混排要主动修正;把已完整表达的口语整理成自然书面句子,没说完的片段仍保持未完。结构清楚;无序清单用项目符号,只有明确顺序或步骤时才使用 1. 2. 3.。" case (.cantonese, .professional): - return "风格:粤语专业整理。先做粤语误识别纠错,再整理表达。保留自然粤语书面表达、必要语气词和中英混排;对专有名词、技术词和英文大小写要更主动。只有原文明显是步骤、清单或待办时,才输出 1. 2. 3.。" + return "风格:粤语专业整理。先做粤语误识别纠错,再整理表达。保留自然粤语书面表达、必要语气词和中英混排;对专有名词、技术词和英文大小写要更主动。无序清单用项目符号,只有明确顺序或步骤时才使用 1. 2. 3.。" case (.auto, .custom), (.chinese, .custom), (.cantonese, .custom): return "" case (.english, .professional): - return "Style: professional cleanup. Apply faithful correction before polishing. Actively fix homophones, ASR substitutions, proper nouns, capitalization, and mixed-language terms when context clearly supports the change. Turn fully expressed speech into natural written sentences, but keep unfinished fragments unfinished. Keep the structure crisp. Use 1. 2. 3. only when the raw text is clearly a list, steps, or action items." + return "Style: professional cleanup. Apply faithful correction before polishing. Actively fix homophones, ASR substitutions, proper nouns, capitalization, and mixed-language terms when context clearly supports the change. Turn fully expressed speech into natural written sentences, but keep unfinished fragments unfinished. Keep the structure crisp. Use bullets for unordered collections and 1. 2. 3. only for explicit sequences or steps." case (.english, .casual): return "Style: natural and direct. Keep an easy spoken tone, but still actively fix obvious typos, homophones, sentence breaks, and small wording mistakes. Do not leave clear ASR errors in place." case (.japanese, .professional): - return "スタイル:専門的に整理。忠実な補正を先に行い、その後で表現を整える。文脈に明確な根拠がある固有名詞、英字表記、誤認識、言い直しを補正し、最後まで述べられた内容だけを自然で明確な日本語にする。言いかけは未完のまま残す。原文が明らかに手順、リスト、TODO の場合だけ 1. 2. 3. を使う。" + return "スタイル:専門的に整理。忠実な補正を先に行い、その後で表現を整える。文脈に明確な根拠がある固有名詞、英字表記、誤認識、言い直しを補正し、最後まで述べられた内容だけを自然で明確な日本語にする。言いかけは未完のまま残す。順序のないリストは箇条書き、明確な手順だけ 1. 2. 3. を使う。" case (.japanese, .casual): return "スタイル:自然で直接的。話し言葉の軽さは残しつつ、明らかな誤認識、同音語、句読点、文の区切りは積極的に直す。" case (.korean, .professional): - return "스타일: 전문적으로 정리. 먼저 충실하게 보정하고 그다음 표현을 다듬는다. 문맥에 분명한 근거가 있는 고유명사, 영문 표기, 오인식, 말 바꿈을 바로잡고 끝까지 표현된 내용만 자연스럽고 명확한 한국어로 만든다. 미완성 발화는 그대로 미완성으로 둔다. 원문이 명확히 단계, 목록, 할 일일 때만 1. 2. 3.을 사용한다." + return "스타일: 전문적으로 정리. 먼저 충실하게 보정하고 그다음 표현을 다듬는다. 문맥에 분명한 근거가 있는 고유명사, 영문 표기, 오인식, 말 바꿈을 바로잡고 끝까지 표현된 내용만 자연스럽고 명확한 한국어로 만든다. 미완성 발화는 그대로 미완성으로 둔다. 순서 없는 목록은 글머리표로, 명확한 단계만 1. 2. 3.으로 쓴다." case (.korean, .casual): return "스타일: 자연스럽고 직접적으로. 말의 편안함은 유지하되 명백한 오인식, 동음이의어, 문장 부호, 문장 경계는 적극적으로 바로잡는다." case (.english, .custom), (.japanese, .custom), (.korean, .custom): @@ -81,9 +81,9 @@ enum PromptStylePrompts { 原文:这周重点一个是稳定注册流程一个是补完埋点最后把文档更新掉 输出: - 1. 稳定注册流程。 - 2. 补完埋点。 - 3. 更新文档。 + - 稳定注册流程。 + - 补完埋点。 + - 更新文档。 专业整理强纠错示例: 原文:把 open type 的 hot key 文案改一下不要影响菜单蓝 @@ -122,9 +122,9 @@ enum PromptStylePrompts { Raw: this week the priorities are stabilizing signup finishing the tracking work and updating the docs Output: - 1. Stabilize signup. - 2. Finish the tracking work. - 3. Update the docs. + - Stabilize signup. + - Finish the tracking work. + - Update the docs. Strong correction examples: Raw: update the open type hot key copy and do not affect the menu bore diff --git a/Sources/Resources/en.lproj/Localizable.strings b/Sources/Resources/en.lproj/Localizable.strings index af4f451..37b6e98 100644 --- a/Sources/Resources/en.lproj/Localizable.strings +++ b/Sources/Resources/en.lproj/Localizable.strings @@ -272,7 +272,7 @@ "screen_context_mode.multimodal" = "Multimodal"; "style.prompt.concise" = "Minimalist. Keep only core information, remove repetition and filler, break long sentences short."; "style.prompt.formal" = "Formal written style. Use proper wording, reduce colloquial tone, keep the text clear and well-formed."; -"style.prompt.professional" = "Professional cleanup. Actively fix typos, homophones, ASR mistakes, and proper nouns, then turn the text into complete natural written sentences. Use numbered lists only when the raw text is clearly a list or action items."; +"style.prompt.professional" = "Professional cleanup. Actively fix typos, homophones, ASR mistakes, and proper nouns, then turn the text into complete natural written sentences. Use bullets for unordered collections and numbering only for explicit steps."; "style.prompt.casual" = "Natural cleanup. Keep the tone easy, but actively fix obvious typos, homophones, and ASR mistakes without over-formalizing."; "style.prompt.custom" = "Format the text with my own rules: preserve meaning, fix obvious ASR mistakes, and add line breaks when useful."; @@ -295,6 +295,31 @@ "rules.subtitle" = "Additional instructions for the LLM when formatting text"; "rules.placeholder" = "e.g. Use Arabic numerals for all numbers"; "rules.empty" = "No rules yet"; +"dictionary.title" = "Personal Dictionary"; +"dictionary.subtitle" = "Manual terms work immediately. With learning enabled, only local edits to the text OpenType just inserted can become candidates."; +"dictionary.auto_learning" = "Learn corrections"; +"dictionary.spoken_form" = "Recognized form"; +"dictionary.preferred_form" = "Preferred spelling"; +"dictionary.filter" = "Dictionary filter"; +"dictionary.filter.all" = "All"; +"dictionary.filter.learned" = "Learned"; +"dictionary.filter.manual" = "Manual"; +"dictionary.filter.pending" = "Pending"; +"dictionary.search" = "Search terms"; +"dictionary.empty" = "No matching terms"; +"dictionary.import" = "Import…"; +"dictionary.export" = "Export…"; +"dictionary.clear_learned" = "Clear Learned"; +"dictionary.clear_learned_confirm" = "Clear all automatically learned terms?"; +"dictionary.added" = "Term added"; +"dictionary.imported_fmt" = "Imported %d terms"; +"dictionary.import_failed" = "Could not import this dictionary"; +"dictionary.exported" = "Dictionary exported"; +"dictionary.export_failed" = "Could not export the dictionary"; +"dictionary.manual_badge" = "Manual"; +"dictionary.learned_badge_fmt" = "Learned · %d"; +"dictionary.save" = "Save"; +"dictionary.approve" = "Approve"; /* ── History & Stats ── */ "history.total" = "Total"; diff --git a/Sources/Resources/zh-Hans.lproj/Localizable.strings b/Sources/Resources/zh-Hans.lproj/Localizable.strings index d9aafce..cdaa5e2 100644 --- a/Sources/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/Resources/zh-Hans.lproj/Localizable.strings @@ -272,7 +272,7 @@ "screen_context_mode.multimodal" = "多模态"; "style.prompt.concise" = "极简。只保留核心信息,删掉修饰、重复和过渡,长句拆短。"; "style.prompt.formal" = "正式书面。用规范表达,减少口语感,保持句子完整、逻辑顺畅。"; -"style.prompt.professional" = "专业整理。更主动纠正错别字、同音词、识别错误和专有名词,整理成完整自然的书面句子;只有原文明显是步骤或待办时才用编号。"; +"style.prompt.professional" = "专业整理。更主动纠正错别字、同音词、识别错误和专有名词,整理成完整自然的书面句子;无序清单用项目符号,只有明确步骤才用编号。"; "style.prompt.casual" = "自然整理。保留口吻,但要主动修正常见错别字、同音词和识别错误,不过度书面化。"; "style.prompt.custom" = "按以下要求整理:保留原意,修正明显错别字和识别错误,按需要分段换行。"; @@ -295,6 +295,31 @@ "rules.subtitle" = "LLM 整理文本时遵循的额外指令"; "rules.placeholder" = "如:所有数字用阿拉伯数字"; "rules.empty" = "暂无规则"; +"dictionary.title" = "个人词库"; +"dictionary.subtitle" = "手动词条立即生效;开启学习后,只有 OpenType 刚插入文本里的局部修改才会成为候选。"; +"dictionary.auto_learning" = "学习纠正"; +"dictionary.spoken_form" = "识别写法"; +"dictionary.preferred_form" = "正确写法"; +"dictionary.filter" = "词库筛选"; +"dictionary.filter.all" = "全部"; +"dictionary.filter.learned" = "自动学习"; +"dictionary.filter.manual" = "手动添加"; +"dictionary.filter.pending" = "待确认"; +"dictionary.search" = "搜索词条"; +"dictionary.empty" = "没有匹配的词条"; +"dictionary.import" = "导入…"; +"dictionary.export" = "导出…"; +"dictionary.clear_learned" = "清空自动词条"; +"dictionary.clear_learned_confirm" = "清空所有自动学习词条?"; +"dictionary.added" = "词条已添加"; +"dictionary.imported_fmt" = "已导入 %d 个词条"; +"dictionary.import_failed" = "无法导入这个词库"; +"dictionary.exported" = "词库已导出"; +"dictionary.export_failed" = "无法导出词库"; +"dictionary.manual_badge" = "手动"; +"dictionary.learned_badge_fmt" = "已学习 · %d"; +"dictionary.save" = "保存"; +"dictionary.approve" = "确认"; /* ── History & Stats ── */ "history.total" = "总输入"; diff --git a/Sources/Speech/SpeechRecognitionContext.swift b/Sources/Speech/SpeechRecognitionContext.swift index c48777f..a599c9a 100644 --- a/Sources/Speech/SpeechRecognitionContext.swift +++ b/Sources/Speech/SpeechRecognitionContext.swift @@ -20,8 +20,23 @@ struct SpeechRecognitionContext: Equatable, Sendable { } init(dictionaryEntries: [DictionaryEntry]) { - self.init(phrases: dictionaryEntries.compactMap { entry -> String? in - guard entry.enabled else { return nil } + let ranked = dictionaryEntries.enumerated().sorted { lhs, rhs in + if lhs.element.origin != rhs.element.origin { + return lhs.element.origin == .manual + } + if lhs.element.origin == .manual { + return lhs.offset < rhs.offset + } + if lhs.element.evidenceCount != rhs.element.evidenceCount { + return lhs.element.evidenceCount > rhs.element.evidenceCount + } + let lhsDate = lhs.element.lastSeenAt ?? lhs.element.createdAt + let rhsDate = rhs.element.lastSeenAt ?? rhs.element.createdAt + if lhsDate != rhsDate { return lhsDate > rhsDate } + return lhs.offset < rhs.offset + }.map(\.element) + self.init(phrases: ranked.compactMap { entry -> String? in + guard entry.isEffective else { return nil } let replacement = entry.replacement.trimmingCharacters(in: .whitespacesAndNewlines) return replacement.isEmpty ? nil : replacement }) diff --git a/Sources/UI/DictionaryFilePanel.swift b/Sources/UI/DictionaryFilePanel.swift new file mode 100644 index 0000000..bd08546 --- /dev/null +++ b/Sources/UI/DictionaryFilePanel.swift @@ -0,0 +1,27 @@ +import AppKit +import Foundation +import UniformTypeIdentifiers + +@MainActor +enum DictionaryFilePanel { + static func importData() throws -> Data? { + let panel = NSOpenPanel() + panel.title = L("dictionary.import") + panel.allowedContentTypes = [.json] + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url else { return nil } + return try Data(contentsOf: url) + } + + static func export(_ data: Data) throws -> Bool { + let panel = NSSavePanel() + panel.title = L("dictionary.export") + panel.nameFieldStringValue = "OpenType-Dictionary.json" + panel.allowedContentTypes = [.json] + guard panel.runModal() == .OK, let url = panel.url else { return false } + try data.write(to: url, options: .atomic) + return true + } +} diff --git a/Sources/UI/DictionaryManagementView.swift b/Sources/UI/DictionaryManagementView.swift new file mode 100644 index 0000000..4a634b4 --- /dev/null +++ b/Sources/UI/DictionaryManagementView.swift @@ -0,0 +1,264 @@ +import SwiftUI + +private enum DictionaryFilter: String, CaseIterable { + case all + case learned + case manual + case pending + + var label: String { L("dictionary.filter.\(rawValue)") } +} +struct DictionaryManagementView: View { + @EnvironmentObject private var settings: AppSettings + @StateObject private var dictionary = PersonalDictionary.shared + @State private var filter: DictionaryFilter = .all + @State private var searchText = "" + @State private var newOriginal = "" + @State private var newReplacement = "" + @State private var statusMessage = "" + @State private var showClearConfirmation = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + header + addRow + filterRow + entriesList + footer + } + .alert(L("dictionary.clear_learned_confirm"), isPresented: $showClearConfirmation) { + Button(L("common.cancel"), role: .cancel) {} + Button(L("common.clear"), role: .destructive) { + dictionary.clearLearnedEntries() + } + } message: { + Text(L("common.cannot_undo")) + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 5) { + HStack { + Label(L("dictionary.title"), systemImage: "text.book.closed") + .font(.headline) + Spacer() + Toggle(L("dictionary.auto_learning"), isOn: $settings.enableCorrectionLearning) + .toggleStyle(.switch) + .controlSize(.small) + } + Text(L("dictionary.subtitle")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var addRow: some View { + HStack(spacing: 8) { + TextField(L("dictionary.spoken_form"), text: $newOriginal) + .textFieldStyle(.roundedBorder) + Image(systemName: "arrow.right") + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + TextField(L("dictionary.preferred_form"), text: $newReplacement) + .textFieldStyle(.roundedBorder) + Button(L("common.add"), action: addEntry) + .controlSize(.small) + .keyboardShortcut(.return, modifiers: [.command]) + .disabled(!canAdd) + } + } + + private var filterRow: some View { + HStack(spacing: 8) { + Picker(L("dictionary.filter"), selection: $filter) { + ForEach(DictionaryFilter.allCases, id: \.self) { item in + Text(item.label).tag(item) + } + } + .pickerStyle(.segmented) + + TextField(L("dictionary.search"), text: $searchText) + .textFieldStyle(.roundedBorder) + .frame(width: 170) + } + } + + private var entriesList: some View { + Group { + if filteredEntries.isEmpty { + Text(L("dictionary.empty")) + .font(.caption) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, minHeight: 88) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(filteredEntries) { entry in + DictionaryEntryRow( + entry: entry, + onSave: { dictionary.updateEntry(id: entry.id, original: $0, replacement: $1) }, + onToggle: { dictionary.setEntryEnabled(id: entry.id, enabled: $0) }, + onApprove: { dictionary.approveEntry(id: entry.id) }, + onDelete: { dictionary.removeEntry(id: entry.id) } + ) + if entry.id != filteredEntries.last?.id { Divider() } + } + } + } + .frame(minHeight: 110, maxHeight: 190) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + } + } + } + } + + private var footer: some View { + HStack(spacing: 10) { + Button(L("dictionary.import"), action: importEntries) + Button(L("dictionary.export"), action: exportEntries) + .disabled(dictionary.entries.isEmpty) + Button(L("dictionary.clear_learned"), role: .destructive) { + showClearConfirmation = true + } + .disabled(!dictionary.entries.contains { $0.origin == .learned }) + Spacer() + Text(statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .controlSize(.small) + } + + private var canAdd: Bool { + let original = newOriginal.trimmingCharacters(in: .whitespacesAndNewlines) + let replacement = newReplacement.trimmingCharacters(in: .whitespacesAndNewlines) + return !original.isEmpty && !replacement.isEmpty && original != replacement + } + + private var filteredEntries: [DictionaryEntry] { + dictionary.entries + .filter { entry in + switch filter { + case .all: return true + case .learned: return entry.origin == .learned + case .manual: return entry.origin == .manual + case .pending: return entry.status == .pending + } + } + .filter { entry in + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + return query.isEmpty + || entry.original.localizedCaseInsensitiveContains(query) + || entry.replacement.localizedCaseInsensitiveContains(query) + } + .sorted { lhs, rhs in + if lhs.status != rhs.status { return lhs.status == .pending } + return (lhs.lastSeenAt ?? lhs.createdAt) > (rhs.lastSeenAt ?? rhs.createdAt) + } + } + + private func addEntry() { + guard dictionary.addEntry(original: newOriginal, replacement: newReplacement) != nil else { return } + newOriginal = "" + newReplacement = "" + statusMessage = L("dictionary.added") + } + + private func importEntries() { + do { + guard let data = try DictionaryFilePanel.importData() else { return } + let count = try dictionary.importEntries(from: data) + statusMessage = String(format: L("dictionary.imported_fmt"), count) + } catch { + statusMessage = L("dictionary.import_failed") + } + } + + private func exportEntries() { + do { + if try DictionaryFilePanel.export(dictionary.exportData()) { + statusMessage = L("dictionary.exported") + } + } catch { + statusMessage = L("dictionary.export_failed") + } + } +} + +private struct DictionaryEntryRow: View { + let entry: DictionaryEntry + let onSave: (String, String) -> Void + let onToggle: (Bool) -> Void + let onApprove: () -> Void + let onDelete: () -> Void + @State private var original: String + @State private var replacement: String + + init( + entry: DictionaryEntry, + onSave: @escaping (String, String) -> Void, + onToggle: @escaping (Bool) -> Void, + onApprove: @escaping () -> Void, + onDelete: @escaping () -> Void + ) { + self.entry = entry + self.onSave = onSave + self.onToggle = onToggle + self.onApprove = onApprove + self.onDelete = onDelete + _original = State(initialValue: entry.original) + _replacement = State(initialValue: entry.replacement) + } + + var body: some View { + HStack(spacing: 8) { + Toggle("", isOn: Binding(get: { entry.enabled }, set: onToggle)) + .labelsHidden() + .controlSize(.small) + TextField(L("dictionary.spoken_form"), text: $original) + .textFieldStyle(.plain) + Image(systemName: "arrow.right") + .font(.caption) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + TextField(L("dictionary.preferred_form"), text: $replacement) + .textFieldStyle(.plain) + Text(entry.origin == .manual + ? L("dictionary.manual_badge") + : String(format: L("dictionary.learned_badge_fmt"), entry.evidenceCount)) + .font(.caption2) + .foregroundStyle(entry.status == .pending ? .orange : .secondary) + .frame(minWidth: 54, alignment: .trailing) + if isDirty { + Button(L("dictionary.save")) { onSave(original, replacement) } + .controlSize(.mini) + } + if entry.status == .pending { + Button(L("dictionary.approve"), action: onApprove) + .controlSize(.mini) + } + Button(role: .destructive, action: onDelete) { + Image(systemName: "trash") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help(L("common.delete")) + .accessibilityLabel(L("common.delete")) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .contextMenu { + if entry.status == .pending { Button(L("dictionary.approve"), action: onApprove) } + Button(L("common.delete"), role: .destructive, action: onDelete) + } + } + + private var isDirty: Bool { + original != entry.original || replacement != entry.replacement + } +} diff --git a/Sources/UI/DictionaryStyleView.swift b/Sources/UI/DictionaryStyleView.swift index 1abc9a3..8daab07 100644 --- a/Sources/UI/DictionaryStyleView.swift +++ b/Sources/UI/DictionaryStyleView.swift @@ -9,6 +9,8 @@ struct DictionaryStyleView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { + DictionaryManagementView() + Divider() customSystemPromptSection if !settings.useCustomSystemPrompt { Divider() diff --git a/Sources/UI/HistoryStatsView.swift b/Sources/UI/HistoryStatsView.swift index 02c685d..a1b8687 100644 --- a/Sources/UI/HistoryStatsView.swift +++ b/Sources/UI/HistoryStatsView.swift @@ -158,7 +158,7 @@ struct HistoryStatsView: View { private func recordCard(_ record: InputRecord) -> some View { VStack(alignment: .leading, spacing: 4) { - Text(record.processedText) + Text(record.displayText) .font(.system(size: 12)) .lineLimit(3) .frame(maxWidth: .infinity, alignment: .leading) @@ -195,7 +195,7 @@ struct HistoryStatsView: View { Button { NSPasteboard.general.clearContents() - NSPasteboard.general.setString(record.processedText, forType: .string) + NSPasteboard.general.setString(record.displayText, forType: .string) } label: { Image(systemName: "doc.on.doc") .font(.system(size: 9)) diff --git a/Tests/OpenTypeTests/CorrectionCandidateClassifierTests.swift b/Tests/OpenTypeTests/CorrectionCandidateClassifierTests.swift new file mode 100644 index 0000000..26fdb4b --- /dev/null +++ b/Tests/OpenTypeTests/CorrectionCandidateClassifierTests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import OpenType + +final class CorrectionCandidateClassifierTests: XCTestCase { + func testLearnsCaseAndSpacingCorrectionAsHighConfidenceTerm() throws { + let candidate = try XCTUnwrap(CorrectionCandidateClassifier.candidate( + inserted: "Please use open type today.", + userFinal: "Please use OpenType today.", + sourceRecordID: UUID(), + languageCode: "en", + bundleIdentifier: "com.apple.Notes" + )) + + XCTAssertEqual(candidate.original, "open type") + XCTAssertEqual(candidate.replacement, "OpenType") + XCTAssertGreaterThanOrEqual(candidate.confidence, 0.92) + } + + func testLearnsOneLocalizedProductTypo() throws { + let candidate = try XCTUnwrap(CorrectionCandidateClassifier.candidate( + inserted: "Use OpenTape for dictation.", + userFinal: "Use OpenType for dictation.", + sourceRecordID: UUID(), + languageCode: "en", + bundleIdentifier: nil + )) + + XCTAssertEqual(candidate.original, "OpenTape") + XCTAssertEqual(candidate.replacement, "OpenType") + } + + func testKeepsChineseSingleCharacterCorrectionPending() throws { + let candidate = try XCTUnwrap(CorrectionCandidateClassifier.candidate( + inserted: "不要影响菜单蓝。", + userFinal: "不要影响菜单栏。", + sourceRecordID: UUID(), + languageCode: "zh", + bundleIdentifier: nil + )) + + XCTAssertEqual(candidate.original, "蓝") + XCTAssertEqual(candidate.replacement, "栏") + XCTAssertLessThan(candidate.confidence, 0.92) + } + + func testRejectsPunctuationAndLineBreakOnlyEdits() { + XCTAssertNil(CorrectionCandidateClassifier.candidate( + inserted: "Shopping list: bananas, milk.", + userFinal: "Shopping list:\n- Bananas\n- Milk", + sourceRecordID: UUID(), + languageCode: "en", + bundleIdentifier: nil + )) + } + + func testRejectsURLAndMultipleEditHunks() { + XCTAssertNil(CorrectionCandidateClassifier.candidate( + inserted: "Open https://example.com/a now", + userFinal: "Open https://example.com/b now", + sourceRecordID: UUID(), + languageCode: "en", + bundleIdentifier: nil + )) + XCTAssertNil(CorrectionCandidateClassifier.candidate( + inserted: "alpha xx middle yy omega", + userFinal: "alpha aa middle bb omega", + sourceRecordID: UUID(), + languageCode: "en", + bundleIdentifier: nil + )) + } + + func testDoesNotAssociatePlainContinuationWithPreviousInsertion() { + XCTAssertNil(CorrectionObservationPolicy.associatedFinalText( + inserted: "Original sentence.", + edited: "Original sentence. More typing" + )) + XCTAssertEqual( + CorrectionObservationPolicy.associatedFinalText( + inserted: "Original sentence.", + edited: "Corrected sentence." + ), + "Corrected sentence." + ) + } +} diff --git a/Tests/OpenTypeTests/CorrectionCapturePrivacyPolicyTests.swift b/Tests/OpenTypeTests/CorrectionCapturePrivacyPolicyTests.swift new file mode 100644 index 0000000..3e1dbe0 --- /dev/null +++ b/Tests/OpenTypeTests/CorrectionCapturePrivacyPolicyTests.swift @@ -0,0 +1,33 @@ +import XCTest +@testable import OpenType + +final class CorrectionCapturePrivacyPolicyTests: XCTestCase { + func testBlocksTerminalAndPasswordManagerApps() { + XCTAssertTrue(CorrectionCapturePrivacyPolicy.isBlocked( + appText: "com.apple.Terminal Terminal", + fieldText: "AXTextArea" + )) + XCTAssertTrue(CorrectionCapturePrivacyPolicy.isBlocked( + appText: "com.1password.1password 1Password", + fieldText: "AXTextField" + )) + } + + func testBlocksSecureAndAddressFields() { + XCTAssertTrue(CorrectionCapturePrivacyPolicy.isBlocked( + appText: "com.apple.Safari Safari", + fieldText: "AXTextField Address and Search" + )) + XCTAssertTrue(CorrectionCapturePrivacyPolicy.isBlocked( + appText: "com.example.app", + fieldText: "AXSecureTextField password" + )) + } + + func testAllowsOrdinaryTextEditorFields() { + XCTAssertFalse(CorrectionCapturePrivacyPolicy.isBlocked( + appText: "com.apple.TextEdit TextEdit", + fieldText: "AXTextArea document body" + )) + } +} diff --git a/Tests/OpenTypeTests/CorrectionCaptureRegionTests.swift b/Tests/OpenTypeTests/CorrectionCaptureRegionTests.swift new file mode 100644 index 0000000..9724dfc --- /dev/null +++ b/Tests/OpenTypeTests/CorrectionCaptureRegionTests.swift @@ -0,0 +1,45 @@ +import XCTest +@testable import OpenType + +final class CorrectionCaptureRegionTests: XCTestCase { + func testFindsEditedInsertionBetweenStableAnchors() throws { + let before = "Prefix | Open Tape | Suffix" + let inserted = "Open Tape" + let range = (before as NSString).range(of: inserted) + let locator = try XCTUnwrap(CorrectionCaptureRegionLocator( + documentText: before, + insertedRange: range + )) + + XCTAssertEqual( + locator.editedText(in: "Prefix | OpenType | Suffix"), + "OpenType" + ) + } + + func testFindsEditedInsertionAtDocumentEnd() throws { + let before = "Existing text. Original sentence." + let inserted = "Original sentence." + let range = (before as NSString).range(of: inserted) + let locator = try XCTUnwrap(CorrectionCaptureRegionLocator( + documentText: before, + insertedRange: range + )) + + XCTAssertEqual( + locator.editedText(in: "Existing text. Corrected sentence."), + "Corrected sentence." + ) + } + + func testRejectsWhenBoundaryAnchorChanged() throws { + let before = "Stable prefix | inserted | stable suffix" + let range = (before as NSString).range(of: "inserted") + let locator = try XCTUnwrap(CorrectionCaptureRegionLocator( + documentText: before, + insertedRange: range + )) + + XCTAssertNil(locator.editedText(in: "Different prefix | corrected | stable suffix")) + } +} diff --git a/Tests/OpenTypeTests/InputHistoryTests.swift b/Tests/OpenTypeTests/InputHistoryTests.swift index 8cd949b..99b604c 100644 --- a/Tests/OpenTypeTests/InputHistoryTests.swift +++ b/Tests/OpenTypeTests/InputHistoryTests.swift @@ -21,6 +21,8 @@ final class InputHistoryTests: XCTestCase { XCTAssertEqual(record.rawCharCount, 9) XCTAssertEqual(record.processedCharCount, 8) XCTAssertNil(record.context) + XCTAssertNil(record.userFinalText) + XCTAssertNil(record.formatKind) } func testInputRecordSearchMatchesContextFields() { @@ -120,4 +122,28 @@ final class InputHistoryTests: XCTestCase { XCTAssertTrue(context.contains("Safari")) XCTAssertTrue(context.contains("Gmail")) } + + @MainActor + func testMemoryStorePrefersUserFinalText() { + let now = Date(timeIntervalSince1970: 10_000) + let record = InputRecord( + id: UUID(), + date: now, + rawText: "open tape", + processedText: "OpenTape", + wasProcessed: true, + userFinalText: "OpenType", + formatKind: .plainParagraph + ) + + let context = MemoryStore.recentContext( + records: [record], + limit: 1, + windowMinutes: 30, + now: now + ) + + XCTAssertTrue(context.contains("OpenType")) + XCTAssertFalse(context.contains("OpenTape")) + } } diff --git a/Tests/OpenTypeTests/PersonalDictionaryLearningTests.swift b/Tests/OpenTypeTests/PersonalDictionaryLearningTests.swift new file mode 100644 index 0000000..766639d --- /dev/null +++ b/Tests/OpenTypeTests/PersonalDictionaryLearningTests.swift @@ -0,0 +1,100 @@ +import Foundation +import XCTest +@testable import OpenType + +final class PersonalDictionaryLearningTests: XCTestCase { + func testLegacyEntryDecodesAsActiveManualTerm() throws { + let data = Data(#"{"original":"open type","replacement":"OpenType","enabled":true}"#.utf8) + let entry = try JSONDecoder().decode(DictionaryEntry.self, from: data) + + XCTAssertEqual(entry.origin, .manual) + XCTAssertEqual(entry.status, .active) + XCTAssertTrue(entry.isEffective) + } + + func testManualTermWorksImmediately() throws { + let store = makeStore() + XCTAssertNotNil(store.addEntry(original: "open type", replacement: "OpenType")) + + XCTAssertEqual(store.applyReplacements(to: "Use open type."), "Use OpenType.") + XCTAssertEqual(SpeechRecognitionContext(dictionaryEntries: store.entries).phrases, ["OpenType"]) + } + + func testAmbiguousLearnedTermRequiresTwoIndependentRecords() throws { + let store = makeStore() + let first = learnedCandidate(recordID: UUID(), confidence: 0.82) + let second = learnedCandidate(recordID: UUID(), confidence: 0.82) + + let entryID = try XCTUnwrap(store.recordLearnedCandidate(first)) + XCTAssertEqual(store.entries.first(where: { $0.id == entryID })?.status, .pending) + XCTAssertEqual(store.applyReplacements(to: "菜单蓝"), "菜单蓝") + XCTAssertTrue(SpeechRecognitionContext(dictionaryEntries: store.entries).phrases.isEmpty) + + store.recordLearnedCandidate(second) + XCTAssertEqual(store.entries.first(where: { $0.id == entryID })?.status, .active) + XCTAssertEqual(store.applyReplacements(to: "菜单蓝"), "菜单栏") + } + + func testHighConfidenceLearnedTermActivatesOnceAndManualEntryWins() throws { + let store = makeStore() + store.recordLearnedCandidate(LearnedCorrectionCandidate( + original: "open type", + replacement: "OpenType", + confidence: 0.98, + sourceRecordID: UUID(), + languageCode: "en", + bundleIdentifier: "com.apple.Notes" + )) + XCTAssertEqual(store.entries.first?.status, .active) + + store.addEntry(original: "open type", replacement: "OpenType Pro") + XCTAssertEqual(store.entries.count, 1) + XCTAssertEqual(store.entries.first?.origin, .manual) + XCTAssertEqual(store.applyReplacements(to: "open type"), "OpenType Pro") + } + + func testConflictingLearnedMappingsRemainPendingUntilOneIsApproved() throws { + let store = makeStore() + store.recordLearnedCandidate(LearnedCorrectionCandidate( + original: "open tape", + replacement: "OpenType", + confidence: 0.98, + sourceRecordID: UUID(), + languageCode: "en", + bundleIdentifier: nil + )) + let competingID = try XCTUnwrap(store.recordLearnedCandidate(LearnedCorrectionCandidate( + original: "open tape", + replacement: "Open Tape", + confidence: 0.98, + sourceRecordID: UUID(), + languageCode: "en", + bundleIdentifier: nil + ))) + + XCTAssertEqual(store.entries.map(\.status), [.pending, .pending]) + XCTAssertEqual(store.applyReplacements(to: "open tape"), "open tape") + + store.approveEntry(id: competingID) + XCTAssertEqual(store.applyReplacements(to: "open tape"), "Open Tape") + XCTAssertEqual(store.entries.filter(\.isEffective).count, 1) + } + + private func learnedCandidate(recordID: UUID, confidence: Double) -> LearnedCorrectionCandidate { + LearnedCorrectionCandidate( + original: "蓝", + replacement: "栏", + confidence: confidence, + sourceRecordID: recordID, + languageCode: "zh", + bundleIdentifier: "com.apple.Notes" + ) + } + + private func makeStore() -> PersonalDictionary { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenTypeDictionaryTests-\(UUID().uuidString)", isDirectory: true) + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return PersonalDictionary(directoryURL: url) + } +} diff --git a/Tests/OpenTypeTests/PromptBuilderTests.swift b/Tests/OpenTypeTests/PromptBuilderTests.swift index 1aa46df..31605c1 100644 --- a/Tests/OpenTypeTests/PromptBuilderTests.swift +++ b/Tests/OpenTypeTests/PromptBuilderTests.swift @@ -94,7 +94,7 @@ final class PromptBuilderTests: XCTestCase { XCTAssertTrue(prompt.contains("输出标签、开场白、备注、引号说明或代码围栏")) XCTAssertTrue(prompt.contains("final_text")) XCTAssertTrue(prompt.contains("普通说明、状态同步和判断句不要强行改成编号列表")) - XCTAssertTrue(prompt.contains("只有原文明显是步骤、清单或待办时,才输出 1. 2. 3.")) + XCTAssertTrue(prompt.contains("无序清单用项目符号,只有明确顺序或步骤时才使用 1. 2. 3.")) XCTAssertTrue(prompt.contains("专业整理补充示例:")) XCTAssertTrue(prompt.contains("原文:今天主要是把登录问题修掉然后回归一遍没问题的话明天发版")) XCTAssertTrue(prompt.contains("专业整理强纠错示例:")) @@ -135,7 +135,7 @@ final class PromptBuilderTests: XCTestCase { XCTAssertTrue(prompt.contains("output tags, notes, preambles, or code fences")) XCTAssertTrue(prompt.contains("final_text")) XCTAssertTrue(prompt.contains("do not force normal explanations or status updates into numbered lists")) - XCTAssertTrue(prompt.contains("Use 1. 2. 3. only when the raw text is clearly a list")) + XCTAssertTrue(prompt.contains("Use bullets for unordered collections and 1. 2. 3. only for explicit sequences or steps")) XCTAssertTrue(prompt.contains("Professional cleanup examples:")) XCTAssertTrue(prompt.contains("Raw: today the main thing is fixing the login issue and then running regression")) XCTAssertTrue(prompt.contains("Strong correction examples:")) diff --git a/Tests/OpenTypeTests/TextFormatKindTests.swift b/Tests/OpenTypeTests/TextFormatKindTests.swift new file mode 100644 index 0000000..988a242 --- /dev/null +++ b/Tests/OpenTypeTests/TextFormatKindTests.swift @@ -0,0 +1,88 @@ +import XCTest +@testable import OpenType + +final class TextFormatKindTests: XCTestCase { + func testSeparatesUnorderedListFromOrderedSteps() { + XCTAssertEqual( + TextFormatClassifier.classify( + text: "购物清单,香蕉,燕麦奶,黑巧克力", + context: nil + ).kind, + .unorderedList + ) + XCTAssertEqual( + TextFormatClassifier.classify( + text: "第一确认需求,第二排期,第三更新预算", + context: nil + ).kind, + .orderedSteps + ) + } + + func testRecognizesEmailStructureAndAppPriors() { + XCTAssertEqual( + TextFormatClassifier.classify( + text: "Hi Anna, call me tomorrow. Thanks, Jack.", + context: nil + ).kind, + .email + ) + let slack = InputContext( + appName: "Slack", + bundleIdentifier: "com.tinyspeck.slackmacgap", + outputMode: .processed, + inputLanguage: .english, + source: .menuBar + ) + XCTAssertEqual( + TextFormatClassifier.classify(text: "the build is ready", context: slack).kind, + .chat + ) + } + + func testExplicitListBeatsMailApplicationPrior() { + let mail = InputContext( + appName: "Mail", + bundleIdentifier: "com.apple.mail", + outputMode: .processed, + inputLanguage: .chinese, + source: .menuBar + ) + XCTAssertEqual( + TextFormatClassifier.classify(text: "待办清单,修复登录,更新文档", context: mail).kind, + .unorderedList + ) + } + + func testCodeApplicationSafetyBeatsFormattingIntent() { + let terminal = InputContext( + appName: "Terminal", + bundleIdentifier: "com.apple.Terminal", + outputMode: .processed, + inputLanguage: .english, + source: .menuBar + ) + XCTAssertEqual( + TextFormatClassifier.classify( + text: "first run git status second run swift test", + context: terminal + ).kind, + .codeOrTerminal + ) + } + + func testFormatContractIsExplicitInPrompt() { + let prompt = PromptBuilder.buildSystemPrompt( + style: .professional, + stylePrompt: "", + formatKind: .unorderedList, + inputLanguage: .chinese, + useCustomSystemPrompt: false, + customSystemPrompt: "" + ) + + XCTAssertTrue(prompt.contains("本次已判定的输出类型:unorderedList")) + XCTAssertTrue(prompt.contains("使用“- ”")) + XCTAssertTrue(prompt.contains("绝对不要改成编号步骤")) + } +} diff --git a/docs/superpowers/specs/2026-08-12-typeless-learning-formatting-roadmap.md b/docs/superpowers/specs/2026-08-12-typeless-learning-formatting-roadmap.md new file mode 100644 index 0000000..4c228bb --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-typeless-learning-formatting-roadmap.md @@ -0,0 +1,258 @@ +# Typeless 自动学习与格式化行为调研及路线图 + +> 日期:2026-08-12 +> +> 状态:外部证据调研完成;OpenType P0 已在当前工作树实现;未对 Typeless 客户端做逆向或受控黑盒实验 +> +> 目标:回答 Typeless 的听写后纠正、个人词典、风格学习和结构化输出有哪些可证实行为,以及 OpenType 应如何追赶 +> +> 证据规则:Typeless 官方帮助、发布说明和隐私文件优先;用户报告只作旁证;专有实现未知时明确写为推断 + +## 1. 结论先行 + +1. **Typeless 确实公开宣称会采集“听写后纠正的词”**。macOS 发布说明称,用户说完后修正一个词,Typeless 会把它自动保存进个人词典;举例集中在人名、项目代号、品牌。Windows 发布说明也说,它会识别听写后的词语修正,使名称、术语和偏好拼写在后续结果中按预期出现。 +2. **它不是把所有编辑混成一个学习系统**。官方把“个人词典”和“写作风格 Personalization”分开:词典负责名称、术语、偏好拼写;Personalization 随使用适应正式/随意、简洁/详细等风格,而且可以关闭。请求时还会使用当前应用和相关文本做上下文处理。 +3. **“为什么只偶尔采集关键词”没有官方算法说明**。官方没有披露观察时限、需要几次重复、如何识别一个编辑属于刚才的听写、哪些词会被过滤,也没有说明词典本地保存还是账号同步。一份 40 天、9000+ 次输入的长期用户报告称,80 多个专业词是在反复使用后逐渐自动加入,少数词多试几次仍不加入就手动添加。这与“低频、偏术语”的观察一致,但不能证明固定阈值。 +4. **Typeless 的质量优势不只是 ASR**。官方对比展示了三个独立能力:识别自我纠正并删除旧意图;把语义清单变成列表;按文体拆分邮件并规范数字。官方还宣称会依当前应用调整语气。这更像“识别 + 意图判断 + 文体格式化 + 个性化词汇”组成的链路,而不是一个词典开关。 +5. **实施前的 OpenType 已有词库生效所需的大部分组件,缺的是可用入口、纠正采集闭环和更细的格式决策**。本次 P0 已补上这些缺口:仅观察刚插入区域的 60 秒纠正会话、保守局部候选、自动/手动/待确认词条管理,以及六类显式 `formatKind` 契约。Qwen / Volc 的 ASR 上下文注入、真实语料基准和 per-app 风格画像仍属于后续工作。 + +### 1.1 当前工作树的 P0 实施状态 + +- 已完成:向后兼容的词条元数据、手动优先和证据合并、待确认流程、导入导出与词库设置界面。 +- 已完成:基于精确 AX 元素和插入锚点的短时纠正观察;切应用、失焦、下一次录音、超时或关闭学习时停止。终端、密码管理器、安全输入和地址栏不读取。 +- 已完成:只从单个局部替换提取稀疏候选;忽略追加、删除、标点换行、整句多处重写、URL、邮箱、路径、数字和疑似密钥。 +- 已完成:`plainParagraph / unorderedList / orderedSteps / email / chat / codeOrTerminal` 六类本地可审计决策,并向后处理提示传入严格排版契约。 +- 已完成:历史记录保存可选 `userFinalText` 和 `formatKind`,后续记忆优先使用用户最终文本。 +- 已验证:完整 Swift 测试、基础 CI、本地化键一致性、release `.app` 打包,以及中文“风格与规则”词库界面的实际渲染和表单启用状态。 +- 尚未声称完成:需要授予真实目标应用辅助功能权限后,用同一音频做一次端到端“插入—手改—学习—再次命中”实测,并建立第 9 节指标基线。 + +## 2. Typeless 官方可复现的前后对比 + +以下均来自 Typeless 官方安装指南的演示内容。为避免把营销样例误当成独立实测,表中采用中文意译和结构复现,不声称这是 OpenType 或本次调研的运行结果。 + +| 场景 | 口述输入的结构 | 官方展示的输出变化 | 能证明什么 | 不能证明什么 | +|---|---|---|---|---| +| 购物清单 | 一句连续话:先说“购物清单”,再依次说三个商品 | “购物清单:”单独一行;三个商品各成一条无序项目 | Typeless 会从语义识别无序集合,并主动换行、加项目符号 | 不能证明所有语言和任意长清单都稳定 | +| 简短邮件 | 一行口述:称呼、告知电话号码、致谢和署名 | 称呼、正文、致谢、署名分行;美国号码被标准化 | 它有邮件文体布局和数字格式化,而不只是补标点 | 不能证明号码格式化在地区不明确时一定正确 | +| 自我纠正 | 先约早上 7 点,带停顿词,再改为下午 3 点 | 只保留下午 3 点,删除停顿词和被推翻的时间 | 它能解析“最终意图覆盖前一意图” | 不能证明内部使用规则、LLM 或 ASR 候选 | + +逐项前后样例仍可在 Typeless 官方的[韩语本地化安装指南](https://www.typeless.com/ko/help/installation-and-setup)中核对;当前[英文安装指南](https://www.typeless.com/help/installation-and-setup)则概括 Dictate 会删除无意义口头内容、纠正拼写、格式化、补全用户描述但想不起的细节,以及随口述顺序更新内容。这些都是产品声明,不是准确率基准。 + +官方 Quickstart 进一步将能力拆成:删除口头禅、删除无意重复、处理半句内改口、优化措辞、自动组织列表/步骤/重点、按当前应用选择语气。[Typeless Quickstart](https://www.typeless.com/help/quickstart/key-features) + +2026 年 7 月的 macOS 2.0 发布说明又宣称:旁注可以只作为上下文、乱序想法会按重要性整理、较晚改口也只保留最终决定。[Typeless macOS 发布说明](https://www.typeless.com/help/release-notes/macos) 这些是更强的语义编辑声明,必须用真实中英文语料另行验证,不能直接当成稳定能力。 + +## 3. 个人词典、纠正学习和风格学习 + +### 3.1 官方明确说了什么 + +| 能力 | 官方证据 | 证据强度 | +|---|---|---| +| 听写后手改会进入词典 | macOS V0.4.0 发布说明明确称,纠正听写后的词会自动保存到个人词典,示例为同事姓名、项目代号、品牌 | 高:第一方功能说明 | +| 后续结果使用修正 | Windows V0.9.1 说明称,系统会捕捉听写后的词语修正,使名称、术语、偏好拼写在未来正确出现 | 高:跨平台第一方重复说明 | +| 自动和手动词条进入同一词典 | 官方首页称个人词典识别重要的名称、术语和表达,词汇可以自动或手动加入 | 高:第一方产品说明 | +| 可手动添加 | Quickstart 指引用户在 Dictionary 中新增行业术语和特殊拼写 | 高 | +| 可批量导入 | macOS V1.4.0 支持从文件导入人名、项目缩写、领域和行业词汇 | 高 | +| 可搜索/编辑/删除 | 移动端 V1.11.0 发布说明允许搜索、更新或删除姓名和常用术语 | 高,但这是移动端 UI 证据 | +| 风格会随使用变化 | macOS V0.9.0 说明会逐渐适应正式/随意、简洁/详细的偏好;关闭 Personalization 后停止学习并回到非个性化措辞 | 高 | +| 当前应用影响输出 | Quickstart 称工作邮件、聊天、客服等应用会使用不同语气;隐私政策承认处理当前应用及相关文本 | 高:行为与数据处理都被第一方承认 | + +直接来源: + +- [Typeless macOS 发布说明](https://www.typeless.com/help/release-notes/macos) +- [Typeless Windows 发布说明](https://www.typeless.com/help/release-notes/windows) +- [Typeless 官方首页:Personal dictionary](https://www.typeless.com/) +- [Personalization 使用说明](https://www.typeless.com/help/release-notes/macos/personalized-smarter) +- [Quickstart:个人词典与自动编辑](https://www.typeless.com/help/quickstart/key-features) +- [iOS 发布说明](https://www.typeless.com/help/release-notes/ios) + +### 3.2 官方没有说什么 + +以下都仍是未知项,不能写成 Typeless 的事实: + +- 是监听 Accessibility 的文本变化、定时读取目标文本,还是由键盘/输入法层拿到编辑事件。 +- “刚才的输出”能被跟踪多久;切应用、移动光标、撤销、整句重写后是否继续学习。 +- 一次纠正是否立即入库,还是同一候选累计到阈值才入库。 +- 是否只收人名、品牌、缩写等稀有词,还是普通词也收但不在 UI 中明显展示。 +- 词典保存的是 `错误写法 -> 正确写法`、仅保存正确热词,还是还包含发音/语言/应用范围。 +- 词典和风格画像是否只存在本机、是否跨设备同步,以及删除/清空的完整语义。 +- 词典是在 ASR 解码阶段加权、在 LLM 后处理阶段提示,还是两者都做。 + +### 3.3 高质量用户侧旁证(非官方) + +一位独立开发者报告自己 40 天使用 9000+ 次、约 30 小时后,Typeless 自动学习了 80 多个专业术语;其描述是常用术语在反复出现后进入词典,少数多次不成功的词再手动添加。他同时给出了一段较长、跳跃的原始口述与分层编号输出,并展示了使用统计和词典截图。[40 天长期使用记录](https://leolabs.me/blog/typeless-deep-dive/en/) + +这份报告的价值在于解释真实体验:自动学习可能是保守、渐进式的,而不是每次编辑都落库。局限也很明确:它是单用户自述,没有逐次记录“说了什么、改了什么、何时入库”,无法证明阈值或筛选规则;文中的隐私数据库观察也没有经过本次调研复核。 + +没有找到能够独立验证 Typeless 自动采集触发算法的高质量技术报告,因此本文件不采用论坛猜测补全黑盒实现。 + +## 4. 隐私和存储边界 + +Typeless 当前官方文件给出的边界是: + +- 音频和有限上下文(当前应用、相关文本)在云端实时处理,结果返回后立即丢弃;官方称服务器不保存音频、转写或屏幕上下文。[Privacy Policy,2026-03-13](https://www.typeless.com/privacy) +- 可能使用第三方 LLM 服务商;官方称这些服务配置为零留存且不用于训练。[Privacy Policy](https://www.typeless.com/privacy) +- Data Controls 更明确称听写数据中的音频、转写和 edits 不会被云端保存或用于训练。[Data Controls,2026-01-09](https://www.typeless.com/data-controls) 官方 Quickstart 另称听写历史由用户控制保留时长并留在设备上。[Typeless Quickstart](https://www.typeless.com/help/quickstart/key-features) +- 用户主动反馈是例外:在明确同意时,可能上传经过假名化的文本或纠正用于改进。[Privacy Policy](https://www.typeless.com/privacy) + +这里存在一个未解释的产品边界:既然个人词典和风格会持续生效,必然有某种持久状态,但官方公开文件没有说明这两类状态的具体字段、位置、保留期或同步机制。“云端零留存”不等于“没有本地历史或个性化数据”。OpenType 不应复制这种模糊表达,应在 UI 中分别解释音频、转写历史、自动学习词条、风格画像和远程模型请求。 + +## 5. 对 Typeless 产品链路的最小推断 + +下图只是与公开行为相容的产品模型,不是对其专有架构的反向结论: + +```text +音频 + -> ASR 原始候选 + -> 结合个人词典 / 当前应用 / 相关文本 + -> 判断最终意图与文体(普通段落、清单、步骤、邮件、聊天等) + -> 忠实纠错 + 自我纠正消解 + 数字规范化 + 排版 + -> 插入目标文本框 + -> 在短窗口内观察用户局部修正 + -> 高置信术语进入个人词典;长期写法更新风格画像 +``` + +公开证据足以支持“存在词典层、风格层、请求时上下文层”这三种产品概念,但不足以支持具体模型、提示词、阈值、数据库或监听 API 的断言。 + +## 6. OpenType 实施前差距(P0 基线) + +以下内容保留为本次实现的审计基线;对应 P0 缺口已经由 1.1 节所列实现覆盖。 + +### 已经具备 + +- `PersonalDictionary` 已保存 `original -> replacement`,做最长优先、非级联替换,并本地写入 `Application Support/OpenType/dictionary.json`(`Sources/Processing/PersonalDictionary.swift`)。 +- 启用词条的 preferred spelling 会成为 ASR 上下文,最多 100 个短语,并能生成 Whisper prompt(`Sources/Speech/SpeechRecognitionContext.swift`)。当前只有 Apple Speech 和 Whisper 实际接入 `configureRecognition`;Qwen / Volc 尚未使用这份上下文。 +- 词典和编辑规则会进入 LLM 提示;生成前后还有忠实度保护。 +- `RecentInsertionAnchor` 已保存进程、AX 元素、插入范围和文本,并可读当前文本/选区,用于安全延迟替换(`Sources/Output/TextInserter+RecentInsertion.swift`)。 +- 格式提示已经要求删除口头禅、合并自我纠正、补标点/分段、识别列表与口述格式;输入上下文也包含当前应用和窗口。 + +### 明确缺少 + +- 没有在用户手动编辑目标应用后读取变化并生成学习候选。 +- `PersonalDictionary.addEntry(...)` 在 `Sources/` 中没有调用点;`DictionaryStyleView` 只有自定义提示、风格和编辑规则,没有词条新增、查看、停用或删除 UI。也就是说,现有词典能力在正常产品流程里没有首个词条的来源。 +- `InputHistory` 只记录 OpenType 插入时的 raw/processed 文本,不会反映随后在外部应用发生的用户修正。 +- 词条没有来源、语言、应用范围、置信度、出现次数、最后使用时间或“自动学习/手动添加”标记。 +- 当前专业风格示例将明显的列表统一引向 `1. 2. 3.`;Typeless 的官方对比则区分无序购物清单和有序步骤。 +- 没有可审计的 `formatKind` 决策,也没有邮件的称呼/正文/落款契约;只靠一段通用提示,很难稳定复现跨模型、跨语言格式。 +- 应用名称被作为宽泛语气上下文,但没有明确的 per-app 输出 profile 和用户可见的覆盖方式。 + +## 7. 推荐给 OpenType 的方案 + +### P0:建立“只观察刚插入内容”的纠正闭环 + +新增一个短生命周期 `CorrectionCaptureSession`,复用现有插入锚点: + +1. 插入完成后保存目标 PID、AX 元素、原插入范围、原始 ASR、最终插入文本、语言、应用和时间。 +2. 仅对该元素注册文本值变化通知;Apple 官方允许用 `AXObserverCreate` 和 `AXObserverAddNotification` 监听指定应用/元素,`kAXValueChangedNotification` 表示元素 value 改变。不是所有应用都支持通知,失败时只在前台、短时、低频读取锚点附近文本,切应用或超时立即停止。参考:[AXObserverCreate](https://developer.apple.com/documentation/applicationservices/1460133-axobservercreate)、[AXObserverAddNotification](https://developer.apple.com/documentation/applicationservices/1462089-axobserveraddnotification)、[kAXValueChangedNotification](https://developer.apple.com/documentation/applicationservices/kaxvaluechangednotification)。 +3. 只对锚点范围附近做局部 diff,不扫描或保存整个文档。选择变化可用 [kAXSelectedTextChangedNotification](https://developer.apple.com/documentation/applicationservices/kaxselectedtextchangednotification) 辅助定位,但它不能替代值变化。 +4. 会话在 60 秒、焦点离开、应用切换、目标元素销毁或文本大范围重写时结束。时间只是 OpenType 的首版建议,不是 Typeless 已知参数。 +5. 密码/安全输入框、浏览器地址栏、终端密钥场景和用户禁用的应用永不学习。 + +### P0:保守候选分类,而不是“每次改字都入库” + +建议学习单元为 `CorrectionCandidate`,至少包含: + +```text +observedForm -> preferredForm +language / app / sourceRecordID +firstSeen / lastSeen / confirmationCount +confidence / origin(auto, suggested, manual) +``` + +首版规则: + +- 只接受一个连续局部替换;整句改写、单纯删除/新增、换行和标点调整不进入词库。 +- 优先姓名、品牌、产品名、CamelCase、全大写缩写、字母数字混合词和短专业术语;大小写纠正如 `api -> API` 仍有价值。 +- 普通常用词、功能词、纯数字、邮箱、URL、文件路径、疑似口令/令牌、超过 4 个词的长片段默认拒绝。 +- 高置信单一专名可立即加入,但应显示可撤销通知;其余候选重复出现 2 次后再自动加入,或先在 Dictionary 的“待确认”区域展示。 +- 同一 `preferredForm` 合并多个听错形式;发生冲突时降级为待确认,不静默覆盖用户手动词条。 + +这组规则解释了为什么一个成熟产品不会高频采集:错误学习的代价比漏学一次更高。它是对 OpenType 的设计建议,不是 Typeless 算法复刻。 + +### P0:让词条在三处生效并可验证 + +OpenType 已基本具备三层,新增自动词条后要保持一致: + +1. ASR context/hotword:提高专名首次解码召回;先保持 Apple Speech / Whisper 一致,再为 Qwen 增加上下文注入,并依据 Volc 接口能力决定是否同步。 +2. 确定性映射:已知听错形式直接替换为 preferred spelling。 +3. LLM 约束:用词典做术语选择和保护,禁止借机添加未口述事实。 + +验收不能只看词典 UI 出现,而要在相同音频的下一次输入中分别记录 raw ASR 和最终输出,确认到底是哪一层生效。 + +同时补一套真正的词库管理界面:`全部 / 自动学习 / 手动添加 / 待确认` 四个视图,支持搜索、编辑、停用、删除、清空自动词条、导入和导出。手动词条立即生效;自动候选必须能看到来源和一键撤销。 + +### P0:把格式化从“通用润色”升级为显式文体决策 + +建议先得到一个可审计的 `formatKind`,再应用对应输出契约: + +- `plainParagraph`:只做忠实纠错、断句和自然分段。 +- `unorderedList`:标题可选;同级项目使用 `-` 或 `•`,不强行编号。 +- `orderedSteps`:只有存在顺序、步骤或优先级时编号。 +- `email`:称呼、正文段落、结束语和署名分区;不凭空补称呼或落款。 +- `chat`:短段、自然语气,避免正式邮件格式。 +- `codeOrTerminal`:保护符号、路径、命令和换行,不做智能引号。 + +当前应用只应是先验,不是硬规则:Mail 中也可能写清单,Notes 中也可能写邮件。建议由口述结构、目标应用、光标周边文本三者共同判断,并把用户的纠正反馈用于 per-app 偏好,而不是永久改变所有应用。 + +### P1:风格画像与词典严格分离 + +- 词典保存离散、可查看和可删除的术语事实。 +- 风格画像只保存低维偏好,例如段落长短、列表符号、正式度、简洁度、是否保留语气词;不要存整段用户内容来“学习风格”。 +- 提供总开关、按应用覆盖、清空自动学习、导出/导入、自动词条来源标记和一键撤销。 +- 所有持久状态默认本地;若未来同步,单独说明同步字段、加密、保留和删除语义。 + +## 8. 必须先做的黑盒实验 + +在声称“复刻 Typeless”之前,用同一 Typeless 版本、同一账号和空词典跑下表,每次编辑后立即查看 Dictionary,并在下一次相同口述中验证是否生效: + +| 变量 | 水平 | +|---|---| +| 词类型 | 人名、品牌、普通名词、功能词、缩写、CamelCase、中英混排、纯数字 | +| 修改类型 | 单词替换、大小写、标点、换行、删除、插入、多词替换、整句重写 | +| 时间 | 立即、10 秒、60 秒、切应用后 | +| 次数 | 第 1、2、3、5 次相同修正 | +| 位置 | 刚插入范围内、同文本框其他位置、另一个文本框 | +| 应用 | 原生 NSTextView、浏览器 contenteditable、Electron、Terminal | +| 网络 | 在线、断网后编辑、下次联网 | + +记录 `是否入库 / 入库时间 / 展示的词形 / 下一次 raw ASR / 下一次 final / 是否跨设备`。只有这套矩阵能回答“是不是第几次、哪些关键词、多久内修改才采集”,网页材料无法回答。 + +## 9. 验收语料和指标 + +### 格式化最小集 + +- 无序清单:标题 + 3 个商品,应输出项目符号而不是编号。 +- 有序步骤:明确“第一、第二、第三”,应输出 1/2/3。 +- 邮件:称呼 + 正文 + 致谢 + 署名,应产生稳定分区但不添加内容。 +- 自我纠正:先说时间 A,再明确改为 B,只保留 B。 +- 普通状态说明:包含多个并列事项但没有清单意图,不应强行列点。 +- 未说完句子:保持未完,不由模型补结论。 +- 中英术语、数字、URL、路径和否定词:格式改善时必须保持事实精确。 + +### 词典闭环最小集 + +- `open type -> OpenType`:局部手改后形成候选,下一次 raw/final 分层验证。 +- 常用词纠正:不得自动污染词典。 +- 标点/换行调整:只更新格式偏好,不生成词条。 +- 整句重写:不得拆出伪词条。 +- 邮箱、URL、token、密码字段:不得学习。 +- 错误自动词条:撤销后立即不再影响 ASR、替换和 LLM。 + +核心指标:术语最终准确率、术语 raw ASR 召回、错误自动学习率、候选确认率、撤销率、格式类型准确率、数字/实体保真率、段落/列表结构准确率和端到端 p95 延迟。格式“看起来更漂亮”不能替代这些门禁。 + +建立真实 baseline 后,建议把首轮发布门禁定为:自动学习 precision 不低于 95%,每 100 次听写产生的错误自动词条少于 1 个;已学术语在下一次相同口述中的最终准确率不低于 90%;相对当前版本,用户后改字符距离下降至少 30%;`formatKind` 准确率不低于 90%,盲评偏好率不低于 70%;数字、URL、路径和否定词不得出现新增回归。这些是建议目标,不是本次调研已经测得的结果,最终数值应在真实基线后校准。 + +## 10. 实施状态与后续顺序 + +```text +[P0 已完成] 词条管理 UI + CorrectionCaptureSession + 局部 diff + 候选审核/隐私开关 + -> [P0 已完成] 高置信自动加入、重复证据激活、三层词典数据接入 + -> [P0 已完成] formatKind 与段落/邮件/聊天/清单/步骤/代码契约 + -> [下一步] 建立 Typeless/OpenType 对照语料与 baseline,记录 userFinalText / formatKind / dictionary event + -> [下一步] 用同一语料盲测本地小/大模型和用户自选远程模型,再决定默认质量档和路由 + -> [下一步] 为 Qwen / Volc 接入可验证的 ASR context/hotword + -> per-app 偏好与低维风格画像 + -> 真实用户 A/B 和阈值调优 +``` + +不要在没有 benchmark 的情况下直接换更大的模型来掩盖问题。若没有 post-edit 闭环、文体分类和可重复语料,再强的模型也无法知道用户固定的人名拼写,且更激进的润色会增加数字、否定和事实被改写的风险。另一方面,当前默认小模型是否构成排版质量上限也不能靠猜测,应让 2B、9B 和用户自选远程模型在同一批输入、同一提示和同一保护规则下对比,再用质量、延迟和内存数据决定是否提供质量档位。