From 87b9971714208657798ac5b3041f364036be4012 Mon Sep 17 00:00:00 2001 From: Trey T Date: Fri, 27 Mar 2026 12:42:21 -0500 Subject: [PATCH] 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) --- .../Services/RouteDescriptionGenerator.swift | 253 ++++++++++++++---- .../Features/Trip/Views/TripOptionsView.swift | 44 +-- .../Planning/Engine/GameDAGRouter.swift | 5 +- 3 files changed, 207 insertions(+), 95 deletions(-) diff --git a/SportsTime/Core/Services/RouteDescriptionGenerator.swift b/SportsTime/Core/Services/RouteDescriptionGenerator.swift index 3f72248..2447e55 100644 --- a/SportsTime/Core/Services/RouteDescriptionGenerator.swift +++ b/SportsTime/Core/Services/RouteDescriptionGenerator.swift @@ -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)] = [] + 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) diff --git a/SportsTime/Features/Trip/Views/TripOptionsView.swift b/SportsTime/Features/Trip/Views/TripOptionsView.swift index 2eacb88..3b8d329 100644 --- a/SportsTime/Features/Trip/Views/TripOptionsView.swift +++ b/SportsTime/Features/Trip/Views/TripOptionsView.swift @@ -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 } } diff --git a/SportsTime/Planning/Engine/GameDAGRouter.swift b/SportsTime/Planning/Engine/GameDAGRouter.swift index 282f549..224fea3 100644 --- a/SportsTime/Planning/Engine/GameDAGRouter.swift +++ b/SportsTime/Planning/Engine/GameDAGRouter.swift @@ -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