Compare commits
5 Commits
34838f5ae1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 22fbfb9790 | |||
| d8929d3776 | |||
| 2860ab0786 | |||
| e15be9ddc4 | |||
| b6ff4e81e9 |
193
IrisCompanion/iris/DataSources/TFLDataSource.swift
Normal file
193
IrisCompanion/iris/DataSources/TFLDataSource.swift
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
//
|
||||||
|
// TFLDataSource.swift
|
||||||
|
// iris
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct TFLDataSourceConfig: Sendable {
|
||||||
|
var cacheValiditySec: Int = 120
|
||||||
|
var ttlSec: Int = 300
|
||||||
|
var maxDisruptions: Int = 3
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class TFLDataSource {
|
||||||
|
struct Disruption: Sendable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let lineName: String
|
||||||
|
let lineId: String
|
||||||
|
let severity: Int
|
||||||
|
let severityDescription: String
|
||||||
|
let reason: String?
|
||||||
|
let isMajor: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TFLData: Sendable, Equatable {
|
||||||
|
let disruptions: [Disruption]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Snapshot: Sendable {
|
||||||
|
let data: TFLData
|
||||||
|
let diagnostics: [String: String]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TFLError: Error, LocalizedError, Sendable {
|
||||||
|
case networkFailed(message: String, diagnostics: [String: String])
|
||||||
|
case invalidResponse(diagnostics: [String: String])
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .networkFailed(let message, _):
|
||||||
|
return message
|
||||||
|
case .invalidResponse:
|
||||||
|
return "Invalid TFL response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Severity 4 = Planned Closure, 5 = Part Closure, 10 = Good Service
|
||||||
|
private static let ignoredSeverities: Set<Int> = [4, 5, 10]
|
||||||
|
// Severity 1 = Closed, 2 = Suspended, 3 = Part Suspended, 6 = Severe Delays
|
||||||
|
private static let majorSeverities: Set<Int> = [1, 2, 3, 6]
|
||||||
|
|
||||||
|
private let config: TFLDataSourceConfig
|
||||||
|
private var cache: (timestamp: Int, data: TFLData)?
|
||||||
|
|
||||||
|
var ttlSec: Int { config.ttlSec }
|
||||||
|
|
||||||
|
init(config: TFLDataSourceConfig = .init()) {
|
||||||
|
self.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
func dataWithDiagnostics(now: Int) async throws -> Snapshot {
|
||||||
|
var diagnostics: [String: String] = [
|
||||||
|
"now": String(now),
|
||||||
|
"cache_validity_sec": String(config.cacheValiditySec),
|
||||||
|
]
|
||||||
|
|
||||||
|
if let cache = cache, now - cache.timestamp < config.cacheValiditySec {
|
||||||
|
diagnostics["source"] = "cache"
|
||||||
|
diagnostics["cache_age_sec"] = String(now - cache.timestamp)
|
||||||
|
return Snapshot(data: cache.data, diagnostics: diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let url = URL(string: "https://api.tfl.gov.uk/Line/Mode/tube,elizabeth-line/Status") else {
|
||||||
|
throw TFLError.invalidResponse(diagnostics: diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
if let httpResponse = response as? HTTPURLResponse {
|
||||||
|
diagnostics["http_status"] = String(httpResponse.statusCode)
|
||||||
|
guard (200..<300).contains(httpResponse.statusCode) else {
|
||||||
|
throw TFLError.networkFailed(
|
||||||
|
message: "HTTP \(httpResponse.statusCode)",
|
||||||
|
diagnostics: diagnostics
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lines = try JSONDecoder().decode([TFLLineStatus].self, from: data)
|
||||||
|
diagnostics["source"] = "network"
|
||||||
|
diagnostics["lines_returned"] = String(lines.count)
|
||||||
|
|
||||||
|
var disruptions: [Disruption] = []
|
||||||
|
var seenLines: Set<String> = []
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
guard !seenLines.contains(line.id) else { continue }
|
||||||
|
|
||||||
|
for status in line.lineStatuses {
|
||||||
|
guard !Self.ignoredSeverities.contains(status.statusSeverity) else { continue }
|
||||||
|
|
||||||
|
seenLines.insert(line.id)
|
||||||
|
let isMajor = Self.majorSeverities.contains(status.statusSeverity)
|
||||||
|
|
||||||
|
let disruption = Disruption(
|
||||||
|
id: "\(line.id):\(status.statusSeverity)",
|
||||||
|
lineName: line.name,
|
||||||
|
lineId: line.id,
|
||||||
|
severity: status.statusSeverity,
|
||||||
|
severityDescription: status.statusSeverityDescription,
|
||||||
|
reason: status.reason ?? status.disruption?.description,
|
||||||
|
isMajor: isMajor
|
||||||
|
)
|
||||||
|
disruptions.append(disruption)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disruptions.sort { $0.severity < $1.severity }
|
||||||
|
|
||||||
|
let limited = Array(disruptions.prefix(config.maxDisruptions))
|
||||||
|
diagnostics["disruptions_found"] = String(disruptions.count)
|
||||||
|
diagnostics["disruptions_returned"] = String(limited.count)
|
||||||
|
|
||||||
|
let tflData = TFLData(disruptions: limited)
|
||||||
|
cache = (timestamp: now, data: tflData)
|
||||||
|
|
||||||
|
return Snapshot(data: tflData, diagnostics: diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func disruptionTitle(_ disruption: Disruption) -> String {
|
||||||
|
var name = disruption.lineName
|
||||||
|
name = name.replacingOccurrences(of: " & City", with: "")
|
||||||
|
name = name.replacingOccurrences(of: "Hammersmith", with: "H'smith")
|
||||||
|
name = name.replacingOccurrences(of: "Metropolitan", with: "Met")
|
||||||
|
name = name.replacingOccurrences(of: "Waterloo", with: "W'loo")
|
||||||
|
name = name.replacingOccurrences(of: "Elizabeth line", with: "Eliz.")
|
||||||
|
|
||||||
|
var severity = disruption.severityDescription
|
||||||
|
severity = severity.replacingOccurrences(of: "Minor Delays", with: "Delays")
|
||||||
|
severity = severity.replacingOccurrences(of: "Severe Delays", with: "Severe")
|
||||||
|
severity = severity.replacingOccurrences(of: "Part Closure", with: "Part Closed")
|
||||||
|
severity = severity.replacingOccurrences(of: "Part Suspended", with: "Part Susp.")
|
||||||
|
|
||||||
|
return "\(name): \(severity)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func disruptionSubtitle(_ disruption: Disruption) -> String {
|
||||||
|
guard let reason = disruption.reason else {
|
||||||
|
return "Check TFL for details"
|
||||||
|
}
|
||||||
|
let phrases = reason.components(separatedBy: ".")
|
||||||
|
if let first = phrases.first?.trimmingCharacters(in: .whitespacesAndNewlines), !first.isEmpty {
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
return "Check TFL for details"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TFL API Response Models
|
||||||
|
|
||||||
|
private struct TFLLineStatus: Codable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let modeName: String
|
||||||
|
let lineStatuses: [LineStatus]
|
||||||
|
|
||||||
|
struct LineStatus: Codable {
|
||||||
|
let statusSeverity: Int
|
||||||
|
let statusSeverityDescription: String
|
||||||
|
let reason: String?
|
||||||
|
let validityPeriods: [ValidityPeriod]?
|
||||||
|
let disruption: Disruption?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ValidityPeriod: Codable {
|
||||||
|
let fromDate: String?
|
||||||
|
let toDate: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Disruption: Codable {
|
||||||
|
let category: String?
|
||||||
|
let description: String?
|
||||||
|
let closureText: String?
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,7 @@ final class HeuristicRanker {
|
|||||||
switch type {
|
switch type {
|
||||||
case .weatherWarning: return 1.0
|
case .weatherWarning: return 1.0
|
||||||
case .weatherAlert: return 0.9
|
case .weatherAlert: return 0.9
|
||||||
|
case .transitAlert: return 0.85
|
||||||
case .calendarEvent: return 0.8
|
case .calendarEvent: return 0.8
|
||||||
case .transit: return 0.75
|
case .transit: return 0.75
|
||||||
case .poiNearby: return 0.6
|
case .poiNearby: return 0.6
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ enum FeedItemType: String, Codable, CaseIterable {
|
|||||||
case currentWeather = "CURRENT_WEATHER"
|
case currentWeather = "CURRENT_WEATHER"
|
||||||
case calendarEvent = "CALENDAR_EVENT"
|
case calendarEvent = "CALENDAR_EVENT"
|
||||||
case stock = "STOCK"
|
case stock = "STOCK"
|
||||||
|
case transitAlert = "TRANSIT_ALERT"
|
||||||
case allQuiet = "ALL_QUIET"
|
case allQuiet = "ALL_QUIET"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
@Published private(set) var lastCalendarDiagnostics: [String: String] = [:]
|
@Published private(set) var lastCalendarDiagnostics: [String: String] = [:]
|
||||||
@Published private(set) var lastPipelineElapsedMs: Int? = nil
|
@Published private(set) var lastPipelineElapsedMs: Int? = nil
|
||||||
@Published private(set) var lastFetchFailed: Bool = false
|
@Published private(set) var lastFetchFailed: Bool = false
|
||||||
|
@Published private(set) var lastTFLDiagnostics: [String: String] = [:]
|
||||||
@Published private(set) var musicAuthorization: MusicAuthorization.Status = .notDetermined
|
@Published private(set) var musicAuthorization: MusicAuthorization.Status = .notDetermined
|
||||||
@Published private(set) var nowPlaying: NowPlayingSnapshot? = nil
|
@Published private(set) var nowPlaying: NowPlayingSnapshot? = nil
|
||||||
@Published private(set) var spotifyNowPlaying: SpotifyNowPlaying? = nil
|
@Published private(set) var spotifyNowPlaying: SpotifyNowPlaying? = nil
|
||||||
@@ -43,6 +44,7 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
private let calendarDataSource = CalendarDataSource()
|
private let calendarDataSource = CalendarDataSource()
|
||||||
private let poiDataSource = POIDataSource()
|
private let poiDataSource = POIDataSource()
|
||||||
private let stockDataSource = StockDataSource()
|
private let stockDataSource = StockDataSource()
|
||||||
|
private let tflDataSource = TFLDataSource()
|
||||||
private let ranker: HeuristicRanker
|
private let ranker: HeuristicRanker
|
||||||
private let store: FeedStore
|
private let store: FeedStore
|
||||||
private let server: LocalServer
|
private let server: LocalServer
|
||||||
@@ -258,11 +260,15 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
async let stockResult = withTimeoutResult(seconds: 6) {
|
async let stockResult = withTimeoutResult(seconds: 6) {
|
||||||
try await self.stockDataSource.dataWithDiagnostics(symbols: stockSymbols, now: nowEpoch)
|
try await self.stockDataSource.dataWithDiagnostics(symbols: stockSymbols, now: nowEpoch)
|
||||||
}
|
}
|
||||||
|
async let tflResult = withTimeoutResult(seconds: 6) {
|
||||||
|
try await self.tflDataSource.dataWithDiagnostics(now: nowEpoch)
|
||||||
|
}
|
||||||
|
|
||||||
let wxRes = await weatherResult
|
let wxRes = await weatherResult
|
||||||
let calRes = await calendarResult
|
let calRes = await calendarResult
|
||||||
let poiRes = await poiResult
|
let poiRes = await poiResult
|
||||||
let stockRes = await stockResult
|
let stockRes = await stockResult
|
||||||
|
let tflRes = await tflResult
|
||||||
|
|
||||||
func calendarTTL(endAt: Int, now: Int) -> Int {
|
func calendarTTL(endAt: Int, now: Int) -> Int {
|
||||||
let ttl = endAt - now
|
let ttl = endAt - now
|
||||||
@@ -279,6 +285,7 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
var calendarItems: [FeedItem] = []
|
var calendarItems: [FeedItem] = []
|
||||||
var poiItems: [FeedItem] = []
|
var poiItems: [FeedItem] = []
|
||||||
var stockItems: [FeedItem] = []
|
var stockItems: [FeedItem] = []
|
||||||
|
var tflItems: [FeedItem] = []
|
||||||
var weatherNowItem: FeedItem? = nil
|
var weatherNowItem: FeedItem? = nil
|
||||||
var fetchFailed = false
|
var fetchFailed = false
|
||||||
var wxDiagnostics: [String: String] = [:]
|
var wxDiagnostics: [String: String] = [:]
|
||||||
@@ -454,13 +461,49 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
logger.warning("stock fetch failed: \(String(describing: error), privacy: .public)")
|
logger.warning("stock fetch failed: \(String(describing: error), privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var tflDiagnostics: [String: String] = [:]
|
||||||
|
switch tflRes {
|
||||||
|
case .success(let snapshot):
|
||||||
|
tflDiagnostics = snapshot.diagnostics
|
||||||
|
for disruption in snapshot.data.disruptions {
|
||||||
|
let confidence: Double = disruption.isMajor ? 0.9 : 0.6
|
||||||
|
let item = FeedItem(
|
||||||
|
id: "tfl:\(disruption.lineId):\(nowEpoch / 300)",
|
||||||
|
type: .transitAlert,
|
||||||
|
title: tflDataSource.disruptionTitle(disruption).truncated(maxLength: TextConstraints.titleMax),
|
||||||
|
subtitle: tflDataSource.disruptionSubtitle(disruption).truncated(maxLength: TextConstraints.subtitleMax),
|
||||||
|
priority: confidence,
|
||||||
|
ttlSec: tflDataSource.ttlSec,
|
||||||
|
condition: nil,
|
||||||
|
startsAt: nil,
|
||||||
|
poiType: nil,
|
||||||
|
bucket: disruption.isMajor ? .rightNow : .fyi,
|
||||||
|
actions: ["DISMISS"]
|
||||||
|
)
|
||||||
|
tflItems.append(item)
|
||||||
|
if disruption.isMajor {
|
||||||
|
rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !snapshot.data.disruptions.isEmpty {
|
||||||
|
logger.info("tfl disruptions fetched count=\(snapshot.data.disruptions.count)")
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
if case TimeoutError.timedOut = error {
|
||||||
|
logger.warning("tfl fetch timeout")
|
||||||
|
} else {
|
||||||
|
logger.warning("tfl fetch failed: \(String(describing: error), privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||||
lastPipelineElapsedMs = elapsedMs
|
lastPipelineElapsedMs = elapsedMs
|
||||||
lastFetchFailed = fetchFailed
|
lastFetchFailed = fetchFailed
|
||||||
lastWeatherDiagnostics = wxDiagnostics
|
lastWeatherDiagnostics = wxDiagnostics
|
||||||
lastCalendarDiagnostics = calDiagnostics
|
lastCalendarDiagnostics = calDiagnostics
|
||||||
|
lastTFLDiagnostics = tflDiagnostics
|
||||||
|
|
||||||
logger.info("pipeline right_now_candidates=\(rightNowCandidates.count) calendar_items=\(calendarItems.count) poi_items=\(poiItems.count) stock_items=\(stockItems.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)")
|
logger.info("pipeline right_now_candidates=\(rightNowCandidates.count) calendar_items=\(calendarItems.count) poi_items=\(poiItems.count) stock_items=\(stockItems.count) tfl_items=\(tflItems.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)")
|
||||||
|
|
||||||
if fetchFailed, rightNowCandidates.isEmpty, calendarItems.isEmpty, weatherNowItem == nil {
|
if fetchFailed, rightNowCandidates.isEmpty, calendarItems.isEmpty, weatherNowItem == nil {
|
||||||
let fallbackFeed = store.getFeed(now: nowEpoch)
|
let fallbackFeed = store.getFeed(now: nowEpoch)
|
||||||
@@ -556,6 +599,28 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let fyiTFL = tflItems
|
||||||
|
.filter { $0.bucket == .fyi }
|
||||||
|
.filter { $0.id != winnerItem.id }
|
||||||
|
.filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) }
|
||||||
|
.prefix(2)
|
||||||
|
|
||||||
|
fyi.append(contentsOf: fyiTFL.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,
|
||||||
|
poiType: item.poiType,
|
||||||
|
bucket: .fyi,
|
||||||
|
actions: ["DISMISS"]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
let items = [winnerItem] + fyi
|
let items = [winnerItem] + fyi
|
||||||
let feedEnvelope = FeedEnvelope(
|
let feedEnvelope = FeedEnvelope(
|
||||||
schema: 1,
|
schema: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user