Files
OSGKeyboard/OSGKeyboardShared/Services/AIAddressExtraction.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

149 lines
5.4 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.
// AIAddressExtraction.swift
// OSGKeyboard · Shared
//
// Parses the LLM's navigate-skill reply into one origin|destination line.
// Fail closed: empty / NONE / no destination never become a run. Only the
// first valid line is kept — opening several map apps is not useful.
import Foundation
public struct AIMapRoute: Equatable, Sendable {
/// Nil means "current location" in the map app.
public let origin: String?
public let destination: String
public init(origin: String?, destination: String) {
self.origin = origin
self.destination = destination
}
}
public enum AIAddressExtraction: Sendable {
/// Canonical line: origin|destination. Empty origin keeps the pipe.
public static let fieldSeparator: Character = "|"
private static let emptyTokens: Set<String> = [
"none", "no", "n/a", "na", "nil", "null",
"无", "没有", "没有地址", "没有地点", "无地址", "无地点",
"没有可导航的地点", "没有可导航的地址",
"no address", "no addresses", "no location", "no locations",
"no place", "no places", "no destination"
]
/// Lines to send to the host. Empty → do not run the companion Shortcut.
public static func lines(
from raw: String,
sourceClipboard: String? = nil
) -> [String] {
guard let route = route(from: raw, sourceClipboard: sourceClipboard) else {
return []
}
return [encode(route)]
}
public static func route(
from raw: String,
sourceClipboard: String? = nil
) -> AIMapRoute? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if isEmptyToken(trimmed) { return nil }
for line in trimmed.components(separatedBy: .newlines) {
guard let route = parseLine(line) else { continue }
if isWholeClipboardEcho(route, source: sourceClipboard) {
return nil
}
return route
}
return nil
}
public static func encode(_ route: AIMapRoute) -> String {
"\(route.origin ?? "")|\(route.destination)"
}
// MARK: - Line
private static func parseLine(_ line: String) -> AIMapRoute? {
let stripped = stripBullet(line)
guard !stripped.isEmpty, !isEmptyToken(stripped) else { return nil }
let parts = stripped
.split(separator: fieldSeparator, omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
let originRaw: String
let destinationRaw: String
if parts.count == 1 {
originRaw = ""
destinationRaw = parts[0]
} else {
originRaw = parts[0]
var destParts = Array(parts[1...])
while destParts.last?.isEmpty == true {
destParts.removeLast()
}
destinationRaw = destParts
.joined(separator: String(fieldSeparator))
.trimmingCharacters(in: .whitespacesAndNewlines)
}
guard let destination = sanitizedPlace(destinationRaw) else { return nil }
let origin = sanitizedPlace(originRaw)
if let origin, origin.caseInsensitiveCompare(destination) == .orderedSame {
return AIMapRoute(origin: nil, destination: destination)
}
return AIMapRoute(origin: origin, destination: destination)
}
/// Drop URLs and empty / NONE tokens. Place names stay as-is.
private static func sanitizedPlace(_ raw: String) -> String? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !isEmptyToken(text) else { return nil }
if text.contains("://") { return nil }
return text
}
private static func isEmptyToken(_ text: String) -> Bool {
let folded = text
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "。.!"))
.lowercased()
return emptyTokens.contains(folded)
}
private static func stripBullet(_ line: String) -> String {
var text = line.trimmingCharacters(in: .whitespacesAndNewlines)
let prefixes = ["- ", "* ", "• ", "、"]
for prefix in prefixes where text.hasPrefix(prefix) {
text = String(text.dropFirst(prefix.count))
.trimmingCharacters(in: .whitespacesAndNewlines)
}
if let dotted = text.range(of: #"^\d+[\.\)、]\s*"#, options: .regularExpression) {
text = String(text[dotted.upperBound...])
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return text
}
/// One long destination that is essentially the clipboard body is not a place.
private static func isWholeClipboardEcho(_ route: AIMapRoute, source: String?) -> Bool {
guard let source, source.count > 80, route.destination.count > 80 else {
return false
}
let a = collapse(route.destination)
let b = collapse(source)
guard !a.isEmpty, !b.isEmpty else { return false }
if a == b { return true }
return a.contains(b) || b.contains(a)
}
private static func collapse(_ text: String) -> String {
text.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: " ")
.lowercased()
}
}