Files
OSGKeyboard/OSGKeyboardShared/Services/LocalASRBiasAdapter.swift
T
Rocky 498f407585 feat(account): add managed credits and cloud gateway
Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
2026-08-20 11:43:21 +08:00

160 lines
5.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// LocalASRBiasAdapter.swift
// OSGKeyboard · Shared
//
// Maps `PersonalDictionary` + builtin lexicon + runtime context into the
// layered bias outputs consumed by local ASR, correction, and polish.
import Foundation
public enum LocalASRBiasAdapter {
/// Bundle IDs where computer-science vocabulary is especially likely.
private static let codeEditorBundleIDs: Set<String> = [
"com.apple.dt.Xcode",
"com.microsoft.VSCode",
"com.google.android.studio",
"com.jetbrains.intellij",
"com.jetbrains.AppCode",
"com.sublimetext.4",
"com.apple.Terminal",
"com.googlecode.iterm2",
"dev.warp.Warp-Stable"
]
public static func adapt(
_ request: LocalASRBiasRequest,
lexicon: BuiltinLexiconIndex = .shared
) -> LocalASRBiasPayload {
let capabilities = request.capabilities
let dictionary = request.dictionary
var selectedSources = ["user"]
let preferredSources = Self.preferredLexiconSources(for: request.frontAppBundleId)
if preferredSources != nil {
selectedSources.append("builtin-computer")
} else {
selectedSources.append("builtin-top")
}
let userSorted = dictionary.effectiveEntries.sorted { $0.usageCount > $1.usageCount }
var mergedTerms: [String] = []
var seen = Set<String>()
func appendTerm(_ term: String) {
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let key = trimmed.lowercased()
guard seen.insert(key).inserted else { return }
mergedTerms.append(trimmed)
}
for entry in userSorted {
appendTerm(entry.term)
}
let userTermCount = mergedTerms.count
let builtinWords = lexicon.topTerms(
limit: request.builtinASRLimit,
minimumWeight: 4,
preferredSources: preferredSources
)
let beforeBuiltin = mergedTerms.count
for word in builtinWords {
appendTerm(word)
}
let builtinTermCount = mergedTerms.count - beforeBuiltin
var hardHotwords: [String] = []
switch capabilities.hotwordMode {
case .perRequest, .recognizerScoped:
let cap = max(capabilities.maxHotwordCount, 1)
hardHotwords = Self.hardHotwordList(from: mergedTerms, maxCount: cap)
case .cloudVocabulary:
hardHotwords = dictionary.asrHotwords(maxCount: max(capabilities.maxHotwordCount, 1))
case .none, .promptOnly:
break
}
var promptBias: String?
var truncated = false
var truncationReason: String?
if capabilities.hotwordMode == .promptOnly, capabilities.maxPromptCharacters > 0 {
let built = Self.buildPromptBias(
dictionary: dictionary,
builtinTerms: builtinWords,
maxCharacters: capabilities.maxPromptCharacters
)
if built.count > capabilities.maxPromptCharacters {
truncated = true
truncationReason = "promptBias exceeded \(capabilities.maxPromptCharacters) characters"
}
promptBias = built.isEmpty ? nil : built
}
let polishFragment = Self.buildPolishFragment(
dictionary: dictionary,
builtinTerms: builtinWords,
maxTerms: request.builtinPolishLimit
)
let correctionPairs = dictionary.localCorrectionPairs()
return LocalASRBiasPayload(
hardHotwords: hardHotwords,
promptBias: promptBias,
corpusContext: promptBias,
polishFragment: polishFragment,
correctionPairs: correctionPairs,
diagnostics: LocalASRBiasDiagnostics(
userTermCount: userTermCount,
builtinTermCount: builtinTermCount,
truncated: truncated,
truncationReason: truncationReason,
selectedSources: selectedSources
)
)
}
// MARK: - Private
private static func preferredLexiconSources(for bundleId: String?) -> Set<String>? {
guard let bundleId, codeEditorBundleIDs.contains(bundleId) else { return nil }
return ["computer_terms"]
}
private static func hardHotwordList(from terms: [String], maxCount: Int) -> [String] {
Array(terms.prefix(maxCount))
}
private static func buildPromptBias(
dictionary: PersonalDictionary,
builtinTerms: [String],
maxCharacters: Int
) -> String {
let userBias = dictionary.asrPromptBias(maxCharacters: maxCharacters)
let userTermsLower = Set(dictionary.effectiveEntries.map { $0.term.lowercased() })
let extras = builtinTerms.filter { !userTermsLower.contains($0.lowercased()) }
guard !extras.isEmpty else { return userBias }
let extraBlock = "常见技术词汇:\(extras.prefix(80).joined(separator: "、"))"
if userBias.isEmpty {
return String(extraBlock.prefix(maxCharacters))
}
let combined = userBias + "" + extraBlock
return String(combined.prefix(maxCharacters))
}
private static func buildPolishFragment(
dictionary: PersonalDictionary,
builtinTerms: [String],
maxTerms: Int
) -> String {
let userTermsLower = Set(dictionary.effectiveEntries.map { $0.term.lowercased() })
let extras = builtinTerms
.filter { !userTermsLower.contains($0.lowercased()) }
.prefix(maxTerms)
guard !extras.isEmpty else { return "" }
return "内置技术词汇参考(需原样保留):\(extras.joined(separator: "、"))"
}
}