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

@@ -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
}

View File

@@ -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 []
}
}

View File

@@ -20,28 +20,12 @@ struct WeatherAlertConfig: Sendable {
init() {}
}
protocol WeatherWarningProviding: Sendable {
func warningCandidates(location: CLLocation, now: Int) -> [Candidate]
}
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 {
struct WeatherWarning: Codable, Equatable, Sendable {
let id: String
let title: String
let subtitle: String
let ttlSec: Int?
let confidence: Double?
let meta: [String: String]?
let ttlSec: Int
let confidence: Double
enum CodingKeys: String, CodingKey {
case id
@@ -49,66 +33,70 @@ struct LocalMockWeatherWarningProvider: WeatherWarningProviding, Sendable {
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
)
}
}
}
@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,
return WeatherData.RainSoon(
startAt: Int(start.timeIntervalSince1970),
ttlSec: config.rainTTL,
metadata: [
"source": "weatherkit_minutely",
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
]
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,
return WeatherData.RainSoon(
startAt: Int(start.timeIntervalSince1970),
ttlSec: config.rainTTL,
metadata: [
"source": "weatherkit_hourly_approx",
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
]
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? {

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

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

View File

@@ -27,49 +27,65 @@ final class HeuristicRanker {
self.lastShownAtProvider = lastShownAt
}
func pickWinner(from candidates: [Candidate], now: Int? = nil, context: UserContext) -> Winner {
let currentTime = now ?? nowProvider()
let valid = candidates
.filter { !$0.isExpired(at: currentTime) }
.filter { $0.confidence >= 0.0 }
guard !valid.isEmpty else {
return WinnerEnvelope.allQuiet(now: currentTime).winner
struct Ranked: Equatable {
let item: FeedItem
let confidence: Double
let isEligibleForRightNow: Bool
}
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),
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 = items
.filter { $0.item.ttlSec > 0 }
.filter { $0.confidence >= 0.0 }
.filter { $0.isEligibleForRightNow }
guard !valid.isEmpty else { return nil }
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

View File

@@ -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"
}
}

View File

@@ -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
}
}

View File

@@ -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) {

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

View File

@@ -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}}

View File

@@ -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 = result.candidates.sorted { $0.confidence > $1.confidence }
self.diagnostics = result.diagnostics
if let error = result.weatherKitError {
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 = 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 {

View File

@@ -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)
}
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 {

View File

@@ -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)
if !winner.subtitle.isEmpty {
Text(winner.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
Text("type \(env.winner.type.rawValue) • prio \(String(format: "%.2f", env.winner.priority)) • ttl \(env.winner.ttlSec)s")
}
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() }
}