From 4ab60ba3dcdbd46de0d542dce4f0f97c369e1585 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 09:26:51 +0000 Subject: [PATCH] Remove manual polish scenario UI; rely on auto AppContext only - Hide AppContext chip and remove manual override from keyboard - Remove polish scenario picker and custom system prompt from settings - Remove scenario section from onboarding - Translation prompt uses auto-detected AppContext instead of polish scenario - Delete ScenarioPrompt, ScenarioStyleDirective, PolishScenario, and related views Co-authored-by: Rocky --- OSGKeyboard/Views/OnboardingView.swift | 27 --- OSGKeyboard/Views/ScenarioPickerRow.swift | 59 ------ OSGKeyboard/Views/SettingsView.swift | 17 +- .../Views/SystemPromptSettingsView.swift | 60 ------ OSGKeyboardExt/KeyboardViewController.swift | 39 +--- .../Services/AppGroupPersistor.swift | 13 +- OSGKeyboardExt/Views/AppContextChip.swift | 76 -------- OSGKeyboardExt/Views/KeyboardRootView.swift | 10 +- OSGKeyboardExt/Views/ScenarioChip.swift | 59 ------ OSGKeyboardShared/Models/PolishScenario.swift | 55 ------ OSGKeyboardShared/Models/ProviderConfig.swift | 56 ------ .../Services/AppGroupStore.swift | 64 ------- .../Services/KeyboardState.swift | 17 -- .../Services/PolishingService.swift | 13 +- .../Services/ScenarioPrompt.swift | 59 ------ .../Services/ScenarioStyleDirective.swift | 171 ------------------ .../Services/TranslationPrompt.swift | 54 ++---- OSGKeyboardTests/LLMClientTests.swift | 53 +----- 18 files changed, 31 insertions(+), 871 deletions(-) delete mode 100644 OSGKeyboard/Views/ScenarioPickerRow.swift delete mode 100644 OSGKeyboard/Views/SystemPromptSettingsView.swift delete mode 100644 OSGKeyboardExt/Views/AppContextChip.swift delete mode 100644 OSGKeyboardExt/Views/ScenarioChip.swift delete mode 100644 OSGKeyboardShared/Models/PolishScenario.swift delete mode 100644 OSGKeyboardShared/Services/ScenarioPrompt.swift delete mode 100644 OSGKeyboardShared/Services/ScenarioStyleDirective.swift diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index c7a056a..8aa3e12 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -757,35 +757,8 @@ private struct APISetupPage: View { .padding(.horizontal, Spacing.lg) } - if config.isPolishScenarioRowVisible { - postProcessingSection - .padding(.horizontal, Spacing.lg) - } } .padding(.bottom, Spacing.xxxl) } } - - /// Polish scenario + optional translation target for cloud onboarding. - private var postProcessingSection: some View { - VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { - Text("settings.polishScenario.section") - .font(TypeStyle.caption2) - .foregroundStyle(palette.textSecondary) - .textCase(.uppercase) - .frame(maxWidth: .infinity, alignment: .leading) - VStack(spacing: 0) { - ScenarioPickerRow(config: config, isVisible: true) - if config.isTranslationRowVisible { - Divider().background(palette.divider) - TranslationPickerRow(config: config, isVisible: true) - } - } - .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) - } - } } diff --git a/OSGKeyboard/Views/ScenarioPickerRow.swift b/OSGKeyboard/Views/ScenarioPickerRow.swift deleted file mode 100644 index 1ae3ad3..0000000 --- a/OSGKeyboard/Views/ScenarioPickerRow.swift +++ /dev/null @@ -1,59 +0,0 @@ -// ScenarioPickerRow.swift -// OSGKeyboard · Main App -// -// Single-row polish scenario picker. Maps menu choices to -// `ProviderConfig.polishScenarioId`. Custom scenario uses the existing -// system prompt editor (linked from Settings when selected). - -import SwiftUI -import OSGKeyboardShared - -struct ScenarioPickerRow: View { - @Environment(\.themePalette) private var palette: ThemePalette - @ObservedObject var config: ProviderConfig - - var isVisible: Bool = true - - var body: some View { - if isVisible { - HStack { - Text("settings.polishScenario.title") - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - Spacer() - Menu { - ForEach(PolishScenarioCatalog.all) { scenario in - Button { - config.polishScenarioId = scenario.id - } label: { - if config.polishScenarioId == scenario.id { - Label(displayLabel(for: scenario), systemImage: "checkmark") - } else { - Text(displayLabel(for: scenario)) - } - } - } - } label: { - HStack(spacing: 6) { - Text(currentLabel) - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(palette.textTertiary) - } - } - } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) - } - } - - private var currentLabel: String { - displayLabel(for: PolishScenarioCatalog.resolve(config.polishScenarioId)) - } - - private func displayLabel(for scenario: PolishScenario) -> String { - PolishScenarioCatalog.displayName(for: scenario.id, language: config.uiLanguage) - } -} diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index 2c98f76..3232ba7 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -135,22 +135,9 @@ struct SettingsView: View { set: { config.localeId = $0 } ) ) - if config.isPolishScenarioRowVisible { + if config.isTranslationRowVisible { Divider().background(palette.divider) - ScenarioPickerRow(config: config, isVisible: true) - if config.engineMode == "cloud", config.isTranslationRowVisible { - Divider().background(palette.divider) - TranslationPickerRow(config: config, isVisible: true) - } - if config.isCustomPolishScenario { - Divider().background(palette.divider) - NavigationLink { - SystemPromptSettingsView(config: config) - } label: { - footerNavigationRow(title: "settings.systemPrompt.edit") - } - .buttonStyle(.plain) - } + TranslationPickerRow(config: config, isVisible: true) } } .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) diff --git a/OSGKeyboard/Views/SystemPromptSettingsView.swift b/OSGKeyboard/Views/SystemPromptSettingsView.swift deleted file mode 100644 index 2a7cbf5..0000000 --- a/OSGKeyboard/Views/SystemPromptSettingsView.swift +++ /dev/null @@ -1,60 +0,0 @@ -// SystemPromptSettingsView.swift -// OSGKeyboard · Main App -// -// Cloud-engine system prompt editor. Reached from Settings when the -// user picks the cloud recognition path. - -import SwiftUI -import OSGKeyboardShared - -struct SystemPromptSettingsView: View { - @Environment(\.themePalette) private var palette: ThemePalette - - @ObservedObject var config: ProviderConfig - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: Spacing.sm) { - Text("settings.systemPrompt.hint") - .font(TypeStyle.caption2) - .foregroundStyle(palette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - - if config.isCustomPolishScenario { - Text("settings.polishScenario.customHint") - .font(TypeStyle.caption2) - .foregroundStyle(palette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - } - - TextEditor(text: $config.systemPrompt) - .font(TypeStyle.mono) - .scrollContentBackground(.hidden) - .frame(minHeight: 320) - .padding(Spacing.sm) - .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) - } - .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.md) - } - .background(palette.background.ignoresSafeArea()) - .navigationTitle("settings.systemPrompt.title") - .navigationBarTitleDisplayMode(.inline) - .toolbar(.visible, for: .navigationBar) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("common.reset") { - config.systemPrompt = config.defaultSystemPrompt - } - .font(TypeStyle.body) - .foregroundStyle(palette.accent) - } - } - .toolbarBackground(palette.background, for: .navigationBar) - .toolbarBackground(.visible, for: .navigationBar) - } -} diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 14b606e..5c4dc3e 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -78,7 +78,6 @@ public final class KeyboardViewController: UIInputViewController { /// Grace period after a chip-side translation write during which the /// 1 Hz App Group poll must not overwrite `translationTargetLocaleId`. private var translationConfigProtectedUntil: Date? - private var polishScenarioConfigProtectedUntil: Date? private var isAwaitingFlowResult = false private var lastFlowAutoStartAttempt: TimeInterval = 0 private static let flowAutoStartCooldown: TimeInterval = 20 @@ -132,7 +131,6 @@ public final class KeyboardViewController: UIInputViewController { // from Settings.app or the host app, and the App Group is the // only thing both processes see consistently. syncOnboardingStateFromAppGroup() - syncAppContextFromAppGroup() // Auto-advance past step 3 ("Enable Keyboard") if the user has // enabled the keyboard in Settings.app while we were away. // This is the "automatic return from jump" feature: no manual @@ -185,11 +183,6 @@ public final class KeyboardViewController: UIInputViewController { // v0.2.1 follow-up: removed `setTranslationEnabled` — the chip // / picker now writes the locale id directly; `enabled` is derived. state.setTranslationTargetLocaleId = { [weak self] id in self?.persistTranslationTargetLocaleId(id) } - state.setPolishScenarioId = { [weak self] id in self?.persistPolishScenarioId(id) } - // v0.3.0: per-app context override. Writes to the App Group - // (so `PolishingService` reads it on the next take) and mirrors - // into local state. - state.setAppContext = { [weak self] c in self?.persistAppContext(c) } // v0.3.0: in-keyboard onboarding actions. Persist via the App // Group so the host app's `ProviderConfig` stays in sync (and // the next launch of the host app opens at the same page). @@ -220,14 +213,6 @@ public final class KeyboardViewController: UIInputViewController { state.onboardingPage = 4 } - // MARK: - App context override (v0.3.0) - - private func persistAppContext(_ context: AppContext) { - let store = AppGroupStore() - store.setDetectedAppContext(context, at: Date()) - state.appContext = context - } - // MARK: - Permission requests from the extension (v0.3.0) /// The keyboard extension cannot present `AVAudioSession` / @@ -333,13 +318,6 @@ public final class KeyboardViewController: UIInputViewController { state.onboardingPage = store.onboardingPage } - /// Mirror the detected app context (or the user's last manual - /// override) so the AppContextChip stays in sync after a jump. - private func syncAppContextFromAppGroup() { - let store = AppGroupStore() - state.appContext = store.detectedAppContext?.context ?? .unknown - } - /// If the user has finished the "Enable Keyboard" step (i.e. the /// keyboard is now in the system list with full access), and the /// overlay is currently sitting on that step, advance to the API @@ -389,16 +367,14 @@ public final class KeyboardViewController: UIInputViewController { private func refreshConfigFromAppGroup() { persistor.refreshRuntimeFlags( into: state, - protectTranslationUntil: translationConfigProtectedUntil, - protectPolishScenarioUntil: polishScenarioConfigProtectedUntil + protectTranslationUntil: translationConfigProtectedUntil ) } private func refreshFlowSessionState() { persistor.refreshRuntimeFlags( into: state, - protectTranslationUntil: translationConfigProtectedUntil, - protectPolishScenarioUntil: polishScenarioConfigProtectedUntil + protectTranslationUntil: translationConfigProtectedUntil ) consumePendingFlowDeliveryIfNeeded() @@ -555,10 +531,6 @@ public final class KeyboardViewController: UIInputViewController { storedCache: store.detectedAppContext ) store.setDetectedAppContext(context) - // v0.3.0: mirror into local state so the chip updates without - // waiting for the next `viewWillAppear`. The user gets - // immediate visual feedback that the polish tone just changed. - state.appContext = context } private func startUtteranceCountdown() { @@ -871,13 +843,6 @@ public final class KeyboardViewController: UIInputViewController { persistor.persist(translationTargetLocaleId: resolved) } - private func persistPolishScenarioId(_ id: String) { - let resolved = PolishScenarioCatalog.resolve(id).id - state.polishScenarioId = resolved - polishScenarioConfigProtectedUntil = Date().addingTimeInterval(2.5) - persistor.persist(polishScenarioId: resolved) - } - // MARK: - Open host app private func openHostApp(path: String = "settings") { diff --git a/OSGKeyboardExt/Services/AppGroupPersistor.swift b/OSGKeyboardExt/Services/AppGroupPersistor.swift index 5021bcf..1b0f943 100644 --- a/OSGKeyboardExt/Services/AppGroupPersistor.swift +++ b/OSGKeyboardExt/Services/AppGroupPersistor.swift @@ -40,7 +40,6 @@ public struct AppGroupPersistor { // startup; `refreshRuntimeFlags` keeps the chip in sync while // the keyboard stays open. state.translationTargetLocaleId = store.translationTargetLocaleId - state.polishScenarioId = store.polishScenarioId state.handednessPreference = store.handednessPreference state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled // v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that @@ -84,8 +83,7 @@ public struct AppGroupPersistor { /// a chip selection the user just wrote to the App Group. public func refreshRuntimeFlags( into state: KeyboardViewController.State, - protectTranslationUntil: Date? = nil, - protectPolishScenarioUntil: Date? = nil + protectTranslationUntil: Date? = nil ) { guard AppGroup.isAvailable else { return } let store = AppGroupStore() @@ -96,10 +94,6 @@ public struct AppGroupPersistor { if !shouldProtectTranslation { state.translationTargetLocaleId = store.translationTargetLocaleId } - let shouldProtectScenario = protectPolishScenarioUntil.map { Date() < $0 } ?? false - if !shouldProtectScenario { - state.polishScenarioId = store.polishScenarioId - } state.handednessPreference = store.handednessPreference // v0.2.0: iOS `SpeechAnalyzer` is always ready. Keep these // toggles here so the keyboard UI doesn't flicker if the host @@ -145,9 +139,4 @@ public struct AppGroupPersistor { guard AppGroup.isAvailable else { return } AppGroupStore().setTranslationTargetLocaleId(translationTargetLocaleId) } - - public func persist(polishScenarioId: String) { - guard AppGroup.isAvailable else { return } - AppGroupStore().setPolishScenarioId(polishScenarioId) - } } \ No newline at end of file diff --git a/OSGKeyboardExt/Views/AppContextChip.swift b/OSGKeyboardExt/Views/AppContextChip.swift deleted file mode 100644 index 76e5bed..0000000 --- a/OSGKeyboardExt/Views/AppContextChip.swift +++ /dev/null @@ -1,76 +0,0 @@ -// AppContextChip.swift -// OSGKeyboard · Keyboard Extension -// -// Surfaces the v0.3.0 per-app polish context on the keyboard top -// bar. The LLM prompt already adapts to the detected context (see -// `PolishingService.buildPrompt(for:context:)`), but without a UI -// cue the user has no way to know "I'm currently in code mode" — -// and no way to override the heuristic when it guesses wrong. -// -// Tap the chip → cycle through the five `AppContext` cases. The new -// value is written to `AppGroupStore.setDetectedAppContext(_:at:)`, -// so the next `PolishingService` call picks it up immediately. - -import SwiftUI -import OSGKeyboardShared - -struct AppContextChip: View { - @Environment(\.themePalette) private var palette: ThemePalette - - @ObservedObject var state: KeyboardViewController.State - - var body: some View { - Menu { - ForEach(AppContext.allCases, id: \.self) { context in - Button { - state.setAppContext(context) - } label: { - if context == state.appContext { - Label(menuLabel(for: context), systemImage: "checkmark") - } else { - Text(menuLabel(for: context)) - } - } - } - } label: { - label - } - .menuStyle(.button) - .accessibilityLabel(ExtL10n.text("keyboard.appContext.a11y")) - .accessibilityHint(ExtL10n.text("keyboard.appContext.a11yHint")) - } - - private var label: some View { - HStack(spacing: 4) { - Image(systemName: iconName(for: state.appContext)) - Text(chipText) - Image(systemName: "chevron.down") - .font(.system(size: 8, weight: .bold)) - } - .font(TypeStyle.caption2) - .foregroundStyle(palette.textPrimary) - .padding(.horizontal, Spacing.xs + 2) - .padding(.vertical, 6) - .frame(minHeight: 28) - .background(palette.surfaceElevated, in: Capsule()) - .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) - } - - private var chipText: String { - ExtL10n.string("keyboard.appContext.chip.\(state.appContext.rawValue)") - } - - private func menuLabel(for context: AppContext) -> String { - ExtL10n.string("keyboard.appContext.menu.\(context.rawValue)") - } - - private func iconName(for context: AppContext) -> String { - switch context { - case .code: return "chevron.left.forwardslash.chevron.right" - case .email: return "envelope" - case .chat: return "bubble.left" - case .document: return "doc.text" - case .unknown: return "questionmark.circle" - } - } -} \ No newline at end of file diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index 72dad05..034f126 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -140,15 +140,7 @@ public struct KeyboardRootView: View { } else { CloudEngineChip() } - // Polish scenario and ASR locale are configured in the main-app - // Settings tab only — keep the keyboard top bar uncluttered. - if state.hasCompletedOnboarding { - AppContextChip(state: state) - } - // v0.3: always show the translation chip when the active - // engine can run the cloud LLM step — off-by-default keeps - // the menu reachable so the user can pick a target language - // without opening Settings. + // App context is auto-detected on each mic press — no UI. if state.isTranslationChipVisible { TranslationChip(state: state) } diff --git a/OSGKeyboardExt/Views/ScenarioChip.swift b/OSGKeyboardExt/Views/ScenarioChip.swift deleted file mode 100644 index 08864b0..0000000 --- a/OSGKeyboardExt/Views/ScenarioChip.swift +++ /dev/null @@ -1,59 +0,0 @@ -// ScenarioChip.swift -// OSGKeyboard · Keyboard Extension -// -// Compact chip on the keyboard top bar for quick polish scenario -// switching. Same Menu pattern as `TranslationChip`. - -import SwiftUI -import OSGKeyboardShared - -struct ScenarioChip: View { - @Environment(\.themePalette) private var palette: ThemePalette - - @ObservedObject var state: KeyboardViewController.State - - var body: some View { - Menu { - ForEach(PolishScenarioCatalog.all) { scenario in - Button { - state.setPolishScenarioId(scenario.id) - } label: { - if scenario.id == state.polishScenarioId { - Label(displayLabel(for: scenario), systemImage: "checkmark") - } else { - Text(displayLabel(for: scenario)) - } - } - } - } label: { - label - } - .menuStyle(.button) - .accessibilityLabel(ExtL10n.text("keyboard.scenario.a11y")) - .accessibilityHint(ExtL10n.text("keyboard.scenario.a11yHint")) - } - - private var label: some View { - HStack(spacing: 4) { - Image(systemName: "text.bubble") - Text(chipText) - Image(systemName: "chevron.down") - .font(.system(size: 8, weight: .bold)) - } - .font(TypeStyle.caption2) - .foregroundStyle(palette.textPrimary) - .padding(.horizontal, Spacing.xs + 2) - .padding(.vertical, 6) - .frame(minHeight: 28) - .background(palette.surfaceElevated, in: Capsule()) - .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) - } - - private var chipText: String { - PolishScenarioCatalog.chipLabel(for: state.polishScenarioId) - } - - private func displayLabel(for scenario: PolishScenario) -> String { - PolishScenarioCatalog.displayName(for: scenario.id) - } -} diff --git a/OSGKeyboardShared/Models/PolishScenario.swift b/OSGKeyboardShared/Models/PolishScenario.swift deleted file mode 100644 index 44042bf..0000000 --- a/OSGKeyboardShared/Models/PolishScenario.swift +++ /dev/null @@ -1,55 +0,0 @@ -// PolishScenario.swift -// OSGKeyboard · Shared -// -// Curated polish scenarios the user picks instead of editing a raw -// system prompt. Display names live in Shared.strings; prompt bodies -// are built by `ScenarioPrompt.make`. - -import Foundation - -public struct PolishScenario: Identifiable, Hashable, Sendable { - public let id: String - /// Shared.strings key for the picker label. - public let labelKey: String - /// Shared.strings key for the compact keyboard chip label. - public let chipLabelKey: String - - public init(id: String, labelKey: String, chipLabelKey: String) { - self.id = id - self.labelKey = labelKey - self.chipLabelKey = chipLabelKey - } -} - -public enum PolishScenarioCatalog { - public static let defaultId = "daily_chat" - public static let customId = "custom" - - /// Order matters — picker / chip render top-to-bottom. - public static let all: [PolishScenario] = [ - PolishScenario(id: "daily_chat", labelKey: "polishScenario.daily_chat", chipLabelKey: "polishScenario.chip.daily_chat"), - PolishScenario(id: "social_lifestyle", labelKey: "polishScenario.social_lifestyle", chipLabelKey: "polishScenario.chip.social_lifestyle"), - PolishScenario(id: "social_short", labelKey: "polishScenario.social_short", chipLabelKey: "polishScenario.chip.social_short"), - PolishScenario(id: "goofy", labelKey: "polishScenario.goofy", chipLabelKey: "polishScenario.chip.goofy"), - PolishScenario(id: "work", labelKey: "polishScenario.work", chipLabelKey: "polishScenario.chip.work"), - PolishScenario(id: "document", labelKey: "polishScenario.document", chipLabelKey: "polishScenario.chip.document"), - PolishScenario(id: "todo", labelKey: "polishScenario.todo", chipLabelKey: "polishScenario.chip.todo"), - PolishScenario(id: customId, labelKey: "polishScenario.custom", chipLabelKey: "polishScenario.chip.custom"), - ] - - public static func isCustom(_ id: String) -> Bool { - id == customId - } - - public static func resolve(_ id: String) -> PolishScenario { - all.first { $0.id == id } ?? all.first { $0.id == defaultId } ?? all[0] - } - - public static func displayName(for id: String, language: AppUILanguage? = nil) -> String { - SharedL10n.string(resolve(id).labelKey, language: language) - } - - public static func chipLabel(for id: String, language: AppUILanguage? = nil) -> String { - SharedL10n.string(resolve(id).chipLabelKey, language: language) - } -} diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index 83ef38d..8b56894 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -23,7 +23,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { // delete the entry, but no other code path touches this key. static let apiKeyLegacy = "config.apiKey" static let model = "config.model" - static let systemPrompt = "config.systemPrompt" static let modeId = "config.modeId" static let localeId = "config.localeId" static let engineMode = "config.engineMode" @@ -51,7 +50,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { // who upgraded from a build that wrote it don't see a flash of // "on" state during init, but new writes never touch the key. static let translationTargetLocaleId = "config.translationTargetLocaleId" - static let polishScenarioId = "config.polishScenarioId" static let handednessPreference = "config.handednessPreference" // v0.3.0: how aggressively the LLM should rewrite transcripts. static let polishIntensity = "config.polishIntensity" @@ -80,9 +78,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { @Published public var model: String { didSet { defaults.set(model, forKey: Key.model) } } - @Published public var systemPrompt: String { - didSet { defaults.set(systemPrompt, forKey: Key.systemPrompt) } - } @Published public var modeId: String { didSet { defaults.set(modeId, forKey: Key.modeId) } } @@ -158,14 +153,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { AppGroupConfigDarwin.postConfigChanged() } } - /// Selected polish scenario preset (e.g. `daily_chat`, `work`) or - /// `custom` when the user edits the raw system prompt. - @Published public var polishScenarioId: String { - didSet { - defaults.set(polishScenarioId, forKey: Key.polishScenarioId) - AppGroupConfigDarwin.postConfigChanged() - } - } /// Which hand the user holds the phone with — mirrors to the keyboard /// extension so delete / return can swap on the bottom row. @Published public var handednessPreference: HandednessPreference { @@ -192,17 +179,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { return isLocalEngine && localModeCloudPolishEnabled } - /// Polish scenario picker visibility — same gate as translation: - /// only when the pipeline can run a cloud LLM step. - public var isPolishScenarioRowVisible: Bool { - if engineMode == "cloud" { return true } - return isLocalEngine && localModeCloudPolishEnabled - } - - public var isCustomPolishScenario: Bool { - PolishScenarioCatalog.isCustom(polishScenarioId) - } - /// v0.3.0: how aggressively the LLM should rewrite the ASR /// transcript. Default is `medium` (Typeless-equivalent). The /// `off` value never calls the LLM — equivalent to "transcribe @@ -249,12 +225,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { isLocalEngine ? "deepseek" : providerId } - /// The system prompt the user *sees* in the editor — fall back to the - /// provider-aware default from `AppGroupStore` when nothing is set. - public var defaultSystemPrompt: String { - AppGroupStore.defaultSystemPrompt(for: providerId) - } - private let defaults: UserDefaults public init(defaults: UserDefaults? = nil) { @@ -272,8 +242,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { self.apiKey = ProviderConfig.resolveAPIKey(defaults: resolvedDefaults) self.model = resolvedDefaults.string(forKey: Key.model) ?? preset.defaultModel - self.systemPrompt = resolvedDefaults.string(forKey: Key.systemPrompt) - ?? AppGroupStore.defaultSystemPrompt(for: pid) self.modeId = resolvedDefaults.string(forKey: Key.modeId) ?? "polish" self.localeId = resolvedDefaults.string(forKey: Key.localeId) ?? "auto" self.engineMode = resolvedDefaults.string(forKey: Key.engineMode) ?? "cloud" @@ -307,17 +275,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { // conservative default that matches the picker / chip UX). self.translationTargetLocaleId = resolvedDefaults.string(forKey: Key.translationTargetLocaleId) ?? TranslationLanguageCatalog.offLocaleId - if let storedScenario = resolvedDefaults.string(forKey: Key.polishScenarioId) { - self.polishScenarioId = PolishScenarioCatalog.resolve(storedScenario).id - } else { - let savedPrompt = resolvedDefaults.string(forKey: Key.systemPrompt) - ?? AppGroupStore.defaultSystemPrompt(for: pid) - if savedPrompt != AppGroupStore.defaultSystemPrompt(for: pid) { - self.polishScenarioId = PolishScenarioCatalog.customId - } else { - self.polishScenarioId = PolishScenarioCatalog.defaultId - } - } self.handednessPreference = HandednessPreference.fromStored( resolvedDefaults.string(forKey: Key.handednessPreference) ) @@ -353,10 +310,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { } public func apply(preset: LLMProvider) { - // Capture the *previous* provider id BEFORE we mutate, so the - // system-prompt reset check below can compare against the actual - // prior default. - let oldId = providerId providerId = preset.id if !preset.defaultBaseURL.isEmpty { baseURL = preset.defaultBaseURL @@ -364,13 +317,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { if !preset.defaultModel.isEmpty { model = preset.defaultModel } - // When switching providers, reset the system prompt to the new - // provider's default — otherwise the user is left editing a - // Chinese prompt on a US-English model. - if systemPrompt.isEmpty - || systemPrompt == AppGroupStore.defaultSystemPrompt(for: oldId) { - systemPrompt = AppGroupStore.defaultSystemPrompt(for: preset.id) - } } public func reset() { @@ -379,8 +325,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { baseURL = preset.defaultBaseURL apiKey = "" model = preset.defaultModel - systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai") - polishScenarioId = PolishScenarioCatalog.defaultId handednessPreference = .left hasAcknowledgedCloudSharing = false } diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index b99b5f4..c2662cc 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -31,7 +31,6 @@ public struct AppGroupStore: @unchecked Sendable { static let providerId = "config.providerId" static let baseURL = "config.baseURL" static let model = "config.model" - static let systemPrompt = "config.systemPrompt" static let modeId = "config.modeId" static let localeId = "config.localeId" static let engineMode = "config.engineMode" @@ -45,7 +44,6 @@ public struct AppGroupStore: @unchecked Sendable { // the `translationEnabled` Bool accessor below is kept as a // computed shim for source compatibility. static let translationTargetLocaleId = "config.translationTargetLocaleId" - static let polishScenarioId = "config.polishScenarioId" static let handednessPreference = "config.handednessPreference" // v0.3.0: polish intensity (off / light / medium / heavy). static let polishIntensity = "config.polishIntensity" @@ -79,10 +77,6 @@ public struct AppGroupStore: @unchecked Sendable { defaults.string(forKey: Key.model) ?? LLMProvider.provider(id: providerId).defaultModel } - public var systemPrompt: String { - defaults.string(forKey: Key.systemPrompt) ?? Self.defaultSystemPrompt(for: providerId) - } - public var modeId: String { defaults.string(forKey: Key.modeId) ?? "polish" } @@ -138,11 +132,6 @@ public struct AppGroupStore: @unchecked Sendable { ?? TranslationLanguageCatalog.offLocaleId } - public var polishScenarioId: String { - let stored = defaults.string(forKey: Key.polishScenarioId) - return PolishScenarioCatalog.resolve(stored ?? PolishScenarioCatalog.defaultId).id - } - /// Bottom-row key order on the keyboard extension. public var handednessPreference: HandednessPreference { HandednessPreference.fromStored(defaults.string(forKey: Key.handednessPreference)) @@ -193,12 +182,6 @@ public struct AppGroupStore: @unchecked Sendable { AppGroupConfigDarwin.postConfigChanged() } - public func setPolishScenarioId(_ id: String) { - let resolved = PolishScenarioCatalog.resolve(id).id - defaults.set(resolved, forKey: Key.polishScenarioId) - AppGroupConfigDarwin.postConfigChanged() - } - public func setHandednessPreference(_ preference: HandednessPreference) { defaults.set(preference.rawValue, forKey: Key.handednessPreference) AppGroupConfigDarwin.postConfigChanged() @@ -229,25 +212,6 @@ public struct AppGroupStore: @unchecked Sendable { return localModeCloudPolishEnabled } - /// Whether the keyboard top-bar scenario chip should render. - public var isPolishScenarioChipVisible: Bool { - if engineMode == "cloud" { return true } - return localModeCloudPolishEnabled - } - - /// System prompt for the polish pipeline honoring scenario selection. - public func resolvedPolishSystemPrompt(providerId: String? = nil) -> String { - if PolishScenarioCatalog.isCustom(polishScenarioId) { - return systemPrompt - } - let pid = providerId ?? self.providerId - return ScenarioPrompt.make( - scenarioId: polishScenarioId, - providerId: pid, - uiLanguage: uiLanguage - ) - } - /// Polish vs translate-and-polish for the active pipeline. public var polishModeForPipeline: PolishingService.PolishMode { isTranslationEffective @@ -365,32 +329,4 @@ public struct AppGroupStore: @unchecked Sendable { model: model ) } - - // MARK: - Defaults - - /// Per-provider default system prompt. We bias the prompt by the - /// provider's *primary* language so Chinese LLMs naturally return - /// Chinese for Chinese input, and English LLMs stay terse. - public static func defaultSystemPrompt(for providerId: String) -> String { - switch providerId { - case "zhipu", "moonshot", "qwen", "deepseek": - return """ - 你是一位语音输入润色助手。请将用户的口述改写为干净的中文(或英文)书面文字: - 1) 保留原意,不编造事实;保持输入语言。 - 2) 添加恰当的标点、大小写、段落。 - 3) 当用户枚举"第一…第二…第三…"时,使用 markdown 列表。 - 4) 简洁,不超出原长 1.5 倍;可去掉无意义的口头禅(嗯、啊、那个)。 - 5) 只输出润色后的正文,不要解释、不要加引号。 - """ - default: - return """ - You are a voice-input polishing assistant. The user has spoken informally; rewrite their dictation as clean written text: - 1) Preserve the user's original intent and meaning; do not invent facts. - 2) Add proper punctuation, capitalization, and paragraph breaks. - 3) When the user enumerates items ("first ... second ... third"), output a markdown list. - 4) Keep the output concise — do not exceed 1.5x the spoken length. Drop filler words (um, uh, like). - 5) Output in the same language as the input. No quotes, no explanation, no preamble. - """ - } - } } diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index c93067f..167db61 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -98,8 +98,6 @@ public final class KeyboardState: ObservableObject { /// Defaults to `offLocaleId` so the keyboard boots in the "off" /// state on first install. @Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId - /// Selected polish scenario mirrored from App Group. - @Published public var polishScenarioId: String = PolishScenarioCatalog.defaultId /// v0.2.0: mirrored from App Group — local engine runs the cloud /// LLM step only when this is `true`. @Published public var localModeCloudPolishEnabled: Bool = false @@ -119,12 +117,6 @@ public final class KeyboardState: ObservableObject { return true } - /// Whether the keyboard top-bar polish scenario chip should render. - public var isPolishScenarioChipVisible: Bool { - if isLocalEngine { return localModeCloudPolishEnabled } - return true - } - /// Convenience shorthand used by the pipeline and views. public var isLocalEngine: Bool { engineMode == "local" } @@ -139,10 +131,6 @@ public final class KeyboardState: ObservableObject { /// to render the right page; main-app `ProviderConfig` is the /// source of truth and the keyboard mirrors it. @Published public var onboardingPage: Int = 0 - /// Per-call context the polish prompt should adapt to. Mirrored - /// from `AppGroupStore.detectedAppContext` on every `viewWillAppear` - /// so the chip stays consistent across the open↔jump cycle. - @Published public var appContext: AppContext = .unknown /// `true` when the user tapped something (mic, settings) right /// before a forced jump to the host app. The keyboard reads this /// on return and auto-resumes the action so the user does not have @@ -172,11 +160,6 @@ public final class KeyboardState: ObservableObject { /// is derived from the locale id, so there's no separate toggle to /// persist. Wired in `KeyboardViewController.installStateActions`. public var setTranslationTargetLocaleId: (String) -> Void = { _ in } - public var setPolishScenarioId: (String) -> Void = { _ in } - /// v0.3.0: manually override the auto-detected app context (e.g. - /// when the heuristic guessed wrong). Writes to the App Group so - /// `PolishingService` picks it up on the next take. - public var setAppContext: (AppContext) -> Void = { _ in } public var advanceOnboarding: () -> Void = {} public var completeOnboarding: () -> Void = {} public var requestMicPermission: () -> Void = {} diff --git a/OSGKeyboardShared/Services/PolishingService.swift b/OSGKeyboardShared/Services/PolishingService.swift index 77f3691..69302f0 100644 --- a/OSGKeyboardShared/Services/PolishingService.swift +++ b/OSGKeyboardShared/Services/PolishingService.swift @@ -200,8 +200,7 @@ public actor PolishingService { prompt = TranslationPrompt.make( target: target, providerId: effectiveProviderId, - scenarioId: store.polishScenarioId, - uiLanguage: store.uiLanguage + appContext: context.appContext ) } } @@ -231,9 +230,8 @@ public actor PolishingService { /// 7. The transcript to process /// 8. Output contract /// - /// The Chinese / English split mirrors the existing per-provider - /// default system prompt in `AppGroupStore.defaultSystemPrompt(for:)` - /// so the polish step stays in the user's chosen output language. + /// The Chinese / English split mirrors `shouldUseChineseGuidance` so + /// the polish step stays in the provider's strongest language. internal func buildPrompt(for text: String, context: PolishContext) -> String { let dictionary = store.personalDictionary let dictionaryBlock = dictionary.promptFragment() @@ -314,10 +312,7 @@ public actor PolishingService { } } - /// Mirror `AppGroupStore.defaultSystemPrompt(for:)` — Chinese LLM - /// providers get a Chinese prompt, English ones get English. - /// Keeping these aligned avoids the "model answers in the wrong - /// language" failure mode that LLM benchmarks consistently flag. + /// Chinese-native LLM providers get a Chinese prompt, English ones get English. private func shouldUseChineseGuidance(providerId: String) -> Bool { switch providerId { case "zhipu", "moonshot", "qwen", "deepseek": diff --git a/OSGKeyboardShared/Services/ScenarioPrompt.swift b/OSGKeyboardShared/Services/ScenarioPrompt.swift deleted file mode 100644 index 7ea9b80..0000000 --- a/OSGKeyboardShared/Services/ScenarioPrompt.swift +++ /dev/null @@ -1,59 +0,0 @@ -// ScenarioPrompt.swift -// OSGKeyboard · Shared -// -// Builds the system prompt for a selected polish scenario. Output -// format rules come from `ScenarioStyleDirective` (shared with -// `TranslationPrompt`). - -import Foundation - -public enum ScenarioPrompt { - - public static func make( - scenarioId: String, - providerId: String, - uiLanguage: AppUILanguage? = nil - ) -> String { - let isChineseNative = Self.isChineseNativeProvider(providerId) - let directive = ScenarioStyleDirective.make( - scenarioId: scenarioId, - providerId: providerId, - uiLanguage: uiLanguage - ) - return isChineseNative - ? """ - \(chineseBase) - \(directive) - """ - : """ - \(englishBase) - \(directive) - """ - } - - private static func isChineseNativeProvider(_ providerId: String) -> Bool { - ["zhipu", "moonshot", "qwen", "deepseek"].contains(providerId) - } - - private static let chineseBase = """ - 你是一位语音输入润色助手。用户用 ASR 转写了一段可能含噪声的口述。 - 硬性要求: - 1) 保留原意,不编造事实;保持输入语言。 - 2) 修复 ASR 噪声(同音错字、漏字、断句错乱)。 - 3) 去掉无意义的口头禅(嗯、啊、那个)。 - 4) 简洁;若下方场景未要求列表/分段,不超出原长 1.5 倍。 - 5) 若场景格式要求列表或分段,允许按格式组织;总长度不超过原长 2 倍。 - 6) 只输出润色后的正文,不要解释、不要加引号。 - """ - - private static let englishBase = """ - You are a voice-input polishing assistant. The user spoke informally and the transcript may contain ASR noise. - Hard rules: - 1) Preserve meaning; do not invent facts; keep the input language. - 2) Fix ASR noise (homophone errors, missing characters, broken segmentation). - 3) Drop filler words (um, uh, like). - 4) Stay concise; if the scenario below does not require lists/sections, do not exceed 1.5x the spoken length. - 5) If the scenario requires bullets or paragraph breaks, use that layout; total length may be up to 2x when listing. - 6) Output ONLY the polished text. No quotes, no explanation, no preamble. - """ -} diff --git a/OSGKeyboardShared/Services/ScenarioStyleDirective.swift b/OSGKeyboardShared/Services/ScenarioStyleDirective.swift deleted file mode 100644 index a53fc12..0000000 --- a/OSGKeyboardShared/Services/ScenarioStyleDirective.swift +++ /dev/null @@ -1,171 +0,0 @@ -// ScenarioStyleDirective.swift -// OSGKeyboard · Shared -// -// Shared output-format rules for each polish scenario. Injected into -// both `ScenarioPrompt` (polish-only) and `TranslationPrompt` -// (translate-and-polish) so the two pipelines stay aligned. -// -// Directives emphasize STRUCTURE (bullets, paragraphs, checklists) -// over tone adjectives — structure is what users notice on short ASR -// transcripts. - -import Foundation - -public enum ScenarioStyleDirective { - - /// Format rules for the given scenario, written in the provider's - /// primary instruction language (Chinese-native vs English-native). - public static func make( - scenarioId: String, - providerId: String, - uiLanguage: AppUILanguage? = nil - ) -> String { - let id = PolishScenarioCatalog.resolve(scenarioId).id - let lang = uiLanguage ?? AppGroupStore().uiLanguage - let isChineseNative = ["zhipu", "moonshot", "qwen", "deepseek"].contains(providerId) - return isChineseNative - ? chinese(id: id, uiLanguage: lang) - : english(id: id, uiLanguage: lang) - } - - // MARK: - Chinese directives - - private static func chinese(id: String, uiLanguage: AppUILanguage) -> String { - let englishPlatformNames = uiLanguage.resolvedLanguageCode() != "zh-Hans" - switch id { - case "work": - return """ - 场景:工作沟通(邮件、钉钉、Slack)。 - 格式(必须): - - 若有 2 个及以上独立事项/请求/问题,必须用 markdown「- 」列表,每条一行;禁止揉进一段。 - - 仅 1 件事:可用 1~2 句短段落;必要时「称呼 + 正文」。 - - 每条 action 清晰;称呼得体;不过度敬语。 - 允许:为列表组织内容,不必强行压成单句。 - 禁止:把多项内容合并成一个长句或一整段散文。 - """ - case "todo": - return """ - 场景:TODO/备忘清单。 - 格式(必须): - - 输出必须是 markdown「- 」列表,每条一行。 - - 每条以动词开头;一条一事;不写称呼、不写解释、不扩写。 - 禁止:段落 prose、寒暄、背景说明。 - """ - case "social_lifestyle": - if englishPlatformNames { - return """ - 场景:社交网络生活分享帖(Social Network)。 - 格式: - - 内容≥2 句时必须用空行分段;第一人称;可读性强。 - - 可适度 emoji;不写广告腔;不编造体验。 - """ - } - return """ - 场景:小红书生活分享帖。 - 格式: - - 内容≥2 句时必须用空行分段;第一人称;可读性强。 - - 可适度 emoji 与语气词;不写广告腔;不编造体验。 - """ - case "social_short": - if englishPlatformNames { - return """ - 场景:Instagram 短 caption。 - 格式:句子短、开头抓人、信息密度高;控制总长度;不臆测标签或热点。 - """ - } - return """ - 场景:微博短帖。 - 格式:句子短、开头抓人、信息密度高;控制总长度;不臆测标签或热点。 - """ - case "goofy": - return """ - 场景:轻松聊天(逗比风格)。 - 格式:自然短句;措辞略俏皮。 - 禁止:新增情节、编段子、捏造态度;严肃内容(请假/道歉/投诉)不要强行搞笑。 - """ - case "document": - return """ - 场景:文档/长文笔记。 - 格式: - - 完整句;≥2 个主题时用空行分段。 - - 枚举或步骤用 markdown「- 」列表;可用 `##` 小标题(仅当内容够长)。 - - 少网络用语;比工作沟通更适合长文叙述。 - """ - case "daily_chat", PolishScenarioCatalog.customId: - fallthrough - default: - return """ - 场景:日常聊天(IM/私聊)。 - 格式:自然短句,像真人发消息;标点轻松;可保留极少量口语感。 - 禁止:公文腔、报告体、强行列表(除非口述本身在枚举)。 - """ - } - } - - // MARK: - English directives - - private static func english(id: String, uiLanguage: AppUILanguage) -> String { - let englishPlatformNames = uiLanguage.resolvedLanguageCode() != "zh-Hans" - switch id { - case "work": - return """ - Scenario: workplace message (email, Slack, Teams). - Format (required): - - If there are 2+ distinct items/requests/questions, you MUST use markdown "- " bullets, one per line; never merge into one paragraph. - - Single item only: 1–2 short sentences; optional greeting + body. - - Clear action per item; polite but not overly formal. - Allowed: list layout instead of forcing a single dense paragraph. - Forbidden: cramming multiple points into one long sentence or prose block. - """ - case "todo": - return """ - Scenario: TODO / checklist note. - Format (required): - - Output MUST be markdown "- " bullets, one item per line. - - Each line starts with a verb; one task per line; no greeting, no explanation. - Forbidden: prose paragraphs, filler, background context. - """ - case "social_lifestyle": - if englishPlatformNames { - return """ - Scenario: social network lifestyle post. - Format: if ≥2 sentences, separate paragraphs with blank lines; first person; light emoji ok; no ad-speak; do not invent experiences. - """ - } - return """ - Scenario: Xiaohongshu-style lifestyle share. - Format: if ≥2 sentences, separate paragraphs with blank lines; first person; light emoji ok; no ad-speak; do not invent experiences. - """ - case "social_short": - if englishPlatformNames { - return """ - Scenario: short Instagram caption. - Format: concise, punchy opening; high density; keep brief; no invented hashtags or trends. - """ - } - return """ - Scenario: Weibo-style short post. - Format: concise, punchy opening; high density; keep brief; no invented hashtags or trends. - """ - case "goofy": - return """ - Scenario: playful chat (goofy tone). - Format: natural short sentences; slightly witty wording only. - Forbidden: new facts, invented jokes, forced humor on serious topics (leave/apology/complaint). - """ - case "document": - return """ - Scenario: document / long-form notes. - Format: complete sentences; blank lines between topics when ≥2 themes; use "- " bullets for steps/enumerations; `##` headings only when content is long enough; minimal slang. - """ - case "daily_chat", PolishScenarioCatalog.customId: - fallthrough - default: - return """ - Scenario: everyday chat (IM/DM). - Format: natural short sentences like texting; relaxed punctuation; very light colloquial tone ok. - Forbidden: memo/report tone; forced bullets unless the speaker is enumerating. - """ - } - } -} diff --git a/OSGKeyboardShared/Services/TranslationPrompt.swift b/OSGKeyboardShared/Services/TranslationPrompt.swift index c42cc20..de0d7c8 100644 --- a/OSGKeyboardShared/Services/TranslationPrompt.swift +++ b/OSGKeyboardShared/Services/TranslationPrompt.swift @@ -3,75 +3,57 @@ // // Builds the system prompt the LLM sees when the user has the // translation toggle on. Re-uses the same per-provider "primary -// language" split the polish prompt uses (`AppGroupStore.defaultSystemPrompt`) -// so Chinese-native LLMs (DeepSeek, Qwen, GLM, Moonshot) get a Chinese -// prompt and English-native LLMs (OpenAI) get an English one — the LLM -// is most reliable when the instructions are written in its strongest -// language. +// language" split as `PolishingService.buildPrompt` so Chinese-native +// LLMs get a Chinese prompt and English-native LLMs get an English one. // // The "translate AND polish" blend is intentional: ASR transcripts are -// noisy (homophone errors, broken segmentation, dropped particles), so -// the prompt asks the model to clean the noise while translating. -// Scenario output format (`ScenarioStyleDirective`) is appended so -// translate-and-polish honours the user's polish scenario choice. +// noisy, so the prompt asks the model to clean the noise while translating. +// Style follows the auto-detected `AppContext` (same as the polish path). import Foundation public enum TranslationPrompt { /// Build the translate-and-polish system prompt. - /// - /// - Parameters: - /// - target: target language entry resolved via `TranslationLanguageCatalog`. - /// - providerId: provider preset id (e.g. `"deepseek"`, `"openai"`); - /// drives the language the prompt is written in. - /// - scenarioId: active polish scenario; format rules are shared - /// with the polish-only path via `ScenarioStyleDirective`. - /// - uiLanguage: host-app UI language (platform label variants). public static func make( target: TranslationLanguage, providerId: String, - scenarioId: String = PolishScenarioCatalog.defaultId, - uiLanguage: AppUILanguage? = nil + appContext: AppContext = .unknown ) -> String { let isChineseNative = ["zhipu", "moonshot", "qwen", "deepseek"].contains(providerId) - let directive = ScenarioStyleDirective.make( - scenarioId: scenarioId, - providerId: providerId, - uiLanguage: uiLanguage - ) + let contextGuideline = appContext.polishGuideline return isChineseNative - ? chinesePrompt(target: target, directive: directive) - : englishPrompt(target: target, directive: directive) + ? chinesePrompt(target: target, contextGuideline: contextGuideline) + : englishPrompt(target: target, contextGuideline: contextGuideline) } // MARK: - Chinese prompt (for DeepSeek / Qwen / GLM / Moonshot) - private static func chinesePrompt(target: TranslationLanguage, directive: String) -> String { + private static func chinesePrompt(target: TranslationLanguage, contextGuideline: String) -> String { """ 你是一位语音输入翻译与润色助手。用户用 ASR 转写了一段可能含噪声的口述: 1) 先识别原话的主要语言(若不确定则按用户给定的方向处理); 2) 将内容翻译为「\(target.promptLanguageName)」,保留原意,不增删事实、不臆测; 3) 顺带修复 ASR 噪声(同音错字、漏字、断句错乱),让译文读起来自然; - 4) 简洁;若下方场景未要求列表/分段,不超过原文 1.5 倍;去掉无意义的口头禅(嗯、啊、那个); - 5) 若场景格式要求列表或分段,允许按格式组织译文;总长度不超过原长 2 倍; - 6) 只输出译文正文,不要解释、不要加引号、不要前缀"以下是翻译"。 - \(directive) + 4) 简洁;不超过原文 1.5 倍;去掉无意义的口头禅(嗯、啊、那个); + 5) 只输出译文正文,不要解释、不要加引号、不要前缀"以下是翻译"。 + + 当前输入场景:\(contextGuideline) """ } // MARK: - English prompt (for OpenAI / OpenAI-compatible non-Chinese) - private static func englishPrompt(target: TranslationLanguage, directive: String) -> String { + private static func englishPrompt(target: TranslationLanguage, contextGuideline: String) -> String { """ You are a voice-input translation and polishing assistant. The user has spoken informally and the transcript may contain ASR noise: 1) Identify the input language; if unclear, assume the user wants translation INTO \(target.promptLanguageName); 2) Translate the content INTO \(target.promptLanguageName), preserving meaning; do not invent facts or omit content; 3) Fix ASR noise (homophone errors, missing characters, broken segmentation) so the translation reads naturally; - 4) Stay concise; if the scenario below does not require lists/sections, do not exceed 1.5x the spoken length; drop filler words (um, uh, like); - 5) If the scenario requires bullets or paragraph breaks, use that layout in the translation; total length may be up to 2x when listing; - 6) Output ONLY the translation. No quotes, no preamble, no explanation. - \(directive) + 4) Stay concise; do not exceed 1.5x the spoken length; drop filler words (um, uh, like); + 5) Output ONLY the translation. No quotes, no preamble, no explanation. + + Current input context: \(contextGuideline) """ } } diff --git a/OSGKeyboardTests/LLMClientTests.swift b/OSGKeyboardTests/LLMClientTests.swift index 79c1a80..ef63f0c 100644 --- a/OSGKeyboardTests/LLMClientTests.swift +++ b/OSGKeyboardTests/LLMClientTests.swift @@ -476,60 +476,13 @@ final class LLMClientTests: XCTestCase { XCTAssertEqual(calls, 1) } - func testResolvedPolishSystemPromptUsesWorkScenario() { - let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defaults.removePersistentDomain(forName: suiteName) - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set("work", forKey: "config.polishScenarioId") - let store = AppGroupStore(defaults: defaults) - let prompt = store.resolvedPolishSystemPrompt(providerId: "openai") - XCTAssertTrue(prompt.localizedCaseInsensitiveContains("workplace")) - } - - func testCustomPolishScenarioUsesStoredSystemPrompt() { - let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defaults.removePersistentDomain(forName: suiteName) - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(PolishScenarioCatalog.customId, forKey: "config.polishScenarioId") - defaults.set("MY CUSTOM PROMPT", forKey: "config.systemPrompt") - let store = AppGroupStore(defaults: defaults) - XCTAssertEqual(store.resolvedPolishSystemPrompt(), "MY CUSTOM PROMPT") - } - - func testPolishScenarioChipVisibleWhenCloudPolishEnabledLocally() { - let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defaults.removePersistentDomain(forName: suiteName) - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set("local", forKey: "config.engineMode") - defaults.set(true, forKey: "config.localModeCloudPolishEnabled") - let store = AppGroupStore(defaults: defaults) - XCTAssertTrue(store.isPolishScenarioChipVisible) - } - - func testTranslationPromptIncludesWorkScenarioBullets() { + func testTranslationPromptIncludesAppContextGuideline() { let prompt = TranslationPrompt.make( target: TranslationLanguageCatalog.resolve("en"), providerId: "openai", - scenarioId: "work" + appContext: .code ) - XCTAssertTrue(prompt.localizedCaseInsensitiveContains("MUST use markdown")) - XCTAssertTrue(prompt.localizedCaseInsensitiveContains("workplace")) - } - - func testWorkScenarioPolishPromptRequiresBullets() { - let prompt = ScenarioPrompt.make(scenarioId: "work", providerId: "openai") - XCTAssertTrue(prompt.localizedCaseInsensitiveContains("MUST use markdown")) - } - - func testTodoScenarioPolishPromptRequiresChecklist() { - let prompt = ScenarioPrompt.make(scenarioId: "todo", providerId: "deepseek") - XCTAssertTrue(prompt.contains("必须是 markdown")) + XCTAssertTrue(prompt.localizedCaseInsensitiveContains("preserve English identifiers")) } }