diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index f281dd4c..807b588f 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */; }; A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62300000000000000000001 /* AudioBufferConverterTests.swift */; }; D1A600000000000000000202 /* SpeakerTurnMergingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A600000000000000000201 /* SpeakerTurnMergingTests.swift */; }; + F1BD00000000000000000002 /* StripThinkingTagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1BD00000000000000000001 /* StripThinkingTagsTests.swift */; }; C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */; }; DA7100020000000000000002 /* DirectAudioReliabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */; }; 7CFA1D0B2F500000C0DEF001 /* TypingServiceTransientPasteboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFA1D0B2F500000C0DEF002 /* TypingServiceTransientPasteboardTests.swift */; }; @@ -57,6 +58,7 @@ 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = ""; }; A62300000000000000000001 /* AudioBufferConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBufferConverterTests.swift; sourceTree = ""; }; D1A600000000000000000201 /* SpeakerTurnMergingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeakerTurnMergingTests.swift; sourceTree = ""; }; + F1BD00000000000000000001 /* StripThinkingTagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripThinkingTagsTests.swift; sourceTree = ""; }; C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEngineRetirementDrainTests.swift; sourceTree = ""; }; DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectAudioReliabilityTests.swift; sourceTree = ""; }; 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -138,6 +140,7 @@ 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */, A62300000000000000000001 /* AudioBufferConverterTests.swift */, D1A600000000000000000201 /* SpeakerTurnMergingTests.swift */, + F1BD00000000000000000001 /* StripThinkingTagsTests.swift */, C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */, DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */, 7CFA1D0B2F500000C0DEF002 /* TypingServiceTransientPasteboardTests.swift */, @@ -300,6 +303,7 @@ 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */, A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */, D1A600000000000000000202 /* SpeakerTurnMergingTests.swift in Sources */, + F1BD00000000000000000002 /* StripThinkingTagsTests.swift in Sources */, C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */, DA7100020000000000000002 /* DirectAudioReliabilityTests.swift in Sources */, 7CFA1D0B2F500000C0DEF001 /* TypingServiceTransientPasteboardTests.swift in Sources */, diff --git a/Sources/Fluid/Services/LLMClient.swift b/Sources/Fluid/Services/LLMClient.swift index a6603b8f..03982e9d 100644 --- a/Sources/Fluid/Services/LLMClient.swift +++ b/Sources/Fluid/Services/LLMClient.swift @@ -897,6 +897,13 @@ final class LLMClient { var workingText = text var thinking = "" + // Models that emit a proper block may still leak a stray + // later in their answer. Detect up front whether an opening tag was + // ever present so the orphan pass below is only applied to the no-opening-tag + // case it is meant for (e.g. Nemotron's "thoughtsresponse"). + let hasOpeningThinkTag = text.range(of: "") != nil + || text.range(of: "") != nil + // First, handle proper ... pairs if let regex = try? NSRegularExpression(pattern: Self.thinkingTagPattern, options: []) { let range = NSRange(workingText.startIndex..., in: workingText) @@ -912,8 +919,15 @@ final class LLMClient { } // Second, handle orphan closing tags (content before without opening tag) - // This handles cases like "We have a request...Hello!" - if let orphanRegex = try? NSRegularExpression(pattern: Self.orphanThinkingPattern, options: []) { + // This handles cases like "We have a request...Hello!" (Nemotron-style output + // that begins with thinking and uses as the separator, with no opening tag). + // Skip this when an opening tag was present: there the thinking section was already + // removed above, so any remaining is stray markup and is stripped below + // rather than reclassifying the preceding answer text as thinking (which dropped the + // text between the real close and the stray close from the visible response). + if !hasOpeningThinkTag, + let orphanRegex = try? NSRegularExpression(pattern: Self.orphanThinkingPattern, options: []) + { let range = NSRange(workingText.startIndex..., in: workingText) let matches = orphanRegex.matches(in: workingText, options: [], range: range) diff --git a/Tests/FluidDictationIntegrationTests/StripThinkingTagsTests.swift b/Tests/FluidDictationIntegrationTests/StripThinkingTagsTests.swift new file mode 100644 index 00000000..04fd50bd --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/StripThinkingTagsTests.swift @@ -0,0 +1,61 @@ +@testable import FluidVoice_Debug +import XCTest + +// Regression tests for `LLMClient.stripThinkingTags`. +// +// Models that emit a proper block can still leak a stray +// later in their answer. The orphan pass (meant for Nemotron's no-opening-tag +// "thoughtsresponse" output) used to reclassify every character between the +// real close and the stray close as thinking, dropping it from the visible response. + +@MainActor +final class StripThinkingTagsTests: XCTestCase { + private let client = LLMClient.shared + + /// A stray closing tag after a real block must not eat the + /// answer text that precedes it. Previously "The literal " was moved into + /// thinking and removed from the response. + func testStrayCloseAfterRealThinkBlockKeepsContent() { + let result = self.client.stripThinkingTags( + "reasoning hereThe literal tag is stray." + ) + + XCTAssertEqual(result.thinking, "reasoning here", "only the real think block is thinking") + XCTAssertTrue(result.content.contains("The literal"), "content before the stray close must stay visible") + XCTAssertTrue(result.content.contains("tag is stray"), "content after the stray close must stay visible") + } + + /// Nemotron-style output has no opening tag and uses as the separator: + /// everything before it is thinking, everything after is content. This must keep + /// working (the orphan pass is still applied when no opening tag was present). + func testOrphanCloseWithoutOpeningTagStillSplits() { + let result = self.client.stripThinkingTags("reasoning hereHello world.") + + XCTAssertEqual(result.thinking, "reasoning here") + XCTAssertEqual(result.content, "Hello world.") + } + + /// The `` variant of the Nemotron separator is handled the same way. + func testOrphanCloseWithoutOpeningTagSupportsLongFormTag() { + let result = self.client.stripThinkingTags("thoughtsresponse") + + XCTAssertEqual(result.thinking, "thoughts") + XCTAssertEqual(result.content, "response") + } + + /// A normal, well-formed block is unchanged. + func testProperThinkBlockIsUnchanged() { + let result = self.client.stripThinkingTags("reasoning hereHello world.") + + XCTAssertEqual(result.thinking, "reasoning here") + XCTAssertEqual(result.content, "Hello world.") + } + + /// Plain content with no thinking tags at all is returned verbatim. + func testPlainContentWithNoTagsIsUnchanged() { + let result = self.client.stripThinkingTags("Just a normal response with no tags at all.") + + XCTAssertEqual(result.thinking, "") + XCTAssertEqual(result.content, "Just a normal response with no tags at all.") + } +}