From 282a47979073f3d34b43771de486e652f2419a5c Mon Sep 17 00:00:00 2001 From: ensan-hcl Date: Sat, 21 Feb 2026 17:57:08 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20backspace=E3=81=A7typo=20correction?= =?UTF-8?q?=E3=82=92=E7=99=BA=E5=8B=95=E3=81=99=E3=82=8B=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/InputUtils/SegmentsManager.swift | 76 +++++++++++++++++++ ...erPredictionBackspaceCorrectionTests.swift | 46 +++++++++++ .../azooKeyMacInputController.swift | 4 + 3 files changed, 126 insertions(+) create mode 100644 Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index fe279a1a..da5890ba 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -61,10 +61,12 @@ public final class SegmentsManager { private var replaceSuggestions: [Candidate] = [] private var suggestSelectionIndex: Int? + private var backspaceAdjustedPredictionCandidate: PredictionCandidate? public struct PredictionCandidate: Sendable { public var displayText: String public var appendText: String + public var deleteCount: Int = 0 } private func candidateReading(_ candidate: Candidate) -> String { @@ -191,6 +193,7 @@ public final class SegmentsManager { @MainActor public func activate() { self.shouldShowCandidateWindow = false + self.backspaceAdjustedPredictionCandidate = nil self.zenzaiPersonalizationMode = self.getZenzaiPersonalizationMode() } @@ -205,6 +208,7 @@ public final class SegmentsManager { self.shouldShowCandidateWindow = false self.selectionIndex = nil self.resetAdditionalCandidates() + self.backspaceAdjustedPredictionCandidate = nil } @MainActor @@ -218,6 +222,7 @@ public final class SegmentsManager { self.shouldShowCandidateWindow = false self.selectionIndex = nil self.resetAdditionalCandidates() + self.backspaceAdjustedPredictionCandidate = nil } @MainActor @@ -230,6 +235,7 @@ public final class SegmentsManager { self.shouldShowCandidateWindow = false self.selectionIndex = nil self.resetAdditionalCandidates() + self.backspaceAdjustedPredictionCandidate = nil } /// 変換キーを押したタイミングで入力の区切りを示す @@ -297,6 +303,8 @@ public final class SegmentsManager { @MainActor public func deleteBackwardFromCursorPosition(count: Int = 1) { + let beforeInput = self.composingText.convertTarget + let beforeFirstCandidateText = self.rawCandidates?.mainResults.first?.text ?? self.rawCandidatesList?.first?.text if !self.composingText.isAtEndIndex { // 右端に持っていく _ = self.composingText.moveCursorFromCursorPosition(count: self.composingText.convertTarget.count - self.composingText.convertTargetCursorPosition) @@ -308,6 +316,15 @@ public final class SegmentsManager { // ライブ変換がオフの場合は変換候補ウィンドウを出したい self.shouldShowCandidateWindow = !self.liveConversionEnabled self.updateRawCandidate() + self.backspaceAdjustedPredictionCandidate = if let beforeFirstCandidateText { + Self.backspaceTypoFixPredictionCandidate( + previousInput: beforeInput, + previousFirstCandidateText: beforeFirstCandidateText, + currentInput: self.composingText.convertTarget + ) + } else { + nil + } } @MainActor @@ -399,6 +416,9 @@ public final class SegmentsManager { /// - Note: /// This function is executed on the `@MainActor` to ensure UI consistency. @MainActor private func updateRawCandidate(requestRichCandidates: Bool = false, forcedLeftSideContext: String? = nil) { + if self.lastOperation != .delete { + self.backspaceAdjustedPredictionCandidate = nil + } self.resetAdditionalCandidates() // 不要 if composingText.isEmpty { @@ -728,6 +748,7 @@ public final class SegmentsManager { suggestSelectionIndex = nil } + // swiftlint:disable:next cyclomatic_complexity public func requestPredictionCandidates() -> [PredictionCandidate] { guard Config.DebugPredictiveTyping().value else { return [] @@ -748,6 +769,10 @@ public final class SegmentsManager { } matchTarget = matchTarget.toHiragana() + if let backspaceAdjustedPredictionCandidate { + return [backspaceAdjustedPredictionCandidate] + } + guard let rawCandidates else { return [] } @@ -774,6 +799,57 @@ public final class SegmentsManager { return [] } + static func backspaceTypoFixPredictionCandidate( + previousInput: String, + previousFirstCandidateText: String, + currentInput: String + ) -> PredictionCandidate? { + let typoSuffix = "くだしあ" + let fixedSuffix = "ください" + + guard let correctedReading = Self.typoFixedTextIfNeeded( + previousInput, + typoSuffix: typoSuffix, + fixedSuffix: fixedSuffix + ) else { + return nil + } + let correctedDisplayText = Self.replacingSuffix(previousFirstCandidateText, suffix: typoSuffix, replacement: fixedSuffix) ?? correctedReading + + let operation = Self.makeSuffixEditOperation(from: currentInput, to: correctedReading) + ?? Self.makeSuffixEditOperation(from: currentInput.toHiragana(), to: correctedReading) + guard let operation else { + return nil + } + + return .init(displayText: correctedDisplayText, appendText: operation.appendText, deleteCount: operation.deleteCount) + } + + private static func typoFixedTextIfNeeded(_ text: String, typoSuffix: String, fixedSuffix: String) -> String? { + if let replaced = Self.replacingSuffix(text, suffix: typoSuffix, replacement: fixedSuffix) { + return replaced + } + let hiragana = text.toHiragana() + return Self.replacingSuffix(hiragana, suffix: typoSuffix, replacement: fixedSuffix) + } + + private static func makeSuffixEditOperation(from currentText: String, to targetText: String) -> (appendText: String, deleteCount: Int)? { + let sharedPrefixLength = zip(currentText, targetText).prefix(while: ==).count + let deleteCount = currentText.count - sharedPrefixLength + let appendText = String(targetText.dropFirst(sharedPrefixLength)) + guard deleteCount > 0 || !appendText.isEmpty else { + return nil + } + return (appendText, deleteCount) + } + + private static func replacingSuffix(_ text: String, suffix: String, replacement: String) -> String? { + guard text.hasSuffix(suffix) else { + return nil + } + return String(text.dropLast(suffix.count)) + replacement + } + // swiftlint:disable:next cyclomatic_complexity public func getCurrentMarkedText(inputState: InputState) -> MarkedText { switch inputState { diff --git a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift new file mode 100644 index 00000000..a31b6676 --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift @@ -0,0 +1,46 @@ +@testable import Core +import Testing + +@Test func testBackspaceTypoFixPredictionCandidateReturnsCorrectedCandidate() async throws { + let candidate = SegmentsManager.backspaceTypoFixPredictionCandidate( + previousInput: "しょうしょうおまちくだしあ", + previousFirstCandidateText: "少々お待ちくだしあ", + currentInput: "しょうしょうおまちくだし" + ) + + #expect(candidate?.displayText == "少々お待ちください") + #expect(candidate?.appendText == "さい") + #expect(candidate?.deleteCount == 1) +} + +@Test func testBackspaceTypoFixPredictionCandidateReturnsNilWhenSuffixDoesNotMatch() async throws { + let candidate = SegmentsManager.backspaceTypoFixPredictionCandidate( + previousInput: "しょうしょうおまちください", + previousFirstCandidateText: "少々お待ちください", + currentInput: "しょうしょうおまちくださ" + ) + + #expect(candidate == nil) +} + +@Test func testBackspaceTypoFixPredictionCandidateFallsBackDisplayTextWhenCandidateSuffixDoesNotMatch() async throws { + let candidate = SegmentsManager.backspaceTypoFixPredictionCandidate( + previousInput: "やめてくだしあ", + previousFirstCandidateText: "辞めて下さい", + currentInput: "やめてくだし" + ) + + #expect(candidate?.displayText == "やめてください") + #expect(candidate?.appendText == "さい") + #expect(candidate?.deleteCount == 1) +} + +@Test func testBackspaceTypoFixPredictionCandidateReturnsNilWhenCurrentInputIsNotPrefix() async throws { + let candidate = SegmentsManager.backspaceTypoFixPredictionCandidate( + previousInput: "しょうしょうおまちくだしあ", + previousFirstCandidateText: "少々お待ちくだしあ", + currentInput: "しょうしょうおまちください" + ) + + #expect(candidate == nil) +} diff --git a/azooKeyMac/InputController/azooKeyMacInputController.swift b/azooKeyMac/InputController/azooKeyMacInputController.swift index 81fa6ada..5a883be0 100644 --- a/azooKeyMac/InputController/azooKeyMacInputController.swift +++ b/azooKeyMac/InputController/azooKeyMacInputController.swift @@ -669,6 +669,10 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s } let appendText = prediction.appendText + let deleteCount = prediction.deleteCount + if deleteCount > 0 { + self.segmentsManager.deleteBackwardFromCursorPosition(count: deleteCount) + } guard !appendText.isEmpty else { return From c1fe9cb377a29733b718bfd2706f7f57d2ceb9e3 Mon Sep 17 00:00:00 2001 From: Miwa / Ensan Date: Tue, 24 Feb 2026 01:15:28 +0900 Subject: [PATCH 2/6] feat: use lm-based typo correction --- Core/Package.swift | 2 +- .../Core/InputUtils/SegmentsManager.swift | 120 +++++++++++++++++- ...erPredictionBackspaceCorrectionTests.swift | 30 +++++ 3 files changed, 145 insertions(+), 7 deletions(-) diff --git a/Core/Package.swift b/Core/Package.swift index 0e73ae56..48d124cd 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -21,7 +21,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/azooKey/AzooKeyKanaKanjiConverter", revision: "44429812ea2f6fe1b8a759dd994c6b29eafbc88f", traits: kanaKanjiConverterTraits) + .package(url: "https://github.com/azooKey/AzooKeyKanaKanjiConverter", revision: "a442255abadd9184350cd20c562290144cffc4ef", traits: kanaKanjiConverterTraits) ], targets: [ .executableTarget( diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index da5890ba..00b1e8bb 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -31,6 +31,7 @@ public final class SegmentsManager { private let context: Context private var composingText: ComposingText = ComposingText() + private var lastInputStyle: InputStyle = .direct private var liveConversionEnabled: Bool { Config.LiveConversion().value @@ -194,6 +195,7 @@ public final class SegmentsManager { public func activate() { self.shouldShowCandidateWindow = false self.backspaceAdjustedPredictionCandidate = nil + self.lastInputStyle = .direct self.zenzaiPersonalizationMode = self.getZenzaiPersonalizationMode() } @@ -209,6 +211,7 @@ public final class SegmentsManager { self.selectionIndex = nil self.resetAdditionalCandidates() self.backspaceAdjustedPredictionCandidate = nil + self.lastInputStyle = .direct } @MainActor @@ -223,6 +226,7 @@ public final class SegmentsManager { self.selectionIndex = nil self.resetAdditionalCandidates() self.backspaceAdjustedPredictionCandidate = nil + self.lastInputStyle = .direct } @MainActor @@ -236,6 +240,7 @@ public final class SegmentsManager { self.selectionIndex = nil self.resetAdditionalCandidates() self.backspaceAdjustedPredictionCandidate = nil + self.lastInputStyle = .direct } /// 変換キーを押したタイミングで入力の区切りを示す @@ -245,6 +250,7 @@ public final class SegmentsManager { // すでに末尾がcompositionSeparatorの場合は何もしない return } + self.lastInputStyle = inputStyle self.composingText.insertAtCursorPosition([.init(piece: .compositionSeparator, inputStyle: inputStyle)]) self.lastOperation = .insert if !skipUpdate { @@ -254,6 +260,7 @@ public final class SegmentsManager { @MainActor public func insertAtCursorPosition(_ string: String, inputStyle: InputStyle) { + self.lastInputStyle = inputStyle self.composingText.insertAtCursorPosition(string, inputStyle: inputStyle) self.lastOperation = .insert // ライブ変換がオフの場合は変換候補ウィンドウを出したい @@ -263,6 +270,7 @@ public final class SegmentsManager { @MainActor public func insertAtCursorPosition(pieces: [InputPiece], inputStyle: InputStyle) { + self.lastInputStyle = inputStyle self.composingText.insertAtCursorPosition(pieces.map { .init(piece: $0, inputStyle: inputStyle) }) self.lastOperation = .insert // ライブ変換がオフの場合は変換候補ウィンドウを出したい @@ -303,6 +311,7 @@ public final class SegmentsManager { @MainActor public func deleteBackwardFromCursorPosition(count: Int = 1) { + var beforeComposingText = self.composingText.prefixToCursorPosition() let beforeInput = self.composingText.convertTarget let beforeFirstCandidateText = self.rawCandidates?.mainResults.first?.text ?? self.rawCandidatesList?.first?.text if !self.composingText.isAtEndIndex { @@ -310,18 +319,31 @@ public final class SegmentsManager { _ = self.composingText.moveCursorFromCursorPosition(count: self.composingText.convertTarget.count - self.composingText.convertTargetCursorPosition) // 一度segmentの編集状態もリセットにする self.didExperienceSegmentEdition = false + beforeComposingText = self.composingText.prefixToCursorPosition() } self.composingText.deleteBackwardFromCursorPosition(count: count) self.lastOperation = .delete // ライブ変換がオフの場合は変換候補ウィンドウを出したい self.shouldShowCandidateWindow = !self.liveConversionEnabled self.updateRawCandidate() - self.backspaceAdjustedPredictionCandidate = if let beforeFirstCandidateText { - Self.backspaceTypoFixPredictionCandidate( - previousInput: beforeInput, - previousFirstCandidateText: beforeFirstCandidateText, - currentInput: self.composingText.convertTarget - ) + let currentInput = self.composingText.convertTarget + let currentBestCandidateText = self.rawCandidates?.mainResults.first?.text ?? self.rawCandidatesList?.first?.text + let shouldTriggerTypoCorrection = Self.shouldTriggerBackspaceTypoCorrection( + deleteCount: count, + currentBestCandidateText: currentBestCandidateText, + currentInput: currentInput + ) + self.backspaceAdjustedPredictionCandidate = if shouldTriggerTypoCorrection { + self.lmBasedBackspaceTypoFixPredictionCandidate(previousComposingText: beforeComposingText, currentInput: currentInput) ?? { + guard let beforeFirstCandidateText else { + return nil + } + return Self.backspaceTypoFixPredictionCandidate( + previousInput: beforeInput, + previousFirstCandidateText: beforeFirstCandidateText, + currentInput: currentInput + ) + }() } else { nil } @@ -799,6 +821,92 @@ public final class SegmentsManager { return [] } + private func requestTypoCorrectionCandidates(composingText targetComposingText: ComposingText, inputStyle: InputStyle) -> [String] { + guard !targetComposingText.isEmpty else { + return [] + } + + let leftSideContext = self.getCleanLeftSideContext(maxCount: 30) ?? "" + let typoCandidates = self.kanaKanjiConverter.experimentalRequestTypoCorrectionOnly( + leftSideContext: leftSideContext, + composingText: targetComposingText, + options: options( + leftSideContext: leftSideContext, + requestRichCandidates: false, + requireJapanesePrediction: .disabled, + requireEnglishPrediction: .disabled + ), + inputStyle: inputStyle, + searchConfig: .init( + beamSize: 16, + topK: 32, + nBest: 3 + ) + ) + + var seen: Set = [] + return typoCandidates.compactMap { candidate in + let text = candidate.convertedText.toHiragana() + guard !text.isEmpty else { + return nil + } + guard seen.insert(text).inserted else { + return nil + } + return text + } + } + + private func convertedText(reading: String, leftSideContext: String?) -> String? { + var composingText = ComposingText() + composingText.insertAtCursorPosition(reading, inputStyle: .direct) + + let result = self.kanaKanjiConverter.requestCandidates( + composingText, + options: options( + leftSideContext: leftSideContext, + requestRichCandidates: false, + requireJapanesePrediction: .disabled, + requireEnglishPrediction: .disabled + ) + ) + return result.mainResults.first?.text + } + + @MainActor + private func lmBasedBackspaceTypoFixPredictionCandidate(previousComposingText: ComposingText, currentInput: String) -> PredictionCandidate? { + let typoCorrectionCandidates = self.requestTypoCorrectionCandidates( + composingText: previousComposingText, + inputStyle: self.lastInputStyle + ) + guard let correctedReading = typoCorrectionCandidates.first else { + return nil + } + + let operation = Self.makeSuffixEditOperation(from: currentInput, to: correctedReading) + ?? Self.makeSuffixEditOperation(from: currentInput.toHiragana(), to: correctedReading) + guard let operation else { + return nil + } + + let correctedDisplayText = self.convertedText( + reading: correctedReading, + leftSideContext: self.getCleanLeftSideContext(maxCount: 30) + ) ?? correctedReading + + return .init(displayText: correctedDisplayText, appendText: operation.appendText, deleteCount: operation.deleteCount) + } + + static func shouldTriggerBackspaceTypoCorrection(deleteCount: Int, currentBestCandidateText: String?, currentInput: String) -> Bool { + guard deleteCount == 1 else { + return false + } + if let currentBestCandidateText, currentBestCandidateText == currentInput { + return false + } + return true + } + static func backspaceTypoFixPredictionCandidate( previousInput: String, previousFirstCandidateText: String, diff --git a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift index a31b6676..5b9a8f9e 100644 --- a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift +++ b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift @@ -44,3 +44,33 @@ import Testing #expect(candidate == nil) } + +@Test func testShouldTriggerBackspaceTypoCorrectionReturnsFalseWhenBestCandidateMatchesCurrentInput() async throws { + let shouldTrigger = SegmentsManager.shouldTriggerBackspaceTypoCorrection( + deleteCount: 1, + currentBestCandidateText: "くだし", + currentInput: "くだし" + ) + + #expect(shouldTrigger == false) +} + +@Test func testShouldTriggerBackspaceTypoCorrectionReturnsTrueWhenBestCandidateDiffersFromCurrentInput() async throws { + let shouldTrigger = SegmentsManager.shouldTriggerBackspaceTypoCorrection( + deleteCount: 1, + currentBestCandidateText: "下さい", + currentInput: "くだし" + ) + + #expect(shouldTrigger == true) +} + +@Test func testShouldTriggerBackspaceTypoCorrectionReturnsFalseWhenDeleteCountIsNotOne() async throws { + let shouldTrigger = SegmentsManager.shouldTriggerBackspaceTypoCorrection( + deleteCount: 2, + currentBestCandidateText: "下さい", + currentInput: "くだし" + ) + + #expect(shouldTrigger == false) +} From 1167c5995789f82dc463534e01e5af352d50f900 Mon Sep 17 00:00:00 2001 From: Miwa / Ensan Date: Wed, 25 Feb 2026 23:33:34 +0900 Subject: [PATCH 3/6] feat: add flag guard for this feature --- Core/Sources/Core/Configs/BoolConfigItem.swift | 6 ++++++ Core/Sources/Core/InputUtils/SegmentsManager.swift | 7 +++++++ azooKeyMac/Windows/ConfigWindow.swift | 2 ++ 3 files changed, 15 insertions(+) diff --git a/Core/Sources/Core/Configs/BoolConfigItem.swift b/Core/Sources/Core/Configs/BoolConfigItem.swift index d9dfc10d..5f326294 100644 --- a/Core/Sources/Core/Configs/BoolConfigItem.swift +++ b/Core/Sources/Core/Configs/BoolConfigItem.swift @@ -32,6 +32,12 @@ extension Config { static let `default` = false public static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.debug.predictiveTyping" } + /// 入力訂正のデバッグ機能を有効化する設定 + public struct DebugTypoCorrection: BoolConfigItem { + public init() {} + static let `default` = false + public static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.debug.typoCorrection" + } /// ライブ変換を有効化する設定 public struct LiveConversion: BoolConfigItem { public init() {} diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 00b1e8bb..5979171a 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -326,6 +326,10 @@ public final class SegmentsManager { // ライブ変換がオフの場合は変換候補ウィンドウを出したい self.shouldShowCandidateWindow = !self.liveConversionEnabled self.updateRawCandidate() + guard Config.DebugTypoCorrection().value else { + self.backspaceAdjustedPredictionCandidate = nil + return + } let currentInput = self.composingText.convertTarget let currentBestCandidateText = self.rawCandidates?.mainResults.first?.text ?? self.rawCandidatesList?.first?.text let shouldTriggerTypoCorrection = Self.shouldTriggerBackspaceTypoCorrection( @@ -822,6 +826,9 @@ public final class SegmentsManager { } private func requestTypoCorrectionCandidates(composingText targetComposingText: ComposingText, inputStyle: InputStyle) -> [String] { + guard Config.DebugTypoCorrection().value else { + return [] + } guard !targetComposingText.isEmpty else { return [] } diff --git a/azooKeyMac/Windows/ConfigWindow.swift b/azooKeyMac/Windows/ConfigWindow.swift index da9276d0..bb19b4b9 100644 --- a/azooKeyMac/Windows/ConfigWindow.swift +++ b/azooKeyMac/Windows/ConfigWindow.swift @@ -17,6 +17,7 @@ struct ConfigWindow: View { @ConfigState private var inferenceLimit = Config.ZenzaiInferenceLimit() @ConfigState private var debugWindow = Config.DebugWindow() @ConfigState private var debugPredictiveTyping = Config.DebugPredictiveTyping() + @ConfigState private var debugTypoCorrection = Config.DebugTypoCorrection() @ConfigState private var userDictionary = Config.UserDictionary() @ConfigState private var systemUserDictionary = Config.SystemUserDictionary() @ConfigState private var keyboardLayout = Config.KeyboardLayout() @@ -513,6 +514,7 @@ struct ConfigWindow: View { Section { Toggle("デバッグウィンドウを有効化", isOn: $debugWindow) Toggle("開発中の予測入力を有効化", isOn: $debugPredictiveTyping) + Toggle("開発中の入力訂正を有効化", isOn: $debugTypoCorrection) Picker("パーソナライズ", selection: $zenzaiPersonalizationLevel) { Text("オフ").tag(Config.ZenzaiPersonalizationLevel.Value.off) Text("弱く").tag(Config.ZenzaiPersonalizationLevel.Value.soft) From c4ca57c1f579ce920e0c7037792fbf27ee65cbe7 Mon Sep 17 00:00:00 2001 From: Miwa / Ensan Date: Wed, 25 Feb 2026 23:35:51 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20revert=20=E3=81=8F=E3=81=A0=E3=81=95?= =?UTF-8?q?=E3=81=84-specific=20PoC=20impl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/InputUtils/SegmentsManager.swift | 54 +------------------ ...erPredictionBackspaceCorrectionTests.swift | 44 --------------- 2 files changed, 1 insertion(+), 97 deletions(-) diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 5979171a..4ae81542 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -312,8 +312,6 @@ public final class SegmentsManager { @MainActor public func deleteBackwardFromCursorPosition(count: Int = 1) { var beforeComposingText = self.composingText.prefixToCursorPosition() - let beforeInput = self.composingText.convertTarget - let beforeFirstCandidateText = self.rawCandidates?.mainResults.first?.text ?? self.rawCandidatesList?.first?.text if !self.composingText.isAtEndIndex { // 右端に持っていく _ = self.composingText.moveCursorFromCursorPosition(count: self.composingText.convertTarget.count - self.composingText.convertTargetCursorPosition) @@ -338,16 +336,7 @@ public final class SegmentsManager { currentInput: currentInput ) self.backspaceAdjustedPredictionCandidate = if shouldTriggerTypoCorrection { - self.lmBasedBackspaceTypoFixPredictionCandidate(previousComposingText: beforeComposingText, currentInput: currentInput) ?? { - guard let beforeFirstCandidateText else { - return nil - } - return Self.backspaceTypoFixPredictionCandidate( - previousInput: beforeInput, - previousFirstCandidateText: beforeFirstCandidateText, - currentInput: currentInput - ) - }() + self.lmBasedBackspaceTypoFixPredictionCandidate(previousComposingText: beforeComposingText, currentInput: currentInput) } else { nil } @@ -914,40 +903,6 @@ public final class SegmentsManager { return true } - static func backspaceTypoFixPredictionCandidate( - previousInput: String, - previousFirstCandidateText: String, - currentInput: String - ) -> PredictionCandidate? { - let typoSuffix = "くだしあ" - let fixedSuffix = "ください" - - guard let correctedReading = Self.typoFixedTextIfNeeded( - previousInput, - typoSuffix: typoSuffix, - fixedSuffix: fixedSuffix - ) else { - return nil - } - let correctedDisplayText = Self.replacingSuffix(previousFirstCandidateText, suffix: typoSuffix, replacement: fixedSuffix) ?? correctedReading - - let operation = Self.makeSuffixEditOperation(from: currentInput, to: correctedReading) - ?? Self.makeSuffixEditOperation(from: currentInput.toHiragana(), to: correctedReading) - guard let operation else { - return nil - } - - return .init(displayText: correctedDisplayText, appendText: operation.appendText, deleteCount: operation.deleteCount) - } - - private static func typoFixedTextIfNeeded(_ text: String, typoSuffix: String, fixedSuffix: String) -> String? { - if let replaced = Self.replacingSuffix(text, suffix: typoSuffix, replacement: fixedSuffix) { - return replaced - } - let hiragana = text.toHiragana() - return Self.replacingSuffix(hiragana, suffix: typoSuffix, replacement: fixedSuffix) - } - private static func makeSuffixEditOperation(from currentText: String, to targetText: String) -> (appendText: String, deleteCount: Int)? { let sharedPrefixLength = zip(currentText, targetText).prefix(while: ==).count let deleteCount = currentText.count - sharedPrefixLength @@ -958,13 +913,6 @@ public final class SegmentsManager { return (appendText, deleteCount) } - private static func replacingSuffix(_ text: String, suffix: String, replacement: String) -> String? { - guard text.hasSuffix(suffix) else { - return nil - } - return String(text.dropLast(suffix.count)) + replacement - } - // swiftlint:disable:next cyclomatic_complexity public func getCurrentMarkedText(inputState: InputState) -> MarkedText { switch inputState { diff --git a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift index 5b9a8f9e..85d0655c 100644 --- a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift +++ b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift @@ -1,50 +1,6 @@ @testable import Core import Testing -@Test func testBackspaceTypoFixPredictionCandidateReturnsCorrectedCandidate() async throws { - let candidate = SegmentsManager.backspaceTypoFixPredictionCandidate( - previousInput: "しょうしょうおまちくだしあ", - previousFirstCandidateText: "少々お待ちくだしあ", - currentInput: "しょうしょうおまちくだし" - ) - - #expect(candidate?.displayText == "少々お待ちください") - #expect(candidate?.appendText == "さい") - #expect(candidate?.deleteCount == 1) -} - -@Test func testBackspaceTypoFixPredictionCandidateReturnsNilWhenSuffixDoesNotMatch() async throws { - let candidate = SegmentsManager.backspaceTypoFixPredictionCandidate( - previousInput: "しょうしょうおまちください", - previousFirstCandidateText: "少々お待ちください", - currentInput: "しょうしょうおまちくださ" - ) - - #expect(candidate == nil) -} - -@Test func testBackspaceTypoFixPredictionCandidateFallsBackDisplayTextWhenCandidateSuffixDoesNotMatch() async throws { - let candidate = SegmentsManager.backspaceTypoFixPredictionCandidate( - previousInput: "やめてくだしあ", - previousFirstCandidateText: "辞めて下さい", - currentInput: "やめてくだし" - ) - - #expect(candidate?.displayText == "やめてください") - #expect(candidate?.appendText == "さい") - #expect(candidate?.deleteCount == 1) -} - -@Test func testBackspaceTypoFixPredictionCandidateReturnsNilWhenCurrentInputIsNotPrefix() async throws { - let candidate = SegmentsManager.backspaceTypoFixPredictionCandidate( - previousInput: "しょうしょうおまちくだしあ", - previousFirstCandidateText: "少々お待ちくだしあ", - currentInput: "しょうしょうおまちください" - ) - - #expect(candidate == nil) -} - @Test func testShouldTriggerBackspaceTypoCorrectionReturnsFalseWhenBestCandidateMatchesCurrentInput() async throws { let shouldTrigger = SegmentsManager.shouldTriggerBackspaceTypoCorrection( deleteCount: 1, From efaa21f6c91e8dae6105ea4d8a2820a6363af3e5 Mon Sep 17 00:00:00 2001 From: Miwa / Ensan Date: Mon, 2 Mar 2026 23:10:39 +0900 Subject: [PATCH 5/6] feat: n-gram based tc and on-demand weight downloading --- Core/Package.swift | 8 +- Core/Sources/Core/Configs/AppGroup.swift | 5 + .../DebugTypoCorrectionWeights.swift | 144 +++++++++++++++++ .../Core/InputUtils/SegmentsManager.swift | 88 ++++++++-- ...erPredictionBackspaceCorrectionTests.swift | 24 +++ .../azooKeyMacInputController.swift | 2 +- .../azooKeyMacInputControllerHelper.swift | 2 + azooKeyMac/Windows/ConfigWindow.swift | 150 +++++++++++++++++- 8 files changed, 405 insertions(+), 18 deletions(-) create mode 100644 Core/Sources/Core/Configs/AppGroup.swift create mode 100644 Core/Sources/Core/InputUtils/DebugTypoCorrectionWeights.swift diff --git a/Core/Package.swift b/Core/Package.swift index 48d124cd..c9a4bc81 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -21,7 +21,9 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/azooKey/AzooKeyKanaKanjiConverter", revision: "a442255abadd9184350cd20c562290144cffc4ef", traits: kanaKanjiConverterTraits) + .package(url: "https://github.com/azooKey/AzooKeyKanaKanjiConverter", revision: "23544d6ea30822fd498caeff2dbc04d78b268134", traits: kanaKanjiConverterTraits), + .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), + .package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.0") ], targets: [ .executableTarget( @@ -36,7 +38,9 @@ let package = Package( name: "Core", dependencies: [ .product(name: "SwiftUtils", package: "AzooKeyKanaKanjiConverter"), - .product(name: "KanaKanjiConverterModuleWithDefaultDictionary", package: "AzooKeyKanaKanjiConverter") + .product(name: "KanaKanjiConverterModuleWithDefaultDictionary", package: "AzooKeyKanaKanjiConverter"), + .product(name: "Crypto", package: "swift-crypto"), + .product(name: "ZIPFoundation", package: "ZIPFoundation") ], swiftSettings: [.interoperabilityMode(.Cxx)], plugins: [ diff --git a/Core/Sources/Core/Configs/AppGroup.swift b/Core/Sources/Core/Configs/AppGroup.swift new file mode 100644 index 00000000..25a0ab62 --- /dev/null +++ b/Core/Sources/Core/Configs/AppGroup.swift @@ -0,0 +1,5 @@ +import Foundation + +public enum AppGroup { + public static let azooKeyMacIdentifier = "group.dev.ensan.inputmethod.azooKeyMac" +} diff --git a/Core/Sources/Core/InputUtils/DebugTypoCorrectionWeights.swift b/Core/Sources/Core/InputUtils/DebugTypoCorrectionWeights.swift new file mode 100644 index 00000000..264d95cc --- /dev/null +++ b/Core/Sources/Core/InputUtils/DebugTypoCorrectionWeights.swift @@ -0,0 +1,144 @@ +import Crypto +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import ZIPFoundation + +public enum DebugTypoCorrectionState: Sendable, Equatable { + case downloaded + case failed + case notDownloaded +} + +public enum DebugTypoCorrectionWeightsError: LocalizedError, Sendable { + case invalidHTTPStatus(url: URL, statusCode: Int) + case hashMismatch(fileName: String, expected: String, actual: String) + case extractedFolderNotFound(path: String) + + public var errorDescription: String? { + switch self { + case .invalidHTTPStatus(let url, let statusCode): + return "Failed to download \(url.lastPathComponent) (HTTP \(statusCode))" + case .hashMismatch(let fileName, let expected, let actual): + return "Hash mismatch for \(fileName). expected=\(expected), actual=\(actual)" + case .extractedFolderNotFound(let path): + return "Extracted folder not found at \(path)" + } + } +} + +public enum DebugTypoCorrectionWeights { + public struct RequiredFile: Sendable, Equatable { + public let fileName: String + public let md5: String + } + + public static let bundleDirectoryName = "input_n5_lm_v1" + + public static let requiredFiles: [RequiredFile] = [ + .init(fileName: "lm_c_abc.marisa", md5: "cb0c5c156eae8b16e9ddd0757d029263"), + .init(fileName: "lm_c_bc.marisa", md5: "49a68be03c58d67fdf078bcb48bce4a2"), + .init(fileName: "lm_r_xbx.marisa", md5: "d95157d1ff815b8d3e42b43660fdfa2f"), + .init(fileName: "lm_u_abx.marisa", md5: "9d3d1be564f78e4f4ca2ec7629a2b80b"), + .init(fileName: "lm_u_xbc.marisa", md5: "2c0f4652f78e8647cc70ab8eceba9b58") + ] + + private static let zipURL = URL(string: "https://huggingface.co/Miwa-Keita/input_n5_lm_v1/resolve/main/input_n5_lm_v1.zip")! + + public static var requiredFileNames: [String] { + Self.requiredFiles.map(\.fileName) + } + + public static func modelDirectoryURL(azooKeyApplicationSupportDirectoryURL: URL) -> URL { + azooKeyApplicationSupportDirectoryURL + .appendingPathComponent("downloaded", isDirectory: true) + .appendingPathComponent(Self.bundleDirectoryName, isDirectory: true) + } + + public static func hasRequiredWeightFiles(modelDirectoryURL: URL) -> Bool { + Self.requiredFiles.allSatisfy { + FileManager.default.fileExists(atPath: modelDirectoryURL.appendingPathComponent($0.fileName).path) + } + } + + public static func state(modelDirectoryURL: URL) -> DebugTypoCorrectionState { + do { + return try Self.validateWeights(modelDirectoryURL: modelDirectoryURL) ? .downloaded : .notDownloaded + } catch { + return .failed + } + } + + public static func validateWeights(modelDirectoryURL: URL) throws -> Bool { + for required in Self.requiredFiles { + let fileURL = modelDirectoryURL.appendingPathComponent(required.fileName) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return false + } + let md5 = try Self.fileMD5HexString(fileURL: fileURL) + guard md5 == required.md5 else { + return false + } + } + return true + } + + public static func downloadWeights(modelDirectoryURL: URL) async throws { + let fileManager = FileManager.default + let parentDirectoryURL = modelDirectoryURL.deletingLastPathComponent() + try fileManager.createDirectory(at: parentDirectoryURL, withIntermediateDirectories: true) + + let temporaryRootURL = fileManager.temporaryDirectory + .appendingPathComponent("azookey-debug-tc-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: temporaryRootURL, withIntermediateDirectories: true) + defer { + try? fileManager.removeItem(at: temporaryRootURL) + } + + let downloadedZipTemporaryURL = temporaryRootURL.appendingPathComponent("input_n5_lm_v1.zip", isDirectory: false) + let (temporaryFileURL, response) = try await URLSession.shared.download(from: Self.zipURL) + if let httpResponse = response as? HTTPURLResponse, !(200 ... 299).contains(httpResponse.statusCode) { + throw DebugTypoCorrectionWeightsError.invalidHTTPStatus(url: Self.zipURL, statusCode: httpResponse.statusCode) + } + try fileManager.moveItem(at: temporaryFileURL, to: downloadedZipTemporaryURL) + + let extractionRootURL = temporaryRootURL.appendingPathComponent("extracted", isDirectory: true) + try fileManager.unzipItem(at: downloadedZipTemporaryURL, to: extractionRootURL) + + let stagingDirectoryURL = extractionRootURL.appendingPathComponent(Self.bundleDirectoryName, isDirectory: true) + guard fileManager.fileExists(atPath: stagingDirectoryURL.path) else { + throw DebugTypoCorrectionWeightsError.extractedFolderNotFound(path: stagingDirectoryURL.path) + } + + for required in Self.requiredFiles { + let fileURL = stagingDirectoryURL.appendingPathComponent(required.fileName, isDirectory: false) + let actualMD5 = try Self.fileMD5HexString(fileURL: fileURL) + guard actualMD5 == required.md5 else { + throw DebugTypoCorrectionWeightsError.hashMismatch(fileName: required.fileName, expected: required.md5, actual: actualMD5) + } + } + + if fileManager.fileExists(atPath: modelDirectoryURL.path) { + try fileManager.removeItem(at: modelDirectoryURL) + } + try fileManager.moveItem(at: stagingDirectoryURL, to: modelDirectoryURL) + } + + private static func fileMD5HexString(fileURL: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: fileURL) + defer { + try? handle.close() + } + + var md5 = Insecure.MD5() + while true { + let data = try handle.read(upToCount: 1_048_576) ?? Data() + if data.isEmpty { + break + } + md5.update(data: data) + } + return md5.finalize().map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 4ae81542..461523ba 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -63,6 +63,7 @@ public final class SegmentsManager { private var replaceSuggestions: [Candidate] = [] private var suggestSelectionIndex: Int? private var backspaceAdjustedPredictionCandidate: PredictionCandidate? + private var backspaceTypoCorrectionLock: BackspaceTypoCorrectionLock? public struct PredictionCandidate: Sendable { public var displayText: String @@ -70,6 +71,11 @@ public final class SegmentsManager { public var deleteCount: Int = 0 } + struct BackspaceTypoCorrectionLock: Sendable { + var displayText: String + var targetReading: String + } + private func candidateReading(_ candidate: Candidate) -> String { candidate.data.map(\.ruby).joined() } @@ -170,7 +176,8 @@ public final class SegmentsManager { requireJapanesePrediction: ConvertRequestOptions.PredictionMode, requireEnglishPrediction: ConvertRequestOptions.PredictionMode ) -> ConvertRequestOptions { - .init( + let canUseDebugTypoCorrection = Config.DebugTypoCorrection().value && self.hasDebugTypoCorrectionWeights() + return .init( requireJapanesePrediction: requireJapanesePrediction, requireEnglishPrediction: requireEnglishPrediction, keyboardLanguage: .ja_JP, @@ -183,18 +190,33 @@ public final class SegmentsManager { specialCandidateProviders: KanaKanjiConverter.defaultSpecialCandidateProviders, zenzaiMode: self.zenzaiMode(leftSideContext: leftSideContext, requestRichCandidates: requestRichCandidates), experimentalZenzaiPredictiveInput: true, + typoCorrectionConfig: .init( + mode: canUseDebugTypoCorrection ? .noisyChannel : .auto, + languageModel: .ngram(.init(prefix: self.downloadedInputN5LMDir.path + "/lm_", n: 5, d: 0.75)) + ), metadata: self.metadata ) } + private func hasDebugTypoCorrectionWeights() -> Bool { + DebugTypoCorrectionWeights.hasRequiredWeightFiles(modelDirectoryURL: self.downloadedInputN5LMDir) + } + public var azooKeyMemoryDir: URL { self.applicationDirectoryURL } + public var downloadedInputN5LMDir: URL { + DebugTypoCorrectionWeights.modelDirectoryURL( + azooKeyApplicationSupportDirectoryURL: self.applicationDirectoryURL.deletingLastPathComponent() + ) + } + @MainActor public func activate() { self.shouldShowCandidateWindow = false self.backspaceAdjustedPredictionCandidate = nil + self.backspaceTypoCorrectionLock = nil self.lastInputStyle = .direct self.zenzaiPersonalizationMode = self.getZenzaiPersonalizationMode() } @@ -211,6 +233,7 @@ public final class SegmentsManager { self.selectionIndex = nil self.resetAdditionalCandidates() self.backspaceAdjustedPredictionCandidate = nil + self.backspaceTypoCorrectionLock = nil self.lastInputStyle = .direct } @@ -226,6 +249,7 @@ public final class SegmentsManager { self.selectionIndex = nil self.resetAdditionalCandidates() self.backspaceAdjustedPredictionCandidate = nil + self.backspaceTypoCorrectionLock = nil self.lastInputStyle = .direct } @@ -240,6 +264,7 @@ public final class SegmentsManager { self.selectionIndex = nil self.resetAdditionalCandidates() self.backspaceAdjustedPredictionCandidate = nil + self.backspaceTypoCorrectionLock = nil self.lastInputStyle = .direct } @@ -324,21 +349,48 @@ public final class SegmentsManager { // ライブ変換がオフの場合は変換候補ウィンドウを出したい self.shouldShowCandidateWindow = !self.liveConversionEnabled self.updateRawCandidate() - guard Config.DebugTypoCorrection().value else { + guard Config.DebugTypoCorrection().value && self.hasDebugTypoCorrectionWeights() else { self.backspaceAdjustedPredictionCandidate = nil + self.backspaceTypoCorrectionLock = nil return } let currentInput = self.composingText.convertTarget + guard count == 1 else { + self.backspaceAdjustedPredictionCandidate = nil + self.backspaceTypoCorrectionLock = nil + return + } + if let lock = self.backspaceTypoCorrectionLock { + self.backspaceAdjustedPredictionCandidate = Self.makeBackspaceTypoCorrectionPredictionCandidate( + currentInput: currentInput, + targetReading: lock.targetReading, + displayText: lock.displayText + ) + if self.backspaceAdjustedPredictionCandidate == nil { + self.backspaceTypoCorrectionLock = nil + } + return + } let currentBestCandidateText = self.rawCandidates?.mainResults.first?.text ?? self.rawCandidatesList?.first?.text let shouldTriggerTypoCorrection = Self.shouldTriggerBackspaceTypoCorrection( deleteCount: count, currentBestCandidateText: currentBestCandidateText, currentInput: currentInput ) - self.backspaceAdjustedPredictionCandidate = if shouldTriggerTypoCorrection { - self.lmBasedBackspaceTypoFixPredictionCandidate(previousComposingText: beforeComposingText, currentInput: currentInput) + guard shouldTriggerTypoCorrection else { + self.backspaceAdjustedPredictionCandidate = nil + self.backspaceTypoCorrectionLock = nil + return + } + self.backspaceTypoCorrectionLock = self.lmBasedBackspaceTypoCorrectionLock(previousComposingText: beforeComposingText) + if let lock = self.backspaceTypoCorrectionLock { + self.backspaceAdjustedPredictionCandidate = Self.makeBackspaceTypoCorrectionPredictionCandidate( + currentInput: currentInput, + targetReading: lock.targetReading, + displayText: lock.displayText + ) } else { - nil + self.backspaceAdjustedPredictionCandidate = nil } } @@ -433,6 +485,7 @@ public final class SegmentsManager { @MainActor private func updateRawCandidate(requestRichCandidates: Bool = false, forcedLeftSideContext: String? = nil) { if self.lastOperation != .delete { self.backspaceAdjustedPredictionCandidate = nil + self.backspaceTypoCorrectionLock = nil } self.resetAdditionalCandidates() // 不要 @@ -815,7 +868,7 @@ public final class SegmentsManager { } private func requestTypoCorrectionCandidates(composingText targetComposingText: ComposingText, inputStyle: InputStyle) -> [String] { - guard Config.DebugTypoCorrection().value else { + guard Config.DebugTypoCorrection().value && self.hasDebugTypoCorrectionWeights() else { return [] } guard !targetComposingText.isEmpty else { @@ -870,7 +923,7 @@ public final class SegmentsManager { } @MainActor - private func lmBasedBackspaceTypoFixPredictionCandidate(previousComposingText: ComposingText, currentInput: String) -> PredictionCandidate? { + private func lmBasedBackspaceTypoCorrectionLock(previousComposingText: ComposingText) -> BackspaceTypoCorrectionLock? { let typoCorrectionCandidates = self.requestTypoCorrectionCandidates( composingText: previousComposingText, inputStyle: self.lastInputStyle @@ -879,18 +932,25 @@ public final class SegmentsManager { return nil } - let operation = Self.makeSuffixEditOperation(from: currentInput, to: correctedReading) - ?? Self.makeSuffixEditOperation(from: currentInput.toHiragana(), to: correctedReading) - guard let operation else { - return nil - } - let correctedDisplayText = self.convertedText( reading: correctedReading, leftSideContext: self.getCleanLeftSideContext(maxCount: 30) ) ?? correctedReading - return .init(displayText: correctedDisplayText, appendText: operation.appendText, deleteCount: operation.deleteCount) + return .init(displayText: correctedDisplayText, targetReading: correctedReading) + } + + static func makeBackspaceTypoCorrectionPredictionCandidate( + currentInput: String, + targetReading: String, + displayText: String + ) -> PredictionCandidate? { + let operation = Self.makeSuffixEditOperation(from: currentInput, to: targetReading) + ?? Self.makeSuffixEditOperation(from: currentInput.toHiragana(), to: targetReading) + guard let operation else { + return nil + } + return .init(displayText: displayText, appendText: operation.appendText, deleteCount: operation.deleteCount) } static func shouldTriggerBackspaceTypoCorrection(deleteCount: Int, currentBestCandidateText: String?, currentInput: String) -> Bool { diff --git a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift index 85d0655c..53629b58 100644 --- a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift +++ b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift @@ -30,3 +30,27 @@ import Testing #expect(shouldTrigger == false) } + +@Test func testMakeBackspaceTypoCorrectionPredictionCandidateRecalculatesEditOperationForCurrentInput() async throws { + let candidate = SegmentsManager.makeBackspaceTypoCorrectionPredictionCandidate( + currentInput: "くだし", + targetReading: "ください", + displayText: "下さい" + ) + + #expect(candidate?.displayText == "下さい") + #expect(candidate?.appendText == "さい") + #expect(candidate?.deleteCount == 1) +} + +@Test func testMakeBackspaceTypoCorrectionPredictionCandidateKeepsDisplayTextAndUpdatesAppendTextOnFurtherDelete() async throws { + let candidate = SegmentsManager.makeBackspaceTypoCorrectionPredictionCandidate( + currentInput: "くだ", + targetReading: "ください", + displayText: "下さい" + ) + + #expect(candidate?.displayText == "下さい") + #expect(candidate?.appendText == "さい") + #expect(candidate?.deleteCount == 0) +} diff --git a/azooKeyMac/InputController/azooKeyMacInputController.swift b/azooKeyMac/InputController/azooKeyMacInputController.swift index 6860b586..4fb1bdd4 100644 --- a/azooKeyMac/InputController/azooKeyMacInputController.swift +++ b/azooKeyMac/InputController/azooKeyMacInputController.swift @@ -110,7 +110,7 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s .appendingPathComponent("memory", isDirectory: true) } - let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.dev.ensan.inputmethod.azooKeyMac") + let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppGroup.azooKeyMacIdentifier) self.segmentsManager = SegmentsManager( kanaKanjiConverter: (NSApplication.shared.delegate as? AppDelegate)!.kanaKanjiConverter, applicationDirectoryURL: applicationDirectoryURL, diff --git a/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift b/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift index ae866db8..c9346d83 100644 --- a/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift +++ b/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift @@ -109,6 +109,8 @@ extension azooKeyMacInputController { do { self.segmentsManager.appendDebugMessage("\(#line): Applicatiion Support Directory Path: \(self.segmentsManager.azooKeyMemoryDir)") try FileManager.default.createDirectory(at: self.segmentsManager.azooKeyMemoryDir, withIntermediateDirectories: true) + self.segmentsManager.appendDebugMessage("\(#line): Debug TypoCorrection Download Directory Path: \(self.segmentsManager.downloadedInputN5LMDir)") + try FileManager.default.createDirectory(at: self.segmentsManager.downloadedInputN5LMDir, withIntermediateDirectories: true) } catch { self.segmentsManager.appendDebugMessage("\(#line): \(error.localizedDescription)") } diff --git a/azooKeyMac/Windows/ConfigWindow.swift b/azooKeyMac/Windows/ConfigWindow.swift index bb19b4b9..16d52c7a 100644 --- a/azooKeyMac/Windows/ConfigWindow.swift +++ b/azooKeyMac/Windows/ConfigWindow.swift @@ -35,6 +35,9 @@ struct ConfigWindow: View { @State private var learningResetMessage: LearningResetMessage? @State private var foundationModelsAvailability: FoundationModelsAvailability? @State private var availabilityCheckDone = false + @State private var debugTypoCorrectionState: DebugTypoCorrectionState = .notDownloaded + @State private var debugTypoCorrectionDownloadInProgress = false + @State private var debugTypoCorrectionErrorMessage: String? private enum Tab: String, CaseIterable, Hashable { case basic = "基本" @@ -60,6 +63,90 @@ struct ConfigWindow: View { case successfulUpdate } + private var azooKeyApplicationSupportDirectoryURL: URL { + if #available(macOS 13, *) { + URL.applicationSupportDirectory + .appending(path: "azooKey", directoryHint: .isDirectory) + } else { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + .appendingPathComponent("azooKey", isDirectory: true) + } + } + + private var debugTypoCorrectionModelDirectoryURL: URL { + DebugTypoCorrectionWeights.modelDirectoryURL( + azooKeyApplicationSupportDirectoryURL: self.azooKeyApplicationSupportDirectoryURL + ) + } + + private var debugTypoCorrectionStatusText: String { + if self.debugTypoCorrectionDownloadInProgress { + return "ダウンロード中..." + } + switch self.debugTypoCorrectionState { + case .downloaded: + return "重み: ダウンロード済み" + case .failed: + return "重み: ダウンロード失敗" + case .notDownloaded: + return "重み: ダウンロード未実施" + } + } + + @MainActor + private func refreshDebugTypoCorrectionState() async { + let modelDirectoryURL = self.debugTypoCorrectionModelDirectoryURL + let state = await Task.detached(priority: .utility) { + DebugTypoCorrectionWeights.state(modelDirectoryURL: modelDirectoryURL) + }.value + self.debugTypoCorrectionState = state + if state != .failed { + self.debugTypoCorrectionErrorMessage = nil + } + } + + @MainActor + private func downloadDebugTypoCorrectionWeights() { + guard !self.debugTypoCorrectionDownloadInProgress else { + return + } + self.debugTypoCorrectionDownloadInProgress = true + self.debugTypoCorrectionErrorMessage = nil + + let modelDirectoryURL = self.debugTypoCorrectionModelDirectoryURL + Task { + do { + try await DebugTypoCorrectionWeights.downloadWeights(modelDirectoryURL: modelDirectoryURL) + let state = await Task.detached(priority: .utility) { + DebugTypoCorrectionWeights.state(modelDirectoryURL: modelDirectoryURL) + }.value + await MainActor.run { + self.debugTypoCorrectionState = state + self.debugTypoCorrectionErrorMessage = state == .failed ? "ダウンロード後の整合性チェックに失敗しました" : nil + self.debugTypoCorrectionDownloadInProgress = false + } + } catch { + await MainActor.run { + self.debugTypoCorrectionState = .failed + self.debugTypoCorrectionErrorMessage = error.localizedDescription + self.debugTypoCorrectionDownloadInProgress = false + } + } + } + } + + private func openAzooKeyDataDirectoryInFinder() { + do { + try FileManager.default.createDirectory( + at: self.azooKeyApplicationSupportDirectoryURL, + withIntermediateDirectories: true + ) + NSWorkspace.shared.activateFileViewerSelecting([self.azooKeyApplicationSupportDirectoryURL]) + } catch { + // no-op + } + } + private func getErrorMessage(for error: OpenAIError) -> String { switch error { case .invalidURL: @@ -514,13 +601,74 @@ struct ConfigWindow: View { Section { Toggle("デバッグウィンドウを有効化", isOn: $debugWindow) Toggle("開発中の予測入力を有効化", isOn: $debugPredictiveTyping) - Toggle("開発中の入力訂正を有効化", isOn: $debugTypoCorrection) + VStack(alignment: .leading, spacing: 6) { + Toggle("開発中の入力訂正を有効化", isOn: $debugTypoCorrection) + if self.debugTypoCorrection.value { + HStack { + Text(self.debugTypoCorrectionStatusText) + .font(.caption) + .foregroundStyle(.secondary) + if self.debugTypoCorrectionDownloadInProgress { + ProgressView() + .controlSize(.small) + } + Spacer() + switch self.debugTypoCorrectionState { + case .downloaded: + EmptyView() + case .failed: + Button("再ダウンロード") { + self.downloadDebugTypoCorrectionWeights() + } + .disabled(self.debugTypoCorrectionDownloadInProgress) + case .notDownloaded: + Button("ダウンロード") { + self.downloadDebugTypoCorrectionWeights() + } + .disabled(self.debugTypoCorrectionDownloadInProgress) + } + } + if case .failed = self.debugTypoCorrectionState, + let errorMessage = self.debugTypoCorrectionErrorMessage { + Text(errorMessage) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .onAppear { + guard self.debugTypoCorrection.value else { + return + } + Task { @MainActor in + await self.refreshDebugTypoCorrectionState() + } + } + .onChange(of: self.debugTypoCorrection.value) { enabled in + if enabled { + Task { @MainActor in + await self.refreshDebugTypoCorrectionState() + } + } else { + self.debugTypoCorrectionState = .notDownloaded + self.debugTypoCorrectionDownloadInProgress = false + self.debugTypoCorrectionErrorMessage = nil + } + } Picker("パーソナライズ", selection: $zenzaiPersonalizationLevel) { Text("オフ").tag(Config.ZenzaiPersonalizationLevel.Value.off) Text("弱く").tag(Config.ZenzaiPersonalizationLevel.Value.soft) Text("普通").tag(Config.ZenzaiPersonalizationLevel.Value.normal) Text("強く").tag(Config.ZenzaiPersonalizationLevel.Value.hard) } + LabeledContent("アプリデータ") { + HStack { + Button("Finderで開く") { + self.openAzooKeyDataDirectoryInFinder() + } + } + } } header: { Label("開発者向け設定", systemImage: "hammer") } From f67b546e5f3b18571912dddbd916ce14aaa105a6 Mon Sep 17 00:00:00 2001 From: Miwa / Ensan Date: Mon, 2 Mar 2026 23:49:11 +0900 Subject: [PATCH 6/6] fix: minor bug --- .../azooKeyMacInputController.swift | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/azooKeyMac/InputController/azooKeyMacInputController.swift b/azooKeyMac/InputController/azooKeyMacInputController.swift index 4fb1bdd4..7edb3e1e 100644 --- a/azooKeyMac/InputController/azooKeyMacInputController.swift +++ b/azooKeyMac/InputController/azooKeyMacInputController.swift @@ -713,24 +713,11 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s guard let prediction = predictions.first else { return } - - let currentTarget = self.segmentsManager.convertTarget - var matchTarget = currentTarget - if let last = matchTarget.last, - last.unicodeScalars.allSatisfy({ $0.isASCII && CharacterSet.letters.contains($0) }) { - matchTarget.removeLast() - self.segmentsManager.deleteBackwardFromCursorPosition(count: 1) - } - - guard !matchTarget.isEmpty else { - return - } - - let appendText = prediction.appendText let deleteCount = prediction.deleteCount if deleteCount > 0 { self.segmentsManager.deleteBackwardFromCursorPosition(count: deleteCount) } + let appendText = prediction.appendText guard !appendText.isEmpty else { return