Skip to content
Open
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 @@ -14,6 +14,7 @@
7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */; };
7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */; };
7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; };
37C99EA57FCA4CDA8967073A /* DictationSystemPromptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C79B8A76E7C4F7A80A8EB95 /* DictationSystemPromptTests.swift */; };
7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; };
7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; };
7CE006BD2E80EBE600DDCCD6 /* AppUpdater in Frameworks */ = {isa = PBXBuildFile; productRef = 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */; };
Expand All @@ -34,6 +35,7 @@
7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = "<group>"; };
7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = "<group>"; };
7C79B8A76E7C4F7A80A8EB95 /* DictationSystemPromptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationSystemPromptTests.swift; sourceTree = "<group>"; };
7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = "<group>"; };
7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = "<group>"; };
7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
Expand Down Expand Up @@ -104,6 +106,7 @@
7CDB0A272F3C4D5600FB7CAD /* Resources */,
7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */,
7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */,
7C79B8A76E7C4F7A80A8EB95 /* DictationSystemPromptTests.swift */,
);
path = FluidDictationIntegrationTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -258,6 +261,7 @@
7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */,
7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */,
7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */,
37C99EA57FCA4CDA8967073A /* DictationSystemPromptTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
23 changes: 6 additions & 17 deletions Sources/Fluid/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1807,23 +1807,12 @@ struct ContentView: View {
return self.buildSystemPrompt(appInfo: appInfo, dictationSlot: dictationSlot)
}()

// Dictation enhancement folds the prompt + transcript into a single user
// turn (substituting `${transcript}` when present, otherwise appending
// the transcript after a blank line). Non-dictation callers — the AI
// chat tab specifically — keep the legacy two-message layout where
// the prompt is the system turn and the input is the user turn.
let systemPrompt: String
let userMessageContent: String
if isDictationCall {
systemPrompt = ""
userMessageContent = SettingsStore.renderDictationUserMessage(
promptText: promptText,
transcript: inputText
)
} else {
systemPrompt = promptText
userMessageContent = inputText
}
// Instructions always go in the system role; the transcript (or user
// input) is always the sole user turn. Folding both into the user message
// was the previous behaviour for dictation calls, but it causes weaker
// models to answer the transcript rather than apply the instructions.
let systemPrompt = SettingsStore.renderSystemPrompt(promptText: promptText, transcript: inputText)
let userMessageContent = inputText

// Route to Apple Intelligence if selected
if currentSelectedProviderID == "apple-intelligence" {
Expand Down
8 changes: 8 additions & 0 deletions Sources/Fluid/Persistence/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1149,6 +1149,14 @@ final class SettingsStore: ObservableObject {
return promptText + "\n\n" + transcript
}

/// Substitute `${transcript}` in a system-role prompt template.
/// Unlike `renderDictationUserMessage`, this never appends the transcript —
/// the transcript is always sent as a separate user turn.
static func renderSystemPrompt(promptText: String, transcript: String) -> String {
guard promptText.contains(self.transcriptPlaceholder) else { return promptText }
return promptText.replacingOccurrences(of: self.transcriptPlaceholder, with: transcript)

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 Avoid duplicating placeholder transcripts

For dictation prompts that contain ${transcript}, this replacement puts the transcript into the system prompt, while both new call sites still send the same text as the user turn (ContentView sets userMessageContent = inputText, and DictationPostProcessingService sets it to trimmed). Those prompts used to include the transcript only once via renderDictationUserMessage; now long dictations can double token usage or exceed context, and templates that relied on the placeholder to control where the sole transcript appears will see duplicate input. Please either avoid expanding the placeholder once the transcript is a separate user message, or suppress the extra user turn for placeholder templates.

Useful? React with 👍 / 👎.

}

private func defaultPromptResolution(
for mode: PromptMode,
source: PromptResolutionSource,
Expand Down
7 changes: 3 additions & 4 deletions Sources/Fluid/Services/DictationPostProcessingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,11 @@ final class DictationPostProcessingService {
)
}

let promptText = settings.effectiveDictationSystemPrompt(for: dictationSlot, appBundleID: nil)
let systemPrompt = ""
let userMessageContent = SettingsStore.renderDictationUserMessage(
promptText: promptText,
let systemPrompt = SettingsStore.renderSystemPrompt(
promptText: settings.effectiveDictationSystemPrompt(for: dictationSlot, appBundleID: nil),
transcript: trimmed
)
let userMessageContent = trimmed

if resolved.providerID == "apple-intelligence" {
#if canImport(FoundationModels)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
@testable import FluidVoice_Debug
import XCTest

// Regression tests for https://github.com/altic-dev/FluidVoice/issues/388
// AI enhancement instructions must be sent in the system role, not the user message.
// Previously, DictationPostProcessingService hardcoded systemPrompt = "" and folded
// the instruction text into the user message alongside the transcript.

@MainActor
final class DictationSystemPromptTests: XCTestCase {
// MARK: - effectiveDictationSystemPrompt

func testEffectiveDictationSystemPrompt_returnsConfiguredPrompt() {
self.withPromptSettingsRestored {
let settings = SettingsStore.shared
let custom = SettingsStore.DictationPromptProfile(
name: "Test Profile",
prompt: "Clean up the transcript. Remove filler words.",
mode: .dictate
)
settings.dictationPromptProfiles = [custom]
settings.selectedDictationPromptID = custom.id

let result = settings.effectiveDictationSystemPrompt(for: .primary)
XCTAssertFalse(result.isEmpty, "effectiveDictationSystemPrompt must return the configured prompt, not an empty string")
XCTAssertTrue(result.contains("Clean up the transcript"), "system prompt must include the custom instruction text")
}
}

func testEffectiveDictationSystemPrompt_offSelection_returnsDefault() {
self.withPromptSettingsRestored {
let settings = SettingsStore.shared
settings.setDictationPromptSelection(.off)

// When off, effectiveDictationSystemPrompt falls back to the built-in default,
// which is non-empty. This ensures the system field is never silently blank.
let result = settings.effectiveDictationSystemPrompt(for: .primary)
XCTAssertFalse(result.isEmpty, "built-in default prompt must be non-empty")
}
}

// MARK: - renderSystemPrompt (${transcript} placeholder in the system role)

func testRenderSystemPrompt_noPlaceholder_returnsPromptUnchanged() {
let prompt = "Clean up the transcript."
let result = SettingsStore.renderSystemPrompt(promptText: prompt, transcript: "hello world")
XCTAssertEqual(result, prompt, "prompt without placeholder must be returned unchanged")
}

func testRenderSystemPrompt_transcriptPlaceholder_isSubstitutedInSystemRole() {
// Users can embed ${transcript} in their system prompt template so the transcript
// appears inline in their instructions. renderSystemPrompt must substitute it before
// the prompt is sent to the provider as the system message.
let prompt = "Rewrite cleanly: \(SettingsStore.transcriptPlaceholder)"
let transcript = "um so like yeah"
let result = SettingsStore.renderSystemPrompt(promptText: prompt, transcript: transcript)
XCTAssertEqual(result, "Rewrite cleanly: um so like yeah")
}

// MARK: - Helpers

private func withPromptSettingsRestored(_ run: () -> Void) {
let keys = [
"DictationPromptProfiles",
"SelectedDictationPromptID",
"DictationPromptOff",
]
let defaults = UserDefaults.standard
var snapshot: [String: Any] = [:]
for key in keys {
if let v = defaults.object(forKey: key) { snapshot[key] = v }
}
defer {
for key in keys {
if let v = snapshot[key] { defaults.set(v, forKey: key) }
else { defaults.removeObject(forKey: key) }
}
}
run()
}
}
Loading