From aa6a0684b47127ef012fa587eb5c0ecd030f78bd Mon Sep 17 00:00:00 2001 From: Chris Ballinger Date: Sun, 12 Apr 2026 20:58:01 -0400 Subject: [PATCH 1/5] Keep tab bar visible on all navigation pushes Remove hidesBottomBarWhenPushed from detail screens, tracks, AI guide, recently viewed, and feature flags views for a consistent Liquid Glass tab bar experience. Co-Authored-By: Claude Opus 4.6 (1M context) --- Docs/2026-04-12-show-tab-bar-on-push.md | 28 +++++++++++++++++++ .../Controllers/DetailHostingController.swift | 1 - iBurn/MoreViewController.swift | 4 --- 3 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 Docs/2026-04-12-show-tab-bar-on-push.md diff --git a/Docs/2026-04-12-show-tab-bar-on-push.md b/Docs/2026-04-12-show-tab-bar-on-push.md new file mode 100644 index 00000000..b96ac34b --- /dev/null +++ b/Docs/2026-04-12-show-tab-bar-on-push.md @@ -0,0 +1,28 @@ +# Show Tab Bar on All Navigation Pushes + +## Problem +Several view controllers set `hidesBottomBarWhenPushed = true`, causing the tab bar to disappear when navigating to detail screens, tracks, AI guide, recently viewed, and feature flags. With the iOS 26 Liquid Glass UI, the tab bar should remain visible throughout navigation for a consistent experience. + +## Solution +Removed all 5 instances of `hidesBottomBarWhenPushed = true` across 2 files. + +## Changes + +### `iBurn/Detail/Controllers/DetailHostingController.swift` +- Removed `self.hidesBottomBarWhenPushed = true` from init (line 67) — this affected all detail screens (art, camps, events, mutant vehicles) + +### `iBurn/MoreViewController.swift` +- Removed `tracksVC.hidesBottomBarWhenPushed = true` from `pushTracksView()` (line 343) +- Removed `hostingVC.hidesBottomBarWhenPushed = true` from `pushAIGuideView()` (line 392) +- Removed `recentVC.hidesBottomBarWhenPushed = true` from `pushRecentlyViewedView()` (line 416) +- Removed `featureFlagsVC.hidesBottomBarWhenPushed = true` from `pushFeatureFlagsView()` (line 501, DEBUG only) + +### Not changed +- `MainMapViewController.swift:183-184` — explicitly re-shows tab bar in `viewWillDisappear`, harmless safety net kept as-is + +## Verification +- Build succeeds with 0 errors, 0 warnings +- Test by navigating to detail views, tracks, recently viewed, AI guide — tab bar should remain visible + +## Branch +`show-tab-bar-on-push` from `origin/master` diff --git a/iBurn/Detail/Controllers/DetailHostingController.swift b/iBurn/Detail/Controllers/DetailHostingController.swift index 45c31b81..77b068fa 100644 --- a/iBurn/Detail/Controllers/DetailHostingController.swift +++ b/iBurn/Detail/Controllers/DetailHostingController.swift @@ -64,7 +64,6 @@ class DetailHostingController: UIHostingController, DynamicViewContr super.init(rootView: DetailView(viewModel: viewModel)) self.title = titleText - self.hidesBottomBarWhenPushed = true } @MainActor required dynamic init?(coder aDecoder: NSCoder) { diff --git a/iBurn/MoreViewController.swift b/iBurn/MoreViewController.swift index c30f472f..ac261f04 100644 --- a/iBurn/MoreViewController.swift +++ b/iBurn/MoreViewController.swift @@ -340,7 +340,6 @@ class MoreViewController: UITableViewController, SKStoreProductViewControllerDel func pushTracksView() { let tracksVC = TracksViewController() - tracksVC.hidesBottomBarWhenPushed = true self.navigationController?.pushViewController(tracksVC, animated: true) } @@ -389,7 +388,6 @@ class MoreViewController: UITableViewController, SKStoreProductViewControllerDel } let hostingVC = UIHostingController(rootView: view) hostingVC.title = "AI Guide" - hostingVC.hidesBottomBarWhenPushed = true navigationController?.pushViewController(hostingVC, animated: true) } #endif @@ -413,7 +411,6 @@ class MoreViewController: UITableViewController, SKStoreProductViewControllerDel func pushRecentlyViewedView() { let recentVC = RecentlyViewedHostingController(dependencies: BRCAppDelegate.shared.dependencies) - recentVC.hidesBottomBarWhenPushed = true navigationController?.pushViewController(recentVC, animated: true) } @@ -498,7 +495,6 @@ class MoreViewController: UITableViewController, SKStoreProductViewControllerDel #if DEBUG func pushFeatureFlagsView() { let featureFlagsVC = FeatureFlagsHostingController() - featureFlagsVC.hidesBottomBarWhenPushed = true navigationController?.pushViewController(featureFlagsVC, animated: true) } #endif From 77a4df1684354896c7d7da151b552dd92743a401 Mon Sep 17 00:00:00 2001 From: Chris Ballinger Date: Sun, 12 Apr 2026 22:34:33 -0400 Subject: [PATCH 2/5] Add AI-generated event summary highlights on detail pages Show a concise AI-generated pro-tip summarizing hosted events on camp, art, and event detail pages, plus the hosted events list view. Uses the existing workflow pipeline (retryWithCandidateFiltering + withContextWindowRetry) so summaries work even when individual events trigger guardrails or exceed the context window. Co-Authored-By: Claude Opus 4.6 (1M context) --- Docs/2026-04-12-ai-event-summary.md | 53 +++++++++++++++++ iBurn/AISearch/AIAssistantModels.swift | 7 +++ .../Workflows/WorkflowUtilities.swift | 48 +++++++++++++++ .../PlayaHostedEventsViewController.swift | 55 +++++++++++++----- iBurn/Detail/Models/DetailCellType.swift | 2 + iBurn/Detail/ViewModels/DetailViewModel.swift | 58 +++++++++++++++++++ iBurn/Detail/Views/DetailView.swift | 42 +++++++++++++- 7 files changed, 249 insertions(+), 16 deletions(-) create mode 100644 Docs/2026-04-12-ai-event-summary.md diff --git a/Docs/2026-04-12-ai-event-summary.md b/Docs/2026-04-12-ai-event-summary.md new file mode 100644 index 00000000..2fa3dec0 --- /dev/null +++ b/Docs/2026-04-12-ai-event-summary.md @@ -0,0 +1,53 @@ +# AI Summary of Hosted Events on Detail Pages + +## Problem +Camp and art detail pages display hosted events (next event + "See all N events" link), but there's no quick summary of what the events collectively offer. Users have to tap through individual events to understand a camp/art's programming. + +## Solution +Added AI-generated event collection summaries using Apple Foundation Models (iOS 26+) with the existing workflow pipeline for guardrail/context handling. + +## Key Changes + +### New Generable Type +- **`iBurn/AISearch/AIAssistantModels.swift`**: Added `GenerableEventCollectionSummary` with single `summary` field + +### Reusable Summary Generator +- **`iBurn/AISearch/Workflows/WorkflowUtilities.swift`**: Added `generateEventCollectionSummary(events:hostName:) async -> String?` + - Wraps `withContextWindowRetry` (halves event count on context overflow) + - Inside uses `retryWithCandidateFiltering` (filters individual events that trigger guardrails) + - Formats events with name, type code display name, and truncated description (120 chars) + - Returns `nil` on complete failure for graceful degradation + - Slightly snarky tone per user preference + +### New Detail Cell Types +- **`iBurn/Detail/Models/DetailCellType.swift`**: Added `.eventSummaryLoading(hostName:)` and `.eventSummary(String, hostName:)` cases + +### Cell Rendering +- **`iBurn/Detail/Views/DetailView.swift`**: + - Added `EventSummaryHeaderView` (shared between detail cells and events list) + - Uses sparkles icon + "AI SUMMARY" header + ProgressView for loading state + - Added rendering cases in `cellContent` switch and `isCellTappable` + +### ViewModel Integration +- **`iBurn/Detail/ViewModels/DetailViewModel.swift`**: + - New state: `resolvedEventSummary: String?`, `isGeneratingEventSummary: Bool` + - `generateEventSummaryIfNeeded()` triggers after deferred data loads (Phase 3) + - `generateEventSummaryCells(hostName:)` returns loading/summary/empty cells + - Wired into `generateHostedEventCells` (camp/art), `generatePlayaEventCellTypes`, `generatePlayaEventOccurrenceCellTypes` + +### Events List Integration +- **`iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift`**: Added summary header above event list via `.task` modifier + +## Three-Phase Loading +1. **Phase 1** (existing): Metadata loads, cells render immediately +2. **Phase 2** (existing): Deferred data loads (host events, images), cells refresh +3. **Phase 3** (new): AI summary generates from resolved events, cells refresh again + +## Graceful Degradation +- Pre-iOS 26: `#if canImport(FoundationModels)` + `@available(iOS 26, *)` guards +- All events trigger guardrails: `retryWithCandidateFiltering` tries halves then individuals, returns nil if <2 safe +- Context overflow: `withContextWindowRetry` halves event count down to minimum 2 +- Complete LLM failure: Returns nil, no summary cell shown + +## Build Verification +- Build succeeds: 0 errors, 6 pre-existing warnings (none from new code) diff --git a/iBurn/AISearch/AIAssistantModels.swift b/iBurn/AISearch/AIAssistantModels.swift index 26b8b174..7fc90a8d 100644 --- a/iBurn/AISearch/AIAssistantModels.swift +++ b/iBurn/AISearch/AIAssistantModels.swift @@ -95,4 +95,11 @@ struct GenerableNearbyResponse { var highlights: [GenerableNearbyHighlight] } +@available(iOS 26, *) +@Generable +struct GenerableEventCollectionSummary { + @Guide(description: "One to two sentence highlight of what this collection of events offers, like a pro tip for someone deciding whether to visit") + var summary: String +} + #endif diff --git a/iBurn/AISearch/Workflows/WorkflowUtilities.swift b/iBurn/AISearch/Workflows/WorkflowUtilities.swift index 37da7740..62ffd7e7 100644 --- a/iBurn/AISearch/Workflows/WorkflowUtilities.swift +++ b/iBurn/AISearch/Workflows/WorkflowUtilities.swift @@ -187,6 +187,54 @@ func withContextWindowRetry( return try await attempt(minimumCount) } +// MARK: - Event Collection Summary + +/// Generate an AI summary of a collection of events hosted by a camp/art. +/// Uses retryWithCandidateFiltering + withContextWindowRetry so we still get +/// a useful summary even when individual events trigger guardrails or exceed context. +@available(iOS 26, *) +func generateEventCollectionSummary( + events: [EventObjectOccurrence], + hostName: String +) async -> String? { + guard !events.isEmpty else { return nil } + + do { + return try await withContextWindowRetry( + initialCount: min(events.count, 20), + minimumCount: 2 + ) { maxCount in + let slice = Array(events.prefix(maxCount)) + + let result: GenerableEventCollectionSummary = try await retryWithCandidateFiltering( + candidates: slice, + minimumCount: 2, + format: { $0.name } + ) { batch in + let text = batch.enumerated().map { idx, event in + let type = EventTypeInfo.displayName(for: event.eventTypeCode) + let desc = event.description.map { String($0.prefix(120)) } ?? "" + return "\(idx + 1). \(event.name) [\(type)]\(desc.isEmpty ? "" : " - \(desc)")" + }.joined(separator: "\n") + + let session = LanguageModelSession(instructions: """ + Highlight what \(hostName) offers based on their events. \ + Write 1-2 sentences max, like a pro tip for someone deciding whether to visit. \ + Focus on standout offerings and variety. + """) + return try await session.respond( + to: Prompt("Events hosted by \(hostName):\n\(text)"), + generating: GenerableEventCollectionSummary.self + ).content + } + return result.summary + } + } catch { + print("Event summary generation failed: \(error)") + return nil + } +} + // MARK: - Note Merging /// Merge LLM-generated notes into entries by matching on lowercased name. diff --git a/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift b/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift index fdc3b59a..48b69584 100644 --- a/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift +++ b/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift @@ -28,26 +28,40 @@ struct PlayaHostedEventsView: View { @Environment(\.themeColors) var themeColors @State private var favoriteIDs: Set = [] @State private var now = Date() + @State private var eventSummary: String? + @State private var isLoadingSummary = false var body: some View { - List(events, id: \.uid) { event in - ObjectRowView( - object: event, - rightSubtitle: event.timeDescription(now: now), - isFavorite: favoriteIDs.contains(event.uid), - onFavoriteTap: { - Task { await toggleFavorite(event) } + VStack(spacing: 0) { + // AI Summary header + if eventSummary != nil || isLoadingSummary { + EventSummaryHeaderView(summary: eventSummary, isLoading: isLoadingSummary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + + Divider() + } + + List(events, id: \.uid) { event in + ObjectRowView( + object: event, + rightSubtitle: event.timeDescription(now: now), + isFavorite: favoriteIDs.contains(event.uid), + onFavoriteTap: { + Task { await toggleFavorite(event) } + } + ) { _ in + Text(EventTypeInfo.emoji(for: event.eventTypeCode)) + .font(.subheadline) } - ) { _ in - Text(EventTypeInfo.emoji(for: event.eventTypeCode)) - .font(.subheadline) + .contentShape(Rectangle()) + .onTapGesture { pushDetail(for: event) } + .listRowBackground(themeColors.backgroundColor) } - .contentShape(Rectangle()) - .onTapGesture { pushDetail(for: event) } - .listRowBackground(themeColors.backgroundColor) + .listStyle(.plain) } - .listStyle(.plain) .task { await loadFavorites() } + .task { await generateSummary() } } private func loadFavorites() async { @@ -72,6 +86,19 @@ struct PlayaHostedEventsView: View { } } + private func generateSummary() async { + #if canImport(FoundationModels) + if #available(iOS 26, *) { + isLoadingSummary = true + eventSummary = await generateEventCollectionSummary( + events: events, + hostName: hostName + ) + isLoadingSummary = false + } + #endif + } + private func pushDetail(for event: EventObjectOccurrence) { let detailVC = DetailViewControllerFactory.create(with: event, playaDB: playaDB) // Walk the responder chain to find a navigation controller diff --git a/iBurn/Detail/Models/DetailCellType.swift b/iBurn/Detail/Models/DetailCellType.swift index 56407119..a8849f80 100644 --- a/iBurn/Detail/Models/DetailCellType.swift +++ b/iBurn/Detail/Models/DetailCellType.swift @@ -39,6 +39,8 @@ enum DetailCellType { case eventRelationship(count: Int, hostName: String, onTap: (() -> Void)?) case nextHostEvent(title: String, scheduleText: String, hostName: String, onTap: (() -> Void)?) case allHostEvents(count: Int, hostName: String, onTap: (() -> Void)?) + case eventSummaryLoading(hostName: String) + case eventSummary(String, hostName: String) case playaAddress(String, tappable: Bool) case distance(CLLocationDistance) case travelTime(CLLocationDistance) diff --git a/iBurn/Detail/ViewModels/DetailViewModel.swift b/iBurn/Detail/ViewModels/DetailViewModel.swift index c8295895..92177288 100644 --- a/iBurn/Detail/ViewModels/DetailViewModel.swift +++ b/iBurn/Detail/ViewModels/DetailViewModel.swift @@ -72,6 +72,10 @@ class DetailViewModel: ObservableObject { private var resolvedHostEvents: [EventObjectOccurrence] = [] /// Resolved occurrences for an EventObject (used by .event detail to show schedule) private var resolvedEventOccurrences: [EventObjectOccurrence] = [] + /// AI-generated summary of hosted events (nil = not yet generated or unavailable) + private var resolvedEventSummary: String? + /// Whether AI summary generation is in progress + private var isGeneratingEventSummary = false // MARK: - Initialization @@ -396,6 +400,11 @@ class DetailViewModel: ObservableObject { if needsRefresh { self.cells = generateCells() } + + // Phase 3: Generate AI summary of hosted events + if !resolvedHostEvents.isEmpty { + await generateEventSummaryIfNeeded() + } } func toggleFavorite() async { @@ -1070,6 +1079,7 @@ class DetailViewModel: ObservableObject { self.coordinator.handle(.navigateToViewController(vc)) } )) + cellTypes.append(contentsOf: generateEventSummaryCells(hostName: hostName)) } // Schedule (from resolved occurrences) @@ -1207,6 +1217,7 @@ class DetailViewModel: ObservableObject { self.coordinator.handle(.navigateToViewController(vc)) } )) + cellTypes.append(contentsOf: generateEventSummaryCells(hostName: hostName)) } // Schedule with color-coded time @@ -1844,6 +1855,50 @@ class DetailViewModel: ObservableObject { return cells } + /// Returns AI summary cell (loading, result, or empty) based on current state. + private func generateEventSummaryCells(hostName: String) -> [DetailCellType] { + #if canImport(FoundationModels) + if #available(iOS 26, *) { + if let summary = resolvedEventSummary { + return [.eventSummary(summary, hostName: hostName)] + } else if isGeneratingEventSummary { + return [.eventSummaryLoading(hostName: hostName)] + } + } + #endif + return [] + } + + /// Kick off AI summary generation after hosted events are loaded. + private func generateEventSummaryIfNeeded() async { + #if canImport(FoundationModels) + guard #available(iOS 26, *) else { return } + guard !resolvedHostEvents.isEmpty, + resolvedEventSummary == nil, + !isGeneratingEventSummary else { return } + + let hostName: String + switch subject { + case .art(let art): hostName = art.name + case .camp(let camp): hostName = camp.name + case .event, .eventOccurrence: hostName = resolvedHostName ?? "this host" + default: return + } + + isGeneratingEventSummary = true + self.cells = generateCells() + + let summary = await generateEventCollectionSummary( + events: resolvedHostEvents, + hostName: hostName + ) + + isGeneratingEventSummary = false + resolvedEventSummary = summary + self.cells = generateCells() + #endif + } + /// Generate hosted event cells (next event + all events) for a camp/art detail screen. private func generateHostedEventCells(hostName: String) -> [DetailCellType] { guard let playaDB, !resolvedHostEvents.isEmpty else { return [] } @@ -1883,6 +1938,9 @@ class DetailViewModel: ObservableObject { } )) + // AI summary of hosted events + cells.append(contentsOf: generateEventSummaryCells(hostName: hostName)) + return cells } diff --git a/iBurn/Detail/Views/DetailView.swift b/iBurn/Detail/Views/DetailView.swift index 4f5e1fb6..8b80515d 100644 --- a/iBurn/Detail/Views/DetailView.swift +++ b/iBurn/Detail/Views/DetailView.swift @@ -213,7 +213,13 @@ struct DetailCellView: View { case .allHostEvents(let count, let hostName, _): DetailAllHostEventsCell(count: count, hostName: hostName) - + + case .eventSummaryLoading: + EventSummaryHeaderView(summary: nil, isLoading: true) + + case .eventSummary(let summary, _): + EventSummaryHeaderView(summary: summary, isLoading: false) + case .schedule(let attributedString): DetailScheduleCell(attributedString: attributedString) @@ -271,7 +277,7 @@ struct DetailCellView: View { return onTap != nil case .playaAddress(_, let tappable): return tappable - case .text, .distance, .travelTime, .schedule, .date, .landmark, .eventType: + case .text, .distance, .travelTime, .schedule, .date, .landmark, .eventType, .eventSummaryLoading, .eventSummary: return false case .image: return true @@ -705,6 +711,38 @@ struct DetailAllHostEventsCell: View { } } +/// Shared view for AI event summary — used by both DetailView cells and PlayaHostedEventsView. +struct EventSummaryHeaderView: View { + let summary: String? + let isLoading: Bool + @Environment(\.themeColors) var themeColors + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label("AI SUMMARY", systemImage: "sparkles") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(themeColors.detailColor) + .textCase(.uppercase) + + if isLoading { + HStack(spacing: 8) { + ProgressView() + .scaleEffect(0.7) + Text("Summarizing events...") + .font(.caption) + .foregroundColor(themeColors.secondaryColor) + } + } else if let summary { + Text(summary) + .font(.subheadline) + .foregroundColor(themeColors.secondaryColor) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} + struct DetailLandmarkCell: View { let landmark: String @Environment(\.themeColors) var themeColors From ae2b83c6d60d9409608fd5d25725dfb15943cfaf Mon Sep 17 00:00:00 2001 From: Chris Ballinger Date: Mon, 13 Apr 2026 10:45:42 -0400 Subject: [PATCH 3/5] Pass event summary from detail page to hosted events list Instead of regenerating the AI summary, pass it through from the detail page. Also move the summary into the scrollable list content so it scrolls away with the rest of the rows. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../PlayaHostedEventsViewController.swift | 40 +++++-------------- iBurn/Detail/ViewModels/DetailViewModel.swift | 9 +++-- 2 files changed, 17 insertions(+), 32 deletions(-) diff --git a/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift b/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift index 48b69584..4e2f9032 100644 --- a/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift +++ b/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift @@ -6,11 +6,12 @@ import PlayaDB /// Used for the "See all N events" tap from the PlayaDB event detail screen. @MainActor class PlayaHostedEventsViewController: UIHostingController { - init(events: [EventObjectOccurrence], hostName: String, playaDB: PlayaDB) { + init(events: [EventObjectOccurrence], hostName: String, playaDB: PlayaDB, eventSummary: String? = nil) { let view = PlayaHostedEventsView( events: events, hostName: hostName, - playaDB: playaDB + playaDB: playaDB, + eventSummary: eventSummary ) super.init(rootView: view) self.title = "Events - \(hostName)" @@ -25,24 +26,20 @@ struct PlayaHostedEventsView: View { let events: [EventObjectOccurrence] let hostName: String let playaDB: PlayaDB + let eventSummary: String? @Environment(\.themeColors) var themeColors @State private var favoriteIDs: Set = [] @State private var now = Date() - @State private var eventSummary: String? - @State private var isLoadingSummary = false var body: some View { - VStack(spacing: 0) { - // AI Summary header - if eventSummary != nil || isLoadingSummary { - EventSummaryHeaderView(summary: eventSummary, isLoading: isLoadingSummary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - - Divider() + List { + // AI Summary as first scrollable row + if let eventSummary { + EventSummaryHeaderView(summary: eventSummary, isLoading: false) + .listRowBackground(themeColors.backgroundColor) } - List(events, id: \.uid) { event in + ForEach(events, id: \.uid) { event in ObjectRowView( object: event, rightSubtitle: event.timeDescription(now: now), @@ -58,10 +55,9 @@ struct PlayaHostedEventsView: View { .onTapGesture { pushDetail(for: event) } .listRowBackground(themeColors.backgroundColor) } - .listStyle(.plain) } + .listStyle(.plain) .task { await loadFavorites() } - .task { await generateSummary() } } private func loadFavorites() async { @@ -86,19 +82,6 @@ struct PlayaHostedEventsView: View { } } - private func generateSummary() async { - #if canImport(FoundationModels) - if #available(iOS 26, *) { - isLoadingSummary = true - eventSummary = await generateEventCollectionSummary( - events: events, - hostName: hostName - ) - isLoadingSummary = false - } - #endif - } - private func pushDetail(for event: EventObjectOccurrence) { let detailVC = DetailViewControllerFactory.create(with: event, playaDB: playaDB) // Walk the responder chain to find a navigation controller @@ -110,4 +93,3 @@ struct PlayaHostedEventsView: View { navController.pushViewController(detailVC, animated: true) } } - diff --git a/iBurn/Detail/ViewModels/DetailViewModel.swift b/iBurn/Detail/ViewModels/DetailViewModel.swift index 92177288..35fd4c5a 100644 --- a/iBurn/Detail/ViewModels/DetailViewModel.swift +++ b/iBurn/Detail/ViewModels/DetailViewModel.swift @@ -1074,7 +1074,8 @@ class DetailViewModel: ObservableObject { let vc = PlayaHostedEventsViewController( events: self.resolvedHostEvents, hostName: hostName, - playaDB: playaDB + playaDB: playaDB, + eventSummary: self.resolvedEventSummary ) self.coordinator.handle(.navigateToViewController(vc)) } @@ -1212,7 +1213,8 @@ class DetailViewModel: ObservableObject { let vc = PlayaHostedEventsViewController( events: self.resolvedHostEvents, hostName: hostName, - playaDB: playaDB + playaDB: playaDB, + eventSummary: self.resolvedEventSummary ) self.coordinator.handle(.navigateToViewController(vc)) } @@ -1932,7 +1934,8 @@ class DetailViewModel: ObservableObject { let vc = PlayaHostedEventsViewController( events: self.resolvedHostEvents, hostName: hostName, - playaDB: playaDB + playaDB: playaDB, + eventSummary: self.resolvedEventSummary ) self.coordinator.handle(.navigateToViewController(vc)) } From 82005a7720800a372f9a4e6436bce735a85eb917 Mon Sep 17 00:00:00 2001 From: Chris Ballinger Date: Mon, 13 Apr 2026 13:36:22 -0400 Subject: [PATCH 4/5] Prevent hallucinations, add cache/pre-warm, tappable schedule tips Split AI summary into Swift-generated schedule tips (factual, from real occurrence data) and LLM-generated overview (no timing info allowed). This prevents the on-device model from fabricating event times. - Schedule tips: group by event name (fixes duplicates), sorted by day-of-week, strikethrough for expired (only during festival), tappable to navigate to event detail, themed highlight color - RAM cache (actor-based EventSummaryCache) keyed by host UID - Model pre-warming at AgentOrchestrator init - LLM overview: 1-2 sentences, can mention event names, no times/days - Host camp description passed to LLM for richer context - Tips show instantly, LLM overview arrives async Co-Authored-By: Claude Opus 4.6 (1M context) --- iBurn/AISearch/AIAssistantModels.swift | 2 +- iBurn/AISearch/AgentOrchestrator.swift | 11 ++ iBurn/AISearch/EventSummaryCache.swift | 24 +++ .../Workflows/WorkflowUtilities.swift | 171 +++++++++++++++++- .../PlayaHostedEventsViewController.swift | 13 +- iBurn/Detail/Models/DetailCellType.swift | 19 +- iBurn/Detail/ViewModels/DetailViewModel.swift | 107 +++++++---- iBurn/Detail/Views/DetailView.swift | 51 ++++-- 8 files changed, 339 insertions(+), 59 deletions(-) create mode 100644 iBurn/AISearch/EventSummaryCache.swift diff --git a/iBurn/AISearch/AIAssistantModels.swift b/iBurn/AISearch/AIAssistantModels.swift index 7fc90a8d..6e6c1397 100644 --- a/iBurn/AISearch/AIAssistantModels.swift +++ b/iBurn/AISearch/AIAssistantModels.swift @@ -98,7 +98,7 @@ struct GenerableNearbyResponse { @available(iOS 26, *) @Generable struct GenerableEventCollectionSummary { - @Guide(description: "One to two sentence highlight of what this collection of events offers, like a pro tip for someone deciding whether to visit") + @Guide(description: "1-2 short sentences highlighting interesting events and offerings. No times, days, or schedules.") var summary: String } diff --git a/iBurn/AISearch/AgentOrchestrator.swift b/iBurn/AISearch/AgentOrchestrator.swift index 535e698d..ba706519 100644 --- a/iBurn/AISearch/AgentOrchestrator.swift +++ b/iBurn/AISearch/AgentOrchestrator.swift @@ -24,12 +24,23 @@ final class AgentOrchestrator: @unchecked Sendable { init(playaDB: PlayaDB, locationProvider: LocationProvider) { self.playaDB = playaDB self.locationProvider = locationProvider + Self.warmUpLanguageModel() } var isAvailable: Bool { SystemLanguageModel.default.isAvailable } + /// Pre-warm the on-device language model with a trivial call so it's + /// already loaded in memory when the user opens a detail page. + private static func warmUpLanguageModel() { + guard SystemLanguageModel.default.isAvailable else { return } + Task.detached(priority: .background) { + let session = LanguageModelSession(instructions: "Reply with OK.") + _ = try? await session.respond(to: Prompt("ping")) + } + } + // MARK: - Step Execution /// Execute a single LLM step with focused tools and instructions diff --git a/iBurn/AISearch/EventSummaryCache.swift b/iBurn/AISearch/EventSummaryCache.swift new file mode 100644 index 00000000..126195cd --- /dev/null +++ b/iBurn/AISearch/EventSummaryCache.swift @@ -0,0 +1,24 @@ +// +// EventSummaryCache.swift +// iBurn +// +// In-memory cache for AI-generated event summaries, keyed by host UID. +// + +import Foundation + +/// Actor-based RAM cache for AI event summaries. +/// Thread-safe and matches the async/await calling pattern used by the workflow pipeline. +actor EventSummaryCache { + static let shared = EventSummaryCache() + + private var cache: [String: EventSummaryContent] = [:] + + func get(_ hostUID: String) -> EventSummaryContent? { + cache[hostUID] + } + + func set(_ hostUID: String, content: EventSummaryContent) { + cache[hostUID] = content + } +} diff --git a/iBurn/AISearch/Workflows/WorkflowUtilities.swift b/iBurn/AISearch/Workflows/WorkflowUtilities.swift index 62ffd7e7..cc1bacf0 100644 --- a/iBurn/AISearch/Workflows/WorkflowUtilities.swift +++ b/iBurn/AISearch/Workflows/WorkflowUtilities.swift @@ -187,18 +187,162 @@ func withContextWindowRetry( return try await attempt(minimumCount) } +// MARK: - Schedule Tip Generator (Pure Swift — No LLM) + +/// Build factual schedule tips from actual event occurrence data. +/// Groups occurrences by event name (merges duplicate EventObjects), detects recurrence, +/// sorts by day-of-week, marks expired events. Returns up to 5 ScheduleTips. +func buildScheduleTips(from events: [EventObjectOccurrence]) -> [ScheduleTip] { + guard !events.isEmpty else { return [] } + + let now = Date() + + // Formatters in BRC timezone + var brcCalendar = Calendar(identifier: .gregorian) + brcCalendar.timeZone = TimeZone.burningManTimeZone + + let dayFormatter = DateFormatter() + dayFormatter.dateFormat = "EEE" + dayFormatter.timeZone = TimeZone.burningManTimeZone + + let timeFormatter = DateFormatter() + timeFormatter.dateFormat = "h:mma" + timeFormatter.timeZone = TimeZone.burningManTimeZone + timeFormatter.amSymbol = "am" + timeFormatter.pmSymbol = "pm" + + func shortTime(_ date: Date) -> String { + let minute = brcCalendar.component(.minute, from: date) + if minute == 0 { + let hourFormatter = DateFormatter() + hourFormatter.dateFormat = "ha" + hourFormatter.timeZone = TimeZone.burningManTimeZone + hourFormatter.amSymbol = "am" + hourFormatter.pmSymbol = "pm" + return hourFormatter.string(from: date) + } + return timeFormatter.string(from: date) + } + + // Group by event NAME to merge duplicate EventObject records + struct OccurrenceInfo { + let firstEventUID: String // for navigation + let typeEmoji: String + let typeName: String + var occurrences: [(day: String, startTime: String, endTime: String, startDate: Date, endDate: Date)] + } + + var grouped: [String: OccurrenceInfo] = [:] + var order: [String] = [] + + for event in events { + let key = event.name + if grouped[key] == nil { + grouped[key] = OccurrenceInfo( + firstEventUID: event.event.uid, + typeEmoji: EventTypeInfo.emoji(for: event.eventTypeCode), + typeName: EventTypeInfo.displayName(for: event.eventTypeCode), + occurrences: [] + ) + order.append(key) + } + let day = dayFormatter.string(from: event.startDate) + let start = shortTime(event.startDate) + let end = shortTime(event.endDate) + grouped[key]?.occurrences.append((day: day, startTime: start, endTime: end, startDate: event.startDate, endDate: event.endDate)) + } + + // Deduplicate identical occurrences within each group (same day + same time range) + for key in order { + guard var info = grouped[key] else { continue } + var seen = Set() + info.occurrences = info.occurrences.filter { occ in + let fingerprint = "\(occ.day)|\(occ.startTime)|\(occ.endTime)" + return seen.insert(fingerprint).inserted + } + grouped[key] = info + } + + // Sort by earliest occurrence's day-of-week (Sun=1 → Sat=7) + let sorted = order.sorted { a, b in + let startA = grouped[a]?.occurrences.first?.startDate ?? .distantFuture + let startB = grouped[b]?.occurrences.first?.startDate ?? .distantFuture + return startA < startB + } + + // Build tips + var tips: [ScheduleTip] = [] + for name in sorted.prefix(5) { + guard let info = grouped[name] else { continue } + + let schedule: String + if info.occurrences.count == 1 { + let occ = info.occurrences[0] + schedule = "\(occ.day) \(occ.startTime)-\(occ.endTime)" + } else { + let timeRanges = Set(info.occurrences.map { "\($0.startTime)-\($0.endTime)" }) + if timeRanges.count == 1, let timeRange = timeRanges.first { + let days = info.occurrences.map(\.day).joined(separator: "/") + schedule = "\(days) \(timeRange)" + } else { + let parts = info.occurrences.map { "\($0.day) \($0.startTime)-\($0.endTime)" } + schedule = parts.joined(separator: ", ") + } + } + + let allExpired = info.occurrences.allSatisfy { $0.endDate < now } + let earliest = info.occurrences.map(\.startDate).min() ?? .distantFuture + + tips.append(ScheduleTip( + text: "\(name) (\(info.typeEmoji) \(info.typeName)) — \(schedule)", + eventUID: info.firstEventUID, + isExpired: allExpired, + earliestStart: earliest + )) + } + + return tips +} + // MARK: - Event Collection Summary -/// Generate an AI summary of a collection of events hosted by a camp/art. -/// Uses retryWithCandidateFiltering + withContextWindowRetry so we still get -/// a useful summary even when individual events trigger guardrails or exceed context. +/// Generate schedule tips (pure Swift) and an AI overview (LLM) for a host's events. +/// Tips are always factual. The LLM overview may fail — tips alone are returned in that case. @available(iOS 26, *) func generateEventCollectionSummary( events: [EventObjectOccurrence], - hostName: String -) async -> String? { + hostName: String, + hostUID: String, + hostDescription: String? = nil +) async -> EventSummaryContent? { guard !events.isEmpty else { return nil } + // Check cache first + if let cached = await EventSummaryCache.shared.get(hostUID) { + return cached + } + + // Step 1: Build factual tips from real data (instant, no LLM) + let tips = buildScheduleTips(from: events) + + // Step 2: Generate overview via LLM (may fail — that's OK) + let overview = await generateEventOverview(events: events, hostName: hostName, hostDescription: hostDescription) + + // Only return content if we have something to show + guard overview != nil || !tips.isEmpty else { return nil } + + let content = EventSummaryContent(summary: overview, tips: tips) + await EventSummaryCache.shared.set(hostUID, content: content) + return content +} + +/// LLM-generated overview. Can mention event names and camp offerings, but no timing info. +@available(iOS 26, *) +private func generateEventOverview( + events: [EventObjectOccurrence], + hostName: String, + hostDescription: String? = nil +) async -> String? { do { return try await withContextWindowRetry( initialCount: min(events.count, 20), @@ -217,20 +361,27 @@ func generateEventCollectionSummary( return "\(idx + 1). \(event.name) [\(type)]\(desc.isEmpty ? "" : " - \(desc)")" }.joined(separator: "\n") + var prompt = "Events hosted by \(hostName):\n\(text)" + if let hostDesc = hostDescription, !hostDesc.isEmpty { + prompt += "\n\nCamp description: \(String(hostDesc.prefix(200)))" + } + let session = LanguageModelSession(instructions: """ - Highlight what \(hostName) offers based on their events. \ - Write 1-2 sentences max, like a pro tip for someone deciding whether to visit. \ - Focus on standout offerings and variety. + Summarize what \(hostName) offers in 1-2 short sentences. \ + You can mention interesting or unique events by name and \ + describe what they're about. You can also mention camp \ + offerings from the description. Do NOT mention any times, \ + days, or schedules — those are shown separately. """) return try await session.respond( - to: Prompt("Events hosted by \(hostName):\n\(text)"), + to: Prompt(prompt), generating: GenerableEventCollectionSummary.self ).content } return result.summary } } catch { - print("Event summary generation failed: \(error)") + print("Event overview generation failed: \(error)") return nil } } diff --git a/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift b/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift index 4e2f9032..910a8b59 100644 --- a/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift +++ b/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift @@ -6,7 +6,7 @@ import PlayaDB /// Used for the "See all N events" tap from the PlayaDB event detail screen. @MainActor class PlayaHostedEventsViewController: UIHostingController { - init(events: [EventObjectOccurrence], hostName: String, playaDB: PlayaDB, eventSummary: String? = nil) { + init(events: [EventObjectOccurrence], hostName: String, playaDB: PlayaDB, eventSummary: EventSummaryContent? = nil) { let view = PlayaHostedEventsView( events: events, hostName: hostName, @@ -26,7 +26,7 @@ struct PlayaHostedEventsView: View { let events: [EventObjectOccurrence] let hostName: String let playaDB: PlayaDB - let eventSummary: String? + let eventSummary: EventSummaryContent? @Environment(\.themeColors) var themeColors @State private var favoriteIDs: Set = [] @State private var now = Date() @@ -35,8 +35,13 @@ struct PlayaHostedEventsView: View { List { // AI Summary as first scrollable row if let eventSummary { - EventSummaryHeaderView(summary: eventSummary, isLoading: false) - .listRowBackground(themeColors.backgroundColor) + EventSummaryHeaderView(content: eventSummary, isLoading: false) { tip in + // Navigate to the tapped event + if let occ = events.first(where: { $0.event.uid == tip.eventUID }) { + pushDetail(for: occ) + } + } + .listRowBackground(themeColors.backgroundColor) } ForEach(events, id: \.uid) { event in diff --git a/iBurn/Detail/Models/DetailCellType.swift b/iBurn/Detail/Models/DetailCellType.swift index a8849f80..20c02689 100644 --- a/iBurn/Detail/Models/DetailCellType.swift +++ b/iBurn/Detail/Models/DetailCellType.swift @@ -40,7 +40,7 @@ enum DetailCellType { case nextHostEvent(title: String, scheduleText: String, hostName: String, onTap: (() -> Void)?) case allHostEvents(count: Int, hostName: String, onTap: (() -> Void)?) case eventSummaryLoading(hostName: String) - case eventSummary(String, hostName: String) + case eventSummary(EventSummaryContent, hostName: String, onTipTap: ((ScheduleTip) -> Void)?) case playaAddress(String, tappable: Bool) case distance(CLLocationDistance) case travelTime(CLLocationDistance) @@ -54,6 +54,23 @@ enum DetailCellType { case viewHistory(firstViewed: Date?, lastViewed: Date?) } +// MARK: - Event Summary Content + +/// A single schedule tip built from real event occurrence data. +struct ScheduleTip: Identifiable { + let id = UUID() + let text: String // formatted display text + let eventUID: String // base event uid (for navigation) + let isExpired: Bool // all occurrences have ended + let earliestStart: Date // for day-of-week sorting +} + +/// AI-generated summary of a host's events, with Swift-generated schedule tips. +struct EventSummaryContent { + let summary: String? // LLM overview (may be nil if generation failed) + let tips: [ScheduleTip] // Swift-generated schedule tips (always factual) +} + // MARK: - Supporting Types /// Text styling options for detail cells diff --git a/iBurn/Detail/ViewModels/DetailViewModel.swift b/iBurn/Detail/ViewModels/DetailViewModel.swift index 35fd4c5a..c59fb881 100644 --- a/iBurn/Detail/ViewModels/DetailViewModel.swift +++ b/iBurn/Detail/ViewModels/DetailViewModel.swift @@ -72,10 +72,12 @@ class DetailViewModel: ObservableObject { private var resolvedHostEvents: [EventObjectOccurrence] = [] /// Resolved occurrences for an EventObject (used by .event detail to show schedule) private var resolvedEventOccurrences: [EventObjectOccurrence] = [] - /// AI-generated summary of hosted events (nil = not yet generated or unavailable) - private var resolvedEventSummary: String? - /// Whether AI summary generation is in progress - private var isGeneratingEventSummary = false + /// Swift-generated schedule tips (available instantly when events load) + private var resolvedEventTips: [ScheduleTip] = [] + /// LLM-generated vibe overview (arrives async) + private var resolvedEventOverview: String? + /// Whether LLM overview generation is in progress + private var isGeneratingEventOverview = false // MARK: - Initialization @@ -1075,7 +1077,7 @@ class DetailViewModel: ObservableObject { events: self.resolvedHostEvents, hostName: hostName, playaDB: playaDB, - eventSummary: self.resolvedEventSummary + eventSummary: self.resolvedEventTips.isEmpty && self.resolvedEventOverview == nil ? nil : EventSummaryContent(summary: self.resolvedEventOverview, tips: self.resolvedEventTips) ) self.coordinator.handle(.navigateToViewController(vc)) } @@ -1214,7 +1216,7 @@ class DetailViewModel: ObservableObject { events: self.resolvedHostEvents, hostName: hostName, playaDB: playaDB, - eventSummary: self.resolvedEventSummary + eventSummary: self.resolvedEventTips.isEmpty && self.resolvedEventOverview == nil ? nil : EventSummaryContent(summary: self.resolvedEventOverview, tips: self.resolvedEventTips) ) self.coordinator.handle(.navigateToViewController(vc)) } @@ -1857,47 +1859,88 @@ class DetailViewModel: ObservableObject { return cells } - /// Returns AI summary cell (loading, result, or empty) based on current state. + /// Returns AI summary cell based on current state. + /// Tips are always available when events are loaded. Overview arrives async. private func generateEventSummaryCells(hostName: String) -> [DetailCellType] { - #if canImport(FoundationModels) - if #available(iOS 26, *) { - if let summary = resolvedEventSummary { - return [.eventSummary(summary, hostName: hostName)] - } else if isGeneratingEventSummary { - return [.eventSummaryLoading(hostName: hostName)] + let hasTips = !resolvedEventTips.isEmpty + let hasOverview = resolvedEventOverview != nil + + if hasTips || hasOverview { + let content = EventSummaryContent( + summary: resolvedEventOverview, + tips: resolvedEventTips + ) + let onTipTap: ((ScheduleTip) -> Void)? = { [weak self] tip in + guard let self, let playaDB else { return } + // Find the first matching occurrence for this event + if let occ = self.resolvedHostEvents.first(where: { $0.event.uid == tip.eventUID }) { + let vc = DetailViewControllerFactory.create(with: occ, playaDB: playaDB) + self.coordinator.handle(.navigateToViewController(vc)) + } } + return [.eventSummary(content, hostName: hostName, onTipTap: onTipTap)] + } else if isGeneratingEventOverview { + return [.eventSummaryLoading(hostName: hostName)] } - #endif return [] } - /// Kick off AI summary generation after hosted events are loaded. + /// Compute schedule tips (sync) and kick off LLM overview (async). private func generateEventSummaryIfNeeded() async { - #if canImport(FoundationModels) - guard #available(iOS 26, *) else { return } guard !resolvedHostEvents.isEmpty, - resolvedEventSummary == nil, - !isGeneratingEventSummary else { return } + resolvedEventTips.isEmpty, + !isGeneratingEventOverview else { return } let hostName: String + let hostUID: String switch subject { - case .art(let art): hostName = art.name - case .camp(let camp): hostName = camp.name - case .event, .eventOccurrence: hostName = resolvedHostName ?? "this host" + case .art(let art): hostName = art.name; hostUID = art.uid + case .camp(let camp): hostName = camp.name; hostUID = camp.uid + case .event(let event): + hostName = resolvedHostName ?? "this host" + hostUID = event.hostedByCamp ?? event.locatedAtArt ?? event.uid + case .eventOccurrence(let occ): + hostName = resolvedHostName ?? "this host" + hostUID = occ.hostedByCamp ?? occ.locatedAtArt ?? occ.event.uid default: return } - isGeneratingEventSummary = true - self.cells = generateCells() + // Check cache first — show immediately without loading spinner + if let cached = await EventSummaryCache.shared.get(hostUID) { + resolvedEventTips = cached.tips + resolvedEventOverview = cached.summary + self.cells = generateCells() + return + } - let summary = await generateEventCollectionSummary( - events: resolvedHostEvents, - hostName: hostName - ) + // Step 1: Compute tips instantly from real data (pure Swift) + #if canImport(FoundationModels) + if #available(iOS 26, *) { + resolvedEventTips = buildScheduleTips(from: resolvedHostEvents) + } + #endif + self.cells = generateCells() // Show tips immediately - isGeneratingEventSummary = false - resolvedEventSummary = summary - self.cells = generateCells() + // Step 2: Generate LLM overview asynchronously + #if canImport(FoundationModels) + if #available(iOS 26, *) { + isGeneratingEventOverview = true + + let content = await generateEventCollectionSummary( + events: resolvedHostEvents, + hostName: hostName, + hostUID: hostUID, + hostDescription: resolvedHostDescription + ) + + isGeneratingEventOverview = false + if let content { + resolvedEventOverview = content.summary + // Update tips from cache if they differ (shouldn't, but be safe) + if !content.tips.isEmpty { resolvedEventTips = content.tips } + } + self.cells = generateCells() + } #endif } @@ -1935,7 +1978,7 @@ class DetailViewModel: ObservableObject { events: self.resolvedHostEvents, hostName: hostName, playaDB: playaDB, - eventSummary: self.resolvedEventSummary + eventSummary: self.resolvedEventTips.isEmpty && self.resolvedEventOverview == nil ? nil : EventSummaryContent(summary: self.resolvedEventOverview, tips: self.resolvedEventTips) ) self.coordinator.handle(.navigateToViewController(vc)) } diff --git a/iBurn/Detail/Views/DetailView.swift b/iBurn/Detail/Views/DetailView.swift index 8b80515d..57d311e5 100644 --- a/iBurn/Detail/Views/DetailView.swift +++ b/iBurn/Detail/Views/DetailView.swift @@ -215,10 +215,10 @@ struct DetailCellView: View { DetailAllHostEventsCell(count: count, hostName: hostName) case .eventSummaryLoading: - EventSummaryHeaderView(summary: nil, isLoading: true) + EventSummaryHeaderView(content: nil, isLoading: true) - case .eventSummary(let summary, _): - EventSummaryHeaderView(summary: summary, isLoading: false) + case .eventSummary(let content, _, let onTipTap): + EventSummaryHeaderView(content: content, isLoading: false, onTipTap: onTipTap) case .schedule(let attributedString): DetailScheduleCell(attributedString: attributedString) @@ -277,7 +277,7 @@ struct DetailCellView: View { return onTap != nil case .playaAddress(_, let tappable): return tappable - case .text, .distance, .travelTime, .schedule, .date, .landmark, .eventType, .eventSummaryLoading, .eventSummary: + case .text, .distance, .travelTime, .schedule, .date, .landmark, .eventType, .eventSummaryLoading, .eventSummary(_, _, _): return false case .image: return true @@ -713,13 +713,14 @@ struct DetailAllHostEventsCell: View { /// Shared view for AI event summary — used by both DetailView cells and PlayaHostedEventsView. struct EventSummaryHeaderView: View { - let summary: String? + let content: EventSummaryContent? let isLoading: Bool + var onTipTap: ((ScheduleTip) -> Void)? @Environment(\.themeColors) var themeColors var body: some View { VStack(alignment: .leading, spacing: 8) { - Label("AI SUMMARY", systemImage: "sparkles") + Label("AI SLOP SUMMARY", systemImage: "sparkles") .font(.caption) .fontWeight(.semibold) .foregroundColor(themeColors.detailColor) @@ -733,11 +734,39 @@ struct EventSummaryHeaderView: View { .font(.caption) .foregroundColor(themeColors.secondaryColor) } - } else if let summary { - Text(summary) - .font(.subheadline) - .foregroundColor(themeColors.secondaryColor) - .fixedSize(horizontal: false, vertical: true) + } else if let content { + if let summary = content.summary { + Text(summary) + .font(.subheadline) + .foregroundColor(themeColors.secondaryColor) + .fixedSize(horizontal: false, vertical: true) + } + + if !content.tips.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(content.tips) { tip in + let expired = tip.isExpired && !YearSettings.isEventOver + if onTipTap != nil { + Button { + onTipTap?(tip) + } label: { + Text("• \(tip.text)") + .font(.caption) + .foregroundColor(expired ? themeColors.secondaryColor : themeColors.detailColor) + .strikethrough(expired) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + Text("• \(tip.text)") + .font(.caption) + .foregroundColor(expired ? themeColors.secondaryColor : themeColors.detailColor) + .strikethrough(expired) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } } } } From bb80cc894bd9fb2acff081b9947c1b768a0b3d2a Mon Sep 17 00:00:00 2001 From: Chris Ballinger Date: Mon, 13 Apr 2026 15:18:55 -0400 Subject: [PATCH 5/5] Add two-pass fact-check validation for AI event overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass 1 generates the overview with tightened instructions ("only reference provided data"). Pass 2 validates against the exact source data (camp description + event names/descriptions) using a separate LLM session that flags unsupported claims. Swift strips flagged phrases. If validation fails or strips too much, the overview is discarded entirely — no unverified text shown. Each step handles its own retries via withContextWindowRetry / retryWithCandidateFiltering, matching the pattern used by other AI workflows. Co-Authored-By: Claude Opus 4.6 (1M context) --- iBurn/AISearch/AIAssistantModels.swift | 9 +- .../Workflows/WorkflowUtilities.swift | 126 +++++++++++++++++- 2 files changed, 127 insertions(+), 8 deletions(-) diff --git a/iBurn/AISearch/AIAssistantModels.swift b/iBurn/AISearch/AIAssistantModels.swift index 6e6c1397..59f6915c 100644 --- a/iBurn/AISearch/AIAssistantModels.swift +++ b/iBurn/AISearch/AIAssistantModels.swift @@ -98,8 +98,15 @@ struct GenerableNearbyResponse { @available(iOS 26, *) @Generable struct GenerableEventCollectionSummary { - @Guide(description: "1-2 short sentences highlighting interesting events and offerings. No times, days, or schedules.") + @Guide(description: "1-2 short factual sentences about this host. Only reference provided data. No times or schedules.") var summary: String } +@available(iOS 26, *) +@Generable +struct GenerableFactCheck { + @Guide(description: "Phrases from the summary that are NOT supported by the source data. Empty if everything is accurate.", .count(0...5)) + var unsupportedClaims: [String] +} + #endif diff --git a/iBurn/AISearch/Workflows/WorkflowUtilities.swift b/iBurn/AISearch/Workflows/WorkflowUtilities.swift index cc1bacf0..80d18b8d 100644 --- a/iBurn/AISearch/Workflows/WorkflowUtilities.swift +++ b/iBurn/AISearch/Workflows/WorkflowUtilities.swift @@ -336,15 +336,123 @@ func generateEventCollectionSummary( return content } -/// LLM-generated overview. Can mention event names and camp offerings, but no timing info. +// MARK: - Source Data Assembly + +/// Build a ground-truth string from event data + host description for fact-checking. +private func buildSourceDataString( + events: [EventObjectOccurrence], + hostName: String, + hostDescription: String? +) -> String { + var parts: [String] = [] + if let desc = hostDescription, !desc.isEmpty { + parts.append("Camp description: \(desc)") + } + parts.append("Events:") + for event in events.prefix(20) { + let type = EventTypeInfo.displayName(for: event.eventTypeCode) + let desc = event.description.map { " - \($0)" } ?? "" + parts.append(" \(event.name) [\(type)]\(desc)") + } + return parts.joined(separator: "\n") +} + +/// Extract first 1-2 sentences of host description as fallback summary. +private func extractHostSummary(hostDescription: String?) -> String? { + guard let desc = hostDescription, !desc.isEmpty else { return nil } + // Split on sentence-ending punctuation, take first 2 + var sentences: [String] = [] + var current = "" + for char in desc { + current.append(char) + if ".!?".contains(char) { + let trimmed = current.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { sentences.append(trimmed) } + current = "" + if sentences.count >= 2 { break } + } + } + // If no sentence boundaries found, take first 150 chars + if sentences.isEmpty { + return String(desc.prefix(150)) + } + return sentences.joined(separator: " ") +} + +/// Clean up text after phrase removal: fix double spaces, dangling punctuation. +private func cleanStrippedText(_ text: String) -> String? { + var result = text + // Remove double+ spaces + while result.contains(" ") { + result = result.replacingOccurrences(of: " ", with: " ") + } + // Remove dangling ", and" / ", all" patterns left after stripping + result = result.replacingOccurrences(of: ", and ,", with: ",") + result = result.replacingOccurrences(of: ", ,", with: ",") + result = result.replacingOccurrences(of: ",,", with: ",") + result = result.trimmingCharacters(in: .whitespacesAndNewlines) + // Remove trailing comma + if result.hasSuffix(",") { result = String(result.dropLast()).trimmingCharacters(in: .whitespaces) } + // Too short after stripping = not useful + if result.count < 20 { return nil } + return result +} + +// MARK: - Validation Pipeline + +/// Validate LLM-generated overview against source data. +/// Uses withContextWindowRetry for context overflow and retries on guardrail errors. +/// Returns cleaned text with unsupported claims removed, or nil on failure. +@available(iOS 26, *) +private func validateOverview(overview: String, sourceData: String) async -> String? { + do { + let factCheck: GenerableFactCheck = try await withContextWindowRetry( + initialCount: 1, // single item, but sourceData may overflow + minimumCount: 1 + ) { _ in + let session = LanguageModelSession(instructions: """ + You are a fact-checker. Compare the summary against the source data below. \ + List any specific phrases or claims in the summary that are NOT directly \ + supported by the source data. Only flag fabricated or embellished details, \ + not reasonable inferences from the data. + """) + return try await session.respond( + to: Prompt("Summary to check:\n\(overview)\n\nSource data:\n\(sourceData)"), + generating: GenerableFactCheck.self + ).content + } + + if factCheck.unsupportedClaims.isEmpty { + return overview // Passed validation + } + + // Strip flagged phrases + var cleaned = overview + for claim in factCheck.unsupportedClaims { + cleaned = cleaned.replacingOccurrences(of: claim, with: "") + } + return cleanStrippedText(cleaned) + } catch { + print("Validation failed, discarding unverified overview: \(error)") + return nil + } +} + +// MARK: - Overview Generation (Generate → Validate → Strip) + +/// LLM-generated overview with two-pass validation. +/// Each step (generation, validation) handles its own retries via +/// withContextWindowRetry / retryWithCandidateFiltering. @available(iOS 26, *) private func generateEventOverview( events: [EventObjectOccurrence], hostName: String, hostDescription: String? = nil ) async -> String? { + // Pass 1: Generate (retries handled by withContextWindowRetry + retryWithCandidateFiltering) + let rawOverview: String? do { - return try await withContextWindowRetry( + rawOverview = try await withContextWindowRetry( initialCount: min(events.count, 20), minimumCount: 2 ) { maxCount in @@ -368,10 +476,8 @@ private func generateEventOverview( let session = LanguageModelSession(instructions: """ Summarize what \(hostName) offers in 1-2 short sentences. \ - You can mention interesting or unique events by name and \ - describe what they're about. You can also mention camp \ - offerings from the description. Do NOT mention any times, \ - days, or schedules — those are shown separately. + Only reference details from the provided data. Do NOT infer \ + or assume anything not explicitly stated. No times or schedules. """) return try await session.respond( to: Prompt(prompt), @@ -382,8 +488,14 @@ private func generateEventOverview( } } catch { print("Event overview generation failed: \(error)") - return nil + rawOverview = nil } + + guard let rawOverview else { return nil } + + // Pass 2: Validate (retries handled by withContextWindowRetry inside validateOverview) + let sourceData = buildSourceDataString(events: events, hostName: hostName, hostDescription: hostDescription) + return await validateOverview(overview: rawOverview, sourceData: sourceData) } // MARK: - Note Merging