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:
Trey T
2026-03-27 12:42:21 -05:00
parent aa6477b886
commit 87b9971714
3 changed files with 207 additions and 95 deletions

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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