Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Fluid.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -57,6 +58,7 @@
980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = "<group>"; };
A62300000000000000000001 /* AudioBufferConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBufferConverterTests.swift; sourceTree = "<group>"; };
D1A600000000000000000201 /* SpeakerTurnMergingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeakerTurnMergingTests.swift; sourceTree = "<group>"; };
F1BD00000000000000000001 /* StripThinkingTagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripThinkingTagsTests.swift; sourceTree = "<group>"; };
C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEngineRetirementDrainTests.swift; sourceTree = "<group>"; };
DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectAudioReliabilityTests.swift; sourceTree = "<group>"; };
7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
18 changes: 16 additions & 2 deletions Sources/Fluid/Services/LLMClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,13 @@ final class LLMClient {
var workingText = text
var thinking = ""

// Models that emit a proper <think>…</think> block may still leak a stray
// </think> 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 "thoughts</think>response").
let hasOpeningThinkTag = text.range(of: "<think>") != nil
|| text.range(of: "<thinking>") != nil
Comment on lines +904 to +905

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep orphan parsing when only the answer mentions tags

When Nemotron-style non-streaming output uses reasoning</think>answer, this flag now disables the orphan-separator pass if the visible answer happens to contain the literal text <think> or <thinking> anywhere. For example, reasoning</think>Explain the <think> tag is returned as visible content with the reasoning leaked at the front instead of extracting reasoning, because there was no real opening tag before the separator—only tag text in the answer. Consider basing this guard on whether the proper-pair regex actually removed a real block before skipping orphan parsing.

Useful? React with 👍 / 👎.


// First, handle proper <think>...</think> pairs
if let regex = try? NSRegularExpression(pattern: Self.thinkingTagPattern, options: []) {
let range = NSRange(workingText.startIndex..., in: workingText)
Expand All @@ -912,8 +919,15 @@ final class LLMClient {
}

// Second, handle orphan closing tags (content before </think> without opening tag)
// This handles cases like "We have a request...</think>Hello!"
if let orphanRegex = try? NSRegularExpression(pattern: Self.orphanThinkingPattern, options: []) {
// This handles cases like "We have a request...</think>Hello!" (Nemotron-style output
// that begins with thinking and uses </think> 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 </think> 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)

Expand Down
61 changes: 61 additions & 0 deletions Tests/FluidDictationIntegrationTests/StripThinkingTagsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
@testable import FluidVoice_Debug
import XCTest

// Regression tests for `LLMClient.stripThinkingTags`.
//
// Models that emit a proper <think>…</think> block can still leak a stray </think>
// later in their answer. The orphan pass (meant for Nemotron's no-opening-tag
// "thoughts</think>response" 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 <think>…</think> 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(
"<think>reasoning here</think>The literal </think> 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 </think> 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 here</think>Hello world.")

XCTAssertEqual(result.thinking, "reasoning here")
XCTAssertEqual(result.content, "Hello world.")
}

/// The `<thinking>` variant of the Nemotron separator is handled the same way.
func testOrphanCloseWithoutOpeningTagSupportsLongFormTag() {
let result = self.client.stripThinkingTags("thoughts</thinking>response")

XCTAssertEqual(result.thinking, "thoughts")
XCTAssertEqual(result.content, "response")
}

/// A normal, well-formed <think>…</think> block is unchanged.
func testProperThinkBlockIsUnchanged() {
let result = self.client.stripThinkingTags("<think>reasoning here</think>Hello 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.")
}
}
Loading