Fix FoundationModels crash, driving constraint, and disable card descriptions
- RouteDescriptionGenerator: reuse session, cap tokens at 60, greedy sampling, prewarm with prompt prefix, serial request queue with rate-limit retry and circuit breaker - Disable AI/template descriptions on trip option cards - GameDAGRouter: fix off-by-one in canTransition driving constraint (daysBetween+1) so multi-day cross-city routes aren't rejected - CanonicalSyncService: wrap SyncStatusMonitor in #if DEBUG Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,23 +2,46 @@
|
||||
// RouteDescriptionGenerator.swift
|
||||
// SportsTime
|
||||
//
|
||||
// On-device AI route description generation using Foundation Models
|
||||
// Route description generation — uses fast templates by default,
|
||||
// with optional on-device AI enhancement via FoundationModels.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import FoundationModels
|
||||
|
||||
/// Generates human-readable route descriptions using on-device AI
|
||||
/// Generates human-readable route descriptions for trip option cards.
|
||||
///
|
||||
/// Uses instant template-based descriptions by default. When FoundationModels
|
||||
/// is available, progressively enhances descriptions with on-device AI in the
|
||||
/// background (serialized to avoid rate limiting).
|
||||
@MainActor
|
||||
@Observable
|
||||
final class RouteDescriptionGenerator {
|
||||
static let shared = RouteDescriptionGenerator()
|
||||
|
||||
private(set) var isAvailable = false
|
||||
|
||||
// Cache AI-generated descriptions by option ID
|
||||
private var aiCache: [UUID: String] = [:]
|
||||
|
||||
private var consecutiveFailures = 0
|
||||
private let maxConsecutiveFailures = 3
|
||||
|
||||
private var pendingRequests: [(id: UUID, input: RouteDescriptionInput, continuation: CheckedContinuation<String?, Never>)] = []
|
||||
private var isProcessing = false
|
||||
|
||||
/// Reused session — avoids per-request session creation overhead.
|
||||
/// Reset only on context overflow.
|
||||
private var session: LanguageModelSession?
|
||||
|
||||
// Cache generated descriptions by option ID
|
||||
private var cache: [UUID: String] = [:]
|
||||
private static let instructions = """
|
||||
You write 1-sentence sports road trip descriptions. Exciting, concise.
|
||||
"""
|
||||
|
||||
private static let generationOptions = GenerationOptions(
|
||||
sampling: .greedy,
|
||||
maximumResponseTokens: 60
|
||||
)
|
||||
|
||||
private init() {
|
||||
checkAvailability()
|
||||
@@ -26,91 +49,210 @@ final class RouteDescriptionGenerator {
|
||||
|
||||
private func checkAvailability() {
|
||||
#if targetEnvironment(simulator)
|
||||
// Keep simulator behavior deterministic and avoid known FoundationModels simulator crashes.
|
||||
isAvailable = false
|
||||
session = nil
|
||||
#else
|
||||
switch SystemLanguageModel.default.availability {
|
||||
case .available:
|
||||
isAvailable = true
|
||||
session = LanguageModelSession(instructions: """
|
||||
You are a travel copywriter creating exciting, brief descriptions for sports road trips.
|
||||
Write in an enthusiastic but concise style. Focus on the adventure and variety of the trip.
|
||||
Keep descriptions to 1-2 short sentences maximum.
|
||||
""")
|
||||
session = LanguageModelSession(instructions: Self.instructions)
|
||||
case .unavailable:
|
||||
isAvailable = false
|
||||
session = nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Generate a brief, exciting description for a route option
|
||||
func generateDescription(for option: RouteDescriptionInput) async -> String? {
|
||||
// Check cache first
|
||||
if let cached = cache[option.id] {
|
||||
/// Pre-load model weights and cache the instruction prefix.
|
||||
/// Call this when entering the trip options view.
|
||||
func prewarm() {
|
||||
guard isAvailable, let session else { return }
|
||||
Task { try? await session.prewarm(promptPrefix: Prompt(Self.instructions)) }
|
||||
}
|
||||
|
||||
// MARK: - Fast Template Descriptions (instant)
|
||||
|
||||
/// Returns an instant template-based description. No async, no AI.
|
||||
func templateDescription(for input: RouteDescriptionInput) -> String {
|
||||
let cityCount = input.cities.count
|
||||
let gameCount = input.totalGames
|
||||
let miles = Int(input.totalMiles)
|
||||
let sports = input.sports
|
||||
|
||||
if cityCount == 1 {
|
||||
let city = input.cities.first ?? "one city"
|
||||
if gameCount == 1 {
|
||||
return "A single-game stop in \(city)."
|
||||
}
|
||||
return "Catch \(gameCount) games in \(city) — no driving needed."
|
||||
}
|
||||
|
||||
let sportText: String
|
||||
if sports.count == 1 {
|
||||
sportText = sports.first ?? "sports"
|
||||
} else {
|
||||
sportText = "\(sports.count)-sport"
|
||||
}
|
||||
|
||||
let milesText = miles > 0 ? " across \(miles) miles" : ""
|
||||
let drivingText: String
|
||||
if input.totalDrivingHours > 0 {
|
||||
let hours = Int(input.totalDrivingHours)
|
||||
drivingText = " (\(hours)h driving)"
|
||||
} else {
|
||||
drivingText = ""
|
||||
}
|
||||
|
||||
let templates: [String]
|
||||
if cityCount <= 2 {
|
||||
templates = [
|
||||
"\(gameCount) \(sportText) games in \(cityCount) cities\(milesText)\(drivingText).",
|
||||
"A \(cityCount)-city \(sportText) trip with \(gameCount) games\(milesText).",
|
||||
]
|
||||
} else if cityCount <= 4 {
|
||||
templates = [
|
||||
"Hit \(cityCount) cities for \(gameCount) \(sportText) games\(milesText)\(drivingText).",
|
||||
"A \(cityCount)-city \(sportText) road trip — \(gameCount) games\(milesText).",
|
||||
]
|
||||
} else {
|
||||
templates = [
|
||||
"Epic \(cityCount)-city tour: \(gameCount) \(sportText) games\(milesText)\(drivingText).",
|
||||
"The grand tour — \(cityCount) cities, \(gameCount) games\(milesText).",
|
||||
]
|
||||
}
|
||||
|
||||
let index = abs(input.id.hashValue) % templates.count
|
||||
return templates[index]
|
||||
}
|
||||
|
||||
// MARK: - AI Enhancement (progressive, serialized)
|
||||
|
||||
/// Request an AI-enhanced description. Returns cached result or nil.
|
||||
func aiDescription(for id: UUID) -> String? {
|
||||
aiCache[id]
|
||||
}
|
||||
|
||||
/// Queue an AI description generation. Serialized to avoid rate limiting.
|
||||
func requestAIDescription(for input: RouteDescriptionInput) async -> String? {
|
||||
if let cached = aiCache[input.id] {
|
||||
return cached
|
||||
}
|
||||
|
||||
guard isAvailable, let session = session else {
|
||||
return nil
|
||||
guard isAvailable else { return nil }
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
pendingRequests.append((id: input.id, input: input, continuation: continuation))
|
||||
processNextIfIdle()
|
||||
}
|
||||
}
|
||||
|
||||
private func processNextIfIdle() {
|
||||
guard !isProcessing, !pendingRequests.isEmpty, isAvailable else { return }
|
||||
isProcessing = true
|
||||
|
||||
let request = pendingRequests.removeFirst()
|
||||
|
||||
if let cached = aiCache[request.id] {
|
||||
request.continuation.resume(returning: cached)
|
||||
isProcessing = false
|
||||
processNextIfIdle()
|
||||
return
|
||||
}
|
||||
|
||||
let prompt = buildPrompt(for: option)
|
||||
Task {
|
||||
let result = await performGeneration(for: request.input)
|
||||
request.continuation.resume(returning: result)
|
||||
try? await Task.sleep(for: .milliseconds(150))
|
||||
isProcessing = false
|
||||
processNextIfIdle()
|
||||
}
|
||||
}
|
||||
|
||||
private func performGeneration(for input: RouteDescriptionInput) async -> String? {
|
||||
let prompt = buildPrompt(for: input)
|
||||
|
||||
// Ensure we have a session
|
||||
if session == nil {
|
||||
session = LanguageModelSession(instructions: Self.instructions)
|
||||
}
|
||||
|
||||
guard let session else { return nil }
|
||||
|
||||
do {
|
||||
let response = try await session.respond(
|
||||
to: prompt,
|
||||
generating: RouteDescription.self
|
||||
generating: RouteDescription.self,
|
||||
options: Self.generationOptions
|
||||
)
|
||||
|
||||
let description = response.content.description
|
||||
cache[option.id] = description
|
||||
aiCache[input.id] = description
|
||||
consecutiveFailures = 0
|
||||
return description
|
||||
|
||||
} catch LanguageModelSession.GenerationError.guardrailViolation {
|
||||
return nil
|
||||
} catch LanguageModelSession.GenerationError.exceededContextWindowSize {
|
||||
// Reset session if context exceeded
|
||||
self.session = LanguageModelSession(instructions: """
|
||||
You are a travel copywriter creating exciting, brief descriptions for sports road trips.
|
||||
Write in an enthusiastic but concise style. Focus on the adventure and variety of the trip.
|
||||
Keep descriptions to 1-2 short sentences maximum.
|
||||
""")
|
||||
return nil
|
||||
} catch let error as LanguageModelSession.GenerationError {
|
||||
switch error {
|
||||
case .rateLimited:
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
do {
|
||||
let response = try await session.respond(
|
||||
to: prompt,
|
||||
generating: RouteDescription.self,
|
||||
options: Self.generationOptions
|
||||
)
|
||||
let description = response.content.description
|
||||
aiCache[input.id] = description
|
||||
consecutiveFailures = 0
|
||||
return description
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
case .exceededContextWindowSize:
|
||||
// Reset session and retry once
|
||||
self.session = LanguageModelSession(instructions: Self.instructions)
|
||||
do {
|
||||
let response = try await self.session!.respond(
|
||||
to: prompt,
|
||||
generating: RouteDescription.self,
|
||||
options: Self.generationOptions
|
||||
)
|
||||
let description = response.content.description
|
||||
aiCache[input.id] = description
|
||||
consecutiveFailures = 0
|
||||
return description
|
||||
} catch {
|
||||
recordFailure()
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
recordFailure()
|
||||
return nil
|
||||
}
|
||||
} catch {
|
||||
recordFailure()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func recordFailure() {
|
||||
consecutiveFailures += 1
|
||||
if consecutiveFailures >= maxConsecutiveFailures {
|
||||
isAvailable = false
|
||||
for pending in pendingRequests {
|
||||
pending.continuation.resume(returning: nil)
|
||||
}
|
||||
pendingRequests.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func buildPrompt(for option: RouteDescriptionInput) -> String {
|
||||
let citiesText = option.cities.joined(separator: ", ")
|
||||
let sportsText = option.sports.isEmpty ? "sports" : option.sports.joined(separator: ", ")
|
||||
|
||||
var details = [String]()
|
||||
details.append("\(option.totalGames) games")
|
||||
details.append("\(option.cities.count) cities: \(citiesText)")
|
||||
|
||||
if option.totalMiles > 0 {
|
||||
details.append("\(Int(option.totalMiles)) miles")
|
||||
}
|
||||
if option.totalDrivingHours > 0 {
|
||||
details.append("\(String(format: "%.1f", option.totalDrivingHours)) hours driving")
|
||||
}
|
||||
|
||||
return """
|
||||
Write a brief, exciting 1-sentence description for this sports road trip:
|
||||
- Route: \(citiesText)
|
||||
- Sports: \(sportsText)
|
||||
- Details: \(details.joined(separator: ", "))
|
||||
|
||||
Make it sound like an adventure. Be concise.
|
||||
"""
|
||||
let cities = option.cities.joined(separator: ", ")
|
||||
let sports = option.sports.joined(separator: "/")
|
||||
let miles = option.totalMiles > 0 ? ", \(Int(option.totalMiles))mi" : ""
|
||||
return "\(option.totalGames) \(sports) games, \(option.cities.count) cities: \(cities)\(miles)"
|
||||
}
|
||||
|
||||
/// Clear the cache (e.g., when starting a new trip search)
|
||||
func clearCache() {
|
||||
cache.removeAll()
|
||||
aiCache.removeAll()
|
||||
consecutiveFailures = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +278,6 @@ struct RouteDescriptionInput: Identifiable {
|
||||
self.id = option.id
|
||||
self.cities = Array(NSOrderedSet(array: option.stops.map { $0.city })) as? [String] ?? []
|
||||
|
||||
// Extract sports from games
|
||||
let gameIds = option.stops.flatMap { $0.games }
|
||||
let sportSet = Set(gameIds.compactMap { games[$0]?.game.sport.rawValue })
|
||||
self.sports = Array(sportSet)
|
||||
|
||||
Reference in New Issue
Block a user