Files
OSGKeyboard/OSGKeyboardShared/Services/LocalASRBiasAdapter.swift
T
Rocky 200265fbd6 feat(macos): local ASR model manager, menu-bar polish, and release 0.5.2
Adds a bundled local ASR model catalog for the macOS app with one-click
Sherpa Qwen3 / SenseVoice downloads (pause/resume, inline actions) and a
shared model storage directory used by MLX Qwen3. Fixes the light-mode
sidebar material and makes the menu-bar icon follow the system appearance
with a refreshed status mark. Renames the built product to OSGKeyboard.app.

Bumps version to 0.5.2 (build 19).
2026-07-09 08:55:37 +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: "、"))"
}
}