Refactor data sources and feed model
This commit is contained in:
@@ -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"]
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user