631617ee53
Idle chips show entities with category icons; a fresh copy surfaces Reply/Summarize/Translate; abort/cancel/empty-tap no longer leave the mic stuck.
163 lines
6.0 KiB
Swift
163 lines
6.0 KiB
Swift
// AIHintKeywordExtractor.swift
|
||
// OSGKeyboard · Shared
|
||
//
|
||
// Deterministic idle-chip labels: the icon carries type, the text is the
|
||
// entity. LLM compression is only a last resort for leftovers.
|
||
|
||
import Foundation
|
||
|
||
public enum AIHintKeywordExtractor: Sendable {
|
||
public static func characterLimit(locale: String) -> Int {
|
||
locale == "zh" ? 10 : 22
|
||
}
|
||
|
||
public static func displayText(for card: AIHintCard) -> String {
|
||
let locale = card.locale == "zh" ? "zh" : "en"
|
||
let kind = AIHintVisualKind.resolve(card)
|
||
let raw = keyword(for: card, kind: kind, locale: locale)
|
||
return finalize(raw, locale: locale)
|
||
}
|
||
|
||
/// Strip prefixes, pick a fitting chunk, then enforce the character cap.
|
||
public static func finalize(_ text: String, locale: String) -> String {
|
||
var value = stripPrefixes(text, locale: locale)
|
||
.replacingOccurrences(of: "\n", with: " ")
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
value = stripTrailingPunctuation(value)
|
||
let limit = characterLimit(locale: locale)
|
||
if value.count <= limit { return value }
|
||
if let clause = firstFittingClause(value, limit: limit) { return clause }
|
||
if let latin = leadingLatinPhrase(value, limit: limit) { return latin }
|
||
if let chunk = lastFittingChunk(value, limit: limit) { return chunk }
|
||
return String(value.prefix(limit))
|
||
}
|
||
|
||
// MARK: - Keyword by kind
|
||
|
||
private static func keyword(
|
||
for card: AIHintCard,
|
||
kind: AIHintVisualKind,
|
||
locale: String
|
||
) -> String {
|
||
switch kind {
|
||
case .trending:
|
||
return firstNonEmpty(card.metadata?.title, card.displayText)
|
||
case .weather:
|
||
if let city = trimmed(card.metadata?.city) {
|
||
if let temp = card.metadata?.tempC {
|
||
return "\(city) \(Int(temp.rounded()))°"
|
||
}
|
||
return city
|
||
}
|
||
return card.displayText
|
||
case .news:
|
||
return locale == "zh" ? "今日早报" : "Today's briefing"
|
||
case .stocks:
|
||
return locale == "zh" ? "今日大盘" : "Markets"
|
||
case .calendar:
|
||
if locale != "zh", let name = trimmed(card.metadata?.name) {
|
||
return name
|
||
}
|
||
return card.displayText
|
||
case .search:
|
||
if card.metadata?.soul != nil {
|
||
return locale == "zh" ? "今日金句" : "A quote"
|
||
}
|
||
return card.displayText
|
||
}
|
||
}
|
||
|
||
// MARK: - Prefixes
|
||
|
||
public static func stripPrefixes(_ text: String, locale: String) -> String {
|
||
var value = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let prefixes = locale == "zh" ? zhPrefixes : enPrefixes
|
||
var changed = true
|
||
while changed {
|
||
changed = false
|
||
for prefix in prefixes where value.hasPrefix(prefix) {
|
||
value = String(value.dropFirst(prefix.count))
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
changed = true
|
||
break
|
||
}
|
||
}
|
||
return value
|
||
}
|
||
|
||
private static let zhPrefixes = [
|
||
"全网热点:", "全网热点:", "临近节日:", "临近节日:",
|
||
"历史上的今天:", "历史上的今天:", "今日一句:", "今日一句:",
|
||
"查百科:", "查百科:", "聊聊", "看看",
|
||
]
|
||
|
||
private static let enPrefixes = [
|
||
"Trending: ", "Upcoming: ", "On this day: ",
|
||
"Chat about ", "Chat ", "Weather in ",
|
||
]
|
||
|
||
// MARK: - Chunks
|
||
|
||
private static func firstFittingClause(_ text: String, limit: Int) -> String? {
|
||
let separators = CharacterSet(charactersIn: ",。;;,.!??!")
|
||
let parts = text.components(separatedBy: separators)
|
||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||
.filter { !$0.isEmpty }
|
||
guard let first = parts.first, first.count <= limit, first.count >= 2 else {
|
||
return nil
|
||
}
|
||
return first
|
||
}
|
||
|
||
private static func lastFittingChunk(_ text: String, limit: Int) -> String? {
|
||
let parts = text.split { $0 == " " || $0 == ":" || $0 == ":" }
|
||
.map(String.init)
|
||
.filter { !$0.isEmpty }
|
||
guard let last = parts.last else { return nil }
|
||
let cleaned = stripTrailingPunctuation(last)
|
||
guard (4...limit).contains(cleaned.count) else { return nil }
|
||
return cleaned
|
||
}
|
||
|
||
private static func leadingLatinPhrase(_ text: String, limit: Int) -> String? {
|
||
var scalars: [Unicode.Scalar] = []
|
||
for scalar in text.unicodeScalars {
|
||
let isLatin = (0x41...0x5A).contains(scalar.value)
|
||
|| (0x61...0x7A).contains(scalar.value)
|
||
|| (0x30...0x39).contains(scalar.value)
|
||
|| scalar == "." || scalar == "-"
|
||
let isSpace = scalar == " "
|
||
if isLatin || (isSpace && !scalars.isEmpty) {
|
||
scalars.append(scalar)
|
||
} else if !scalars.isEmpty {
|
||
break
|
||
}
|
||
}
|
||
var phrase = String(String.UnicodeScalarView(scalars))
|
||
.trimmingCharacters(in: .whitespaces)
|
||
guard phrase.count >= 2 else { return nil }
|
||
if phrase.count <= limit { return phrase }
|
||
while phrase.count > limit {
|
||
guard let lastSpace = phrase.lastIndex(of: " "), lastSpace > phrase.startIndex else {
|
||
return String(phrase.prefix(limit))
|
||
}
|
||
phrase = String(phrase[..<lastSpace])
|
||
}
|
||
return phrase
|
||
}
|
||
|
||
private static func stripTrailingPunctuation(_ text: String) -> String {
|
||
text.trimmingCharacters(in: CharacterSet(charactersIn: "…。.!??!、,"))
|
||
}
|
||
|
||
private static func firstNonEmpty(_ values: String?...) -> String {
|
||
values.compactMap(trimmed).first ?? ""
|
||
}
|
||
|
||
private static func trimmed(_ value: String?) -> String? {
|
||
guard let value else { return nil }
|
||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return trimmed.isEmpty ? nil : trimmed
|
||
}
|
||
}
|