diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f8da29c6..5cf87ddd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -266,8 +266,12 @@ or endpoint mode so mapped weights and Metal buffers do not stay resident unnece Context sources are independently enabled and bounded: recent AX prefix/trailing text, surface metadata, user rules/extended context, relevant clipboard content, visual OCR, language, and settings. -[PromptContextSanitizer.swift](Cotabby/Support/Context/PromptContextSanitizer.swift) sanitizes optional text, -and prompt renderers apply per-section budgets. +[PromptContextSanitizer.swift](Cotabby/Support/Context/PromptContextSanitizer.swift) sanitizes optional text +and extracts Latin terms plus CJK bigrams. [ContextRelevanceSelector.swift](Cotabby/Support/Context/ContextRelevanceSelector.swift) +then ranks clipboard and OCR lines against the live caret prefix under source-local line/character caps. +Local engines retain a bounded visual fallback for semantically related text without lexical overlap; +configured endpoints receive visual text only when relevance evidence survives. Prompt renderers apply +the remaining global section budget. [ClipboardContextProvider.swift](Cotabby/Services/Context/ClipboardContextProvider.swift) reads a fresh bounded value at request time rather than recording clipboard history. Relevance and distillation @@ -403,8 +407,8 @@ policies, prompt utilities, normalization, and layout logic should receive focus | Rapid Tab leaks to the host after exhausting a tail | PostExhaustionAcceptanceState, acceptance coordinator | | Acceptance key passes through or is stolen | InputMonitor, acceptance validation | | Wrong or repeated inserted text | SuggestionInserter, InputSuppressionController, reconciler | -| Clipboard context is irrelevant | ClipboardRelevanceFilter, ClipboardContentDistiller | -| Screenshot context is stale/noisy | VisualContextCoordinator, OCRTextHygiene | +| Clipboard context is irrelevant | ClipboardRelevanceFilter, ClipboardContentDistiller, ContextRelevanceSelector | +| Screenshot context is stale/noisy | VisualContextCoordinator, OCRTextHygiene, ContextRelevanceSelector | | Emoji or macro conflicts with suggestions | InlineCommandCoordinator, feature trigger machine | | Permission loop or lost grant | PermissionManager, PermissionGuidanceController, app identity | | Settings/onboarding window issue | SettingsCoordinator, WelcomeCoordinator | diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 2ee749ed..a6c52042 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -136,6 +136,7 @@ 27A7976690FC7FDA7EE364D8 /* SuggestionTextColorCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CF768650A90705FC0D2730 /* SuggestionTextColorCodec.swift */; }; 27B1A99D61398752863DB251 /* RequestIDTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E80071E4A5D844CEF5B7C487 /* RequestIDTests.swift */; }; 27D4F5CACADE171F142178B4 /* SettingsSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADB38D0160B47637572FC5E /* SettingsSidebarView.swift */; }; + 288DC9575FDCA1370AA73185 /* ContextRelevanceSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D45B7D55C95DB033B3A8793E /* ContextRelevanceSelector.swift */; }; 28B4967D21137565D652A953 /* InsertionSafetyGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89746E8CE7E9487337EE6F /* InsertionSafetyGate.swift */; }; 28D217A96946A2005FCBEBFD /* emoji.json in Resources */ = {isa = PBXBuildFile; fileRef = C379D77029D6E88C8C1B9AF7 /* emoji.json */; }; 2905970239BB48AD6AA95A92 /* PermissionDragSourceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F000304686E4573BC351AD3F /* PermissionDragSourceView.swift */; }; @@ -175,6 +176,7 @@ 33EC776945F8CE612A8E85A3 /* CustomRulesEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7D1184F0BC707F0A9148DD /* CustomRulesEditor.swift */; }; 35F1681148A324CBEC797BAF /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DE038A6DF4A16B3EB5CEBD3 /* MenuBarView.swift */; }; 36DBF7CA5EC371E4AA860843 /* SpellingDictionaryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72B8156EEF23664F775190AB /* SpellingDictionaryPicker.swift */; }; + 36EE3D0E98A4E1CA9EB62436 /* ContextRelevanceSelectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95ACE8721762F01C8146BE4C /* ContextRelevanceSelectorTests.swift */; }; 37029DDB055255BA5419C4FA /* WelcomePermissionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F952E54538EF82D1B1E672D8 /* WelcomePermissionStepView.swift */; }; 37625DC44E2228CC897222B7 /* ru-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6CF1FBAABEF545B620AF8D78 /* ru-100k.txt */; }; 37644CB48FCDF44AFA5EBA6E /* EmojiCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD93D7DE258C8E5367A433D /* EmojiCatalog.swift */; }; @@ -535,6 +537,7 @@ AD0FE3F0F75A40B827109589 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3350EDE01ED5125520C79D53 /* SettingsCoordinator.swift */; }; AD7940C54015532D2E8973DD /* CompositionInputModeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9316289DD11A72D3EAC0A929 /* CompositionInputModeClassifier.swift */; }; ADB10BCA76295920FE4A9FE1 /* SuggestionFadeInPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2B351B5FDF077F8FF49A9A7 /* SuggestionFadeInPolicy.swift */; }; + ADD9949A333F6E78708773E9 /* ContextRelevanceSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D45B7D55C95DB033B3A8793E /* ContextRelevanceSelector.swift */; }; AE739989AC401113B99018BC /* SuggestionCoordinator+Input.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFA31F5FA8D164CB6C30D06 /* SuggestionCoordinator+Input.swift */; }; AED18FC69D7ECC5A0A80B8FB /* CustomRulesCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D93E3819F3BB0957FF3DF6 /* CustomRulesCatalog.swift */; }; AEF6FC272F0B1B20B43FD0EE /* SuggestionTextColorCodecTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 655F56C230F45CAC13EEE59C /* SuggestionTextColorCodecTests.swift */; }; @@ -1065,6 +1068,7 @@ 928A884292355FEF8B605B64 /* SuggestionFadeInPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFadeInPolicyTests.swift; sourceTree = ""; }; 9316289DD11A72D3EAC0A929 /* CompositionInputModeClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionInputModeClassifier.swift; sourceTree = ""; }; 94A26D86030FC5FA67371C52 /* DateMacroEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateMacroEvaluator.swift; sourceTree = ""; }; + 95ACE8721762F01C8146BE4C /* ContextRelevanceSelectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextRelevanceSelectorTests.swift; sourceTree = ""; }; 95C62A4403E4815004C6FCB8 /* SettingsSearchRankerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchRankerTests.swift; sourceTree = ""; }; 961A47BBFDBF7C233BC4DDC4 /* TextLayoutCaretEstimatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLayoutCaretEstimatorTests.swift; sourceTree = ""; }; 9720913FECB22A9A28C96333 /* SuggestionOverlayViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionOverlayViews.swift; sourceTree = ""; }; @@ -1195,6 +1199,7 @@ D253D06D941F60EDAF112625 /* SuggestionSettingsModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsModelTests.swift; sourceTree = ""; }; D381F1752705DC80E491B025 /* ArithmeticEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArithmeticEvaluatorTests.swift; sourceTree = ""; }; D3C1C9A9BF1F74C16E4401EC /* CotabbyBrand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyBrand.swift; sourceTree = ""; }; + D45B7D55C95DB033B3A8793E /* ContextRelevanceSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextRelevanceSelector.swift; sourceTree = ""; }; D47E1471C110614F0D07E399 /* PermissionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionModels.swift; sourceTree = ""; }; D48B95B6665109B6C6A63B42 /* WritingPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WritingPaneView.swift; sourceTree = ""; }; D4BA53E8B4DBF1612C8F70BC /* SuggestionFocusFreshnessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFocusFreshnessTests.swift; sourceTree = ""; }; @@ -2119,6 +2124,7 @@ children = ( B2229037BCDF7EFD3D47C15F /* ClipboardContentDistiller.swift */, 2BF37C1A0F734BB940E371C2 /* ClipboardRelevanceFilter.swift */, + D45B7D55C95DB033B3A8793E /* ContextRelevanceSelector.swift */, 7736E6B390C822F91CC1DA99 /* OCRTextHygiene.swift */, D182046E27CB170D943E8F86 /* PromptContextSanitizer.swift */, 27B03589D82D0ADCF41B9CD0 /* SurfaceContextComposer.swift */, @@ -2794,6 +2800,7 @@ children = ( 3680E1B8FA712A888F509640 /* ClipboardContentDistillerTests.swift */, 04364C3242A25358E7C85FD6 /* ClipboardRelevanceFilterTests.swift */, + 95ACE8721762F01C8146BE4C /* ContextRelevanceSelectorTests.swift */, 64B0584FA16465453F04CA8C /* OCRTextHygieneTests.swift */, 0DA66559D50874865032EE8C /* PromptContextSanitizerTests.swift */, 652973AF18C20D51F81F76C5 /* SurfaceContextComposerTests.swift */, @@ -3273,6 +3280,7 @@ B9348E5D08C50B82D2C62BCF /* ContextBuffer.swift in Sources */, DA80B804C506B427A663302D /* ContextLivePreviewField.swift in Sources */, 930BA578E742D96FD9D340ED /* ContextPaneView.swift in Sources */, + ADD9949A333F6E78708773E9 /* ContextRelevanceSelector.swift in Sources */, BFE1811C699B5B87530C3248 /* ControlTokenMarkers.swift in Sources */, B55B160E0534AE23BAC1C3DA /* CotabbyApp.swift in Sources */, AF55F1ABEDEA10C76C307CEC /* CotabbyAppEnvironment.swift in Sources */, @@ -3554,6 +3562,7 @@ 3EAFEEC83B25B023C606A726 /* ContextBuffer.swift in Sources */, 76933444A4EF1F1CE5039E9E /* ContextLivePreviewField.swift in Sources */, 2089E4C64C1BFDCDA9F2F721 /* ContextPaneView.swift in Sources */, + 288DC9575FDCA1370AA73185 /* ContextRelevanceSelector.swift in Sources */, 1415DED5362F6F343AF63123 /* ControlTokenMarkers.swift in Sources */, AA2E09FF7E430D66ECA8ECD5 /* CotabbyApp.swift in Sources */, FCC571EC239846F06007BFCA /* CotabbyAppEnvironment.swift in Sources */, @@ -3818,6 +3827,7 @@ 5F2F0475865445A777B5E3F0 /* CompletionSeamGuardTests.swift in Sources */, 9BDC4312E18A9E67045DD4F1 /* CompositionInputModeClassifierTests.swift in Sources */, 3E235B9AECC972973A94B678 /* ConfidenceSuppressionPolicyTests.swift in Sources */, + 36EE3D0E98A4E1CA9EB62436 /* ContextRelevanceSelectorTests.swift in Sources */, 57BCDFE786675C9793F3E08E /* ControlTokenMarkersTests.swift in Sources */, F8D1C3FD1A1ACAE87D885D29 /* CotabbyDebugOptionsTests.swift in Sources */, 65D20F8E6309CED34A638D35 /* CotabbyTestFixtures.swift in Sources */, diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift index eca44d03..c30ce0fe 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift @@ -302,10 +302,10 @@ extension SuggestionCoordinator { } /// Resolves the clipboard prompt section under the pinning policy documented on - /// `clipboardPrefaceMemo`: an accepted (non-nil) verdict is reused for the rest of the field - /// session so the prompt head stays stable and the engine's KV common prefix survives; a nil - /// verdict re-evaluates per request because it adds nothing to the prompt and the clipboard - /// may only become relevant once more text is typed. A new copy or a field switch always + /// `clipboardPrefaceMemo`: an accepted (non-nil) verdict is reused only while the copy remains + /// fresh, so short-term prompt stability does not turn into indefinite same-field retention. A + /// nil verdict re-evaluates per request because it adds nothing to the prompt and the clipboard + /// may only become relevant once more text is typed. A new copy, field switch, or expiry always /// re-evaluates. private func pinnedClipboardContext(rawContext: FocusedInputSnapshot) -> String? { guard settingsSnapshot.isClipboardContextEnabled else { @@ -313,11 +313,14 @@ extension SuggestionCoordinator { } let changeCount = clipboardContextProvider.currentChangeCount + let now = Date() if let memo = clipboardPrefaceMemo, - memo.focusSequence == rawContext.focusChangeSequence, - memo.changeCount == changeCount, - memo.value != nil { - return memo.value + let value = memo.reusableValue( + focusSequence: rawContext.focusChangeSequence, + changeCount: changeCount, + now: now + ) { + return value } // Same bounded window the downstream distiller sees, so the relevance gate and the @@ -327,15 +330,17 @@ extension SuggestionCoordinator { configuration: configuration, engine: settingsSnapshot.selectedEngine ) + let selectionPrefix = SuggestionRequestFactory.contextSelectionPrefix(from: truncatedPrefix) let value = clipboardRelevanceFilter.filter( clipboard: clipboardContextProvider.currentContext(), pasteboardChangeCount: changeCount, - precedingText: truncatedPrefix + precedingText: selectionPrefix ) clipboardPrefaceMemo = ClipboardPrefaceMemo( focusSequence: rawContext.focusChangeSequence, changeCount: changeCount, - value: value + value: value, + expiresAt: clipboardRelevanceFilter.acceptedContextExpiresAt ?? now ) return value } diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift index bc321eb0..beb7e782 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift @@ -76,18 +76,32 @@ final class SuggestionCoordinator: ObservableObject { // barrier task that the next generation must cross before it can ask the runtime for output. var cacheResetSequence: UInt64 = 0 var pendingCacheReset: (sequence: UInt64, task: Task)? - /// One accepted clipboard-relevance verdict per (field session, pasteboard state). The verdict - /// used to be re-evaluated against the live prefix on every request, and because the clipboard - /// section precedes the typed prefix in the prompt, every flip rewrote the prompt HEAD and - /// collapsed the engine's reusable common prefix back to zero (a full re-prefill). A pinned - /// non-nil verdict keeps the prompt head stable for the field session; a nil verdict keeps - /// re-evaluating because adding nothing to the prompt cannot destabilize the head, and the - /// clipboard may only become relevant once more text is typed. A new copy (change count) or a - /// field switch (focus sequence) always re-evaluates. See `pinnedClipboardContext`. + /// One accepted clipboard-relevance verdict per (field session, pasteboard state). Pinning the + /// bounded source prevents relevance from flipping on every keystroke; the request factory may + /// still select different lines from that source as the live prefix becomes more specific. A nil + /// verdict keeps re-evaluating because adding nothing cannot destabilize the prompt head and the + /// clipboard may become relevant later. A field switch, new copy, or freshness expiry always + /// re-evaluates. See `pinnedClipboardContext`. struct ClipboardPrefaceMemo { let focusSequence: UInt64 let changeCount: Int let value: String? + let expiresAt: Date + + /// A memo hit must still honor the relevance filter's freshness window. Without this check, + /// one accepted copy could remain in prompts for an arbitrarily long same-field session. + func reusableValue( + focusSequence: UInt64, + changeCount: Int, + now: Date + ) -> String? { + guard self.focusSequence == focusSequence, + self.changeCount == changeCount, + now < expiresAt else { + return nil + } + return value + } } var clipboardPrefaceMemo: ClipboardPrefaceMemo? diff --git a/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift b/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift index ab8c5b05..9ef2da84 100644 --- a/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift +++ b/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift @@ -196,6 +196,10 @@ protocol ClipboardContextProviding: AnyObject { @MainActor protocol ClipboardRelevanceFiltering: AnyObject { + /// Absolute expiry of the currently accepted clipboard value, derived from the observed copy + /// time rather than from each field/request that reuses it. + var acceptedContextExpiresAt: Date? { get } + /// Returns `clipboard` when it should be injected into the prompt, or `nil` to drop it. /// /// `precedingText` should be the same bounded window the downstream distiller will see, @@ -207,6 +211,12 @@ protocol ClipboardRelevanceFiltering: AnyObject { ) -> String? } +extension ClipboardRelevanceFiltering { + /// Test doubles or alternate filters without a source-age clock fail closed: their accepted value + /// is not memoized beyond the request that produced it. + var acceptedContextExpiresAt: Date? { nil } +} + @MainActor protocol SuggestionInserting: AnyObject { var lastErrorMessage: String? { get } diff --git a/Cotabby/Support/Context/ClipboardContentDistiller.swift b/Cotabby/Support/Context/ClipboardContentDistiller.swift index c2ef8e5a..b459c2ac 100644 --- a/Cotabby/Support/Context/ClipboardContentDistiller.swift +++ b/Cotabby/Support/Context/ClipboardContentDistiller.swift @@ -1,33 +1,28 @@ import Foundation -/// Extracts only the clipboard lines that share meaningful tokens with the user's current -/// prefix text. Short clipboard content passes through unchanged; longer content is filtered -/// to the lines most likely to help the autocomplete model. -enum ClipboardContentDistiller { - private static let compactLineThreshold = 3 - private static let headFallbackCharacters = 300 +/// Selects the bounded clipboard lines that are relevant to the user's current caret context. +/// +/// `ClipboardRelevanceFilter` remains the owner of permission-adjacent freshness and pasteboard +/// identity. This value only performs the second, line-level pass after that gate succeeds. Keeping +/// the two responsibilities separate prevents one matching line from admitting unrelated neighbors, +/// which was especially harmful for short copied blocks and long clipboard head fallbacks. +nonisolated enum ClipboardContentDistiller { + static let defaultLimits = ContextRelevanceSelector.Limits( + maxLines: 3, + maxCharacters: 400 + ) - /// Returns a distilled version of `clipboard` containing only lines relevant to `prefixText`. - /// - /// - Clipboard with ≤3 lines or empty `prefixText` is returned as-is. - /// - Longer clipboard keeps only lines whose tokens overlap with `prefixText`. - /// - If no individual line overlaps, the first 300 characters are returned as a head fallback. - static func distill(clipboard: String, prefixText: String) -> String { - let lines = clipboard.components(separatedBy: "\n") - guard lines.count > compactLineThreshold else { return clipboard } - - let prefixTokens = PromptContextSanitizer.significantTokens(from: prefixText) - guard !prefixTokens.isEmpty else { return clipboard } - - let relevantLines = lines.filter { line in - let lineTokens = PromptContextSanitizer.significantTokens(from: line) - return !lineTokens.isDisjoint(with: prefixTokens) - } - - if relevantLines.isEmpty { - return String(clipboard.prefix(headFallbackCharacters)) - } - - return relevantLines.joined(separator: "\n") + /// Returns relevant clipboard lines in their original order, or `nil` when no line shares + /// meaningful Latin terms or CJK bigrams with `prefixText`. + static func distill( + clipboard: String, + prefixText: String, + limits: ContextRelevanceSelector.Limits = defaultLimits + ) -> String? { + ContextRelevanceSelector.selectRelevantLines( + from: clipboard, + prefixText: prefixText, + limits: limits + ) } } diff --git a/Cotabby/Support/Context/ClipboardRelevanceFilter.swift b/Cotabby/Support/Context/ClipboardRelevanceFilter.swift index 0afc8f7f..f68c4fa0 100644 --- a/Cotabby/Support/Context/ClipboardRelevanceFilter.swift +++ b/Cotabby/Support/Context/ClipboardRelevanceFilter.swift @@ -21,23 +21,34 @@ import Foundation @MainActor final class ClipboardRelevanceFilter: ClipboardRelevanceFiltering { static let staleThresholdSeconds: TimeInterval = 300 - private static let minimumTokenLength = 3 + /// Bound text before tokenization and memoization so a large pasteboard cannot allocate an + /// unbounded CJK bigram set on the main actor. Final prompt selection applies a much smaller cap. + static let maximumEvaluatedCharacters = 8_000 private var lastKnownChangeCount: Int? + private var lastObservationDate: Date? private var lastChangeDate: Date? private let dateProvider: () -> Date + var acceptedContextExpiresAt: Date? { + lastChangeDate?.addingTimeInterval(Self.staleThresholdSeconds) + } + init(dateProvider: @escaping () -> Date = { Date() }) { self.dateProvider = dateProvider } - /// Returns `clipboard` unchanged when it looks relevant, or `nil` when it should be dropped. + /// Returns a bounded clipboard value when it looks relevant, or `nil` when it should be dropped. func filter( clipboard: String?, pasteboardChangeCount: Int, precedingText: String ) -> String? { guard let clipboard else { return nil } + let boundedClipboard = String(clipboard.prefix(Self.maximumEvaluatedCharacters)) + let now = dateProvider() + let previousObservationDate = lastObservationDate + lastObservationDate = now guard let baselineChangeCount = lastKnownChangeCount else { // First observation: record the baseline so we can detect *new* copies, but leave @@ -49,25 +60,30 @@ final class ClipboardRelevanceFilter: ClipboardRelevanceFiltering { if pasteboardChangeCount != baselineChangeCount { lastKnownChangeCount = pasteboardChangeCount - lastChangeDate = dateProvider() + // NSPasteboard exposes no copy timestamp. The copy happened sometime after our previous + // observation, so use that earlier boundary as a conservative source time. If the gap is + // already five minutes, the content may be stale and must fail closed. + guard let previousObservationDate, + now.timeIntervalSince(previousObservationDate) < Self.staleThresholdSeconds else { + lastChangeDate = nil + return nil + } + lastChangeDate = previousObservationDate } guard let lastChangeDate, - dateProvider().timeIntervalSince(lastChangeDate) < Self.staleThresholdSeconds + now.timeIntervalSince(lastChangeDate) < Self.staleThresholdSeconds else { return nil } - let clipboardTokens = Self.tokens(from: clipboard) - let prefixTokens = Self.tokens(from: precedingText) - guard !clipboardTokens.isDisjoint(with: prefixTokens) else { + guard PromptContextSanitizer.hasMeaningfulRelevance( + between: boundedClipboard, + and: precedingText + ) else { return nil } - return clipboard - } - - private static func tokens(from text: String) -> Set { - PromptContextSanitizer.significantTokens(from: text, minimumLength: minimumTokenLength) + return boundedClipboard } } diff --git a/Cotabby/Support/Context/ContextRelevanceSelector.swift b/Cotabby/Support/Context/ContextRelevanceSelector.swift new file mode 100644 index 00000000..77da5c92 --- /dev/null +++ b/Cotabby/Support/Context/ContextRelevanceSelector.swift @@ -0,0 +1,177 @@ +import Foundation + +/// Selects the small part of an already-authorized auxiliary context source that is relevant to +/// the text nearest the caret. +/// +/// The selector is deliberately pure. Clipboard and OCR services remain responsible for permission, +/// freshness, capture, and sanitization; this type only ranks their line-oriented text. Keeping the +/// policy here gives every source the same multilingual matching and deterministic budget behavior +/// without coupling prompt construction to AppKit or Vision. +nonisolated enum ContextRelevanceSelector { + /// A source-local budget applied before the selected text competes in the global prompt budget. + /// Independent limits keep one large OCR or clipboard payload from crowding out every other cue. + struct Limits: Equatable, Sendable { + let maxLines: Int + let maxCharacters: Int + + init(maxLines: Int, maxCharacters: Int) { + self.maxLines = max(0, maxLines) + self.maxCharacters = max(0, maxCharacters) + } + } + + /// Returns relevant lines in their original reading order, or `nil` when the source has no + /// meaningful overlap with the caret context. + /// + /// Ranking prefers stronger multilingual evidence. Later lines break score ties because chat + /// transcripts and copied notes commonly place the newest information last. The character budget + /// is spent in that ranked order so an earlier weak line cannot crowd out a later strong one; only + /// the admitted lines are restored to source order for a coherent final excerpt. + static func selectRelevantLines( + from text: String, + prefixText: String, + limits: Limits + ) -> String? { + guard limits.maxLines > 0, limits.maxCharacters > 0 else { return nil } + + let prefixTerms = PromptContextSanitizer.relevanceTerms(from: prefixText) + guard !prefixTerms.words.isEmpty || !prefixTerms.cjkBigrams.isEmpty else { return nil } + + let candidates = relevantCandidates( + from: text, + prefixText: prefixText, + prefixTerms: prefixTerms + ) + let selected = admittedCandidates( + from: candidates, + prefixTerms: prefixTerms, + limits: limits + ) + let result = selected + .sorted { $0.candidate.index < $1.candidate.index } + .map(\.text) + .joined(separator: "\n") + return result.isEmpty ? nil : result + } + + private static func relevantCandidates( + from text: String, + prefixText: String, + prefixTerms: PromptContextSanitizer.RelevanceTerms + ) -> [Candidate] { + let candidates = removingPrefixDuplicateLines(from: text, prefixText: prefixText) + .components(separatedBy: .newlines) + .enumerated() + .compactMap { index, rawLine -> Candidate? in + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty else { return nil } + + let evidence = PromptContextSanitizer.relevanceEvidence( + between: PromptContextSanitizer.relevanceTerms(from: line), + and: prefixTerms + ) + guard evidence.isMeaningful else { return nil } + return Candidate(line: line, index: index, score: evidence.score) + } + + // OCR and copied transcripts can repeat the same visible line. Keep the newest occurrence so + // duplicates cannot consume multiple line slots or prompt characters. + var latestByNormalizedLine: [String: Candidate] = [:] + for candidate in candidates { + latestByNormalizedLine[normalizedForDuplicateComparison(candidate.line)] = candidate + } + return latestByNormalizedLine.values.sorted { lhs, rhs in + lhs.score == rhs.score ? lhs.index > rhs.index : lhs.score > rhs.score + } + } + + private static func admittedCandidates( + from rankedCandidates: [Candidate], + prefixTerms: PromptContextSanitizer.RelevanceTerms, + limits: Limits + ) -> [SelectedCandidate] { + var selected: [SelectedCandidate] = [] + var remainingCharacters = limits.maxCharacters + + for candidate in rankedCandidates where selected.count < limits.maxLines { + let separatorCost = selected.isEmpty ? 0 : 1 + guard remainingCharacters > separatorCost else { break } + let availableCharacters = remainingCharacters - separatorCost + + if candidate.line.count <= availableCharacters { + selected.append(SelectedCandidate(candidate: candidate, text: candidate.line)) + remainingCharacters -= separatorCost + candidate.line.count + continue + } + + guard selected.isEmpty, + let truncated = relevantTruncatedPrefix( + of: candidate.line, + maxCharacters: availableCharacters, + prefixTerms: prefixTerms + ) else { + continue + } + selected.append(SelectedCandidate(candidate: candidate, text: truncated)) + remainingCharacters -= truncated.count + } + return selected + } + + /// An oversized top candidate may be truncated only when the retained prefix still carries the + /// relevance evidence that admitted the full line. Otherwise no text from that line is emitted. + private static func relevantTruncatedPrefix( + of line: String, + maxCharacters: Int, + prefixTerms: PromptContextSanitizer.RelevanceTerms + ) -> String? { + let truncated = String(line.prefix(maxCharacters)) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !truncated.isEmpty else { return nil } + let evidence = PromptContextSanitizer.relevanceEvidence( + between: PromptContextSanitizer.relevanceTerms(from: truncated), + and: prefixTerms + ) + return evidence.isMeaningful ? truncated : nil + } + + /// Removes lines already present in the caret prefix before either relevance ranking or a + /// conservative visual fallback. This separate step prevents an all-duplicate source from being + /// mistaken for a merely low-confidence source and reintroduced unchanged. + static func removingPrefixDuplicateLines(from text: String, prefixText: String) -> String { + let normalizedPrefix = normalizedForDuplicateComparison(prefixText) + return text + .components(separatedBy: .newlines) + .compactMap { rawLine -> String? in + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty else { return nil } + let normalizedLine = normalizedForDuplicateComparison(line) + guard normalizedLine.count < 4 || !normalizedPrefix.contains(normalizedLine) else { + return nil + } + return line + } + .joined(separator: "\n") + } + + private static func normalizedForDuplicateComparison(_ text: String) -> String { + // Auxiliary lines are sanitized before selection. Applying the same transformation to the + // caret prefix makes `Deploy: alpha` and its sanitized `Deploy alpha` form compare equally. + PromptContextSanitizer.sanitize(text) + .precomposedStringWithCompatibilityMapping + .lowercased() + .split(whereSeparator: { $0.isWhitespace }) + .joined(separator: " ") + } + + private struct Candidate { + let line: String + let index: Int + let score: Int + } + + private struct SelectedCandidate { + let candidate: Candidate + let text: String + } +} diff --git a/Cotabby/Support/Context/PromptContextSanitizer.swift b/Cotabby/Support/Context/PromptContextSanitizer.swift index 83418a03..2acc0380 100644 --- a/Cotabby/Support/Context/PromptContextSanitizer.swift +++ b/Cotabby/Support/Context/PromptContextSanitizer.swift @@ -7,7 +7,7 @@ import Foundation /// ANSI color escapes, and other prompt-shaped symbols. Those tokens are not useful semantic /// context for autocomplete, and small local models can copy them back as output. Keeping this as /// a pure `Support/` helper makes the policy deterministic, shared, and easy to test. -enum PromptContextSanitizer { +nonisolated enum PromptContextSanitizer { private static let ansiEscapePattern = "\u{001B}\\[[0-?]*[ -/]*[@-~]" private static let allowedCharacters = CharacterSet.alphanumerics .union(.whitespacesAndNewlines) @@ -62,11 +62,241 @@ enum PromptContextSanitizer { return bounded.trimmingCharacters(in: .whitespacesAndNewlines) } - /// Extracts lowercased tokens of at least `minimumLength` characters, splitting on - /// non-alphanumeric boundaries. Used by clipboard relevance and distillation logic. + /// Extracts normalized terms for clipboard and OCR relevance checks. + /// + /// Whitespace-delimited words work for Latin and Korean text, while Chinese/Japanese content does + /// not reliably expose the same boundaries. Alongside general words and complete Hangul runs, we + /// therefore emit overlapping two-character terms for Han and Katakana content. Hiragana-only + /// grammar is deliberately excluded; relevance policy also requires multiple shared bigrams. static func significantTokens(from text: String, minimumLength: Int = 3) -> Set { - let words = text.lowercased().components(separatedBy: .alphanumerics.inverted) - return Set(words.filter { $0.count >= minimumLength }) + let normalized = normalizedForRelevance(text) + var tokens = wordTokens(in: normalized, minimumLength: minimumLength) + tokens.formUnion(cjkBigrams(in: normalized)) + return tokens + } + + /// Precomputed terms let a selector compare many candidate lines without repeatedly tokenizing + /// the same caret prefix on the main-actor request path. + struct RelevanceTerms: Equatable, Sendable { + let words: Set + let cjkBigrams: Set + } + + static func relevanceTerms(from text: String, minimumWordLength: Int = 3) -> RelevanceTerms { + let normalized = normalizedForRelevance(text) + return RelevanceTerms( + words: wordTokens(in: normalized, minimumLength: minimumWordLength), + cjkBigrams: cjkBigrams(in: normalized) + ) + } + + /// Separates word and CJK evidence so source selectors can demand a stronger signal than one + /// ubiquitous two-character fragment. Two shared CJK bigrams imply at least a shared three- + /// character run (or two distinct terms), while one meaningful Latin/technical word is enough. + struct RelevanceEvidence: Equatable, Sendable { + let wordOverlapCount: Int + let cjkBigramOverlapCount: Int + + var isMeaningful: Bool { + wordOverlapCount > 0 || cjkBigramOverlapCount >= 2 + } + + var score: Int { + wordOverlapCount * 3 + cjkBigramOverlapCount + } + } + + static func relevanceEvidence( + between lhs: String, + and rhs: String, + minimumWordLength: Int = 3 + ) -> RelevanceEvidence { + relevanceEvidence( + between: relevanceTerms(from: lhs, minimumWordLength: minimumWordLength), + and: relevanceTerms(from: rhs, minimumWordLength: minimumWordLength) + ) + } + + static func relevanceEvidence( + between lhs: RelevanceTerms, + and rhs: RelevanceTerms + ) -> RelevanceEvidence { + RelevanceEvidence( + wordOverlapCount: overlapCount(lhs.words, rhs.words), + cjkBigramOverlapCount: overlapCount(lhs.cjkBigrams, rhs.cjkBigrams) + ) + } + + private static func overlapCount(_ lhs: Set, _ rhs: Set) -> Int { + let (smaller, larger) = lhs.count <= rhs.count ? (lhs, rhs) : (rhs, lhs) + return smaller.reduce(into: 0) { count, term in + if larger.contains(term) { + count += 1 + } + } + } + + static func hasMeaningfulRelevance(between lhs: String, and rhs: String) -> Bool { + relevanceEvidence(between: lhs, and: rhs).isMeaningful + } + + private static func normalizedForRelevance(_ text: String) -> String { + // Compatibility normalization folds full-width Latin/digits and visually-equivalent forms + // before matching. This is especially useful for OCR and mixed CJK/ASCII clipboard text. + text.precomposedStringWithCompatibilityMapping.lowercased() + } + + private static func wordTokens(in normalizedText: String, minimumLength: Int) -> Set { + enum RunKind: Equatable { + case general + case hangul + } + + var words = Set() + var run = "" + var runKind: RunKind? + + func commitRun() { + let term = runKind == .hangul ? normalizedHangulWord(run) : run + let requiredLength = runKind == .hangul ? 2 : minimumLength + if term.count >= requiredLength, !relevanceStopWords.contains(term) { + words.insert(term) + } + run = "" + runKind = nil + } + + for character in normalizedText { + let nextKind: RunKind? + if isHangul(character) { + // Korean uses spaces between lexical words. Matching the complete run avoids treating + // shared grammatical endings such as `합니다` as independent relevance evidence. + nextKind = .hangul + } else { + let isAlphanumeric = character.unicodeScalars.allSatisfy { + CharacterSet.alphanumerics.contains($0) + } + nextKind = isAlphanumeric && !isCJK(character) ? .general : nil + } + + guard let nextKind else { + commitRun() + continue + } + if let runKind, runKind != nextKind { + commitRun() + } + runKind = nextKind + run.append(character) + } + commitRun() + return words + } + + /// Reduces common Korean inflection/particle forms to the content-bearing stem before matching. + /// This avoids privacy-sensitive false positives where unrelated sentences share only polite + /// grammar such as `것입니다` or attached `합니다`, while retaining `배포`, `계획`, and `공유`. + private static func normalizedHangulWord(_ word: String) -> String { + var result = word + for suffix in hangulVerbEndings where result.count >= suffix.count && result.hasSuffix(suffix) { + result.removeLast(suffix.count) + break + } + for particle in hangulParticles where result.count >= particle.count && result.hasSuffix(particle) { + result.removeLast(particle.count) + break + } + return result + } + + private static let hangulVerbEndings = [ + "겠습니다", "었습니다", "았습니다", "였습니다", "입니다", "습니다", "합니다", "됩니다" + ] + private static let hangulParticles = [ + "으로", "에서", "에게", "부터", "까지", "처럼", "보다", + "은", "는", "이", "가", "을", "를", "에", "로", "와", "과", "도", "만" + ] + + /// High-frequency connective words are poor evidence that two contexts describe the same task. + /// Keeping this list intentionally small avoids turning relevance selection into a language model + /// while preventing matches such as two unrelated English lines that merely share "the". + private static let relevanceStopWords: Set = [ + "and", "are", "for", "from", "has", "have", "into", "not", "that", "the", + "then", "this", "was", "when", "will", "with", "you", "your" + ] + + private static func cjkBigrams(in text: String) -> Set { + var terms = Set() + var run: [Character] = [] + + func addTerms(for run: [Character]) { + // One-character overlap is far too common to establish relevance in CJK prose. Requiring + // a bigram still catches meaningful shared terms such as `发布` without matching every + // unrelated sentence that happens to contain `的` or `会`. + guard run.count >= 2 else { return } + for index in 0..<(run.count - 1) { + terms.insert(String([run[index], run[index + 1]])) + } + } + + for character in text { + if isBigramContentCharacter(character) { + run.append(character) + } else { + // Hiragana grammar and Hangul word endings are handled conservatively elsewhere; + // neither participates in free-form sliding bigrams. + addTerms(for: run) + run.removeAll(keepingCapacity: true) + } + } + addTerms(for: run) + return terms + } + + private static func isBigramContentCharacter(_ character: Character) -> Bool { + character.unicodeScalars.allSatisfy { scalar in + switch scalar.value { + case 0x30A0...0x30FF, // Katakana + 0x3400...0x4DBF, // CJK Unified Ideographs Extension A + 0x4E00...0x9FFF, // CJK Unified Ideographs + 0xF900...0xFAFF, // CJK Compatibility Ideographs + 0x20000...0x2A6DF, // CJK Unified Ideographs Extension B + 0x2A700...0x2EE5F, // CJK Unified Ideographs Extensions C-F and I + 0x30000...0x3134F, // CJK Unified Ideographs Extension G + 0x31350...0x323AF: // CJK Unified Ideographs Extension H + return true + default: + return false + } + } + } + + private static func isHangul(_ character: Character) -> Bool { + character.unicodeScalars.allSatisfy { scalar in + (0xAC00...0xD7AF).contains(scalar.value) + || (0x1100...0x11FF).contains(scalar.value) + } + } + + private static func isCJK(_ character: Character) -> Bool { + character.unicodeScalars.allSatisfy { scalar in + switch scalar.value { + case 0x3400...0x4DBF, // CJK Unified Ideographs Extension A + 0x4E00...0x9FFF, // CJK Unified Ideographs + 0xF900...0xFAFF, // CJK Compatibility Ideographs + 0x3040...0x309F, // Hiragana + 0x30A0...0x30FF, // Katakana + 0xAC00...0xD7AF, // Hangul syllables + 0x1100...0x11FF, // Hangul Jamo + 0x20000...0x2A6DF, // CJK Unified Ideographs Extension B + 0x2A700...0x2EE5F, // CJK Unified Ideographs Extensions C-F and I + 0x30000...0x3134F, // CJK Unified Ideographs Extension G + 0x31350...0x323AF: // CJK Unified Ideographs Extension H + return true + default: + return false + } + } } static func containsAlphanumericSignal(_ text: String) -> Bool { diff --git a/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift b/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift index 3c50304d..bc556850 100644 --- a/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift +++ b/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift @@ -65,10 +65,13 @@ enum BaseCompletionPromptRenderer { sections.append(Self.contextSection("notes", "Notes the writer keeps in mind: \(notes)", priority: 40, maxChars: 1300)) } if let clip = Self.nonEmpty(clipboardContext) { - sections.append(Self.contextSection("clipboard", "On the clipboard: \(clip)", priority: 35, maxChars: 400)) + // Section caps include their labels; add the fixed framing length so the selector's full + // 400-character clipboard allowance survives this second, global-budget-aware boundary. + sections.append(Self.contextSection("clipboard", "On the clipboard: \(clip)", priority: 35, maxChars: 418)) } if let screen = Self.nonEmpty(visualContextSummary) { - sections.append(Self.contextSection("screen", "Nearby on screen: \(screen)", priority: 30, maxChars: 500)) + // Same alignment for the 700-character visual allowance and its 18-character label. + sections.append(Self.contextSection("screen", "Nearby on screen: \(screen)", priority: 30, maxChars: 718)) } // The caret prefix: top priority so it is never starved, kept by its END (the text nearest // the caret), and rendered last with no label so the model continues from where the user diff --git a/Cotabby/Support/Prompting/TokenCountEstimator.swift b/Cotabby/Support/Prompting/TokenCountEstimator.swift index 485d0e18..93faf6ca 100644 --- a/Cotabby/Support/Prompting/TokenCountEstimator.swift +++ b/Cotabby/Support/Prompting/TokenCountEstimator.swift @@ -5,11 +5,11 @@ import Foundation /// prompt more faithfully than a flat character count without paying for a real tokenizer on the /// main-actor prompt path. /// -/// It is intentionally an approximation: a word-aware heuristic (roughly four characters per token -/// within a word, every word at least one token) is closer to real subword tokenization than a single -/// global chars-per-token ratio — especially for code or short function words — while staying -/// allocation-light and deterministic for tests. It is not exact, so it is used only for relative -/// budgeting decisions, never to assert a hard token limit. +/// It is intentionally an approximation: Latin-like runs use roughly four characters per token, +/// while characters from scripts whose model tokenizers are commonly much denser (CJK, Hangul, +/// Thai, and neighboring space-less scripts) count individually. This is closer to the models Cotabby +/// ships than a global chars-per-token ratio while staying deterministic and tokenizer-free. It is not +/// exact, so it is used only for relative budgeting decisions, never to assert a hard token limit. nonisolated enum TokenCountEstimator { static func estimate(_ text: String) -> Int { // Split on punctuation as well as whitespace: real subword tokenizers break "can't", "end.", @@ -20,7 +20,53 @@ nonisolated enum TokenCountEstimator { return 0 } return words.reduce(0) { total, word in - total + max(1, Int((Double(word.count) / 4.0).rounded())) + total + estimateWord(word) + } + } + + private static func estimateWord(_ word: Substring) -> Int { + var denseScriptCharacters = 0 + var otherCharacters = 0 + for character in word { + if character.isDenseTokenizerScript { + denseScriptCharacters += 1 + } else { + otherCharacters += 1 + } + } + + let otherTokens = otherCharacters == 0 + ? 0 + : max(1, Int((Double(otherCharacters) / 4.0).rounded())) + return denseScriptCharacters + otherTokens + } +} + +nonisolated private extension Character { + /// Scripts where common llama/endpoint tokenizers are much closer to one token per character + /// than the four-Latin-characters heuristic. Counting them individually is deliberately + /// conservative so optional OCR/clipboard context cannot crowd the caret prefix out of KV. + var isDenseTokenizerScript: Bool { + unicodeScalars.contains { scalar in + switch scalar.value { + case 0x3040...0x30FF, // Hiragana + Katakana + 0x3400...0x4DBF, // CJK Unified Ideographs Extension A + 0x4E00...0x9FFF, // CJK Unified Ideographs + 0xF900...0xFAFF, // CJK Compatibility Ideographs + 0xAC00...0xD7A3, // Hangul syllables + 0x1100...0x11FF, // Hangul Jamo + 0x0E00...0x0E7F, // Thai + 0x0E80...0x0EFF, // Lao + 0x1780...0x17FF, // Khmer + 0x1000...0x109F, // Myanmar + 0x20000...0x2A6DF, // CJK Unified Ideographs Extension B + 0x2A700...0x2EE5F, // CJK Unified Ideographs Extensions C-F and I + 0x30000...0x3134F, // CJK Unified Ideographs Extension G + 0x31350...0x323AF: // CJK Unified Ideographs Extension H + return true + default: + return false + } } } } diff --git a/Cotabby/Support/Suggestion/Request/SuggestionRequestFactory.swift b/Cotabby/Support/Suggestion/Request/SuggestionRequestFactory.swift index 48190318..13c4f309 100644 --- a/Cotabby/Support/Suggestion/Request/SuggestionRequestFactory.swift +++ b/Cotabby/Support/Suggestion/Request/SuggestionRequestFactory.swift @@ -18,7 +18,30 @@ struct SuggestionRequestBuildResult: Equatable, Sendable { /// Pure prompt-policy surface for the autocomplete pipeline. /// This type has no access to UserDefaults, tasks, overlays, or runtime services. enum SuggestionRequestFactory { - private static let maxClipboardContextCharacters = 1_200 + /// Optional context should match the user's current thought, not any topic mentioned far back in + /// the full prompt window. A smaller caret-local query also bounds multilingual tokenization work. + private static let maxContextSelectionPrefixCharacters = 600 + + /// Auxiliary sources receive independent line and character caps before global prompt budgeting. + /// These match the renderer's effective source limits, so selection—not a later silent clip—decides + /// which clipboard and OCR evidence survives. + private static let baseClipboardContextLimits = ContextRelevanceSelector.Limits( + maxLines: 3, + maxCharacters: 400 + ) + private static let baseVisualContextLimits = ContextRelevanceSelector.Limits( + maxLines: 6, + maxCharacters: 700 + ) + private static let baseVisualFallbackCharacters = 500 + private static let foundationClipboardContextLimits = ContextRelevanceSelector.Limits( + maxLines: 8, + maxCharacters: 1_200 + ) + private static let foundationVisualContextLimits = ContextRelevanceSelector.Limits( + maxLines: 12, + maxCharacters: VisualContextConfiguration.default.maxSummaryCharacters + ) /// Require at least one non-whitespace character so we don't suggest on a blank field. /// No trailing-space gate — the debounce handles rapid keystroke settling, and @@ -41,6 +64,7 @@ enum SuggestionRequestFactory { configuration: configuration, engine: settings.selectedEngine ) + let contextSelectionPrefix = contextSelectionPrefix(from: prefixText) let completionLengthInstruction = settings.effectiveWordRange.promptInstruction let userName = activeUserName(settings: settings) // Custom rules are hidden from users (CustomRulesCatalog.isUserFacingEnabled == false): the @@ -61,10 +85,14 @@ enum SuggestionRequestFactory { let boundedClipboardContext = activeClipboardContext( rawContext: clipboardContext, settings: settings, - prefixText: prefixText + prefixText: contextSelectionPrefix, + limits: clipboardContextLimits(for: settings.selectedEngine) ) let boundedVisualContextSummary = activeVisualContextSummary( - rawSummary: visualContextSummary + rawSummary: visualContextSummary, + prefixText: contextSelectionPrefix, + limits: visualContextLimits(for: settings.selectedEngine), + fallbackMaxCharacters: visualFallbackCharacters(for: settings.selectedEngine) ) // The composed surface description; nil when the user disabled it or the surface class // suppresses it (code editors, terminals, anonymous generic apps). The composer sanitizes @@ -171,6 +199,42 @@ enum SuggestionRequestFactory { return trailingWords.isEmpty ? characterWindow : trailingWords } + /// The exact caret-local tail used by both the clipboard freshness/relevance gate and downstream + /// line selection. Exposed so the coordinator and factory cannot silently evaluate different + /// windows and disagree about whether a source is relevant. + static func contextSelectionPrefix(from promptPrefix: String) -> String { + String(promptPrefix.suffix(maxContextSelectionPrefixCharacters)) + } + + private static func clipboardContextLimits( + for engine: SuggestionEngineKind + ) -> ContextRelevanceSelector.Limits { + engine == .appleIntelligence + ? foundationClipboardContextLimits + : baseClipboardContextLimits + } + + private static func visualContextLimits( + for engine: SuggestionEngineKind + ) -> ContextRelevanceSelector.Limits { + engine == .appleIntelligence + ? foundationVisualContextLimits + : baseVisualContextLimits + } + + private static func visualFallbackCharacters(for engine: SuggestionEngineKind) -> Int? { + switch engine { + case .appleIntelligence: + return foundationVisualContextLimits.maxCharacters + case .llamaOpenSource: + return baseVisualFallbackCharacters + case .openAICompatible: + // A configured endpoint may be on the public internet. Without relevance evidence, + // omitting passive screen text is safer than transmitting the local fallback. + return nil + } + } + private static func activeUserName( settings: SuggestionSettingsSnapshot ) -> String? { @@ -180,7 +244,8 @@ enum SuggestionRequestFactory { private static func activeClipboardContext( rawContext: String?, settings: SuggestionSettingsSnapshot, - prefixText: String + prefixText: String, + limits: ContextRelevanceSelector.Limits ) -> String? { guard settings.isClipboardContextEnabled, let rawContext @@ -195,14 +260,19 @@ enum SuggestionRequestFactory { return nil } - let distilled = ClipboardContentDistiller.distill( + return ClipboardContentDistiller.distill( clipboard: sanitizedContext, - prefixText: prefixText + prefixText: prefixText, + limits: limits ) - return clippedText(distilled, maxCharacters: maxClipboardContextCharacters) } - private static func activeVisualContextSummary(rawSummary: String?) -> String? { + private static func activeVisualContextSummary( + rawSummary: String?, + prefixText: String, + limits: ContextRelevanceSelector.Limits, + fallbackMaxCharacters: Int? + ) -> String? { guard let rawSummary else { return nil } @@ -214,18 +284,29 @@ enum SuggestionRequestFactory { return nil } - return sanitizedSummary - } + let deduplicatedSummary = ContextRelevanceSelector.removingPrefixDuplicateLines( + from: sanitizedSummary, + prefixText: prefixText + ) + guard !deduplicatedSummary.isEmpty else { return nil } - private static func clippedText(_ text: String, maxCharacters: Int) -> String { - guard text.count > maxCharacters else { - return text + // OCR is collected once per field, then selected again at request time against the live + // caret prefix. Strong lexical/CJK evidence promotes a compact excerpt; when evidence is + // inconclusive, the bounded shipping fallback below preserves conversational recall. + if let selected = ContextRelevanceSelector.selectRelevantLines( + from: deduplicatedSummary, + prefixText: prefixText, + limits: limits + ) { + return selected } - let suffix = "..." - let allowedPrefixCount = max(maxCharacters - suffix.count, 0) - return String(text.prefix(allowedPrefixCount)) - .trimmingCharacters(in: .whitespacesAndNewlines) + suffix + // Local engines preserve the shipping fallback when lexical/CJK evidence is inconclusive: + // a reply and draft can be semantically related without sharing literal terms. Public/LAN + // endpoints receive no passive screen fallback; a future measured semantic ranker can safely + // improve their recall without transmitting unrelated text. + guard let fallbackMaxCharacters else { return nil } + return String(deduplicatedSummary.prefix(max(0, fallbackMaxCharacters))) } /// Picks the per-request token budget from the *effective* word range (preset or custom) and diff --git a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorPredictionTests.swift b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorPredictionTests.swift index 4d8b91c2..1cc1f501 100644 --- a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorPredictionTests.swift +++ b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorPredictionTests.swift @@ -45,6 +45,30 @@ final class SuggestionCoordinatorPredictionTests: XCTestCase { XCTAssertNotNil(rig.interactionState.activeSession) } + func test_clipboardPrefaceMemo_reusesOnlyMatchingFreshClipboard() { + let now = Date() + let memo = SuggestionCoordinator.ClipboardPrefaceMemo( + focusSequence: 7, + changeCount: 42, + value: "project context", + expiresAt: now.addingTimeInterval(60) + ) + + XCTAssertEqual( + memo.reusableValue(focusSequence: 7, changeCount: 42, now: now), + "project context" + ) + XCTAssertNil(memo.reusableValue(focusSequence: 8, changeCount: 42, now: now)) + XCTAssertNil(memo.reusableValue(focusSequence: 7, changeCount: 43, now: now)) + XCTAssertNil( + memo.reusableValue( + focusSequence: 7, + changeCount: 42, + now: now.addingTimeInterval(60) + ) + ) + } + // MARK: - Gates before generation func test_schedulePrediction_disabledAppGoesStraightToDisabledState() async { diff --git a/CotabbyTests/Support/Context/ClipboardContentDistillerTests.swift b/CotabbyTests/Support/Context/ClipboardContentDistillerTests.swift index 56d8703c..2bd77629 100644 --- a/CotabbyTests/Support/Context/ClipboardContentDistillerTests.swift +++ b/CotabbyTests/Support/Context/ClipboardContentDistillerTests.swift @@ -2,19 +2,14 @@ import XCTest @testable import Cotabby final class ClipboardContentDistillerTests: XCTestCase { - - // MARK: - Short clipboard passes through - - func test_shortClipboard_returnedAsIs() { - let clipboard = "line one\nline two\nline three" + func test_shortClipboard_keepsOnlyRelevantLines() { let result = ClipboardContentDistiller.distill( - clipboard: clipboard, - prefixText: "completely unrelated text" + clipboard: "meeting agenda\nunrelated lunch\nmeeting decisions", + prefixText: "the meeting starts soon" ) - XCTAssertEqual(result, clipboard) - } - // MARK: - Long clipboard with partial overlap + XCTAssertEqual(result, "meeting agenda\nmeeting decisions") + } func test_longClipboard_keepsOnlyMatchingLines() { let clipboard = [ @@ -29,15 +24,14 @@ final class ClipboardContentDistillerTests: XCTestCase { clipboard: clipboard, prefixText: "the deploy is running" ) + XCTAssertEqual(result, [ "func deploy() {", - " print(\"starting deploy\")" + "print(\"starting deploy\")" ].joined(separator: "\n")) } - // MARK: - No per-line overlap falls back to head - - func test_longClipboard_noPerLineOverlap_returnsHead() { + func test_unrelatedClipboard_returnsNilInsteadOfHeadFallback() { let clipboard = [ "alpha bravo charlie", "delta echo foxtrot", @@ -45,15 +39,14 @@ final class ClipboardContentDistillerTests: XCTestCase { "juliet kilo lima" ].joined(separator: "\n") - let result = ClipboardContentDistiller.distill( - clipboard: clipboard, - prefixText: "completely different words" + XCTAssertNil( + ClipboardContentDistiller.distill( + clipboard: clipboard, + prefixText: "completely different words" + ) ) - XCTAssertEqual(result, String(clipboard.prefix(300))) } - // MARK: - Case insensitive - func test_caseInsensitiveMatching() { let clipboard = [ "The DEPLOYMENT pipeline", @@ -66,44 +59,44 @@ final class ClipboardContentDistillerTests: XCTestCase { clipboard: clipboard, prefixText: "our deployment is slow" ) + XCTAssertEqual(result, [ "The DEPLOYMENT pipeline", "Check deployment status" ].joined(separator: "\n")) } - // MARK: - Short tokens ignored - - func test_shortTokensIgnored() { - let clipboard = [ - "a b c d e", - "x y z w v", - "real content here", - "more filler words" - ].joined(separator: "\n") - + func test_cjkClipboard_keepsRelevantLines() { let result = ClipboardContentDistiller.distill( - clipboard: clipboard, - prefixText: "a b c x y z" + clipboard: "午餐时间十二点\n发布说明已经完成\n发布计划等待审核", + prefixText: "发布计划正在" ) - // No tokens >= 3 chars overlap, so head fallback. - XCTAssertEqual(result, String(clipboard.prefix(300))) - } - // MARK: - Empty prefix returns clipboard as-is + XCTAssertEqual(result, "发布计划等待审核") + } - func test_emptyPrefixText_returnsClipboardAsIs() { - let clipboard = [ - "line one content", - "line two content", - "line three content", - "line four content" - ].joined(separator: "\n") + func test_shortTokensAndEmptyPrefix_returnNil() { + XCTAssertNil( + ClipboardContentDistiller.distill( + clipboard: "a b c\nx y z", + prefixText: "a b c x y z" + ) + ) + XCTAssertNil( + ClipboardContentDistiller.distill( + clipboard: "line one content", + prefixText: "" + ) + ) + } + func test_customLimits_boundLinesAndCharacters() { let result = ClipboardContentDistiller.distill( - clipboard: clipboard, - prefixText: "" + clipboard: "project alpha\nproject beta\nproject gamma", + prefixText: "project status", + limits: .init(maxLines: 2, maxCharacters: 20) ) - XCTAssertEqual(result, clipboard) + + XCTAssertEqual(result, "project gamma") } } diff --git a/CotabbyTests/Support/Context/ClipboardRelevanceFilterTests.swift b/CotabbyTests/Support/Context/ClipboardRelevanceFilterTests.swift index af92bfdd..5cdd964f 100644 --- a/CotabbyTests/Support/Context/ClipboardRelevanceFilterTests.swift +++ b/CotabbyTests/Support/Context/ClipboardRelevanceFilterTests.swift @@ -88,6 +88,54 @@ final class ClipboardRelevanceFilterTests: XCTestCase { XCTAssertNil(result) } + func test_freshClipboard_matchesCJKTermsWithoutWhitespaceBoundaries() { + _ = filter.filter( + clipboard: "baseline", + pasteboardChangeCount: 1, + precedingText: "" + ) + + let result = filter.filter( + clipboard: "发布说明和发布计划", + pasteboardChangeCount: 2, + precedingText: "发布计划正在审核" + ) + + XCTAssertEqual(result, "发布说明和发布计划") + } + + func test_freshClipboard_singleCJKCharacterOverlapReturnsNil() { + _ = filter.filter( + clipboard: "baseline", + pasteboardChangeCount: 1, + precedingText: "" + ) + + let result = filter.filter( + clipboard: "会议预算已经批准", + pasteboardChangeCount: 2, + precedingText: "发布会马上开始" + ) + + XCTAssertNil(result) + } + + func test_freshClipboard_commonEnglishWordDoesNotEstablishRelevance() { + _ = filter.filter( + clipboard: "baseline", + pasteboardChangeCount: 1, + precedingText: "" + ) + + let result = filter.filter( + clipboard: "the quarterly lunch menu", + pasteboardChangeCount: 2, + precedingText: "the deployment status" + ) + + XCTAssertNil(result) + } + func test_tokenOverlap_isCaseInsensitive() { _ = filter.filter( clipboard: "irrelevant baseline content", @@ -103,6 +151,23 @@ final class ClipboardRelevanceFilterTests: XCTestCase { XCTAssertEqual(result, "Deployment Pipeline") } + func test_freshClipboard_isBoundedBeforeMatchingAndReturn() { + _ = filter.filter( + clipboard: "baseline", + pasteboardChangeCount: 1, + precedingText: "" + ) + let clipboard = String(repeating: "project ", count: 2_000) + + let result = filter.filter( + clipboard: clipboard, + pasteboardChangeCount: 2, + precedingText: "project status" + ) + + XCTAssertEqual(result?.count, ClipboardRelevanceFilter.maximumEvaluatedCharacters) + } + // MARK: - Staleness func test_staleClipboard_returnsNil() { @@ -130,29 +195,61 @@ final class ClipboardRelevanceFilterTests: XCTestCase { XCTAssertNil(result) } - func test_newCopyResetsStalenessClock() { + func test_expirationIsDerivedFromCopyTimeAndDoesNotSlideOnReuse() { _ = filter.filter( clipboard: "baseline", pasteboardChangeCount: 1, precedingText: "" ) + let previousObservationDate = now! + now = now.addingTimeInterval(1) + _ = filter.filter( + clipboard: "project context", + pasteboardChangeCount: 2, + precedingText: "project status" + ) + let expectedExpiry = previousObservationDate.addingTimeInterval( + ClipboardRelevanceFilter.staleThresholdSeconds + ) + XCTAssertEqual(filter.acceptedContextExpiresAt, expectedExpiry) - // First real copy. + now = now.addingTimeInterval(120) _ = filter.filter( - clipboard: "first content", + clipboard: "project context", pasteboardChangeCount: 2, - precedingText: "first content" + precedingText: "project status" ) + XCTAssertEqual(filter.acceptedContextExpiresAt, expectedExpiry) + } - // Time passes past the staleness threshold. + func test_changeDiscoveredAfterLongObservationGapFailsClosed() { + _ = filter.filter( + clipboard: "baseline", + pasteboardChangeCount: 1, + precedingText: "" + ) now = now.addingTimeInterval(ClipboardRelevanceFilter.staleThresholdSeconds + 1) - // A new copy resets the clock. - let result = filter.filter( - clipboard: "second content matching prefix", - pasteboardChangeCount: 3, - precedingText: "second content" + // The pasteboard exposes no copy time, so after a long observation gap this content may + // already be stale. Record the new baseline but do not inject it. + XCTAssertNil( + filter.filter( + clipboard: "unknown age content", + pasteboardChangeCount: 2, + precedingText: "unknown age" + ) + ) + + // A subsequent change observed inside the freshness window has a conservative source time + // and becomes eligible normally. + now = now.addingTimeInterval(1) + XCTAssertEqual( + filter.filter( + clipboard: "fresh project context", + pasteboardChangeCount: 3, + precedingText: "project context" + ), + "fresh project context" ) - XCTAssertEqual(result, "second content matching prefix") } } diff --git a/CotabbyTests/Support/Context/ContextRelevanceSelectorTests.swift b/CotabbyTests/Support/Context/ContextRelevanceSelectorTests.swift new file mode 100644 index 00000000..c63a592a --- /dev/null +++ b/CotabbyTests/Support/Context/ContextRelevanceSelectorTests.swift @@ -0,0 +1,243 @@ +import XCTest +@testable import Cotabby + +/// Locks down the deterministic line ranking shared by clipboard and OCR prompt context. Keeping the +/// selector pure lets multilingual relevance and source-local budgets run in normal CI without a +/// pasteboard, Vision, or a model. +final class ContextRelevanceSelectorTests: XCTestCase { + func test_selectRelevantLines_prefersMoreRelevantThenRecentButRestoresReadingOrder() { + let text = [ + "Earlier project note", + "Project alpha is blocked", + "Unrelated social reminder", + "Project alpha is ready for review" + ].joined(separator: "\n") + + let result = ContextRelevanceSelector.selectRelevantLines( + from: text, + prefixText: "The project alpha status is", + limits: .init(maxLines: 3, maxCharacters: 200) + ) + + XCTAssertEqual(result, [ + "Earlier project note", + "Project alpha is blocked", + "Project alpha is ready for review" + ].joined(separator: "\n")) + } + + func test_selectRelevantLines_matchesCJKTermsWithoutWhitespaceBoundaries() { + let text = [ + "明日の天気について", + "发布说明已经准备好了", + "发布计划需要审核" + ].joined(separator: "\n") + + let result = ContextRelevanceSelector.selectRelevantLines( + from: text, + prefixText: "发布计划正在", + limits: .init(maxLines: 3, maxCharacters: 200) + ) + + XCTAssertEqual(result, "发布计划需要审核") + } + + func test_selectRelevantLines_matchesJapaneseAndKoreanTerms() { + XCTAssertEqual( + ContextRelevanceSelector.selectRelevantLines( + from: "明日の天気\n会議資料を共有しました", + prefixText: "会議資料を確認", + limits: .init(maxLines: 2, maxCharacters: 200) + ), + "会議資料を共有しました" + ) + XCTAssertEqual( + ContextRelevanceSelector.selectRelevantLines( + from: "점심 메뉴\n배포 계획 승인 완료", + prefixText: "배포 계획을 검토", + limits: .init(maxLines: 2, maxCharacters: 200) + ), + "배포 계획 승인 완료" + ) + } + + func test_selectRelevantLines_matchesMixedFullWidthAndLatinTerms() { + let result = ContextRelevanceSelector.selectRelevantLines( + from: "API 发布计划 ready\nunrelated reminder", + prefixText: "API 发布状态", + limits: .init(maxLines: 2, maxCharacters: 200) + ) + + XCTAssertEqual(result, "API 发布计划 ready") + } + + func test_selectRelevantLines_rejectsOneCommonCJKBigramAndJapaneseGrammar() { + XCTAssertNil( + ContextRelevanceSelector.selectRelevantLines( + from: "发布按钮", + prefixText: "发布计划正在审核", + limits: .init(maxLines: 2, maxCharacters: 200) + ) + ) + XCTAssertNil( + ContextRelevanceSelector.selectRelevantLines( + from: "給与明細を共有します", + prefixText: "この処理を実行します", + limits: .init(maxLines: 2, maxCharacters: 200) + ) + ) + XCTAssertNil( + ContextRelevanceSelector.selectRelevantLines( + from: "급여 명세를 공유합니다", + prefixText: "이 작업을 실행합니다", + limits: .init(maxLines: 2, maxCharacters: 200) + ) + ) + XCTAssertNil( + ContextRelevanceSelector.selectRelevantLines( + from: "급여 명세는 안전할 것입니다", + prefixText: "이 작업은 실행될 것입니다", + limits: .init(maxLines: 2, maxCharacters: 200) + ) + ) + XCTAssertNil( + ContextRelevanceSelector.selectRelevantLines( + from: "급여를 합니다", + prefixText: "회의를 합니다", + limits: .init(maxLines: 2, maxCharacters: 200) + ) + ) + } + + func test_selectRelevantLines_doesNotTreatOneCJKCharacterAsEvidence() { + XCTAssertNil( + ContextRelevanceSelector.selectRelevantLines( + from: "会议预算已经批准", + prefixText: "发布会马上开始", + limits: .init(maxLines: 2, maxCharacters: 200) + ) + ) + } + + func test_selectRelevantLines_ignoresCommonEnglishStopWords() { + XCTAssertNil( + ContextRelevanceSelector.selectRelevantLines( + from: "the quarterly lunch menu", + prefixText: "the deployment status", + limits: .init(maxLines: 2, maxCharacters: 200) + ) + ) + } + + func test_selectRelevantLines_appliesLineAndCharacterLimitsIncludingSeparators() { + let result = ContextRelevanceSelector.selectRelevantLines( + from: "project alpha\nproject beta\nproject gamma", + prefixText: "project status", + limits: .init(maxLines: 2, maxCharacters: 20) + ) + + XCTAssertEqual(result, "project gamma") + } + + func test_selectRelevantLines_strongLaterLineCannotBeCrowdedOutByWeakEarlierLine() { + let result = ContextRelevanceSelector.selectRelevantLines( + from: String(repeating: "project detail ", count: 20) + + "\nproject alpha status ready", + prefixText: "project alpha status is", + limits: .init(maxLines: 2, maxCharacters: 30) + ) + + XCTAssertEqual(result, "project alpha status ready") + } + + func test_selectRelevantLines_revalidatesAnOversizedLineAfterTruncation() { + let line = String(repeating: "unrelated ", count: 20) + "project alpha" + + XCTAssertNil( + ContextRelevanceSelector.selectRelevantLines( + from: line, + prefixText: "project alpha status", + limits: .init(maxLines: 1, maxCharacters: 40) + ) + ) + } + + func test_selectRelevantLines_dropsPrefixDuplicate() { + XCTAssertNil( + ContextRelevanceSelector.selectRelevantLines( + from: "shipping the release", + prefixText: "We are shipping the release", + limits: .init(maxLines: 2, maxCharacters: 200) + ) + ) + } + + func test_selectRelevantLines_dropsDuplicateAfterSanitizationChangesPunctuation() { + XCTAssertNil( + ContextRelevanceSelector.selectRelevantLines( + from: "Deploy alpha", + prefixText: "Status: Deploy: alpha", + limits: .init(maxLines: 2, maxCharacters: 200) + ) + ) + } + + func test_selectRelevantLines_dropsSourceWithoutCaretEvidence() { + XCTAssertNil( + ContextRelevanceSelector.selectRelevantLines( + from: "quarterly budget review", + prefixText: "shipping the release", + limits: .init(maxLines: 3, maxCharacters: 200) + ) + ) + } + + func test_selectRelevantLines_deduplicatesRepeatedSourceLines() { + let result = ContextRelevanceSelector.selectRelevantLines( + from: "project alpha is ready\nProject alpha is ready\nproject beta is blocked", + prefixText: "project alpha status", + limits: .init(maxLines: 3, maxCharacters: 200) + ) + + XCTAssertEqual(result, "Project alpha is ready\nproject beta is blocked") + } + + func test_selectRelevantLines_staysStableWhenPrefixGrowsWithoutChangingEvidence() { + let text = "project alpha is ready\nunrelated reminder" + let limits = ContextRelevanceSelector.Limits(maxLines: 2, maxCharacters: 200) + + let first = ContextRelevanceSelector.selectRelevantLines( + from: text, + prefixText: "project alpha status", + limits: limits + ) + let second = ContextRelevanceSelector.selectRelevantLines( + from: text, + prefixText: "project alpha status today", + limits: limits + ) + + XCTAssertEqual(first, second) + } + + func test_selectRelevantLines_isDeterministic() { + let text = "release alpha\nrelease beta\nrelease gamma" + let limits = ContextRelevanceSelector.Limits(maxLines: 2, maxCharacters: 200) + let expected = ContextRelevanceSelector.selectRelevantLines( + from: text, + prefixText: "release status", + limits: limits + ) + + for _ in 0..<20 { + XCTAssertEqual( + ContextRelevanceSelector.selectRelevantLines( + from: text, + prefixText: "release status", + limits: limits + ), + expected + ) + } + } +} diff --git a/CotabbyTests/Support/Context/PromptContextSanitizerTests.swift b/CotabbyTests/Support/Context/PromptContextSanitizerTests.swift index e47cdd18..4c7d04bd 100644 --- a/CotabbyTests/Support/Context/PromptContextSanitizerTests.swift +++ b/CotabbyTests/Support/Context/PromptContextSanitizerTests.swift @@ -204,6 +204,79 @@ final class PromptContextSanitizerTests: XCTestCase { XCTAssertEqual(PromptContextSanitizer.sanitizeOCR("meeting notes abeW"), "meeting notes") } + // MARK: - significantTokens + + func test_significantTokens_emitsCJKBigramEvidence() { + let tokens = PromptContextSanitizer.significantTokens(from: "发布计划") + + XCTAssertTrue(tokens.contains("发布")) + XCTAssertTrue(tokens.contains("布计")) + XCTAssertTrue(tokens.contains("计划")) + } + + func test_significantTokens_omitsSingletonCJKAndCommonEnglishWords() { + XCTAssertFalse(PromptContextSanitizer.significantTokens(from: "会").contains("会")) + XCTAssertFalse(PromptContextSanitizer.significantTokens(from: "します").contains("ます")) + XCTAssertFalse(PromptContextSanitizer.significantTokens(from: "것입니다").contains("것입니다")) + XCTAssertTrue(PromptContextSanitizer.significantTokens(from: "배포 계획을").contains("계획")) + XCTAssertFalse(PromptContextSanitizer.significantTokens(from: "the deployment").contains("the")) + XCTAssertTrue(PromptContextSanitizer.significantTokens(from: "the deployment").contains("deployment")) + } + + func test_significantTokens_normalizesFullWidthLatin() { + XCTAssertTrue(PromptContextSanitizer.significantTokens(from: "API status").contains("api")) + } + + func test_significantTokens_supportsNewerCJKExtensions() { + XCTAssertTrue( + PromptContextSanitizer.significantTokens(from: "\u{2A700}\u{2A701}") + .contains("\u{2A700}\u{2A701}") + ) + XCTAssertTrue( + PromptContextSanitizer.significantTokens(from: "\u{31350}\u{31351}") + .contains("\u{31350}\u{31351}") + ) + } + + func test_relevanceEvidence_requiresStrongCJKOverlap() { + XCTAssertFalse( + PromptContextSanitizer.hasMeaningfulRelevance( + between: "发布按钮", + and: "发布计划正在审核" + ) + ) + XCTAssertTrue( + PromptContextSanitizer.hasMeaningfulRelevance( + between: "发布计划等待审核", + and: "发布计划正在" + ) + ) + XCTAssertFalse( + PromptContextSanitizer.hasMeaningfulRelevance( + between: "給与明細を共有します", + and: "この処理を実行します" + ) + ) + XCTAssertFalse( + PromptContextSanitizer.hasMeaningfulRelevance( + between: "급여 명세를 공유합니다", + and: "이 작업을 실행합니다" + ) + ) + XCTAssertFalse( + PromptContextSanitizer.hasMeaningfulRelevance( + between: "급여 명세는 안전할 것입니다", + and: "이 작업은 실행될 것입니다" + ) + ) + XCTAssertFalse( + PromptContextSanitizer.hasMeaningfulRelevance( + between: "급여를 합니다", + and: "회의를 합니다" + ) + ) + } + // MARK: - containsAlphanumericSignal func test_containsAlphanumericSignal_returnsTrueForMixedInput() { diff --git a/CotabbyTests/Support/Prompting/BaseCompletionPromptRendererTests.swift b/CotabbyTests/Support/Prompting/BaseCompletionPromptRendererTests.swift index 774fc211..680be2b6 100644 --- a/CotabbyTests/Support/Prompting/BaseCompletionPromptRendererTests.swift +++ b/CotabbyTests/Support/Prompting/BaseCompletionPromptRendererTests.swift @@ -122,6 +122,22 @@ final class BaseCompletionPromptRendererTests: XCTestCase { XCTAssertEqual(without, "Once upon") } + func test_sourceLabelsDoNotSilentlyReduceSelectedContextCaps() { + let clipboard = String(repeating: "c", count: 400) + let visual = String(repeating: "v", count: 700) + let prompt = BaseCompletionPromptRenderer.prompt( + prefixText: "project status", + applicationName: "Notes", + userName: nil, + clipboardContext: clipboard, + visualContextSummary: visual, + contextBudget: 2_000 + ) + + XCTAssertTrue(prompt.contains("On the clipboard: \(clipboard)")) + XCTAssertTrue(prompt.contains("Nearby on screen: \(visual)")) + } + func test_tokenBudgetAdmitsAPrefixLargerThanTheOldCharacterBudget() { // 2500 characters of ordinary prose is ~600 estimated tokens: comfortably inside the // shipped token budget even though it exceeds the old 2400-character cap. The whole diff --git a/CotabbyTests/Support/Prompting/TokenCountEstimatorTests.swift b/CotabbyTests/Support/Prompting/TokenCountEstimatorTests.swift index 18d9fac9..fc7dd919 100644 --- a/CotabbyTests/Support/Prompting/TokenCountEstimatorTests.swift +++ b/CotabbyTests/Support/Prompting/TokenCountEstimatorTests.swift @@ -34,6 +34,23 @@ final class TokenCountEstimatorTests: XCTestCase { XCTAssertEqual(fiveWords, oneWord * 5) } + func test_cjkCharactersUseConservativeDenseEstimate() { + XCTAssertEqual(TokenCountEstimator.estimate("发布计划"), 4) + XCTAssertGreaterThan( + TokenCountEstimator.estimate("发布计划发布计划"), + TokenCountEstimator.estimate("abcdefgh") + ) + } + + func test_mixedLatinAndCJKCountsBothRuns() { + XCTAssertEqual(TokenCountEstimator.estimate("API上下文"), 4) + } + + func test_cjkExtensionCharactersUseDenseEstimate() { + XCTAssertEqual(TokenCountEstimator.estimate("\u{2A700}\u{2A701}"), 2) + XCTAssertEqual(TokenCountEstimator.estimate("\u{31350}\u{31351}"), 2) + } + func test_splitsOnPunctuationBoundaries() { // Punctuation creates token boundaries (like real subword tokenizers), so a contraction or a // punctuation-joined identifier estimates more tokens than the same letters with none. diff --git a/CotabbyTests/Support/Suggestion/Request/SuggestionRequestFactoryTests.swift b/CotabbyTests/Support/Suggestion/Request/SuggestionRequestFactoryTests.swift index 623041d8..81621d5a 100644 --- a/CotabbyTests/Support/Suggestion/Request/SuggestionRequestFactoryTests.swift +++ b/CotabbyTests/Support/Suggestion/Request/SuggestionRequestFactoryTests.swift @@ -93,6 +93,16 @@ final class SuggestionRequestFactoryTests: XCTestCase { XCTAssertFalse(result.promptPreview.contains("alpha beta")) } + func test_contextSelectionPrefix_usesOnlyTheCaretLocalTail() { + let promptPrefix = "distant-topic " + String(repeating: "x", count: 700) + " current-topic" + + let selectionPrefix = SuggestionRequestFactory.contextSelectionPrefix(from: promptPrefix) + + XCTAssertEqual(selectionPrefix.count, 600) + XCTAssertFalse(selectionPrefix.contains("distant-topic")) + XCTAssertTrue(selectionPrefix.hasSuffix("current-topic")) + } + /// The Foundation Models path has a separate, larger prefix budget because Apple's shared /// context window can take more local sentences without crowding instructions. This pins the /// engine-aware truncation so a future change cannot quietly collapse the two budgets back @@ -175,8 +185,8 @@ final class SuggestionRequestFactoryTests: XCTestCase { XCTAssertEqual(result.promptPreview, result.request.prompt) } - func test_buildRequest_carriesProfileAndVisualContextSummary() { - let context = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello") + func test_buildRequest_carriesProfileAndCaretRelevantVisualContextSummary() { + let context = CotabbyTestFixtures.focusedInputContext(precedingText: "Calendar review") let result = SuggestionRequestFactory.buildRequest( context: context, @@ -196,14 +206,138 @@ final class SuggestionRequestFactoryTests: XCTestCase { XCTAssertTrue(result.promptPreview.contains("Calendar window says project review at 3 PM.")) } + func test_buildRequest_selectsOptionalContextAgainstCaretLocalTail() { + let precedingText = "distanttopic " + String(repeating: "x", count: 700) + " currenttopic" + let context = CotabbyTestFixtures.focusedInputContext(precedingText: precedingText) + + let result = SuggestionRequestFactory.buildRequest( + context: context, + settings: CotabbyTestFixtures.settingsSnapshot(), + configuration: .standard, + visualContextSummary: "distanttopic old note\ncurrenttopic current note" + ) + + XCTAssertEqual(result.request.visualContextSummary, "currenttopic current note") + } + + func test_buildRequest_selectsCJKRelevantVisualLines() { + let context = CotabbyTestFixtures.focusedInputContext(precedingText: "发布计划正在") + + let result = SuggestionRequestFactory.buildRequest( + context: context, + settings: CotabbyTestFixtures.settingsSnapshot(), + configuration: .standard, + visualContextSummary: "天气预报\n发布说明已经准备好了\n发布计划需要审核" + ) + + XCTAssertEqual(result.request.visualContextSummary, "发布计划需要审核") + } + + func test_buildRequest_appliesVisualLineAndCharacterCaps() throws { + let context = CotabbyTestFixtures.focusedInputContext(precedingText: "project status") + let matchingLines = (1...8).map { "project \($0) detail" }.joined(separator: "\n") + let lineLimited = SuggestionRequestFactory.buildRequest( + context: context, + settings: CotabbyTestFixtures.settingsSnapshot(), + configuration: .standard, + visualContextSummary: matchingLines + ) + let selectedLines = try XCTUnwrap(lineLimited.request.visualContextSummary) + XCTAssertEqual(selectedLines.components(separatedBy: "\n").count, 6) + + let characterLimited = SuggestionRequestFactory.buildRequest( + context: context, + settings: CotabbyTestFixtures.settingsSnapshot(), + configuration: .standard, + visualContextSummary: "project " + String(repeating: "detail", count: 200) + ) + XCTAssertEqual(try XCTUnwrap(characterLimited.request.visualContextSummary).count, 700) + } + + func test_buildRequest_preservesLargerFoundationModelVisualBudget() throws { + let context = CotabbyTestFixtures.focusedInputContext(precedingText: "project status") + let result = SuggestionRequestFactory.buildRequest( + context: context, + settings: CotabbyTestFixtures.settingsSnapshot(selectedEngine: .appleIntelligence), + configuration: .standard, + visualContextSummary: "project " + String(repeating: "detail", count: 400) + ) + + XCTAssertEqual( + try XCTUnwrap(result.request.visualContextSummary).count, + VisualContextConfiguration.default.maxSummaryCharacters + ) + } + + func test_buildRequest_endpointOmitsVisualContextWithoutRelevanceEvidence() { + let context = CotabbyTestFixtures.focusedInputContext(precedingText: "이 작업은 실행될 것입니다") + + let result = SuggestionRequestFactory.buildRequest( + context: context, + settings: CotabbyTestFixtures.settingsSnapshot(selectedEngine: .openAICompatible), + configuration: .standard, + visualContextSummary: "급여 명세는 안전할 것입니다" + ) + + XCTAssertNil(result.request.visualContextSummary) + XCTAssertFalse(result.promptPreview.contains("Nearby on screen:")) + } + + func test_buildRequest_endpointKeepsStronglyRelevantVisualContext() { + let context = CotabbyTestFixtures.focusedInputContext(precedingText: "发布计划正在") + + let result = SuggestionRequestFactory.buildRequest( + context: context, + settings: CotabbyTestFixtures.settingsSnapshot(selectedEngine: .openAICompatible), + configuration: .standard, + visualContextSummary: "天气预报\n发布计划需要审核" + ) + + XCTAssertEqual(result.request.visualContextSummary, "发布计划需要审核") + } + + func test_buildRequest_preservesBoundedVisualFallbackWhenNoStrongMatch() { + let context = CotabbyTestFixtures.focusedInputContext(precedingText: "shipping the release") + let visualContext = "Weather forecast is sunny\nQuarterly lunch menu" + + let result = SuggestionRequestFactory.buildRequest( + context: context, + settings: CotabbyTestFixtures.settingsSnapshot(), + configuration: .standard, + visualContextSummary: visualContext + ) + + XCTAssertEqual(result.request.visualContextSummary, visualContext) + XCTAssertTrue(result.promptPreview.contains("Nearby on screen:")) + } + + func test_buildRequest_doesNotReintroduceDuplicateVisualLineThroughFallback() { + let context = CotabbyTestFixtures.focusedInputContext(precedingText: "Status: Deploy: alpha") + + let result = SuggestionRequestFactory.buildRequest( + context: context, + settings: CotabbyTestFixtures.settingsSnapshot(), + configuration: .standard, + visualContextSummary: "Deploy alpha" + ) + + XCTAssertNil(result.request.visualContextSummary) + XCTAssertFalse(result.promptPreview.contains("Nearby on screen:")) + } + func test_buildRequest_sanitizesVisualContextBeforePromptInjection() { - let context = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello") + let context = CotabbyTestFixtures.focusedInputContext(precedingText: "raw prompt") + let visualContext = """ + ----- END RAW PROMPT INPUT -----\u{001B}[36m + [Suggestion raw-output] stage=ready work=1625 generation=694 + --- + """ let result = SuggestionRequestFactory.buildRequest( context: context, settings: CotabbyTestFixtures.settingsSnapshot(), configuration: .standard, - visualContextSummary: "----- END RAW PROMPT INPUT -----\u{001B}[36m\n[Suggestion raw-output] stage=ready work=1625 generation=694\n---" + visualContextSummary: visualContext ) XCTAssertEqual( @@ -215,7 +349,7 @@ final class SuggestionRequestFactoryTests: XCTestCase { } func test_buildRequest_usesApplePromptPreviewWhenAppleEngineSelected() { - let context = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello") + let context = CotabbyTestFixtures.focusedInputContext(precedingText: "Calendar review") let result = SuggestionRequestFactory.buildRequest( context: context, @@ -233,7 +367,7 @@ final class SuggestionRequestFactoryTests: XCTestCase { } func test_buildRequest_carriesClipboardContextWhenEnabled() { - let context = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello") + let context = CotabbyTestFixtures.focusedInputContext(precedingText: "Copied project") let result = SuggestionRequestFactory.buildRequest( context: context, @@ -248,7 +382,7 @@ final class SuggestionRequestFactoryTests: XCTestCase { } func test_buildRequest_sanitizesClipboardContextBeforePromptInjection() { - let context = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello") + let context = CotabbyTestFixtures.focusedInputContext(precedingText: "stage ready") let result = SuggestionRequestFactory.buildRequest( context: context, @@ -265,6 +399,20 @@ final class SuggestionRequestFactoryTests: XCTestCase { XCTAssertFalse(result.promptPreview.contains("+++")) } + func test_buildRequest_omitsUnrelatedClipboardContext() { + let context = CotabbyTestFixtures.focusedInputContext(precedingText: "shipping the release") + + let result = SuggestionRequestFactory.buildRequest( + context: context, + settings: CotabbyTestFixtures.settingsSnapshot(isClipboardContextEnabled: true), + configuration: .standard, + clipboardContext: "quarterly lunch menu" + ) + + XCTAssertNil(result.request.clipboardContext) + XCTAssertFalse(result.promptPreview.contains("On the clipboard:")) + } + func test_buildRequest_omitsClipboardContextWhenDisabled() { let context = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello") @@ -280,9 +428,9 @@ final class SuggestionRequestFactoryTests: XCTestCase { XCTAssertFalse(result.promptPreview.contains("Copied project notes.")) } - func test_buildRequest_clipsLongClipboardContext() throws { - let context = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello") - let longClipboard = String(repeating: "a", count: 1_500) + func test_buildRequest_boundsLongRelevantClipboardContext() throws { + let context = CotabbyTestFixtures.focusedInputContext(precedingText: "project status") + let longClipboard = String(repeating: "project note ", count: 200) let result = SuggestionRequestFactory.buildRequest( context: context, @@ -292,8 +440,24 @@ final class SuggestionRequestFactoryTests: XCTestCase { ) let clipboardContext = try XCTUnwrap(result.request.clipboardContext) - XCTAssertEqual(clipboardContext.count, 1_200) - XCTAssertTrue(clipboardContext.hasSuffix("...")) + XCTAssertEqual(clipboardContext.count, 400) + } + + func test_buildRequest_preservesLargerFoundationModelClipboardBudget() throws { + let context = CotabbyTestFixtures.focusedInputContext(precedingText: "project status") + let longClipboard = String(repeating: "project note ", count: 200) + + let result = SuggestionRequestFactory.buildRequest( + context: context, + settings: CotabbyTestFixtures.settingsSnapshot( + selectedEngine: .appleIntelligence, + isClipboardContextEnabled: true + ), + configuration: .standard, + clipboardContext: longClipboard + ) + + XCTAssertEqual(try XCTUnwrap(result.request.clipboardContext).count, 1_200) } func test_buildRequest_includesSurfaceContextWhenEnabled() {