diff --git a/Makefile b/Makefile index 00fda5c5..7e41ab18 100644 --- a/Makefile +++ b/Makefile @@ -72,14 +72,14 @@ endif test: $(TEST_RUNNER) @$(TEST_RUNNER) -$(TEST_RUNNER): Sources/AppContextService.swift Sources/LLMAPITransport.swift Sources/ModelConfiguration.swift Tests/AppContextServiceTests.swift +$(TEST_RUNNER): Sources/AppContextService.swift Sources/LLMAPITransport.swift Sources/ModelConfiguration.swift Sources/LLMCooldownManager.swift Sources/PostProcessingService.swift Tests/AppContextServiceTests.swift @mkdir -p "$(BUILD_DIR)" swiftc \ -parse-as-library \ -o "$(TEST_RUNNER)" \ -sdk $(shell xcrun --show-sdk-path) \ -target $(ARCH)-apple-macosx13.0 \ - Sources/AppContextService.swift Sources/LLMAPITransport.swift Sources/ModelConfiguration.swift Tests/AppContextServiceTests.swift + Sources/AppContextService.swift Sources/LLMAPITransport.swift Sources/ModelConfiguration.swift Sources/LLMCooldownManager.swift Sources/PostProcessingService.swift Tests/AppContextServiceTests.swift icon: $(ICON_ICNS) diff --git a/Sources/AppState.swift b/Sources/AppState.swift index 0626d972..d91c65c8 100644 --- a/Sources/AppState.swift +++ b/Sources/AppState.swift @@ -1013,6 +1013,16 @@ final class AppState: ObservableObject, @unchecked Sendable { return stored.trimmingCharacters(in: .whitespacesAndNewlines) } + /// English name of the selected dictation language, for the cleanup prompt to + /// name explicitly. Empty on Auto-detect, because there is nothing to name, and + /// empty for English, because the prompt's own examples are already English and + /// English dictation never drifts. + static func dictationLanguageName(for language: String) -> String { + let normalized = normalizeTranscriptionLanguage(language) + guard !normalized.isEmpty, normalized != "en" else { return "" } + return transcriptionLanguageOptions.first { $0.code == normalized }?.name ?? "" + } + private static func normalizeTranscriptionLanguage(_ language: String) -> String { let normalized = language.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard transcriptionLanguageOptions.contains(where: { $0.code == normalized }) else { @@ -2573,7 +2583,8 @@ final class AppState: ObservableObject, @unchecked Sendable { context: context, customVocabulary: customVocabulary, customSystemPrompt: customSystemPrompt, - outputLanguage: outputLanguage + outputLanguage: outputLanguage, + dictationLanguage: Self.dictationLanguageName(for: transcriptionLanguage) ) return (result.transcript, .postProcessingSucceeded, result.prompt) } catch { diff --git a/Sources/PostProcessingService.swift b/Sources/PostProcessingService.swift index 1a813cdf..543b288b 100644 --- a/Sources/PostProcessingService.swift +++ b/Sources/PostProcessingService.swift @@ -165,7 +165,8 @@ Behavior: context: AppContext, customVocabulary: String, customSystemPrompt: String = "", - outputLanguage: String = "" + outputLanguage: String = "", + dictationLanguage: String = "" ) async throws -> PostProcessingResult { let vocabularyTerms = mergedVocabularyTerms(rawVocabulary: customVocabulary) @@ -180,7 +181,8 @@ Behavior: contextSummary: context.contextSummary, customVocabulary: vocabularyTerms, customSystemPrompt: customSystemPrompt, - outputLanguage: outputLanguage + outputLanguage: outputLanguage, + dictationLanguage: dictationLanguage ) } @@ -310,7 +312,8 @@ Behavior: contextSummary: String, customVocabulary: [String], customSystemPrompt: String = "", - outputLanguage: String = "" + outputLanguage: String = "", + dictationLanguage: String = "" ) async throws -> PostProcessingResult { var primaryModel = resolvedPrimaryModel() let retryModel = resolvedRetryModel(for: primaryModel) @@ -330,7 +333,8 @@ Behavior: model: primaryModel, customVocabulary: customVocabulary, customSystemPrompt: customSystemPrompt, - outputLanguage: outputLanguage + outputLanguage: outputLanguage, + dictationLanguage: dictationLanguage ) } catch let error as PostProcessingError { // Unified fallback policy: decide whether to retry on the other model. @@ -377,7 +381,8 @@ Behavior: model: retryModel, customVocabulary: customVocabulary, customSystemPrompt: customSystemPrompt, - outputLanguage: outputLanguage + outputLanguage: outputLanguage, + dictationLanguage: dictationLanguage ) } catch PostProcessingError.suspectedInstructionExecution { return PostProcessingResult( @@ -472,7 +477,8 @@ Behavior: model: String, customVocabulary: [String], customSystemPrompt: String = "", - outputLanguage: String = "" + outputLanguage: String = "", + dictationLanguage: String = "" ) async throws -> PostProcessingResult { var request = URLRequest(url: URL(string: "\(baseURL)/chat/completions")!) request.httpMethod = "POST" @@ -495,8 +501,11 @@ Use these spellings exactly in the output when relevant: ? Self.defaultSystemPrompt : customSystemPrompt let trimmedOutputLanguage = outputLanguage.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedDictationLanguage = dictationLanguage.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedOutputLanguage.isEmpty { systemPrompt = Self.applyOutputLanguage(systemPrompt, language: trimmedOutputLanguage) + } else if !trimmedDictationLanguage.isEmpty { + systemPrompt = Self.applyDictationLanguage(systemPrompt, language: trimmedDictationLanguage) } if !vocabularyPrompt.isEmpty { systemPrompt += "\n\n" + vocabularyPrompt @@ -741,6 +750,17 @@ Model: \(model) prompt + "\n\nIMPORTANT: Translate the final cleaned text into \(language). Output ONLY in \(language), regardless of the original spoken language." } + /// Counterpart to `applyOutputLanguage` for when no output language is set: name + /// the language the user dictates in so cleanup keeps it. Every example in the + /// system prompt is English, and smaller models follow the examples over the + /// "Preserve the speaker's ... language" rule, returning English for non-English + /// dictation. Naming the language is what stops that; generic wording does not. + /// "Primarily" and the explicit carve-out keep this consistent with the prompt's + /// own "Preserve mixed-language text exactly as mixed" rule. + static func applyDictationLanguage(_ prompt: String, language: String) -> String { + prompt + "\n\nIMPORTANT: The dictation is primarily in \(language). Write the cleaned text in \(language). Preserve mixed-language words and spans in their original languages." + } + /// System prompt used for verbatim translation. Deliberately /// minimal — the whole point of this path is to translate word- /// for-word without cleanup, so we avoid every rewrite / formatting diff --git a/Tests/AppContextServiceTests.swift b/Tests/AppContextServiceTests.swift index 39eb0a6a..8828fb51 100644 --- a/Tests/AppContextServiceTests.swift +++ b/Tests/AppContextServiceTests.swift @@ -8,6 +8,8 @@ struct AppContextServiceTests { testNonStrippingModelPreservesExistingBehavior() testDeprecatedGroqModelsAreNotPredefined() testQwenCleanupDisablesReasoning() + testDictationLanguageIsNamedInPrompt() + testDictationLanguageLeavesPromptBodyIntact() print("AppContextServiceTests passed") } @@ -74,6 +76,30 @@ struct AppContextServiceTests { expect(config.includeReasoning == false, "Qwen cleanup should exclude reasoning output") } + private static func testDictationLanguageIsNamedInPrompt() { + let prompt = PostProcessingService.applyDictationLanguage("PROMPT", language: "Russian") + + expect(prompt.hasPrefix("PROMPT"), "Directive must be appended, not replace the prompt") + expect(prompt.contains("primarily in Russian"), "Language must be named explicitly") + expect(prompt.contains("Write the cleaned text in Russian"), + "Output language must be named explicitly") + expect(prompt.contains("Preserve mixed-language words and spans in their original languages"), + "Naming a language must not override mixed-language preservation") + } + + private static func testDictationLanguageLeavesPromptBodyIntact() { + // applyOutputLanguage asks for a translation, applyDictationLanguage asks for the + // opposite; neither may edit the prompt body, only append to it. + let body = PostProcessingService.defaultSystemPrompt + let translated = PostProcessingService.applyOutputLanguage(body, language: "German") + let preserved = PostProcessingService.applyDictationLanguage(body, language: "German") + + expect(translated.hasPrefix(body), "applyOutputLanguage must only append") + expect(preserved.hasPrefix(body), "applyDictationLanguage must only append") + expect(!preserved.contains("Translate the final cleaned text"), + "Preserving a language must not ask for a translation") + } + private static func expectEqual(_ actual: String?, _ expected: String, file: StaticString = #file, line: UInt = #line) { expect(actual == expected, "Expected \(expected.debugDescription), got \((actual ?? "nil").debugDescription)", file: file, line: line) }