feat: cursor navigation, key sounds, dictionary tooling, key security

Batch of in-progress app work from the working tree.

- feat(keyboard): CursorNavigation + CursorDragPad for caret movement;
  KeyboardSoundFeedback for system key click sounds
- feat(dictionary): DictionaryAliasGenerator + PersonalDictionaryEntrySheet;
  TranscriptPostProcessor quality gate; retire DictionaryLearner
- feat(ui): TabBarVisibility handling; drop PageHeaderRow /
  PageHeaderConfirmButton; refresh views and localizable strings
- fix(security): move the hardcoded DeepSeek key out of
  PreconfiguredKeys.swift into a gitignored PreconfiguredKeys.local.swift
  (seeded from .example by generate-xcodeproj.sh)
- docs(agents): add Conventional Commits versioning + bilingual changelog rules
- chore(gitignore): ignore PreconfiguredKeys.local.swift, .cache/, pycache

Custom language model / lexicon work stays on
feature/custom-language-model-asr. Changelog bullets added under
[Unreleased]; no version bump.
This commit is contained in:
Rocky
2026-07-05 18:27:23 +08:00
parent 074e24d87f
commit 05e005e9ce
60 changed files with 3247 additions and 1388 deletions
+151 -131
View File
@@ -10,24 +10,15 @@
// English dictation while halving the network round-trip.
//
// Engine matrix:
// - `engineMode == "cloud"` always polish
// - `engineMode == "local"`,
// cloud polish disabled ASR-only, return raw.
// - `engineMode == "local"`,
// cloud polish enabled DeepSeek LLM step (polish or translate).
// Translation uses `.translate` + `TranslationPrompt`; polish uses
// the default system prompt. Missing preconfigured DeepSeek key
// throws `missingAPIKey` and callers deliver raw + warning.
// - `polishIntensity == .off` ASR-only, return raw,
// regardless of engine mode
// - Missing API key return raw + throw
// `.missingAPIKey` so the caller can show the "fill in your key"
// hint inline
// - `engineMode == "cloud"` on-device ASR, then user's cloud LLM
// - `engineMode == "local"` on-device ASR, then built-in DeepSeek
// - Ultra-short, structure-free utterances skip the LLM entirely
// - Cloud without API key raw + `.missingAPIKey` warning
// - Local without build key raw + `.missingAPIKey` warning
//
// Caller-supplied `PolishContext` carries the per-call signals:
// - `appContext` code / email / chat / document / unknown
// - `intensity` off / light / medium / heavy (per-call
// override; default is the user-configured value)
// - `intensity` light / medium / heavy (per-call override)
// - `precedingText` optional tail of the cursor's preceding text
// for reference resolution
//
@@ -63,11 +54,11 @@ public actor PolishingService {
/// one from `store.makeClient()` per call.
private let injectedClient: LLMClient?
/// Default `timeout` is `LLMClient.requestTimeout + 1` second so the
/// safety-net `withThrowingTaskGroup` never wins the race against
/// the URL request itself; if the request times out cleanly the
/// network error reaches us first. The +1 is the single point of
/// slack between the two clocks keep it here, not in `LLMClient`.
/// `timeout` is the baseline (shortest) per-request HTTP timeout,
/// used as the floor for `effectiveTimeout(for:)`. It defaults to the
/// shared `LLMClient.requestTimeout`. The safety-net timer adds its
/// own slack on top of the length-scaled budget in `polishRemote`, so
/// no `+1` is baked in here.
public init(
store: AppGroupStore = AppGroupStore(),
client: LLMClient? = nil,
@@ -75,10 +66,10 @@ public actor PolishingService {
) {
self.store = store
self.injectedClient = client
self.timeout = timeout ?? (LLMClientFactory.defaultRequestTimeout + 1)
self.timeout = timeout ?? LLMClientFactory.defaultRequestTimeout
}
/// v0.3.0: context-aware polish entry point. The optional
/// v0.3.0: context-aware polish entry point. The optional
/// `PolishContext` carries per-call signals (app context,
/// intensity, preceding text). Translation is a separate concept
/// (see `mode` below) so callers wanting the v0.2.1 translate
@@ -94,42 +85,38 @@ public actor PolishingService {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
// Resolve per-call context: per-call override wins over the
// user-configured App Group value.
let resolvedContext = resolveContext(override: context)
// "off" intensity never calls the LLM, regardless of engine
// or mode. This lets users opt into "transcribe only" with
// one tap without having to flip the engine mode or pick a
// translation off-locale.
if resolvedContext.intensity == .off {
return trimmed
// Ultra-short, structure-free inputs skip the LLM to save
// latency (e.g. "", "OK", "").
if mode == .polish,
systemPrompt == nil || systemPrompt?.isEmpty == true,
TranscriptPostProcessor.shouldSkipLLM(for: trimmed) {
return TranscriptPostProcessor.localClean(trimmed)
}
// Local engine + cloud-polish-off: pure ASR, no LLM.
if store.engineMode == "local" {
guard store.shouldRunCloudLLMStep else { return trimmed }
} else {
// Cloud engine needs an API key.
if store.engineMode == "cloud", injectedClient == nil {
guard !store.apiKey.isEmpty else {
throw PolishError.missingAPIKey
}
}
return try await polishRemote(
let llmResult = try await polishRemote(
trimmed,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
context: resolvedContext
)
// Translation and custom prompts bypass the polish post-processor.
if mode != .polish || (systemPrompt != nil && !(systemPrompt?.isEmpty ?? true)) {
return llmResult
}
return TranscriptPostProcessor.process(original: trimmed, llmOutput: llmResult)
}
/// Build the final `PolishContext` for this call. Per-call
/// overrides take precedence; otherwise we read the user-configured
/// values out of the App Group (so the keyboard extension's
/// `PolishingService` instance does not need to know about
/// `ProviderConfig`).
private func resolveContext(override: PolishContext?) -> PolishContext {
guard let override else {
return PolishContext(
@@ -137,10 +124,6 @@ public actor PolishingService {
intensity: store.polishIntensity
)
}
// If the override leaves a field at its default-when-nil
// value, fall back to the App Group value. Today every
// `PolishContext` field is non-optional so this branch
// simply forwards; kept for future-proofing.
return override
}
@@ -151,12 +134,10 @@ public actor PolishingService {
providerIdOverride: String? = nil,
context: PolishContext
) async throws -> String {
// v0.2.1 follow-up: when the caller pins a provider id (the
// local engine pins DeepSeek) we still want to honor the
// injected test client, but we have to re-derive the
// preset/baseURL/model/apiKey quartet from the *override* so
// the injected client gets the right values when it's nil.
let effectiveProviderId = providerIdOverride ?? store.providerId
let effectiveProviderId = Self.resolvedProviderId(
store: store,
providerIdOverride: providerIdOverride
)
let client: LLMClient
if let injectedClient {
client = injectedClient
@@ -169,32 +150,27 @@ public actor PolishingService {
)
let apiKey: String
if effectiveProviderId == "deepseek" {
let preconfigured = PreconfiguredKeys.deepseek
if preconfigured == "TODO_FILL_LATER_DEEPSEEK_KEY" {
// Placeholder still in place refuse the round-
// trip so the UI can surface a "build not
// configured" hint instead of a 401.
guard PreconfiguredKeys.isDeepseekConfigured else {
throw PolishError.missingAPIKey
}
apiKey = preconfigured
apiKey = PreconfiguredKeys.deepseek
} else {
apiKey = store.apiKey
}
client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model)
}
// Polish-mode callers (and translation-mode callers that
// haven't supplied an explicit override) get the new
// "intelligent" prompt that uses `PolishContext.appContext`,
// `intensity`, and the personal dictionary. Translation-mode
// callers keep the v0.2.1 `TranslationPrompt` path so the
// translate-and-polish output contract doesn't change.
let prompt: String
if let override = systemPrompt, !override.isEmpty {
prompt = override
} else {
switch mode {
case .polish:
prompt = buildPrompt(for: trimmed, context: context)
prompt = buildPrompt(
for: trimmed,
context: context,
providerId: effectiveProviderId
)
case .translate(let targetLocaleId):
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
prompt = TranslationPrompt.make(
@@ -205,13 +181,17 @@ public actor PolishingService {
}
}
let budget = effectiveTimeout(for: trimmed)
// The HTTP request itself uses `budget`; the safety-net timer is
// given a small slack on top so a clean URL timeout surfaces its
// (more specific) transport error before the race fires.
let safetyNet = budget + 2
return try await withThrowingTaskGroup(of: String.self) { group in
group.addTask {
try await client.polish(trimmed, systemPrompt: prompt)
try await client.polish(trimmed, systemPrompt: prompt, timeout: budget)
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(budget * 1_000_000_000))
try await Task.sleep(nanoseconds: UInt64(safetyNet * 1_000_000_000))
throw PolishError.timeout
}
let result = try await group.next()!
@@ -220,91 +200,120 @@ public actor PolishingService {
}
}
/// Build the one-step "intelligent" prompt. The structure is:
/// 1. Role
/// 2. Three numbered tasks (correction, polish, style)
/// 3. Hard rules (do-not-modify list, length cap, short-circuit)
/// 4. User dictionary block (if any)
/// 5. Context + intensity guidelines
/// 6. Optional preceding text
/// 7. The transcript to process
/// 8. Output contract
///
/// 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 {
/// Shared output contract injected into every polish prompt.
internal static func globalOutputContract(useChinese: Bool) -> String {
if useChinese {
return """
## 全局输出契约(所有润色档位均必须遵守,优先级最高)
1. **禁止新增 emoji**:原文无 emoji 时输出不得出现 emoji;原文有 emoji 时仅可原样保留。
2. **必须恢复合理标点**:逗号、句号、问号、感叹号;按语义分句,不要输出无标点长段。
3. **必须做内容触发型结构化**(所有档位):
- 「第一点/第二个/步骤一/一是二是三是」→ 转为 `1. ` 编号列表并换行
- 「首先/其次/最后/另外/一方面」→ 分段换行,不强行编号
- 待办、会议纪要、多个问题、长文本多句 → 按语义分段
- 短但含结构信号的文本仍要格式化;极短且无结构的已由系统跳过
4. **数字要结合上下文判断**(重要):
- 有意义的数字(价格、日期、数量、时间、电话、版本号)→ 保持不变
- 但语音里的序号常被误识别成数字或时间,需结合上下文修回并列表化:
· 已出现「第一点」,随后的「第2:00 / 第2点0 / 第二零零」多半是「第二点」,「第3:00」多半是「第三点」
· 「1、2、3」「一、二、三」在列举语境里就是序号,转成 `1. ` 列表
- 判断依据是上下文里是否在“分点/列举”,不要机械地保留听错的数字
5. **保守改写**:能加标点就不改词;能分段就不重写;能小改就不大改;不新增事实。
6. **不改**人名、地名、专有名词(除非 ASR 明显错误)。
7. 输出语言必须与原文一致;不翻译、不扩写成 AI 文案。
8. 只输出最终文本:不要解释、不要引号包裹、不要前缀说明。
"""
} else {
return """
## Global output contract (mandatory at every intensity — highest priority)
1. **No new emojis**: if the original has none, output must have none; preserve originals only.
2. **Restore proper punctuation**: commas, periods, question marks; break run-on speech into sentences.
3. **Content-triggered structure** (every intensity):
- "first point / second / step one / one is two is three" → numbered `1. ` list with line breaks
- "firstly / secondly / finally / on the other hand" → paragraph breaks, not forced numbering
- todos, meeting notes, multiple questions, long multi-clause speech → semantic paragraphs
4. **Judge numbers by context** (important):
- Meaningful numbers (prices, dates, quantities, times, phone numbers, versions) → keep unchanged.
- But spoken ordinals are often misrecognized as digits/times; use context to restore and listify:
· after a "first point", a following "2:00 / point 2 / two oh oh" is likely "second point", "3:00" is "third point"
· "1, 2, 3" or "one, two, three" in an enumerating context are ordinals → convert to a `1. ` list
- Decide by whether the context is enumerating; do not mechanically preserve a misheard number.
5. **Conservative rewrite**: prefer punctuation over rewording; prefer breaks over rewriting; minimal changes.
6. **Do not** alter person names, places, or proper nouns unless clearly misrecognized.
7. Output language must match the input; do not translate or expand into marketing copy.
8. Output the final text only: no explanation, no quotes, no preamble.
"""
}
}
internal func buildPrompt(
for text: String,
context: PolishContext,
providerId: String
) -> String {
let dictionary = store.personalDictionary
let dictionaryBlock = dictionary.promptFragment()
let contextGuideline = context.appContext.polishGuideline
let intensityGuideline = context.intensity.promptGuideline
let contract = Self.globalOutputContract(useChinese: shouldUseChineseGuidance(providerId: providerId))
let precedingBlock = context.precedingForPrompt
.map { "上文(仅供参考,**不要**改写):\n\($0)\n" } ?? ""
let useChinese = shouldUseChineseGuidance(providerId: store.providerId)
.map {
"""
## 上文(仅供参考 — 用于术语/语气/是否续接列表或换行;**禁止**改写上文,**禁止**从上文新增事实)
\($0)
"""
} ?? ""
let useChinese = shouldUseChineseGuidance(providerId: providerId)
if useChinese {
return """
你是智能语音输入法的后处理引擎。一次完成三件事
你是智能语音输入法的后处理引擎。一次完成:ASR 纠错、标点恢复、语义分段、按档位润色。
\(contract)
## 任务 1:纠错
- 修正明显的语音识别错误(同音字、近音字、漏字、错字)
- 修正专有名词、英文术语(参考下面的用户词典)
- **绝不**修改数字、人名、地名(除非明显错得离谱)
## 任务 2:润色
- 删除冗余的语气词(嗯、呃、那个、就是、然后、对、ok)
- 删除重复说错的字句
- 必要时调整语序让表达更通顺
- 加合适的标点
## 任务 2:标点与结构
- 恢复合理标点与句子边界
- 识别口语中的列表、步骤、分点、会议纪要结构并格式化
- 长文本按语义换行分段
## 任务 3:风格适配
## 任务 3:润色(按档位)
当前输入场景:\(context.appContext.rawValue)
风格要求:\(contextGuideline)
润色档位:\(intensityGuideline)
## 重要规则
1. **最小改动原则**:原文已经能听懂的部分不要重写
2. 保留说话人的口吻和意图
3. 不添加原文中没有的信息
4. 短句(≤ 8 个中文字符 或 ≤ 15 个英文字符)直接原样返回,不要润色
5. 输出语言必须与原文一致
\(dictionaryBlock.isEmpty ? "" : "## 用户词典(必须原样保留,禁止改写)\n\(dictionaryBlock)\n")
\(precedingBlock)
## 原文
\(precedingBlock)## 原文
\(text)
请直接输出处理后的文本,**不要任何解释**。
"""
} else {
return """
You are the post-processing engine of a voice-input keyboard. Complete three tasks in one pass:
You are the post-processing engine of a voice-input keyboard. In one pass: fix ASR errors, restore punctuation, structure content, and polish per intensity.
\(contract)
## Task 1: Correction
- Fix obvious speech-recognition errors (homophones, near-misses, missing/extra characters).
- Correct proper nouns, English terms, and technical identifiers (see the user dictionary below).
- **Never** alter numbers, person names, or place names unless clearly wrong.
## Task 2: Polish
- Remove redundant filler words (um, uh, like, you know, basically).
- Remove duplicated fragments the speaker self-corrected.
- Adjust obviously broken word order.
- Add appropriate punctuation and capitalization.
## Task 2: Punctuation and structure
- Restore proper punctuation and sentence boundaries.
- Detect oral lists, steps, enumerated points, meeting-note structure and format them.
- Break long speech into semantic paragraphs.
## Task 3: Style adaptation
## Task 3: Polish (per intensity)
Current input context: \(context.appContext.rawValue)
Style guideline: \(contextGuideline)
Polish intensity: \(intensityGuideline)
## Hard rules
1. Minimum-change principle: do not rewrite parts the user already said clearly.
2. Preserve the speaker's voice and intent.
3. Never add information that is not in the original.
4. Short inputs (≤ 15 English words or ≤ 8 CJK characters) must be returned verbatim.
5. Output language must match the input language.
\(dictionaryBlock.isEmpty ? "" : "## User dictionary (must be preserved verbatim)\n\(dictionaryBlock)\n")
\(precedingBlock)
## Original transcript
\(precedingBlock)## Original transcript
\(text)
Output the processed text directly. **No explanation, no quotes, no preamble.**
@@ -312,7 +321,6 @@ public actor PolishingService {
}
}
/// 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":
@@ -322,19 +330,35 @@ public actor PolishingService {
}
}
/// Scale polish budget with transcript length (3-minute Flow utterances).
private func effectiveTimeout(for text: String) -> TimeInterval {
let scaled = timeout + (Double(text.count) / 200.0) * 2.0
/// Per-request HTTP timeout, scaled with transcript length. This is
/// the *actual* value handed to `LLMClient.polish(timeout:)`, so long
/// dictations (which generate long, listified, multi-paragraph output)
/// are not cut off mid-generation by a fixed 15 s ceiling. Grows by
/// ~10 s per 100 characters, capped at 120 s.
///
/// Previously this value was computed but only used for the safety-net
/// timer while the URLRequest stayed pinned at 15 s the scaling was
/// dead code and long transcripts timed out, falling back to the raw
/// (unpolished, unsegmented) ASR text.
internal func effectiveTimeout(for text: String) -> TimeInterval {
let scaled = timeout + (Double(text.count) / 100.0) * 10.0
return min(max(scaled, timeout), 120)
}
/// Picks base URL + model for one remote polish call.
///
/// When `providerIdOverride` is set (local engine pins DeepSeek),
/// always use that preset's defaults so cloud-engine settings
/// (e.g. Qwen base URL saved while testing cloud mode) are not
/// mixed with the pinned provider's API key. Cloud engine passes
/// `nil` and keeps honoring `store.baseURL` / `store.model`.
internal static func resolvedProviderId(
store: AppGroupStore,
providerIdOverride: String?
) -> String {
if let providerIdOverride {
return providerIdOverride
}
if store.engineMode == "local" {
return "deepseek"
}
let id = store.providerId
return id == "deepseek" ? "openai" : id
}
internal static func resolveLLMEndpoint(
store: AppGroupStore,
preset: LLMProvider,
@@ -344,10 +368,6 @@ public actor PolishingService {
return (preset.defaultBaseURL, preset.defaultModel)
}
let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL
// Pre-existing typo fix: the user-overridden `store.model`
// path was returning `preset.defaultModel` on both branches,
// silently ignoring the user's custom model field. Restore
// the asymmetry so the user override actually wins.
let model = store.model.isEmpty ? preset.defaultModel : store.model
return (baseURL, model)
}
@@ -361,7 +381,7 @@ extension PolishingService.PolishError: LocalizedError {
case .timeout:
return "LLM polish timed out."
case .missingAPIKey:
return "Missing API key (local: set PreconfiguredKeys.deepseek; cloud: Settings API key)."
return "Missing API key (cloud: Settings API key; local: build configuration)."
}
}
}