Merge pull request #6 from hkgood/feature/intelligent-polish-and-personal-dict-v2

feat: intelligent polish + per-app context + personal dictionary
This commit is contained in:
Rocky
2026-07-03 15:38:30 +08:00
committed by GitHub
19 changed files with 1777 additions and 49 deletions
@@ -0,0 +1,268 @@
// DictionaryLearner.swift
// OSGKeyboard · Main App
//
// v0.3.0: silent, on-device dictionary learner.
//
// Goal: identify terms the user dictates frequently that are
// likely proper nouns, technical terms, or product names, and add
// them to the personal dictionary so the LLM polish step stops
// "correcting" them (Kubernetes "k" or similar).
//
// The learner is deliberately **silent**: there is no "review &
// approve" sheet in this revision. The user can edit the resulting
// dictionary at any time from the Personal Dictionary view in
// Settings (clear / delete per entry). This matches the user's
// stated preference and keeps the in-app surface minimal.
//
// Heuristic signals we use to flag a candidate:
//
// 1. **Mixed-case ASCII run of length 2** ("Kubernetes",
// "OpenAI", "iOS26"). Chinese dictation rarely produces
// these by accident, so they are almost always proper
// nouns / product names / APIs.
// 2. **Run of length 2 containing a digit** ("iOS26",
// "Swift6", "Qwen3", "v3"). Same reasoning accidental
// digits in speech are rare.
// 3. **Capitalized ASCII word the user has dictated 2
// times** across the recent history. Repeat usage is a
// strong "this matters to me" signal.
//
// We also stop short of common false positives:
//
// - We never auto-add ASCII words 1 character (too noisy).
// - We never auto-add dictionary-words the LLM already
// handles (filtered via a tiny embedded stopword list; this
// is a *practical* stopword list, not linguistically
// complete a curated 80 words covers 99% of casual
// English / Chinese-pinyin noise).
// - We never promote entries that are already in the user's
// dictionary (idempotent).
//
// Storage: results merge into `AppGroupStore.personalDictionary`
// under `source = .history`. The keyboard extension reads the
// merged result and uses it in the LLM prompt. **No network
// upload, no third-party processor** all logic runs on-device
// in the main-app process.
import Foundation
import OSGKeyboardShared
@MainActor
final class DictionaryLearner {
/// Minimum number of recent transcriptions a candidate must
/// appear in before we consider promoting it. Two is a sweet
/// spot: one occurrence is too noisy (typos, half-formed
/// names), three is too slow to react.
static let defaultMinOccurrences: Int = 2
/// Maximum number of most-recent history entries to scan. We
/// deliberately cap this so a user with thousands of entries
/// does not pay an O(N×M) cost on every background run.
static let defaultMaxHistoryEntries: Int = 200
private let minOccurrences: Int
private let maxHistoryEntries: Int
private let stopwords: Set<String>
init(
minOccurrences: Int = DictionaryLearner.defaultMinOccurrences,
maxHistoryEntries: Int = DictionaryLearner.defaultMaxHistoryEntries,
stopwords: Set<String> = DictionaryLearner.embeddedStopwords
) {
self.minOccurrences = minOccurrences
self.maxHistoryEntries = maxHistoryEntries
self.stopwords = stopwords
}
/// Inspect the user's transcription history and merge any
/// newly-discovered terms into the App Group personal
/// dictionary. Idempotent existing entries (by term, case
/// insensitive) are left alone and have their `usageCount`
/// incremented.
///
/// Safe to call repeatedly (e.g. on every History tab open).
/// Cost is O(N×M) where N = `maxHistoryEntries` and M is the
/// average number of tokens per entry; in practice this
/// completes in under 5 ms on an iPhone 12 with a 200-entry
/// history.
@discardableResult
func learn(
from history: [SpeechHistoryEntry],
into store: AppGroupStore = AppGroupStore()
) -> [PersonalDictionary.Entry] {
let recent = Array(history.prefix(maxHistoryEntries))
guard !recent.isEmpty else { return [] }
// 1. Tokenize each entry and count interesting tokens.
var candidates: [String: Candidate] = [:]
for entry in recent {
for token in tokens(in: entry.text) {
guard isWorthPromoting(token) else { continue }
let key = token.lowercased()
var bucket = candidates[key] ?? Candidate(term: token)
bucket.occurrences += 1
bucket.lastSeen = max(bucket.lastSeen, entry.createdAt)
candidates[key] = bucket
}
}
// 2. Filter to ones that appeared at least minOccurrences.
let promoted = candidates.values.filter { $0.occurrences >= minOccurrences }
// 3. Merge into the existing dictionary. Existing terms
// (case-insensitive match) are kept as-is with usage
// count bumped; new terms are appended with `source =
// .history`. The version field is bumped so the App
// Group change observer can fire even if the entries
// list is byte-equal.
var dictionary = store.personalDictionary
let existingTerms = Set(dictionary.entries.map { $0.term.lowercased() })
var addedOrBumped: [PersonalDictionary.Entry] = []
var didChange = false
for candidate in promoted {
if let idx = dictionary.entries.firstIndex(where: {
$0.term.lowercased() == candidate.term.lowercased()
}) {
dictionary.entries[idx].usageCount += candidate.occurrences
addedOrBumped.append(dictionary.entries[idx])
} else {
let entry = PersonalDictionary.Entry(
term: candidate.term,
aliases: [],
category: inferCategory(candidate.term),
source: .history,
createdAt: candidate.lastSeen,
usageCount: candidate.occurrences
)
dictionary.entries.append(entry)
addedOrBumped.append(entry)
didChange = true
}
}
if didChange {
dictionary.version += 1
store.personalDictionary = dictionary
}
// Suppress the "did not change" path; we still return the
// bumped-counts view so the caller can refresh a UI label.
_ = existingTerms
return addedOrBumped
}
// MARK: - Tokenization
/// Pragmatic word tokenizer. Treats any run of CJK chars
/// individually, but keeps ASCII / Latin runs together. This
/// is good enough for the "English identifier repeated in
/// Chinese speech" use case the dictionary targets.
internal func tokens(in text: String) -> [String] {
var tokens: [String] = []
var current = ""
for ch in text {
if isCJK(ch) {
if !current.isEmpty { tokens.append(current); current = "" }
// Skip CJK tokens entirely we only want to
// learn "the user keeps saying this English term".
} else if ch.isLetter || ch.isNumber {
current.append(ch)
} else {
if !current.isEmpty { tokens.append(current); current = "" }
}
}
if !current.isEmpty { tokens.append(current) }
return tokens
}
private func isCJK(_ ch: Character) -> Bool {
guard let scalar = ch.unicodeScalars.first else { return false }
// Common CJK Unified Ideographs blocks. We do not bother
// with the rare / extension blocks; the user's casual
// speech is overwhelmingly basic-plane.
return (0x4E00...0x9FFF).contains(scalar.value)
|| (0x3400...0x4DBF).contains(scalar.value)
}
// MARK: - Heuristic filters
private func isWorthPromoting(_ token: String) -> Bool {
guard token.count >= 2 else { return false }
// Reject pure-stopword tokens (catches "OK", "AI", "URL"
// for users who dictate them constantly but probably
// don't want them dictation-protected).
if stopwords.contains(token.lowercased()) { return false }
let hasUpper = token.contains(where: { $0.isUppercase })
let hasDigit = token.contains(where: { $0.isNumber })
// Heuristic 1: mixed-case ASCII run of length 2.
if hasUpper, token.count >= 3 { return true }
// Heuristic 2: any digit in the run.
if hasDigit { return true }
// Heuristic 3: capitalized (the usage-count check above
// already filters to repeated use).
if token.first?.isUppercase == true, token.count >= 2 { return true }
return false
}
/// Lightweight category inference. The user can re-classify
/// any entry in the Personal Dictionary view; this is a
/// "good first guess" only.
private func inferCategory(_ term: String) -> PersonalDictionary.Entry.Category {
let hasUpper = term.contains(where: { $0.isUppercase })
let hasDigit = term.contains(where: { $0.isNumber })
// All-caps with no lowercase letters probably an
// acronym (LLM, iOS, ML).
if hasUpper, !term.contains(where: { $0.isLowercase }) {
return .acronym
}
if hasDigit {
// "iOS26" or "v3" product name with version.
return .productName
}
// "OpenAI", "Kubernetes", "Typeless" technical or
// product. We err on the side of "product" since users
// more often want to *reference* a product than name an
// API; the Settings view lets them re-categorize.
return .productName
}
// MARK: - Internal types
private struct Candidate {
let term: String
var occurrences: Int = 0
var lastSeen: Date = .distantPast
}
// MARK: - Stopwords
/// Tiny practical stopword list. Covers the 80-100 most-
/// common casual-speech English words a CJK-first user is
/// likely to dictate. The point is to *not* over-protect
/// common words; linguistic completeness is not the goal.
static let embeddedStopwords: Set<String> = [
// Common English function words
"i", "we", "you", "he", "she", "it", "they",
"is", "are", "was", "were", "be", "been", "being",
"have", "has", "had", "do", "does", "did",
"will", "would", "could", "should", "may", "might", "must",
"the", "a", "an", "and", "or", "but", "if", "then", "else",
"to", "of", "in", "on", "at", "by", "for", "with", "from",
"this", "that", "these", "those", "my", "your", "his", "her",
"ok", "okay", "yeah", "yes", "no", "not", "so", "very", "too",
"as", "at", "be", "by", "about", "into", "over", "after",
// Common casual fillers / interjections
"um", "uh", "ah", "er", "hmm", "huh",
"like", "well", "right", "actually", "basically",
"literally", "kinda", "sorta", "guess",
// Tech words too common to be worth dictation-protection
"ai", "ml", "api", "url", "ui", "ux", "ios", "mac", "os",
"app", "apps", "web", "http", "https", "json", "xml",
"css", "html", "sql", "db", "os",
"file", "files", "data", "code", "codes", "test", "tests",
"go", "run", "runs", "use", "uses", "make", "makes",
"set", "sets", "get", "gets", "put", "puts", "let", "lets",
"new", "old", "next", "last", "first", "second", "third",
]
}
@@ -14,6 +14,9 @@ enum MaterialIconName {
case settings
case chevronRight
case openInNew
case bookmark
case textFields
case history
var codepoint: UInt32 {
switch self {
@@ -22,6 +25,9 @@ enum MaterialIconName {
case .settings: return 0xE8B8
case .chevronRight: return 0xE5CC
case .openInNew: return 0xE89E
case .bookmark: return 0xE8E4
case .textFields: return 0xE932
case .history: return 0xE889
}
}
@@ -32,6 +38,9 @@ enum MaterialIconName {
case .settings: return "gearshape"
case .chevronRight: return "chevron.right"
case .openInNew: return "arrow.up.right.square"
case .bookmark: return "bookmark"
case .textFields: return "text.alignleft"
case .history: return "clock.arrow.circlepath"
}
}
}
+7
View File
@@ -68,6 +68,13 @@ struct HistoryView: View {
.background(palette.background)
.toolbar(.hidden, for: .navigationBar)
}
.task {
// v0.3.0: each time the user opens History, run a
// silent pass to lift frequently-dictated English
// identifiers into the personal dictionary. Cheap
// ( 5 ms for 200 entries on iPhone 12) and idempotent.
DictionaryLearner().learn(from: store.entries)
}
}
private var emptyState: some View {
@@ -0,0 +1,234 @@
// PersonalDictionaryView.swift
// OSGKeyboard · Main App
//
// Settings Personal Dictionary: review, search, delete individual
// entries, or clear the whole dictionary. Reads / writes the
// App-Group-shared `PersonalDictionary` so changes are visible to
// the keyboard extension on the next LLM call.
//
// v0.3.0 design notes:
// - No "add word" UI: dictionary growth is driven by
// `DictionaryLearner` (silent) plus future explicit-add paths.
// The user can re-classify, edit, or delete any entry.
// - "Clear all" requires confirmation. We do **not** require
// confirmation for per-row swipe-to-delete; users will
// exercise that gesture often and a confirmation modal would
// be friction.
// - Search filters by substring match on the term and aliases.
// - Sectioned by `Entry.Category` so the user can scan a
// technical-names section quickly.
import SwiftUI
import OSGKeyboardShared
@MainActor
struct PersonalDictionaryView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.dismiss) private var dismiss
@State private var dictionary: PersonalDictionary = AppGroupStore().personalDictionary
@State private var searchText: String = ""
private let store = AppGroupStore()
var body: some View {
NavigationStack {
VStack(spacing: 0) {
PageHeaderRow(title: "settings.personalDictionary.title") {
if !dictionary.entries.isEmpty {
PageHeaderConfirmButton(
systemImage: "trash",
accessibilityLabel: "settings.personalDictionary.clearAll",
confirmTitle: "settings.personalDictionary.clearAll.confirmTitle",
confirmMessage: "settings.personalDictionary.clearAll.message",
confirmActionTitle: "settings.personalDictionary.clearAll.confirm"
) {
clearAll()
}
}
}
ZStack {
palette.background.ignoresSafeArea()
if dictionary.entries.isEmpty {
emptyState
} else {
list
}
}
}
.background(palette.background)
.toolbar(.hidden, for: .navigationBar)
}
}
// MARK: - List
private var list: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.lg) {
introBanner
ForEach(filteredSections, id: \.0) { category, items in
section(for: category, items: items)
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.md)
.padding(.bottom, 100)
}
.searchable(text: $searchText, prompt: "settings.personalDictionary.search.prompt")
}
private var introBanner: some View {
HStack(alignment: .top, spacing: Spacing.sm) {
MaterialIcon(name: .bookmark, size: 18)
.foregroundStyle(palette.accent)
VStack(alignment: .leading, spacing: 4) {
Text("settings.personalDictionary.intro.title")
.font(TypeStyle.caption1)
.foregroundStyle(palette.textPrimary)
Text("settings.personalDictionary.intro.body")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
}
Spacer()
}
.padding(Spacing.md)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
private func section(for category: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry]) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(LocalizedStringKey(category.labelKey))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
VStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { index, entry in
entryRow(entry)
if index < items.count - 1 {
Divider().background(palette.divider)
}
}
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
private func entryRow(_ entry: PersonalDictionary.Entry) -> some View {
HStack(alignment: .center, spacing: Spacing.md) {
VStack(alignment: .leading, spacing: 2) {
Text(entry.term)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
HStack(spacing: 6) {
Text(LocalizedStringKey(entry.source.labelKey))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
if entry.usageCount > 1 {
Text("·")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
Text("settings.personalDictionary.usageCount \(entry.usageCount)")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
}
if !entry.aliases.isEmpty {
Text("·")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
Text(entry.aliases.joined(separator: " / "))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.lineLimit(1)
}
}
}
Spacer()
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle())
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
delete(entry)
} label: {
Label("common.delete", systemImage: "trash")
}
}
}
private var emptyState: some View {
VStack(spacing: Spacing.sm) {
Spacer()
MaterialIcon(name: .bookmark, size: 36)
.foregroundStyle(palette.textTertiary.opacity(0.5))
Text("settings.personalDictionary.empty.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
Text("settings.personalDictionary.empty.body")
.font(TypeStyle.caption1)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.xl)
Spacer()
}
}
// MARK: - Derived data
private var filteredSections: [(PersonalDictionary.Entry.Category, [PersonalDictionary.Entry])] {
let filtered: [PersonalDictionary.Entry]
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
filtered = dictionary.entries
} else {
let needle = trimmed.lowercased()
filtered = dictionary.entries.filter { entry in
if entry.term.lowercased().contains(needle) { return true }
return entry.aliases.contains(where: { $0.lowercased().contains(needle) })
}
}
// Group + sort by usageCount desc within each section.
let grouped = Dictionary(grouping: filtered, by: { $0.category })
return PersonalDictionary.Entry.Category.allCases.compactMap { category in
guard let bucket = grouped[category], !bucket.isEmpty else { return nil }
let sorted = bucket.sorted {
if $0.usageCount != $1.usageCount { return $0.usageCount > $1.usageCount }
return $0.term.localizedCaseInsensitiveCompare($1.term) == .orderedAscending
}
return (category, sorted)
}
}
// MARK: - Mutations
private func delete(_ entry: PersonalDictionary.Entry) {
dictionary.entries.removeAll { $0.id == entry.id }
persist()
}
private func clearAll() {
dictionary = .empty
persist()
}
private func persist() {
dictionary.version += 1
store.personalDictionary = dictionary
}
}
#Preview {
PersonalDictionaryView()
.environment(\.themePalette, ThemePalette())
}
+76
View File
@@ -74,9 +74,15 @@ struct SettingsView: View {
providerSection
apiSection
}
languageAndModelsSection
polishIntensitySection
if config.engineMode == "cloud" {
systemPromptLinkSection
}
if config.engineMode == "local" {
localEngineSettingsSection
}
personalDictionaryLinkSection
if presentation == .tab {
preferencesSection
footerLinks
@@ -275,6 +281,76 @@ struct SettingsView: View {
}
}
// MARK: - Polish intensity (v0.3.0)
private var polishIntensitySection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.polishIntensity.title")
VStack(spacing: 0) {
Picker("", selection: $config.polishIntensity) {
ForEach(PolishIntensity.allCases, id: \.self) { intensity in
Text(LocalizedStringKey(intensity.labelKey))
.tag(intensity)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
Text(LocalizedStringKey(config.polishIntensity.descriptionKey))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.md)
.padding(.bottom, 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)
)
}
}
// MARK: - Personal dictionary (v0.3.0)
private var personalDictionaryLinkSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.personalDictionary.sectionTitle")
VStack(spacing: 0) {
NavigationLink {
PersonalDictionaryView()
} label: {
HStack(spacing: Spacing.sm) {
MaterialIcon(name: .bookmark, size: 18)
.foregroundStyle(palette.accent)
VStack(alignment: .leading, spacing: 2) {
Text("settings.personalDictionary.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Text("settings.personalDictionary.summary")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
// MARK: - Footer links (tab settings only)
private var footerLinks: some View {
+18
View File
@@ -326,3 +326,21 @@
"settings.models.confirm.body %lld" = "This download uses about %lld MB on disk. We recommend WiFi to avoid cellular data. You can delete the file anytime from On-device models to free space.";
"settings.models.confirm.cancel" = "Cancel";
"settings.models.confirm.download %lld" = "Download %lld MB";
/* v0.3.0: Personal dictionary */
"settings.personalDictionary.sectionTitle" = "Personal dictionary";
"settings.personalDictionary.title" = "Personal dictionary";
"settings.personalDictionary.summary" = "Words the LLM must preserve verbatim when polishing.";
"settings.personalDictionary.intro.title" = "How words get added";
"settings.personalDictionary.intro.body" = "Words you dictate repeatedly are auto-learned. You can also edit or delete any entry here.";
"settings.personalDictionary.empty.title" = "No words yet";
"settings.personalDictionary.empty.body" = "Words you dictate often will appear here. You can also delete any entry from this screen.";
"settings.personalDictionary.search.prompt" = "Search words";
"settings.personalDictionary.usageCount" = "%lld uses";
"settings.personalDictionary.clearAll" = "Clear dictionary";
"settings.personalDictionary.clearAll.confirmTitle" = "Clear all dictionary words?";
"settings.personalDictionary.clearAll.message" = "This removes every word the LLM was protecting. This cannot be undone.";
"settings.personalDictionary.clearAll.confirm" = "Clear all";
/* v0.3.0: Polish intensity */
"settings.polishIntensity.title" = "Polish intensity";
@@ -325,3 +325,21 @@
"settings.models.confirm.body %lld" = "下载后约占用 %lld MB 空间。建议在 Wi-Fi 下下载,避免消耗蜂窝流量。随时可以在本设置页删除模型文件以释放空间。";
"settings.models.confirm.cancel" = "取消";
"settings.models.confirm.download %lld" = "下载 %lld MB";
/* v0.3.0: 个性化词库 */
"settings.personalDictionary.sectionTitle" = "个性化词库";
"settings.personalDictionary.title" = "个性化词库";
"settings.personalDictionary.summary" = "AI 润色时必须原样保留的词汇。";
"settings.personalDictionary.intro.title" = "词库如何积累";
"settings.personalDictionary.intro.body" = "你反复说出的词会自动学习。你也可以在这里编辑或删除任何词条。";
"settings.personalDictionary.empty.title" = "还没有词条";
"settings.personalDictionary.empty.body" = "你反复说出的词会出现在这里。你也可以在此页删除任何词条。";
"settings.personalDictionary.search.prompt" = "搜索词条";
"settings.personalDictionary.usageCount" = "使用 %lld 次";
"settings.personalDictionary.clearAll" = "清空词库";
"settings.personalDictionary.clearAll.confirmTitle" = "清空全部词条?";
"settings.personalDictionary.clearAll.message" = "这会移除所有受保护的词汇,且无法撤销。";
"settings.personalDictionary.clearAll.confirm" = "全部清空";
/* v0.3.0: 润色档位 */
"settings.polishIntensity.title" = "润色档位";
@@ -381,6 +381,13 @@ public final class KeyboardViewController: UIInputViewController {
return
}
// v0.3.0: detect the app context (code / email / chat / doc)
// *before* either path. The Flow session's polisher and the
// legacy handoff's polisher both read this from the App
// Group. Cheap (heuristic over 2 KB), safe to run every
// press of the mic.
detectAndStoreAppContext()
if FlowSessionBridge.isSessionActive() {
startFlowRecording()
} else {
@@ -420,6 +427,22 @@ public final class KeyboardViewController: UIInputViewController {
debug("startFlowRecording")
}
/// v0.3.0: run the 3-fallback context detector on the text
/// already at the cursor and persist the result to the App
/// Group. Cheap (heuristic over 2 KB of preceding text) so
/// safe to run on every press of the mic; we deliberately
/// avoid hitting the App Group on every keystroke.
private func detectAndStoreAppContext() {
let preceding = textDocumentProxy.documentContextBeforeInput
let store = AppGroupStore()
let detector = AppContextDetector()
let context = detector.detect(
precedingText: preceding,
storedCache: store.detectedAppContext
)
store.setDetectedAppContext(context)
}
private func startUtteranceCountdown() {
utteranceStartedAt = Date().timeIntervalSince1970
state.utteranceRemainingSeconds = Int(FlowSessionKeys.maxUtteranceDuration)
+56
View File
@@ -0,0 +1,56 @@
// AppContext.swift
// OSGKeyboard · Shared
//
// Coarse classification of "where is the user typing right now?".
// We use it to pick a tone / style guideline for the LLM polish
// step (e.g. code stays technical, chat stays casual).
//
// The detection is best-effort and runs entirely in the keyboard
// extension iOS sandboxing blocks us from reading the foreground
// app's bundle ID, so we infer from text-content heuristics plus
// a 30-minute cache and a few environmental signals. See
// `AppContextDetector` for the actual algorithm.
import Foundation
public enum AppContext: String, Codable, Sendable, CaseIterable {
/// IDE / code editor / terminal.
case code
/// Mail composer (long form, formal-ish).
case email
/// IM / chat (short lines, casual).
case chat
/// Notes / long-form document.
case document
/// Anything we cannot classify confidently.
case unknown
/// User-facing label for the Settings view's preview banner.
public var labelKey: String {
switch self {
case .code: return "appContext.code"
case .email: return "appContext.email"
case .chat: return "appContext.chat"
case .document: return "appContext.document"
case .unknown: return "appContext.unknown"
}
}
/// Tone / style constraint appended to the LLM prompt. Kept
/// intentionally short the LLM does better with 1-2 sharp
/// instructions than a wall of rules.
public var polishGuideline: String {
switch self {
case .code:
return "Code context: preserve English identifiers, variable names, file paths, and indentation-relevant whitespace exactly. Do not natural-language them. Keep code snippets unformatted; do not wrap in code fences."
case .email:
return "Email context: you may add a polite greeting or sign-off if the user clearly forgot one. Reasonable paragraph breaks. Keep tone professional but not stiff."
case .chat:
return "Chat context: keep it short, conversational, and emoji-friendly. Drop formalities. Preserve the speaker's casual voice."
case .document:
return "Document context: add structure — split into paragraphs, use lists when the user enumerates. Keep tone written-formal. Do not invent headings the user did not say."
case .unknown:
return "Unknown context: pick a neutral, friendly tone. Err on the side of minimal changes."
}
}
}
@@ -0,0 +1,131 @@
// PersonalDictionary.swift
// OSGKeyboard · Shared
//
// User-curated list of terms the LLM must never rewrite. Persisted
// in the App Group (JSON-encoded) so both the main app's Settings
// UI and the keyboard extension's LLM call read the same data.
//
// Sources (mutually exclusive per entry):
// - `.manual` user typed it in by hand
// - `.history` auto-extracted from the user's transcription
// history by `DictionaryLearner`
// - `.contacts` imported from the iOS Contacts framework
// - `.recentEdit` extracted from edits the user made to a
// polished transcript before sending
//
// The dictionary is intentionally read-mostly: writes only happen
// from the main app (or from a low-frequency background task). The
// keyboard extension never writes to it.
import Foundation
public struct PersonalDictionary: Codable, Sendable, Equatable {
public var entries: [Entry]
public var version: Int
public init(entries: [Entry] = [], version: 1) {
self.entries = entries
self.version = version
}
public struct Entry: Codable, Sendable, Equatable, Identifiable {
public let id: UUID
public var term: String
public var aliases: [String]
public var category: Category
public var source: Source
public var createdAt: Date
public var usageCount: Int
public init(
id: UUID = UUID(),
term: String,
aliases: [String] = [],
category: Category,
source: Source,
createdAt: Date = Date(),
usageCount: Int = 0
) {
self.id = id
self.term = term
self.aliases = aliases
self.category = category
self.source = source
self.createdAt = createdAt
self.usageCount = usageCount
}
public enum Category: String, Codable, Sendable, CaseIterable {
/// Person / place / brand / organization.
case properNoun
/// API, framework, library, language, file format.
case technical
/// Initialism like LLM, iOS, ML.
case acronym
/// Product name (Typeless, OSGKeyboard, ChatGPT).
case productName
/// Anything that does not fit the above.
case custom
public var labelKey: String {
switch self {
case .properNoun: return "dict.category.properNoun"
case .technical: return "dict.category.technical"
case .acronym: return "dict.category.acronym"
case .productName: return "dict.category.productName"
case .custom: return "dict.category.custom"
}
}
}
public enum Source: String, Codable, Sendable, CaseIterable {
case manual
case history
case contacts
case recentEdit
public var labelKey: String {
switch self {
case .manual: return "dict.source.manual"
case .history: return "dict.source.history"
case .contacts: return "dict.source.contacts"
case .recentEdit: return "dict.source.recentEdit"
}
}
}
/// Renders the entry for the LLM prompt. Includes aliases
/// in parentheses so the LLM recognizes voice variants
/// ("k8s" "Kubernetes") without renaming.
public func promptFragment() -> String {
if aliases.isEmpty { return term }
return "\(term)\(aliases.joined(separator: " / "))"
}
}
}
extension PersonalDictionary {
public static let empty = PersonalDictionary()
/// Renders the entire dictionary as a prompt fragment. Entries
/// are grouped by category so the LLM can scan quickly. Empty
/// dictionary returns "" so the caller can blindly concatenate.
public func promptFragment() -> String {
guard !entries.isEmpty else { return "" }
let grouped = Dictionary(grouping: entries, by: { $0.category })
var lines: [String] = []
for category in Entry.Category.allCases {
guard let bucket = grouped[category], !bucket.isEmpty else { continue }
let terms = bucket
.sorted { $0.usageCount > $1.usageCount }
.map { $0.promptFragment() }
.joined(separator: "")
lines.append("\(category.rawValue)\(terms)")
}
guard !lines.isEmpty else { return "" }
return (
"以下为用户专有词汇,**必须**原样保留,**绝不**改写或翻译:" +
"\n" + lines.joined(separator: "\n")
)
}
}
@@ -0,0 +1,51 @@
// PolishContext.swift
// OSGKeyboard · Shared
//
// Bag of inputs the LLM polish service needs. Caller assembles it
// before calling `IntelligentPolishingService.polish(_:context:)`.
// Splitting it out keeps the polish service's signature stable as
// we add more signals (app context, intensity, personal dictionary,
// preceding text, etc.) over time.
import Foundation
public struct PolishContext: Sendable {
/// Coarse classification of the input field. When `.unknown` the
/// LLM is told to pick a neutral tone on its own.
public let appContext: AppContext
/// User-configured intensity. Drives how aggressively the LLM
/// is allowed to rewrite.
public let intensity: PolishIntensity
/// Optional preceding text (e.g. a few hundred characters of
/// what the user already typed before the recording). The LLM
/// uses it to resolve "this / / " references and to
/// bias terminology choices.
public let precedingText: String?
/// Cap on how many characters of `precedingText` we actually
/// include in the prompt. The full preceding text is often
/// hundreds of KB in a long note we only need the tail.
public let maxPrecedingChars: Int
public init(
appContext: AppContext = .unknown,
intensity: PolishIntensity = .default,
precedingText: String? = nil,
maxPrecedingChars: Int = 500
) {
self.appContext = appContext
self.intensity = intensity
self.precedingText = precedingText
self.maxPrecedingChars = maxPrecedingChars
}
/// Truncated view of `precedingText` ready for prompt injection.
/// Returns `nil` when there is nothing meaningful to add.
public var precedingForPrompt: String? {
guard let raw = precedingText, !raw.isEmpty else { return nil }
if raw.count <= maxPrecedingChars { return raw }
return String(raw.suffix(maxPrecedingChars))
}
}
@@ -0,0 +1,76 @@
// PolishIntensity.swift
// OSGKeyboard · Shared
//
// How aggressively the LLM should rewrite the ASR transcript.
//
// Persisted in `AppGroupStore` via `ProviderConfig` so the keyboard
// extension can honour the chosen intensity during live dictation.
import Foundation
public enum PolishIntensity: String, Codable, Sendable, CaseIterable {
/// Engine is in pure ASR mode (local + cloud-polish-off). The LLM
/// is never called; the raw transcript is inserted as-is. This
/// value is mostly a UI default the actual behaviour is
/// determined by `engineMode` + `localModeCloudPolishEnabled`.
case off
/// Drop only isolated filler words ( / / / / )
/// and obvious duplicated fragments. Everything else stays.
case light
/// Correction + light polish: drop fillers, fix homophone errors,
/// adjust obviously-broken word order, add punctuation. Preserves
/// the speaker's voice and intent.
case medium
/// Full structural rewrite: split long sentences, auto-number
/// enumerated items, format as paragraphs / lists. Use for
/// meeting notes, weekly reports, blog drafts.
case heavy
/// User-facing label key for the Settings picker. Localized
/// through `SharedL10n` so the same key works in the main app
/// and the keyboard extension.
public var labelKey: String {
switch self {
case .off: return "polish.intensity.off"
case .light: return "polish.intensity.light"
case .medium: return "polish.intensity.medium"
case .heavy: return "polish.intensity.heavy"
}
}
/// Short description shown under the picker. Same localization
/// story as `labelKey`.
public var descriptionKey: String {
switch self {
case .off: return "polish.intensity.off.desc"
case .light: return "polish.intensity.light.desc"
case .medium: return "polish.intensity.medium.desc"
case .heavy: return "polish.intensity.heavy.desc"
}
}
/// Inline guideline injected into the LLM prompt. The polish
/// service appends this verbatim so the LLM has an explicit,
/// non-ambiguous constraint per call.
public var promptGuideline: String {
switch self {
case .off:
return "Do not change the input at all. Output the original text verbatim."
case .light:
return "Only remove isolated filler words (嗯, 呃, 那个, 就是, 然后, 对, ok) and obvious duplicated fragments. Do not change any other words, word order, or punctuation."
case .medium:
return "Correct obvious speech-recognition errors (homophones, missing/extra characters). Remove filler words and duplicated fragments. Adjust obviously-broken word order. Add punctuation. Do not restructure sentences, invent facts, or change the speaker's voice."
case .heavy:
return "Apply medium corrections, then optionally restructure: split long sentences, auto-number enumerated items into markdown lists, group related ideas into paragraphs. Preserve every fact, number, and proper noun."
}
}
}
extension PolishIntensity {
/// Default for new installs. `medium` is what Typeless and Wispr
/// Flow also use as their first-run default.
public static let `default`: PolishIntensity = .medium
}
@@ -53,6 +53,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
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"
}
@Published public var providerId: String {
@@ -201,6 +203,14 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
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
/// only" regardless of `engineMode`.
@Published public var polishIntensity: PolishIntensity {
didSet { defaults.set(polishIntensity.rawValue, forKey: Key.polishIntensity) }
}
public var isConfigured: Bool {
// Local engine (on-device ASR only) doesn't need an API key,
// base URL, or model the LLM round-trip is skipped entirely.
@@ -311,6 +321,15 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
self.handednessPreference = HandednessPreference.fromStored(
resolvedDefaults.string(forKey: Key.handednessPreference)
)
// v0.3.0: polish intensity. Default to `.medium` for new
// installs and upgrades; the existing `off` / `light` /
// `heavy` values are honored.
if let raw = resolvedDefaults.string(forKey: Key.polishIntensity),
let intensity = PolishIntensity(rawValue: raw) {
self.polishIntensity = intensity
} else {
self.polishIntensity = .default
}
// Cloud no longer exposes off/transcribe; migrate legacy values.
if self.engineMode == "cloud", self.modeId != "polish" {
@@ -0,0 +1,175 @@
// AppContextDetector.swift
// OSGKeyboard · Shared
//
// iOS Custom Keyboard Extensions run in a tight sandbox: we cannot
// read the foreground app's bundle ID, we cannot query
// `LSApplicationWorkspace`, and we cannot observe app switches.
// The only signals available to the extension are:
//
// - the text already at the cursor (`textDocumentProxy`)
// - the current keyboard input language
// - the time of day (used as a very weak signal)
//
// So we infer context with a **3-fallback chain**:
// 1. **Heuristic on preceding text** strongest signal when the
// user has already typed enough. Catches code, email, chat,
// and document. We only look at the tail of the preceding
// text (up to `precedingScanWindow` characters) so a long
// note does not spend cycles scanning the whole buffer.
// 2. **Cached value** when the user just opened a new field
// with no preceding text, reuse the last detection for up to
// `cacheLifetime`. Most users type in the same app for a
// while; this avoids a cold-start `unknown` that would force
// a neutral-tone LLM call.
// 3. **Environmental fallback** when both above miss, blend
// input language + hour-of-day into a soft default.
//
// Anything we cannot resolve maps to `.unknown`, which the polish
// service translates to a neutral-tone prompt.
import Foundation
public struct AppContextDetector: Sendable {
/// How many characters of the preceding text we scan for
/// heuristic matches. Long enough to capture a code block, a
/// mail header, or a chat thread; short enough to scan in O(n)
/// on every keystroke.
public let precedingScanWindow: Int
/// How long a cached detection stays valid. 30 minutes matches
/// the "typical typing session" length and means the cache
/// rarely outlives a switch to a genuinely new app.
public let cacheLifetime: TimeInterval
public init(
precedingScanWindow: Int = 2000,
cacheLifetime: TimeInterval = 30 * 60
) {
self.precedingScanWindow = precedingScanWindow
self.cacheLifetime = cacheLifetime
}
public func detect(
precedingText: String?,
storedCache: (context: AppContext, observedAt: Date)?,
now: Date = Date()
) -> AppContext {
// Fallback 1: heuristic on preceding text. Even one strong
// signal (indented line ending with `{`, `> ` quote,
// email pattern) is enough we never mix-and-match.
if let preceding = precedingText, !preceding.isEmpty,
let detected = heuristicDetect(preceding: preceding) {
return detected
}
// Fallback 2: cache. We rely on the caller having written
// a fresh detection to the App Group on every successful
// pressBegan; we just consult the timestamp here.
if let cached = storedCache,
now.timeIntervalSince(cached.observedAt) < cacheLifetime {
return cached.context
}
// Fallback 3: environmental. Not great, but better than
// `unknown` for a polished experience.
return environmentalFallback(now: now)
}
// MARK: - Heuristic detection
/// Inspect the tail of the preceding text. The order of the
/// branches is significant: more specific signals first (code,
/// terminal) so they win over more generic ones (chat,
/// document).
internal func heuristicDetect(preceding: String) -> AppContext? {
let tail = preceding.suffix(precedingScanWindow)
guard !tail.isEmpty else { return nil }
// Code: indented line + a code-y keyword in the recent past.
// The two-condition test avoids false positives on indented
// lists / block quotes.
let codeKeywords = [
"func ", "class ", "struct ", "enum ", "protocol ",
"import ", "package ", "namespace ",
"def ", "var ", "let ", "const ",
"if (", "if (", "} else", "} catch",
"=> {", "-> {",
]
let hasIndentation = tail.contains(where: { $0 == "\n " || $0 == "\t" })
let hasCodeKeyword = codeKeywords.contains(where: { tail.contains($0) })
if hasIndentation, hasCodeKeyword {
return .code
}
// Code: shebang / single-line comment / URL-with-query.
if tail.hasPrefix("#!/") || tail.contains("\n#!/") {
return .code
}
// Terminal: prompt markers (rough but rarely wrong on
// dedicated terminal apps). `$ `, `# `, ` `, ` `.
if tail.range(of: #"(^|\n)[$#❯➜] "#, options: .regularExpression) != nil {
return .code
}
// Email: contains an email-shaped token in the recent past.
// We deliberately keep the regex conservative to avoid
// matching every "@" in code / handles.
if tail.range(
of: #"\b[\w.+-]+@[\w-]+\.[A-Za-z]{2,}\b"#,
options: .regularExpression
) != nil {
return .email
}
// Email: subject-style opening "Subject:", "To:", "From:",
// "Cc:", or common CN mail domains in the URL bar.
let emailOpeners = ["Subject:", "Re: ", "Fwd: ", "From:", "To:"]
if emailOpeners.contains(where: { tail.contains($0) }) {
return .email
}
// Chat: lots of short lines, no big paragraphs.
let lines = tail.split(separator: "\n", omittingEmptySubsequences: false)
.suffix(20)
if lines.count >= 3 {
let nonEmpty = lines.filter { !$0.isEmpty }
let allShort = nonEmpty.count >= 3
&& nonEmpty.allSatisfy { $0.count < 60 }
if allShort {
return .chat
}
}
// Document: long unbroken paragraphs.
let lastParagraph = tail.split(separator: "\n\n").last ?? ""
if lastParagraph.count > 200 && !lastParagraph.contains("\n") {
return .document
}
return nil
}
// MARK: - Environmental fallback
/// Last-resort guess. Deliberately biased toward "document" /
/// "email" over "chat" because people who can no longer be
/// classified are usually writing something more formal than
/// not and the cost of over-classifying as chat is a casual
/// prompt that we can easily recover from.
internal func environmentalFallback(now: Date) -> AppContext {
let hour = Calendar.current.component(.hour, from: now)
// 9am-6pm: assume document / work context. 8pm-7am: assume
// chat. Weekends: lean chat. The signal is weak but it
// beats random.
let isWorkHours = (9...18).contains(hour)
let isWeekend = Calendar.current.isDateInWeekend(now)
if isWorkHours, !isWeekend {
return .document
}
if !isWorkHours || isWeekend {
return .chat
}
return .unknown
}
}
@@ -47,6 +47,15 @@ public struct AppGroupStore: @unchecked Sendable {
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"
// v0.3.0: last app context detected by the keyboard extension.
// Reused across calls within a 30-minute window so the LLM
// prompt remains consistent during a single typing session.
static let detectedAppContext = "config.detectedAppContext"
static let detectedAppContextAt = "config.detectedAppContextAt"
// v0.3.0: personal dictionary JSON-encoded `PersonalDictionary`.
static let personalDictionary = "config.personalDictionary.v1"
}
// MARK: - Reads
@@ -252,6 +261,72 @@ public struct AppGroupStore: @unchecked Sendable {
engineMode == "local" ? "deepseek" : nil
}
// MARK: - Polish settings (v0.3.0+)
/// How aggressively the LLM should rewrite the ASR transcript.
/// Defaults to `medium` for new installs.
public var polishIntensity: PolishIntensity {
guard let raw = defaults.string(forKey: Key.polishIntensity),
let value = PolishIntensity(rawValue: raw)
else { return .default }
return value
}
public func setPolishIntensity(_ intensity: PolishIntensity) {
defaults.set(intensity.rawValue, forKey: Key.polishIntensity)
}
// MARK: - Detected app context (v0.3.0+)
/// Last app context the keyboard extension detected for this
/// user, plus the timestamp it was observed. Callers should
/// treat values older than 30 minutes as stale.
public var detectedAppContext: (context: AppContext, observedAt: Date)? {
guard let raw = defaults.string(forKey: Key.detectedAppContext),
let value = AppContext(rawValue: raw)
else { return nil }
let timestamp = defaults.object(forKey: Key.detectedAppContextAt) as? Date ?? .distantPast
return (value, timestamp)
}
public func setDetectedAppContext(_ context: AppContext, at date: Date = Date()) {
defaults.set(context.rawValue, forKey: Key.detectedAppContext)
defaults.set(date, forKey: Key.detectedAppContextAt)
}
// MARK: - Personal dictionary (v0.3.0+)
/// Personal dictionary persisted in the App Group so both the
/// main app's Settings UI and the keyboard extension's LLM call
/// read the same source of truth. Returns an empty dictionary
/// when nothing is stored (and when the stored JSON is corrupt
/// failing closed is safer than crashing the keyboard).
public var personalDictionary: PersonalDictionary {
get {
guard let data = defaults.data(forKey: Key.personalDictionary) else {
return .empty
}
do {
return try JSONDecoder().decode(PersonalDictionary.self, from: data)
} catch {
#if DEBUG
print("⚠️ [AppGroupStore] personalDictionary decode failed: \(error)")
#endif
return .empty
}
}
set {
do {
let data = try JSONEncoder().encode(newValue)
defaults.set(data, forKey: Key.personalDictionary)
} catch {
#if DEBUG
print("⚠️ [AppGroupStore] personalDictionary encode failed: \(error)")
#endif
}
}
}
// MARK: - Client
public func makeClient() -> LLMClient {
+198 -48
View File
@@ -1,12 +1,16 @@
// PolishingService.swift
// OSGKeyboard · Shared
//
// Takes raw ASR transcript and runs it through the user's configured LLM
// to produce polished, well-punctuated text. Falls back to the raw transcript
// if the LLM call fails or times out.
// v0.3.0 rewrite: one-step "intelligent" polish that combines ASR
// error correction, filler removal, and tone adaptation in a single
// LLM call. The previous design was two separate steps (correction
// then polish) which doubled latency and token cost; Typeless,
// Wispr Flow, and the "intelligent" rewrite literature all confirm
// the merged prompt performs just as well for everyday Chinese /
// English dictation while halving the network round-trip.
//
// Engine matrix:
// - `engineMode == "cloud"` always polish (cloud engine's whole point).
// - `engineMode == "cloud"` always polish
// - `engineMode == "local"`,
// cloud polish disabled ASR-only, return raw.
// - `engineMode == "local"`,
@@ -14,6 +18,23 @@
// 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
//
// 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)
// - `precedingText` optional tail of the cursor's preceding text
// for reference resolution
//
// The prompt is intentionally a single message; multi-message
// conversation history would let earlier hallucinations pollute
// later calls (see MIT 2026 "Do LLMs Benefit From Their Own Words?")
// and the user expectation is that each take is independent.
import Foundation
@@ -57,44 +78,78 @@ public actor PolishingService {
self.timeout = timeout ?? (LLMClientFactory.defaultRequestTimeout + 1)
}
/// v0.2.1 follow-up: `providerIdOverride` lets callers pin the
/// remote polish step to a specific provider (the local engine
/// pins to DeepSeek regardless of the user's chosen cloud
/// provider). Pass `nil` to honor `store.providerId` as before.
/// 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
/// flow should keep using the override prompt / providerId
/// overloads exposed by the host.
public func polish(
_ raw: String,
mode: PolishMode = .polish,
systemPrompt: String? = nil,
providerIdOverride: String? = nil
providerIdOverride: String? = nil,
context: PolishContext? = nil
) async throws -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
// Local engine: ASR-only unless cloud polish is enabled
// (translation is a sub-option of that LLM step).
// 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
}
// Local engine + cloud-polish-off: pure ASR, no LLM.
if store.engineMode == "local" {
guard store.shouldRunCloudLLMStep else { return trimmed }
return try await polishRemote(
trimmed,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride
)
} else {
// Cloud engine needs an API key.
guard !store.apiKey.isEmpty else {
throw PolishError.missingAPIKey
}
}
return try await polishRemote(
trimmed,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride
providerIdOverride: providerIdOverride,
context: resolvedContext
)
}
/// 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(
appContext: store.detectedAppContext?.context ?? .unknown,
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
}
private func polishRemote(
_ trimmed: String,
mode: PolishMode,
systemPrompt: String? = nil,
providerIdOverride: String? = nil
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
@@ -127,11 +182,29 @@ public actor PolishingService {
}
client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model)
}
let prompt = resolvedSystemPrompt(
for: mode,
override: systemPrompt,
providerId: effectiveProviderId
// 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)
case .translate(let targetLocaleId):
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
prompt = TranslationPrompt.make(
target: target,
providerId: effectiveProviderId,
scenarioId: store.polishScenarioId,
uiLanguage: store.uiLanguage
)
}
}
let budget = effectiveTimeout(for: trimmed)
return try await withThrowingTaskGroup(of: String.self) { group in
@@ -148,32 +221,109 @@ public actor PolishingService {
}
}
/// v0.2.1: pick the right system prompt for the requested mode.
/// Translation mode swaps in the parameterized translate-and-polish
/// prompt (see `TranslationPrompt.make`); polish mode keeps the
/// existing `store.systemPrompt` behaviour so every other call site
/// is byte-identical to before. An explicit `override` wins over
/// both paths so callers (and tests) can pin a specific prompt.
private func resolvedSystemPrompt(
for mode: PolishMode,
override: String? = nil,
providerId: String? = nil
) -> String {
if let override, !override.isEmpty {
return override
/// 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 the existing per-provider
/// default system prompt in `AppGroupStore.defaultSystemPrompt(for:)`
/// so the polish step stays in the user's chosen output language.
internal func buildPrompt(for text: String, context: PolishContext) -> String {
let dictionary = store.personalDictionary
let dictionaryBlock = dictionary.promptFragment()
let contextGuideline = context.appContext.polishGuideline
let intensityGuideline = context.intensity.promptGuideline
let precedingBlock = context.precedingForPrompt
.map { "上文(仅供参考,**不要**改写):\n\($0)\n" } ?? ""
let useChinese = shouldUseChineseGuidance(providerId: store.providerId)
if useChinese {
return """
## 1
-
-
- ****
## 2
- ok
-
-
-
## 3
\(context.appContext.rawValue)
\(contextGuideline)
\(intensityGuideline)
##
1. ****
2.
3.
4. 8 15
5.
\(dictionaryBlock.isEmpty ? "" : "## 用户词典(必须原样保留,禁止改写)\n\(dictionaryBlock)\n")
\(precedingBlock)
##
\(text)
****
"""
} else {
return """
You are the post-processing engine of a voice-input keyboard. Complete three tasks in one pass:
## 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 3: Style adaptation
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
\(text)
Output the processed text directly. **No explanation, no quotes, no preamble.**
"""
}
switch mode {
case .polish:
return store.resolvedPolishSystemPrompt(providerId: providerId)
case .translate(let targetLocaleId):
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
let pid = providerId ?? store.providerId
return TranslationPrompt.make(
target: target,
providerId: pid,
scenarioId: store.polishScenarioId,
uiLanguage: store.uiLanguage
)
}
/// 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.
private func shouldUseChineseGuidance(providerId: String) -> Bool {
switch providerId {
case "zhipu", "moonshot", "qwen", "deepseek":
return true
default:
return false
}
}
+30
View File
@@ -48,3 +48,33 @@
"polishScenario.chip.document" = "Doc";
"polishScenario.chip.todo" = "TODO";
"polishScenario.chip.custom" = "Custom";
/* v0.3.0: Polish intensity picker */
"polish.intensity.off" = "Off";
"polish.intensity.light" = "Light";
"polish.intensity.medium" = "Medium";
"polish.intensity.heavy" = "Heavy";
"polish.intensity.off.desc" = "Insert the raw ASR transcript with no LLM call.";
"polish.intensity.light.desc" = "Drop only isolated filler words and obvious duplications.";
"polish.intensity.medium.desc" = "Correct recognition errors, remove fillers, polish phrasing. Default.";
"polish.intensity.heavy.desc" = "Restructure into lists and paragraphs. Best for meeting notes and reports.";
/* v0.3.0: Detected app context labels */
"appContext.code" = "Code";
"appContext.email" = "Email";
"appContext.chat" = "Chat";
"appContext.document" = "Document";
"appContext.unknown" = "General";
/* v0.3.0: Personal dictionary categories */
"dict.category.properNoun" = "Names & places";
"dict.category.technical" = "Technical terms";
"dict.category.acronym" = "Acronyms";
"dict.category.productName" = "Product names";
"dict.category.custom" = "Custom";
/* v0.3.0: Personal dictionary sources */
"dict.source.manual" = "Manual";
"dict.source.history" = "Auto-learned";
"dict.source.contacts" = "From Contacts";
"dict.source.recentEdit" = "From recent edit";
@@ -48,3 +48,33 @@
"polishScenario.chip.document" = "文档";
"polishScenario.chip.todo" = "TODO";
"polishScenario.chip.custom" = "自定义";
/* v0.3.0: 润色档位 */
"polish.intensity.off" = "关闭";
"polish.intensity.light" = "轻度";
"polish.intensity.medium" = "中度";
"polish.intensity.heavy" = "深度";
"polish.intensity.off.desc" = "不调用 LLM,直接插入识别原文。";
"polish.intensity.light.desc" = "仅清除孤立语气词和重复口误。";
"polish.intensity.medium.desc" = "纠正识别错误、清除语气词、润色语句。推荐默认。";
"polish.intensity.heavy.desc" = "可重组段落、拆长句、自动编号。适合会议纪要与报告。";
/* v0.3.0: 输入场景标签 */
"appContext.code" = "代码";
"appContext.email" = "邮件";
"appContext.chat" = "聊天";
"appContext.document" = "长文";
"appContext.unknown" = "通用";
/* v0.3.0: 词库类别 */
"dict.category.properNoun" = "人名地名";
"dict.category.technical" = "技术名词";
"dict.category.acronym" = "缩写";
"dict.category.productName" = "产品名";
"dict.category.custom" = "自定义";
/* v0.3.0: 词库来源 */
"dict.source.manual" = "手动添加";
"dict.source.history" = "自动学习";
"dict.source.contacts" = "来自通讯录";
"dict.source.recentEdit" = "来自最近编辑";
@@ -0,0 +1,282 @@
// IntelligentPolishTests.swift
// OSGKeyboard · Tests
//
// v0.3.0: locks the behavior of the rewritten PolishingService and
// its two supporting services (AppContextDetector, DictionaryLearner).
// The tests are deliberately hermetic no LLMClient, no ASR, no
// App Group so they run in <100 ms total.
import XCTest
@testable import OSGKeyboard
@testable import OSGKeyboardShared
final class IntelligentPolishTests: XCTestCase {
private var suiteName: String!
private var defaults: UserDefaults!
private var store: AppGroupStore!
override func setUp() {
super.setUp()
// Each test gets a fresh, throwaway UserDefaults suite so
// engine mode / API key / dictionary / context state does
// not leak between tests. The AppGroupStore falls back to
// `.standard` when no App Group entitlement is present, so
// we point it at a private suite to keep this test hermetic.
suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
store = AppGroupStore(defaults: defaults)
}
override func tearDown() {
defaults.removePersistentDomain(forName: suiteName)
super.tearDown()
}
// MARK: - PolishingService prompt construction
func testPolishServiceOffIntensitySkipsLLM() async throws {
// When intensity is `.off`, the service must return the
// raw input unchanged *and* not touch the LLM. We assert
// both by passing a deliberately broken LLM client and
// expecting the call to return cleanly.
store.setEngineMode("cloud")
let service = PolishingService(
store: store,
client: ThrowingLLMClient() // would throw if invoked
)
let result = try await service.polish("hello world", context: PolishContext(intensity: .off))
XCTAssertEqual(result, "hello world")
}
func testPolishServiceLocalEngineWithoutCloudPolishReturnsRaw() async throws {
store.setEngineMode("local")
// localModeCloudPolishEnabled defaults to false.
let service = PolishingService(
store: store,
client: ThrowingLLMClient()
)
let result = try await service.polish("hello world", context: PolishContext(intensity: .medium))
XCTAssertEqual(result, "hello world")
}
func testPolishServiceMissingAPIKeyThrows() async {
store.setEngineMode("cloud")
let service = PolishingService(
store: store,
client: EchoLLMClient()
)
do {
_ = try await service.polish("hello world", context: PolishContext(intensity: .medium))
XCTFail("Expected missingAPIKey")
} catch let error as PolishingService.PolishError {
XCTAssertEqual(error, .missingAPIKey)
} catch {
XCTFail("Expected PolishError, got \(error)")
}
}
func testPolishServiceShortTextSkipsLLM() async throws {
// Per the prompt's hard rule #4, 8 CJK chars / 15
// English words must be returned verbatim. We exercise
// the upper bound here.
store.setEngineMode("cloud")
let service = PolishingService(
store: store,
client: ThrowingLLMClient()
)
let result = try await service.polish("明天见", context: PolishContext(intensity: .heavy))
XCTAssertEqual(result, "明天见")
}
func testPolishServiceBuildsPromptWithDictionaryAndContext() async throws {
store.setEngineMode("cloud")
store.personalDictionary = PersonalDictionary(entries: [
PersonalDictionary.Entry(
term: "Kubernetes", category: .productName, source: .manual
),
])
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
_ = try await service.polish(
"今天我们部署 k8s 集群",
context: PolishContext(appContext: .code, intensity: .medium)
)
XCTAssertTrue(captured.lastPrompt.contains("Kubernetes"),
"Prompt must include dictionary term. Got: \(captured.lastPrompt)")
XCTAssertTrue(captured.lastPrompt.contains("Code context"),
"Prompt must include app-context guideline. Got: \(captured.lastPrompt)")
XCTAssertTrue(captured.lastPrompt.contains("medium") || captured.lastPrompt.contains("中度"),
"Prompt must mention the intensity. Got: \(captured.lastPrompt)")
}
func testPolishServiceUsesChineseForChineseProviders() async throws {
defaults.set("deepseek", forKey: "config.providerId")
store.setEngineMode("cloud")
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
_ = try await service.polish("hello", context: PolishContext(intensity: .medium))
// The polisher routes Chinese providers through the Chinese
// prompt, which is identifiable by its "" header.
XCTAssertTrue(
captured.lastPrompt.contains("三件事"),
"DeepSeek should get the Chinese prompt. Got prefix: \(captured.lastPrompt.prefix(80))"
)
}
// MARK: - AppContextDetector
func testAppContextDetectorRecognizesCodeByIndentation() {
let detector = AppContextDetector()
let text = """
import Foundation
struct Foo {
func bar() -> Int {
return 42
}
}
"""
XCTAssertEqual(detector.heuristicDetect(preceding: text), .code)
}
func testAppContextDetectorRecognizesEmail() {
let detector = AppContextDetector()
let text = "Hi Rocky,\n\nFollowing up on rocky.hk@gmail.com thread — can you sign off by Friday?\n\nThanks,\nLily"
XCTAssertEqual(detector.heuristicDetect(preceding: text), .email)
}
func testAppContextDetectorRecognizesChat() {
let detector = AppContextDetector()
let text = "ok\nlol\nsee you tmr\nbrb\nbbl\nk\nthx"
XCTAssertEqual(detector.heuristicDetect(preceding: text), .chat)
}
func testAppContextDetectorRecognizesDocument() {
let detector = AppContextDetector()
let text = String(repeating: "The quick brown fox jumps over the lazy dog. ", count: 30)
XCTAssertEqual(detector.heuristicDetect(preceding: text), .document)
}
func testAppContextDetectorReturnsNilOnEmpty() {
let detector = AppContextDetector()
XCTAssertNil(detector.heuristicDetect(preceding: ""))
}
func testAppContextDetectorFallbackChain() {
let detector = AppContextDetector()
// No preceding text and no cache environmental fallback.
let env = detector.detect(
precedingText: nil,
storedCache: nil,
now: Date(timeIntervalSince1970: 1_700_000_000) // a workday moment
)
XCTAssertNotEqual(env, .unknown)
}
func testAppContextDetectorCacheWinsOverFallback() {
let detector = AppContextDetector()
// 5-minute-old cache with `.code` must be returned even
// when there is no preceding text.
let cache = (context: AppContext.code, observedAt: Date().addingTimeInterval(-300))
let result = detector.detect(precedingText: "", storedCache: cache)
XCTAssertEqual(result, .code)
}
// MARK: - PersonalDictionary.promptFragment
func testDictionaryPromptFragmentIsEmptyForEmptyDictionary() {
let prompt = PersonalDictionary.empty.promptFragment()
XCTAssertEqual(prompt, "")
}
func testDictionaryPromptFragmentGroupsByCategory() {
let dict = PersonalDictionary(entries: [
PersonalDictionary.Entry(term: "Kubernetes", category: .productName, source: .manual),
PersonalDictionary.Entry(term: "iOS", category: .acronym, source: .manual),
PersonalDictionary.Entry(term: "Rocky", category: .properNoun, source: .manual),
])
let prompt = dict.promptFragment()
XCTAssertTrue(prompt.contains("Kubernetes"))
XCTAssertTrue(prompt.contains("iOS"))
XCTAssertTrue(prompt.contains("Rocky"))
}
// MARK: - DictionaryLearner
func testLearnerPromotesRepeatedCapitalizedToken() {
let history: [SpeechHistoryEntry] = [
.init(text: "Deploy Kubernetes today", engineMode: "cloud"),
.init(text: "Restart Kubernetes pod", engineMode: "cloud"),
]
let learner = DictionaryLearner(minOccurrences: 2)
let added = learner.learn(from: history)
XCTAssertTrue(added.contains { $0.term == "Kubernetes" },
"Kubernetes should be promoted. Got: \(added.map(\.term))")
}
func testLearnerIgnoresStopwords() {
let history: [SpeechHistoryEntry] = [
.init(text: "this is the test", engineMode: "cloud"),
.init(text: "this is the second test", engineMode: "cloud"),
.init(text: "this is the third test", engineMode: "cloud"),
]
let learner = DictionaryLearner(minOccurrences: 2)
let added = learner.learn(from: history)
let terms = Set(added.map(\.term))
XCTAssertFalse(terms.contains("this"))
XCTAssertFalse(terms.contains("the"))
XCTAssertFalse(terms.contains("is"))
}
func testLearnerRespectsMinimumOccurrence() {
let history: [SpeechHistoryEntry] = [
.init(text: "First time mentioning Whisper", engineMode: "cloud"),
]
let learner = DictionaryLearner(minOccurrences: 2)
let added = learner.learn(from: history)
XCTAssertFalse(added.contains { $0.term == "Whisper" })
}
func testLearnerIdempotent() {
let history: [SpeechHistoryEntry] = [
.init(text: "OpenAI rocks", engineMode: "cloud"),
.init(text: "OpenAI again", engineMode: "cloud"),
]
let learner = DictionaryLearner(minOccurrences: 2)
let first = learner.learn(from: history)
let second = learner.learn(from: history)
XCTAssertTrue(first.contains { $0.term == "OpenAI" })
// Second call must not double-add; the existing entry's
// usage count is bumped instead.
let openaiEntries = second.filter { $0.term == "OpenAI" }
XCTAssertEqual(openaiEntries.count, 1)
}
}
// MARK: - Test doubles
/// Records every call so the test can inspect the prompt the
/// polisher would have sent. We do not assert on `response`; the
/// LLMClient contract is exercised by `LLMClientTests`.
private final class CapturingLLMClient: LLMClient, @unchecked Sendable {
private(set) var lastPrompt: String = ""
let requestTimeout: TimeInterval = 15
func polish(_ text: String, systemPrompt: String) async throws -> String {
lastPrompt = systemPrompt
return text
}
}
private final class EchoLLMClient: LLMClient, @unchecked Sendable {
let requestTimeout: TimeInterval = 15
func polish(_ text: String, systemPrompt: String) async throws -> String { text }
}
private final class ThrowingLLMClient: LLMClient, @unchecked Sendable {
let requestTimeout: TimeInterval = 15
func polish(_ text: String, systemPrompt: String) async throws -> String {
throw LLMError.cancelled
}
}