diff --git a/AGENTS.md b/AGENTS.md index f5a6c03b..6b62038b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1017,6 +1017,10 @@ Engine-side stores, all `UserDefaults`-backed value types with the same shape: **`EnhancedTranscriptsStore`** (`BlurtEnhancedTranscripts`, **on** by default — unset reads as enabled; picks the cleanup rewrite over the verbatim transcript in the dictation _response_, re-read at every request — it shapes no part of the request), + **`RewriteModeStore`** (`BlurtRewriteMode`, `RewriteMode.fast` by default — unset or unknown + reads as Fast; Smart sends `config.rewrite_mode: "smart"` for the larger rewrite model, Fast + omits the key so the default request is unchanged, and nothing is sent while enhanced + transcripts are off; re-read at every request), **`StyleProfileStore`** (three keys: `BlurtStyleProfiles`, the JSON list of up to `profileLimit` (4) named `StyleProfile`s; `BlurtActiveStyleProfile`, the id of the one in effect, or the `defaultStyleID` sentinel when the user has selected **Default** — base diff --git a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift index 37b032d9..8a6d9468 100644 --- a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift +++ b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift @@ -109,16 +109,39 @@ private struct TranscriptionSection: View { @AppStorage(EnhancedTranscriptsStore.defaultsKey) private var enhancedTranscripts = EnhancedTranscriptsStore.defaultValue + // "" is "nothing persisted"; the unset default belongs to + // `RewriteMode.fromPersisted`, as `HotkeyStepView`'s activation slot does. + @AppStorage(RewriteModeStore.defaultsKey) private var rewriteModeRaw = "" + + private var rewriteMode: Binding { + Binding( + get: { RewriteMode.fromPersisted(rewriteModeRaw) }, + // Through the store, which owns the encoding (see `HotkeyStepView`). + set: { RewriteModeStore().mode = $0 }) + } + var body: some View { Section { Toggle(isOn: $enhancedTranscripts) { SettingLabel(title: "Enhanced transcripts", systemImage: "wand.and.stars") } .accessibilityIdentifier(UITestIdentifiers.enhancedTranscriptsToggle) + // Picks the model for the rewrite the switch above pastes, so it is + // disabled with it, as the styles below are. + PickerSettingRow( + title: "Dictation mode", systemImage: "speedometer", + accessibilityID: UITestIdentifiers.rewriteModePicker, selection: rewriteMode + ) { + ForEach(RewriteMode.allCases, id: \.self) { mode in + Text(mode.label).tag(mode) + } + } + .disabled(!enhancedTranscripts) } footer: { Text( "Polishes each dictation before pasting — removing filler words and fixing punctuation. " + "Turn off to paste your words exactly as spoken.") + Text(RewriteMode.guidance) } } } diff --git a/App/Blurt/Shared/UITestIdentifiers.swift b/App/Blurt/Shared/UITestIdentifiers.swift index 6aa2bc23..aca70b02 100644 --- a/App/Blurt/Shared/UITestIdentifiers.swift +++ b/App/Blurt/Shared/UITestIdentifiers.swift @@ -69,6 +69,7 @@ enum UITestIdentifiers { static let developerToggle = "settings.developer.toggle" static let developerShowLogs = "settings.developer.showLogs" static let enhancedTranscriptsToggle = "settings.enhancedTranscripts.toggle" + static let rewriteModePicker = "settings.rewriteMode.picker" static let selectionSpeechToggle = "settings.selectionSpeech.toggle" static let selectionAskToggle = "settings.selectionAsk.toggle" static let readAloudSpeedPicker = "settings.readAloud.speed" diff --git a/Sources/AssemblyAI/Dictation/DictationWireTypes.swift b/Sources/AssemblyAI/Dictation/DictationWireTypes.swift index d4875504..04be9637 100644 --- a/Sources/AssemblyAI/Dictation/DictationWireTypes.swift +++ b/Sources/AssemblyAI/Dictation/DictationWireTypes.swift @@ -93,15 +93,22 @@ public struct DictationConfig: Encodable, Sendable { /// Adding an `llm` case back would reintroduce it *and* leave the response's /// choice unreachable — read `transcript(from:)` first. public let llmInstruction: String? + /// Which model the cleanup rewrite runs on: `"smart"` (a larger model) or + /// `"fast"`, or nil to omit the key and leave the route on its default. + /// A top-level sibling of `llm_instruction`, which is where the rest of the + /// rewrite request already lives. + public let rewriteMode: String? public init( - sampleRate: Int, channels: Int, sttPrompt: String, keytermsPrompt: [String], llmInstruction: String? + sampleRate: Int, channels: Int, sttPrompt: String, keytermsPrompt: [String], llmInstruction: String?, + rewriteMode: String? = nil ) { self.sampleRate = sampleRate self.channels = channels self.sttPrompt = sttPrompt self.keytermsPrompt = keytermsPrompt self.llmInstruction = llmInstruction + self.rewriteMode = rewriteMode } enum CodingKeys: String, CodingKey { case sampleRate = "sample_rate" @@ -109,6 +116,7 @@ public struct DictationConfig: Encodable, Sendable { case sttPrompt = "stt_prompt" case keytermsPrompt = "keyterms_prompt" case llmInstruction = "llm_instruction" + case rewriteMode = "rewrite_mode" } /// Hand-written for the one thing synthesis can't express: an empty @@ -129,6 +137,7 @@ public struct DictationConfig: Encodable, Sendable { try container.encode(keytermsPrompt, forKey: .keytermsPrompt) } try container.encodeIfPresent(llmInstruction, forKey: .llmInstruction) + try container.encodeIfPresent(rewriteMode, forKey: .rewriteMode) } } diff --git a/Sources/BlurtEngine/Config/DefaultsKey.swift b/Sources/BlurtEngine/Config/DefaultsKey.swift index bf7ddc51..706d98b9 100644 --- a/Sources/BlurtEngine/Config/DefaultsKey.swift +++ b/Sources/BlurtEngine/Config/DefaultsKey.swift @@ -31,6 +31,8 @@ enum DefaultsKey: String, CaseIterable { case keyTerms = "KeyTerms" case developerMode = "DeveloperMode" case enhancedTranscripts = "EnhancedTranscripts" + /// Which model the cleanup rewrite runs on (`RewriteModeStore`). + case rewriteMode = "RewriteMode" /// The pre-profiles single custom-style field. `StyleProfileStore` still /// *reads* it — an install that predates profiles has its text here and no /// list — so the case stays, and its raw value with it. diff --git a/Sources/BlurtEngine/Config/RewriteModeStore.swift b/Sources/BlurtEngine/Config/RewriteModeStore.swift new file mode 100644 index 00000000..8f8eeb66 --- /dev/null +++ b/Sources/BlurtEngine/Config/RewriteModeStore.swift @@ -0,0 +1,63 @@ +import Foundation + +/// Which model the dictation API's server-side cleanup rewrite runs on — the +/// request's `rewrite_mode` option. Fast is the model every request has used so +/// far and the unset default; Smart opts in to a larger one that rewrites better +/// and takes longer. The raw value is what `RewriteModeStore` persists, so it +/// follows `DefaultsKey`'s rule: rename a case freely, its raw value never. +public enum RewriteMode: String, CaseIterable, Sendable { + /// The shipped behavior, and the unset default (`fromPersisted`). + case fast = "Fast" + /// The larger rewrite model. It has a longer server-side path, and a rewrite + /// that misses the route's budget comes back as `llm_error` and degrades to the + /// verbatim transcript (`AssemblyAITranscriber.transcript(from:)`). + case smart = "Smart" + + /// Decodes a persisted raw value, falling back to Fast for unset ("") or + /// unknown values. The single decode-with-default rule shared by + /// `RewriteModeStore` and the `@AppStorage` view that reads the raw slot. + public static func fromPersisted(_ raw: String) -> RewriteMode { + RewriteMode(rawValue: raw) ?? .fast + } + + /// Menu-picker title. + public var label: String { + switch self { + case .fast: return "Fast" + case .smart: return "Smart" + } + } + + /// The Settings footer sentence under the picker. + public static let guidance = "Smart uses a larger model for better rewrites; Fast is quicker." + + /// What goes on the wire as `rewrite_mode`, or nil to omit the key. Fast + /// **omits** it rather than sending `"fast"`, so the default request stays + /// byte-identical to the one that shipped before this setting existed. + var wireValue: String? { + switch self { + case .fast: return nil + case .smart: return "smart" + } + } +} + +/// Persists the chosen `RewriteMode` as its raw value in `UserDefaults`. Reads +/// as Fast when unset or when the stored value isn't a known mode. +/// `AssemblyAITranscriber` reads it at each request, so a change applies to the +/// very next dictation. Same shape as `TriggerActivationStore`. +public struct RewriteModeStore { + /// UserDefaults key holding the mode. Public so SwiftUI views can observe it + /// directly (e.g. `@AppStorage`) and re-render on change. + public static var defaultsKey: String { DefaultsKey.rewriteMode.key } + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + public var mode: RewriteMode { + get { RewriteMode.fromPersisted(defaults.string(forKey: Self.defaultsKey) ?? "") } + nonmutating set { defaults.set(newValue.rawValue, forKey: Self.defaultsKey) } + } +} diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index a0e6360f..bb91f112 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -191,7 +191,7 @@ func transcribe( func warmUp() async // optional; no-op default ``` -`AssemblyAITranscriber` is a stateless `Sendable` struct. One `POST https://dictation.assemblyai.com/v1/transcribe/live` per utterance, **uploaded in chunks while the recording is still happening**: called at press, it writes the whole `config` part and the `audio` part's headers immediately and then each captured frame as `frames` delivers it, so finishing that stream is what signals end-of-audio. `URLRequest.httpBodyStream` is fed through a `CFStream` bound pair (`ChunkedRequestBody`, in the `AssemblyAI` library with `HTTPTransport`, the wire types and `APIKeyValidator`) because no `URLSession` API takes an `AsyncSequence` body; leaving `Content-Length` unset is what makes the transfer chunked. The body is a JSON `config` part (`sample_rate`, `channels`, and `llm_instruction` carrying `CleanupInstruction.text` with any custom style instructions appended — `CleanupInstruction.sendable(appending:)` — on every request, whatever the enhanced-transcripts setting says) followed by the raw S16LE PCM byte-for-byte, with the API key in `Authorization` and `Blurt/ (macOS .)` in `User-Agent` — `; dev` appended off a release build, so local installs stay out of the service-side baseline (see `UserAgent`; no model header — the service pins the STT model server-side). That `config` part is written **first**, and this route requires it — it rejects an audio-first body with a 400 (see `AssemblyAITranscriber.streamedBody` for the service's exact wording). That is where the latency comes from — the service opens its upstream STT call as soon as the config lands, so inference overlaps the recording and nothing sits between the last frame and the response. The cost is that the press-time focused-field read has to be settled _before_ the request opens, so `DictationSession.startUpload` awaits it (bounded by `contextWaitBudget`) and hands it in as a value; there is no longer a `context` channel for the body producer to wait on. The response carries the verbatim `text`, the rewritten `llm_response`, and the documented request metadata — `session_id` (quote it when reporting a problem), `audio_duration_ms`, `request_time_ms` and `sync_time_ms`, all logged by `logServerMetrics` and all decoded optionally so a dropped diagnostic can never turn a good dictation into a `malformedResponse`. `transcript(from:)` picks between the two transcripts: with enhanced transcripts on it returns the rewrite and falls back to `text` when that is null or blank (the rewrite is best-effort — `llm_error` is logged, never surfaced as a failure); with it off it returns `text`. Its initializer takes an `apiKeyProvider` closure (defaults to `APIKeyStore.current`), a `baseURL`, an `HTTPTransport` — inject a fake transport (see `Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift`) to test against canned responses — an `enhancedTranscripts` closure deciding, per request, which of the response's two transcripts to paste (nil, the default, reads `EnhancedTranscriptsStore`), and a `customStyle` closure supplying the style instructions appended to the cleanup instruction (nil, the default, reads the active profile from `StyleProfileStore`). `warmUp()` (in `DictationWarmUp.swift`) fires a GET at the route's own `/warm` endpoint — "an unauthenticated no-op" published for exactly this — to pre-pool the connection; it hit the bare host root until 2026-09-11, which pooled the same connection but asked for a page the service never documented answering. It never throws, and any failure just means the real request pays connection setup as before. The reference's two conditions for the pooled connection to still be there are structural here: same client (the injected `transport`, which is the one `transcribe` uses) and same host (`baseURL`, so a data-zone override warms the zone it will call). +`AssemblyAITranscriber` is a stateless `Sendable` struct. One `POST https://dictation.assemblyai.com/v1/transcribe/live` per utterance, **uploaded in chunks while the recording is still happening**: called at press, it writes the whole `config` part and the `audio` part's headers immediately and then each captured frame as `frames` delivers it, so finishing that stream is what signals end-of-audio. `URLRequest.httpBodyStream` is fed through a `CFStream` bound pair (`ChunkedRequestBody`, in the `AssemblyAI` library with `HTTPTransport`, the wire types and `APIKeyValidator`) because no `URLSession` API takes an `AsyncSequence` body; leaving `Content-Length` unset is what makes the transfer chunked. The body is a JSON `config` part (`sample_rate`, `channels`, and `llm_instruction` carrying `CleanupInstruction.text` with any custom style instructions appended — `CleanupInstruction.sendable(appending:)` — on every request, whatever the enhanced-transcripts setting says) followed by the raw S16LE PCM byte-for-byte, with the API key in `Authorization` and `Blurt/ (macOS .)` in `User-Agent` — `; dev` appended off a release build, so local installs stay out of the service-side baseline (see `UserAgent`; no model header — the service pins the STT model server-side). That `config` part is written **first**, and this route requires it — it rejects an audio-first body with a 400 (see `AssemblyAITranscriber.streamedBody` for the service's exact wording). That is where the latency comes from — the service opens its upstream STT call as soon as the config lands, so inference overlaps the recording and nothing sits between the last frame and the response. The cost is that the press-time focused-field read has to be settled _before_ the request opens, so `DictationSession.startUpload` awaits it (bounded by `contextWaitBudget`) and hands it in as a value; there is no longer a `context` channel for the body producer to wait on. The response carries the verbatim `text`, the rewritten `llm_response`, and the documented request metadata — `session_id` (quote it when reporting a problem), `audio_duration_ms`, `request_time_ms` and `sync_time_ms`, all logged by `logServerMetrics` and all decoded optionally so a dropped diagnostic can never turn a good dictation into a `malformedResponse`. `transcript(from:)` picks between the two transcripts: with enhanced transcripts on it returns the rewrite and falls back to `text` when that is null or blank (the rewrite is best-effort — `llm_error` is logged, never surfaced as a failure); with it off it returns `text`. Its initializer takes an `apiKeyProvider` closure (defaults to `APIKeyStore.current`), a `baseURL`, an `HTTPTransport` — inject a fake transport (see `Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift`) to test against canned responses — an `enhancedTranscripts` closure deciding, per request, which of the response's two transcripts to paste (nil, the default, reads `EnhancedTranscriptsStore`), a `customStyle` closure supplying the style instructions appended to the cleanup instruction (nil, the default, reads the active profile from `StyleProfileStore`), and a `rewriteMode` closure choosing the rewrite's model (nil, the default, reads `RewriteModeStore`; Smart sends `config.rewrite_mode: "smart"`, Fast omits it, and nothing is sent while enhanced transcripts are off). `warmUp()` (in `DictationWarmUp.swift`) fires a GET at the route's own `/warm` endpoint — "an unauthenticated no-op" published for exactly this — to pre-pool the connection; it hit the bare host root until 2026-09-11, which pooled the same connection but asked for a page the service never documented answering. It never throws, and any failure just means the real request pays connection setup as before. The reference's two conditions for the pooled connection to still be there are structural here: same client (the injected `transport`, which is the one `transcribe` uses) and same host (`baseURL`, so a data-zone override warms the zone it will call). The model's limits live in `SyncSTTLimits` (16 kHz sample rate, ~0.1 s–120 s audio, and the auto-release math — the sync STT model behind the dictation service) — the single source shared by the mic, the session, and the request so recorded and declared geometry can't drift. diff --git a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift index ebddcf0a..1b540399 100644 --- a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift +++ b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift @@ -51,6 +51,7 @@ public struct AssemblyAITranscriber: TranscriberProtocol { let transport: any HTTPTransport private let enhancedTranscriptsEnabled: @Sendable () -> Bool private let customStyle: @Sendable () -> String? + private let rewriteMode: @Sendable () -> RewriteMode /// Idle timeout for the transcribe round trip — `URLRequest.timeoutInterval` is /// reset each time data moves, so this bounds *stalls*, not total elapsed time. @@ -75,7 +76,8 @@ public struct AssemblyAITranscriber: TranscriberProtocol { /// the route returns gets pasted — it no longer shapes the request, which /// always asks for the rewrite; `customStyle` supplies the *active* style /// profile's instructions, appended to the cleanup instruction — one - /// profile's text, never a join of several (see `StyleProfileStore`). Both are + /// profile's text, never a join of several (see `StyleProfileStore`); + /// `rewriteMode` picks the rewrite's model (`RewriteModeStore`). All three are /// read at every `transcribe` so a settings change applies to the next /// dictation without rebuilding the transcriber. `nil` (the default) reads /// the corresponding store — spelled as optionals rather than default @@ -86,13 +88,15 @@ public struct AssemblyAITranscriber: TranscriberProtocol { baseURL: URL = URL(staticString: "https://dictation.assemblyai.com"), transport: any HTTPTransport = URLSession.shared, enhancedTranscripts: (@Sendable () -> Bool)? = nil, - customStyle: (@Sendable () -> String?)? = nil + customStyle: (@Sendable () -> String?)? = nil, + rewriteMode: (@Sendable () -> RewriteMode)? = nil ) { self.apiKeyProvider = apiKeyProvider self.baseURL = baseURL self.transport = transport self.enhancedTranscriptsEnabled = enhancedTranscripts ?? { EnhancedTranscriptsStore().isEnabled } self.customStyle = customStyle ?? { StyleProfileStore().activeInstructions } + self.rewriteMode = rewriteMode ?? { RewriteModeStore().mode } } // MARK: - Dictation request @@ -261,7 +265,10 @@ public struct AssemblyAITranscriber: TranscriberProtocol { channels: 1, sttPrompt: sttPrompt, keytermsPrompt: keytermsPrompt, - llmInstruction: cleanupInstruction() + llmInstruction: cleanupInstruction(), + // Only while the rewrite is what gets pasted: with enhanced transcripts + // off it is discarded unread, so a slower model would only add latency. + rewriteMode: enhancedTranscriptsEnabled() ? rewriteMode().wireValue : nil ) ) } diff --git a/Tests/BlurtEngineTests/KeytermsWireTests.swift b/Tests/BlurtEngineTests/KeytermsWireTests.swift index e8672e41..84567a97 100644 --- a/Tests/BlurtEngineTests/KeytermsWireTests.swift +++ b/Tests/BlurtEngineTests/KeytermsWireTests.swift @@ -142,7 +142,8 @@ extension HTTPClientTests { apiKeyProvider: { "test-key" }, transport: FakeHTTPTransport { _ in (500, Data()) }, enhancedTranscripts: { true }, - customStyle: { nil } + customStyle: { nil }, + rewriteMode: { .fast } ) .makeConfigData(sampleRate: 16_000, sttPrompt: prompt, keytermsPrompt: keyterms) return try #require(JSONSerialization.jsonObject(with: config) as? [String: Any]) diff --git a/Tests/BlurtEngineTests/PersistedSettingsTests.swift b/Tests/BlurtEngineTests/PersistedSettingsTests.swift index 0696bcb6..14d509b1 100644 --- a/Tests/BlurtEngineTests/PersistedSettingsTests.swift +++ b/Tests/BlurtEngineTests/PersistedSettingsTests.swift @@ -19,6 +19,7 @@ struct PersistedSettingsTests { // the other direction too: a stray `false` surviving a reset would leave a // "clean" install pasting verbatim transcripts. #expect(PersistedSettings.allDefaultsKeys.contains(EnhancedTranscriptsStore.defaultsKey)) + #expect(PersistedSettings.allDefaultsKeys.contains(RewriteModeStore.defaultsKey)) // Three keys for the style profiles: the list, the active pointer, and the // pre-profiles single field `StyleProfileStore` still reads. The legacy one // belongs to the sweep too — a reset that left it behind would have a @@ -42,11 +43,11 @@ struct PersistedSettingsTests { @Test("the roster carries no stale or duplicate keys") func rosterHasNoStrays() { - // Exactly the thirteen known stores' keys (OverlayOriginStore and + // Exactly the fourteen known stores' keys (OverlayOriginStore and // ReadAloudStyleStore contribute two each, StyleProfileStore three): a removed store // must leave the roster in the same change, and a key listed twice would hint // at a copy-paste slip. - #expect(PersistedSettings.allDefaultsKeys.count == 17) + #expect(PersistedSettings.allDefaultsKeys.count == 18) #expect(Set(PersistedSettings.allDefaultsKeys).count == PersistedSettings.allDefaultsKeys.count) } @@ -65,6 +66,7 @@ struct PersistedSettingsTests { KeyTermsStore.defaultsKey, DeveloperModeStore.defaultsKey, EnhancedTranscriptsStore.defaultsKey, + RewriteModeStore.defaultsKey, StyleProfileStore.defaultsKey, StyleProfileStore.activeDefaultsKey, StyleProfileStore.legacyDefaultsKey, @@ -80,7 +82,7 @@ struct PersistedSettingsTests { #expect(storeKeys == Set(DefaultsKey.allCases.map(\.key))) // No two stores sharing a slot — the Set above would have quietly absorbed a // collision, and two stores on one key means each overwrites the other. - #expect(storeKeys.count == 17) + #expect(storeKeys.count == 18) } @Test("resetAll clears every roster key and leaves unrelated ones alone") diff --git a/Tests/BlurtEngineTests/RewriteModeTests.swift b/Tests/BlurtEngineTests/RewriteModeTests.swift new file mode 100644 index 00000000..29d2cf28 --- /dev/null +++ b/Tests/BlurtEngineTests/RewriteModeTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing + +@testable import BlurtEngine + +/// The rewrite mode's decode rule, store round-trip, and what each mode puts on +/// the wire as `rewrite_mode`. +@Suite("RewriteMode") +struct RewriteModeTests { + @Test("defaults to fast when unset") + func defaultsToFast() { + // The shipped behavior: an install that never touched the picker keeps the + // model every request used before the setting existed. + #expect(RewriteModeStore(defaults: freshDefaults()).mode == .fast) + #expect(RewriteMode.fromPersisted("") == .fast) + } + + @Test("persists and reads back a chosen mode") + func roundTrips() { + let defaults = freshDefaults() + RewriteModeStore(defaults: defaults).mode = .smart + #expect(RewriteModeStore(defaults: defaults).mode == .smart) + RewriteModeStore(defaults: defaults).mode = .fast + #expect(RewriteModeStore(defaults: defaults).mode == .fast) + } + + @Test("an unknown stored value falls back to fast") + func unknownFallsBack() { + let defaults = freshDefaults() + defaults.set("Turbo", forKey: RewriteModeStore.defaultsKey) + #expect(RewriteModeStore(defaults: defaults).mode == .fast) + // The wire spelling is not the persisted one, so it must not decode either. + #expect(RewriteMode.fromPersisted("smart") == .fast) + } + + @Test("every mode names itself distinctly") + func labelsAreDistinct() { + #expect(Set(RewriteMode.allCases.map(\.label)).count == RewriteMode.allCases.count) + #expect(!RewriteMode.guidance.isEmpty) + } + + @Test("fast omits rewrite_mode, so the default request is unchanged") + func fastOmitsTheKey() throws { + let object = try config(mode: .fast) + #expect(object.keys.contains("rewrite_mode") == false) + #expect(object.keys.sorted() == ["channels", "llm_instruction", "sample_rate"]) + } + + @Test("smart sends rewrite_mode as \"smart\"") + func smartSendsTheKey() throws { + #expect(try config(mode: .smart)["rewrite_mode"] as? String == "smart") + } + + @Test("smart is not requested while enhanced transcripts are off") + func smartOmittedWhenRewriteIsDiscarded() throws { + // The rewrite is discarded unread with the setting off, so asking for the + // slower model would only cost latency. + let object = try config(mode: .smart, enhancedTranscripts: false) + #expect(object.keys.contains("rewrite_mode") == false) + } + + // MARK: - helpers + + private func config(mode: RewriteMode, enhancedTranscripts: Bool = true) throws -> [String: Any] { + let data = try makeTranscriber( + apiKey: "test-key", enhancedTranscripts: enhancedTranscripts, rewriteMode: mode + ) + .makeConfigData(sampleRate: 16_000, sttPrompt: "", keytermsPrompt: []) + return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } +} diff --git a/Tests/BlurtEngineTests/Stubs/TranscriberTestSupport.swift b/Tests/BlurtEngineTests/Stubs/TranscriberTestSupport.swift index 044fd598..25078467 100644 --- a/Tests/BlurtEngineTests/Stubs/TranscriberTestSupport.swift +++ b/Tests/BlurtEngineTests/Stubs/TranscriberTestSupport.swift @@ -14,12 +14,14 @@ func makeTranscriber( // fails rather than quietly succeeding on an empty body. transport: any HTTPTransport = FakeHTTPTransport { _ in (500, Data()) }, enhancedTranscripts: Bool = true, - customStyle: String? = nil + customStyle: String? = nil, + rewriteMode: RewriteMode = .fast ) -> AssemblyAITranscriber { AssemblyAITranscriber( apiKeyProvider: { apiKey }, transport: transport, enhancedTranscripts: { enhancedTranscripts }, - customStyle: { customStyle }) + customStyle: { customStyle }, + rewriteMode: { rewriteMode }) } /// Drains a canned frame feed the way a conforming transcriber must: consume it