diff --git a/IrisCompanion/iris/DataSources/CalendarDataSource.swift b/IrisCompanion/iris/DataSources/CalendarDataSource.swift index e4615b1..50ef23c 100644 --- a/IrisCompanion/iris/DataSources/CalendarDataSource.swift +++ b/IrisCompanion/iris/DataSources/CalendarDataSource.swift @@ -19,12 +19,42 @@ struct CalendarDataSourceConfig: Sendable { } final class CalendarDataSource { - struct CandidatesResult: Sendable { - let candidates: [Candidate] - let error: String? + struct Event: Sendable, Equatable { + let id: String + let title: String + let startAt: Int + let endAt: Int + let isAllDay: Bool + let location: String? + } + + struct Data: Sendable, Equatable { + let events: [Event] + } + + struct Snapshot: Sendable, Equatable { + let data: Data let diagnostics: [String: String] } + enum CalendarError: Error, LocalizedError, Sendable, Equatable { + case accessNotGranted(diagnostics: [String: String]) + + var errorDescription: String? { + switch self { + case .accessNotGranted: + return "Calendar access not granted." + } + } + + var diagnostics: [String: String] { + switch self { + case .accessNotGranted(let diagnostics): + return diagnostics + } + } + } + private let store: EKEventStore private let config: CalendarDataSourceConfig @@ -33,7 +63,7 @@ final class CalendarDataSource { self.config = config } - func candidatesWithDiagnostics(now: Int) async -> CandidatesResult { + func dataWithDiagnostics(now: Int) async throws -> Snapshot { var diagnostics: [String: String] = [ "now": String(now), "lookahead_sec": String(config.lookaheadSec), @@ -50,7 +80,7 @@ final class CalendarDataSource { diagnostics["access_granted"] = accessGranted ? "true" : "false" guard accessGranted else { - return CandidatesResult(candidates: [], error: "Calendar access not granted.", diagnostics: diagnostics) + throw CalendarError.accessNotGranted(diagnostics: diagnostics) } let predicate = store.predicateForEvents(withStart: nowDate, end: endDate, calendars: nil) @@ -64,10 +94,10 @@ final class CalendarDataSource { diagnostics["events_filtered"] = String(filtered.count) - let candidates = buildCandidates(from: filtered, now: now, nowDate: nowDate) - diagnostics["candidates"] = String(candidates.count) + let outputEvents = buildEvents(from: filtered, nowDate: nowDate) + diagnostics["events_output"] = String(outputEvents.count) - return CandidatesResult(candidates: candidates, error: nil, diagnostics: diagnostics) + return Snapshot(data: Data(events: outputEvents), diagnostics: diagnostics) } private func shouldInclude(event: EKEvent) -> Bool { @@ -84,8 +114,8 @@ final class CalendarDataSource { return true } - private func buildCandidates(from events: [EKEvent], now: Int, nowDate: Date) -> [Candidate] { - var results: [Candidate] = [] + private func buildEvents(from events: [EKEvent], nowDate: Date) -> [Event] { + var results: [Event] = [] results.reserveCapacity(min(config.maxCandidates, events.count)) for event in events { @@ -99,34 +129,16 @@ final class CalendarDataSource { continue } - let title = (isOngoing ? "Now: \(event.title ?? "Event")" : event.title ?? "Upcoming") - .truncated(maxLength: TextConstraints.titleMax) - - let subtitle = subtitleText(event: event, nowDate: nowDate) - .truncated(maxLength: TextConstraints.subtitleMax) - - let confidence: Double = isOngoing ? 0.9 : 0.7 - let ttl = ttlSec(end: end, nowDate: nowDate) - let id = "cal:\(event.eventIdentifier ?? UUID().uuidString):\(Int(start.timeIntervalSince1970))" - + let title = (event.title ?? "Event").trimmingCharacters(in: .whitespacesAndNewlines) results.append( - Candidate( + Event( id: id, - type: .info, - title: title, - subtitle: subtitle, - confidence: confidence, - createdAt: now, - ttlSec: ttl, - metadata: [ - "source": "eventkit", - "calendar": event.calendar.title, - "start": String(Int(start.timeIntervalSince1970)), - "end": String(Int(end.timeIntervalSince1970)), - "all_day": event.isAllDay ? "true" : "false", - "location": event.location ?? "", - ] + title: title.isEmpty ? "Event" : title, + startAt: Int(start.timeIntervalSince1970), + endAt: Int(end.timeIntervalSince1970), + isAllDay: event.isAllDay, + location: event.location ) ) } @@ -134,31 +146,6 @@ final class CalendarDataSource { return results } - private func ttlSec(end: Date, nowDate: Date) -> Int { - let ttl = Int(end.timeIntervalSince(nowDate)) - // Keep the candidate alive until it ends, but cap at 2h and floor at 60s. - return min(max(ttl, 60), 2 * 60 * 60) - } - - private func subtitleText(event: EKEvent, nowDate: Date) -> String { - guard let start = event.startDate, let end = event.endDate else { return "" } - let isOngoing = start <= nowDate && end > nowDate - - if isOngoing { - let remainingMin = max(0, Int(floor(end.timeIntervalSince(nowDate) / 60.0))) - if let loc = event.location, !loc.isEmpty { - return "\(loc) • \(remainingMin)m left" - } - return "\(remainingMin)m left" - } - - let minutes = max(0, Int(floor(start.timeIntervalSince(nowDate) / 60.0))) - if let loc = event.location, !loc.isEmpty { - return "In \(minutes)m • \(loc)" - } - return "In \(minutes)m" - } - private func calendarAuthorizationStatusString() -> String { if #available(iOS 17.0, *) { return String(describing: EKEventStore.authorizationStatus(for: .event)) @@ -169,13 +156,18 @@ final class CalendarDataSource { private func ensureCalendarAccess() async -> Bool { let status = EKEventStore.authorizationStatus(for: .event) + if #available(iOS 17.0, *) { + if status == .fullAccess { return true } + if status == .writeOnly { return false } + } + switch status { - case .authorized: - return true - case .denied, .restricted: - return false case .notDetermined: return await requestAccess() + case .denied, .restricted: + return false + case .authorized: + return true @unknown default: return false } diff --git a/IrisCompanion/iris/DataSources/POIDataSource.swift b/IrisCompanion/iris/DataSources/POIDataSource.swift index 5a98d96..f9bc813 100644 --- a/IrisCompanion/iris/DataSources/POIDataSource.swift +++ b/IrisCompanion/iris/DataSources/POIDataSource.swift @@ -15,13 +15,18 @@ struct POIDataSourceConfig: Sendable { /// Placeholder POI source. Hook point for MapKit / local cache / server-driven POIs. final class POIDataSource { + struct POI: Sendable, Equatable { + let id: String + let name: String + } + private let config: POIDataSourceConfig init(config: POIDataSourceConfig = .init()) { self.config = config } - func candidates(for location: CLLocation, now: Int) async throws -> [Candidate] { + func data(for location: CLLocation, now: Int) async throws -> [POI] { // Phase 1 stub: return nothing. // (Still async/throws so the orchestrator can treat it uniformly with real implementations later.) _ = config @@ -30,4 +35,3 @@ final class POIDataSource { return [] } } - diff --git a/IrisCompanion/iris/DataSources/WeatherDataSource.swift b/IrisCompanion/iris/DataSources/WeatherDataSource.swift index 8b8ca27..a81fd21 100644 --- a/IrisCompanion/iris/DataSources/WeatherDataSource.swift +++ b/IrisCompanion/iris/DataSources/WeatherDataSource.swift @@ -20,95 +20,83 @@ struct WeatherAlertConfig: Sendable { init() {} } -protocol WeatherWarningProviding: Sendable { - func warningCandidates(location: CLLocation, now: Int) -> [Candidate] -} +struct WeatherWarning: Codable, Equatable, Sendable { + let id: String + let title: String + let subtitle: String + let ttlSec: Int + let confidence: Double -struct NoopWeatherWarningProvider: WeatherWarningProviding { - func warningCandidates(location: CLLocation, now: Int) -> [Candidate] { [] } -} - -/// Loads mock warning candidates from a local JSON file. -/// -/// File format: -/// [ -/// { "id":"warn:demo", "title":"...", "subtitle":"...", "ttl_sec":3600, "confidence":0.9, "meta": { "source":"mock" } } -/// ] -struct LocalMockWeatherWarningProvider: WeatherWarningProviding, Sendable { - struct MockWarning: Codable { - let id: String - let title: String - let subtitle: String - let ttlSec: Int? - let confidence: Double? - let meta: [String: String]? - - enum CodingKeys: String, CodingKey { - case id - case title - case subtitle - case ttlSec = "ttl_sec" - case confidence - case meta - } - } - - let url: URL? - - init(url: URL?) { - self.url = url - } - - func warningCandidates(location: CLLocation, now: Int) -> [Candidate] { - guard let url else { return [] } - guard let data = try? Data(contentsOf: url) else { return [] } - guard let items = try? JSONDecoder().decode([MockWarning].self, from: data) else { return [] } - return items.map { item in - Candidate( - id: item.id, - type: .weatherWarning, - title: item.title.truncated(maxLength: TextConstraints.titleMax), - subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax), - confidence: min(max(item.confidence ?? 0.9, 0.0), 1.0), - createdAt: now, - ttlSec: max(1, item.ttlSec ?? 3600), - metadata: item.meta - ) - } + enum CodingKeys: String, CodingKey { + case id + case title + case subtitle + case ttlSec = "ttl_sec" + case confidence } } @available(iOS 16.0, *) final class WeatherDataSource { + struct WeatherData: Sendable, Equatable { + struct Current: Sendable, Equatable { + let temperatureC: Int + let feelsLikeC: Int + let condition: WeatherKit.WeatherCondition + } + + enum RainSource: String, Sendable, Equatable { + case minutely + case hourlyApprox + } + + struct RainSoon: Sendable, Equatable { + let startAt: Int + let ttlSec: Int + let source: RainSource + } + + struct WindAlert: Sendable, Equatable { + let gustMps: Double + let thresholdMps: Double + let ttlSec: Int + } + + let current: Current? + let rainSoon: RainSoon? + let windAlert: WindAlert? + let warnings: [WeatherWarning] + } + private let service: WeatherService private let config: WeatherAlertConfig - private let warningProvider: WeatherWarningProviding init(service: WeatherService = .shared, - config: WeatherAlertConfig = .init(), - warningProvider: WeatherWarningProviding = LocalMockWeatherWarningProvider( - url: Bundle.main.url(forResource: "mock_weather_warnings", withExtension: "json") - )) { + config: WeatherAlertConfig = .init()) { self.service = service self.config = config - self.warningProvider = warningProvider } - /// Returns alert candidates derived from WeatherKit forecasts. - func candidates(for location: CLLocation, now: Int) async -> [Candidate] { - let result = await candidatesWithDiagnostics(for: location, now: now) - return result.candidates - } - - struct CandidatesResult: Sendable { - let candidates: [Candidate] - let weatherKitError: String? + struct Snapshot: Sendable { + let data: WeatherData let diagnostics: [String: String] } - func candidatesWithDiagnostics(for location: CLLocation, now: Int) async -> CandidatesResult { - var results: [Candidate] = [] - var errorString: String? = nil + enum WeatherError: Error, LocalizedError, Sendable { + case weatherKitFailed(message: String, diagnostics: [String: String]) + + var errorDescription: String? { + switch self { + case .weatherKitFailed(let message, _): + return message + } + } + } + + func dataWithDiagnostics(for location: CLLocation, now: Int) async throws -> Snapshot { + var current: WeatherData.Current? = nil + var rainSoon: WeatherData.RainSoon? = nil + var windAlert: WeatherData.WindAlert? = nil var diagnostics: [String: String] = [ "lat": String(format: "%.5f", location.coordinate.latitude), "lon": String(format: "%.5f", location.coordinate.longitude), @@ -120,7 +108,7 @@ final class WeatherDataSource { do { let weather = try await service.weather(for: location) - results.append(currentConditionsCandidate(weather: weather, location: location, now: now)) + current = currentConditions(weather: weather) diagnostics["minute_forecast"] = (weather.minuteForecast != nil) ? "present" : "nil" diagnostics["hourly_count"] = String(weather.hourlyForecast.forecast.count) if let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value { @@ -129,122 +117,100 @@ final class WeatherDataSource { diagnostics["current_gust_mps"] = "nil" } - results.append(contentsOf: rainCandidates(weather: weather, location: location, now: now)) - results.append(contentsOf: windCandidates(weather: weather, location: location, now: now)) - diagnostics["candidates_weatherkit"] = String(results.count) - + rainSoon = rainSoonAlert(weather: weather, now: now) + windAlert = windAlertInfo(weather: weather) diagnostics.merge(rainDiagnostics(weather: weather, now: now)) { _, new in new } } catch { - errorString = String(describing: error) - diagnostics["weatherkit_error"] = errorString + let msg = String(describing: error) + diagnostics["weatherkit_error"] = msg + throw WeatherError.weatherKitFailed(message: msg, diagnostics: diagnostics) } - results.append(contentsOf: warningProvider.warningCandidates(location: location, now: now)) - diagnostics["candidates_total"] = String(results.count) - return CandidatesResult(candidates: results, weatherKitError: errorString, diagnostics: diagnostics) + let warnings = warnings(now: now) + diagnostics["warnings_count"] = String(warnings.count) + + let data = WeatherData( + current: current, + rainSoon: rainSoon, + windAlert: windAlert, + warnings: warnings + ) + let output = Snapshot(data: data, diagnostics: diagnostics) + return output } - private func currentConditionsCandidate(weather: Weather, location: CLLocation, now: Int) -> Candidate { + private func warnings(now: Int) -> [WeatherWarning] { + struct MockWarning: Codable { + let id: String + let title: String + let subtitle: String + let ttlSec: Int? + let confidence: Double? + + enum CodingKeys: String, CodingKey { + case id + case title + case subtitle + case ttlSec = "ttl_sec" + case confidence + } + } + + _ = now + guard let url = Bundle.main.url(forResource: "mock_weather_warnings", withExtension: "json") else { return [] } + guard let data = try? Data(contentsOf: url) else { return [] } + guard let items = try? JSONDecoder().decode([MockWarning].self, from: data) else { return [] } + return items.map { item in + WeatherWarning( + id: item.id, + title: item.title.truncated(maxLength: TextConstraints.titleMax), + subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax), + ttlSec: max(1, item.ttlSec ?? 3600), + confidence: min(max(item.confidence ?? 0.9, 0.0), 1.0) + ) + } + } + + private func currentConditions(weather: Weather) -> WeatherData.Current { let tempC = weather.currentWeather.temperature.converted(to: .celsius).value let feelsC = weather.currentWeather.apparentTemperature.converted(to: .celsius).value let tempInt = Int(tempC.rounded()) let feelsInt = Int(feelsC.rounded()) - let cond = weather.currentWeather.condition.description let conditionEnum = weather.currentWeather.condition - - let title = "Now \(tempInt)°C \(cond)".truncated(maxLength: TextConstraints.titleMax) - let subtitle = "Feels \(feelsInt)°C".truncated(maxLength: TextConstraints.subtitleMax) - - return Candidate( - id: "wx:now:\(now / 60)", - type: .currentWeather, - title: title, - subtitle: subtitle, - confidence: 0.8, - createdAt: now, - ttlSec: 1800, - condition: conditionEnum, - metadata: [ - "source": "weatherkit_current", - "lat": String(format: "%.5f", location.coordinate.latitude), - "lon": String(format: "%.5f", location.coordinate.longitude), - ] - ) + return WeatherData.Current(temperatureC: tempInt, feelsLikeC: feelsInt, condition: conditionEnum) } - private func rainCandidates(weather: Weather, location: CLLocation, now: Int) -> [Candidate] { + private func rainSoonAlert(weather: Weather, now: Int) -> WeatherData.RainSoon? { let nowDate = Date(timeIntervalSince1970: TimeInterval(now)) let lookahead = TimeInterval(config.rainLookaheadSec) if let minuteForecast = weather.minuteForecast { if let start = firstRainStart(minuteForecast.forecast, now: nowDate, within: lookahead) { - return [ - Candidate( - id: "wx:rain:\(Int(start.timeIntervalSince1970))", - type: .weatherAlert, - title: rainTitle(start: start, now: nowDate).truncated(maxLength: TextConstraints.titleMax), - subtitle: "Carry an umbrella".truncated(maxLength: TextConstraints.subtitleMax), - confidence: 0.9, - createdAt: now, - ttlSec: config.rainTTL, - metadata: [ - "source": "weatherkit_minutely", - "lat": String(format: "%.5f", location.coordinate.latitude), - "lon": String(format: "%.5f", location.coordinate.longitude), - ] - ) - ] + return WeatherData.RainSoon( + startAt: Int(start.timeIntervalSince1970), + ttlSec: config.rainTTL, + source: .minutely + ) } - return [] + return nil } if let start = firstRainStartHourly(weather.hourlyForecast.forecast, now: nowDate, within: lookahead) { - return [ - Candidate( - id: "wx:rain:\(Int(start.timeIntervalSince1970))", - type: .weatherAlert, - title: rainTitle(start: start, now: nowDate).truncated(maxLength: TextConstraints.titleMax), - subtitle: "Rain likely soon".truncated(maxLength: TextConstraints.subtitleMax), - confidence: 0.6, - createdAt: now, - ttlSec: config.rainTTL, - metadata: [ - "source": "weatherkit_hourly_approx", - "lat": String(format: "%.5f", location.coordinate.latitude), - "lon": String(format: "%.5f", location.coordinate.longitude), - ] - ) - ] + return WeatherData.RainSoon( + startAt: Int(start.timeIntervalSince1970), + ttlSec: config.rainTTL, + source: .hourlyApprox + ) } - return [] + return nil } - private func windCandidates(weather: Weather, location: CLLocation, now: Int) -> [Candidate] { - guard let gustThreshold = config.gustThresholdMps else { return [] } - guard let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value else { return [] } - guard gust >= gustThreshold else { return [] } - - let mph = Int((gust * 2.236936).rounded()) - let title = "Wind gusts ~\(mph) mph".truncated(maxLength: TextConstraints.titleMax) - return [ - Candidate( - id: "wx:wind:\(now):\(Int(gustThreshold * 10))", - type: .weatherAlert, - title: title, - subtitle: "Use caution outside".truncated(maxLength: TextConstraints.subtitleMax), - confidence: 0.8, - createdAt: now, - ttlSec: config.windTTL, - metadata: [ - "source": "weatherkit_current", - "gust_mps": String(format: "%.2f", gust), - "threshold_mps": String(format: "%.2f", gustThreshold), - "lat": String(format: "%.5f", location.coordinate.latitude), - "lon": String(format: "%.5f", location.coordinate.longitude), - ] - ) - ] + private func windAlertInfo(weather: Weather) -> WeatherData.WindAlert? { + guard let gustThreshold = config.gustThresholdMps else { return nil } + guard let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value else { return nil } + guard gust >= gustThreshold else { return nil } + return WeatherData.WindAlert(gustMps: gust, thresholdMps: gustThreshold, ttlSec: config.windTTL) } private func firstRainStart(_ minutes: [MinuteWeather], now: Date, within lookahead: TimeInterval) -> Date? { diff --git a/IrisCompanion/iris/Models/Candidate.swift b/IrisCompanion/iris/Models/Candidate.swift index 0ac0cc5..29925c8 100644 --- a/IrisCompanion/iris/Models/Candidate.swift +++ b/IrisCompanion/iris/Models/Candidate.swift @@ -2,60 +2,7 @@ // Candidate.swift // iris // -// Created by Codex. +// Deprecated: data sources now return typed data and the orchestrator produces `FeedItem`s. // import Foundation -import WeatherKit - -struct Candidate: Codable, Equatable { - let id: String - let type: WinnerType - let title: String - let subtitle: String - let confidence: Double - let createdAt: Int - let ttlSec: Int - let condition: WeatherKit.WeatherCondition? - let metadata: [String: String]? - - enum CodingKeys: String, CodingKey { - case id - case type - case title - case subtitle - case confidence - case createdAt - case ttlSec - case condition - case metadata - } - - init(id: String, - type: WinnerType, - title: String, - subtitle: String, - confidence: Double, - createdAt: Int, - ttlSec: Int, - condition: WeatherKit.WeatherCondition? = nil, - metadata: [String: String]? = nil) { - self.id = id - self.type = type - self.title = title - self.subtitle = subtitle - self.confidence = confidence - self.createdAt = createdAt - self.ttlSec = ttlSec - self.condition = condition - self.metadata = metadata - } - - func isExpired(at now: Int) -> Bool { - if ttlSec > 0 { - createdAt + ttlSec <= now - } else { - true - } - } -} diff --git a/IrisCompanion/iris/Models/FeedEnvelope.swift b/IrisCompanion/iris/Models/FeedEnvelope.swift index 8bdbbc2..2fd93e0 100644 --- a/IrisCompanion/iris/Models/FeedEnvelope.swift +++ b/IrisCompanion/iris/Models/FeedEnvelope.swift @@ -11,7 +11,7 @@ import WeatherKit struct FeedEnvelope: Codable, Equatable { let schema: Int let generatedAt: Int - let feed: [FeedCard] + let feed: [FeedItem] let meta: FeedMeta enum CodingKeys: String, CodingKey { @@ -22,19 +22,20 @@ struct FeedEnvelope: Codable, Equatable { } } -struct FeedCard: Codable, Equatable { +struct FeedItem: Codable, Equatable { enum Bucket: String, Codable { case rightNow = "RIGHT_NOW" case fyi = "FYI" } let id: String - let type: WinnerType + let type: FeedItemType let title: String let subtitle: String let priority: Double let ttlSec: Int let condition: WeatherKit.WeatherCondition? + let startsAt: Int? let bucket: Bucket let actions: [String] @@ -46,17 +47,19 @@ struct FeedCard: Codable, Equatable { case priority case ttlSec = "ttl_sec" case condition + case startsAt = "starts_at" case bucket case actions } init(id: String, - type: WinnerType, + type: FeedItemType, title: String, subtitle: String, priority: Double, ttlSec: Int, condition: WeatherKit.WeatherCondition? = nil, + startsAt: Int? = nil, bucket: Bucket, actions: [String]) { self.id = id @@ -66,6 +69,7 @@ struct FeedCard: Codable, Equatable { self.priority = priority self.ttlSec = ttlSec self.condition = condition + self.startsAt = startsAt self.bucket = bucket self.actions = actions } @@ -73,13 +77,14 @@ struct FeedCard: Codable, Equatable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) - type = try container.decode(WinnerType.self, forKey: .type) + type = try container.decode(FeedItemType.self, forKey: .type) title = try container.decode(String.self, forKey: .title) subtitle = try container.decode(String.self, forKey: .subtitle) priority = try container.decode(Double.self, forKey: .priority) ttlSec = try container.decode(Int.self, forKey: .ttlSec) bucket = try container.decode(Bucket.self, forKey: .bucket) actions = try container.decode([String].self, forKey: .actions) + startsAt = try container.decodeIfPresent(Int.self, forKey: .startsAt) if let encoded = try container.decodeIfPresent(String.self, forKey: .condition) { condition = WeatherKit.WeatherCondition.irisDecode(encoded) @@ -98,6 +103,7 @@ struct FeedCard: Codable, Equatable { try container.encode(ttlSec, forKey: .ttlSec) try container.encode(bucket, forKey: .bucket) try container.encode(actions, forKey: .actions) + try container.encodeIfPresent(startsAt, forKey: .startsAt) if let condition { try container.encode(condition.irisScreamingCase(), forKey: .condition) } @@ -114,50 +120,9 @@ struct FeedMeta: Codable, Equatable { } } -extension FeedEnvelope { - static func fromWinnerAndWeather(now: Int, winner: WinnerEnvelope, weather: Candidate?) -> FeedEnvelope { - var cards: [FeedCard] = [] - - let winnerCard = FeedCard( - id: winner.winner.id, - type: winner.winner.type, - title: winner.winner.title.truncated(maxLength: TextConstraints.titleMax), - subtitle: winner.winner.subtitle.truncated(maxLength: TextConstraints.subtitleMax), - priority: min(max(winner.winner.priority, 0.0), 1.0), - ttlSec: max(1, winner.winner.ttlSec), - condition: nil, - bucket: .rightNow, - actions: ["DISMISS"] - ) - cards.append(winnerCard) - - if let weather, weather.id != winner.winner.id { - let weatherCard = FeedCard( - id: weather.id, - type: weather.type, - title: weather.title.truncated(maxLength: TextConstraints.titleMax), - subtitle: weather.subtitle.truncated(maxLength: TextConstraints.subtitleMax), - priority: min(max(weather.confidence, 0.0), 1.0), - ttlSec: max(1, weather.ttlSec), - condition: weather.condition, - bucket: .fyi, - actions: ["DISMISS"] - ) - cards.append(weatherCard) - } - - return FeedEnvelope( - schema: 1, - generatedAt: now, - feed: cards, - meta: FeedMeta(winnerId: winner.winner.id, unreadCount: cards.count) - ) - } -} - extension FeedEnvelope { static func allQuiet(now: Int, reason: String = "no_candidates", source: String = "engine") -> FeedEnvelope { - let card = FeedCard( + let item = FeedItem( id: "quiet-000", type: .allQuiet, title: "All Quiet", @@ -165,29 +130,14 @@ extension FeedEnvelope { priority: 0.05, ttlSec: 300, condition: nil, + startsAt: nil, bucket: .rightNow, actions: ["DISMISS"] ) - return FeedEnvelope(schema: 1, generatedAt: now, feed: [card], meta: FeedMeta(winnerId: card.id, unreadCount: 1)) + return FeedEnvelope(schema: 1, generatedAt: now, feed: [item], meta: FeedMeta(winnerId: item.id, unreadCount: 1)) } - func winnerCard() -> FeedCard? { + func winnerItem() -> FeedItem? { feed.first(where: { $0.id == meta.winnerId }) ?? feed.first } - - func asWinnerEnvelope() -> WinnerEnvelope { - let now = generatedAt - guard let winnerCard = winnerCard() else { - return WinnerEnvelope.allQuiet(now: now) - } - let winner = Winner( - id: winnerCard.id, - type: winnerCard.type, - title: winnerCard.title.truncated(maxLength: TextConstraints.titleMax), - subtitle: winnerCard.subtitle.truncated(maxLength: TextConstraints.subtitleMax), - priority: min(max(winnerCard.priority, 0.0), 1.0), - ttlSec: max(1, winnerCard.ttlSec) - ) - return WinnerEnvelope(schema: 1, generatedAt: now, winner: winner, debug: nil) - } } diff --git a/IrisCompanion/iris/Models/FeedStore.swift b/IrisCompanion/iris/Models/FeedStore.swift index 6f66480..9bc5cb1 100644 --- a/IrisCompanion/iris/Models/FeedStore.swift +++ b/IrisCompanion/iris/Models/FeedStore.swift @@ -10,7 +10,7 @@ import Foundation final class FeedStore { struct CardKey: Hashable, Codable { let id: String - let type: WinnerType + let type: FeedItemType } struct CardState: Codable, Equatable { @@ -58,17 +58,17 @@ final class FeedStore { } } - func lastShownAt(candidateId: String) -> Int? { + func lastShownAt(feedItemId: String) -> Int? { queue.sync { let matches = states.compactMap { (key, value) -> Int? in - guard key.hasSuffix("|" + candidateId) else { return nil } + guard key.hasSuffix("|" + feedItemId) else { return nil } return value.lastShownAt } return matches.max() } } - func isSuppressed(id: String, type: WinnerType, now: Int) -> Bool { + func isSuppressed(id: String, type: FeedItemType, now: Int) -> Bool { queue.sync { let key = Self.keyString(id: id, type: type) guard let state = states[key] else { return false } @@ -78,7 +78,7 @@ final class FeedStore { } } - func dismiss(id: String, type: WinnerType, until: Int? = nil) { + func dismiss(id: String, type: FeedItemType, until: Int? = nil) { queue.sync { let key = Self.keyString(id: id, type: type) var state = states[key] ?? CardState() @@ -88,7 +88,7 @@ final class FeedStore { } } - func snooze(id: String, type: WinnerType, until: Int) { + func snooze(id: String, type: FeedItemType, until: Int) { queue.sync { let key = Self.keyString(id: id, type: type) var state = states[key] ?? CardState() @@ -98,7 +98,7 @@ final class FeedStore { } } - func clearSuppression(id: String, type: WinnerType) { + func clearSuppression(id: String, type: FeedItemType) { queue.sync { let key = Self.keyString(id: id, type: type) var state = states[key] ?? CardState() @@ -119,11 +119,11 @@ final class FeedStore { } private func normalizedFeed(_ feed: FeedEnvelope, now: Int, applyingSuppression: Bool = true) -> FeedEnvelope { - let normalizedCards = feed.feed.compactMap { card -> FeedCard? in + let normalizedCards = feed.feed.compactMap { card -> FeedItem? in let ttl = max(1, card.ttlSec) if feed.generatedAt + ttl <= now { return nil } if applyingSuppression, isSuppressedUnlocked(id: card.id, type: card.type, now: now) { return nil } - return FeedCard( + return FeedItem( id: card.id, type: card.type, title: card.title.truncated(maxLength: TextConstraints.titleMax), @@ -131,6 +131,7 @@ final class FeedStore { priority: min(max(card.priority, 0.0), 1.0), ttlSec: ttl, condition: card.condition, + startsAt: card.startsAt, bucket: card.bucket, actions: card.actions ) @@ -146,7 +147,7 @@ final class FeedStore { return normalized } - private func isSuppressedUnlocked(id: String, type: WinnerType, now: Int) -> Bool { + private func isSuppressedUnlocked(id: String, type: FeedItemType, now: Int) -> Bool { let key = Self.keyString(id: id, type: type) guard let state = states[key] else { return false } if let until = state.dismissedUntil, until > now { return true } @@ -159,7 +160,7 @@ final class FeedStore { Self.save(persisted, to: fileURL) } - private static func keyString(id: String, type: WinnerType) -> String { + private static func keyString(id: String, type: FeedItemType) -> String { "\(type.rawValue)|\(id)" } diff --git a/IrisCompanion/iris/Models/HeuristicRanker.swift b/IrisCompanion/iris/Models/HeuristicRanker.swift index 44162e4..4a01d1b 100644 --- a/IrisCompanion/iris/Models/HeuristicRanker.swift +++ b/IrisCompanion/iris/Models/HeuristicRanker.swift @@ -27,49 +27,65 @@ final class HeuristicRanker { self.lastShownAtProvider = lastShownAt } - func pickWinner(from candidates: [Candidate], now: Int? = nil, context: UserContext) -> Winner { + struct Ranked: Equatable { + let item: FeedItem + let confidence: Double + let isEligibleForRightNow: Bool + } + + struct WinnerSelection: Equatable { + let item: FeedItem + let priority: Double + } + + func pickWinner(from items: [Ranked], now: Int? = nil, context: UserContext) -> WinnerSelection? { let currentTime = now ?? nowProvider() - let valid = candidates - .filter { !$0.isExpired(at: currentTime) } + let valid = items + .filter { $0.item.ttlSec > 0 } .filter { $0.confidence >= 0.0 } + .filter { $0.isEligibleForRightNow } - guard !valid.isEmpty else { - return WinnerEnvelope.allQuiet(now: currentTime).winner - } + guard !valid.isEmpty else { return nil } - var best: (candidate: Candidate, score: Double)? - for candidate in valid { - let baseWeight = baseWeight(for: candidate.type) - var score = baseWeight * min(max(candidate.confidence, 0.0), 1.0) - if let shownAt = lastShownAtProvider(candidate.id), + var best: (item: FeedItem, score: Double, confidence: Double)? + for proposed in valid { + let baseWeight = baseWeight(for: proposed.item.type) + var score = baseWeight * min(max(proposed.confidence, 0.0), 1.0) + if let shownAt = lastShownAtProvider(proposed.item.id), currentTime - shownAt <= 2 * 60 * 60 { score -= 0.4 } if best == nil || score > best!.score { - best = (candidate, score) + best = (proposed.item, score, proposed.confidence) } } guard let best else { - return WinnerEnvelope.allQuiet(now: currentTime).winner + return nil } let priority = min(max(best.score, 0.0), 1.0) - return Winner( - id: best.candidate.id, - type: best.candidate.type, - title: best.candidate.title.truncated(maxLength: TextConstraints.titleMax), - subtitle: best.candidate.subtitle.truncated(maxLength: TextConstraints.subtitleMax), + let item = FeedItem( + id: best.item.id, + type: best.item.type, + title: best.item.title.truncated(maxLength: TextConstraints.titleMax), + subtitle: best.item.subtitle.truncated(maxLength: TextConstraints.subtitleMax), priority: priority, - ttlSec: max(1, best.candidate.ttlSec) + ttlSec: max(1, best.item.ttlSec), + condition: best.item.condition, + startsAt: best.item.startsAt, + bucket: .rightNow, + actions: ["DISMISS"] ) + return WinnerSelection(item: item, priority: priority) } - private func baseWeight(for type: WinnerType) -> Double { + private func baseWeight(for type: FeedItemType) -> Double { switch type { case .weatherWarning: return 1.0 case .weatherAlert: return 0.9 + case .calendarEvent: return 0.8 case .transit: return 0.75 case .poiNearby: return 0.6 case .info: return 0.4 diff --git a/IrisCompanion/iris/Models/Winner.swift b/IrisCompanion/iris/Models/Winner.swift index fa55056..840782b 100644 --- a/IrisCompanion/iris/Models/Winner.swift +++ b/IrisCompanion/iris/Models/Winner.swift @@ -7,7 +7,7 @@ import Foundation -enum WinnerType: String, Codable, CaseIterable { +enum FeedItemType: String, Codable, CaseIterable { case weatherAlert = "WEATHER_ALERT" case weatherWarning = "WEATHER_WARNING" case transit = "TRANSIT" @@ -15,23 +15,6 @@ enum WinnerType: String, Codable, CaseIterable { case info = "INFO" case nowPlaying = "NOW_PLAYING" case currentWeather = "CURRENT_WEATHER" + case calendarEvent = "CALENDAR_EVENT" case allQuiet = "ALL_QUIET" } - -struct Winner: Codable, Equatable { - let id: String - let type: WinnerType - let title: String - let subtitle: String - let priority: Double - let ttlSec: Int - - enum CodingKeys: String, CodingKey { - case id - case type - case title - case subtitle - case priority - case ttlSec = "ttl_sec" - } -} diff --git a/IrisCompanion/iris/Models/WinnerEnvelope.swift b/IrisCompanion/iris/Models/WinnerEnvelope.swift index b6f5e58..2863d34 100644 --- a/IrisCompanion/iris/Models/WinnerEnvelope.swift +++ b/IrisCompanion/iris/Models/WinnerEnvelope.swift @@ -7,82 +7,6 @@ import Foundation -struct WinnerEnvelope: Codable, Equatable { - struct DebugInfo: Codable, Equatable { - let reason: String - let source: String - } - - let schema: Int - let generatedAt: Int - let winner: Winner - let debug: DebugInfo? - - enum CodingKeys: String, CodingKey { - case schema - case generatedAt = "generated_at" - case winner - case debug - } - - static func allQuiet(now: Int, reason: String = "no_candidates", source: String = "engine") -> WinnerEnvelope { - let winner = Winner( - id: "quiet-000", - type: .allQuiet, - title: "All Quiet", - subtitle: "No urgent updates", - priority: 0.05, - ttlSec: 300 - ) - return WinnerEnvelope(schema: 1, generatedAt: now, winner: winner, debug: .init(reason: reason, source: source)) - } -} - -enum EnvelopeValidationError: Error, LocalizedError { - case invalidSchema(Int) - case invalidPriority(Double) - case invalidTTL(Int) - - var errorDescription: String? { - switch self { - case .invalidSchema(let schema): - return "Invalid schema \(schema). Expected 1." - case .invalidPriority(let priority): - return "Invalid priority \(priority). Must be between 0 and 1." - case .invalidTTL(let ttl): - return "Invalid ttl \(ttl). Must be greater than 0." - } - } -} - -func validateEnvelope(_ envelope: WinnerEnvelope) throws -> WinnerEnvelope { - guard envelope.schema == 1 else { - throw EnvelopeValidationError.invalidSchema(envelope.schema) - } - guard envelope.winner.priority >= 0.0, envelope.winner.priority <= 1.0 else { - throw EnvelopeValidationError.invalidPriority(envelope.winner.priority) - } - guard envelope.winner.ttlSec > 0 else { - throw EnvelopeValidationError.invalidTTL(envelope.winner.ttlSec) - } - - let validatedWinner = Winner( - id: envelope.winner.id, - type: envelope.winner.type, - title: envelope.winner.title.truncated(maxLength: TextConstraints.titleMax), - subtitle: envelope.winner.subtitle.truncated(maxLength: TextConstraints.subtitleMax), - priority: envelope.winner.priority, - ttlSec: envelope.winner.ttlSec - ) - - return WinnerEnvelope( - schema: envelope.schema, - generatedAt: envelope.generatedAt, - winner: validatedWinner, - debug: envelope.debug - ) -} - enum TextConstraints { static let titleMax = 26 static let subtitleMax = 30 @@ -97,4 +21,3 @@ extension String { return String(prefix(maxLength - ellipsisCount)) + TextConstraints.ellipsis } } - diff --git a/IrisCompanion/iris/Network/LocalServer.swift b/IrisCompanion/iris/Network/LocalServer.swift index ea4fcc4..b5adaa9 100644 --- a/IrisCompanion/iris/Network/LocalServer.swift +++ b/IrisCompanion/iris/Network/LocalServer.swift @@ -24,7 +24,7 @@ final class LocalServer: ObservableObject { private var listener: NWListener? private var browser: NWBrowser? private var startDate = Date() - private var currentEnvelope: WinnerEnvelope + private var currentEnvelope: FeedEnvelope private var heartbeatTimer: DispatchSourceTimer? private var addressTimer: DispatchSourceTimer? private var requestBuffers: [ObjectIdentifier: Data] = [:] @@ -32,20 +32,7 @@ final class LocalServer: ObservableObject { init(port: Int = 8765) { self.port = port - let winner = Winner( - id: "quiet-000", - type: .allQuiet, - title: "All Quiet", - subtitle: "No urgent updates", - priority: 0.05, - ttlSec: 300 - ) - self.currentEnvelope = WinnerEnvelope( - schema: 1, - generatedAt: Int(Date().timeIntervalSince1970), - winner: winner, - debug: nil - ) + self.currentEnvelope = FeedEnvelope.allQuiet(now: Int(Date().timeIntervalSince1970), reason: "no_feed", source: "server") } var testURL: String { @@ -97,15 +84,15 @@ final class LocalServer: ObservableObject { } } - func broadcastWinner(_ envelope: WinnerEnvelope) { - let validated = (try? validateEnvelope(envelope)) ?? envelope - currentEnvelope = validated + func broadcastFeed(_ envelope: FeedEnvelope) { + currentEnvelope = envelope + let winner = envelope.winnerItem() DispatchQueue.main.async { - self.lastWinnerTitle = validated.winner.title - self.lastWinnerSubtitle = validated.winner.subtitle + self.lastWinnerTitle = winner?.title ?? "All Quiet" + self.lastWinnerSubtitle = winner?.subtitle ?? "No urgent updates" self.lastBroadcastAt = Date() } - let data = sseEvent(name: "winner", payload: jsonLine(from: validated)) + let data = sseEvent(name: "feed", payload: jsonLine(from: envelope)) broadcast(data: data) } @@ -312,7 +299,7 @@ final class LocalServer: ObservableObject { } } - private func jsonLine(from envelope: WinnerEnvelope) -> String { + private func jsonLine(from envelope: FeedEnvelope) -> String { let encoder = JSONEncoder() if let data = try? encoder.encode(envelope), let string = String(data: data, encoding: .utf8) { diff --git a/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift b/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift index 9e15414..de40049 100644 --- a/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift +++ b/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift @@ -18,10 +18,10 @@ final class ContextOrchestrator: NSObject, ObservableObject { @Published private(set) var lastLocation: CLLocation? = nil @Published private(set) var lastRecomputeAt: Date? = nil @Published private(set) var lastRecomputeReason: String? = nil - @Published private(set) var lastWinner: WinnerEnvelope? = nil + @Published private(set) var lastFeed: FeedEnvelope? = nil @Published private(set) var lastError: String? = nil - @Published private(set) var lastCandidates: [Candidate] = [] @Published private(set) var lastWeatherDiagnostics: [String: String] = [:] + @Published private(set) var lastCalendarDiagnostics: [String: String] = [:] @Published private(set) var lastPipelineElapsedMs: Int? = nil @Published private(set) var lastFetchFailed: Bool = false @Published private(set) var musicAuthorization: MusicAuthorization.Status = .notDetermined @@ -50,7 +50,7 @@ final class ContextOrchestrator: NSObject, ObservableObject { self.store = store self.server = server self.ble = ble - self.ranker = HeuristicRanker(lastShownAt: { id in store.lastShownAt(candidateId: id) }) + self.ranker = HeuristicRanker(lastShownAt: { id in store.lastShownAt(feedItemId: id) }) super.init() locationManager.delegate = self @@ -61,8 +61,8 @@ final class ContextOrchestrator: NSObject, ObservableObject { ble.onFirstSubscribe = { [weak self] in Task { @MainActor in - self?.logger.info("BLE subscribed: pushing latest winner") - self?.pushLatestWinnerToBle() + self?.logger.info("BLE subscribed: pushing latest feed") + self?.pushLatestFeedToBle() } } ble.onControlCommand = { [weak self] command in @@ -76,12 +76,12 @@ final class ContextOrchestrator: NSObject, ObservableObject { guard let self else { return } self.musicAuthorization = update.authorization self.nowPlaying = update.snapshot - self.pushLatestWinnerToBle() + self.pushLatestFeedToBle() } } let feed = store.getFeed() - lastWinner = feed.asWinnerEnvelope() + lastFeed = feed } func start() { @@ -191,98 +191,246 @@ final class ContextOrchestrator: NSObject, ObservableObject { let start = Date() async let weatherResult = withTimeoutResult(seconds: 6) { - await self.weatherDataSource.candidatesWithDiagnostics(for: location, now: nowEpoch) + try await self.weatherDataSource.dataWithDiagnostics(for: location, now: nowEpoch) } async let calendarResult = withTimeoutResult(seconds: 6) { - await self.calendarDataSource.candidatesWithDiagnostics(now: nowEpoch) + try await self.calendarDataSource.dataWithDiagnostics(now: nowEpoch) } async let poiResult = withTimeoutResult(seconds: 6) { - try await self.poiDataSource.candidates(for: location, now: nowEpoch) + try await self.poiDataSource.data(for: location, now: nowEpoch) } let wxRes = await weatherResult let calRes = await calendarResult let poiRes = await poiResult - var candidates: [Candidate] = [] + func calendarTTL(endAt: Int, now: Int) -> Int { + let ttl = endAt - now + return min(max(ttl, 60), 2 * 60 * 60) + } + + func rainTitle(startAt: Int, now: Int) -> String { + let minutes = max(0, Int(((TimeInterval(startAt - now)) / 60.0).rounded())) + if minutes <= 0 { return "Rain now" } + return "Rain in ~\(minutes) min" + } + + var rightNowCandidates: [HeuristicRanker.Ranked] = [] + var calendarItems: [FeedItem] = [] + var weatherNowItem: FeedItem? = nil var fetchFailed = false var wxDiagnostics: [String: String] = [:] - var weatherNowCandidate: Candidate? = nil + var calDiagnostics: [String: String] = [:] switch wxRes { - case .success(let wx): - candidates.append(contentsOf: wx.candidates) - wxDiagnostics = wx.diagnostics - weatherNowCandidate = wx.candidates.first(where: { $0.type == .currentWeather }) ?? wx.candidates.first(where: { $0.id.hasPrefix("wx:now:") }) - if let wxErr = wx.weatherKitError { + case .success(let snapshot): + wxDiagnostics = snapshot.diagnostics + let weather = snapshot.data + + if let current = weather.current { + weatherNowItem = FeedItem( + id: "wx:now:\(nowEpoch / 60)", + type: .currentWeather, + title: "Now \(current.temperatureC)°C \(current.condition.description)".truncated(maxLength: TextConstraints.titleMax), + subtitle: "Feels \(current.feelsLikeC)°C".truncated(maxLength: TextConstraints.subtitleMax), + priority: 0.8, + ttlSec: 1800, + condition: current.condition, + startsAt: nil, + bucket: .fyi, + actions: ["DISMISS"] + ) + } + + if let rainSoon = weather.rainSoon { + let title = rainTitle(startAt: rainSoon.startAt, now: nowEpoch).truncated(maxLength: TextConstraints.titleMax) + let subtitle = (rainSoon.source == .minutely ? "Carry an umbrella" : "Rain likely soon") + .truncated(maxLength: TextConstraints.subtitleMax) + let confidence: Double = (rainSoon.source == .minutely) ? 0.9 : 0.6 + + let item = FeedItem( + id: "wx:rain:\(rainSoon.startAt)", + type: .weatherAlert, + title: title, + subtitle: subtitle, + priority: confidence, + ttlSec: max(1, rainSoon.ttlSec), + condition: nil, + startsAt: nil, + bucket: .rightNow, + actions: ["DISMISS"] + ) + rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true)) + } + + if let wind = weather.windAlert { + let mph = Int((wind.gustMps * 2.236936).rounded()) + let title = "Wind gusts ~\(mph) mph".truncated(maxLength: TextConstraints.titleMax) + let subtitle = "Use caution outside".truncated(maxLength: TextConstraints.subtitleMax) + let confidence: Double = 0.8 + + let item = FeedItem( + id: "wx:wind:\(nowEpoch):\(Int(wind.thresholdMps * 10))", + type: .weatherAlert, + title: title, + subtitle: subtitle, + priority: confidence, + ttlSec: max(1, wind.ttlSec), + condition: nil, + startsAt: nil, + bucket: .rightNow, + actions: ["DISMISS"] + ) + rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true)) + } + + for warning in weather.warnings { + let confidence = min(max(warning.confidence, 0.0), 1.0) + let item = FeedItem( + id: warning.id, + type: .weatherWarning, + title: warning.title.truncated(maxLength: TextConstraints.titleMax), + subtitle: warning.subtitle.truncated(maxLength: TextConstraints.subtitleMax), + priority: confidence, + ttlSec: max(1, warning.ttlSec), + condition: nil, + startsAt: nil, + bucket: .rightNow, + actions: ["DISMISS"] + ) + rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true)) + } + + if let wxErr = wxDiagnostics["weatherkit_error"] { fetchFailed = true logger.warning("weather fetch error: \(wxErr, privacy: .public)") } case .failure(let error): fetchFailed = true - logger.error("weather fetch failed: \(String(describing: error), privacy: .public)") + if let weatherError = error as? WeatherDataSource.WeatherError, + case .weatherKitFailed(_, let diagnostics) = weatherError { + wxDiagnostics = diagnostics + logger.warning("weather fetch error: \(weatherError.localizedDescription, privacy: .public)") + } else { + logger.error("weather fetch failed: \(String(describing: error), privacy: .public)") + } } switch poiRes { - case .success(let pois): - candidates.append(contentsOf: pois) + case .success: + break case .failure(let error): fetchFailed = true logger.error("poi fetch failed: \(String(describing: error), privacy: .public)") } switch calRes { - case .success(let cal): - candidates.append(contentsOf: cal.candidates) - if let err = cal.error { - fetchFailed = true - logger.warning("calendar error: \(err, privacy: .public)") + case .success(let snapshot): + calDiagnostics = snapshot.diagnostics + for event in snapshot.data.events { + let isOngoing = event.startAt <= nowEpoch && event.endAt > nowEpoch + let startsInSec = event.startAt - nowEpoch + let eligibleForRightNow = isOngoing || startsInSec <= 30 * 60 + let confidence: Double = isOngoing ? 0.9 : 0.7 + + let item = FeedItem( + id: event.id, + type: .calendarEvent, + title: event.title.truncated(maxLength: TextConstraints.titleMax), + subtitle: "", + priority: confidence, + ttlSec: calendarTTL(endAt: event.endAt, now: nowEpoch), + condition: nil, + startsAt: event.startAt, + bucket: .fyi, + actions: ["DISMISS"] + ) + calendarItems.append(item) + rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: eligibleForRightNow)) } case .failure(let error): fetchFailed = true - logger.error("calendar fetch failed: \(String(describing: error), privacy: .public)") + if let calendarError = error as? CalendarDataSource.CalendarError { + calDiagnostics = calendarError.diagnostics + logger.warning("calendar error: \(calendarError.localizedDescription, privacy: .public)") + } else { + logger.error("calendar fetch failed: \(String(describing: error), privacy: .public)") + } } let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) lastPipelineElapsedMs = elapsedMs lastFetchFailed = fetchFailed - lastCandidates = candidates lastWeatherDiagnostics = wxDiagnostics + lastCalendarDiagnostics = calDiagnostics - logger.info("pipeline candidates total=\(candidates.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)") + logger.info("pipeline right_now_candidates=\(rightNowCandidates.count) calendar_items=\(calendarItems.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)") - if fetchFailed, candidates.isEmpty { + if fetchFailed, rightNowCandidates.isEmpty, calendarItems.isEmpty, weatherNowItem == nil { let fallbackFeed = store.getFeed(now: nowEpoch) - let fallbackWinner = fallbackFeed.asWinnerEnvelope() - lastWinner = fallbackWinner - lastError = "Fetch failed; using previous winner." - server.broadcastWinner(fallbackWinner) + lastFeed = fallbackFeed + lastError = "Fetch failed; using previous feed." + server.broadcastFeed(fallbackFeed) ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: fallbackFeed, now: nowEpoch))) ?? Data(), msgType: 1) return } - let unsuppressed = candidates - .filter { $0.type != .currentWeather } - .filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) } - let winner = ranker.pickWinner(from: unsuppressed, now: nowEpoch, context: userContext) - let envelope = WinnerEnvelope(schema: 1, generatedAt: nowEpoch, winner: winner, debug: nil) - let validated = (try? validateEnvelope(envelope)) ?? envelope + let eligibleUnsuppressed = rightNowCandidates.filter { ranked in + !store.isSuppressed(id: ranked.item.id, type: ranked.item.type, now: nowEpoch) + } - let feedEnvelope = FeedEnvelope.fromWinnerAndWeather(now: nowEpoch, winner: validated, weather: weatherNowCandidate) + let winnerSelection = ranker.pickWinner(from: eligibleUnsuppressed, now: nowEpoch, context: userContext) + let winnerItem = winnerSelection?.item ?? FeedEnvelope.allQuiet(now: nowEpoch).feed[0] + + let fyiCalendar = calendarItems + .filter { $0.id != winnerItem.id } + .filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) } + .sorted(by: { ($0.startsAt ?? Int.max) < ($1.startsAt ?? Int.max) }) + .prefix(3) + + var fyi: [FeedItem] = [] + fyi.append(contentsOf: fyiCalendar.map { item in + FeedItem( + id: item.id, + type: item.type, + title: item.title.truncated(maxLength: TextConstraints.titleMax), + subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax), + priority: min(max(item.priority, 0.0), 1.0), + ttlSec: max(1, item.ttlSec), + condition: item.condition, + startsAt: item.startsAt, + bucket: .fyi, + actions: ["DISMISS"] + ) + }) + + if let weatherNowItem, + weatherNowItem.id != winnerItem.id, + !store.isSuppressed(id: weatherNowItem.id, type: weatherNowItem.type, now: nowEpoch) { + fyi.append(weatherNowItem) + } + + let items = [winnerItem] + fyi + let feedEnvelope = FeedEnvelope( + schema: 1, + generatedAt: nowEpoch, + feed: items, + meta: FeedMeta(winnerId: winnerItem.id, unreadCount: items.count) + ) store.setFeed(feedEnvelope, now: nowEpoch) - lastWinner = validated + lastFeed = feedEnvelope lastRecomputeAt = Date() lastRecomputeLocation = location lastRecomputeAccuracy = location.horizontalAccuracy lastError = fetchFailed ? "Partial fetch failure." : nil - logger.info("winner id=\(validated.winner.id, privacy: .public) type=\(validated.winner.type.rawValue, privacy: .public) prio=\(validated.winner.priority, format: .fixed(precision: 2)) ttl=\(validated.winner.ttlSec)") + logger.info("winner id=\(winnerItem.id, privacy: .public) type=\(winnerItem.type.rawValue, privacy: .public) prio=\(winnerItem.priority, format: .fixed(precision: 2)) ttl=\(winnerItem.ttlSec)") - server.broadcastWinner(validated) + server.broadcastFeed(feedEnvelope) ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: feedEnvelope, now: nowEpoch))) ?? Data(), msgType: 1) } - private func pushLatestWinnerToBle() { + private func pushLatestFeedToBle() { let nowEpoch = Int(Date().timeIntervalSince1970) let feed = store.getFeed(now: nowEpoch) ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: feed, now: nowEpoch))) ?? Data(), msgType: 1) @@ -292,7 +440,7 @@ final class ContextOrchestrator: NSObject, ObservableObject { guard !command.isEmpty else { return } if command == "REQ_FULL" { logger.info("BLE control REQ_FULL") - pushLatestWinnerToBle() + pushLatestFeedToBle() return } if command.hasPrefix("ACK:") { @@ -303,7 +451,7 @@ final class ContextOrchestrator: NSObject, ObservableObject { } private func feedForGlass(base: FeedEnvelope, now: Int) -> FeedEnvelope { - guard let nowPlayingCard = nowPlaying?.asFeedCard(baseGeneratedAt: base.generatedAt, now: now) else { + guard let nowPlayingCard = nowPlaying?.asFeedItem(baseGeneratedAt: base.generatedAt, now: now) else { return base } @@ -376,7 +524,7 @@ struct NowPlayingSnapshot: Equatable, Sendable { let album: String? let playbackStatus: MusicKit.MusicPlayer.PlaybackStatus - func asFeedCard(baseGeneratedAt: Int, now: Int) -> FeedCard { + func asFeedItem(baseGeneratedAt: Int, now: Int) -> FeedItem { let desiredLifetimeSec = 30 let ttl = max(1, (now - baseGeneratedAt) + desiredLifetimeSec) @@ -385,7 +533,7 @@ struct NowPlayingSnapshot: Equatable, Sendable { .filter { !$0.isEmpty } let subtitle = subtitleParts.isEmpty ? "Apple Music" : subtitleParts.joined(separator: " • ") - return FeedCard( + return FeedItem( id: "music:now:\(itemId)", type: .nowPlaying, title: title.truncated(maxLength: TextConstraints.titleMax), @@ -393,6 +541,7 @@ struct NowPlayingSnapshot: Equatable, Sendable { priority: playbackStatus == .playing ? 0.35 : 0.2, ttlSec: ttl, condition: nil, + startsAt: nil, bucket: .fyi, actions: ["DISMISS"] ) diff --git a/IrisCompanion/iris/ProtocolFixtures/full_feed_fixture.json b/IrisCompanion/iris/ProtocolFixtures/full_feed_fixture.json index 33de771..29dc6f6 100644 --- a/IrisCompanion/iris/ProtocolFixtures/full_feed_fixture.json +++ b/IrisCompanion/iris/ProtocolFixtures/full_feed_fixture.json @@ -1 +1 @@ -{"schema":1,"generated_at":1767716400,"feed":[{"id":"demo:welcome","type":"INFO","title":"Glass Now online","subtitle":"Connected to iPhone","priority":0.8,"ttl_sec":86400,"bucket":"RIGHT_NOW","actions":["DISMISS"]},{"id":"demo:next","type":"INFO","title":"Next: Calendar","subtitle":"Then Weather + POI","priority":0.4,"ttl_sec":86400,"bucket":"FYI","actions":["DISMISS"]},{"id":"music:now:demo","type":"NOW_PLAYING","title":"Midnight City","subtitle":"M83 • Hurry Up, We're Dreaming","priority":0.35,"ttl_sec":30,"bucket":"FYI","actions":["DISMISS"]}],"meta":{"winner_id":"demo:welcome","unread_count":3}} +{"schema":1,"generated_at":1767716400,"feed":[{"id":"demo:welcome","type":"INFO","title":"Glass Now online","subtitle":"Connected to iPhone","priority":0.8,"ttl_sec":86400,"bucket":"RIGHT_NOW","actions":["DISMISS"]},{"id":"cal:demo:1767717000","type":"CALENDAR_EVENT","title":"Team Sync","subtitle":"","priority":0.7,"ttl_sec":5400,"starts_at":1767717000,"bucket":"FYI","actions":["DISMISS"]},{"id":"demo:next","type":"INFO","title":"Next: Calendar","subtitle":"Then Weather + POI","priority":0.4,"ttl_sec":86400,"bucket":"FYI","actions":["DISMISS"]},{"id":"music:now:demo","type":"NOW_PLAYING","title":"Midnight City","subtitle":"M83 • Hurry Up, We're Dreaming","priority":0.35,"ttl_sec":30,"bucket":"FYI","actions":["DISMISS"]}],"meta":{"winner_id":"demo:welcome","unread_count":4}} diff --git a/IrisCompanion/iris/ViewModels/CandidatesViewModel.swift b/IrisCompanion/iris/ViewModels/CandidatesViewModel.swift index 5336fac..3513bbb 100644 --- a/IrisCompanion/iris/ViewModels/CandidatesViewModel.swift +++ b/IrisCompanion/iris/ViewModels/CandidatesViewModel.swift @@ -11,7 +11,7 @@ import os @MainActor final class CandidatesViewModel: ObservableObject { - @Published private(set) var candidates: [Candidate] = [] + @Published private(set) var candidates: [FeedItem] = [] @Published private(set) var lastUpdatedAt: Date? = nil @Published private(set) var isLoading = false @Published private(set) var lastError: String? = nil @@ -41,24 +41,121 @@ final class CandidatesViewModel: ObservableObject { if #available(iOS 16.0, *) { let ds = WeatherDataSource() - let result = await ds.candidatesWithDiagnostics(for: location, now: now) + let output: WeatherDataSource.Snapshot + do { + output = try await ds.dataWithDiagnostics(for: location, now: now) + } catch let error as WeatherDataSource.WeatherError { + switch error { + case .weatherKitFailed(_, let diagnostics): + await MainActor.run { + self.candidates = [] + self.diagnostics = diagnostics + self.lastError = "WeatherKit error: \(error.localizedDescription)" + } + return + } + } catch { + await MainActor.run { + self.candidates = [] + self.diagnostics = ["weatherkit_error": String(describing: error)] + self.lastError = "WeatherKit error: \(error)" + } + return + } + let data = output.data + + var items: [FeedItem] = [] + if let current = data.current { + items.append( + FeedItem( + id: "wx:now:\(now / 60)", + type: .currentWeather, + title: "Now \(current.temperatureC)°C \(current.condition.description)".truncated(maxLength: TextConstraints.titleMax), + subtitle: "Feels \(current.feelsLikeC)°C".truncated(maxLength: TextConstraints.subtitleMax), + priority: 0.8, + ttlSec: 1800, + condition: current.condition, + startsAt: nil, + bucket: .fyi, + actions: ["DISMISS"] + ) + ) + } + + if let rainSoon = data.rainSoon { + let minutes = max(0, Int(((TimeInterval(rainSoon.startAt - now)) / 60.0).rounded())) + let title = (minutes <= 0) ? "Rain now" : "Rain in ~\(minutes) min" + let subtitle = (rainSoon.source == .minutely) ? "Carry an umbrella" : "Rain likely soon" + let confidence: Double = (rainSoon.source == .minutely) ? 0.9 : 0.6 + items.append( + FeedItem( + id: "wx:rain:\(rainSoon.startAt)", + type: .weatherAlert, + title: title.truncated(maxLength: TextConstraints.titleMax), + subtitle: subtitle.truncated(maxLength: TextConstraints.subtitleMax), + priority: confidence, + ttlSec: max(1, rainSoon.ttlSec), + condition: nil, + startsAt: nil, + bucket: .rightNow, + actions: ["DISMISS"] + ) + ) + } + + if let wind = data.windAlert { + let mph = Int((wind.gustMps * 2.236936).rounded()) + items.append( + FeedItem( + id: "wx:wind:\(now):\(Int(wind.thresholdMps * 10))", + type: .weatherAlert, + title: "Wind gusts ~\(mph) mph".truncated(maxLength: TextConstraints.titleMax), + subtitle: "Use caution outside".truncated(maxLength: TextConstraints.subtitleMax), + priority: 0.8, + ttlSec: max(1, wind.ttlSec), + condition: nil, + startsAt: nil, + bucket: .rightNow, + actions: ["DISMISS"] + ) + ) + } + + for warning in data.warnings { + items.append( + FeedItem( + id: warning.id, + type: .weatherWarning, + title: warning.title.truncated(maxLength: TextConstraints.titleMax), + subtitle: warning.subtitle.truncated(maxLength: TextConstraints.subtitleMax), + priority: min(max(warning.confidence, 0.0), 1.0), + ttlSec: max(1, warning.ttlSec), + condition: nil, + startsAt: nil, + bucket: .rightNow, + actions: ["DISMISS"] + ) + ) + } + + items.sort { $0.priority > $1.priority } await MainActor.run { - self.candidates = result.candidates.sorted { $0.confidence > $1.confidence } - self.diagnostics = result.diagnostics - if let error = result.weatherKitError { + self.candidates = items + self.diagnostics = output.diagnostics + if let error = output.diagnostics["weatherkit_error"] { self.lastError = "WeatherKit error: \(error)" } } - if let error = result.weatherKitError { + if let error = output.diagnostics["weatherkit_error"] { self.logger.error("WeatherKit error: \(error)") } - self.logger.info("Produced candidates count=\(result.candidates.count)") - for c in result.candidates { - self.logger.info("Candidate id=\(c.id, privacy: .public) type=\(c.type.rawValue, privacy: .public) conf=\(c.confidence, format: .fixed(precision: 2)) ttl=\(c.ttlSec) title=\(c.title, privacy: .public)") + self.logger.info("Produced feed items count=\(items.count)") + for item in items { + self.logger.info("FeedItem id=\(item.id, privacy: .public) type=\(item.type.rawValue, privacy: .public) prio=\(item.priority, format: .fixed(precision: 2)) ttl=\(item.ttlSec) title=\(item.title, privacy: .public)") } - if result.candidates.isEmpty { - self.logger.info("Diagnostics: \(String(describing: result.diagnostics), privacy: .public)") + if items.isEmpty { + self.logger.info("Diagnostics: \(String(describing: output.diagnostics), privacy: .public)") } } else { await MainActor.run { diff --git a/IrisCompanion/iris/Views/CandidatesView.swift b/IrisCompanion/iris/Views/CandidatesView.swift index b4944b8..1a4b819 100644 --- a/IrisCompanion/iris/Views/CandidatesView.swift +++ b/IrisCompanion/iris/Views/CandidatesView.swift @@ -75,7 +75,7 @@ struct CandidatesView: View { } private struct CandidateRow: View { - let candidate: Candidate + let candidate: FeedItem var body: some View { VStack(alignment: .leading, spacing: 6) { @@ -88,29 +88,22 @@ private struct CandidateRow: View { .font(.caption) .foregroundStyle(.secondary) } - Text(candidate.subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) + if !candidate.subtitle.isEmpty { + Text(candidate.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } HStack(spacing: 12) { - Text(String(format: "conf %.2f", candidate.confidence)) + Text(String(format: "prio %.2f", candidate.priority)) Text("ttl \(candidate.ttlSec)s") - Text(expiresText(now: Int(Date().timeIntervalSince1970))) } .font(.caption) .foregroundStyle(.secondary) } .padding(.vertical, 4) } - - private func expiresText(now: Int) -> String { - let expiresAt = candidate.createdAt + candidate.ttlSec - let remaining = expiresAt - now - if remaining <= 0 { return "expired" } - if remaining < 60 { return "in \(remaining)s" } - return "in \(remaining / 60)m" - } } struct CandidatesView_Previews: PreviewProvider { diff --git a/IrisCompanion/iris/Views/OrchestratorView.swift b/IrisCompanion/iris/Views/OrchestratorView.swift index 7ab0a43..9b41ba2 100644 --- a/IrisCompanion/iris/Views/OrchestratorView.swift +++ b/IrisCompanion/iris/Views/OrchestratorView.swift @@ -45,18 +45,48 @@ struct OrchestratorView: View { Button("Recompute Now") { orchestrator.recomputeNow() } } - Section("Winner") { - if let env = orchestrator.lastWinner { - Text(env.winner.title) + Section("Feed") { + if let feed = orchestrator.lastFeed, let winner = feed.winnerItem() { + Text(winner.title) .font(.headline) - Text(env.winner.subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - Text("type \(env.winner.type.rawValue) • prio \(String(format: "%.2f", env.winner.priority)) • ttl \(env.winner.ttlSec)s") + if !winner.subtitle.isEmpty { + Text(winner.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Text("type \(winner.type.rawValue) • prio \(String(format: "%.2f", winner.priority)) • ttl \(winner.ttlSec)s") .font(.caption) .foregroundStyle(.secondary) + + if feed.feed.count > 1 { + Divider() + } + + ForEach(feed.feed, id: \.id) { item in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(item.title) + .font(.headline) + .lineLimit(1) + Spacer() + Text(item.type.rawValue) + .font(.caption) + .foregroundStyle(.secondary) + } + if !item.subtitle.isEmpty { + Text(item.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Text("bucket \(item.bucket.rawValue) • prio \(String(format: "%.2f", item.priority)) • ttl \(item.ttlSec)s") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } } else { - Text("No winner yet") + Text("No feed yet") .foregroundStyle(.secondary) } } @@ -80,35 +110,6 @@ struct OrchestratorView: View { } } - Section("Candidates (\(orchestrator.lastCandidates.count))") { - if orchestrator.lastCandidates.isEmpty { - Text("No candidates") - .foregroundStyle(.secondary) - } else { - ForEach(orchestrator.lastCandidates, id: \.id) { c in - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(c.title) - .font(.headline) - .lineLimit(1) - Spacer() - Text(c.type.rawValue) - .font(.caption) - .foregroundStyle(.secondary) - } - Text(c.subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - Text("conf \(String(format: "%.2f", c.confidence)) • ttl \(c.ttlSec)s") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.vertical, 4) - } - } - } - if !orchestrator.lastWeatherDiagnostics.isEmpty { Section("Weather Diagnostics") { ForEach(orchestrator.lastWeatherDiagnostics.keys.sorted(), id: \.self) { key in @@ -122,6 +123,19 @@ struct OrchestratorView: View { } } + if !orchestrator.lastCalendarDiagnostics.isEmpty { + Section("Calendar Diagnostics") { + ForEach(orchestrator.lastCalendarDiagnostics.keys.sorted(), id: \.self) { key in + LabeledContent(key) { + Text(orchestrator.lastCalendarDiagnostics[key] ?? "") + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + } + Section("Test") { Button("Send Fixture Feed Now") { orchestrator.sendFixtureFeedNow() } }