feat(dictionary): add iCloud KVS sync, cloud ASR, and lexicon expansion

Mirror the personal dictionary through iCloud Key-Value Store with
deterministic merge rules, main-app-only sync UI, and App Group as the
keyboard runtime cache. Add cloud-engine ASR with dictionary bias and
expand the bundled custom language model lexicon.
This commit is contained in:
Rocky
2026-07-06 22:44:14 +08:00
parent 07df14b546
commit bf844caa7f
40 changed files with 5692 additions and 106 deletions
+2
View File
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)com.osgkeyboard.ios</string>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.com.osgkeyboard.shared</string> <string>group.com.osgkeyboard.shared</string>
@@ -1,21 +1,25 @@
{ {
"version": "v1", "version": "v1",
"name": "ai-tech-brands", "name": "ai-tech-brands",
"generated_at": "2026-07-05T11:34:08.462301+00:00", "generated_at": "2026-07-06T13:10:52.972532+00:00",
"locale": "zh-Hans", "locale": "zh-Hans",
"entry_count": 1259, "entry_count": 3040,
"seed_file": "Scripts/lexicon/seeds/ai_tech_brands_seed.tsv", "seed_file": "Scripts/lexicon/seeds/ai_tech_brands_seed.tsv",
"license": "MIT (curated seed; OSGKeyboard contributors)", "license": "MIT (curated seed; OSGKeyboard contributors)",
"categories": { "categories": {
"ai_brand": 169, "ai_brand": 284,
"ai_model": 90, "ai_model": 152,
"ai_platform": 53, "ai_platform": 116,
"ai_term": 197, "ai_term": 270,
"dev_tool": 283, "athlete": 73,
"fintech": 7, "celebrity": 251,
"tech_company": 314, "consumer_brand": 215,
"tech_leader": 37, "dev_tool": 330,
"tech_term": 109 "fintech": 109,
"game_ip": 70,
"tech_company": 601,
"tech_leader": 202,
"tech_term": 367
}, },
"notes": [ "notes": [
"Curated bilingual AI brands, tech companies, terminology, and hot words.", "Curated bilingual AI brands, tech companies, terminology, and hot words.",
File diff suppressed because it is too large Load Diff
@@ -39,7 +39,7 @@ final class FlowSessionManager: ObservableObject {
private var sessionASRWarmedLocaleID: String? private var sessionASRWarmedLocaleID: String?
private var asr: ASRService { private var asr: ASRService {
if let sessionASR { return sessionASR } if let sessionASR { return sessionASR }
let service = ASRServiceFactory.make() let service = ASRServiceFactory.make(store: store)
sessionASR = service sessionASR = service
return service return service
} }
@@ -342,7 +342,7 @@ final class FlowSessionManager: ObservableObject {
return return
} }
sessionASR?.cancel() sessionASR?.cancel()
sessionASR = ASRServiceFactory.make() sessionASR = ASRServiceFactory.make(store: store)
sessionASREngineMode = engineMode sessionASREngineMode = engineMode
sessionASRWarmedLocaleID = nil sessionASRWarmedLocaleID = nil
} }
+15 -3
View File
@@ -140,7 +140,8 @@ struct HomeView: View {
} }
private var headerGradientColors: [Color] { private var headerGradientColors: [Color] {
if sessionIsLive { // API Key
if sessionIsLive, !needsCloudSetup {
return [ return [
palette.accent.opacity(0.28), palette.accent.opacity(0.28),
palette.accent.opacity(0.10), palette.accent.opacity(0.10),
@@ -175,7 +176,14 @@ struct HomeView: View {
.fill(flowStatusColor) .fill(flowStatusColor)
.frame(width: 6, height: 6) .frame(width: 6, height: 6)
if flowManager.isActive, if needsCloudSetup {
// API Key / /
Text("home.flow.notReady")
.font(TypeStyle.caption2)
.foregroundStyle(palette.warning)
.lineLimit(1)
.minimumScaleFactor(0.85)
} else if flowManager.isActive,
let expires = flowManager.sessionExpiresAt { let expires = flowManager.sessionExpiresAt {
Text("home.flow.label") Text("home.flow.label")
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
@@ -195,7 +203,10 @@ struct HomeView: View {
.minimumScaleFactor(0.85) .minimumScaleFactor(0.85)
} }
if flowManager.isActive { if needsCloudSetup {
// API Key
EmptyView()
} else if flowManager.isActive {
Button { Button {
flowManager.endSession() flowManager.endSession()
} label: { } label: {
@@ -306,6 +317,7 @@ struct HomeView: View {
} }
private var flowStatusColor: Color { private var flowStatusColor: Color {
if needsCloudSetup { return palette.warning }
if flowManager.isActive { return palette.accent } if flowManager.isActive { return palette.accent }
if flowManager.isStarting { return palette.accent } if flowManager.isStarting { return palette.accent }
if needsPermissionSetup { return palette.warning } if needsPermissionSetup { return palette.warning }
+10 -1
View File
@@ -38,6 +38,10 @@ struct MainAppRoot: View {
.onAppear { .onAppear {
flowManager.setAppForeground(scenePhase == .active) flowManager.setAppForeground(scenePhase == .active)
flowManager.activateOnForeground() flowManager.activateOnForeground()
PersonalDictionaryCloudSync.shared.startObservingExternalChanges()
Task {
await PersonalDictionaryCloudSync.shared.pullAndMergeIfEnabled()
}
} }
.onReceive(NotificationCenter.default.publisher(for: .osgKeyboardOpenURL)) { notification in .onReceive(NotificationCenter.default.publisher(for: .osgKeyboardOpenURL)) { notification in
guard let url = notification.userInfo?["url"] as? URL else { return } guard let url = notification.userInfo?["url"] as? URL else { return }
@@ -50,9 +54,14 @@ struct MainAppRoot: View {
} }
.onChange(of: scenePhase) { _, phase in .onChange(of: scenePhase) { _, phase in
flowManager.handleScenePhase(phase) flowManager.handleScenePhase(phase)
guard phase == .active, config.hasCompletedOnboarding else { return } guard phase == .active else { return }
if config.hasCompletedOnboarding {
flowManager.activateOnForeground() flowManager.activateOnForeground()
} }
Task {
await PersonalDictionaryCloudSync.shared.pullAndMergeIfEnabled()
}
}
} }
private func handleIncomingURL(_ url: URL) { private func handleIncomingURL(_ url: URL) {
@@ -0,0 +1,99 @@
// PersonalDictionaryICloudSyncRow.swift
// OSGKeyboard · Main App
//
// Settings-row toggle for mirroring the personal dictionary through
// iCloud Key-Value Store. Lives in Settings .
import SwiftUI
import OSGKeyboardShared
@MainActor
struct PersonalDictionaryICloudSyncRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
@State private var isEnabled: Bool = AppGroupStore().personalDictionaryICloudSyncEnabled
@State private var syncErrorMessage: String?
@State private var isApplyingToggle = false
private let store = AppGroupStore()
var body: some View {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Toggle(isOn: toggleBinding) {
Text("settings.personalDictionary.iCloudSync.settingsTitle")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
}
.tint(palette.accent)
.disabled(isApplyingToggle)
if let syncErrorMessage {
Text(syncErrorMessage)
.font(TypeStyle.caption2)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, Spacing.xxs)
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .leading)
.onAppear { reloadFromStore() }
.onReceive(
NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)
) { _ in
reloadFromStore()
}
}
private var toggleBinding: Binding<Bool> {
Binding(
get: { isEnabled },
set: { newValue in
guard newValue != isEnabled else { return }
if newValue {
enableSync()
} else {
disableSync()
}
}
)
}
private func reloadFromStore() {
isEnabled = store.personalDictionaryICloudSyncEnabled
}
private func enableSync() {
isApplyingToggle = true
syncErrorMessage = nil
Task {
do {
try await PersonalDictionaryCloudSync.shared.enableSync()
reloadFromStore()
} catch let error as PersonalDictionaryCloudSyncError {
isEnabled = false
syncErrorMessage = localizedSyncError(error)
} catch {
isEnabled = false
syncErrorMessage = error.localizedDescription
}
isApplyingToggle = false
}
}
private func disableSync() {
PersonalDictionaryCloudSync.shared.disableSync()
isEnabled = false
syncErrorMessage = nil
}
private func localizedSyncError(_ error: PersonalDictionaryCloudSyncError) -> String {
switch error {
case .payloadTooLarge:
return AppL10n.string("settings.personalDictionary.iCloudSync.error.tooLarge")
case .encodeFailed, .decodeFailed:
return AppL10n.string("settings.personalDictionary.iCloudSync.error.generic")
}
}
}
+49 -53
View File
@@ -80,60 +80,48 @@ struct PersonalDictionaryView: View {
saveManualEntry(term: term, editingID: editingEntry?.id) saveManualEntry(term: term, editingID: editingEntry?.id)
} }
} }
.task {
reloadFromStore()
await PersonalDictionaryCloudSync.shared.pullAndMergeIfEnabled()
reloadFromStore()
}
.onReceive(
NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)
) { _ in
reloadFromStore()
}
} }
// MARK: - List // MARK: - List
private var list: some View { private var list: some View {
ScrollView { List {
LazyVStack(alignment: .leading, spacing: Spacing.lg) { ForEach(filteredSections, id: \.0) { _, items in
introBanner Section {
ForEach(filteredSections, id: \.0) { category, items in ForEach(items) { entry in
section(for: category, items: items)
}
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.md)
.tabBarScrollBottomPadding()
}
.searchable(text: $searchText, prompt: "settings.personalDictionary.search.prompt")
}
private var introBanner: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text("settings.personalDictionary.intro.title")
.font(TypeStyle.caption)
.foregroundStyle(palette.textPrimary)
Text("settings.personalDictionary.intro.body")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
private func section(for _: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry]) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
VStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { index, entry in
entryRow(entry) entryRow(entry)
if index < items.count - 1 { .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
Divider().background(palette.divider) .listRowBackground(palette.surface)
.listRowSeparatorTint(palette.divider)
}
.onDelete { offsets in
delete(items: items, at: offsets)
} }
} }
} }
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) }
.overlay( .listStyle(.insetGrouped)
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) .listSectionSpacing(Spacing.lg)
.stroke(palette.divider, lineWidth: 0.5) .scrollContentBackground(.hidden)
.background(palette.background)
//
.contentMargins(.top, Spacing.lg, for: .scrollContent)
.searchable(
text: $searchText,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "settings.personalDictionary.search.prompt"
) )
} .tabBarScrollBottomPadding()
} }
private func entryRow(_ entry: PersonalDictionary.Entry) -> some View { private func entryRow(_ entry: PersonalDictionary.Entry) -> some View {
@@ -187,18 +175,10 @@ struct PersonalDictionaryView: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
delete(entry)
} label: {
Label("common.delete", systemImage: "trash")
}
}
} }
private var emptyState: some View { private var emptyState: some View {
VStack(spacing: Spacing.sm) { VStack(spacing: Spacing.sm) {
Spacer()
Image(systemName: "square.stack.3d.down.right.fill") Image(systemName: "square.stack.3d.down.right.fill")
.font(.system(size: 36, weight: .regular)) .font(.system(size: 36, weight: .regular))
.foregroundStyle(palette.textTertiary.opacity(0.5)) .foregroundStyle(palette.textTertiary.opacity(0.5))
@@ -219,8 +199,11 @@ struct PersonalDictionaryView: View {
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(palette.accent) .tint(palette.accent)
.padding(.top, Spacing.sm) .padding(.top, Spacing.sm)
Spacer()
} }
// large title
//
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.padding(.bottom, 72)
} }
// MARK: - Derived data // MARK: - Derived data
@@ -285,6 +268,12 @@ struct PersonalDictionaryView: View {
persist() persist()
} }
private func delete(items: [PersonalDictionary.Entry], at offsets: IndexSet) {
for index in offsets {
delete(items[index])
}
}
private func clearAll() { private func clearAll() {
dictionary = .empty dictionary = .empty
generatingAliasEntryIDs = [] generatingAliasEntryIDs = []
@@ -294,6 +283,13 @@ struct PersonalDictionaryView: View {
private func persist() { private func persist() {
dictionary.version += 1 dictionary.version += 1
store.setPersonalDictionary(dictionary) store.setPersonalDictionary(dictionary)
Task {
try? await PersonalDictionaryCloudSync.shared.pushLocalIfEnabled(dictionary)
}
}
private func reloadFromStore() {
dictionary = store.personalDictionary
} }
} }
@@ -48,6 +48,10 @@ struct ProviderPickerSection: View {
.font(TypeStyle.body) .font(TypeStyle.body)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
if provider.supportsPersonalDictionaryCloudASR {
personalDictionaryBadge
}
Spacer(minLength: Spacing.xs) Spacer(minLength: Spacing.xs)
if selected { if selected {
@@ -80,6 +84,16 @@ struct ProviderPickerSection: View {
} }
} }
} }
/// / GLM ASR API
private var personalDictionaryBadge: some View {
Text("settings.provider.personalDictionaryBadge")
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 4)
.background(palette.accentMuted, in: Capsule())
}
} }
enum ProviderLogo { enum ProviderLogo {
+25 -9
View File
@@ -40,6 +40,7 @@ struct SettingsView: View {
ScrollView { ScrollView {
VStack(spacing: Spacing.md) { VStack(spacing: Spacing.md) {
languageAndPolishSection languageAndPolishSection
dictionaryAndPolishSection
flowSessionSection flowSessionSection
engineSection engineSection
// v0.2.1: hide provider/api card when the // v0.2.1: hide provider/api card when the
@@ -180,15 +181,6 @@ struct SettingsView: View {
Divider().background(palette.divider) Divider().background(palette.divider)
polishIntensityPreferenceRows
if config.isTranslationRowVisible {
Divider().background(palette.divider)
TranslationPickerRow(config: config, isVisible: true)
}
Divider().background(palette.divider)
cursorDragNavigationToggleRow cursorDragNavigationToggleRow
} }
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
@@ -220,6 +212,30 @@ struct SettingsView: View {
} }
} }
// MARK: - Dictionary & polish
private var dictionaryAndPolishSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.dictionaryAndPolish.title")
VStack(spacing: 0) {
polishIntensityPreferenceRows
Divider().background(palette.divider)
TranslationPickerRow(config: config, isVisible: config.isTranslationRowVisible)
Divider().background(palette.divider)
PersonalDictionaryICloudSyncRow()
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
/// v0.2.1 follow-up: dedicated section for the local engine's /// v0.2.1 follow-up: dedicated section for the local engine's
/// settings (cloud-polish toggle + translation row). Renders only /// settings (cloud-polish toggle + translation row). Renders only
/// when `engineMode == "local"` so the cloud-engine user doesn't /// when `engineMode == "local"` so the cloud-engine user doesn't
+11 -2
View File
@@ -98,8 +98,10 @@
"settings.engine.local.ios26" = "Always on-device, no network."; "settings.engine.local.ios26" = "Always on-device, no network.";
"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish."; "settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish.";
"settings.engine.cloud.title" = "Cloud recognition & polish"; "settings.engine.cloud.title" = "Cloud recognition & polish";
"settings.engine.cloud.subtitle" = "On-device ASR + polish via your API. Text is sent to the endpoint you configure."; "settings.engine.cloud.subtitle" = "Cloud ASR (with your dictionary) + API polish. Audio is sent to your provider.";
"settings.engine.cloud.badge" = "Cloud engine";
"settings.provider.title" = "Provider"; "settings.provider.title" = "Provider";
"settings.provider.personalDictionaryBadge" = "Personal dictionary";
"settings.provider.subtitle" = "Pick the LLM that polishes your dictation."; "settings.provider.subtitle" = "Pick the LLM that polishes your dictation.";
"provider.openai" = "OpenAI"; "provider.openai" = "OpenAI";
"provider.deepseek" = "DeepSeek"; "provider.deepseek" = "DeepSeek";
@@ -141,6 +143,7 @@
"settings.systemPrompt.hint" = "Used by the cloud LLM when polishing your dictation. Local engine skips this step."; "settings.systemPrompt.hint" = "Used by the cloud LLM when polishing your dictation. Local engine skips this step.";
"settings.about.title" = "About"; "settings.about.title" = "About";
"settings.preferences.title" = "Preferences"; "settings.preferences.title" = "Preferences";
"settings.dictionaryAndPolish.title" = "Dictionary & polish";
"settings.handedness.title" = "Handedness"; "settings.handedness.title" = "Handedness";
"settings.handedness.left" = "Left hand"; "settings.handedness.left" = "Left hand";
"settings.handedness.right" = "Right hand"; "settings.handedness.right" = "Right hand";
@@ -283,6 +286,7 @@
"home.flow.active" = "Voice session active"; "home.flow.active" = "Voice session active";
"home.flow.label" = "Ready"; "home.flow.label" = "Ready";
"home.flow.inactive" = "Voice session inactive"; "home.flow.inactive" = "Voice session inactive";
"home.flow.notReady" = "Not ready";
"home.flow.hint" = "Switch to any app and tap the keyboard mic to dictate."; "home.flow.hint" = "Switch to any app and tap the keyboard mic to dictate.";
"home.setup.permission.mic" = "Microphone access is off — voice input won't work."; "home.setup.permission.mic" = "Microphone access is off — voice input won't work.";
"home.setup.permission.speech" = "Speech recognition is off — voice input won't work."; "home.setup.permission.speech" = "Speech recognition is off — voice input won't work.";
@@ -346,7 +350,7 @@
"settings.personalDictionary.intro.title" = "About your dictionary"; "settings.personalDictionary.intro.title" = "About your dictionary";
"settings.personalDictionary.intro.body" = "Add words manually. Common speech-recognition mishearings are generated automatically after you save. Tap a word to edit."; "settings.personalDictionary.intro.body" = "Add words manually. Common speech-recognition mishearings are generated automatically after you save. Tap a word to edit.";
"settings.personalDictionary.empty.title" = "No words yet"; "settings.personalDictionary.empty.title" = "No words yet";
"settings.personalDictionary.empty.body" = "Add words you want protected during speech correction."; "settings.personalDictionary.empty.body" = "Add your personal dictionary to improve recognition accuracy\nSyncs across devices via iCloud";
"settings.personalDictionary.search.prompt" = "Search words"; "settings.personalDictionary.search.prompt" = "Search words";
"settings.personalDictionary.usageCount" = "%lld uses"; "settings.personalDictionary.usageCount" = "%lld uses";
"settings.personalDictionary.add.title" = "Add word"; "settings.personalDictionary.add.title" = "Add word";
@@ -358,6 +362,11 @@
"settings.personalDictionary.clearAll.confirmTitle" = "Clear all dictionary words?"; "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.message" = "This removes every word the LLM was protecting. This cannot be undone.";
"settings.personalDictionary.clearAll.confirm" = "Clear all"; "settings.personalDictionary.clearAll.confirm" = "Clear all";
"settings.personalDictionary.iCloudSync.title" = "Sync via iCloud";
"settings.personalDictionary.iCloudSync.settingsTitle" = "Personal dictionary iCloud sync";
"settings.personalDictionary.iCloudSync.subtitle" = "Keep your dictionary in sync across iPhone, iPad, and Mac. Data stays in your private iCloud account and is not used for training.";
"settings.personalDictionary.iCloudSync.error.tooLarge" = "Dictionary is too large to sync via iCloud. Remove some entries and try again.";
"settings.personalDictionary.iCloudSync.error.generic" = "Could not sync your dictionary with iCloud. Try again later.";
/* v0.3.0: Polish intensity */ /* v0.3.0: Polish intensity */
"settings.polishIntensity.title" = "Polish intensity"; "settings.polishIntensity.title" = "Polish intensity";
+13 -4
View File
@@ -98,8 +98,10 @@
"settings.engine.local.ios26" = "全程在手机本地,不用联网"; "settings.engine.local.ios26" = "全程在手机本地,不用联网";
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。"; "settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
"settings.engine.cloud.title" = "云端识别与润色"; "settings.engine.cloud.title" = "云端识别与润色";
"settings.engine.cloud.subtitle" = "本地转写 + 你配置的 API 润色,文字发往第三方服务"; "settings.engine.cloud.subtitle" = "云端 ASR(含个性词库)+ API 润色,音频将发往第三方服务";
"settings.engine.cloud.badge" = "云端引擎";
"settings.provider.title" = "云端引擎"; "settings.provider.title" = "云端引擎";
"settings.provider.personalDictionaryBadge" = "个性词库";
"settings.provider.subtitle" = "选择 LLM 提供商。"; "settings.provider.subtitle" = "选择 LLM 提供商。";
"provider.openai" = "OpenAI"; "provider.openai" = "OpenAI";
"provider.deepseek" = "DeepSeek"; "provider.deepseek" = "DeepSeek";
@@ -141,6 +143,7 @@
"settings.systemPrompt.hint" = "云端润色时使用。本地识别模式不会用到此提示词。"; "settings.systemPrompt.hint" = "云端润色时使用。本地识别模式不会用到此提示词。";
"settings.about.title" = "关于"; "settings.about.title" = "关于";
"settings.preferences.title" = "偏好设置"; "settings.preferences.title" = "偏好设置";
"settings.dictionaryAndPolish.title" = "词库与润色";
"settings.handedness.title" = "握持偏好"; "settings.handedness.title" = "握持偏好";
"settings.handedness.left" = "左手"; "settings.handedness.left" = "左手";
"settings.handedness.right" = "右手"; "settings.handedness.right" = "右手";
@@ -282,6 +285,7 @@
"home.flow.active" = "语音会话进行中"; "home.flow.active" = "语音会话进行中";
"home.flow.label" = "就绪"; "home.flow.label" = "就绪";
"home.flow.inactive" = "语音会话未启动"; "home.flow.inactive" = "语音会话未启动";
"home.flow.notReady" = "未就绪";
"home.flow.hint" = "切到别的 App,点键盘麦克风就能说。"; "home.flow.hint" = "切到别的 App,点键盘麦克风就能说。";
"home.setup.permission.mic" = "麦克风还没授权,语音输入用不了。"; "home.setup.permission.mic" = "麦克风还没授权,语音输入用不了。";
"home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。"; "home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。";
@@ -345,7 +349,7 @@
"settings.personalDictionary.intro.title" = "关于词库"; "settings.personalDictionary.intro.title" = "关于词库";
"settings.personalDictionary.intro.body" = "手动添加词条;保存后会自动生成常见误识别写法。点击词条可编辑。"; "settings.personalDictionary.intro.body" = "手动添加词条;保存后会自动生成常见误识别写法。点击词条可编辑。";
"settings.personalDictionary.empty.title" = "还没有词条"; "settings.personalDictionary.empty.title" = "还没有词条";
"settings.personalDictionary.empty.body" = "添加希望在语音纠错时保护的词汇。"; "settings.personalDictionary.empty.body" = "添加个性化词库,提升识别准确性\n词库将通过 iCloud 实现跨设备同步";
"settings.personalDictionary.search.prompt" = "搜索词条"; "settings.personalDictionary.search.prompt" = "搜索词条";
"settings.personalDictionary.usageCount" = "使用 %lld 次"; "settings.personalDictionary.usageCount" = "使用 %lld 次";
"settings.personalDictionary.add.title" = "添加词条"; "settings.personalDictionary.add.title" = "添加词条";
@@ -357,9 +361,14 @@
"settings.personalDictionary.clearAll.confirmTitle" = "清空全部词条?"; "settings.personalDictionary.clearAll.confirmTitle" = "清空全部词条?";
"settings.personalDictionary.clearAll.message" = "这会移除所有受保护的词汇,且无法撤销。"; "settings.personalDictionary.clearAll.message" = "这会移除所有受保护的词汇,且无法撤销。";
"settings.personalDictionary.clearAll.confirm" = "全部清空"; "settings.personalDictionary.clearAll.confirm" = "全部清空";
"settings.personalDictionary.iCloudSync.title" = "通过 iCloud 同步";
"settings.personalDictionary.iCloudSync.settingsTitle" = "个性词库 iCloud 同步";
"settings.personalDictionary.iCloudSync.subtitle" = "在 iPhone、iPad 和 Mac 之间同步词库。数据仅保存在你的私人 iCloud 账户中,不会用于训练。";
"settings.personalDictionary.iCloudSync.error.tooLarge" = "词库过大,无法通过 iCloud 同步。请删除部分词条后重试。";
"settings.personalDictionary.iCloudSync.error.generic" = "无法与 iCloud 同步词库,请稍后重试。";
/* v0.3.0: 润色档位 */ /* v0.3.0: 润色强度 */
"settings.polishIntensity.title" = "润色档位"; "settings.polishIntensity.title" = "润色强度";
/* Flow 会话策略 */ /* Flow 会话策略 */
"settings.flow.title" = "语音会话"; "settings.flow.title" = "语音会话";
@@ -30,6 +30,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let detectedAppContext = "config.detectedAppContext" public static let detectedAppContext = "config.detectedAppContext"
public static let detectedAppContextAt = "config.detectedAppContextAt" public static let detectedAppContextAt = "config.detectedAppContextAt"
public static let personalDictionary = "config.personalDictionary.v1" public static let personalDictionary = "config.personalDictionary.v1"
/// When true, the main app mirrors the personal dictionary via iCloud KVS.
public static let personalDictionaryICloudSyncEnabled = "config.personalDictionary.iCloudSyncEnabled"
/// When true, the host app auto-returns to the source app after a cold-start handoff. /// When true, the host app auto-returns to the source app after a cold-start handoff.
public static let flowSkipAppSwitch = "config.flowSkipAppSwitch" public static let flowSkipAppSwitch = "config.flowSkipAppSwitch"
/// Raw `FlowInactivityDuration` value; session expires after this idle window. /// Raw `FlowInactivityDuration` value; session expires after this idle window.
@@ -53,6 +55,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var cursorDragNavigationEnabled: Bool public var cursorDragNavigationEnabled: Bool
public var polishIntensity: PolishIntensity public var polishIntensity: PolishIntensity
public var personalDictionary: PersonalDictionary public var personalDictionary: PersonalDictionary
/// Opt-in iCloud KVS sync for the personal dictionary (main app only).
public var personalDictionaryICloudSyncEnabled: Bool
/// Auto-return to the host app after `startflow` cold start (default on). /// Auto-return to the host app after `startflow` cold start (default on).
public var flowSkipAppSwitch: Bool public var flowSkipAppSwitch: Bool
/// Idle timeout before the Flow session ends; resets on each utterance. /// Idle timeout before the Flow session ends; resets on each utterance.
@@ -154,6 +158,12 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}(), }(),
polishIntensity: resolvePolishIntensity(from: defaults), polishIntensity: resolvePolishIntensity(from: defaults),
personalDictionary: decodePersonalDictionary(from: defaults), personalDictionary: decodePersonalDictionary(from: defaults),
personalDictionaryICloudSyncEnabled: {
if defaults.object(forKey: Keys.personalDictionaryICloudSyncEnabled) == nil {
return true
}
return defaults.bool(forKey: Keys.personalDictionaryICloudSyncEnabled)
}(),
flowSkipAppSwitch: { flowSkipAppSwitch: {
if defaults.object(forKey: Keys.flowSkipAppSwitch) == nil { if defaults.object(forKey: Keys.flowSkipAppSwitch) == nil {
return true return true
@@ -212,6 +222,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity) defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch) defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration) defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
defaults.set(personalDictionaryICloudSyncEnabled, forKey: Keys.personalDictionaryICloudSyncEnabled)
Self.encodePersonalDictionary(personalDictionary, to: defaults) Self.encodePersonalDictionary(personalDictionary, to: defaults)
} }
@@ -0,0 +1,121 @@
// CloudASRModels.swift
// OSGKeyboard · Shared
//
// Cloud-engine ASR routing: which provider uses official hotwords /
// vocabulary APIs vs. a transcription prompt bias.
import Foundation
/// How a cloud provider applies the user's personal dictionary during ASR.
public enum CloudASRStrategy: String, Sendable, Equatable {
/// GLM-ASR `hotwords` + optional `prompt`.
case zhipuHotwords
/// Fun-ASR managed `vocabulary_id` + context text.
case alibabaVocabulary
/// OpenAI / MiMo / `prompt` on transcription APIs.
case prompt
/// Moonshot API 退 ASR
case localFallback
}
public enum CloudASRError: Error, LocalizedError, Sendable, Equatable {
case noAPIKey
case invalidURL
case http(status: Int, message: String?)
case decoding(String)
case transport(String)
case emptyTranscript
case audioTooLong
case providerUnsupported
public var errorDescription: String? {
switch self {
case .noAPIKey:
return SharedL10n.string("error.cloudASR.noAPIKey")
case .invalidURL:
return SharedL10n.string("error.cloudASR.invalidURL")
case .http(let status, let message):
if let message, !message.isEmpty {
return SharedL10n.format("error.cloudASR.httpWithMessage", status, message)
}
return SharedL10n.format("error.cloudASR.http", status)
case .decoding(let detail):
return SharedL10n.format("error.cloudASR.decoding", detail)
case .transport(let detail):
return SharedL10n.format("error.cloudASR.transport", detail)
case .emptyTranscript:
return SharedL10n.string("error.cloudASR.emptyTranscript")
case .audioTooLong:
return SharedL10n.string("error.cloudASR.audioTooLong")
case .providerUnsupported:
return SharedL10n.string("error.cloudASR.providerUnsupported")
}
}
}
public enum CloudASRModelCatalog {
/// Sync Fun-ASR Flash base64 upload, 5 min, supports context + vocabulary.
public static let alibabaFunASRFlash = "fun-asr-flash-2026-06-15"
/// Must match the ASR model used at recognition time.
public static let alibabaVocabularyTargetModel = alibabaFunASRFlash
public static let zhipuGLMASR = "glm-asr-2512"
public static let openAITranscribe = "gpt-4o-mini-transcribe"
public static let openAIWhisper = "whisper-1"
public static let mimoASR = "mimo-v2.5-asr"
public static let alibabaAPIBase = "https://dashscope.aliyuncs.com/api/v1"
public static let alibabaCustomizationPath = "/services/audio/asr/customization"
public static let alibabaMultimodalPath = "/services/aigc/multimodal-generation/generation"
public static let zhipuTranscriptionPath = "/audio/transcriptions"
public static func strategy(for providerId: String) -> CloudASRStrategy {
switch providerId {
case "zhipu":
return .zhipuHotwords
case "qwen":
return .alibabaVocabulary
case "moonshot":
return .localFallback
case "openai", "mimo", "custom":
return .prompt
default:
return .prompt
}
}
public static func defaultModel(for providerId: String) -> String {
switch providerId {
case "zhipu":
return zhipuGLMASR
case "qwen":
return alibabaFunASRFlash
case "mimo":
return mimoASR
case "openai", "custom":
return openAITranscribe
default:
return openAITranscribe
}
}
}
extension LLMProvider {
public var cloudASRStrategy: CloudASRStrategy {
CloudASRModelCatalog.strategy(for: id)
}
public var defaultCloudASRModel: String {
CloudASRModelCatalog.defaultModel(for: id)
}
/// Official hotwords / vocabulary APIs during cloud ASR (not prompt-only bias).
public var supportsPersonalDictionaryCloudASR: Bool {
switch cloudASRStrategy {
case .zhipuHotwords, .alibabaVocabulary:
return true
case .prompt, .localFallback:
return false
}
}
}
@@ -0,0 +1,114 @@
// PersonalDictionary+ASRBias.swift
// OSGKeyboard · Shared
//
// Formats the user dictionary for cloud ASR bias (hotwords, Alibaba
// vocabulary entries, or Whisper-style prompt fragments).
import Foundation
import CryptoKit
public struct AlibabaHotwordEntry: Codable, Sendable, Equatable {
public let text: String
public let weight: Int
public let lang: String?
public init(text: String, weight: Int = 4, lang: String? = nil) {
self.text = text
self.weight = weight
self.lang = lang
}
}
extension PersonalDictionary {
/// Stable fingerprint used to decide when to refresh Alibaba vocabulary.
public func vocabularySyncFingerprint() -> String {
let payload = effectiveEntries
.sorted { $0.term.localizedCaseInsensitiveCompare($1.term) == .orderedAscending }
.map { entry in
let aliases = entry.aliases
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
.joined(separator: ",")
return "\(entry.term.lowercased())|\(aliases)"
}
.joined(separator: ";")
let digest = SHA256.hash(data: Data(payload.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
/// `hotwords` canonical terms only (aliases go to `asrPromptBias`).
public func asrHotwords(maxCount: Int = 100) -> [String] {
var seen = Set<String>()
var words: [String] = []
for entry in effectiveEntries {
let term = entry.term.trimmingCharacters(in: .whitespacesAndNewlines)
guard !term.isEmpty else { continue }
let key = term.lowercased()
guard seen.insert(key).inserted else { continue }
words.append(term)
if words.count >= maxCount { break }
}
return words
}
/// `text` + `weight` (+ optional `lang`).
public func alibabaHotwordEntries(maxCount: Int = 500, defaultWeight: Int = 4) -> [AlibabaHotwordEntry] {
var seen = Set<String>()
var entries: [AlibabaHotwordEntry] = []
for entry in effectiveEntries {
let term = entry.term.trimmingCharacters(in: .whitespacesAndNewlines)
guard !term.isEmpty, term.count <= 15 else { continue }
let key = term.lowercased()
guard seen.insert(key).inserted else { continue }
entries.append(
AlibabaHotwordEntry(
text: term,
weight: defaultWeight,
lang: Self.inferAlibabaLang(for: term)
)
)
if entries.count >= maxCount { break }
}
return entries
}
/// Whisper / OpenAI-style short prompt bias (also used by MiMo text hint).
public func asrPromptBias(maxCharacters: Int = 800) -> String {
let entries = effectiveEntries
guard !entries.isEmpty else { return "" }
var lines: [String] = []
for entry in entries {
if entry.aliases.isEmpty {
lines.append(entry.term)
} else {
let aliasHint = entry.aliases.prefix(4).joined(separator: ", ")
lines.append("\(entry.term)(常见误识别:\(aliasHint)")
}
let joined = lines.joined(separator: "")
if joined.count > maxCharacters {
if lines.count == 1 {
return String(joined.prefix(maxCharacters))
}
lines.removeLast()
break
}
}
guard !lines.isEmpty else { return "" }
let body = lines.joined(separator: "")
return "用户专有词汇,转写时请优先使用以下标准写法:\(body)"
}
/// Compact domain context for Alibaba Fun-ASR Flash `input_text`.
public func alibabaContextText(maxCharacters: Int = 1200) -> String {
let prompt = asrPromptBias(maxCharacters: maxCharacters)
guard !prompt.isEmpty else { return "" }
return prompt
}
private static func inferAlibabaLang(for term: String) -> String? {
let hasNonASCII = term.unicodeScalars.contains { !$0.isASCII }
if hasNonASCII { return "zh" }
return "en"
}
}
@@ -0,0 +1,91 @@
// PersonalDictionary+Merging.swift
// OSGKeyboard · Shared
//
// Deterministic merge rules for iCloud KVS sync. Pure logic no
// NSUbiquitousKeyValueStore dependency so unit tests stay hermetic.
import Foundation
extension PersonalDictionary {
/// Merges two dictionary snapshots for cross-device sync.
///
/// Rules:
/// - Same `id`: keep the entry with the newer `updatedAt`.
/// - Same canonical term (case-insensitive) but different `id`: union
/// aliases, take max `usageCount`, keep the newer entry's fields.
public static func merge(local: PersonalDictionary, remote: PersonalDictionary) -> PersonalDictionary {
var mergedByID: [UUID: Entry] = [:]
var canonicalOwner: [String: UUID] = [:]
func insertOrMerge(_ candidate: Entry) {
let key = candidate.term.lowercased()
if let existingID = canonicalOwner[key], var existing = mergedByID[existingID] {
if candidate.id == existingID {
mergedByID[existingID] = resolveEntryConflict(existing: existing, incoming: candidate)
return
}
existing = mergeSameTerm(existing: existing, incoming: candidate)
mergedByID[existingID] = existing
return
}
if let existing = mergedByID[candidate.id] {
mergedByID[candidate.id] = resolveEntryConflict(existing: existing, incoming: candidate)
canonicalOwner[key] = candidate.id
return
}
mergedByID[candidate.id] = candidate
canonicalOwner[key] = candidate.id
}
for entry in local.entries { insertOrMerge(entry) }
for entry in remote.entries { insertOrMerge(entry) }
let mergedEntries = mergedByID.values.sorted {
if $0.updatedAt != $1.updatedAt {
return $0.updatedAt > $1.updatedAt
}
return $0.term.localizedCaseInsensitiveCompare($1.term) == .orderedAscending
}
let lastSyncedAt = [local.lastSyncedAt, remote.lastSyncedAt]
.compactMap { $0 }
.max()
return PersonalDictionary(
entries: mergedEntries,
version: max(local.version, remote.version) + 1,
lastSyncedAt: lastSyncedAt
)
}
private static func resolveEntryConflict(existing: Entry, incoming: Entry) -> Entry {
incoming.updatedAt >= existing.updatedAt ? incoming : existing
}
private static func mergeSameTerm(existing: Entry, incoming: Entry) -> Entry {
let winner = incoming.updatedAt >= existing.updatedAt ? incoming : existing
let loser = winner.id == existing.id ? incoming : existing
var merged = winner
merged.aliases = unionAliases(
existing: winner.aliases,
incoming: loser.aliases,
excludingTerm: winner.term
)
merged.usageCount = max(winner.usageCount, loser.usageCount)
merged.updatedAt = max(winner.updatedAt, loser.updatedAt)
return merged
}
private static func unionAliases(
existing: [String],
incoming: [String],
excludingTerm: String
) -> [String] {
let termLower = excludingTerm.lowercased()
return Array(
Set((existing + incoming).filter { $0.lowercased() != termLower })
).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
}
@@ -21,10 +21,33 @@ import Foundation
public struct PersonalDictionary: Codable, Sendable, Equatable { public struct PersonalDictionary: Codable, Sendable, Equatable {
public var entries: [Entry] public var entries: [Entry]
public var version: Int public var version: Int
/// When this dictionary blob was last successfully pushed to iCloud KVS.
public var lastSyncedAt: Date?
public init(entries: [Entry] = [], version: Int = 1) { public init(entries: [Entry] = [], version: Int = 1, lastSyncedAt: Date? = nil) {
self.entries = entries self.entries = entries
self.version = version self.version = version
self.lastSyncedAt = lastSyncedAt
}
private enum CodingKeys: String, CodingKey {
case entries
case version
case lastSyncedAt
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
entries = try container.decodeIfPresent([Entry].self, forKey: .entries) ?? []
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
lastSyncedAt = try container.decodeIfPresent(Date.self, forKey: .lastSyncedAt)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(entries, forKey: .entries)
try container.encode(version, forKey: .version)
try container.encodeIfPresent(lastSyncedAt, forKey: .lastSyncedAt)
} }
public struct Entry: Codable, Sendable, Equatable, Identifiable { public struct Entry: Codable, Sendable, Equatable, Identifiable {
@@ -34,6 +57,8 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
public var category: Category public var category: Category
public var source: Source public var source: Source
public var createdAt: Date public var createdAt: Date
/// Last mutation time used for iCloud merge conflict resolution.
public var updatedAt: Date
public var usageCount: Int public var usageCount: Int
public init( public init(
@@ -43,6 +68,7 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
category: Category, category: Category,
source: Source, source: Source,
createdAt: Date = Date(), createdAt: Date = Date(),
updatedAt: Date? = nil,
usageCount: Int = 0 usageCount: Int = 0
) { ) {
self.id = id self.id = id
@@ -51,9 +77,45 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
self.category = category self.category = category
self.source = source self.source = source
self.createdAt = createdAt self.createdAt = createdAt
self.updatedAt = updatedAt ?? createdAt
self.usageCount = usageCount self.usageCount = usageCount
} }
private enum CodingKeys: String, CodingKey {
case id
case term
case aliases
case category
case source
case createdAt
case updatedAt
case usageCount
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
term = try container.decode(String.self, forKey: .term)
aliases = try container.decodeIfPresent([String].self, forKey: .aliases) ?? []
category = try container.decode(Category.self, forKey: .category)
source = try container.decode(Source.self, forKey: .source)
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt
usageCount = try container.decodeIfPresent(Int.self, forKey: .usageCount) ?? 0
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(term, forKey: .term)
try container.encode(aliases, forKey: .aliases)
try container.encode(category, forKey: .category)
try container.encode(source, forKey: .source)
try container.encode(createdAt, forKey: .createdAt)
try container.encode(updatedAt, forKey: .updatedAt)
try container.encode(usageCount, forKey: .usageCount)
}
public enum Category: String, Codable, Sendable, CaseIterable { public enum Category: String, Codable, Sendable, CaseIterable {
/// Person / place / brand / organization. /// Person / place / brand / organization.
case properNoun case properNoun
@@ -140,6 +202,7 @@ extension PersonalDictionary {
category: .productName, category: .productName,
source: .manual, source: .manual,
createdAt: Date(timeIntervalSince1970: 0), createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0),
usageCount: 0 usageCount: 0
), ),
] ]
@@ -182,6 +245,7 @@ extension PersonalDictionary {
if termChanged || regenerateAliases { if termChanged || regenerateAliases {
entry.aliases = [] entry.aliases = []
} }
entry.updatedAt = Date()
entries[idx] = entry entries[idx] = entry
return entry return entry
} }
@@ -193,15 +257,19 @@ extension PersonalDictionary {
entry.term = trimmed entry.term = trimmed
entry.category = category entry.category = category
entry.source = .manual entry.source = .manual
entry.updatedAt = Date()
entries[idx] = entry entries[idx] = entry
return entry return entry
} }
let now = Date()
let entry = Entry( let entry = Entry(
term: trimmed, term: trimmed,
aliases: [], aliases: [],
category: category, category: category,
source: .manual source: .manual,
createdAt: now,
updatedAt: now
) )
entries.append(entry) entries.append(entry)
return entry return entry
@@ -216,6 +284,7 @@ extension PersonalDictionary {
entries[idx].aliases = Array( entries[idx].aliases = Array(
Set(cleaned.filter { $0.lowercased() != termLower }) Set(cleaned.filter { $0.lowercased() != termLower })
).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } ).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
entries[idx].updatedAt = Date()
} }
/// Renders the entire dictionary as a prompt fragment. Entries /// Renders the entire dictionary as a prompt fragment. Entries
@@ -64,7 +64,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
} }
} }
/// "local" on-device ASR + built-in DeepSeek polish. /// "local" on-device ASR + built-in DeepSeek polish.
/// "cloud" on-device ASR + user's cloud LLM polish. /// "cloud" provider cloud ASR (with personal dictionary) + user's cloud LLM polish.
@Published public var engineMode: String { @Published public var engineMode: String {
didSet { didSet {
guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return } guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return }
@@ -1,13 +1,13 @@
{ {
"bin_bytes" : 174285, "bin_bytes" : 198494,
"bin_file" : "OSGKeyboardCLM.bin", "bin_file" : "OSGKeyboardCLM.bin",
"export_seconds" : 0.029847979545593262, "export_seconds" : 0.035165071487426758,
"generated_at" : "2026-07-05T11:34:09Z", "generated_at" : "2026-07-06T13:16:27Z",
"identifier" : "com.osgkeyboard.custom-lm.v1", "identifier" : "com.osgkeyboard.custom-lm.v1",
"locale" : "zh_CN", "locale" : "zh_CN",
"phrase_count" : 11550, "phrase_count" : 13329,
"sources" : { "sources" : {
"ai_tech_seed" : 1259, "ai_tech_seed" : 3040,
"computer_terms" : 10300 "computer_terms" : 10300
}, },
"version" : "1.0.0" "version" : "1.0.0"
+7 -3
View File
@@ -121,9 +121,13 @@ public enum ASREvent: Sendable, Equatable {
// MARK: - Factory // MARK: - Factory
public enum ASRServiceFactory { public enum ASRServiceFactory {
/// Returns the on-device `SpeechAnalyzer` + `DictationTranscriber` backend. /// Returns on-device SpeechAnalyzer for `local`, or the user's cloud
public static func make() -> ASRService { /// ASR provider when `engineMode == "cloud"`.
SpeechAnalyzerASR() public static func make(store: AppGroupStore = AppGroupStore()) -> ASRService {
if store.engineMode == "cloud" {
return CloudASRService(store: store)
}
return SpeechAnalyzerASR()
} }
} }
@@ -155,6 +155,16 @@ public struct AppGroupStore: @unchecked Sendable {
public func setPersonalDictionary(_ dictionary: PersonalDictionary) { public func setPersonalDictionary(_ dictionary: PersonalDictionary) {
mutateConfiguration { $0.personalDictionary = dictionary } mutateConfiguration { $0.personalDictionary = dictionary }
AppGroupConfigDarwin.postConfigChanged()
}
public var personalDictionaryICloudSyncEnabled: Bool {
get { configuration.personalDictionaryICloudSyncEnabled }
set { setPersonalDictionaryICloudSyncEnabled(newValue) }
}
public func setPersonalDictionaryICloudSyncEnabled(_ enabled: Bool) {
mutateConfiguration { $0.personalDictionaryICloudSyncEnabled = enabled }
} }
// MARK: - Client // MARK: - Client
@@ -0,0 +1,169 @@
// AlibabaVocabularySync.swift
// OSGKeyboard · Shared
//
// Syncs PersonalDictionary DashScope custom vocabulary (Fun-ASR Flash).
import Foundation
public enum AlibabaVocabularySync {
public enum Keys {
public static let vocabularyId = "config.alibabaASRVocabularyId"
public static let fingerprint = "config.alibabaASRVocabularyFingerprint"
}
private static let vocabularyPrefix = "osgkb"
/// Returns a ready `vocabulary_id`, creating or updating the remote list as needed.
public static func ensureVocabularyID(
dictionary: PersonalDictionary,
apiKey: String,
targetModel: String = CloudASRModelCatalog.alibabaVocabularyTargetModel,
defaults: UserDefaults,
session: URLSession = .shared
) async throws -> String? {
let entries = dictionary.alibabaHotwordEntries()
guard !entries.isEmpty else {
clearCache(defaults: defaults)
return nil
}
let fingerprint = dictionary.vocabularySyncFingerprint()
if let cachedID = defaults.string(forKey: Keys.vocabularyId),
defaults.string(forKey: Keys.fingerprint) == fingerprint,
!cachedID.isEmpty {
return cachedID
}
let url = try customizationURL()
if let existingID = defaults.string(forKey: Keys.vocabularyId), !existingID.isEmpty {
try await updateVocabulary(
id: existingID,
entries: entries,
apiKey: apiKey,
url: url,
session: session
)
cache(id: existingID, fingerprint: fingerprint, defaults: defaults)
return existingID
}
let createdID = try await createVocabulary(
entries: entries,
targetModel: targetModel,
apiKey: apiKey,
url: url,
session: session
)
cache(id: createdID, fingerprint: fingerprint, defaults: defaults)
return createdID
}
public static func clearCache(defaults: UserDefaults) {
defaults.removeObject(forKey: Keys.vocabularyId)
defaults.removeObject(forKey: Keys.fingerprint)
}
private static func cache(id: String, fingerprint: String, defaults: UserDefaults) {
defaults.set(id, forKey: Keys.vocabularyId)
defaults.set(fingerprint, forKey: Keys.fingerprint)
}
private static func customizationURL() throws -> URL {
let raw = CloudASRModelCatalog.alibabaAPIBase + CloudASRModelCatalog.alibabaCustomizationPath
guard let url = URL(string: raw) else { throw CloudASRError.invalidURL }
return url
}
private static func createVocabulary(
entries: [AlibabaHotwordEntry],
targetModel: String,
apiKey: String,
url: URL,
session: URLSession
) async throws -> String {
let vocabulary = entries.map { entry -> [String: Any] in
var item: [String: Any] = ["text": entry.text, "weight": entry.weight]
if let lang = entry.lang { item["lang"] = lang }
return item
}
let body: [String: Any] = [
"model": "speech-biasing",
"input": [
"action": "create_vocabulary",
"target_model": targetModel,
"prefix": vocabularyPrefix,
"vocabulary": vocabulary,
] as [String: Any],
]
let data = try await postJSON(body, to: url, apiKey: apiKey, session: session)
guard let id = parseVocabularyID(from: data) else {
throw CloudASRError.decoding("missing vocabulary_id")
}
return id
}
private static func updateVocabulary(
id: String,
entries: [AlibabaHotwordEntry],
apiKey: String,
url: URL,
session: URLSession
) async throws {
let vocabulary = entries.map { entry -> [String: Any] in
var item: [String: Any] = ["text": entry.text, "weight": entry.weight]
if let lang = entry.lang { item["lang"] = lang }
return item
}
let body: [String: Any] = [
"model": "speech-biasing",
"input": [
"action": "update_vocabulary",
"vocabulary_id": id,
"vocabulary": vocabulary,
] as [String: Any],
]
_ = try await postJSON(body, to: url, apiKey: apiKey, session: session)
}
private static func postJSON(
_ body: [String: Any],
to url: URL,
apiKey: String,
session: URLSession
) async throws -> [String: Any] {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw CloudASRError.transport("non-HTTP response")
}
let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
guard (200..<300).contains(http.statusCode) else {
let message = parseAPIErrorMessage(from: json)
throw CloudASRError.http(status: http.statusCode, message: message)
}
return json ?? [:]
}
private static func parseVocabularyID(from json: [String: Any]) -> String? {
if let output = json["output"] as? [String: Any],
let id = output["vocabulary_id"] as? String {
return id
}
return nil
}
private static func parseAPIErrorMessage(from json: [String: Any]?) -> String? {
guard let json else { return nil }
if let message = json["message"] as? String { return message }
if let error = json["error"] as? [String: Any],
let message = error["message"] as? String {
return message
}
return nil
}
}
@@ -0,0 +1,411 @@
// CloudASRClients.swift
// OSGKeyboard · Shared
//
// Provider-specific cloud ASR backends with personal-dictionary bias.
import Foundation
public protocol CloudASRTranscribing: Sendable {
func prepare(dictionary: PersonalDictionary) async throws
func transcribe(
samples: [Float],
sampleRate: Int,
locale: Locale,
dictionary: PersonalDictionary
) async throws -> String
}
public enum CloudASRClientFactory {
public static func make(store: AppGroupStore, session: URLSession = .shared) -> CloudASRTranscribing {
let strategy = CloudASRModelCatalog.strategy(for: store.providerId)
switch strategy {
case .zhipuHotwords:
return ZhipuCloudASRClient(
apiKey: store.apiKey,
model: CloudASRModelCatalog.defaultModel(for: store.providerId),
session: session
)
case .alibabaVocabulary:
return AlibabaFunASRClient(
apiKey: store.apiKey,
model: CloudASRModelCatalog.defaultModel(for: store.providerId),
store: store,
session: session
)
case .prompt:
return PromptCloudASRClient(
providerId: store.providerId,
baseURL: store.baseURL,
apiKey: store.apiKey,
model: CloudASRModelCatalog.defaultModel(for: store.providerId),
session: session
)
case .localFallback:
return UnsupportedCloudASRClient(providerId: store.providerId)
}
}
}
// MARK: - Zhipu (hotwords + prompt)
struct ZhipuCloudASRClient: CloudASRTranscribing {
let apiKey: String
let model: String
let session: URLSession
private static let maxDurationSeconds: TimeInterval = 30
func prepare(dictionary: PersonalDictionary) async throws {}
func transcribe(
samples: [Float],
sampleRate: Int,
locale: Locale,
dictionary: PersonalDictionary
) async throws -> String {
guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey }
let duration = Double(samples.count) / Double(sampleRate)
guard duration <= Self.maxDurationSeconds else { throw CloudASRError.audioTooLong }
let wav = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate)
let urlString = "https://open.bigmodel.cn/api/paas/v4\(CloudASRModelCatalog.zhipuTranscriptionPath)"
guard let url = URL(string: urlString) else { throw CloudASRError.invalidURL }
let boundary = "Boundary-\(UUID().uuidString)"
var body = Data()
func appendField(_ name: String, _ value: String) {
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!)
body.append("\(value)\r\n".data(using: .utf8)!)
}
appendField("model", model)
appendField("stream", "false")
let hotwords = dictionary.asrHotwords()
if !hotwords.isEmpty,
let hotwordsJSON = try? JSONSerialization.data(withJSONObject: hotwords),
let hotwordsString = String(data: hotwordsJSON, encoding: .utf8) {
appendField("hotwords", hotwordsString)
}
let prompt = dictionary.asrPromptBias()
if !prompt.isEmpty {
appendField("prompt", prompt)
}
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"chunk.wav\"\r\n".data(using: .utf8)!)
body.append("Content-Type: audio/wav\r\n\r\n".data(using: .utf8)!)
body.append(wav)
body.append("\r\n".data(using: .utf8)!)
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.httpBody = body
request.timeoutInterval = 60
let (data, response) = try await session.data(for: request)
try Self.validateHTTP(response: response, data: data)
guard let text = Self.parseZhipuText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty else {
throw CloudASRError.emptyTranscript
}
return text
}
private static func parseZhipuText(from data: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
return json["text"] as? String
}
fileprivate static func validateHTTP(response: URLResponse, data: Data) throws {
guard let http = response as? HTTPURLResponse else {
throw CloudASRError.transport("non-HTTP response")
}
guard (200..<300).contains(http.statusCode) else {
let message = parseErrorMessage(from: data)
throw CloudASRError.http(status: http.statusCode, message: message)
}
}
fileprivate static func parseErrorMessage(from data: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
if let message = json["message"] as? String { return message }
if let error = json["error"] as? [String: Any],
let message = error["message"] as? String {
return message
}
return nil
}
}
// MARK: - Alibaba Fun-ASR Flash (vocabulary_id + context)
struct AlibabaFunASRClient: CloudASRTranscribing {
let apiKey: String
let model: String
// Hold the (@unchecked Sendable) AppGroupStore rather than a raw
// UserDefaults so this struct stays Sendable under strict concurrency.
let store: AppGroupStore
let session: URLSession
func prepare(dictionary: PersonalDictionary) async throws {
_ = try await AlibabaVocabularySync.ensureVocabularyID(
dictionary: dictionary,
apiKey: apiKey,
targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel,
defaults: store.defaults,
session: session
)
}
func transcribe(
samples: [Float],
sampleRate: Int,
locale: Locale,
dictionary: PersonalDictionary
) async throws -> String {
guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey }
let vocabularyID = try await AlibabaVocabularySync.ensureVocabularyID(
dictionary: dictionary,
apiKey: apiKey,
targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel,
defaults: store.defaults,
session: session
)
let dataURI = PCMSampleWavEncoder.dataURI(samples: samples, sampleRate: sampleRate)
let urlString = CloudASRModelCatalog.alibabaAPIBase + CloudASRModelCatalog.alibabaMultimodalPath
guard let url = URL(string: urlString) else { throw CloudASRError.invalidURL }
var messages: [[String: Any]] = []
let context = dictionary.alibabaContextText()
if !context.isEmpty {
messages.append([
"role": "user",
"content": [
["type": "input_text", "text": context],
],
])
}
messages.append([
"role": "user",
"content": [
[
"type": "input_audio",
"input_audio": ["data": dataURI],
],
],
])
var parameters: [String: Any] = [
"format": "wav",
"sample_rate": "\(sampleRate)",
]
if let vocabularyID, !vocabularyID.isEmpty {
parameters["vocabulary_id"] = vocabularyID
}
let body: [String: Any] = [
"model": model,
"input": ["messages": messages],
"parameters": parameters,
]
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("disable", forHTTPHeaderField: "X-DashScope-SSE")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
request.timeoutInterval = 90
let (data, response) = try await session.data(for: request)
try ZhipuCloudASRClient.validateHTTP(response: response, data: data)
guard let text = Self.parseText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty else {
throw CloudASRError.emptyTranscript
}
return text
}
private static func parseText(from data: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let output = json["output"] as? [String: Any] else {
return nil
}
if let text = output["text"] as? String { return text }
if let sentence = output["sentence"] as? [String: Any],
let text = sentence["text"] as? String {
return text
}
return nil
}
}
// MARK: - Prompt-biased transcription (OpenAI / MiMo / custom)
struct PromptCloudASRClient: CloudASRTranscribing {
let providerId: String
let baseURL: String
let apiKey: String
let model: String
let session: URLSession
func prepare(dictionary: PersonalDictionary) async throws {}
func transcribe(
samples: [Float],
sampleRate: Int,
locale: Locale,
dictionary: PersonalDictionary
) async throws -> String {
guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey }
if providerId == "mimo" {
return try await transcribeMiMo(
samples: samples,
sampleRate: sampleRate,
dictionary: dictionary
)
}
return try await transcribeOpenAIStyle(
samples: samples,
sampleRate: sampleRate,
dictionary: dictionary
)
}
private func transcribeOpenAIStyle(
samples: [Float],
sampleRate: Int,
dictionary: PersonalDictionary
) async throws -> String {
let wav = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate)
let trimmedBase = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL
let urlString = "\(trimmedBase)/audio/transcriptions"
guard let url = URL(string: urlString) else { throw CloudASRError.invalidURL }
let boundary = "Boundary-\(UUID().uuidString)"
var body = Data()
func appendField(_ name: String, _ value: String) {
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!)
body.append("\(value)\r\n".data(using: .utf8)!)
}
appendField("model", model)
let prompt = dictionary.asrPromptBias(maxCharacters: 600)
if !prompt.isEmpty {
appendField("prompt", prompt)
}
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"chunk.wav\"\r\n".data(using: .utf8)!)
body.append("Content-Type: audio/wav\r\n\r\n".data(using: .utf8)!)
body.append(wav)
body.append("\r\n".data(using: .utf8)!)
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.httpBody = body
request.timeoutInterval = 90
let (data, response) = try await session.data(for: request)
try ZhipuCloudASRClient.validateHTTP(response: response, data: data)
guard let text = Self.parseOpenAIText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty else {
throw CloudASRError.emptyTranscript
}
return text
}
private func transcribeMiMo(
samples: [Float],
sampleRate: Int,
dictionary: PersonalDictionary
) async throws -> String {
let dataURI = PCMSampleWavEncoder.dataURI(samples: samples, sampleRate: sampleRate)
let trimmedBase = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL
let urlString = "\(trimmedBase)/chat/completions"
guard let url = URL(string: urlString) else { throw CloudASRError.invalidURL }
var userContent: [[String: Any]] = []
let prompt = dictionary.asrPromptBias()
if !prompt.isEmpty {
userContent.append(["type": "text", "text": prompt])
}
userContent.append([
"type": "input_audio",
"input_audio": ["data": dataURI],
])
let body: [String: Any] = [
"model": model,
"messages": [
["role": "user", "content": userContent],
],
"asr_options": ["language": "auto"],
]
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(apiKey, forHTTPHeaderField: "api-key")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
request.timeoutInterval = 90
let (data, response) = try await session.data(for: request)
try ZhipuCloudASRClient.validateHTTP(response: response, data: data)
guard let text = Self.parseChatCompletionText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty else {
throw CloudASRError.emptyTranscript
}
return text
}
private static func parseOpenAIText(from data: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
return json["text"] as? String
}
private static func parseChatCompletionText(from data: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let choices = json["choices"] as? [[String: Any]],
let first = choices.first,
let message = first["message"] as? [String: Any] else {
return nil
}
return message["content"] as? String
}
}
// MARK: - Unsupported hosted ASR (Moonshot)
struct UnsupportedCloudASRClient: CloudASRTranscribing {
let providerId: String
func prepare(dictionary: PersonalDictionary) async throws {}
func transcribe(
samples: [Float],
sampleRate: Int,
locale: Locale,
dictionary: PersonalDictionary
) async throws -> String {
throw CloudASRError.providerUnsupported
}
}
@@ -0,0 +1,147 @@
// CloudASRService.swift
// OSGKeyboard · Shared
//
// Cloud-engine ASR: uploads PCM chunks to the user's configured provider
// with personal-dictionary bias. Moonshot falls back to on-device ASR.
import Foundation
import os
public final class CloudASRService: ASRService, @unchecked Sendable {
private let store: AppGroupStore
private let session: URLSession
private let localFallback: ASRService
private let lock = OSAllocatedUnfairLock()
private var client: CloudASRTranscribing?
private var usesLocalFallback = false
private var boundProviderId: String?
private var cancelled = false
public init(
store: AppGroupStore = AppGroupStore(),
session: URLSession = .shared,
localFallback: ASRService? = nil
) {
self.store = store
self.session = session
// `SpeechAnalyzerASR` is internal, so it can't appear in a public
// default argument value resolve the fallback in the body instead.
self.localFallback = localFallback ?? SpeechAnalyzerASR()
}
public func resetForNewUtterance() {
lock.withLock { cancelled = false }
if usesLocalFallback {
localFallback.resetForNewUtterance()
}
}
public func warmup(locale: Locale) async {
bindClientIfNeeded()
if usesLocalFallback {
await localFallback.warmup(locale: locale)
return
}
guard let client = lock.withLock({ client }) else { return }
do {
try await client.prepare(dictionary: store.personalDictionary)
} catch {
OSGLog.asr.warning("cloud ASR vocabulary prepare failed: \(error.localizedDescription, privacy: .public)")
}
}
public func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
guard !samples.isEmpty else { return .success("") }
if Task.isCancelled || lock.withLock({ cancelled }) { return .cancelled }
bindClientIfNeeded()
if usesLocalFallback {
return await localFallback.transcribeChunk(samples: samples, locale: locale)
}
guard let client = lock.withLock({ client }) else {
return .failure(CloudASRError.providerUnsupported.localizedDescription)
}
do {
let text = try await client.transcribe(
samples: samples,
sampleRate: 16_000,
locale: locale,
dictionary: store.personalDictionary
)
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? .success("") : .success(trimmed)
} catch is CancellationError {
return .cancelled
} catch {
return .failure(error.localizedDescription)
}
}
public func transcribe(
stream: AsyncStream<AudioBufferSnapshot>,
locale: Locale
) -> AsyncStream<ASREvent> {
bindClientIfNeeded()
if usesLocalFallback {
return localFallback.transcribe(stream: stream, locale: locale)
}
return AsyncStream { continuation in
continuation.yield(.capability(onDeviceSupported: false))
let task = Task {
var samples: [Float] = []
for await snap in stream {
if Task.isCancelled { break }
samples.append(contentsOf: snap.samples)
}
guard !Task.isCancelled, !self.lock.withLock({ self.cancelled }) else {
continuation.finish()
return
}
guard !samples.isEmpty else {
continuation.yield(.error(SharedL10n.string("error.asr.noSpeech")))
continuation.finish()
return
}
switch await self.transcribeChunk(samples: samples, locale: locale) {
case .success(let text):
if text.isEmpty {
continuation.yield(.error(SharedL10n.string("error.asr.noSpeech")))
} else {
continuation.yield(.final(text))
}
case .failure(let message):
continuation.yield(.error(message))
case .cancelled:
break
}
continuation.finish()
}
continuation.onTermination = { @Sendable _ in
task.cancel()
self.cancel()
}
}
}
public func cancel() {
lock.withLock { cancelled = true }
localFallback.cancel()
}
private func bindClientIfNeeded() {
let providerId = store.providerId
let strategy = CloudASRModelCatalog.strategy(for: providerId)
lock.withLock {
guard boundProviderId != providerId else { return }
boundProviderId = providerId
usesLocalFallback = strategy == .localFallback
client = usesLocalFallback
? nil
: CloudASRClientFactory.make(store: store, session: session)
}
}
}
@@ -115,7 +115,7 @@ public final class LiveDictationController: ObservableObject {
private var didInstallTap = false private var didInstallTap = false
public init(asr: ASRService? = nil) { public init(asr: ASRService? = nil) {
self.asr = asr ?? ASRServiceFactory.make() self.asr = asr ?? ASRServiceFactory.make(store: AppGroupStore())
} }
/// Start dictation using a persisted settings locale id (`auto`, `zh-Hans`, ). /// Start dictation using a persisted settings locale id (`auto`, `zh-Hans`, ).
@@ -0,0 +1,146 @@
// PersonalDictionaryCloudSync.swift
// OSGKeyboard · Shared
//
// Mirrors the personal dictionary through iCloud Key-Value Store while
// keeping App Group UserDefaults as the keyboard extension's runtime
// source of truth. Intended for main-app call sites only.
import Foundation
public extension Notification.Name {
/// Posted after a remote KVS pull updates the App Group dictionary.
static let personalDictionaryDidSyncFromCloud = Notification.Name(
"com.osgkeyboard.personalDictionary.didSyncFromCloud"
)
}
public enum PersonalDictionaryCloudSyncError: Error, Equatable, Sendable {
case payloadTooLarge(byteCount: Int)
case encodeFailed
case decodeFailed
}
@MainActor
public final class PersonalDictionaryCloudSync {
public static let shared = PersonalDictionaryCloudSync()
public static let kvsKey = "personalDictionary.v1"
/// Stay below the ~1 MB per-key KVS limit.
public static let maxPayloadBytes = 900_000
private let kvs: UbiquitousKeyValueStoreing
private let makeStore: () -> AppGroupStore
private var externalChangeObserver: NSObjectProtocol?
public init(
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() }
) {
self.kvs = kvs
self.makeStore = makeStore
}
// MARK: - Lifecycle
public func startObservingExternalChanges() {
guard externalChangeObserver == nil else { return }
externalChangeObserver = NotificationCenter.default.addObserver(
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
await self.pullAndMergeIfEnabled()
}
}
}
public func stopObservingExternalChanges() {
if let externalChangeObserver {
NotificationCenter.default.removeObserver(externalChangeObserver)
self.externalChangeObserver = nil
}
}
/// Pull remote changes on launch / foreground when sync is enabled.
public func pullAndMergeIfEnabled() async {
let store = makeStore()
guard store.personalDictionaryICloudSyncEnabled else { return }
await pullAndMerge(store: store)
}
/// Push the current local dictionary when sync is enabled.
public func pushLocalIfEnabled(_ dictionary: PersonalDictionary) async throws {
let store = makeStore()
guard store.personalDictionaryICloudSyncEnabled else { return }
try push(dictionary)
}
/// Enable sync: merge local + remote, persist locally, then upload.
public func enableSync() async throws {
let store = makeStore()
store.setPersonalDictionaryICloudSyncEnabled(true)
let local = store.personalDictionary
let remote = loadRemote() ?? .empty
let merged = PersonalDictionary.merge(local: local, remote: remote)
store.setPersonalDictionary(merged)
try push(merged)
}
public func disableSync() {
makeStore().setPersonalDictionaryICloudSyncEnabled(false)
}
// MARK: - Core operations
public func pullAndMerge(store: AppGroupStore) async {
guard store.personalDictionaryICloudSyncEnabled else { return }
let local = store.personalDictionary
guard let remote = loadRemote() else { return }
let merged = PersonalDictionary.merge(local: local, remote: remote)
guard merged != local else { return }
store.setPersonalDictionary(merged)
NotificationCenter.default.post(name: .personalDictionaryDidSyncFromCloud, object: nil)
}
public func push(_ dictionary: PersonalDictionary) throws {
var payload = dictionary
payload.lastSyncedAt = Date()
let data = try encode(payload)
kvs.set(data, forKey: Self.kvsKey)
_ = kvs.synchronize()
}
public func loadRemote() -> PersonalDictionary? {
guard let data = kvs.data(forKey: Self.kvsKey) else { return nil }
return try? decode(data)
}
// MARK: - Encoding
public func encode(_ dictionary: PersonalDictionary) throws -> Data {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
guard let data = try? encoder.encode(dictionary) else {
throw PersonalDictionaryCloudSyncError.encodeFailed
}
guard data.count <= Self.maxPayloadBytes else {
throw PersonalDictionaryCloudSyncError.payloadTooLarge(byteCount: data.count)
}
return data
}
public func decode(_ data: Data) throws -> PersonalDictionary {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
guard let dictionary = try? decoder.decode(PersonalDictionary.self, from: data) else {
throw PersonalDictionaryCloudSyncError.decodeFailed
}
return dictionary
}
}
@@ -0,0 +1,15 @@
// UbiquitousKeyValueStoreing.swift
// OSGKeyboard · Shared
//
// Test seam around `NSUbiquitousKeyValueStore`.
import Foundation
public protocol UbiquitousKeyValueStoreing: AnyObject {
func data(forKey key: String) -> Data?
func set(_ value: Data?, forKey key: String)
@discardableResult
func synchronize() -> Bool
}
extension NSUbiquitousKeyValueStore: UbiquitousKeyValueStoreing {}
@@ -10,8 +10,8 @@
// English dictation while halving the network round-trip. // English dictation while halving the network round-trip.
// //
// Engine matrix: // Engine matrix:
// - `engineMode == "cloud"` on-device ASR, then user's cloud LLM // - `engineMode == "cloud"` provider cloud ASR + user's cloud LLM
// - `engineMode == "local"` on-device ASR, then built-in DeepSeek // - `engineMode == "local"` on-device ASR + built-in DeepSeek
// - Ultra-short, structure-free utterances skip the LLM entirely // - Ultra-short, structure-free utterances skip the LLM entirely
// - Cloud without API key raw + `.missingAPIKey` warning // - Cloud without API key raw + `.missingAPIKey` warning
// - Local without build key raw + `.missingAPIKey` warning // - Local without build key raw + `.missingAPIKey` warning
@@ -6,6 +6,7 @@
// or a safety timeout elapses (symmetric to pre-roll at utterance start). // or a safety timeout elapses (symmetric to pre-roll at utterance start).
import Foundation import Foundation
import os
/// Tunable tail-drain policy shared by Flow capture and preview dictation. /// Tunable tail-drain policy shared by Flow capture and preview dictation.
public struct FlowCaptureTailDrainPolicy: Sendable, Equatable { public struct FlowCaptureTailDrainPolicy: Sendable, Equatable {
@@ -9,8 +9,7 @@ import os
public enum FlowPipelineDiagnostics { public enum FlowPipelineDiagnostics {
public static func logDrain(_ report: FlowCaptureDrainReport) { public static func logDrain(_ report: FlowCaptureDrainReport) {
OSGLog.flow.info( OSGLog.flow.info(
"tailDrain duration=\(report.drainDurationSeconds, format: .fixed(precision: 2))s " + "tailDrain duration=\(report.drainDurationSeconds, format: .fixed(precision: 2))s silenceEnd=\(report.endedBySilence) tailSamples=\(report.tailSampleCount)"
"silenceEnd=\(report.endedBySilence) tailSamples=\(report.tailSampleCount)"
) )
} }
@@ -21,8 +20,7 @@ public enum FlowPipelineDiagnostics {
chunkWarnings: Int chunkWarnings: Int
) { ) {
OSGLog.flow.info( OSGLog.flow.info(
"chunkPipeline chunks=\(chunkCount) lastChunkSamples=\(lastChunkSamples) " + "chunkPipeline chunks=\(chunkCount) lastChunkSamples=\(lastChunkSamples) stitchedLen=\(stitchedLength) warnings=\(chunkWarnings)"
"stitchedLen=\(stitchedLength) warnings=\(chunkWarnings)"
) )
} }
@@ -0,0 +1,67 @@
// PCMSampleWavEncoder.swift
// OSGKeyboard · Shared
//
// Encodes mono Float32 PCM (@ 16 kHz) into a minimal WAV byte stream for
// cloud ASR multipart / base64 uploads.
import Foundation
public enum PCMSampleWavEncoder {
public static func encode(samples: [Float], sampleRate: Int = 16_000) -> Data {
guard !samples.isEmpty else {
return encode(pcm16: [], sampleRate: sampleRate)
}
var pcm16 = [Int16]()
pcm16.reserveCapacity(samples.count)
for sample in samples {
let scaled = sample * 32_767.0
let clipped = Swift.max(-32_768.0, Swift.min(32_767.0, scaled))
pcm16.append(Int16(clipped.rounded()))
}
return encode(pcm16: pcm16, sampleRate: sampleRate)
}
public static func encode(pcm16: [Int16], sampleRate: Int) -> Data {
let byteRate = sampleRate * 2
let dataSize = pcm16.count * MemoryLayout<Int16>.size
var data = Data()
data.reserveCapacity(44 + dataSize)
func appendASCII(_ string: String) {
data.append(contentsOf: string.utf8)
}
func appendLE32(_ value: UInt32) {
var le = value.littleEndian
withUnsafeBytes(of: &le) { data.append(contentsOf: $0) }
}
func appendLE16(_ value: UInt16) {
var le = value.littleEndian
withUnsafeBytes(of: &le) { data.append(contentsOf: $0) }
}
appendASCII("RIFF")
appendLE32(UInt32(36 + dataSize))
appendASCII("WAVE")
appendASCII("fmt ")
appendLE32(16)
appendLE16(1) // PCM
appendLE16(1) // mono
appendLE32(UInt32(sampleRate))
appendLE32(UInt32(byteRate))
appendLE16(2) // block align
appendLE16(16) // bits per sample
appendASCII("data")
appendLE32(UInt32(dataSize))
pcm16.withUnsafeBufferPointer { buffer in
guard let base = buffer.baseAddress else { return }
data.append(UnsafeBufferPointer(start: base, count: buffer.count))
}
return data
}
public static func dataURI(samples: [Float], sampleRate: Int = 16_000) -> String {
let wav = encode(samples: samples, sampleRate: sampleRate)
return "data:audio/wav;base64,\(wav.base64EncodedString())"
}
}
+11
View File
@@ -33,6 +33,17 @@
"error.asr.noSpeech" = "No speech detected. Please try again."; "error.asr.noSpeech" = "No speech detected. Please try again.";
"error.asr.chunkFailed" = "Segment %lld failed: %@"; "error.asr.chunkFailed" = "Segment %lld failed: %@";
/* Cloud ASR errors */
"error.cloudASR.noAPIKey" = "API Key is missing.";
"error.cloudASR.invalidURL" = "Invalid cloud ASR endpoint.";
"error.cloudASR.http" = "Cloud ASR returned HTTP %lld.";
"error.cloudASR.httpWithMessage" = "Cloud ASR returned HTTP %lld: %@";
"error.cloudASR.decoding" = "Failed to parse cloud ASR response: %@";
"error.cloudASR.transport" = "Cloud ASR network error: %@";
"error.cloudASR.emptyTranscript" = "Cloud ASR returned an empty transcript.";
"error.cloudASR.audioTooLong" = "Audio segment is too long for this cloud ASR provider.";
"error.cloudASR.providerUnsupported" = "This provider does not support cloud speech recognition yet.";
/* Polish scenarios */ /* Polish scenarios */
"polishScenario.daily_chat" = "Daily Chat"; "polishScenario.daily_chat" = "Daily Chat";
"polishScenario.social_lifestyle" = "Social Network"; "polishScenario.social_lifestyle" = "Social Network";
@@ -33,6 +33,17 @@
"error.asr.noSpeech" = "未识别到语音内容,请重试。"; "error.asr.noSpeech" = "未识别到语音内容,请重试。";
"error.asr.chunkFailed" = "第 %lld 段识别失败:%@"; "error.asr.chunkFailed" = "第 %lld 段识别失败:%@";
/* Cloud ASR errors */
"error.cloudASR.noAPIKey" = "未填写 API Key。";
"error.cloudASR.invalidURL" = "云端识别接口地址无效。";
"error.cloudASR.http" = "云端识别返回 HTTP %lld。";
"error.cloudASR.httpWithMessage" = "云端识别返回 HTTP %lld%@";
"error.cloudASR.decoding" = "解析云端识别响应失败:%@";
"error.cloudASR.transport" = "云端识别网络错误:%@";
"error.cloudASR.emptyTranscript" = "云端识别返回了空文本。";
"error.cloudASR.audioTooLong" = "音频片段超过该云端识别服务的时长限制。";
"error.cloudASR.providerUnsupported" = "该服务商暂不支持云端语音识别。";
/* 润色场景 */ /* 润色场景 */
"polishScenario.daily_chat" = "日常聊天"; "polishScenario.daily_chat" = "日常聊天";
"polishScenario.social_lifestyle" = "小红书"; "polishScenario.social_lifestyle" = "小红书";
+78
View File
@@ -0,0 +1,78 @@
// CloudASRTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class CloudASRTests: XCTestCase {
func testCloudASRStrategyRouting() {
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "zhipu"), .zhipuHotwords)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "qwen"), .alibabaVocabulary)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "openai"), .prompt)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "mimo"), .prompt)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "moonshot"), .localFallback)
}
func testCloudASRModelDefaults() {
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "qwen"), "fun-asr-flash-2026-06-15")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "zhipu"), "glm-asr-2512")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "mimo"), "mimo-v2.5-asr")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "openai"), "gpt-4o-mini-transcribe")
}
func testPersonalDictionaryCloudASRBadgeProviders() {
XCTAssertTrue(LLMProvider.provider(id: "zhipu").supportsPersonalDictionaryCloudASR)
XCTAssertTrue(LLMProvider.provider(id: "qwen").supportsPersonalDictionaryCloudASR)
XCTAssertFalse(LLMProvider.provider(id: "openai").supportsPersonalDictionaryCloudASR)
XCTAssertFalse(LLMProvider.provider(id: "moonshot").supportsPersonalDictionaryCloudASR)
}
func testPersonalDictionaryASRHotwordsDedupesTerms() {
let dict = PersonalDictionary(entries: [
PersonalDictionary.Entry(term: "Kubernetes", category: .technical, source: .manual),
PersonalDictionary.Entry(term: "kubernetes", category: .technical, source: .manual),
PersonalDictionary.Entry(term: "OSGKeyboard", category: .productName, source: .manual),
])
let hotwords = dict.asrHotwords()
XCTAssertEqual(hotwords.count, 2)
XCTAssertTrue(hotwords.contains("Kubernetes"))
XCTAssertTrue(hotwords.contains("OSGKeyboard"))
}
func testPersonalDictionaryASRPromptIncludesAliases() {
var dict = PersonalDictionary.empty
_ = dict.upsertManual(term: "Kubernetes")
dict.updateAliases(
for: dict.entries[0].id,
aliases: ["k8s", "库伯内特斯"]
)
let prompt = dict.asrPromptBias()
XCTAssertTrue(prompt.contains("Kubernetes"))
XCTAssertTrue(prompt.contains("k8s"))
}
func testPersonalDictionaryAlibabaHotwordEntries() {
let dict = PersonalDictionary(entries: [
PersonalDictionary.Entry(term: "Cursor", category: .productName, source: .manual),
])
let entries = dict.alibabaHotwordEntries()
XCTAssertEqual(entries.count, 1)
XCTAssertEqual(entries[0].text, "Cursor")
XCTAssertEqual(entries[0].weight, 4)
}
func testPCMSampleWavEncoderProducesHeader() {
let wav = PCMSampleWavEncoder.encode(samples: [0.0, 0.5, -0.5], sampleRate: 16_000)
XCTAssertGreaterThan(wav.count, 44)
XCTAssertEqual(String(data: wav.prefix(4), encoding: .ascii), "RIFF")
XCTAssertEqual(String(data: wav.dropFirst(8).prefix(4), encoding: .ascii), "WAVE")
}
func testVocabularyFingerprintChangesWhenDictionaryChanges() {
var dict = PersonalDictionary.empty
let emptyFP = dict.vocabularySyncFingerprint()
_ = dict.upsertManual(term: "OSGKeyboard")
XCTAssertNotEqual(emptyFP, dict.vocabularySyncFingerprint())
}
}
@@ -0,0 +1,226 @@
// PersonalDictionaryCloudSyncTests.swift
// OSGKeyboardTests
//
// Hermetic tests for iCloud KVS dictionary merge + sync service.
import XCTest
@testable import OSGKeyboardShared
// MARK: - Fake KVS
private final class FakeUbiquitousKeyValueStore: UbiquitousKeyValueStoreing, @unchecked Sendable {
private var storage: [String: Data] = [:]
func data(forKey key: String) -> Data? {
storage[key]
}
func set(_ value: Data?, forKey key: String) {
if let value {
storage[key] = value
} else {
storage.removeValue(forKey: key)
}
}
func synchronize() -> Bool { true }
}
// MARK: - Tests
@MainActor
final class PersonalDictionaryCloudSyncTests: XCTestCase {
private var suiteName: String!
private var defaults: UserDefaults!
private var store: AppGroupStore!
private var kvs: FakeUbiquitousKeyValueStore!
private var sync: PersonalDictionaryCloudSync!
override func setUp() {
super.setUp()
suiteName = "group.com.osgkeyboard.shared.tests.sync.\(UUID().uuidString)"
defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
store = AppGroupStore(defaults: defaults)
kvs = FakeUbiquitousKeyValueStore()
sync = PersonalDictionaryCloudSync(kvs: kvs) { [unowned self] in store }
}
override func tearDown() {
defaults.removePersistentDomain(forName: suiteName)
super.tearDown()
}
// MARK: - Merge
func testMergeKeepsNewerEntryForSameID() {
let id = UUID()
let older = PersonalDictionary.Entry(
id: id,
term: "Kubernetes",
category: .technical,
source: .manual,
createdAt: Date(timeIntervalSince1970: 100),
updatedAt: Date(timeIntervalSince1970: 100),
usageCount: 1
)
let newer = PersonalDictionary.Entry(
id: id,
term: "Kubernetes",
aliases: ["k8s"],
category: .technical,
source: .manual,
createdAt: Date(timeIntervalSince1970: 100),
updatedAt: Date(timeIntervalSince1970: 200),
usageCount: 3
)
let merged = PersonalDictionary.merge(
local: PersonalDictionary(entries: [older]),
remote: PersonalDictionary(entries: [newer])
)
XCTAssertEqual(merged.entries.count, 1)
XCTAssertEqual(merged.entries[0].aliases, ["k8s"])
XCTAssertEqual(merged.entries[0].usageCount, 3)
}
func testMergeUnionsAliasesForSameCanonicalTerm() {
let local = PersonalDictionary.Entry(
term: "Cursor",
aliases: ["cursor ide"],
category: .productName,
source: .manual,
createdAt: Date(timeIntervalSince1970: 100),
updatedAt: Date(timeIntervalSince1970: 150)
)
let remote = PersonalDictionary.Entry(
term: "cursor",
aliases: ["光标"],
category: .productName,
source: .manual,
createdAt: Date(timeIntervalSince1970: 120),
updatedAt: Date(timeIntervalSince1970: 200)
)
let merged = PersonalDictionary.merge(
local: PersonalDictionary(entries: [local]),
remote: PersonalDictionary(entries: [remote])
)
XCTAssertEqual(merged.entries.count, 1)
XCTAssertEqual(Set(merged.entries[0].aliases), Set(["cursor ide", "光标"]))
XCTAssertEqual(merged.entries[0].term, "cursor")
}
func testMergeCombinesDistinctTerms() {
let local = PersonalDictionary(entries: [
PersonalDictionary.Entry(term: "Alpha", category: .custom, source: .manual),
])
let remote = PersonalDictionary(entries: [
PersonalDictionary.Entry(term: "Beta", category: .custom, source: .manual),
])
let merged = PersonalDictionary.merge(local: local, remote: remote)
XCTAssertEqual(Set(merged.entries.map(\.term)), Set(["Alpha", "Beta"]))
}
// MARK: - Backward-compatible decode
func testEntryDecodesWithoutUpdatedAt() throws {
let json = """
{
"id": "A0000000-0000-4000-8000-000000000099",
"term": "Legacy",
"aliases": [],
"category": "custom",
"source": "manual",
"createdAt": 1_700_000_000,
"usageCount": 2
}
""".data(using: .utf8)!
let entry = try JSONDecoder().decode(PersonalDictionary.Entry.self, from: json)
XCTAssertEqual(entry.term, "Legacy")
XCTAssertEqual(entry.updatedAt.timeIntervalSince1970, 1_700_000_000, accuracy: 1)
}
// MARK: - Sync service
func testEnableSyncMergesLocalAndRemoteThenUploads() async throws {
let remoteEntry = PersonalDictionary.Entry(
term: "RemoteTerm",
category: .custom,
source: .manual,
updatedAt: Date(timeIntervalSince1970: 500)
)
let remoteData = try sync.encode(PersonalDictionary(entries: [remoteEntry]))
kvs.set(remoteData, forKey: PersonalDictionaryCloudSync.kvsKey)
store.personalDictionary = PersonalDictionary(entries: [
PersonalDictionary.Entry(
term: "LocalTerm",
category: .custom,
source: .manual,
updatedAt: Date(timeIntervalSince1970: 400)
),
])
try await sync.enableSync()
XCTAssertTrue(store.personalDictionaryICloudSyncEnabled)
XCTAssertEqual(Set(store.personalDictionary.entries.map(\.term)), Set(["LocalTerm", "RemoteTerm"]))
XCTAssertNotNil(sync.loadRemote())
}
func testPushLocalIfEnabledSkipsWhenDisabled() async throws {
store.personalDictionary = PersonalDictionary(entries: [
PersonalDictionary.Entry(term: "OnlyLocal", category: .custom, source: .manual),
])
store.setPersonalDictionaryICloudSyncEnabled(false)
try await sync.pushLocalIfEnabled(store.personalDictionary)
XCTAssertNil(sync.loadRemote())
}
func testPullAndMergeWritesMergedDictionaryToAppGroup() async {
store.setPersonalDictionaryICloudSyncEnabled(true)
store.personalDictionary = PersonalDictionary(entries: [
PersonalDictionary.Entry(term: "LocalOnly", category: .custom, source: .manual),
])
let remote = PersonalDictionary(entries: [
PersonalDictionary.Entry(
term: "CloudOnly",
category: .custom,
source: .manual,
updatedAt: Date(timeIntervalSince1970: 900)
),
])
kvs.set(try! sync.encode(remote), forKey: PersonalDictionaryCloudSync.kvsKey)
await sync.pullAndMerge(store: store)
XCTAssertEqual(Set(store.personalDictionary.entries.map(\.term)), Set(["LocalOnly", "CloudOnly"]))
}
func testEncodeRejectsOversizedPayload() {
let hugeTerm = String(repeating: "x", count: PersonalDictionaryCloudSync.maxPayloadBytes)
let dictionary = PersonalDictionary(entries: [
PersonalDictionary.Entry(term: hugeTerm, category: .custom, source: .manual),
])
XCTAssertThrowsError(try sync.encode(dictionary)) { error in
guard case .payloadTooLarge = error as? PersonalDictionaryCloudSyncError else {
return XCTFail("Expected payloadTooLarge, got \(error)")
}
}
}
func testAppGroupConfigurationDefaultsICloudSyncToOn() {
let config = AppGroupConfiguration.load(fromAvailable: defaults)
XCTAssertTrue(config.personalDictionaryICloudSyncEnabled)
}
}
+896
View File
@@ -0,0 +1,896 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Append batch-2 expansion rows to ai_tech_brands_seed.tsv with deduplication."""
from __future__ import annotations
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SEED_PATH = REPO_ROOT / "Scripts/lexicon/seeds/ai_tech_brands_seed.tsv"
# word, pinyin, aliases (| separated), category, weight
EXPANSION: list[tuple[str, str, str, str, int]] = [
# --- AI brands / models / agents ---
("NanoClaw", "", "nano claw|Nano Claw", "ai_brand", 86),
("ZeroClaw", "", "zero claw|Zero Claw", "ai_brand", 82),
("Hermes Agent", "", "hermes agent|Hermes", "ai_brand", 84),
("Fugu", "", "sakana fugu|Fugu AI", "ai_brand", 84),
("Sakana AI", "", "sakana ai|Sakana", "ai_brand", 82),
("Agents-A1", "", "agents a1|Agents A1", "ai_model", 82),
("NemoClaw", "", "nemo claw|Nemo Claw", "ai_brand", 80),
("Moltworker", "", "molt worker|Moltworker", "ai_platform", 78),
("Perplexity Computer", "", "perplexity computer|Personal Computer", "ai_brand", 84),
("Personal Computer", "", "perplexity personal computer", "ai_brand", 82),
("Simular", "", "simular ai|Simular AI", "ai_brand", 78),
("Vellum", "", "vellum ai|Vellum AI", "ai_platform", 78),
("Cloudflare Agents", "", "cloudflare agents", "ai_platform", 78),
("NVIDIA NIM", "", "nvidia nim|NIM inference", "ai_platform", 82),
("Fireworks AI", "", "fireworks ai|Fireworks", "ai_platform", 82),
("Together AI", "", "together ai|Together", "ai_platform", 82),
("Cerebras", "", "cerebras systems|Cerebras Systems", "tech_company", 82),
("SambaNova", "", "samba nova|Samba Nova", "tech_company", 80),
("Grokipedia", "", "grok ipedia|Grok pedia", "ai_brand", 76),
("Grok Imagine", "", "grok imagine", "ai_brand", 78),
("Imagen", "", "google imagen|Imagen AI", "ai_model", 82),
("Veo", "", "google veo|Veo AI", "ai_model", 82),
("Suno", "", "suno ai|Suno AI", "ai_brand", 84),
("Udio", "", "udio ai|Udio AI", "ai_brand", 82),
("Flux", "", "black forest flux|FLUX", "ai_model", 84),
("Black Forest Labs", "", "black forest labs|BFL", "ai_brand", 82),
("Ideogram", "", "ideogram ai|Ideogram AI", "ai_brand", 80),
("Recraft", "", "recraft ai|Recraft AI", "ai_brand", 78),
("Leonardo AI", "", "leonardo ai|Leonardo", "ai_brand", 80),
("Krea", "", "krea ai|Krea AI", "ai_brand", 76),
("PixVerse", "", "pix verse|Pixverse", "ai_brand", 80),
("Vidu", "", "vidu ai|Vidu AI", "ai_brand", 80),
("可灵AI", "ke ling AI", "Kling AI|可灵", "ai_brand", 84),
("通义万相", "tong yi wan xiang", "Wanxiang|万相", "ai_brand", 84),
("Wanxiang", "", "通义万相|wan xiang", "ai_brand", 82),
("海螺视频", "hai luo shi pin", "Hailuo Video", "ai_brand", 80),
("Skywork", "", "skywork ai|Skywork", "ai_brand", 78),
("天工AI", "tian gong AI", "天工", "ai_brand", 78),
("纳米搜索", "na mi sou suo", "Nami Search", "ai_brand", 76),
("Flowith", "", "flowith ai|Flowith AI", "ai_platform", 76),
("FlowGPT", "", "flow gpt|Flow GPT", "ai_platform", 74),
("Coze", "", "coze ai|扣子", "ai_platform", 86),
("扣子", "kou zi", "Coze|coze", "ai_platform", 84),
("Dify", "", "dify ai|Dify AI", "ai_platform", 84),
("FastGPT", "", "fast gpt|FastGPT", "ai_platform", 78),
("MaxKB", "", "max kb|MaxKB", "ai_platform", 76),
("RAGFlow", "", "rag flow|RAGFlow", "ai_platform", 82),
("Mem0", "", "mem zero|Mem0 AI", "ai_platform", 80),
("Letta", "", "letta ai|Letta AI", "ai_platform", 78),
("Zep", "", "zep ai|Zep AI", "ai_platform", 76),
("GraphRAG", "", "graph rag|Graph RAG", "ai_term", 84),
("LightRAG", "", "light rag|Light RAG", "ai_term", 82),
("Devstral", "", "devstral|mistral devstral", "ai_model", 82),
("Pixtral", "", "pixtral|mistral pixtral", "ai_model", 82),
("Mistral Small", "", "mistral small", "ai_model", 80),
("Mistral Codestral", "", "mistral codestral", "ai_model", 82),
("Codestral", "", "codestral|mistral codestral", "ai_model", 84),
("Command R", "", "command r|cohere command", "ai_model", 80),
("Cohere Command", "", "cohere command|Command R", "ai_model", 78),
("Phi", "", "phi model|microsoft phi", "ai_model", 78),
("Phi模型", "phi mo xing", "Phi|microsoft phi", "ai_model", 76),
("Granite", "", "ibm granite|Granite model", "ai_model", 80),
("IBM Granite", "", "granite|IBM Granite", "ai_model", 78),
("Amazon Nova", "", "amazon nova|Nova model", "ai_model", 82),
("Bedrock", "", "aws bedrock|Amazon Bedrock", "ai_platform", 82),
("Gemma开源", "gemma kai yuan", "Gemma open", "ai_model", 76),
("Claude Mythos", "", "claude mythos|Mythos", "ai_model", 82),
("Whisper", "", "openai whisper|Whisper ASR", "ai_model", 82),
("NotebookLM", "", "notebook lm|Notebook LM", "ai_brand", 84),
("Google AI Studio", "", "google ai studio|AI Studio", "dev_tool", 82),
("AI Studio", "", "google ai studio", "dev_tool", 80),
("Gemini Live", "", "gemini live", "ai_brand", 80),
("Gemini Robotics", "", "gemini robotics", "ai_brand", 78),
("Apple Intelligence", "", "apple intelligence|苹果智能", "ai_brand", 86),
("苹果智能", "ping guo zhi neng", "Apple Intelligence", "ai_brand", 84),
("Siri", "", "apple siri|Siri AI", "ai_brand", 82),
("Writing Tools", "", "apple writing tools", "ai_term", 76),
("Image Playground", "", "image playground|苹果图像游乐场", "ai_term", 74),
("Copilot Studio", "", "copilot studio|Microsoft Copilot Studio", "ai_platform", 80),
("Microsoft Agent", "", "microsoft agent", "ai_platform", 78),
("Semantic Kernel", "", "semantic kernel|SK", "ai_platform", 80),
("AutoGen Studio", "", "autogen studio|AutoGen Studio", "ai_platform", 78),
("Windsurf Cascade", "", "windsurf cascade|Cascade", "dev_tool", 82),
("Cascade", "", "windsurf cascade", "dev_tool", 80),
("Tabnine", "", "tab nine|Tabnine", "dev_tool", 80),
("Sourcegraph Cody", "", "sourcegraph cody|Cody", "dev_tool", 82),
("Cody", "", "sourcegraph cody", "dev_tool", 78),
("GitHub Spark", "", "github spark|Spark", "dev_tool", 78),
("GitHub Models", "", "github models", "ai_platform", 78),
("Browser Use", "", "browser use|browser-use", "ai_term", 84),
("Augment", "", "augment code|Augment Code", "dev_tool", 80),
("JetBrains Junie", "", "jetbrains junie|Junie", "dev_tool", 78),
("Kilo Code", "", "kilo code|KiloCode", "dev_tool", 78),
("Zencoder", "", "zen coder|Zencoder", "dev_tool", 76),
("Goose", "", "goose ai|Block Goose", "dev_tool", 76),
("Omnigent", "", "omnigent ai|Omnigent", "dev_tool", 74),
# --- Entrepreneurs / internet figures ---
("俞敏洪", "yu min hong", "Yu Minhong|新东方俞敏洪", "tech_leader", 86),
("Yu Minhong", "", "yu minhong|俞敏洪", "tech_leader", 84),
("New Oriental", "", "new oriental|新东方", "tech_company", 80),
("新东方", "xin dong fang", "New Oriental", "tech_company", 80),
("卢伟冰", "lu wei bing", "Lu Weibing", "tech_leader", 84),
("Lu Weibing", "", "lu weibing|卢伟冰", "tech_leader", 84),
("影视飓风", "ying shi ju feng", "影视飓风Tim", "tech_leader", 82),
("影视飓风Tim", "", "ying shi ju feng tim|影视飓风", "tech_leader", 82),
("曹德旺", "cao de wang", "Cao Dewang", "tech_leader", 80),
("Cao Dewang", "", "cao dewang|曹德旺", "tech_leader", 80),
("福耀玻璃", "fu yao bo li", "Fuyao Glass", "tech_company", 76),
("宗庆后", "zong qing hou", "Zong Qinghou", "tech_leader", 78),
("钟睒睒", "zhong shan shan", "Zhong Shanshan", "tech_leader", 80),
("Nongfu Spring", "", "nongfu spring|农夫山泉", "consumer_brand", 82),
("农夫山泉", "nong fu shan quan", "Nongfu Spring", "consumer_brand", 82),
("王健林", "wang jian lin", "Wang Jianlin", "tech_leader", 78),
("Wang Jianlin", "", "wang jianlin|王健林", "tech_leader", 78),
("许家印", "xu jia yin", "Xu Jiayin", "tech_leader", 74),
("Xu Jiayin", "", "xu jiayin|许家印", "tech_leader", 74),
("孙正义", "sun zheng yi", "Masayoshi Son", "tech_leader", 82),
("Masayoshi Son", "", "masayoshi son|孙正义", "tech_leader", 82),
("张朝阳", "zhang chao yang", "Charles Zhang", "tech_leader", 80),
("Charles Zhang", "", "charles zhang|张朝阳", "tech_leader", 80),
("搜狐", "sou hu", "Sohu", "tech_company", 78),
("Sohu", "", "sohu|搜狐", "tech_company", 78),
("丁磊", "ding lei", "William Ding", "tech_leader", 84),
("William Ding", "", "william ding|丁磊", "tech_leader", 84),
("陈天桥", "chen tian qiao", "Chen Tianqiao", "tech_leader", 76),
("沈南鹏", "shen nan peng", "Neil Shen", "tech_leader", 82),
("Neil Shen", "", "neil shen|沈南鹏", "tech_leader", 82),
("红杉中国", "hong shan zhong guo", "Sequoia China", "fintech", 80),
("Sequoia China", "", "sequoia china|红杉中国", "fintech", 80),
("徐新", "xu xin", "Xu Xin", "tech_leader", 76),
("今日资本", "jin ri zi ben", "Capital Today", "fintech", 74),
("朱啸虎", "zhu xiao hu", "Zhu Xiaohu", "tech_leader", 78),
("李开复", "li kai fu", "Kai-Fu Lee", "tech_leader", 86),
("Kai-Fu Lee", "", "kai fu lee|李开复", "tech_leader", 86),
("创新工场", "chuang xin gong chang", "Innovation Works", "tech_company", 78),
("Innovation Works", "", "innovation works|创新工场", "tech_company", 78),
("傅盛", "fu sheng", "Fu Sheng", "tech_leader", 78),
("Fu Sheng", "", "fu sheng|傅盛", "tech_leader", 78),
("猎豹移动", "lie bao yi dong", "Cheetah Mobile", "tech_company", 76),
("王小川", "wang xiao chuan", "Wang Xiaochuan", "tech_leader", 82),
("Wang Xiaochuan", "", "wang xiaochuan|王小川", "tech_leader", 82),
("周航", "zhou hang", "Zhou Hang", "tech_leader", 72),
("陈睿", "chen rui", "Chen Rui|B站陈睿", "tech_leader", 80),
("Chen Rui", "", "chen rui|陈睿", "tech_leader", 80),
("宿华", "su hua", "Su Hua", "tech_leader", 78),
("Su Hua", "", "su hua|宿华", "tech_leader", 78),
("程一笑", "cheng yi xiao", "Cheng Yixiao", "tech_leader", 76),
("Cheng Yixiao", "", "cheng yixiao|程一笑", "tech_leader", 76),
("陈欧", "chen ou", "Chen Ou", "tech_leader", 72),
("聚美优品", "ju mei you pin", "Jumei", "tech_company", 70),
("江南春", "jiang nan chun", "Jiang Nanchun", "tech_leader", 76),
("分众传媒", "fen zhong chuan mei", "Focus Media", "tech_company", 76),
("沈晖", "shen hui", "William Feng", "tech_leader", 74),
("李想", "li xiang", "Li Xiang|理想李想", "tech_leader", 86),
("Li Xiang", "", "li xiang|李想", "tech_leader", 86),
("王传福", "wang chuan fu", "Wang Chuanfu", "tech_leader", 86),
("Wang Chuanfu", "", "wang chuanfu|王传福", "tech_leader", 86),
("曾毓群", "zeng yu qun", "Robin Zeng", "tech_leader", 84),
("Robin Zeng", "", "robin zeng|曾毓群", "tech_leader", 84),
("郭台铭", "guo tai ming", "Terry Gou", "tech_leader", 80),
("Terry Gou", "", "terry gou|郭台铭", "tech_leader", 80),
("富士康", "fu shi kang", "Foxconn", "tech_company", 82),
("Foxconn", "", "foxconn|富士康", "tech_company", 82),
("刘慈欣", "liu ci xin", "Liu Cixin", "celebrity", 82),
("Liu Cixin", "", "liu cixin|刘慈欣", "celebrity", 82),
("三体", "san ti", "Three Body Problem|Three-Body", "tech_term", 80),
# --- Celebrities ---
("刘宇宁", "liu yu ning", "Liu Yuning", "celebrity", 86),
("Liu Yuning", "", "liu yuning|刘宇宁", "celebrity", 86),
("梓渝", "zi yu", "Ziyu", "celebrity", 82),
("Ziyu", "", "zi yu|梓渝", "celebrity", 82),
("周深", "zhou shen", "Zhou Shen", "celebrity", 86),
("Zhou Shen", "", "zhou shen|周深", "celebrity", 86),
("白鹿", "bai lu", "Bai Lu", "celebrity", 84),
("Bai Lu", "", "bai lu|白鹿", "celebrity", 84),
("孟子义", "meng zi yi", "Meng Ziyi", "celebrity", 82),
("Meng Ziyi", "", "meng ziyi|孟子义", "celebrity", 82),
("虞书欣", "yu shu xin", "Esther Yu", "celebrity", 84),
("Esther Yu", "", "esther yu|虞书欣", "celebrity", 84),
("赵丽颖", "zhao li ying", "Zhao Liying", "celebrity", 86),
("Zhao Liying", "", "zhao liying|赵丽颖", "celebrity", 86),
("杨紫", "yang zi", "Yang Zi", "celebrity", 86),
("成毅", "cheng yi", "Cheng Yi", "celebrity", 84),
("任嘉伦", "ren jia lun", "Ren Jialun", "celebrity", 82),
("Ren Jialun", "", "ren jialun|任嘉伦", "celebrity", 82),
("李现", "li xian", "Li Xian", "celebrity", 84),
("Li Xian", "", "li xian|李现", "celebrity", 84),
("杨洋", "yang yang", "Yang Yang", "celebrity", 84),
("吴磊", "wu lei", "Wu Lei", "celebrity", 82),
("Wu Lei", "", "wu lei|吴磊", "celebrity", 82),
("胡歌", "hu ge", "Hu Ge", "celebrity", 86),
("Hu Ge", "", "hu ge|胡歌", "celebrity", 86),
("黄晓明", "huang xiao ming", "Huang Xiaoming", "celebrity", 82),
("Huang Xiaoming", "", "huang xiaoming|黄晓明", "celebrity", 82),
("陈坤", "chen kun", "Chen Kun", "celebrity", 80),
("章子怡", "zhang zi yi", "Zhang Ziyi", "celebrity", 82),
("Zhang Ziyi", "", "zhang ziyi|章子怡", "celebrity", 82),
("巩俐", "gong li", "Gong Li", "celebrity", 82),
("周迅", "zhou xun", "Zhou Xun", "celebrity", 82),
("汤唯", "tang wei", "Tang Wei", "celebrity", 80),
("舒淇", "shu qi", "Shu Qi", "celebrity", 80),
("黄渤", "huang bo", "Huang Bo", "celebrity", 86),
("Huang Bo", "", "huang bo|黄渤", "celebrity", 86),
("沈腾", "shen teng", "Shen Teng", "celebrity", 86),
("Shen Teng", "", "shen teng|沈腾", "celebrity", 86),
("贾玲", "jia ling", "Jia Ling", "celebrity", 84),
("Jia Ling", "", "jia ling|贾玲", "celebrity", 84),
("马丽", "ma li", "Ma Li", "celebrity", 80),
("张小斐", "zhang xiao fei", "Zhang Xiaofei", "celebrity", 80),
("吴京", "wu jing", "Wu Jing", "celebrity", 84),
("Wu Jing", "", "wu jing|吴京", "celebrity", 84),
("张译", "zhang yi yan", "Zhang Yi actor", "celebrity", 82),
("雷佳音", "lei jia yin", "Lei Jiayin", "celebrity", 80),
("孙俪", "sun li", "Sun Li", "celebrity", 82),
("靳东", "jin dong", "Jin Dong", "celebrity", 78),
("那英", "na ying", "Na Ying", "celebrity", 82),
("汪峰", "wang feng", "Wang Feng", "celebrity", 80),
("王菲", "wang fei", "Faye Wong", "celebrity", 84),
("张学友", "zhang xue you", "Jacky Cheung", "celebrity", 82),
("Jacky Cheung", "", "jacky cheung|张学友", "celebrity", 82),
("林俊杰", "lin jun jie", "JJ Lin", "celebrity", 84),
("JJ Lin", "", "jj lin|林俊杰", "celebrity", 84),
("蔡依林", "cai yi lin", "Jolin Tsai", "celebrity", 82),
("Jolin Tsai", "", "jolin tsai|蔡依林", "celebrity", 82),
("王力宏", "wang li hong", "Wang Leehom", "celebrity", 82),
("陈奕迅", "chen yi xun", "Eason Chan", "celebrity", 84),
("Eason Chan", "", "eason chan|陈奕迅", "celebrity", 84),
("毛不易", "mao bu yi", "Mao Buyi", "celebrity", 82),
("华晨宇", "hua chen yu", "Hua Chenyu", "celebrity", 80),
("时代少年团", "shi dai shao nian tuan", "TNT|Teens in Times", "celebrity", 80),
("TFBOYS", "", "tf boys|TF Boys", "celebrity", 82),
("蔡徐坤", "cai xu kun", "Cai Xukun", "celebrity", 82),
("Cai Xukun", "", "cai xukun|蔡徐坤", "celebrity", 82),
("黄子韬", "huang zi tao", "Huang Zitao", "celebrity", 80),
("Huang Zitao", "", "huang zitao|黄子韬", "celebrity", 80),
("BLACKPINK", "", "black pink|Black Pink", "celebrity", 84),
("Jennie", "", "blackpink jennie", "celebrity", 80),
("Jisoo", "", "blackpink jisoo", "celebrity", 78),
("Rosé", "", "blackpink rose|Rose", "celebrity", 78),
("BTS", "", "bts|防弹少年团", "celebrity", 84),
("防弹少年团", "fang dan shao nian tuan", "BTS", "celebrity", 84),
("NewJeans", "", "new jeans|New Jeans", "celebrity", 82),
("aespa", "", "aespa组合", "celebrity", 80),
("IVE", "", "ive组合", "celebrity", 78),
("Rihanna", "", "rihanna|蕾哈娜", "celebrity", 82),
("蕾哈娜", "lei ha na", "Rihanna", "celebrity", 80),
("Drake", "", "drake rapper", "celebrity", 82),
("Kanye West", "", "kanye west|Ye", "celebrity", 80),
("Kendrick Lamar", "", "kendrick lamar", "celebrity", 80),
("Billie Eilish", "", "billie eilish", "celebrity", 82),
("Ariana Grande", "", "ariana grande", "celebrity", 82),
("Lady Gaga", "", "lady gaga", "celebrity", 82),
("Tom Hanks", "", "tom hanks", "celebrity", 80),
("Leonardo DiCaprio", "", "leonardo dicaprio|小李子", "celebrity", 82),
("小李子", "xiao li zi", "Leonardo DiCaprio", "celebrity", 80),
("Brad Pitt", "", "brad pitt", "celebrity", 80),
("Angelina Jolie", "", "angelina jolie", "celebrity", 78),
("Jennifer Lawrence", "", "jennifer lawrence", "celebrity", 78),
("Scarlett Johansson", "", "scarlett johansson", "celebrity", 78),
("Robert Downey Jr", "", "robert downey jr|钢铁侠", "celebrity", 80),
("钢铁侠", "gang tie xia", "Iron Man|Robert Downey Jr", "celebrity", 78),
("漫威", "man wei", "Marvel", "tech_term", 80),
("Marvel", "", "marvel|漫威", "tech_term", 80),
("蝙蝠侠", "bian fu xia", "Batman", "celebrity", 76),
("Batman", "", "batman|蝙蝠侠", "celebrity", 76),
# --- Athletes ---
("王楚钦", "wang chu qin", "Wang Chuqin", "athlete", 88),
("Wang Chuqin", "", "wang chuqin|王楚钦", "athlete", 88),
("孙颖莎", "sun ying sha", "Sun Yingsha", "athlete", 88),
("Sun Yingsha", "", "sun yingsha|孙颖莎", "athlete", 88),
("樊振东", "fan zhen dong", "Fan Zhendong", "athlete", 86),
("Fan Zhendong", "", "fan zhendong|樊振东", "athlete", 86),
("王曼昱", "wang man yu", "Wang Manyu", "athlete", 84),
("Wang Manyu", "", "wang manyu|王曼昱", "athlete", 84),
("郑钦文", "zheng qin wen", "Zheng Qinwen", "athlete", 86),
("Zheng Qinwen", "", "zheng qinwen|郑钦文", "athlete", 86),
("林诗栋", "lin shi dong", "Lin Shidong", "athlete", 80),
("马龙", "ma long", "Ma Long", "athlete", 86),
("Ma Long", "", "ma long|马龙", "athlete", 86),
("全红婵", "quan hong chan", "Quan Hongchan", "athlete", 86),
("Quan Hongchan", "", "quan hongchan|全红婵", "athlete", 86),
("蒯曼", "kuai man", "Kuai Man", "athlete", 76),
("林高远", "lin gao yuan", "Lin Gaoyuan", "athlete", 76),
("苏炳添", "su bing tian", "Su Bingtian", "athlete", 80),
("谷爱凌", "gu ai ling", "Eileen Gu", "athlete", 86),
("Eileen Gu", "", "eileen gu|谷爱凌", "athlete", 86),
("姚明", "yao ming", "Yao Ming", "athlete", 86),
("Yao Ming", "", "yao ming|姚明", "athlete", 86),
("李娜", "li na", "Li Na tennis", "athlete", 80),
("刘翔", "liu xiang", "Liu Xiang", "athlete", 80),
("易建联", "yi jian lian", "Yi Jianlian", "athlete", 78),
("C罗", "C luo", "Cristiano Ronaldo", "athlete", 86),
("Cristiano Ronaldo", "", "cristiano ronaldo|C罗", "athlete", 86),
("梅西", "mei xi", "Lionel Messi", "athlete", 88),
("Lionel Messi", "", "lionel messi|梅西", "athlete", 88),
("姆巴佩", "mu ba pei", "Kylian Mbappé", "athlete", 84),
("Kylian Mbappé", "", "kylian mbappe|姆巴佩", "athlete", 84),
("哈兰德", "ha lan de", "Erling Haaland", "athlete", 82),
("Erling Haaland", "", "erling haaland|哈兰德", "athlete", 82),
("詹姆斯", "zhan mu si", "LeBron James", "athlete", 86),
("LeBron James", "", "lebron james|詹姆斯", "athlete", 86),
("库里", "ku li", "Stephen Curry", "athlete", 84),
("Stephen Curry", "", "stephen curry|库里", "athlete", 84),
("科比", "ke bi", "Kobe Bryant", "athlete", 82),
("Kobe Bryant", "", "kobe bryant|科比", "athlete", 82),
("乔丹", "qiao dan", "Michael Jordan", "athlete", 84),
("Michael Jordan", "", "michael jordan|乔丹", "athlete", 84),
("费德勒", "fei de le", "Roger Federer", "athlete", 80),
("纳达尔", "na da er", "Rafael Nadal", "athlete", 80),
("德约科维奇", "de yue ke wei qi", "Novak Djokovic", "athlete", 82),
("Novak Djokovic", "", "novak djokovic|德约", "athlete", 82),
# --- Consumer brands ---
("Burberry", "", "burberry|博柏利", "consumer_brand", 80),
("博柏利", "bo bai li", "Burberry", "consumer_brand", 80),
("Balenciaga", "", "balenciaga|巴黎世家", "consumer_brand", 82),
("巴黎世家", "ba li shi jia", "Balenciaga", "consumer_brand", 82),
("Versace", "", "versace|范思哲", "consumer_brand", 78),
("范思哲", "fan si zhe", "Versace", "consumer_brand", 78),
("Armani", "", "armani|阿玛尼", "consumer_brand", 80),
("阿玛尼", "a ma ni", "Armani", "consumer_brand", 80),
("Cartier", "", "cartier|卡地亚", "consumer_brand", 82),
("卡地亚", "ka di ya", "Cartier", "consumer_brand", 82),
("Bulgari", "", "bulgari|宝格丽", "consumer_brand", 78),
("宝格丽", "bao ge li", "Bulgari", "consumer_brand", 78),
("Van Cleef", "", "van cleef|梵克雅宝", "consumer_brand", 76),
("梵克雅宝", "fan ke ya bao", "Van Cleef", "consumer_brand", 76),
("Moncler", "", "moncler|盟可睐", "consumer_brand", 78),
("盟可睐", "meng ke lai", "Moncler", "consumer_brand", 78),
("Canada Goose", "", "canada goose|加拿大鹅", "consumer_brand", 78),
("加拿大鹅", "jia na da e", "Canada Goose", "consumer_brand", 78),
("New Balance", "", "new balance|新百伦", "consumer_brand", 80),
("新百伦", "xin bai lun", "New Balance", "consumer_brand", 80),
("Puma", "", "puma|彪马", "consumer_brand", 78),
("彪马", "biao ma", "Puma", "consumer_brand", 78),
("Converse", "", "converse|匡威", "consumer_brand", 76),
("匡威", "kuang wei", "Converse", "consumer_brand", 76),
("Vans", "", "vans", "consumer_brand", 76),
("Skechers", "", "skechers|斯凯奇", "consumer_brand", 76),
("斯凯奇", "si kai qi", "Skechers", "consumer_brand", 76),
("Levi's", "", "levis|李维斯", "consumer_brand", 76),
("李维斯", "li wei si", "Levi's", "consumer_brand", 76),
("Coach", "", "coach|蔻驰", "consumer_brand", 78),
("蔻驰", "kou chi", "Coach", "consumer_brand", 78),
("Michael Kors", "", "michael kors|MK", "consumer_brand", 76),
("Estée Lauder", "", "estee lauder|雅诗兰黛", "consumer_brand", 78),
("雅诗兰黛", "ya shi lan dai", "Estée Lauder", "consumer_brand", 78),
("L'Oréal", "", "loreal|欧莱雅", "consumer_brand", 78),
("欧莱雅", "ou lai ya", "L'Oréal", "consumer_brand", 78),
("Shiseido", "", "shiseido|资生堂", "consumer_brand", 76),
("资生堂", "zi sheng tang", "Shiseido", "consumer_brand", 76),
("SK-II", "", "sk two|SK2", "consumer_brand", 78),
("兰蔻", "lan ke", "Lancôme", "consumer_brand", 78),
("Lancôme", "", "lancome|兰蔻", "consumer_brand", 78),
("YSL", "", "ysl|圣罗兰", "consumer_brand", 78),
("圣罗兰", "sheng luo lan", "YSL", "consumer_brand", 78),
("MAC", "", "mac cosmetics", "consumer_brand", 76),
("完美日记", "wan mei ri ji", "Perfect Diary", "consumer_brand", 80),
("Perfect Diary", "", "perfect diary|完美日记", "consumer_brand", 80),
("花西子", "hua xi zi", "Florasis", "consumer_brand", 78),
("Florasis", "", "florasis|花西子", "consumer_brand", 78),
("名创优品", "ming chuang you pin", "Miniso", "consumer_brand", 78),
("Miniso", "", "miniso|名创优品", "consumer_brand", 78),
("海底捞", "hai di lao", "Haidilao", "consumer_brand", 82),
("Haidilao", "", "haidilao|海底捞", "consumer_brand", 82),
("西贝", "xi bei", "Xibei", "consumer_brand", 76),
("老乡鸡", "lao xiang ji", "Laoxiangji", "consumer_brand", 76),
("盒马", "he ma", "Hema|盒马鲜生", "consumer_brand", 80),
("Hema", "", "hema|盒马", "consumer_brand", 80),
("山姆会员店", "shan mu hui yuan dian", "Sam's Club", "consumer_brand", 80),
("Sam's Club", "", "sams club|山姆", "consumer_brand", 80),
("Costco", "", "costco|开市客", "consumer_brand", 80),
("开市客", "kai shi ke", "Costco", "consumer_brand", 80),
("沃尔玛", "wo er ma", "Walmart", "consumer_brand", 78),
("Walmart", "", "walmart|沃尔玛", "consumer_brand", 78),
("家乐福", "jia le fu", "Carrefour", "consumer_brand", 72),
("宜家", "yi jia", "IKEA", "consumer_brand", 80),
("IKEA", "", "ikea|宜家", "consumer_brand", 80),
("MUJI", "", "muji|无印良品", "consumer_brand", 78),
("无印良品", "wu yin liang pin", "MUJI", "consumer_brand", 78),
("始祖鸟", "shi zu niao", "Arc'teryx", "consumer_brand", 82),
("Arc'teryx", "", "arcteryx|始祖鸟", "consumer_brand", 82),
("北面", "bei mian", "The North Face", "consumer_brand", 78),
("The North Face", "", "north face|北面", "consumer_brand", 78),
("Columbia", "", "columbia sportswear", "consumer_brand", 72),
("李宁", "li ning", "Li-Ning", "consumer_brand", 82),
("Li-Ning", "", "li ning|李宁", "consumer_brand", 82),
("安踏", "an ta", "Anta", "consumer_brand", 82),
("Anta", "", "anta|安踏", "consumer_brand", 82),
("特步", "te bu", "Xtep", "consumer_brand", 76),
("Xtep", "", "xtep|特步", "consumer_brand", 76),
("鸿星尔克", "hong xing er ke", "Erke", "consumer_brand", 76),
("Erke", "", "erke|鸿星尔克", "consumer_brand", 76),
("波司登", "bo si deng", "Bosideng", "consumer_brand", 78),
("Bosideng", "", "bosideng|波司登", "consumer_brand", 78),
("周大福", "zhou da fu", "Chow Tai Fook", "consumer_brand", 76),
("Chow Tai Fook", "", "chow tai fook|周大福", "consumer_brand", 76),
("老凤祥", "lao feng xiang", "Lao Feng Xiang", "consumer_brand", 74),
("中国黄金", "zhong guo huang jin", "China Gold", "consumer_brand", 74),
# --- Auto / EV ---
("领克", "ling ke", "Lynk & Co|Lynk", "tech_company", 84),
("Lynk & Co", "", "lynk co|领克", "tech_company", 84),
("问界", "wen jie", "AITO|AITO问界", "tech_company", 86),
("AITO", "", "aito|问界", "tech_company", 86),
("智界", "zhi jie", "Luxeed", "tech_company", 84),
("Luxeed", "", "luxeed|智界", "tech_company", 84),
("享界", "xiang jie", "Stelato", "tech_company", 82),
("Stelato", "", "stelato|享界", "tech_company", 82),
("尊界", "zun jie", "MAEXTRO", "tech_company", 82),
("MAEXTRO", "", "maextro|尊界", "tech_company", 82),
("小米YU7", "xiao mi YU7", "Xiaomi YU7|YU7", "tech_company", 88),
("YU7", "", "xiaomi yu7|小米YU7", "tech_company", 86),
("理想L9", "li xiang L9", "Li L9", "tech_company", 82),
("Li L9", "", "li l9|理想L9", "tech_company", 82),
("理想MEGA", "li xiang MEGA", "Li MEGA|MEGA", "tech_company", 82),
("小鹏MONA", "xiao peng MONA", "MONA", "tech_company", 80),
("极狐", "ji hu", "ARCFOX", "tech_company", 80),
("ARCFOX", "", "arcfox|极狐", "tech_company", 80),
("阿维塔", "a wei ta", "Avatr", "tech_company", 82),
("Avatr", "", "avatr|阿维塔", "tech_company", 82),
("深蓝", "shen lan", "Deepal", "tech_company", 80),
("Deepal", "", "deepal|深蓝", "tech_company", 80),
("岚图", "lan tu", "Voyah", "tech_company", 82),
("Voyah", "", "voyah|岚图", "tech_company", 82),
("腾势", "teng shi", "Denza", "tech_company", 82),
("Denza", "", "denza|腾势", "tech_company", 82),
("方程豹", "fang cheng bao", "Fangchengbao", "tech_company", 82),
("Fangchengbao", "", "fangchengbao|方程豹", "tech_company", 82),
("仰望", "yang wang", "Yangwang", "tech_company", 84),
("Yangwang", "", "yangwang|仰望", "tech_company", 84),
("智己", "zhi ji", "IM Motors", "tech_company", 82),
("IM Motors", "", "im motors|智己", "tech_company", 82),
("飞凡", "fei fan", "Rising Auto", "tech_company", 76),
("昊铂", "hao bo", "Hyper", "tech_company", 78),
("Hyper", "", "hyper|昊铂", "tech_company", 78),
("广汽埃安", "guang qi ai an", "Aion", "tech_company", 82),
("Aion", "", "aion|埃安", "tech_company", 82),
("哪吒汽车", "na zha qi che", "Neta Auto", "tech_company", 80),
("Neta Auto", "", "neta auto|哪吒汽车", "tech_company", 80),
("零跑", "ling pao", "Leapmotor", "tech_company", 84),
("Leapmotor", "", "leapmotor|零跑", "tech_company", 84),
("赛力斯", "sai li si", "Seres", "tech_company", 84),
("Seres", "", "seres|赛力斯", "tech_company", 84),
("长安", "chang an", "Changan", "tech_company", 78),
("Changan", "", "changan|长安", "tech_company", 78),
("上汽", "shang qi", "SAIC", "tech_company", 78),
("SAIC", "", "saic|上汽", "tech_company", 78),
("一汽", "yi qi", "FAW", "tech_company", 76),
("FAW", "", "faw|一汽", "tech_company", 76),
("东风", "dong feng", "Dongfeng", "tech_company", 76),
("Dongfeng", "", "dongfeng|东风", "tech_company", 76),
("奇瑞", "qi rui", "Chery", "tech_company", 78),
("Chery", "", "chery|奇瑞", "tech_company", 78),
("吉利银河", "ji li yin he", "Galaxy Geely", "tech_company", 78),
("路特斯", "lu te si", "Lotus", "tech_company", 80),
("Lotus", "", "lotus|路特斯", "tech_company", 80),
("保时捷", "bao shi jie", "Porsche", "tech_company", 84),
("Porsche", "", "porsche|保时捷", "tech_company", 84),
("法拉利", "fa la li", "Ferrari", "tech_company", 82),
("Ferrari", "", "ferrari|法拉利", "tech_company", 82),
("兰博基尼", "lan bo ji ni", "Lamborghini", "tech_company", 82),
("Lamborghini", "", "lamborghini|兰博基尼", "tech_company", 82),
("奔驰", "ben chi", "Mercedes-Benz", "tech_company", 84),
("Mercedes-Benz", "", "mercedes benz|奔驰", "tech_company", 84),
("宝马", "bao ma", "BMW", "tech_company", 84),
("BMW", "", "bmw|宝马", "tech_company", 84),
("大众", "da zhong", "Volkswagen", "tech_company", 80),
("Volkswagen", "", "volkswagen|大众", "tech_company", 80),
("丰田", "feng tian", "Toyota", "tech_company", 80),
("Toyota", "", "toyota|丰田", "tech_company", 80),
("本田", "ben tian", "Honda", "tech_company", 78),
("Honda", "", "honda|本田", "tech_company", 78),
# --- Tech / semicon / cloud ---
("Anduril", "", "anduril industries", "tech_company", 80),
("Scale AI", "", "scale ai|Scale", "tech_company", 84),
("Labelbox", "", "labelbox", "tech_company", 74),
("Weights & Biases", "", "weights and biases|W&B", "dev_tool", 80),
("W&B", "", "weights and biases", "dev_tool", 78),
("Langfuse", "", "langfuse", "dev_tool", 78),
("Helicone", "", "helicone", "dev_tool", 74),
("Braintrust", "", "braintrust ai", "dev_tool", 76),
("Arize", "", "arize ai|Arize AI", "dev_tool", 74),
("CoreWeave", "", "coreweave", "tech_company", 82),
("Lambda Labs", "", "lambda labs|Lambda", "tech_company", 78),
("Crusoe", "", "crusoe energy", "tech_company", 74),
("RunPod", "", "run pod|RunPod", "tech_company", 78),
("Groq Cloud", "", "groq cloud", "ai_platform", 78),
("Baseten", "", "baseten", "ai_platform", 76),
("Anyscale", "", "anyscale|Ray", "tech_company", 78),
("Ray Serve", "", "ray serve", "dev_tool", 76),
("Micron", "", "micron|美光", "tech_company", 82),
("美光", "mei guang", "Micron", "tech_company", 82),
("SK Hynix", "", "sk hynix|海力士", "tech_company", 82),
("海力士", "hai li shi", "SK Hynix", "tech_company", 82),
("Kioxia", "", "kioxia|铠侠", "tech_company", 76),
("铠侠", "kai xia", "Kioxia", "tech_company", 76),
("Western Digital", "", "western digital|西部数据", "tech_company", 78),
("西部数据", "xi bu shu ju", "Western Digital", "tech_company", 78),
("MediaTek", "", "mediatek|联发科", "tech_company", 82),
("联发科", "lian fa ke", "MediaTek", "tech_company", 82),
("Rockchip", "", "rockchip|瑞芯微", "tech_company", 78),
("瑞芯微", "rui xin wei", "Rockchip", "tech_company", 78),
("Allwinner", "", "allwinner|全志", "tech_company", 74),
("全志", "quan zhi", "Allwinner", "tech_company", 74),
("寒武纪", "han wu ji", "Cambricon", "tech_company", 80),
("Cambricon", "", "cambricon|寒武纪", "tech_company", 80),
("壁仞", "bi ren", "Biren", "tech_company", 78),
("Biren", "", "biren|壁仞", "tech_company", 78),
("摩尔线程", "mo er xian cheng", "Moore Threads", "tech_company", 80),
("Moore Threads", "", "moore threads|摩尔线程", "tech_company", 80),
("燧原", "sui yuan", "Enflame", "tech_company", 76),
("Enflame", "", "enflame|燧原", "tech_company", 76),
("平头哥", "ping tou ge", "T-Head", "tech_company", 78),
("T-Head", "", "t head|平头哥", "tech_company", 78),
("紫光", "zi guang", "Unigroup", "tech_company", 76),
("Unigroup", "", "unigroup|紫光", "tech_company", 76),
("长鑫", "chang xin", "CXMT", "tech_company", 78),
("CXMT", "", "cxmt|长鑫存储", "tech_company", 78),
("长江存储", "chang jiang cun chu", "YMTC", "tech_company", 80),
("YMTC", "", "ymtc|长江存储", "tech_company", 80),
("商汤绝影", "shang tang jue ying", "SenseAuto", "tech_company", 78),
("绝影", "jue ying", "SenseAuto", "tech_company", 76),
("Momenta", "", "momenta|魔门塔", "tech_company", 82),
("魔门塔", "mo men ta", "Momenta", "tech_company", 80),
("文远知行", "wen yuan zhi xing", "WeRide", "tech_company", 82),
("WeRide", "", "weride|文远知行", "tech_company", 82),
("小马智行", "xiao ma zhi xing", "Pony.ai", "tech_company", 82),
("Pony.ai", "", "pony ai|小马智行", "tech_company", 82),
("元戎启行", "yuan rong qi xing", "DeepRoute", "tech_company", 78),
("DeepRoute", "", "deeproute|元戎启行", "tech_company", 78),
("禾赛", "he sai", "Hesai", "tech_company", 80),
("Hesai", "", "hesai|禾赛", "tech_company", 80),
("速腾聚创", "su teng ju chuang", "RoboSense", "tech_company", 80),
("RoboSense", "", "robosense|速腾聚创", "tech_company", 80),
("图达通", "tu da tong", "Innovusion", "tech_company", 76),
("Innovusion", "", "innovusion|图达通", "tech_company", 76),
("OpenHarmony", "", "open harmony|开源鸿蒙", "tech_company", 82),
("开源鸿蒙", "kai yuan hong meng", "OpenHarmony", "tech_company", 82),
("openEuler", "", "open euler|欧拉", "tech_company", 76),
("欧拉", "ou la", "openEuler", "tech_company", 76),
("龙芯", "long xin", "Loongson", "tech_company", 78),
("Loongson", "", "loongson|龙芯", "tech_company", 78),
("飞腾", "fei teng", "Phytium", "tech_company", 76),
("Phytium", "", "phytium|飞腾", "tech_company", 76),
("鲲鹏", "kun peng", "Kunpeng", "tech_company", 80),
("Kunpeng", "", "kunpeng|鲲鹏", "tech_company", 80),
("飞书", "fei shu", "Lark", "tech_company", 86),
("Lark", "", "lark|飞书", "tech_company", 86),
("钉钉", "ding ding", "DingTalk", "tech_company", 84),
("DingTalk", "", "dingtalk|钉钉", "tech_company", 84),
("企业微信", "qi ye wei xin", "WeCom", "tech_company", 84),
("WeCom", "", "wecom|企业微信", "tech_company", 84),
# --- AI / internet hot terms ---
("skill", "", "skills|Agent Skills", "ai_term", 78),
("skills", "", "agent skills|Claude Skills", "ai_term", 76),
("Agent Skills", "", "agent skills|Claude Skills", "ai_term", 82),
("Claude Skills", "", "claude skills", "ai_term", 80),
("MCP协议", "MCP xie yi", "MCP protocol", "ai_term", 84),
("A2A", "", "agent to agent|Agent2Agent", "ai_term", 82),
("Agent2Agent", "", "a2a|agent to agent", "ai_term", 80),
("A2A协议", "A2A xie yi", "Agent2Agent", "ai_term", 78),
("deep agent", "", "deep agents", "ai_term", 82),
("deep agents", "", "deep agent", "ai_term", 80),
("agentic workflow", "", "agentic workflows", "ai_term", 84),
("agentic coding", "", "agentic code", "ai_term", 82),
("spec driven", "", "spec-driven|规格驱动", "ai_term", 80),
("spec-driven", "", "spec driven", "ai_term", 80),
("规格驱动", "gui ge qu dong", "spec-driven", "ai_term", 78),
("vibe marketing", "", "氛围营销", "ai_term", 76),
("氛围营销", "fen wei ying xiao", "vibe marketing", "ai_term", 74),
("AI原生应用", "AI yuan sheng ying yong", "AI native app", "ai_term", 80),
("AI应用", "AI ying yong", "AI app", "ai_term", 78),
("模型蒸馏", "mo xing zheng liu", "model distillation", "ai_term", 80),
("model distillation", "", "模型蒸馏", "ai_term", 78),
("合成数据", "he cheng shu ju", "synthetic data", "ai_term", 80),
("synthetic data", "", "合成数据", "ai_term", 78),
("数据飞轮", "shu ju fei lun", "data flywheel", "ai_term", 78),
("data flywheel", "", "数据飞轮", "ai_term", 76),
("长上下文", "chang shang xia wen", "long context", "ai_term", 82),
("long context", "", "长上下文", "ai_term", 80),
("百万上下文", "bai wan shang xia wen", "million context", "ai_term", 80),
("million context", "", "百万上下文", "ai_term", 78),
("KV cache", "", "kv cache|KV缓存", "ai_term", 80),
("KV缓存", "KV huan cun", "KV cache", "ai_term", 78),
("投机解码", "tou ji jie ma", "speculative decoding", "ai_term", 76),
("speculative decoding", "", "投机解码", "ai_term", 74),
("MoE路由", "MoE lu you", "expert routing", "ai_term", 76),
("expert routing", "", "MoE路由", "ai_term", 74),
("稀疏注意力", "xi shu zhu yi li", "sparse attention", "ai_term", 74),
("sparse attention", "", "稀疏注意力", "ai_term", 72),
("视频大模型", "shi pin da mo xing", "video foundation model", "ai_term", 82),
("video foundation model", "", "视频大模型", "ai_term", 80),
("多智能体协作", "duo zhi neng ti xie zuo", "multi-agent collaboration", "ai_term", 80),
("AI陪伴", "AI pei ban", "AI companion", "ai_term", 78),
("AI companion", "", "AI陪伴", "ai_term", 76),
("数字人", "shu zi ren", "digital human", "ai_term", 82),
("digital human", "", "数字人", "ai_term", 80),
("虚拟偶像", "xu ni ou xiang", "virtual idol", "ai_term", 76),
("virtual idol", "", "虚拟偶像", "ai_term", 74),
("赛博永生", "sai bo yong sheng", "cyber immortality", "tech_term", 72),
("赛博菩萨", "sai bo pu sa", "cyber bodhisattva", "tech_term", 70),
("赛博佛祖", "sai bo fo zu", "cyber buddha", "tech_term", 70),
("抽象", "chou xiang", "abstract meme", "tech_term", 72),
("钝感力", "dun gan li", "emotional bluntness", "tech_term", 74),
("松弛感", "song chi gan", "relaxed vibe", "tech_term", 76),
("情绪稳定", "qing xu wen ding", "emotionally stable", "tech_term", 74),
("搭子", "da zi", "buddy partner", "tech_term", 76),
("citywalk", "", "city walk|City Walk", "tech_term", 76),
("City Walk", "", "citywalk", "tech_term", 74),
("特种兵旅游", "te zhong bing lv you", "special forces travel", "tech_term", 72),
("显眼包", "xian yan bao", "attention seeker", "tech_term", 72),
("i人", "i ren", "introvert", "tech_term", 74),
("e人", "e ren", "extrovert", "tech_term", 74),
("MBTI", "", "mbti人格", "tech_term", 76),
("脆皮大学生", "cui pi da xue sheng", "fragile college student", "tech_term", 70),
("班味", "ban wei", "office vibe", "tech_term", 74),
("班味很重", "ban wei hen zhong", "heavy office vibe", "tech_term", 72),
("偷感", "tou gan", "stealth vibe", "tech_term", 72),
("偷感很重", "tou gan hen zhong", "heavy stealth vibe", "tech_term", 70),
("水灵灵", "shui ling ling", "fresh and lively", "tech_term", 70),
("硬控", "ying kong", "hard control meme", "tech_term", 72),
("绝绝子", "jue jue zi", "absolutely amazing", "tech_term", 72),
("尊嘟假嘟", "zun du jia du", "really or not", "tech_term", 70),
("芭比Q了", "ba bi Q le", "barbecue done|finished", "tech_term", 68),
("栓Q", "shuan Q", "thank you meme", "tech_term", 68),
("YYDS", "", "yyds|永远的神", "tech_term", 74),
("永远的神", "yong yuan de shen", "YYDS", "tech_term", 72),
("破防", "po fang", "emotionally broken", "tech_term", 74),
("上头", "shang tou", "addictive hype", "tech_term", 70),
("下头", "xia tou", "turn off vibe", "tech_term", 70),
("拿捏", "na nie", "nailed it", "tech_term", 70),
("种草", "zhong cao", "plant grass recommend", "tech_term", 78),
("拔草", "ba cao", "unrecommend", "tech_term", 74),
("安利", "an li", "recommend", "tech_term", 74),
("避雷", "bi lei", "avoid pitfall", "tech_term", 74),
("测评", "ce ping", "product review", "tech_term", 76),
("开箱", "kai xiang", "unboxing", "tech_term", 76),
("沉浸式", "chen jin shi", "immersive", "tech_term", 74),
("氛围感", "fen wei gan", "atmospheric vibe", "tech_term", 76),
("高级感", "gao ji gan", "premium feel", "tech_term", 74),
("质感", "zhi gan", "texture quality", "tech_term", 72),
("带货", "dai huo", "live commerce sales", "tech_term", 78),
("直播带货", "zhi bo dai huo", "livestream commerce", "tech_term", 80),
("短视频", "duan shi pin", "short video", "tech_term", 78),
("中视频", "zhong shi pin", "mid-length video", "tech_term", 72),
("知识付费", "zhi shi fu fei", "paid knowledge", "tech_term", 74),
("私域", "si yu", "private domain traffic", "tech_term", 78),
("公域", "gong yu", "public domain traffic", "tech_term", 74),
("全域经营", "quan yu jing ying", "omnichannel operation", "tech_term", 74),
("本地化", "ben di hua", "localization", "tech_term", 72),
("GTM", "", "go to market", "tech_term", 74),
("PLG", "", "product led growth", "tech_term", 72),
("SLG", "", "sales led growth", "tech_term", 70),
("ARR", "", "annual recurring revenue", "tech_term", 72),
("MRR", "", "monthly recurring revenue", "tech_term", 70),
("NDR", "", "net dollar retention", "tech_term", 68),
("CAC", "", "customer acquisition cost", "tech_term", 70),
("LTV", "", "lifetime value", "tech_term", 70),
("融资", "rong zi", "fundraising", "tech_term", 74),
("估值", "gu zhi", "valuation", "tech_term", 74),
("IPO", "", "initial public offering|上市", "tech_term", 76),
("上市", "shang shi", "IPO listing", "tech_term", 74),
("独角兽", "du jiao shou", "unicorn startup", "tech_term", 76),
("decacorn", "", "decacorn startup", "tech_term", 68),
("A轮", "A lun", "Series A", "tech_term", 72),
("B轮", "B lun", "Series B", "tech_term", 70),
("C轮", "C lun", "Series C", "tech_term", 68),
("Pre-A", "", "pre a round", "tech_term", 68),
("降本增效", "jiang ben zeng xiao", "cost cut efficiency", "tech_term", 78),
("裁员", "cai yuan", "layoffs", "tech_term", 74),
("优化", "you hua", "layoff euphemism", "tech_term", 70),
("毕业", "bi ye", "layoff euphemism", "tech_term", 68),
("35岁危机", "35 sui wei ji", "age 35 crisis", "tech_term", 72),
("考公", "kao gong", "civil service exam", "tech_term", 74),
("考编", "kao bian", "public institution exam", "tech_term", 72),
("灵活就业", "ling huo jiu ye", "flexible employment", "tech_term", 70),
("银发经济", "yin fa jing ji", "silver economy", "tech_term", 78),
("宠物经济", "chong wu jing ji", "pet economy", "tech_term", 74),
("悦己消费", "yue ji xiao fei", "self-reward spending", "tech_term", 74),
("情绪消费", "qing xu xiao fei", "emotional spending", "tech_term", 74),
("体验经济", "ti yan jing ji", "experience economy", "tech_term", 72),
("首发经济", "shou fa jing ji", "launch economy", "tech_term", 74),
("冰雪经济", "bing xue jing ji", "ice snow economy", "tech_term", 72),
("以旧换新", "yi jiu huan xin", "trade-in program", "tech_term", 78),
("国补", "guo bu", "national subsidy", "tech_term", 80),
("消费券", "xiao fei quan", "consumption voucher", "tech_term", 72),
("数据要素", "shu ju yao su", "data as factor", "tech_term", 78),
("数据资产", "shu ju zi chan", "data assets", "tech_term", 76),
("数据确权", "shu ju que quan", "data rights confirmation", "tech_term", 74),
("耐心资本", "nai xin zi ben", "patient capital", "tech_term", 76),
("长期主义", "chang qi zhu yi", "long-termism", "tech_term", 76),
("价值投资者", "jia zhi tou zi zhe", "value investor", "tech_term", 72),
("价值投资", "jia zhi tou zi", "value investing", "tech_term", 72),
# --- Games / media IP ---
("米哈游", "mi ha you", "miHoYo", "game_ip", 86),
("miHoYo", "", "mihoyo|米哈游", "game_ip", 86),
("原神", "yuan shen", "Genshin Impact", "game_ip", 88),
("Genshin Impact", "", "genshin impact|原神", "game_ip", 88),
("崩坏星穹铁道", "beng huai xing qiong tie dao", "Honkai Star Rail", "game_ip", 86),
("Honkai Star Rail", "", "honkai star rail|星穹铁道", "game_ip", 86),
("星穹铁道", "xing qiong tie dao", "Honkai Star Rail", "game_ip", 84),
("崩坏", "beng huai", "Honkai", "game_ip", 80),
("Honkai", "", "honkai|崩坏", "game_ip", 80),
("绝区零", "jue qu ling", "Zenless Zone Zero", "game_ip", 84),
("Zenless Zone Zero", "", "zenless zone zero|ZZZ", "game_ip", 84),
("鸣潮", "ming chao", "Wuthering Waves", "game_ip", 82),
("Wuthering Waves", "", "wuthering waves|鸣潮", "game_ip", 82),
("腾讯游戏", "teng xun you xi", "Tencent Games", "game_ip", 84),
("王者荣耀", "wang zhe rong yao", "Honor of Kings", "game_ip", 88),
("Honor of Kings", "", "honor of kings|王者", "game_ip", 88),
("和平精英", "he ping jing ying", "PUBG Mobile China", "game_ip", 86),
("PUBG Mobile", "", "pubg mobile|和平精英", "game_ip", 84),
("英雄联盟", "ying xiong lian meng", "League of Legends", "game_ip", 86),
("League of Legends", "", "league of legends|LOL", "game_ip", 86),
("无畏契约", "wu wei qi yue", "Valorant", "game_ip", 84),
("Valorant", "", "valorant|无畏契约", "game_ip", 84),
("DOTA2", "", "dota 2|Dota 2", "game_ip", 82),
("Dota 2", "", "dota2|DOTA2", "game_ip", 82),
("CS2", "", "counter strike 2|CSGO", "game_ip", 82),
("Counter-Strike", "", "counter strike|CS2", "game_ip", 80),
("Steam", "", "steam平台", "game_ip", 86),
("Epic Games", "", "epic games|Epic", "game_ip", 80),
("育碧", "yu bi", "Ubisoft", "game_ip", 78),
("Ubisoft", "", "ubisoft|育碧", "game_ip", 78),
("暴雪", "bao xue", "Blizzard", "game_ip", 82),
("Blizzard", "", "blizzard|暴雪", "game_ip", 82),
("动视", "dong shi", "Activision", "game_ip", 80),
("Activision", "", "activision|动视", "game_ip", 80),
("PlayStation", "", "playstation|PS5", "game_ip", 82),
("PS5", "", "playstation 5", "game_ip", 80),
("Xbox", "", "xbox series", "game_ip", 80),
("艾尔登法环", "ai er deng fa huan", "Elden Ring", "game_ip", 82),
("Elden Ring", "", "elden ring|老头环", "game_ip", 82),
("老头环", "lao tou huan", "Elden Ring", "game_ip", 80),
("何同学", "he tong xue", "何同学UP主", "celebrity", 82),
("老师好我叫何同学", "lao shi hao wo jiao he tong xue", "何同学", "celebrity", 80),
("罗翔", "luo xiang", "罗翔说刑法", "celebrity", 84),
("罗翔说刑法", "luo xiang shuo xing fa", "罗翔", "celebrity", 82),
("papi酱", "papi jiang", "papi", "celebrity", 78),
("李子柒", "li zi qi", "Li Ziqi", "celebrity", 84),
("Li Ziqi", "", "li ziqi|李子柒", "celebrity", 84),
("董宇辉", "dong yu hui", "Dong Yuhui", "celebrity", 84),
("Dong Yuhui", "", "dong yuhui|董宇辉", "celebrity", 84),
("东方甄选", "dong fang zhen xuan", "East Buy", "tech_company", 80),
("疯狂小杨哥", "feng kuang xiao yang ge", "Xiao Yangge", "celebrity", 78),
("李佳琦", "li jia qi", "Austin Li", "celebrity", 84),
("Austin Li", "", "austin li|李佳琦", "celebrity", 84),
("薇娅", "wei ya", "Viya", "celebrity", 76),
("Viya", "", "viya|薇娅", "celebrity", 76),
("交个朋友", "jiao ge peng you", "Make Friends Live", "tech_company", 78),
("交个朋友直播间", "jiao ge peng you zhi bo jian", "交个朋友", "tech_term", 76),
# --- Finance / investment ---
("伯克希尔", "bo ke xi er", "Berkshire Hathaway", "fintech", 82),
("Berkshire Hathaway", "", "berkshire hathaway|伯克希尔", "fintech", 82),
("芒格", "mang ge", "Charlie Munger", "tech_leader", 80),
("Charlie Munger", "", "charlie munger|芒格", "tech_leader", 80),
("索罗斯", "suo luo si", "George Soros", "tech_leader", 78),
("George Soros", "", "george soros|索罗斯", "tech_leader", 78),
("达利欧", "da li ou", "Ray Dalio", "tech_leader", 80),
("Ray Dalio", "", "ray dalio|达利欧", "tech_leader", 80),
("桥水", "qiao shui", "Bridgewater", "fintech", 80),
("Bridgewater", "", "bridgewater|桥水基金", "fintech", 80),
("黑石", "hei shi", "Blackstone", "fintech", 80),
("Blackstone", "", "blackstone|黑石集团", "fintech", 80),
("KKR", "", "kkr", "fintech", 76),
("红杉", "hong shan", "Sequoia", "fintech", 80),
("Sequoia", "", "sequoia|红杉资本", "fintech", 80),
("a16z", "", "andreessen horowitz|A16Z", "fintech", 82),
("Andreessen Horowitz", "", "a16z|andreessen horowitz", "fintech", 82),
("高瓴", "gao ling", "Hillhouse", "fintech", 80),
("Hillhouse", "", "hillhouse|高瓴资本", "fintech", 80),
("IDG", "", "idg capital", "fintech", 76),
("经纬", "jing wei", "Matrix Partners China", "fintech", 76),
("Matrix Partners", "", "matrix partners|经纬创投", "fintech", 76),
("真格", "zhen ge", "ZhenFund", "fintech", 76),
("ZhenFund", "", "zhen fund|真格基金", "fintech", 76),
("源码", "yuan ma", "Source Code Capital", "fintech", 74),
("中金", "zhong jin", "CICC", "fintech", 78),
("CICC", "", "cicc|中金公司", "fintech", 78),
("中信", "zhong xin", "CITIC", "fintech", 78),
("CITIC", "", "citic|中信证券", "fintech", 78),
("华泰", "hua tai", "Huatai", "fintech", 76),
("Huatai", "", "huatai|华泰证券", "fintech", 76),
("国泰君安", "guo tai jun an", "Guotai Junan", "fintech", 76),
("Guotai Junan", "", "guotai junan|国泰君安", "fintech", 76),
("摩根大通", "mo gen da tong", "JPMorgan", "fintech", 80),
("JPMorgan", "", "jp morgan|摩根大通", "fintech", 80),
("高盛", "gao sheng", "Goldman Sachs", "fintech", 82),
("Goldman Sachs", "", "goldman sachs|高盛", "fintech", 82),
("摩根士丹利", "mo gen shi dan li", "Morgan Stanley", "fintech", 80),
("Morgan Stanley", "", "morgan stanley|摩根士丹利", "fintech", 80),
("纳斯达克", "na si da ke", "Nasdaq", "fintech", 80),
("Nasdaq", "", "nasdaq|纳斯达克", "fintech", 80),
("标普500", "biao pu 500", "S&P 500", "fintech", 78),
("S&P 500", "", "s and p 500|标普500", "fintech", 78),
("道琼斯", "dao qiong si", "Dow Jones", "fintech", 76),
("Dow Jones", "", "dow jones|道琼斯", "fintech", 76),
("恒生指数", "heng sheng zhi shu", "Hang Seng Index", "fintech", 76),
("沪深300", "hu shen 300", "CSI 300", "fintech", 76),
("比特币", "bi te bi", "Bitcoin", "fintech", 84),
("Bitcoin", "", "bitcoin|BTC", "fintech", 84),
("BTC", "", "bitcoin|比特币", "fintech", 80),
("以太坊", "yi tai fang", "Ethereum", "fintech", 82),
("Ethereum", "", "ethereum|ETH", "fintech", 82),
("ETH", "", "ethereum|以太坊", "fintech", 78),
("Solana", "", "solana crypto", "fintech", 78),
("狗狗币", "gou gou bi", "Dogecoin", "fintech", 76),
("Dogecoin", "", "dogecoin|狗狗币", "fintech", 76),
("美联储", "mei lian chu", "Federal Reserve", "fintech", 82),
("Federal Reserve", "", "federal reserve|美联储", "fintech", 82),
("鲍威尔", "bao wei er", "Jerome Powell", "tech_leader", 80),
("Jerome Powell", "", "jerome powell|鲍威尔", "tech_leader", 80),
("加息", "jia xi", "rate hike", "fintech", 74),
("降息", "jiang xi", "rate cut", "fintech", 76),
("量化宽松", "liang hua kuan song", "quantitative easing", "fintech", 74),
]
def load_existing_terms(seed_path: Path) -> set[str]:
terms: set[str] = set()
with seed_path.open(encoding="utf-8") as handle:
for raw_line in handle:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
parts = line.split("\t")
if not parts:
continue
word = parts[0].strip()
if word:
terms.add(word.lower())
if len(parts) > 2:
for alias in parts[2].split("|"):
alias = alias.strip()
if alias:
terms.add(alias.lower())
return terms
def format_row(word: str, pinyin: str, aliases: str, category: str, weight: int) -> str:
return f"{word}\t{pinyin}\t{aliases}\t{category}\t{weight}"
def main() -> int:
existing = load_existing_terms(SEED_PATH)
seen_in_batch: set[str] = set()
to_append: list[str] = []
skipped: list[str] = []
for word, pinyin, aliases, category, weight in EXPANSION:
key = word.strip().lower()
if not key:
continue
if key in existing or key in seen_in_batch:
skipped.append(word)
continue
seen_in_batch.add(key)
to_append.append(format_row(word, pinyin, aliases, category, weight))
original = SEED_PATH.read_text(encoding="utf-8")
if not original.endswith("\n"):
original += "\n"
block = [
"",
"# --- Batch 2 expansion (2026-07-06): AI, celebrities, brands, hot terms ---",
*to_append,
"",
]
SEED_PATH.write_text(original + "\n".join(block), encoding="utf-8")
print(f"Appended: {len(to_append)} rows")
print(f"Skipped duplicates: {len(skipped)}")
if skipped:
preview = ", ".join(skipped[:40])
suffix = "..." if len(skipped) > 40 else ""
print(f"Skipped preview: {preview}{suffix}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
# Personal Dictionary iCloud KVS — Manual Verification Checklist
Use this on **macOS with Xcode 16+** and at least two devices signed into the **same Apple ID** with iCloud Drive / iCloud enabled.
## Prerequisites
1. In Apple Developer Portal, enable **iCloud****Key-value storage** for `com.osgkeyboard.ios`.
2. Regenerate provisioning profiles after entitlements change.
3. Run `xcodegen generate` and install a fresh build on each device.
## Scenarios
| # | Steps | Expected |
|---|--------|----------|
| 1 | Device A: open Personal Dictionary, enable **Sync via iCloud**, add term `TestWordA` | Toggle stays on; term appears locally |
| 2 | Device B: open app → Personal Dictionary tab | `TestWordA` appears after pull (may take up to ~1 min) |
| 3 | Device B: add `TestWordB` | Device A eventually shows both terms |
| 4 | Both devices: edit same term offline, then go online | Newer edit wins; aliases union when terms match |
| 5 | Device A: delete a term | Term disappears on Device B after sync |
| 6 | Device A: disable iCloud sync | Local dictionary remains; Device B stops receiving new edits from A |
| 7 | Sign out of iCloud on one device | App keeps local dictionary; sync errors may surface in UI |
| 8 | Keyboard extension on Device A | Uses App Group cache immediately after main-app save — no iCloud wait |
## Notes
- KVS propagation is **eventual**; force-quit and reopen the app to speed up pulls.
- The keyboard extension never talks to iCloud directly; only the main app syncs.
- Payload limit is ~1 MB per key; very large dictionaries should show the “too large” error.
+6
View File
@@ -79,6 +79,12 @@ targets:
# specify kSecAttrAccessGroup explicitly. # specify kSecAttrAccessGroup explicitly.
keychain-access-groups: keychain-access-groups:
- $(AppIdentifierPrefix)com.osgkeyboard.shared - $(AppIdentifierPrefix)com.osgkeyboard.shared
# iCloud Key-Value Store — personal dictionary sync across
# iPhone / iPad / Mac (Designed for iPad). Main app only.
# NOTE: this entitlement is a single STRING, not an array — an
# array value never matches the profile and breaks automatic
# signing ("doesn't match the entitlements file's value").
com.apple.developer.ubiquity-kvstore-identifier: $(TeamIdentifierPrefix)com.osgkeyboard.ios
resources: resources:
- path: OSGKeyboard/Assets.xcassets - path: OSGKeyboard/Assets.xcassets
- path: OSGKeyboard/en.lproj - path: OSGKeyboard/en.lproj