Refactor data sources and feed model

This commit is contained in:
2026-01-10 00:25:36 +00:00
parent 1e65a3f57d
commit 324b35a464
15 changed files with 631 additions and 609 deletions

View File

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