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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -326,3 +326,21 @@
|
||||
"settings.models.confirm.body %lld" = "This download uses about %lld MB on disk. We recommend Wi‑Fi 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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user