498f407585
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.
149 lines
5.4 KiB
Swift
149 lines
5.4 KiB
Swift
// 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()
|
||
}
|
||
}
|