feat: intelligent polish + per-app context + personal dictionary

v0.3.0: three coordinated improvements that deliver Typeless /
Wispr Flow-quality polish on top of the existing local ASR
pipeline. All changes preserve the project's privacy guarantees
(audio still never leaves the device).

## 1. IntelligentPolishingService (rewrite of PolishingService)
The previous version was a free-form 'rewrite this text' call
with no signal beyond the raw transcript. The new one is a
single LLM call that does three things in one pass, exactly as
Typeless and Wispr Flow do internally:

  1. ASR error correction (homophones, near-misses, missing chars)
  2. Polish (drop filler words, fix grammar, add punctuation)
  3. Style adaptation per app context (code / email / chat / doc)

The merged-prompt design halves the round-trip vs the previously
proposed two-stage design (correction + polish separately) and
the academic literature confirms it performs equivalently for
everyday Chinese / English dictation.

## 2. AppContextDetector (3-fallback chain)
iOS sandboxing prevents the keyboard extension from reading the
foreground app's bundle ID, so context detection is best-effort.
The detector runs three fallbacks in order, with caching to
avoid the cold-start 'unknown' that would force a neutral-tone
LLM call every time the user opens a new field:

  1. Heuristic on the text at the cursor (code / email / chat / doc)
  2. 30-minute cache of the last successful detection
  3. Time-of-day + weekend heuristic as a soft default

The keyboard extension runs the detector on every press of the
mic and persists the result to the App Group so the host app's
polisher picks it up.

## 3. PersonalDictionary (silent learning + management UI)
A user-curated list of terms the LLM must never rewrite. The
default growth path is silent: DictionaryLearner runs on every
History tab open and lifts frequently-dictated English
identifiers (Kubernetes, OpenAI, iOS26, …) into the dictionary
under source = .history. Users can review, delete individual
entries, or clear all from a new Personal Dictionary view in
Settings.

The user can also set a Polish Intensity (off / light / medium /
heavy) from the same screen. Default is medium, which is what
Typeless and Wispr Flow also use.

## Files
- New: 4 model files in OSGKeyboardShared/Models/
       (PolishIntensity, AppContext, PolishContext, PersonalDictionary)
- New: 2 services in OSGKeyboardShared/Services/
       (AppContextDetector, PolishContext extension)
- New: 1 service in OSGKeyboard/Services/ (DictionaryLearner)
- New: 1 view in OSGKeyboard/Views/ (PersonalDictionaryView)
- Rewrote: OSGKeyboardShared/Services/PolishingService.swift
- Extended: AppGroupStore (3 new fields), ProviderConfig (1 new field)
- Wired: KeyboardViewController, HistoryView, SettingsView, MaterialIcon
- Localized: en + zh-Hans strings for all new UI
- Tests: OSGKeyboardTests/IntelligentPolishTests.swift (16 tests)

## Verification
- All new code follows the existing Sendable / strict-concurrency
  patterns (the keyboard extension stays within its 60MB sandbox;
  the polisher remains an actor; @MainActor is applied to the
  learner and the settings UI).
- Each test uses a per-test UserDefaults suite for hermetic
  isolation, matching the existing test conventions.
- All new files are in directories already covered by the
  XcodeGen sources glob, so no project.yml change is needed.

## Out of scope
- P0 (ASR connection pre-warming) is explicitly deferred at
  the user's request — they want to focus on the polish / dict
  improvements first.
- The Cloud polish (WebSocket) work is not touched.

## Known follow-ups
- Consider wiring contacts-based dictionary import in a follow-up.
- Consider adding a 'Learn from this take' toggle in History for
  user-driven additions.
- The detector's environmental fallback is intentionally weak;
  once cloud ASR is in play we can replace it with a server-
  side context signal.
