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 <hkgood@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-07-03 09:26:51 +00:00
parent 74f33b480f
commit 4ab60ba3dc
18 changed files with 31 additions and 871 deletions
@@ -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.
"""
}
}
}
@@ -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 openjump 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 = {}
@@ -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":
@@ -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.
"""
}
@@ -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: 12 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.
"""
}
}
}
@@ -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)
"""
}
}