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)
|
||||
|
||||
@@ -315,6 +315,7 @@ struct TripOptionsView: View {
|
||||
TripDetailView(trip: trip)
|
||||
}
|
||||
.onAppear {
|
||||
RouteDescriptionGenerator.shared.prewarm()
|
||||
if isDemoMode && !hasAppliedDemoSelection {
|
||||
hasAppliedDemoSelection = true
|
||||
// Auto-select "Most Games" sort after a delay
|
||||
@@ -491,7 +492,11 @@ struct TripOptionCard: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@State private var aiDescription: String?
|
||||
@State private var isLoadingDescription = false
|
||||
|
||||
private var templateDescription: String {
|
||||
let input = RouteDescriptionInput(from: option, games: games)
|
||||
return RouteDescriptionGenerator.shared.templateDescription(for: input)
|
||||
}
|
||||
|
||||
private var uniqueCities: [String] {
|
||||
option.stops.map { $0.city }.removingDuplicates()
|
||||
@@ -571,21 +576,6 @@ struct TripOptionCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
// AI-generated description (after stats)
|
||||
if let description = aiDescription {
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.transition(.opacity)
|
||||
} else if isLoadingDescription {
|
||||
HStack(spacing: 4) {
|
||||
LoadingSpinner(size: .small)
|
||||
Text("Generating...")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -606,28 +596,6 @@ struct TripOptionCard: View {
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.task(id: option.id) {
|
||||
// Reset state when option changes
|
||||
aiDescription = nil
|
||||
isLoadingDescription = false
|
||||
await generateDescription()
|
||||
}
|
||||
}
|
||||
|
||||
private func generateDescription() async {
|
||||
guard RouteDescriptionGenerator.shared.isAvailable else { return }
|
||||
|
||||
isLoadingDescription = true
|
||||
|
||||
// Build input from THIS specific option
|
||||
let input = RouteDescriptionInput(from: option, games: games)
|
||||
|
||||
if let description = await RouteDescriptionGenerator.shared.generateDescription(for: input) {
|
||||
Theme.Animation.withMotion(.easeInOut(duration: 0.3)) {
|
||||
aiDescription = description
|
||||
}
|
||||
}
|
||||
isLoadingDescription = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -654,9 +654,12 @@ enum GameDAGRouter {
|
||||
// Calculate driving hours available
|
||||
// For same-day games: enforce both time availability AND daily driving limit
|
||||
// For multi-day trips: use total available driving hours across days
|
||||
// daysBetween counts calendar day gaps, but the traveler can drive on
|
||||
// partial days at both ends (post-game evening + pre-game morning), so
|
||||
// use (daysBetween + 1) for multi-day trips to avoid off-by-one rejection.
|
||||
let maxDrivingHoursAvailable = daysBetween == 0
|
||||
? min(max(0, availableHours), constraints.maxDailyDrivingHours)
|
||||
: Double(daysBetween) * constraints.maxDailyDrivingHours
|
||||
: Double(daysBetween + 1) * constraints.maxDailyDrivingHours
|
||||
|
||||
let feasible = drivingHours <= maxDrivingHoursAvailable && drivingHours <= availableHours
|
||||
|
||||
|
||||
Reference in New Issue
Block a user