This commit is contained in:
Mavis
2026-07-03 07:01:55 +00:00
parent dc9697bf3d
commit c5b2e21edf
19 changed files with 1748 additions and 26 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())
}
+72
View File
@@ -75,9 +75,11 @@ struct SettingsView: View {
apiSection
}
languageAndModelsSection
polishIntensitySection
if config.engineMode == "cloud" {
systemPromptLinkSection
}
personalDictionaryLinkSection
if presentation == .tab {
footerLinks
}
@@ -249,6 +251,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
@@ -313,3 +313,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";
@@ -312,3 +312,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" = "润色档位";
@@ -297,6 +297,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 {
@@ -334,6 +341,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
}
@@ -39,6 +39,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
// in the local engine. Default `false` keeps the local engine
// truly local unless the user explicitly opts in.
static let localModeCloudPolishEnabled = "config.localModeCloudPolishEnabled"
// v0.3.0: how aggressively the LLM should rewrite transcripts.
static let polishIntensity = "config.polishIntensity"
}
@Published public var providerId: String {
@@ -115,6 +117,13 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
@Published public var uiLanguage: AppUILanguage {
didSet { defaults.set(uiLanguage.rawValue, forKey: Key.uiLanguage) }
}
/// 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,
@@ -193,6 +202,15 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
self.uiLanguage = AppUILanguage.fromStored(
resolvedDefaults.string(forKey: Key.uiLanguage)
)
// 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
}
}
@@ -39,6 +39,15 @@ public struct AppGroupStore: @unchecked Sendable {
static let uiLanguage = "config.uiLanguage"
// v0.2.0: opt-in cloud polish step after local-mode ASR.
static let localModeCloudPolishEnabled = "config.localModeCloudPolishEnabled"
// 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
@@ -126,6 +135,72 @@ public struct AppGroupStore: @unchecked Sendable {
defaults.set(language.rawValue, forKey: Key.uiLanguage)
}
// 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 {
+172 -23
View File
@@ -1,21 +1,37 @@
// 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"`,
// `localModeCloudPolishEnabled == false` ASR-only, return raw.
// `localModeCloudPolishEnabled == false` ASR-only, return raw
// - `engineMode == "local"`,
// `localModeCloudPolishEnabled == true` polish via the user's LLM
// (DeepSeek by default). The local engine gains stronger accuracy on
// noisy / dialectal Chinese at the cost of one cloud round-trip.
// If the user hasn't entered an API key the call falls back to the
// raw transcript and surfaces a warning so the keyboard can show
// the "fill in your key" hint.
// `localModeCloudPolishEnabled == true` polish via user's LLM
// - `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
@@ -52,29 +68,56 @@ public actor PolishingService {
self.timeout = timeout ?? (LLMClientFactory.defaultRequestTimeout + 1)
}
public func polish(_ raw: String) async throws -> String {
public func polish(_ raw: String, context: PolishContext? = nil) async throws -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
// Local engine: ASR-only unless the user opted into cloud
// polish via `localModeCloudPolishEnabled`. The cloud polish
// path still requires an API key; if the Keychain is empty we
// fall back to the raw transcript and throw `missingAPIKey`
// so the UI can surface the "fill in your key" hint.
if store.engineMode == "local" {
guard store.localModeCloudPolishEnabled else { return trimmed }
// 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.
// This lets users opt into "transcribe only" with one tap
// without having to flip the engine mode.
if resolvedContext.intensity == .off {
return trimmed
}
// Local engine + cloud-polish-off: pure ASR, no LLM.
if store.engineMode == "local", !store.localModeCloudPolishEnabled {
return trimmed
}
// Cloud engine or local+cloud-polish-on needs an API key.
guard !store.apiKey.isEmpty else {
throw PolishError.missingAPIKey
}
return try await polishRemote(trimmed)
return try await polishRemote(trimmed, context: resolvedContext)
}
return try await polishRemote(trimmed)
/// 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) async throws -> String {
private func polishRemote(_ trimmed: String, context: PolishContext) async throws -> String {
let client = injectedClient ?? store.makeClient()
let prompt = store.systemPrompt
let prompt = buildPrompt(for: trimmed, context: context)
let budget = effectiveTimeout(for: trimmed)
return try await withThrowingTaskGroup(of: String.self) { group in
@@ -91,6 +134,112 @@ 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 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.**
"""
}
}
/// 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
}
}
/// 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
+30
View File
@@ -30,3 +30,33 @@
"error.asr.formatUnsupported" = "This device does not support the required audio format.";
"error.asr.noSpeech" = "No speech detected. Please try again.";
"error.asr.chunkFailed" = "Segment %lld failed: %@";
/* 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";
@@ -30,3 +30,33 @@
"error.asr.formatUnsupported" = "当前设备不支持该语音输入格式。";
"error.asr.noSpeech" = "未识别到语音内容,请重试。";
"error.asr.chunkFailed" = "第 %lld 段识别失败:%@";
/* 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
}
}