diff --git a/PulseLoop.xcodeproj/project.pbxproj b/PulseLoop.xcodeproj/project.pbxproj index 8669e06..2da2880 100644 --- a/PulseLoop.xcodeproj/project.pbxproj +++ b/PulseLoop.xcodeproj/project.pbxproj @@ -649,7 +649,7 @@ INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "PulseLoop connects to your SMART_RING to sync activity, heart rate, SpO2, and sleep."; INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "PulseLoop connects to your SMART_RING to sync activity, heart rate, SpO2, and sleep."; INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "PulseLoop keeps recording your workout route in the background while a workout is active."; - INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "PulseLoop records your route during outdoor workouts when GPS is enabled."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "PulseLoop records your route during outdoor workouts, and — when enabled — uses your approximate location for weather-aware coaching."; INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -689,7 +689,7 @@ INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "PulseLoop connects to your SMART_RING to sync activity, heart rate, SpO2, and sleep."; INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "PulseLoop connects to your SMART_RING to sync activity, heart rate, SpO2, and sleep."; INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "PulseLoop keeps recording your workout route in the background while a workout is active."; - INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "PulseLoop records your route during outdoor workouts when GPS is enabled."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "PulseLoop records your route during outdoor workouts, and — when enabled — uses your approximate location for weather-aware coaching."; INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -723,6 +723,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.0; MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) -Xfrontend -solver-expression-time-threshold=60"; PRODUCT_BUNDLE_IDENTIFIER = xyz.sakshambhutani.pulseloop2Tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_APPROACHABLE_CONCURRENCY = YES; diff --git a/PulseLoop/Coach/Apple/AppleFoundationModelsClient.swift b/PulseLoop/Coach/Apple/AppleFoundationModelsClient.swift index 72e93cc..8075029 100644 --- a/PulseLoop/Coach/Apple/AppleFoundationModelsClient.swift +++ b/PulseLoop/Coach/Apple/AppleFoundationModelsClient.swift @@ -123,18 +123,26 @@ final class AppleFoundationModelsClient: ResponsesClient, @unchecked Sendable { } /// Renders the running transcript into a single prompt string, trimmed to the - /// on-device character budget (oldest turns dropped first). + /// on-device character budget by dropping whole oldest turns (rather than a + /// mid-sentence `suffix` cut that leaves the model a truncated first line). The + /// newest turn is always kept even if it alone exceeds the budget — the + /// `promptCharBudget` still backstops a runaway single turn. private func buildPrompt() -> String { - var lines: [String] = [] - for turn in turns { - let label = turn.role == "assistant" ? "Assistant" : "User" - lines.append("\(label): \(turn.text)") - } - var prompt = lines.joined(separator: "\n\n") - if prompt.count > promptCharBudget { - prompt = String(prompt.suffix(promptCharBudget)) + let lines = turns.map { "\($0.role == "assistant" ? "Assistant" : "User"): \($0.text)" } + + // Keep the most recent turns that fit, walking backward from the newest. + var kept: [String] = [] + var used = 0 + for line in lines.reversed() { + let add = line.count + (kept.isEmpty ? 0 : 2) // account for the "\n\n" join + if !kept.isEmpty, used + add > promptCharBudget { break } + kept.insert(line, at: 0) + used += add } - return prompt + + let prompt = kept.joined(separator: "\n\n") + // Backstop: a single oversized newest turn still gets clamped. + return prompt.count > promptCharBudget ? String(prompt.suffix(promptCharBudget)) : prompt } #if canImport(FoundationModels) diff --git a/PulseLoop/Coach/Config/CoachFeatureFlags.swift b/PulseLoop/Coach/Config/CoachFeatureFlags.swift index f06f587..62ca9eb 100644 --- a/PulseLoop/Coach/Config/CoachFeatureFlags.swift +++ b/PulseLoop/Coach/Config/CoachFeatureFlags.swift @@ -40,6 +40,20 @@ struct CoachFeatureFlags { var maxRounds: Int { max(1, settings.maxRounds) } var model: String { settings.model } + /// The model string actually sent for the active provider — the single place + /// that resolves the per-provider naming (OpenRouter/MiniMax read their own + /// slug fields; on-device has no external model name). Used to label and price + /// a turn's usage. Mirrors `CoachClientResolver`'s per-provider `model` choice. + var effectiveModel: String { + switch settings.providerMode { + case .appleOnDevice: return "apple-on-device" + case .offlineStub: return "offline-stub" + case .userOpenRouterKey: return settings.openRouterModel + case .userMiniMaxKey: return settings.minimaxModel + case .userOpenAIKey, .userGeminiKey, .backendProxy: return settings.model + } + } + /// One-line status for the Settings UI. var statusLine: String { if !settings.coachMasterEnabled { return "Off — turn on AI Coach to enable." } diff --git a/PulseLoop/Coach/Config/CoachSettings.swift b/PulseLoop/Coach/Config/CoachSettings.swift index 9eda768..377fc4a 100644 --- a/PulseLoop/Coach/Config/CoachSettings.swift +++ b/PulseLoop/Coach/Config/CoachSettings.swift @@ -180,6 +180,10 @@ struct CoachSettings: Codable, Equatable { /// sleep). On-device only — free/unlimited local inference makes "watch the /// stream and speak up when something looks off" practical. Off by default. var proactiveAlertsEnabled: Bool = false + /// Opt-in: share the user's city name + current weather with the coach so it + /// can ground practical advice (outdoor vs indoor, hydration, rain). City-level + /// only — never the precise location. Off by default. + var enableEnvironmentContext: Bool = false /// The OpenRouter model slug to use. Free-form (the user may type any slug); /// falls back to the default only when the stored `model` is blank. @@ -221,6 +225,7 @@ struct CoachSettings: Codable, Equatable { middayHour = try c.decodeIfPresent(Int.self, forKey: .middayHour) ?? d.middayHour eveningHour = try c.decodeIfPresent(Int.self, forKey: .eveningHour) ?? d.eveningHour proactiveAlertsEnabled = try c.decodeIfPresent(Bool.self, forKey: .proactiveAlertsEnabled) ?? d.proactiveAlertsEnabled + enableEnvironmentContext = try c.decodeIfPresent(Bool.self, forKey: .enableEnvironmentContext) ?? d.enableEnvironmentContext } } diff --git a/PulseLoop/Coach/Config/CoachSettingsSection.swift b/PulseLoop/Coach/Config/CoachSettingsSection.swift index 19fbe0f..a7f6ff1 100644 --- a/PulseLoop/Coach/Config/CoachSettingsSection.swift +++ b/PulseLoop/Coach/Config/CoachSettingsSection.swift @@ -236,6 +236,10 @@ struct CoachSettingsSection: View { toggleRow("AI actions (set goals, log, edit)", isOn: writeToolsBinding) toggleRow("Live ring measurements", isOn: liveMeasurementsBinding) + toggleRow("Use location & weather", isOn: environmentContextBinding) + Text("Shares your city name and current weather with the AI provider so coaching can account for conditions. Never shares your precise location.") + .font(.caption).foregroundStyle(PulseColors.textMuted) + .padding(.horizontal, 16) if showsImageInputToggle { toggleRow("Image input (attach photos)", isOn: imageInputBinding) } @@ -535,6 +539,15 @@ struct CoachSettingsSection: View { private var imageInputBinding: Binding { Binding(get: { store.settings.enableImageInput }, set: { store.settings.enableImageInput = $0 }) } + private var environmentContextBinding: Binding { + Binding( + get: { store.settings.enableEnvironmentContext }, + set: { newValue in + store.settings.enableEnvironmentContext = newValue + if newValue { CoachEnvironmentContextService.shared.requestPermissionIfNeeded() } + } + ) + } // MARK: - Key actions diff --git a/PulseLoop/Coach/Config/CoachVarietyHints.swift b/PulseLoop/Coach/Config/CoachVarietyHints.swift new file mode 100644 index 0000000..c3e234a --- /dev/null +++ b/PulseLoop/Coach/Config/CoachVarietyHints.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Prompt-only variety for the coach's repetitive surfaces (daily check-ins, +/// Today/Sleep cards). Picks a deterministic "coaching angle" from a seed so the +/// same context yields the same angle within a run (idempotent regeneration), +/// while different seeds rotate through fresh framings — avoiding the "every +/// notification opens the same way" fatigue. +/// +/// The seed hash is a stable FNV-1a over the UTF-8 bytes — NOT Swift's +/// `hashValue`, which is per-launch randomized and would give a different angle +/// every process for the same seed. +enum CoachVarietyHints { + /// ~8 distinct framings. Order is fixed so `angle(seed:)` is reproducible. + static let angles: [String] = [ + "Compare today to yesterday — call out what changed and why it might matter.", + "Notice a streak or milestone worth celebrating (consecutive active days, a personal best).", + "Zoom in on a single metric and go one level deeper than usual (e.g. resting HR, deep sleep).", + "Take a recovery lens — how rested is the body, and what would help it bounce back?", + "Lead with a genuine question that invites the user to reflect, then ground it in a number.", + "Lead with one concrete, immediately useful tip tied to today's data.", + "If weather/location context is available, make the advice weather-aware (outdoor vs indoor, hydration, rain).", + "Zoom out to the week's trend — is the direction improving, flat, or slipping?", + ] + + /// Deterministic angle for a seed. Stable across launches (FNV-1a), so + /// regenerating the same check-in doesn't reshuffle the framing. + static func angle(seed: String) -> String { + guard !angles.isEmpty else { return "" } + let index = Int(fnv1a(seed) % UInt64(angles.count)) + return angles[index] + } + + /// FNV-1a 64-bit over UTF-8 bytes — a small, stable, non-cryptographic hash. + static func fnv1a(_ string: String) -> UInt64 { + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + let prime: UInt64 = 0x0000_0100_0000_01b3 + for byte in string.utf8 { + hash ^= UInt64(byte) + hash = hash &* prime + } + return hash + } +} diff --git a/PulseLoop/Coach/Context/CoachContextBudget.swift b/PulseLoop/Coach/Context/CoachContextBudget.swift new file mode 100644 index 0000000..8705abf --- /dev/null +++ b/PulseLoop/Coach/Context/CoachContextBudget.swift @@ -0,0 +1,32 @@ +import Foundation + +/// How much context to pack into a turn. `.full` is the cloud default; `.compact` +/// trims the packet (and system prompt + history) for the tiny on-device Apple +/// model, whose context window is small and which is tool-less. The packet SHAPE +/// is identical between the two — only the contents are trimmed — so encoders and +/// tools are untouched. +enum CoachContextBudget { + case full + case compact + + /// Max durable memories embedded in the packet. + var maxMemories: Int { self == .compact ? 3 : 8 } + /// Max recent workouts embedded in the packet. + var maxWorkouts: Int { self == .compact ? 2 : 8 } + /// Max data-quality warnings embedded in the packet. + var maxWarnings: Int { self == .compact ? 2 : Int.max } + /// Character cap on each memory `value` (long notes are truncated). + var memoryValueCap: Int { self == .compact ? 120 : Int.max } + /// Character cap on the rolling conversation summary. + var conversationSummaryCap: Int { self == .compact ? 400 : Int.max } + /// Max prior conversation turns replayed to the model. + var historyTurns: Int { self == .compact ? 4 : 10 } +} + +extension CoachFeatureFlags { + /// Compact on-device, full everywhere else. The tiny local model needs a + /// smaller packet + prompt; cloud models don't. + var contextBudget: CoachContextBudget { + settings.providerMode == .appleOnDevice ? .compact : .full + } +} diff --git a/PulseLoop/Coach/Context/CoachContextBuilder.swift b/PulseLoop/Coach/Context/CoachContextBuilder.swift index ed5bea9..4761125 100644 --- a/PulseLoop/Coach/Context/CoachContextBuilder.swift +++ b/PulseLoop/Coach/Context/CoachContextBuilder.swift @@ -9,7 +9,9 @@ enum CoachContextBuilder { static func build( context: ModelContext, conversationSummary: String? = nil, - now: Date = Date() + now: Date = Date(), + budget: CoachContextBudget = .full, + environment: CoachContextPacket.EnvironmentContext? = nil ) -> CoachContextPacket { let summary = MetricsService.buildTodaySummary(context: context) let profile = ProfileRepository.profile(context: context) @@ -104,15 +106,23 @@ enum CoachContextBuilder { lastSevenDays: week, latestVitals: vitals, latestSleep: sleep, - recentWorkouts: recentWorkouts(context: context), - memories: memories(context: context), - conversationSummary: conversationSummary, - dataQualityWarnings: warnings + recentWorkouts: recentWorkouts(context: context, limit: budget.maxWorkouts), + memories: memories(context: context, limit: budget.maxMemories, valueCap: budget.memoryValueCap), + conversationSummary: cap(conversationSummary, to: budget.conversationSummaryCap), + dataQualityWarnings: Array(warnings.prefix(budget.maxWarnings)), + environment: environment ) } // MARK: - Helpers + /// Truncates a string to `limit` characters (adding an ellipsis when cut). + /// `Int.max` (the full budget) is a no-op. + private static func cap(_ text: String?, to limit: Int) -> String? { + guard let text, limit != .max, text.count > limit else { return text } + return String(text.prefix(limit)) + "…" + } + private static func recentWorkouts(context: ModelContext, limit: Int = 8) -> [CoachContextPacket.WorkoutContext] { ActivityRepository.sessions(context: context) .filter { $0.status == .finished } @@ -134,7 +144,7 @@ enum CoachContextBuilder { } } - private static func memories(context: ModelContext, limit: Int = 8, now: Date = Date()) -> [CoachContextPacket.MemoryContext] { + private static func memories(context: ModelContext, limit: Int = 8, valueCap: Int = .max, now: Date = Date()) -> [CoachContextPacket.MemoryContext] { let descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.importance, order: .reverse), SortDescriptor(\.updatedAt, order: .reverse)] ) @@ -142,7 +152,7 @@ enum CoachContextBuilder { return rows .filter { $0.expiresAt == nil || $0.expiresAt! > now } // drop expired .prefix(limit) - .map { .init(type: $0.memoryType, key: $0.key, value: $0.value, importance: $0.importance) } + .map { .init(type: $0.memoryType, key: $0.key, value: cap($0.value, to: valueCap) ?? $0.value, importance: $0.importance) } } private static func profileCompleteness(_ profile: UserProfile?) -> String { diff --git a/PulseLoop/Coach/Context/CoachContextPacket.swift b/PulseLoop/Coach/Context/CoachContextPacket.swift index 15690bf..5c6a81a 100644 --- a/PulseLoop/Coach/Context/CoachContextPacket.swift +++ b/PulseLoop/Coach/Context/CoachContextPacket.swift @@ -21,6 +21,10 @@ struct CoachContextPacket: Encodable { var memories: [MemoryContext] var conversationSummary: String? var dataQualityWarnings: [String] + /// Opt-in city-level location + current weather. Nil when the toggle is off, + /// permission is denied, or WeatherKit is unavailable. Raw coordinates NEVER + /// appear here — only the reverse-geocoded city/region. + var environment: EnvironmentContext? struct ProfileContext: Encodable { var name: String? @@ -104,4 +108,20 @@ struct CoachContextPacket: Encodable { var value: String var importance: Int } + + /// City-level location + current/forecast weather. City-only privacy: never a + /// street, never coordinates. Any field may be nil when weather degrades to a + /// city-only or stale result. + struct EnvironmentContext: Encodable { + var city: String? + var region: String? + var tempC: Double? + var condition: String? + var highC: Double? + var lowC: Double? + var precipitationChancePct: Int? + var sunrise: String? + var sunset: String? + var asOf: String + } } diff --git a/PulseLoop/Coach/Context/CoachEnvironmentContextService.swift b/PulseLoop/Coach/Context/CoachEnvironmentContextService.swift new file mode 100644 index 0000000..0ba6a8a --- /dev/null +++ b/PulseLoop/Coach/Context/CoachEnvironmentContextService.swift @@ -0,0 +1,269 @@ +import Foundation +import UIKit +@preconcurrency import CoreLocation +import WeatherKit + +/// Opt-in city-level location + weather for the coach. Produces a +/// `CoachContextPacket.EnvironmentContext` so coaching can ground practical advice +/// (outdoor vs indoor, hydration on hot days, planning around rain) without ever +/// exposing the user's precise location — only the reverse-geocoded city/region. +/// +/// Privacy + resilience by design: +/// - Coarse one-shot location (`kCLLocationAccuracyThreeKilometers`), fetched only +/// when the app is active AND authorized; background (BGTask) callers use the +/// persisted cache so a fresh process never triggers a location prompt. +/// - Raw coordinates NEVER enter the packet — only city/region from `CLGeocoder`. +/// - Weather runs in do/catch; ANY failure (missing entitlement in forks, airplane +/// mode) degrades to city-only or a stale cached weather (≤3h). `snapshot` never throws. +@MainActor +final class CoachEnvironmentContextService: NSObject, CLLocationManagerDelegate { + static let shared = CoachEnvironmentContextService() + + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + private let manager = CLLocationManager() + private let weather = WeatherService.shared + private let defaults: UserDefaults + private static let cacheKey = "pulseloop.coach.environment.v1" + + /// Weather is re-fetched after this; the city/coords are kept much longer. + private let weatherTTL: TimeInterval = 30 * 60 + /// City + coordinates cache lifetime (people don't move cities minute-to-minute). + private let locationTTL: TimeInterval = 6 * 3600 + /// Oldest weather we'll still surface when a fresh fetch fails. + private let staleWeatherWindow: TimeInterval = 3 * 3600 + /// After a failed fetch, don't retry weather for this long — a persistent + /// failure (e.g. WeatherKit not enabled on the App ID) would otherwise be + /// re-attempted on every coach turn and summary refresh. + private let weatherRetryCooldown: TimeInterval = 5 * 60 + + private var cache: Cached? + private var lastWeatherFailureAt: Date? + private var locationContinuation: CheckedContinuation? + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyThreeKilometers + cache = loadCache() + } + + // MARK: - Public API + + /// Ask for When-In-Use authorization when the user enables the toggle. No-op + /// once a decision exists (the OS won't re-prompt anyway). + func requestPermissionIfNeeded() { + if manager.authorizationStatus == .notDetermined { + manager.requestWhenInUseAuthorization() + } + } + + /// Build the environment context, or nil when the toggle is off. Never throws: + /// weather failures degrade to city-only or a stale cached reading. + func snapshot(now: Date = Date()) async -> CoachContextPacket.EnvironmentContext? { + guard CoachSettingsStore.shared.settings.enableEnvironmentContext else { return nil } + + // Resolve a coordinate: a fresh coarse fix (foreground + authorized), else the cache. + let coordinate = await resolveCoordinate(now: now) + guard let coordinate else { + // No usable location at all — surface a stale cache if we have one. + return cache?.environment(now: now, staleWeatherWindow: staleWeatherWindow) + } + + // City/region: reuse the cached geocode when the coordinate is close and fresh; else reverse-geocode. + let place = await resolvePlace(for: coordinate, now: now) + + // Weather: reuse cached weather within TTL, else fetch; degrade to city-only / stale on error. + if let cached = cache, cached.isWeatherFresh(now: now, ttl: weatherTTL) { + return cached.environment(now: now, staleWeatherWindow: staleWeatherWindow, overridePlace: place) + } + if let failedAt = lastWeatherFailureAt, now.timeIntervalSince(failedAt) < weatherRetryCooldown { + return degraded(place: place, now: now) + } + + do { + let weatherData = try await weather.weather(for: CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude), including: .current, .daily) + let env = makeContext(place: place, current: weatherData.0, daily: weatherData.1, now: now) + store(Cached(coordinate: coordinate, place: place, weather: env, weatherAt: now, locationAt: now), now: now) + lastWeatherFailureAt = nil + return env + } catch { + lastWeatherFailureAt = now + return degraded(place: place, now: now) + } + } + + /// City-only, or a stale cached weather (≤3h) with the current city. + private func degraded(place: Place?, now: Date) -> CoachContextPacket.EnvironmentContext? { + cache?.environment(now: now, staleWeatherWindow: staleWeatherWindow, overridePlace: place) + ?? CoachContextPacket.EnvironmentContext(city: place?.city, region: place?.region, asOf: CoachDataAccess.isoString(now)) + } + + // MARK: - Location + + private func resolveCoordinate(now: Date) async -> CLLocationCoordinate2D? { + let authorized = manager.authorizationStatus == .authorizedWhenInUse || manager.authorizationStatus == .authorizedAlways + // Only fetch fresh location when the app is active AND authorized. In the + // background (BGTask path) use the persisted cache only. + if authorized, UIApplication.shared.applicationState != .background { + if let fix = await requestOneShotLocation() { + return fix.coordinate + } + } + if let cached = cache, now.timeIntervalSince(cached.locationAt) < locationTTL { + return cached.coordinate + } + return nil + } + + private func requestOneShotLocation() async -> CLLocation? { + if let existing = locationContinuation { + existing.resume(returning: nil) // never leave a prior continuation dangling + locationContinuation = nil + } + return await withCheckedContinuation { (continuation: CheckedContinuation) in + locationContinuation = continuation + manager.requestLocation() + } + } + + private func resolvePlace(for coordinate: CLLocationCoordinate2D, now: Date) async -> Place? { + if let cached = cache, now.timeIntervalSince(cached.locationAt) < locationTTL, + cached.coordinate.isNear(coordinate), let place = cached.place { + return place + } + let geocoder = CLGeocoder() + let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) + guard let placemark = try? await geocoder.reverseGeocodeLocation(location).first else { + return cache?.place + } + return Place(city: placemark.locality, region: placemark.administrativeArea) + } + + // MARK: - CLLocationManagerDelegate + + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + let fix = locations.last + MainActor.assumeIsolated { + locationContinuation?.resume(returning: fix) + locationContinuation = nil + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + MainActor.assumeIsolated { + locationContinuation?.resume(returning: nil) + locationContinuation = nil + } + } + + // MARK: - Weather → context + + private func makeContext(place: Place?, current: CurrentWeather, daily: Forecast, now: Date) -> CoachContextPacket.EnvironmentContext { + let today = daily.forecast.first + let iso = ISO8601DateFormatter() + return CoachContextPacket.EnvironmentContext( + city: place?.city, + region: place?.region, + tempC: current.temperature.converted(to: .celsius).value, + condition: current.condition.description, + highC: today?.highTemperature.converted(to: .celsius).value, + lowC: today?.lowTemperature.converted(to: .celsius).value, + precipitationChancePct: today.map { Int(($0.precipitationChance * 100).rounded()) }, + sunrise: today?.sun.sunrise.map { iso.string(from: $0) }, + sunset: today?.sun.sunset.map { iso.string(from: $0) }, + asOf: CoachDataAccess.isoString(now) + ) + } + + // MARK: - Cache (in-memory + UserDefaults) + + private func store(_ cached: Cached, now: Date) { + cache = cached + if let data = try? JSONEncoder().encode(cached) { + defaults.set(data, forKey: Self.cacheKey) + } + } + + private func loadCache() -> Cached? { + guard let data = defaults.data(forKey: Self.cacheKey) else { return nil } + return try? JSONDecoder().decode(Cached.self, from: data) + } + + // MARK: - Supporting types + + struct Place: Codable { + var city: String? + var region: String? + } + + private struct Cached: Codable { + var lat: Double + var lon: Double + var place: Place? + var weather: CachedWeather? + var weatherAt: Date + var locationAt: Date + + init(coordinate: CLLocationCoordinate2D, place: Place?, weather env: CoachContextPacket.EnvironmentContext?, weatherAt: Date, locationAt: Date) { + self.lat = coordinate.latitude + self.lon = coordinate.longitude + self.place = place + self.weather = env.map(CachedWeather.init) + self.weatherAt = weatherAt + self.locationAt = locationAt + } + + var coordinate: CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: lat, longitude: lon) } + + func isWeatherFresh(now: Date, ttl: TimeInterval) -> Bool { + weather != nil && now.timeIntervalSince(weatherAt) < ttl + } + + /// Rebuild the packet context from cache, dropping weather that's older than the stale window. + func environment(now: Date, staleWeatherWindow: TimeInterval, + overridePlace: Place? = nil) -> CoachContextPacket.EnvironmentContext? { + let place = overridePlace ?? place + let weatherIsUsable = weather != nil && now.timeIntervalSince(weatherAt) <= staleWeatherWindow + if let weather, weatherIsUsable { + return weather.context(city: place?.city, region: place?.region, asOf: CoachDataAccess.isoString(weatherAt)) + } + // No usable weather — city-only (or nil if we don't even have a city). + guard place?.city != nil || place?.region != nil else { return nil } + return CoachContextPacket.EnvironmentContext(city: place?.city, region: place?.region, asOf: CoachDataAccess.isoString(now)) + } + } + + private struct CachedWeather: Codable { + var tempC: Double? + var condition: String? + var highC: Double? + var lowC: Double? + var precipitationChancePct: Int? + var sunrise: String? + var sunset: String? + + init(_ env: CoachContextPacket.EnvironmentContext) { + tempC = env.tempC; condition = env.condition + highC = env.highC; lowC = env.lowC + precipitationChancePct = env.precipitationChancePct + sunrise = env.sunrise; sunset = env.sunset + } + + func context(city: String?, region: String?, asOf: String) -> CoachContextPacket.EnvironmentContext { + CoachContextPacket.EnvironmentContext( + city: city, region: region, tempC: tempC, condition: condition, + highC: highC, lowC: lowC, precipitationChancePct: precipitationChancePct, + sunrise: sunrise, sunset: sunset, asOf: asOf + ) + } + } +} + +private extension CLLocationCoordinate2D { + /// Within ~1km — close enough to reuse the same city geocode. + func isNear(_ other: CLLocationCoordinate2D) -> Bool { + abs(latitude - other.latitude) < 0.01 && abs(longitude - other.longitude) < 0.01 + } +} diff --git a/PulseLoop/Coach/Context/CoachPromptBuilder.swift b/PulseLoop/Coach/Context/CoachPromptBuilder.swift index fbfe829..c009d30 100644 --- a/PulseLoop/Coach/Context/CoachPromptBuilder.swift +++ b/PulseLoop/Coach/Context/CoachPromptBuilder.swift @@ -17,6 +17,7 @@ enum CoachPromptBuilder { - Report all measurements (distance, weight, height, temperature) in the user's preferred units from the context packet profile (units "metric" → km/kg/cm/°C; "imperial" → mi/lb/in/°F), converting from the data tools' values, and state the unit explicitly. - Ground personal claims in the user's actual app data, retrieved via tools. - If data is sparse, say so clearly. Never pretend missing data exists. + - Set `safety_note` and `data_quality_note` to null by default. Use safety_note only for a genuine safety concern (worrying symptoms, clearly abnormal vitals, medical-adjacent advice) and data_quality_note only when a data gap materially changes the answer. Most responses need neither — never restate generic wellness-grade or sparse-data caveats in them. - Do not diagnose medical conditions. Use cautious language for health interpretations. - For chest pain, fainting, trouble breathing, persistent abnormal values, or very low SpO2, advise seeking professional care. - Use tools instead of guessing whenever the user asks about their data. @@ -24,6 +25,7 @@ enum CoachPromptBuilder { - For a heart-rate or SpO2 trend within a single day, use granularity "raw" (or "hour") so the chart shows the readings across the day — not a single daily-average point. Use "day" only for multi-day comparisons. - Prefer compact retrieval first; use the analysis tools (analyze_trend, compare_periods, compute_correlation, detect_outliers, summarize_distribution) only when a simple summary is not enough. - Use web search only for external/general knowledge questions, never to interpret the user's own readings. When web search is used, cite sources, and keep claims grounded in the user's own readings (e.g. "your ring data shows X") clearly separate from general guidance. + - When the context packet includes an `environment` block (the user's city + current weather), use it to ground practical suggestions — outdoor vs indoor workouts, hydration on hot days, planning around rain. If web search is enabled you may search local conditions (air quality, pollen). Never reference the user's location more precisely than the city. - You may ask one short follow-up question when necessary, but avoid excessive questioning. - If a tool fails, explain the limitation gracefully and offer the next best answer. @@ -43,10 +45,31 @@ enum CoachPromptBuilder { Return only the structured JSON matching the coach_response schema. Do not include hidden reasoning. """ - /// Developer message embedding the context packet + rolling summary. - static func developerMessage(packet: CoachContextPacket) -> String { + /// Compact system prompt for the tool-less on-device model. Keeps the load- + /// bearing guarantees — identity, units rule, ground-in-real-data, medical + /// caution, JSON-only output — and drops the tool/chart/action guidance and the + /// long data-limitation prose that the on-device path can't act on anyway. + static let systemPromptCompact = """ + You are PulseLoop Coach, an evidence-grounded health and fitness coach for a smart-ring app. + + - Be conversational, concise, warm, and specific; address the user by name when known. + - Report measurements in the user's preferred units from the profile (metric → km/kg/cm/°C; imperial → mi/lb/in/°F) and state the unit. + - Ground every personal claim in the user's actual data from the context packet. If data is sparse, say so — never invent readings. + - Do not diagnose. Use cautious language, and for chest pain, fainting, trouble breathing, or very low SpO2, advise seeking professional care. + - Leave safety_note and data_quality_note null unless there is a genuine safety concern or a data gap that changes the answer. + + Return only the structured JSON matching the coach_response schema. No prose, no hidden reasoning. + """ + + /// Developer message embedding the context packet + rolling summary. The budget + /// only changes what the caller packed into `packet`; the message text is the + /// same, minus the tool guidance the compact (tool-less) path can't use. + static func developerMessage(packet: CoachContextPacket, budget: CoachContextBudget = .full) -> String { let json = encodePacket(packet) let summary = packet.conversationSummary ?? "(no prior summary)" + let guidance = budget == .compact + ? "Answer from the context packet above. Today's date and the user's timezone are in the packet." + : "Use the provided tools to retrieve, analyze, chart, search, or act. Prefer compact retrieval first, then deeper analysis only if needed. Today's date and the user's timezone are in the context packet." return """ Current context packet: \(json) @@ -54,7 +77,7 @@ enum CoachPromptBuilder { Conversation summary: \(summary) - Use the provided tools to retrieve, analyze, chart, search, or act. Prefer compact retrieval first, then deeper analysis only if needed. Today's date and the user's timezone are in the context packet. + \(guidance) """ } // swiftlint:enable line_length diff --git a/PulseLoop/Coach/Gemini/GeminiClient.swift b/PulseLoop/Coach/Gemini/GeminiClient.swift index e6adef7..781bfa9 100644 --- a/PulseLoop/Coach/Gemini/GeminiClient.swift +++ b/PulseLoop/Coach/Gemini/GeminiClient.swift @@ -363,7 +363,20 @@ final class GeminiClient: ResponsesClient, @unchecked Sendable { if outputItems.isEmpty { throw ResponsesError.emptyOutput } storedModelParts[responseId] = modelParts - return OpenAIResponse(id: responseId, outputItems: outputItems) + return OpenAIResponse(id: responseId, outputItems: outputItems, usage: usage(from: root)) + } + + /// Maps Gemini's `usageMetadata` to the app's usage struct. Output tokens count + /// both the answer (`candidatesTokenCount`) and any thinking tokens + /// (`thoughtsTokenCount`); cached context is `cachedContentTokenCount`. + private func usage(from root: [String: Any]) -> CoachTokenUsage? { + guard let meta = root["usageMetadata"] as? [String: Any] else { return nil } + let output = (meta["candidatesTokenCount"] as? Int ?? 0) + (meta["thoughtsTokenCount"] as? Int ?? 0) + return CoachTokenUsage( + inputTokens: meta["promptTokenCount"] as? Int ?? 0, + outputTokens: output, + cachedInputTokens: meta["cachedContentTokenCount"] as? Int ?? 0 + ) } /// Extracts cited pages from a candidate's `groundingMetadata` as diff --git a/PulseLoop/Coach/MiniMax/MiniMaxClient.swift b/PulseLoop/Coach/MiniMax/MiniMaxClient.swift index 1e9c501..4eb8014 100644 --- a/PulseLoop/Coach/MiniMax/MiniMaxClient.swift +++ b/PulseLoop/Coach/MiniMax/MiniMaxClient.swift @@ -246,7 +246,18 @@ final class MiniMaxClient: ResponsesClient, @unchecked Sendable { if outputItems.isEmpty { throw ResponsesError.emptyOutput } storedAssistantMessage[responseId] = assistantMessage - return OpenAIResponse(id: responseId, outputItems: outputItems) + return OpenAIResponse(id: responseId, outputItems: outputItems, usage: usage(from: root)) + } + + /// Maps MiniMax's `usage` block. Only the prompt/completion split is used; if + /// MiniMax reports just `total_tokens` (no split), usage stays `nil` rather than + /// mis-attributing the total to either side. + private func usage(from root: [String: Any]) -> CoachTokenUsage? { + guard let usage = root["usage"] as? [String: Any] else { return nil } + guard let input = usage["prompt_tokens"] as? Int, let output = usage["completion_tokens"] as? Int else { + return nil + } + return CoachTokenUsage(inputTokens: input, outputTokens: output) } /// Removes `` reasoning blocks (and any leading whitespace they diff --git a/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift b/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift index c9da420..4f6c25e 100644 --- a/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift +++ b/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift @@ -10,13 +10,15 @@ enum CoachNotificationGenerator { slot: CoachNotificationSlot, packet: NotificationContextPacket, flags: CoachFeatureFlags, - client: ResponsesClient + client: ResponsesClient, + angle: String = "", + recentTexts: [String] = [] ) async -> CoachNotification { guard flags.coachEnabled else { return scripted(slot: slot, packet: packet) } do { let input: [[String: Any]] = [ OpenAIRequestBuilder.message(role: "system", content: NotificationPromptBuilder.systemPrompt(slot: slot)), - OpenAIRequestBuilder.message(role: "developer", content: NotificationPromptBuilder.developerMessage(packet: packet)), + OpenAIRequestBuilder.message(role: "developer", content: NotificationPromptBuilder.developerMessage(packet: packet, angle: angle, recentTexts: recentTexts)), ] let body = try OpenAIRequestBuilder.data( model: flags.model, input: input, tools: [], diff --git a/PulseLoop/Coach/Notifications/CoachNotificationScheduler.swift b/PulseLoop/Coach/Notifications/CoachNotificationScheduler.swift index 2874374..eb6a9b8 100644 --- a/PulseLoop/Coach/Notifications/CoachNotificationScheduler.swift +++ b/PulseLoop/Coach/Notifications/CoachNotificationScheduler.swift @@ -78,7 +78,13 @@ final class CoachNotificationScheduler { return } let work = Task { - _ = await service.runDueSlot() + let outcome = await service.runDueSlot() + // Morning skipped because last night's sleep hasn't synced yet — queue one + // short retry so we catch the check-in once the sync lands (instead of + // waiting for the next full window). + if outcome == .skippedNoSleepData { + submitSleepRetry() + } // Background is a good moment to also catch a proactive alert; the // call self-gates (on-device only, enabled, deduped). _ = await service.runProactiveAlertIfNeeded() @@ -86,4 +92,14 @@ final class CoachNotificationScheduler { } task.expirationHandler = { work.cancel() } } + + /// One +45min app-refresh wake to retry a morning check-in that was skipped + /// because last night's sleep hadn't synced. Best-effort; the foreground + /// catch-up covers the case where the retry doesn't run. + private func submitSleepRetry() { + guard isRegistered else { return } + let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier) + request.earliestBeginDate = Date().addingTimeInterval(45 * 60) + try? BGTaskScheduler.shared.submit(request) + } } diff --git a/PulseLoop/Coach/Notifications/CoachNotificationService.swift b/PulseLoop/Coach/Notifications/CoachNotificationService.swift index 409f318..64a23e5 100644 --- a/PulseLoop/Coach/Notifications/CoachNotificationService.swift +++ b/PulseLoop/Coach/Notifications/CoachNotificationService.swift @@ -14,6 +14,10 @@ final class CoachNotificationService { case skippedDuplicate case skippedDisabled case skippedNoData + /// The morning slot was due but last night's sleep hasn't fully synced yet. + /// Deliberately NOT recorded, so a foreground catch-up (or a scheduled +45min + /// retry) can fire it once the sync lands. + case skippedNoSleepData /// The model decided there was nothing worth interrupting the user for. case skippedAdaptive /// A proactive alert fired for a detected anomaly. @@ -90,6 +94,11 @@ final class CoachNotificationService { let flags = CoachFeatureFlags(settings: settings, hasAPIKey: apiKey != nil) guard force || flags.coachEnabled else { return .skippedDisabled } + // Morning-only: don't fire until last night's sleep has fully synced — otherwise + // the check-in leads with partial/absent sleep. Skip WITHOUT recording so a + // foreground catch-up (or a +45min BGAppRefresh retry) can fire it once synced. + if !force, slot == .morning, !sleepDataSynced(now: now) { return .skippedNoSleepData } + if !force { let fresh = await ensureFreshData(now: now) if Task.isCancelled { return .skippedNoData } // BGTask expired mid-wait @@ -100,9 +109,13 @@ final class CoachNotificationService { } } - let packet = NotificationContextBuilder.build(slot: slot, context: modelContext, now: now) + let environment = await CoachEnvironmentContextService.shared.snapshot(now: now) + let packet = NotificationContextBuilder.build(slot: slot, context: modelContext, now: now, environment: environment) + let angle = CoachVarietyHints.angle(seed: CoachNotificationRecord.dateKey(for: now) + slot.rawValue) + let recentTexts = recentNotificationTexts() let notification = await CoachNotificationGenerator.generate( - slot: slot, packet: packet, flags: flags, client: activeClient + slot: slot, packet: packet, flags: flags, client: activeClient, + angle: angle, recentTexts: recentTexts ) // Adaptive skip: when the model says there's nothing useful to add, stay // quiet (but the test button still forces a delivery). @@ -129,7 +142,8 @@ final class CoachNotificationService { } let slot = forcedSlot(now: now) // only for building the context packet - let packet = NotificationContextBuilder.build(slot: slot, context: modelContext, now: now) + let environment = await CoachEnvironmentContextService.shared.snapshot(now: now) + let packet = NotificationContextBuilder.build(slot: slot, context: modelContext, now: now, environment: environment) guard let anomaly = CoachAnomalyDetector.detect(packet) else { return .noAnomaly } if !force, isAnomalyDuplicate(anomaly, now: now) { return .noAnomaly } @@ -164,6 +178,29 @@ final class CoachNotificationService { return ((try? modelContext.fetch(descriptor)) ?? []).isEmpty == false } + /// Whether last night's sleep is safe to summarize in the morning check-in: + /// a full history sync must have completed at/after the night ended. Passes + /// permissively when there's no last-night session at all (nothing to wait for) + /// or no device / full-sync stamp (streaming devices that never run a paged + /// history sync) — the general freshness gate still guards those. + func sleepDataSynced(now: Date = Date()) -> Bool { + guard let session = SleepRepository.latestSession(context: modelContext) else { return true } + // Only gate on a *recent* night; an old session (nothing newer) shouldn't block today. + let staleCutoff = now.addingTimeInterval(-36 * 3600) + guard session.endAt >= staleCutoff else { return true } + guard let fullSync = DeviceRepository.current(context: modelContext)?.lastFullSyncAt else { return true } + return fullSync >= session.endAt + } + + /// The last 6 check-ins ("title — body") so the generator can avoid repeating + /// their phrasing/openings. Newest first. + private func recentNotificationTexts(limit: Int = 6) -> [String] { + var descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.createdAt, order: .reverse)]) + descriptor.fetchLimit = limit + let rows = (try? modelContext.fetch(descriptor)) ?? [] + return rows.map { "\($0.title) — \($0.body)" } + } + func hasRecentData(now: Date) -> Bool { let cutoff = now.addingTimeInterval(-freshnessWindow) // Gate on the last *completed history sync*, not `lastSyncAt` — the latter is re-stamped on diff --git a/PulseLoop/Coach/Notifications/NotificationContextBuilder.swift b/PulseLoop/Coach/Notifications/NotificationContextBuilder.swift index 352967e..d816fbe 100644 --- a/PulseLoop/Coach/Notifications/NotificationContextBuilder.swift +++ b/PulseLoop/Coach/Notifications/NotificationContextBuilder.swift @@ -22,12 +22,14 @@ struct NotificationContextPacket: Encodable { var recentWorkouts: [CoachContextPacket.WorkoutContext] var memories: [CoachContextPacket.MemoryContext] var dataQualityWarnings: [String] + var environment: CoachContextPacket.EnvironmentContext? } @MainActor enum NotificationContextBuilder { static func build( - slot: CoachNotificationSlot, context: ModelContext, now: Date = Date() + slot: CoachNotificationSlot, context: ModelContext, now: Date = Date(), + environment: CoachContextPacket.EnvironmentContext? = nil ) -> NotificationContextPacket { let packet = CoachContextBuilder.build(context: context, now: now) let cutoff = now.addingTimeInterval(-12 * 3600) @@ -51,7 +53,8 @@ enum NotificationContextBuilder { spo2Last12h: CoachDataAccess.stats(spo2), recentWorkouts: packet.recentWorkouts, memories: packet.memories, - dataQualityWarnings: packet.dataQualityWarnings + dataQualityWarnings: packet.dataQualityWarnings, + environment: environment ) } } diff --git a/PulseLoop/Coach/Notifications/NotificationPromptBuilder.swift b/PulseLoop/Coach/Notifications/NotificationPromptBuilder.swift index 20081da..c18a91a 100644 --- a/PulseLoop/Coach/Notifications/NotificationPromptBuilder.swift +++ b/PulseLoop/Coach/Notifications/NotificationPromptBuilder.swift @@ -31,17 +31,25 @@ enum NotificationPromptBuilder { - Be warm and engaging, like a thoughtful coach. At most one emoji, and only if it fits. - Ground every claim in the provided data. If data is thin, keep it light and honest; never invent numbers. - No medical diagnosis or alarming language. Wellness tone only. + - When an `environment` block (city + weather) is present, actively consider it when shaping the check-in (outdoor vs indoor, timing around rain, hydration). + - If conditions are extreme (very hot, very cold, storms, heavy rain), call it out with one practical adjustment — hydrate more, layer up, or move indoors. + - Never name a location finer than the city. + - A coaching angle and your recent check-ins are provided — vary your voice and structure; never open two check-ins the same way. """ } - static func developerMessage(packet: NotificationContextPacket) -> String { + static func developerMessage(packet: NotificationContextPacket, angle: String = "", recentTexts: [String] = []) -> String { let json = encode(packet) - return """ - Context (last ~12 hours): - \(json) - - Write a fresh \(packet.slot) check-in now as {"title","body","tip","followUp","skip"}. - """ + var blocks = ["Context (last ~12 hours):\n\(json)"] + if !angle.isEmpty { + blocks.append("Coaching angle for this check-in (take it unless the data makes it a poor fit): \(angle)") + } + if !recentTexts.isEmpty { + let list = recentTexts.map { "- \($0)" }.joined(separator: "\n") + blocks.append("Your most recent check-ins — do NOT repeat their phrasing, openings, or structure:\n\(list)") + } + blocks.append("Write a fresh \(packet.slot) check-in now as {\"title\",\"body\",\"tip\",\"followUp\",\"skip\"}.") + return blocks.joined(separator: "\n\n") } // MARK: - Proactive anomaly alerts diff --git a/PulseLoop/Coach/OpenAI/ResponsesTypes.swift b/PulseLoop/Coach/OpenAI/ResponsesTypes.swift index 740f5bb..9231cac 100644 --- a/PulseLoop/Coach/OpenAI/ResponsesTypes.swift +++ b/PulseLoop/Coach/OpenAI/ResponsesTypes.swift @@ -19,6 +19,9 @@ struct ResponseFunctionCall: Sendable { struct OpenAIResponse: Sendable { let id: String let outputItems: [ResponseOutputItem] + /// Token usage for this call, when the provider reports it. `nil` for + /// providers that don't (Apple on-device) or turns that omit the usage block. + var usage: CoachTokenUsage? = nil var functionCalls: [ResponseFunctionCall] { outputItems.compactMap { if case .functionCall(let fc) = $0 { return fc } else { return nil } } @@ -61,7 +64,20 @@ struct OpenAIResponse: Sendable { items.append(.other(type: type)) } } - return OpenAIResponse(id: id, outputItems: items) + return OpenAIResponse(id: id, outputItems: items, usage: parseUsage(root["usage"] as? [String: Any])) + } + + /// Reads the Responses-API `usage` block: `input_tokens` / `output_tokens`, + /// with cached input under `input_tokens_details.cached_tokens`. Returns `nil` + /// when the block is absent so callers keep their default. + private static func parseUsage(_ usage: [String: Any]?) -> CoachTokenUsage? { + guard let usage else { return nil } + let cached = (usage["input_tokens_details"] as? [String: Any])?["cached_tokens"] as? Int ?? 0 + return CoachTokenUsage( + inputTokens: usage["input_tokens"] as? Int ?? 0, + outputTokens: usage["output_tokens"] as? Int ?? 0, + cachedInputTokens: cached + ) } private static func extractText(_ messageItem: [String: Any]) -> String { diff --git a/PulseLoop/Coach/OpenRouter/OpenRouterClient.swift b/PulseLoop/Coach/OpenRouter/OpenRouterClient.swift index 28c301a..1374cbf 100644 --- a/PulseLoop/Coach/OpenRouter/OpenRouterClient.swift +++ b/PulseLoop/Coach/OpenRouter/OpenRouterClient.swift @@ -222,6 +222,11 @@ final class OpenRouterClient: ResponsesClient, @unchecked Sendable { body["provider"] = provider } + // Ask OpenRouter to include the usage accounting (token counts + the exact + // upstream `cost` in USD) in the response, so the persisted cost matches the + // Activity page rather than a catalog estimate. + body["usage"] = ["include": true] + return body } @@ -342,6 +347,21 @@ final class OpenRouterClient: ResponsesClient, @unchecked Sendable { if outputItems.isEmpty { throw ResponsesError.emptyOutput } storedAssistantMessage[responseId] = assistantMessage - return OpenAIResponse(id: responseId, outputItems: outputItems) + return OpenAIResponse(id: responseId, outputItems: outputItems, usage: usage(from: root)) + } + + /// Maps OpenRouter's Chat Completions `usage` block. `cost` is the exact USD + /// OpenRouter billed for the call (requested via `usage.include` in the body), + /// so it's stored as the reported cost instead of a catalog estimate. + private func usage(from root: [String: Any]) -> CoachTokenUsage? { + guard let usage = root["usage"] as? [String: Any] else { return nil } + let cached = (usage["prompt_tokens_details"] as? [String: Any])?["cached_tokens"] as? Int ?? 0 + let cost = (usage["cost"] as? Double) ?? (usage["cost"] as? NSNumber)?.doubleValue + return CoachTokenUsage( + inputTokens: usage["prompt_tokens"] as? Int ?? 0, + outputTokens: usage["completion_tokens"] as? Int ?? 0, + cachedInputTokens: cached, + reportedCostUSD: cost + ) } } diff --git a/PulseLoop/Coach/Orchestration/CoachFallbacks.swift b/PulseLoop/Coach/Orchestration/CoachFallbacks.swift index a5f6317..41ad68f 100644 --- a/PulseLoop/Coach/Orchestration/CoachFallbacks.swift +++ b/PulseLoop/Coach/Orchestration/CoachFallbacks.swift @@ -9,8 +9,7 @@ enum CoachFallbacks { CoachResponse( responseType: .errorRecovery, title: "I had trouble with that", - summary: "I checked your data but couldn't finish preparing the answer. Try asking again, or narrow the question.", - dataQualityNote: "No changes were made.", + summary: "I checked your data but couldn't finish preparing the answer. No changes were made — try asking again, or narrow the question.", followUpChips: ["How am I doing today?", "Summarize my week", "What data is missing?"], confidence: .low ) @@ -26,7 +25,6 @@ enum CoachFallbacks { title: "No activity synced yet", // swiftlint:disable:next line_length summary: "I don't have today's activity from the ring yet. Sync the ring or take a measurement and I'll summarize what comes in. (The AI coach is off — add an OpenAI key in Settings to enable full coaching.)", - dataQualityNote: packet.dataQualityWarnings.first, followUpChips: ["Is my ring connected?", "What data is missing?"], confidence: .low ) @@ -40,7 +38,6 @@ enum CoachFallbacks { title: "Here's where you are today", summary: "You're at \(steps) steps so far today. The AI coach is off — add an OpenAI key in Settings for trends and tailored guidance.", bullets: bullets, - dataQualityNote: packet.dataQualityWarnings.last, followUpChips: ["How does today compare to yesterday?", "What's my heart rate trend?"], confidence: today.dataConfidence == "high" ? .medium : .low ) diff --git a/PulseLoop/Coach/Orchestration/CoachOrchestrator.swift b/PulseLoop/Coach/Orchestration/CoachOrchestrator.swift index 9b37fdf..d801977 100644 --- a/PulseLoop/Coach/Orchestration/CoachOrchestrator.swift +++ b/PulseLoop/Coach/Orchestration/CoachOrchestrator.swift @@ -18,11 +18,29 @@ struct CoachOrchestrator { let assistant: CoachResponse let trace: [CoachToolCallTrace] var pendingActions: [PendingAction] = [] + /// Activity sessions created/edited during this turn (immediate writes). + /// Drives the in-chat workout card. + var loggedActivityIds: [UUID] = [] + /// Summed token usage across every model call in the turn (initial send + + /// tool-loop rounds + JSON-repair sends). `nil` when no call reported usage + /// (e.g. Apple on-device) or the turn never reached the client. + var usage: CoachTokenUsage? = nil /// Set when the turn failed; surfaced as a red error bubble instead of the /// `assistant` fallback. `nil` on success. var error: CoachTurnError? = nil } + /// Reference accumulator so a single instance sums usage across every `send` + /// in a turn regardless of which code path (initial / tool-loop / repair) made + /// the call. `nil` stays `nil` until at least one call reports usage. + private final class UsageTally { + var total: CoachTokenUsage? + func add(_ usage: CoachTokenUsage?) { + guard let usage else { return } + if total == nil { total = usage } else { total?.add(usage) } + } + } + struct PriorMessage { let role: String; let text: String; var images: [CoachImagePayload] = [] } /// Substituted as the user prompt when an image is sent with no text, so the @@ -56,12 +74,15 @@ struct CoachOrchestrator { ) async throws -> TurnResult { let toolSpecs = registry.toolSpecs let textFormat = CoachResponseSchema.textFormat + let tally = UsageTally() // Initial input: system + developer + recent turns + the new user message. // Images only ever ride on user turns (system/developer/assistant stay text). + let budget = flags.contextBudget + let systemPrompt = budget == .compact ? CoachPromptBuilder.systemPromptCompact : CoachPromptBuilder.systemPrompt var input: [[String: Any]] = [ - OpenAIRequestBuilder.message(role: "system", content: CoachPromptBuilder.systemPrompt), - OpenAIRequestBuilder.message(role: "developer", content: CoachPromptBuilder.developerMessage(packet: packet)), + OpenAIRequestBuilder.message(role: "system", content: systemPrompt), + OpenAIRequestBuilder.message(role: "developer", content: CoachPromptBuilder.developerMessage(packet: packet, budget: budget)), ] for m in recentMessages { let isUser = m.role == "user" @@ -75,7 +96,7 @@ struct CoachOrchestrator { onTrace(CoachTraceEvent(label: "Thinking about your question…", status: .thinking)) - var response = try await send(input: input, tools: toolSpecs, textFormat: textFormat, previousResponseId: nil) + var response = try await send(input: input, tools: toolSpecs, textFormat: textFormat, previousResponseId: nil, tally: tally) noteWebSearch(response, onTrace: onTrace) var trace: [CoachToolCallTrace] = [] @@ -123,20 +144,22 @@ struct CoachOrchestrator { rounds += 1 onTrace(CoachTraceEvent(label: "Putting it together…", status: .writingAnswer)) - response = try await send(input: outputs, tools: toolSpecs, textFormat: textFormat, previousResponseId: response.id) + response = try await send(input: outputs, tools: toolSpecs, textFormat: textFormat, previousResponseId: response.id, tally: tally) noteWebSearch(response, onTrace: onTrace) } do { - let assistant = try await parseFinal(response, textFormat: textFormat) + let assistant = try await parseFinal(response, textFormat: textFormat, tally: tally) onTrace(CoachTraceEvent(label: "", status: .done)) - return TurnResult(assistant: assistant, trace: trace, pendingActions: toolContext.pendingActions) + return TurnResult( + assistant: assistant, trace: trace, pendingActions: toolContext.pendingActions, + loggedActivityIds: toolContext.loggedActivityIds, usage: tally.total) } catch let parseError as ParseExhausted { // The model never produced valid coach_response JSON. Surface it as an // error bubble, but keep the trace from the tools that did run. onTrace(CoachTraceEvent(label: "Couldn't format the answer", status: .failedTool)) return TurnResult( - assistant: CoachFallbacks.fallback(), trace: trace, + assistant: CoachFallbacks.fallback(), trace: trace, usage: tally.total, error: CoachTurnError(code: "Bad response", reason: parseError.reason)) } } @@ -147,7 +170,7 @@ struct CoachOrchestrator { // MARK: - Final parse + repair - private func parseFinal(_ response: OpenAIResponse, textFormat: [String: Any]) async throws -> CoachResponse { + private func parseFinal(_ response: OpenAIResponse, textFormat: [String: Any], tally: UsageTally) async throws -> CoachResponse { var current = response var attempts = 1 while true { @@ -162,19 +185,21 @@ struct CoachOrchestrator { let repair = OpenAIRequestBuilder.message( role: "user", content: "Your previous output did not match the required coach_response JSON schema. Return only valid JSON for that schema now.") - current = try await send(input: [repair], tools: [], textFormat: textFormat, previousResponseId: current.id) + current = try await send(input: [repair], tools: [], textFormat: textFormat, previousResponseId: current.id, tally: tally) } } // MARK: - Helpers private func send( - input: [[String: Any]], tools: [[String: Any]], textFormat: [String: Any], previousResponseId: String? + input: [[String: Any]], tools: [[String: Any]], textFormat: [String: Any], previousResponseId: String?, tally: UsageTally ) async throws -> OpenAIResponse { let body = try OpenAIRequestBuilder.data( model: flags.model, input: input, tools: tools, textFormat: textFormat, previousResponseId: previousResponseId, reasoningEffort: flags.settings.reasoningEffort) - return try await client.send(requestBody: body) + let response = try await client.send(requestBody: body) + tally.add(response.usage) + return response } private func noteWebSearch(_ response: OpenAIResponse, onTrace: (CoachTraceEvent) -> Void) { diff --git a/PulseLoop/Coach/Schema/CoachResponseSchema.swift b/PulseLoop/Coach/Schema/CoachResponseSchema.swift index e0096e9..ddeed43 100644 --- a/PulseLoop/Coach/Schema/CoachResponseSchema.swift +++ b/PulseLoop/Coach/Schema/CoachResponseSchema.swift @@ -25,10 +25,10 @@ enum CoachResponseSchema { "summary": string (≤ 900 chars) — put the main answer here, not in a "message" field, "bullets": array of strings (≤ 5 items, each ≤ 220 chars), "chart": null, or a chart object (only when response_type is "insight_with_chart"), - "safety_note": string or null, - "data_quality_note": string or null, + "safety_note": string or null (null unless there is a genuine safety concern), + "data_quality_note": string or null (null unless a data gap materially affects the answer), "sources": array of { "title": string, "url": string, "publisher": string } (use [] if none), - "follow_up_chips": array of strings (≤ 4 items, each ≤ 60 chars), + "follow_up_chips": array of strings (≤ 2 items, each ≤ 60 chars), "actions_taken": array of strings (use [] if none), "confidence": one of "low" | "medium" | "high" } @@ -84,7 +84,7 @@ enum CoachResponseSchema { "follow_up_chips": [ "type": "array", "items": ["type": "string", "maxLength": 60], - "maxItems": 4, + "maxItems": 2, ], "actions_taken": ["type": "array", "items": ["type": "string"]], "confidence": ["type": "string", "enum": ["low", "medium", "high"]], diff --git a/PulseLoop/Coach/Schema/CoachResponseView.swift b/PulseLoop/Coach/Schema/CoachResponseView.swift index dbdbf5e..eee1657 100644 --- a/PulseLoop/Coach/Schema/CoachResponseView.swift +++ b/PulseLoop/Coach/Schema/CoachResponseView.swift @@ -23,17 +23,7 @@ struct CoachResponseView: View { .fixedSize(horizontal: false, vertical: true) } - if !response.bullets.isEmpty { - VStack(alignment: .leading, spacing: 5) { - ForEach(response.bullets, id: \.self) { bullet in - HStack(alignment: .top, spacing: 6) { - Text("•").foregroundStyle(PulseColors.accent) - Text(coachMarkdown: bullet).foregroundStyle(PulseColors.textSecondary) - } - .font(.system(size: 13)) - } - } - } + bulletsSection if let chart = response.chart { CoachChartView(chart: chart).padding(.top, 2) @@ -47,47 +37,72 @@ struct CoachResponseView: View { noteRow(icon: "info.circle", text: dq, tone: PulseColors.textMuted) } - if !response.sources.isEmpty { - VStack(alignment: .leading, spacing: 3) { - Text("SOURCES") - .font(.system(size: 9, weight: .semibold)).tracking(1.2) - .foregroundStyle(PulseColors.textMuted) - ForEach(response.sources) { source in - if let url = URL(string: source.url) { - Link(destination: url) { - Text("\(source.title) — \(source.publisher)") - .font(.system(size: 11)) - .foregroundStyle(PulseColors.info) - .underline() - } - } else { - Text("\(source.title) — \(source.publisher)") - .font(.system(size: 11)) - .foregroundStyle(PulseColors.textMuted) - } + sourcesSection + chipsSection + } + } + + @ViewBuilder + private var bulletsSection: some View { + if !response.bullets.isEmpty { + VStack(alignment: .leading, spacing: 5) { + ForEach(response.bullets, id: \.self) { bullet in + HStack(alignment: .top, spacing: 6) { + Text("•").foregroundStyle(PulseColors.accent) + Text(coachMarkdown: bullet).foregroundStyle(PulseColors.textSecondary) } + .font(.system(size: 13)) } - .padding(.top, 2) } + } + } - if !response.followUpChips.isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - ForEach(response.followUpChips, id: \.self) { chip in - Button { onChipTap?(chip) } label: { - Text(chip) - .font(.system(size: 12)) - .foregroundStyle(PulseColors.textSecondary) - .padding(.horizontal, 12).padding(.vertical, 6) - .background(PulseColors.cardSoft, in: Capsule()) - .overlay(Capsule().stroke(PulseColors.borderSubtle, lineWidth: 1)) - } - .buttonStyle(.plain) - } + @ViewBuilder + private var sourcesSection: some View { + if !response.sources.isEmpty { + VStack(alignment: .leading, spacing: 3) { + Text("SOURCES") + .font(.system(size: 9, weight: .semibold)).tracking(1.2) + .foregroundStyle(PulseColors.textMuted) + ForEach(response.sources) { source in + sourceLink(source) + } + } + .padding(.top, 2) + } + } + + @ViewBuilder + private func sourceLink(_ source: CoachSource) -> some View { + if let url = URL(string: source.url) { + Link(destination: url) { + Text("\(source.title) — \(source.publisher)") + .font(.system(size: 11)) + .foregroundStyle(PulseColors.info) + .underline() + } + } else { + Text("\(source.title) — \(source.publisher)") + .font(.system(size: 11)) + .foregroundStyle(PulseColors.textMuted) + } + } + + @ViewBuilder + private var chipsSection: some View { + if !response.followUpChips.isEmpty { + // Full-width tappable rows (≤2). Wider than the old capsules so a + // real follow-up question reads without truncation. `.prefix(2)` + // clamps legacy messages and non-strict providers at render time. + VStack(spacing: 6) { + ForEach(Array(response.followUpChips.prefix(2)), id: \.self) { chip in + Button { onChipTap?(chip) } label: { + CoachFollowUpChipLabel(text: chip) } + .buttonStyle(.plain) } - .padding(.top, 2) } + .padding(.top, 2) } } @@ -103,6 +118,33 @@ struct CoachResponseView: View { } } +/// Full-width follow-up chip row: wraps up to two lines of question text with a +/// trailing arrow glyph. Shared by the chat response view and the Today/Sleep +/// summary cards so both render the same wider treatment. +struct CoachFollowUpChipLabel: View { + let text: String + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Text(text) + .font(.system(size: 13)) + .foregroundStyle(PulseColors.textSecondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 4) + Image(systemName: "arrow.up.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(PulseColors.textMuted) + .padding(.top, 2) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12).padding(.vertical, 10) + .background(PulseColors.cardSoft, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(PulseColors.borderSubtle, lineWidth: 1)) + } +} + extension Text { /// Renders inline Markdown (**bold**, *italic*, `code`, links) so model /// output like "**HR**" displays formatted instead of raw. Falls back to the diff --git a/PulseLoop/Coach/Schema/CoachToolTraceView.swift b/PulseLoop/Coach/Schema/CoachToolTraceView.swift new file mode 100644 index 0000000..e77d5e6 --- /dev/null +++ b/PulseLoop/Coach/Schema/CoachToolTraceView.swift @@ -0,0 +1,91 @@ +import SwiftUI +import SwiftData + +/// Per-message transparency disclosure: a muted collapsed summary of the tools a +/// turn ran ("Got HR data → Drew chart"), expanding to ordered rows with status +/// icons and one-line output summaries. Renders nothing when the message ran no +/// tools, so it can be dropped unconditionally after any assistant/error bubble. +struct CoachToolTraceDisclosure: View { + @Query private var calls: [CoachToolCall] + @State private var expanded = false + + init(messageId: UUID) { + // Splitting the predicate and sort out of the Query(...) call keeps this + // initializer's type-check cost low (the inline form is slow enough to + // risk the compiler's expression budget on CI's slower runners). + let predicate = #Predicate { $0.messageId == messageId } + let order: [SortDescriptor] = [ + SortDescriptor(\.sequence, order: .forward), + SortDescriptor(\.createdAt, order: .forward), + ] + _calls = Query(filter: predicate, sort: order) + } + + /// Collapsed line: joins ≤2 friendly labels with " → ", else "Used N tools". + private var collapsedText: String { + let labels = calls.map(Self.displayLabel) + if labels.count <= 2 { return labels.joined(separator: " → ") } + return "Used \(labels.count) tools" + } + + var body: some View { + if calls.isEmpty { + EmptyView() + } else { + VStack(alignment: .leading, spacing: 6) { + Button { + withAnimation(.easeOut(duration: 0.18)) { expanded.toggle() } + } label: { + HStack(spacing: 5) { + Image(systemName: "wrench.and.screwdriver") + .font(.system(size: 10)) + Text(collapsedText) + .font(.system(size: 11)) + .lineLimit(1) + Image(systemName: expanded ? "chevron.up" : "chevron.down") + .font(.system(size: 9, weight: .semibold)) + } + .foregroundStyle(PulseColors.textMuted) + } + .buttonStyle(.plain) + + if expanded { + VStack(alignment: .leading, spacing: 6) { + ForEach(calls) { call in + traceRow(call) + } + } + } + } + .padding(.leading, 6) + } + } + + private func traceRow(_ call: CoachToolCall) -> some View { + let isError = call.statusRaw == "error" + return HStack(alignment: .top, spacing: 6) { + Image(systemName: isError ? "xmark.circle" : "checkmark.circle") + .font(.system(size: 11)) + .foregroundStyle(isError ? PulseColors.danger : PulseColors.success) + .padding(.top, 1) + VStack(alignment: .leading, spacing: 1) { + Text(Self.displayLabel(call)) + .font(.system(size: 11)) + .foregroundStyle(PulseColors.textSecondary) + if let summary = call.outputJSON, !summary.isEmpty { + Text(summary) + .font(.system(size: 10)) + .foregroundStyle(PulseColors.textMuted) + .lineLimit(1) + } + } + } + } + + /// Friendly label, falling back to a humanized `toolName` for legacy rows + /// that predate the persisted `label` field. + static func displayLabel(_ call: CoachToolCall) -> String { + if !call.label.isEmpty { return call.label } + return call.toolName.replacingOccurrences(of: "_", with: " ").capitalized + } +} diff --git a/PulseLoop/Coach/Schema/CoachWorkoutCard.swift b/PulseLoop/Coach/Schema/CoachWorkoutCard.swift new file mode 100644 index 0000000..f54d136 --- /dev/null +++ b/PulseLoop/Coach/Schema/CoachWorkoutCard.swift @@ -0,0 +1,30 @@ +import SwiftUI +import SwiftData + +/// In-chat card for a workout the coach logged or edited this turn. Fetches the +/// `ActivitySession` by id and renders the existing `ActivityWorkoutRow`, so it +/// stays visually identical to the Activity tab. Renders nothing if the session +/// was since deleted (e.g. the user removed it), matching the design intent that +/// this is a locally-known fact, not an LLM-emitted card. +struct CoachWorkoutCard: View { + let activityId: UUID + var onOpen: ((UUID) -> Void)? + + @Environment(\.modelContext) private var modelContext + @Query private var profiles: [UserProfile] + + private var units: UnitsPreference { profiles.first?.units ?? .metric } + + private var session: ActivitySession? { + let id = activityId + var descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) + descriptor.fetchLimit = 1 + return (try? modelContext.fetch(descriptor))?.first + } + + var body: some View { + if let session { + ActivityWorkoutRow(session: session, units: units) { onOpen?(session.id) } + } + } +} diff --git a/PulseLoop/Coach/Summaries/CoachSummaryContent.swift b/PulseLoop/Coach/Summaries/CoachSummaryContent.swift index baeafce..368b2f7 100644 --- a/PulseLoop/Coach/Summaries/CoachSummaryContent.swift +++ b/PulseLoop/Coach/Summaries/CoachSummaryContent.swift @@ -46,8 +46,9 @@ enum CoachSummarySchema { "body": ["type": "string", "maxLength": 320], "chips": [ "type": "array", + // Kept short so both questions fit side by side on the card. "items": ["type": "string", "maxLength": 40], - "maxItems": 3, + "maxItems": 2, ], ], "required": ["title", "body", "chips"], diff --git a/PulseLoop/Coach/Summaries/CoachSummaryContextBuilder.swift b/PulseLoop/Coach/Summaries/CoachSummaryContextBuilder.swift index 7497e84..3b8e888 100644 --- a/PulseLoop/Coach/Summaries/CoachSummaryContextBuilder.swift +++ b/PulseLoop/Coach/Summaries/CoachSummaryContextBuilder.swift @@ -11,11 +11,15 @@ enum CoachSummaryContextBuilder { let json: String let signature: String let fallback: CoachSummaryContent + /// The end of the night this summary describes (sleepDay only); nil for + /// today/sleepRange. Drives the sleep-sync gate in `CoachSummaryService`. + var sleepSessionEnd: Date? = nil } // MARK: Today - static func today(context: ModelContext, now: Date = Date()) -> Built { + static func today(context: ModelContext, now: Date = Date(), + environment: CoachContextPacket.EnvironmentContext? = nil) -> Built { let summary = MetricsService.buildTodaySummary(context: context) let packet = CoachContextBuilder.build(context: context, now: now) let hero = TodayInsights.deriveHero(summary) @@ -27,10 +31,11 @@ enum CoachSummaryContextBuilder { let latestSleep: CoachContextPacket.SleepContext? let memories: [CoachContextPacket.MemoryContext] let dataQualityWarnings: [String] + let environment: CoachContextPacket.EnvironmentContext? } let p = Packet(today: packet.today, goals: packet.goals, latestVitals: packet.latestVitals, latestSleep: packet.latestSleep, memories: packet.memories, - dataQualityWarnings: packet.dataQualityWarnings) + dataQualityWarnings: packet.dataQualityWarnings, environment: environment) let sig = signature([ packet.today.steps.map(String.init), packet.today.calories.map { String(Int($0)) }, @@ -48,7 +53,8 @@ enum CoachSummaryContextBuilder { // MARK: Sleep — nightly - static func sleepDay(context: ModelContext, now: Date = Date()) -> Built? { + static func sleepDay(context: ModelContext, now: Date = Date(), + environment: CoachContextPacket.EnvironmentContext? = nil) -> Built? { let range = SleepService.sleepRange(.day, context: context, now: now) guard let night = SleepInsights.validSessions(range.sessions).last else { return nil } let score = SleepScore.calculate(night) @@ -59,13 +65,15 @@ enum CoachSummaryContextBuilder { let date: String, totalMin: Int, deepMin: Int, lightMin: Int, awakeMin: Int let score: Int, scoreLabel: String, awakePct: Int?, deepPct: Int, activitySteps: Int? let memories: [CoachContextPacket.MemoryContext] + let environment: CoachContextPacket.EnvironmentContext? } let p = Packet( date: CoachDataAccess.localDateString(night.session.date), totalMin: night.session.totalMinutes, deepMin: night.deepMinutes, lightMin: night.lightMinutes, awakeMin: night.awakeMinutes, score: score.score, scoreLabel: score.label.rawValue, awakePct: score.awakePct, - deepPct: score.deepPct, activitySteps: activitySteps, memories: memories + deepPct: score.deepPct, activitySteps: activitySteps, memories: memories, + environment: environment ) let sig = signature([ String(night.session.totalMinutes), String(night.deepMinutes), @@ -76,7 +84,8 @@ enum CoachSummaryContextBuilder { return Built( scopeKey: CoachDataAccess.localDateString(night.session.date), json: encode(p), signature: sig, - fallback: CoachSummaryContent(title: coach.headline, body: coach.body, chips: coach.chips) + fallback: CoachSummaryContent(title: coach.headline, body: coach.body, chips: coach.chips), + sleepSessionEnd: night.session.endAt ) } diff --git a/PulseLoop/Coach/Summaries/CoachSummaryCoordinator.swift b/PulseLoop/Coach/Summaries/CoachSummaryCoordinator.swift index 5b73a43..542b145 100644 --- a/PulseLoop/Coach/Summaries/CoachSummaryCoordinator.swift +++ b/PulseLoop/Coach/Summaries/CoachSummaryCoordinator.swift @@ -39,6 +39,12 @@ final class CoachSummaryCoordinator { pendingSleep = true pendingToday = true scheduleRefresh() + case let .syncProgress(stage) where stage == "done": + // A full history sync just completed — the sleep gate may now pass, so + // re-evaluate both cards (the service self-gates if there's nothing new). + pendingSleep = true + pendingToday = true + scheduleRefresh() default: break } diff --git a/PulseLoop/Coach/Summaries/CoachSummaryGenerator.swift b/PulseLoop/Coach/Summaries/CoachSummaryGenerator.swift index d5ccc17..4cf6e41 100644 --- a/PulseLoop/Coach/Summaries/CoachSummaryGenerator.swift +++ b/PulseLoop/Coach/Summaries/CoachSummaryGenerator.swift @@ -11,13 +11,15 @@ enum CoachSummaryGenerator { contextJSON: String, fallback: CoachSummaryContent, flags: CoachFeatureFlags, - client: ResponsesClient + client: ResponsesClient, + angle: String = "", + recentTexts: [String] = [] ) async -> CoachSummaryContent { guard flags.coachEnabled else { return fallback } do { let input: [[String: Any]] = [ OpenAIRequestBuilder.message(role: "system", content: CoachSummaryPromptBuilder.systemPrompt(kind: kind)), - OpenAIRequestBuilder.message(role: "developer", content: CoachSummaryPromptBuilder.developerMessage(contextJSON: contextJSON)), + OpenAIRequestBuilder.message(role: "developer", content: CoachSummaryPromptBuilder.developerMessage(contextJSON: contextJSON, angle: angle, recentTexts: recentTexts)), ] let body = try OpenAIRequestBuilder.data( model: flags.model, input: input, tools: [], diff --git a/PulseLoop/Coach/Summaries/CoachSummaryPromptBuilder.swift b/PulseLoop/Coach/Summaries/CoachSummaryPromptBuilder.swift index 6d78c39..19f5a3d 100644 --- a/PulseLoop/Coach/Summaries/CoachSummaryPromptBuilder.swift +++ b/PulseLoop/Coach/Summaries/CoachSummaryPromptBuilder.swift @@ -19,18 +19,24 @@ enum CoachSummaryPromptBuilder { Rules: - Output ONLY JSON {"title","body","chips"}. Title ≤ ~6 words. Body 1–2 short, specific sentences citing real numbers from the data. - - `chips` is up to 3 short follow-up questions the user might tap to dig in (e.g. "Why is my deep sleep low?", "How do I compare to last week?"). Phrase them as the user would ask the coach. + - `chips`: up to 2 short follow-up questions the user might tap (e.g. "Why is my deep sleep low?"). Keep each under 40 characters — they render side by side. Phrase them as the user would ask. - Be warm, specific, and genuinely useful — not generic. Ground every claim in the provided data; if data is thin, say so lightly and never invent numbers. - No medical diagnosis or alarming language. Wellness tone. At most one emoji, only if it fits. + - When an `environment` block (city + weather) is present, you may use it for concrete advice (outdoor vs indoor, hydration, rain). Never name a location finer than the city; don't force it. + - A coaching angle and your recent cards are provided — vary your voice and structure; never open two cards the same way. """ } - static func developerMessage(contextJSON: String) -> String { - """ - Data for this card: - \(contextJSON) - - Write the card now as {"title","body","chips"}. - """ + static func developerMessage(contextJSON: String, angle: String = "", recentTexts: [String] = []) -> String { + var blocks = ["Data for this card:\n\(contextJSON)"] + if !angle.isEmpty { + blocks.append("Coaching angle for this check-in (take it unless the data makes it a poor fit): \(angle)") + } + if !recentTexts.isEmpty { + let list = recentTexts.map { "- \($0)" }.joined(separator: "\n") + blocks.append("Your most recent check-ins — do NOT repeat their phrasing, openings, or structure:\n\(list)") + } + blocks.append("Write the card now as {\"title\",\"body\",\"chips\"}.") + return blocks.joined(separator: "\n\n") } } diff --git a/PulseLoop/Coach/Summaries/CoachSummaryService.swift b/PulseLoop/Coach/Summaries/CoachSummaryService.swift index ba20cac..44f8c1d 100644 --- a/PulseLoop/Coach/Summaries/CoachSummaryService.swift +++ b/PulseLoop/Coach/Summaries/CoachSummaryService.swift @@ -67,7 +67,8 @@ final class CoachSummaryService { // MARK: - Refresh (self-gating) func refreshTodayIfNeeded(now: Date = Date()) async { - let built = CoachSummaryContextBuilder.today(context: modelContext, now: now) + let environment = await CoachEnvironmentContextService.shared.snapshot(now: now) + let built = CoachSummaryContextBuilder.today(context: modelContext, now: now, environment: environment) let existing = summary(kind: CoachSummaryKind.today.rawValue, scopeKey: built.scopeKey) if let existing { if existing.dataSignature == built.signature { return } // no new data @@ -77,10 +78,31 @@ final class CoachSummaryService { } func refreshSleepDayIfNeeded(now: Date = Date()) async { - guard let built = CoachSummaryContextBuilder.sleepDay(context: modelContext, now: now) else { return } - // Once per night: only when this night isn't summarized yet. - if summary(kind: CoachSummaryKind.sleepDay.rawValue, scopeKey: built.scopeKey) != nil { return } - await generateAndUpsert(.sleepDay, built: built, existing: nil, now: now) + let environment = await CoachEnvironmentContextService.shared.snapshot(now: now) + guard let built = CoachSummaryContextBuilder.sleepDay(context: modelContext, now: now, environment: environment), + let endAt = built.sleepSessionEnd else { return } + + // Gate 1: the night is actually over (give the ring a beat to flush the tail). + guard now >= endAt.addingTimeInterval(30 * 60) else { return } + + // Gate 2: a full history sync completed AFTER the night (implies we're not mid-sync + // and the night's data is complete). Streaming devices with no full-sync stamp fall + // back to a 2h-after-wake floor. + if let fullSync = DeviceRepository.current(context: modelContext)?.lastFullSyncAt { + guard fullSync >= endAt else { return } + } else { + guard now >= endAt.addingTimeInterval(2 * 3600) else { return } + } + + // Signature-based upsert: regenerate only when the signature differs AND the existing + // row is older than the minInterval floor — allows one corrective pass if a late sync + // grows the night, without churning. + let existing = summary(kind: CoachSummaryKind.sleepDay.rawValue, scopeKey: built.scopeKey) + if let existing { + if existing.dataSignature == built.signature { return } + if now.timeIntervalSince(existing.updatedAt) < minInterval { return } + } + await generateAndUpsert(.sleepDay, built: built, existing: existing, now: now) } func refreshSleepRangeIfNeeded(_ range: SleepRangeKey, now: Date = Date()) async { @@ -93,6 +115,20 @@ final class CoachSummaryService { await generateAndUpsert(.sleepRange(range), built: built, existing: existing, now: now) } + /// The last few cards of this kind (title — body) so the generator can avoid + /// repeating their phrasing/openings. Newest first; the row being regenerated + /// is excluded. + private func recentSummaryTexts(kind: CoachSummaryKind, excluding excludeId: UUID?, limit: Int = 5) -> [String] { + let rawKind = kind.rawValue + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.kind == rawKind }, + sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] + ) + descriptor.fetchLimit = limit + 1 + let rows = (try? modelContext.fetch(descriptor)) ?? [] + return rows.filter { $0.id != excludeId }.prefix(limit).map { "\($0.title) — \($0.body)" } + } + private func resolveClient() -> (key: String?, client: ResponsesClient) { CoachClientResolver.resolve( settings: settingsStore.settings, @@ -110,9 +146,11 @@ final class CoachSummaryService { ) async { let (apiKey, activeClient) = resolveClient() let flags = CoachFeatureFlags(settings: settingsStore.settings, hasAPIKey: apiKey != nil) + let angle = CoachVarietyHints.angle(seed: built.scopeKey + kind.rawValue) + let recentTexts = recentSummaryTexts(kind: kind, excluding: existing?.id) let content = await CoachSummaryGenerator.generate( kind: kind, contextJSON: built.json, fallback: built.fallback, - flags: flags, client: activeClient + flags: flags, client: activeClient, angle: angle, recentTexts: recentTexts ) if let existing { existing.apply(content, signature: built.signature, now: now) diff --git a/PulseLoop/Coach/Tools/ActionTools.swift b/PulseLoop/Coach/Tools/ActionTools.swift index 1561bd5..b7030e5 100644 --- a/PulseLoop/Coach/Tools/ActionTools.swift +++ b/PulseLoop/Coach/Tools/ActionTools.swift @@ -145,9 +145,16 @@ enum ActionTools { return .object(["ok": false, "needs_follow_up": true, "reason": "duration_missing", "suggested_question": "Roughly how long was the \(args.activityType) session?"]) } - let start: Date = args.startTime.flatMap(CoachDataAccess.parseLocalDate) + let now = Date() + var start: Date = args.startTime.flatMap(CoachDataAccess.parseLocalDate) ?? CoachDataAccess.parseLocalDate(args.date).map { $0.addingTimeInterval(12 * 3600) } - ?? Date() + ?? now + // The same-day noon default can land in the future (logging "today" + // in the morning) and ManualActivityService rejects future sessions; + // pull an unspecified start back so the session ends by now. + if args.startTime == nil, start.addingTimeInterval(duration * 60) > now { + start = now.addingTimeInterval(-duration * 60) + } let session: ActivitySession do { session = try ManualActivityService.create( @@ -161,6 +168,7 @@ enum ActionTools { } catch { return .error(error.localizedDescription) } + ctx.loggedActivityIds.append(session.id) return .object(["ok": true, "created": true, "activity_id": session.id.uuidString, "type": args.activityType, "duration_min": duration]) } @@ -206,6 +214,7 @@ enum ActionTools { let isToday = Calendar.current.isDateInToday(session.startedAt) if isToday { applyUpdatesNow(updates, to: session, context: ctx.modelContext) + ctx.loggedActivityIds.append(session.id) return .object(["ok": true, "updated": true, "activity_id": args.activityId]) } // Older session → confirm. diff --git a/PulseLoop/Coach/Tools/CoachTool.swift b/PulseLoop/Coach/Tools/CoachTool.swift index ad52bc8..af70b92 100644 --- a/PulseLoop/Coach/Tools/CoachTool.swift +++ b/PulseLoop/Coach/Tools/CoachTool.swift @@ -11,6 +11,9 @@ final class ToolExecutionContext { let coordinator: RingSyncCoordinator? /// Risky writes proposed this turn, awaiting a Confirm/Cancel tap. var pendingActions: [PendingAction] = [] + /// Activity sessions created/edited during this turn (immediate writes, not + /// confirmation-gated ones). Drives the in-chat workout card. + var loggedActivityIds: [UUID] = [] init(modelContext: ModelContext, flags: CoachFeatureFlags, coordinator: RingSyncCoordinator? = nil) { self.modelContext = modelContext diff --git a/PulseLoop/Coach/Usage/CoachModelPricing.swift b/PulseLoop/Coach/Usage/CoachModelPricing.swift new file mode 100644 index 0000000..14be1ce --- /dev/null +++ b/PulseLoop/Coach/Usage/CoachModelPricing.swift @@ -0,0 +1,93 @@ +import Foundation + +/// USD-per-1M-token rates for one model. `cachedInputPer1M` is the (cheaper) +/// prompt-cache-hit rate; `nil` means the model isn't cache-priced, so cached +/// input bills at the normal input rate. +struct CoachModelPrice: Equatable { + let inputPer1M: Double + let cachedInputPer1M: Double? + let outputPer1M: Double +} + +/// Estimates a turn's USD cost from token usage when the provider doesn't bill it +/// back (only OpenRouter reports exact cost). Keys are the provider model strings +/// from the `CoachSettings` enums; lookup is normalized (lowercase, strip the +/// OpenRouter `:online` web-search suffix), tries an exact match first, then the +/// longest matching key prefix, so a Custom slug like `openai/gpt-5.5-preview` +/// still prices off `openai/gpt-5.5`. +/// +/// On-device / offline models are free ($0). An unknown free-form model returns +/// `nil` so the UI can show "cost unavailable" rather than a wrong number. +enum CoachPricingCatalog { + /// Normalized-key → published list price, standard (non-batch) tier, as of + /// July 2026. Sources: platform OpenAI/Google/DeepSeek/MiniMax pricing pages, + /// platform.claude.com pricing docs, and openrouter.ai model pages. Estimates + /// only — OpenRouter's API-reported exact cost always takes precedence. + private static let table: [String: CoachModelPrice] = [ + // OpenAI (native) — CoachModel enum + "gpt-5.4-mini": CoachModelPrice(inputPer1M: 0.75, cachedInputPer1M: 0.075, outputPer1M: 4.50), + "gpt-5.4": CoachModelPrice(inputPer1M: 2.50, cachedInputPer1M: 0.25, outputPer1M: 15.00), + "gpt-5.5": CoachModelPrice(inputPer1M: 5.00, cachedInputPer1M: 0.50, outputPer1M: 30.00), + // Gemini (native) — GeminiModel enum. gemini-2.0-flash was shut down + // June 1 2026 and has no published price; it intentionally has no entry + // so the UI reports "cost unavailable". + "gemini-2.5-flash": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: 0.03, outputPer1M: 2.50), + "gemini-2.5-pro": CoachModelPrice(inputPer1M: 1.25, cachedInputPer1M: 0.125, outputPer1M: 10.00), // ≤200k-token tier; >200k bills 2x + // OpenRouter — OpenRouterModel enum (vendor/model slugs). List prices + // match the vendors'; cached rates use the vendor cache-read price. + "anthropic/claude-sonnet-4.6": CoachModelPrice(inputPer1M: 3.00, cachedInputPer1M: 0.30, outputPer1M: 15.00), + "anthropic/claude-opus-4.7": CoachModelPrice(inputPer1M: 5.00, cachedInputPer1M: 0.50, outputPer1M: 25.00), + "openai/gpt-5.5": CoachModelPrice(inputPer1M: 5.00, cachedInputPer1M: 0.50, outputPer1M: 30.00), + "openai/gpt-5.4-mini": CoachModelPrice(inputPer1M: 0.75, cachedInputPer1M: 0.075, outputPer1M: 4.50), + "google/gemini-2.5-flash": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: 0.03, outputPer1M: 2.50), + "google/gemini-2.5-pro": CoachModelPrice(inputPer1M: 1.25, cachedInputPer1M: 0.125, outputPer1M: 10.00), + "deepseek/deepseek-v4-flash": CoachModelPrice(inputPer1M: 0.09, cachedInputPer1M: nil, outputPer1M: 0.18), // OpenRouter routes below DeepSeek's direct $0.14/$0.28 + // MiniMax — MiniMaxModel enum. No published cache-read rate; -highspeed + // variants bill 2x the standard rate. + "minimax-m3": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: nil, outputPer1M: 1.20), // ≤512k-token tier; >512k bills 2x + "minimax-m2.7": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: nil, outputPer1M: 1.20), + "minimax-m2.7-highspeed": CoachModelPrice(inputPer1M: 0.60, cachedInputPer1M: nil, outputPer1M: 2.40), + "minimax-m2.5": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: nil, outputPer1M: 1.20), + "minimax-m2.5-highspeed": CoachModelPrice(inputPer1M: 0.60, cachedInputPer1M: nil, outputPer1M: 2.40), + "minimax-m2.1": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: nil, outputPer1M: 1.20), + "minimax-m2.1-highspeed": CoachModelPrice(inputPer1M: 0.60, cachedInputPer1M: nil, outputPer1M: 2.40), + "minimax-m2": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: nil, outputPer1M: 1.20), + ] + + /// The `effectiveModel` strings that always cost $0 (local / scripted). + private static let freeModels: Set = ["apple-on-device", "offline-stub"] + + /// The estimated USD cost of `usage` for `model`. Returns 0 for on-device / + /// offline models, `nil` for an unrecognized model, else the priced estimate. + static func cost(model: String, usage: CoachTokenUsage) -> Double? { + guard let price = price(for: model) else { return nil } + let cachedRate = price.cachedInputPer1M ?? price.inputPer1M + let uncachedInput = max(0, usage.inputTokens - usage.cachedInputTokens) + let dollars = (Double(uncachedInput) * price.inputPer1M + + Double(usage.cachedInputTokens) * cachedRate + + Double(usage.outputTokens) * price.outputPer1M) / 1_000_000 + return dollars + } + + /// Resolves a model string to its price. Free models resolve to an all-zero + /// price; unknown models return `nil`. + static func price(for model: String) -> CoachModelPrice? { + let key = normalize(model) + if freeModels.contains(key) { return CoachModelPrice(inputPer1M: 0, cachedInputPer1M: 0, outputPer1M: 0) } + if let exact = table[key] { return exact } + // Longest-prefix fallback: a Custom slug like `openai/gpt-5.5-preview` + // prices off the longest catalog key that it starts with. + return table + .filter { key.hasPrefix($0.key) } + .max(by: { $0.key.count < $1.key.count })? + .value + } + + /// Lowercases and strips the OpenRouter `:online` web-search suffix so both the + /// web-search and plain variants of a slug price the same. + private static func normalize(_ model: String) -> String { + var key = model.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + if key.hasSuffix(":online") { key.removeLast(":online".count) } + return key + } +} diff --git a/PulseLoop/Coach/Usage/CoachTokenUsage.swift b/PulseLoop/Coach/Usage/CoachTokenUsage.swift new file mode 100644 index 0000000..118caac --- /dev/null +++ b/PulseLoop/Coach/Usage/CoachTokenUsage.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Token accounting for one model call (or a summed agent turn). Populated by +/// each `ResponsesClient` from the provider's usage block; `nil` fields mean the +/// provider didn't report that figure. `reportedCostUSD` is set only when the +/// provider returns an exact cost (OpenRouter) — otherwise the catalog estimates it. +struct CoachTokenUsage: Sendable, Equatable { + var inputTokens: Int + var outputTokens: Int + /// Cached (prompt-cache-hit) input tokens, a subset of `inputTokens`. 0 when + /// the provider doesn't report caching. + var cachedInputTokens: Int + /// Exact USD cost when the provider bills it back (OpenRouter). `nil` → estimate + /// from the pricing catalog. + var reportedCostUSD: Double? + + init(inputTokens: Int = 0, outputTokens: Int = 0, cachedInputTokens: Int = 0, reportedCostUSD: Double? = nil) { + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cachedInputTokens = cachedInputTokens + self.reportedCostUSD = reportedCostUSD + } + + /// Sums another call's usage into this one. Costs add when both sides report + /// one (or one side is nil → keep the present value); tokens always add. + mutating func add(_ other: CoachTokenUsage) { + inputTokens += other.inputTokens + outputTokens += other.outputTokens + cachedInputTokens += other.cachedInputTokens + switch (reportedCostUSD, other.reportedCostUSD) { + case let (lhs?, rhs?): reportedCostUSD = lhs + rhs + case (nil, let rhs?): reportedCostUSD = rhs + default: break // keep lhs (possibly nil) + } + } +} diff --git a/PulseLoop/Coach/ViewModels/CoachViewModel.swift b/PulseLoop/Coach/ViewModels/CoachViewModel.swift index 63be051..f5905a2 100644 --- a/PulseLoop/Coach/ViewModels/CoachViewModel.swift +++ b/PulseLoop/Coach/ViewModels/CoachViewModel.swift @@ -8,6 +8,8 @@ import SwiftData @MainActor @Observable final class CoachViewModel { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + var traceEvents: [CoachTraceEvent] = [] var isSending = false var errorBanner: String? @@ -59,8 +61,11 @@ final class CoachViewModel { let (apiKey, activeClient) = resolveClient() let flags = CoachFeatureFlags(settings: settingsStore.settings, hasAPIKey: apiKey != nil) - let packet = CoachContextBuilder.build(context: context) - let recent = recentMessages(conversationId: conversationId, excluding: userMessage.id, context: context) + let budget = flags.contextBudget + let environment = await CoachEnvironmentContextService.shared.snapshot() + let packet = CoachContextBuilder.build(context: context, budget: budget, environment: environment) + let recent = recentMessages( + conversationId: conversationId, excluding: userMessage.id, context: context, limit: budget.historyTurns) let userImages = CoachAttachmentStore.payloads(for: attachments) let orchestrator = CoachOrchestrator( @@ -79,7 +84,7 @@ final class CoachViewModel { self?.traceEvents.append(event) } - persist(result, conversationId: conversationId, context: context) + persist(result, conversationId: conversationId, context: context, flags: flags) } // MARK: - Provider resolution @@ -102,7 +107,14 @@ final class CoachViewModel { guard let action = PendingAction.decode(fromJSON: message.pendingActionJSON) else { return } let resultText = PendingActionExecutor.execute(action, context: context) message.pendingActionJSON = nil - context.insert(CoachMessage(conversationId: message.conversationId, role: "assistant", body: resultText)) + // A confirmed edit still touches a real workout — surface its card. Deletes + // have nothing left to show, so skip them. + let loggedIds: String? = action.kind == .updateActivitySession + ? UUID(uuidString: action.activityId).map { Self.encodeActivityIds([$0]) } ?? nil + : nil + context.insert(CoachMessage( + conversationId: message.conversationId, role: "assistant", body: resultText, + loggedActivityIdsJSON: loggedIds)) try? context.save() } @@ -115,9 +127,10 @@ final class CoachViewModel { // MARK: - Persistence - private func persist(_ result: CoachOrchestrator.TurnResult, conversationId: UUID, context: ModelContext) { + private func persist(_ result: CoachOrchestrator.TurnResult, conversationId: UUID, context: ModelContext, flags: CoachFeatureFlags) { // A failed turn surfaces as a red error bubble (role "error") carrying the - // code + reason, instead of the generic assistant fallback. + // code + reason, instead of the generic assistant fallback. Failed turns + // still burned tokens, so usage rides on the error message too. if let error = result.error { let errorMessage = CoachMessage( conversationId: conversationId, @@ -125,9 +138,10 @@ final class CoachViewModel { body: error.plainText, cardsJSON: error.encodedJSON() ) + applyUsage(result.usage, to: errorMessage, flags: flags) context.insert(errorMessage) persistTrace(result.trace, messageId: errorMessage.id, conversationId: conversationId, context: context) - touchConversation(conversationId, context: context) + touchConversation(conversationId, context: context, usage: result.usage, cost: errorMessage.costUSD) try? context.save() return } @@ -137,32 +151,65 @@ final class CoachViewModel { role: "assistant", body: result.assistant.plainText, cardsJSON: result.assistant.encodedJSON(), - pendingActionJSON: result.pendingActions.first?.encodedJSON() + pendingActionJSON: result.pendingActions.first?.encodedJSON(), + loggedActivityIdsJSON: Self.encodeActivityIds(result.loggedActivityIds) ) + applyUsage(result.usage, to: assistant, flags: flags) context.insert(assistant) persistTrace(result.trace, messageId: assistant.id, conversationId: conversationId, context: context) - touchConversation(conversationId, context: context) + touchConversation(conversationId, context: context, usage: result.usage, cost: assistant.costUSD) try? context.save() } - private func persistTrace( + /// Stamps a turn's token/cost accounting onto the message. Cost prefers the + /// provider-reported figure (OpenRouter), else the catalog estimate; `nil` when + /// the model is unknown or on-device (the UI shows "cost unavailable"). + private func applyUsage(_ usage: CoachTokenUsage?, to message: CoachMessage, flags: CoachFeatureFlags) { + message.modelUsed = flags.effectiveModel + message.providerUsed = flags.settings.providerMode.rawValue + guard let usage else { return } + message.inputTokens = usage.inputTokens + message.outputTokens = usage.outputTokens + message.costUSD = usage.reportedCostUSD ?? CoachPricingCatalog.cost(model: flags.effectiveModel, usage: usage) + } + + /// Internal (not private) so unit tests can assert label/status/sequence are + /// persisted rather than dropped. + func persistTrace( _ trace: [CoachToolCallTrace], messageId: UUID, conversationId: UUID, context: ModelContext ) { - for entry in trace { + for (index, entry) in trace.enumerated() { context.insert(CoachToolCall( conversationId: conversationId, messageId: messageId, toolName: entry.toolName, inputJSON: entry.argsRedacted, - outputJSON: entry.resultSummary + outputJSON: entry.resultSummary, + label: entry.label, + statusRaw: entry.status, + sequence: index )) } } - private func touchConversation(_ conversationId: UUID, context: ModelContext) { + /// Encodes activity ids logged/edited during a turn onto a message. Returns + /// nil for the common empty case to keep migrations light. + static func encodeActivityIds(_ ids: [UUID]) -> String? { + guard !ids.isEmpty else { return nil } + return (try? JSONEncoder().encode(ids)).flatMap { String(data: $0, encoding: .utf8) } + } + + private func touchConversation( + _ conversationId: UUID, context: ModelContext, usage: CoachTokenUsage? = nil, cost: Double? = nil + ) { if let convo = fetchConversation(conversationId, context: context) { convo.updatedAt = Date() + if let usage { + convo.totalInputTokens += usage.inputTokens + convo.totalOutputTokens += usage.outputTokens + } + if let cost { convo.totalCostUSD += cost } } } diff --git a/PulseLoop/DesignSystem/Components.swift b/PulseLoop/DesignSystem/Components.swift index 8888969..4a8901f 100644 --- a/PulseLoop/DesignSystem/Components.swift +++ b/PulseLoop/DesignSystem/Components.swift @@ -126,16 +126,11 @@ struct CoachMessageCard: View { .foregroundStyle(PulseColors.textSecondary) .padding(.top, 6) if !chips.isEmpty { - HStack(spacing: 8) { - ForEach(chips, id: \.self) { chip in - Text(chip) - .font(.system(size: 11)) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .foregroundStyle(PulseColors.textSecondary) - .background(PulseColors.cardSoft) - .clipShape(Capsule()) - .overlay(Capsule().stroke(PulseColors.borderSubtle, lineWidth: 1)) + // Both questions share one compact row (each wraps to ≤2 lines) + // so the card stays short; chat bubbles use full-width rows instead. + HStack(alignment: .top, spacing: 6) { + ForEach(chips.prefix(2), id: \.self) { chip in + CoachSummaryChip(text: chip) } } .padding(.top, 12) @@ -154,6 +149,29 @@ struct CoachMessageCard: View { } } +/// One compact follow-up question on a Today/Sleep coach card. Extracted so the +/// modifier chain doesn't blow the parent view's type-check budget. +private struct CoachSummaryChip: View { + let text: String + + var body: some View { + Text(text) + .font(.system(size: 11)) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .foregroundStyle(PulseColors.textSecondary) + .background(PulseColors.cardSoft, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(PulseColors.borderSubtle, lineWidth: 1) + ) + } +} + // MARK: - Tappable metric card with delta + sparkline (Today / Activity / Vitals) struct MetricDelta { diff --git a/PulseLoop/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index bed43c7..7a284f4 100644 --- a/PulseLoop/Models/PulseModels.swift +++ b/PulseLoop/Models/PulseModels.swift @@ -753,7 +753,12 @@ final class CoachConversation { var title: String var createdAt: Date var updatedAt: Date - + // Running token/cost totals across the conversation's turns. Defaults keep the + // SwiftData migration lightweight for existing installs. + var totalInputTokens: Int = 0 + var totalOutputTokens: Int = 0 + var totalCostUSD: Double = 0 + init(id: UUID = UUID(), title: String = "Today check-in") { self.id = id self.title = title @@ -775,9 +780,28 @@ final class CoachMessage { /// bytes live in `Documents/coach_attachments/`; this holds only the refs. /// Optional with a default keeps the SwiftData migration lightweight. var attachmentsJSON: String? = nil + // Token/cost accounting for the turn that produced this message (assistant and + // error rows carry it — failed turns burned tokens too). `costUSD` is the + // provider-reported cost or a catalog estimate; nil when unavailable. Optional + // with defaults keeps the SwiftData migration lightweight. + var inputTokens: Int? = nil + var outputTokens: Int? = nil + var costUSD: Double? = nil + var modelUsed: String? = nil + var providerUsed: String? = nil + /// Encoded `[UUID]` of activity sessions logged/edited by the turn that + /// produced this message. Drives the in-chat workout card. Optional with a + /// default keeps the SwiftData migration lightweight. + var loggedActivityIdsJSON: String? = nil var createdAt: Date - init(id: UUID = UUID(), conversationId: UUID, role: String, body: String, cardsJSON: String? = nil, pendingActionJSON: String? = nil, attachmentsJSON: String? = nil, createdAt: Date = Date()) { + init( + id: UUID = UUID(), conversationId: UUID, role: String, body: String, cardsJSON: String? = nil, + pendingActionJSON: String? = nil, attachmentsJSON: String? = nil, + inputTokens: Int? = nil, outputTokens: Int? = nil, costUSD: Double? = nil, + modelUsed: String? = nil, providerUsed: String? = nil, + loggedActivityIdsJSON: String? = nil, createdAt: Date = Date() + ) { self.id = id self.conversationId = conversationId self.role = role @@ -785,6 +809,12 @@ final class CoachMessage { self.cardsJSON = cardsJSON self.pendingActionJSON = pendingActionJSON self.attachmentsJSON = attachmentsJSON + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.costUSD = costUSD + self.modelUsed = modelUsed + self.providerUsed = providerUsed + self.loggedActivityIdsJSON = loggedActivityIdsJSON self.createdAt = createdAt } } @@ -835,15 +865,28 @@ final class CoachToolCall { var toolName: String var inputJSON: String? var outputJSON: String? + // Friendly label ("Got HR data"), execution status ("success"/"error"), and + // 0-based ordering within the turn. Defaults keep the SwiftData migration + // lightweight; legacy rows fall back to a humanized `toolName` in the UI. + var label: String = "" + var statusRaw: String = "success" + var sequence: Int = 0 var createdAt: Date - - init(id: UUID = UUID(), conversationId: UUID, messageId: UUID? = nil, toolName: String, inputJSON: String? = nil, outputJSON: String? = nil) { + + init( + id: UUID = UUID(), conversationId: UUID, messageId: UUID? = nil, toolName: String, + inputJSON: String? = nil, outputJSON: String? = nil, + label: String = "", statusRaw: String = "success", sequence: Int = 0 + ) { self.id = id self.conversationId = conversationId self.messageId = messageId self.toolName = toolName self.inputJSON = inputJSON self.outputJSON = outputJSON + self.label = label + self.statusRaw = statusRaw + self.sequence = sequence self.createdAt = Date() } } diff --git a/PulseLoop/PulseLoop.entitlements b/PulseLoop/PulseLoop.entitlements index df7e3dc..5f7f529 100644 --- a/PulseLoop/PulseLoop.entitlements +++ b/PulseLoop/PulseLoop.entitlements @@ -2,6 +2,8 @@ + com.apple.developer.weatherkit + com.apple.security.application-groups group.xyz.sakshambhutani.pulseloop2 diff --git a/PulseLoop/Views/CoachUsageSheet.swift b/PulseLoop/Views/CoachUsageSheet.swift new file mode 100644 index 0000000..2987297 --- /dev/null +++ b/PulseLoop/Views/CoachUsageSheet.swift @@ -0,0 +1,183 @@ +import SwiftUI + +/// Per-conversation token/cost transparency. Shows the provider + model(s), total +/// input/output tokens, and total cost, with a per-message breakdown. Tolerates +/// all-nil accounting (older conversations, on-device turns) by rendering "—". +struct CoachUsageSheet: View { + let conversation: CoachConversation? + let messages: [CoachMessage] + let settings: CoachSettings + @Environment(\.dismiss) private var dismiss + + /// Assistant/error rows that carried token accounting. + private var accountedMessages: [CoachMessage] { + messages.filter { $0.inputTokens != nil || $0.outputTokens != nil || $0.costUSD != nil } + } + + private var totalInputTokens: Int { + if let convo = conversation, convo.totalInputTokens > 0 { return convo.totalInputTokens } + return messages.compactMap(\.inputTokens).reduce(0, +) + } + + private var totalOutputTokens: Int { + if let convo = conversation, convo.totalOutputTokens > 0 { return convo.totalOutputTokens } + return messages.compactMap(\.outputTokens).reduce(0, +) + } + + private var totalCost: Double? { + if let convo = conversation, convo.totalCostUSD > 0 { return convo.totalCostUSD } + let costs = messages.compactMap(\.costUSD) + return costs.isEmpty ? nil : costs.reduce(0, +) + } + + /// Distinct model names seen across the conversation's turns, in order. + private var models: [String] { + var seen: [String] = [] + for m in messages { + if let model = m.modelUsed, !model.isEmpty, !seen.contains(model) { seen.append(model) } + } + if seen.isEmpty { seen.append(settings.model) } + return seen + } + + private var providerLabel: String { + messages.compactMap(\.providerUsed).first + .flatMap { CoachProviderMode(rawValue: $0)?.label } + ?? settings.providerMode.label + } + + /// The catalog can't price on-device/offline/custom models. Show the note when + /// a turn burned tokens but has no cost, or the provider inherently lacks it. + private var showsCostNote: Bool { + let providerLacksCost = settings.providerMode == .appleOnDevice || settings.providerMode == .offlineStub + let anyTokenlessCost = accountedMessages.contains { + ($0.inputTokens != nil || $0.outputTokens != nil) && $0.costUSD == nil + } + return providerLacksCost || anyTokenlessCost + } + + private static let tokenFormatter: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .decimal + f.groupingSeparator = "," + return f + }() + + private func formatTokens(_ value: Int) -> String { + Self.tokenFormatter.string(from: NSNumber(value: value)) ?? "\(value)" + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 12) { + summaryCard + if showsCostNote { costNote } + if !accountedMessages.isEmpty { breakdownCard } + } + .padding(16) + } + .scrollContentBackground(.hidden) + .background(PulseColors.background) + .navigationTitle("Usage & cost") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } } + } + } + .presentationDetents([.medium, .large]) + } + + private var summaryCard: some View { + VStack(spacing: 0) { + usageRow(label: "Provider", value: providerLabel) + divider + usageRow(label: models.count > 1 ? "Models" : "Model", value: models.joined(separator: ", ")) + divider + usageRow(label: "Input tokens", value: formatTokens(totalInputTokens), mono: true) + divider + usageRow(label: "Output tokens", value: formatTokens(totalOutputTokens), mono: true) + divider + usageRow( + label: "Estimated cost", + value: totalCost.map { String(format: "$%.4f", $0) } ?? "—", + mono: true + ) + } + .padding(.horizontal, 16).padding(.vertical, 4) + .background(PulseColors.card, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(PulseColors.borderSubtle, lineWidth: 1)) + } + + private var costNote: some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "info.circle").font(.system(size: 11)).foregroundStyle(PulseColors.textMuted) + Text("Cost estimates aren't available for on-device or custom models.") + .font(.system(size: 12)).foregroundStyle(PulseColors.textMuted) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(PulseColors.textMuted.opacity(0.10), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + private var breakdownCard: some View { + VStack(alignment: .leading, spacing: 0) { + DisclosureGroup { + VStack(alignment: .leading, spacing: 10) { + ForEach(accountedMessages) { message in + messageBreakdownRow(message) + } + } + .padding(.top, 8) + } label: { + Text("Per-message breakdown") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(PulseColors.textPrimary) + } + .tint(PulseColors.textSecondary) + } + .padding(16) + .background(PulseColors.card, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(PulseColors.borderSubtle, lineWidth: 1)) + } + + private func messageBreakdownRow(_ message: CoachMessage) -> some View { + let preview = message.body.replacingOccurrences(of: "\n", with: " ").prefix(30) + let tokens = "\(message.inputTokens ?? 0) in · \(message.outputTokens ?? 0) out" + let cost = message.costUSD.map { String(format: "$%.4f", $0) } ?? "—" + return VStack(alignment: .leading, spacing: 2) { + Text(preview.isEmpty ? "(no text)" : String(preview)) + .font(.system(size: 12)) + .foregroundStyle(PulseColors.textPrimary) + .lineLimit(1) + HStack { + Text(tokens) + .font(.system(size: 11).monospacedDigit()) + .foregroundStyle(PulseColors.textMuted) + Spacer() + Text(cost) + .font(.system(size: 11).monospacedDigit()) + .foregroundStyle(PulseColors.textMuted) + } + } + } + + private func usageRow(label: String, value: String, mono: Bool = false) -> some View { + HStack { + Text(label) + .font(.system(size: 14)) + .foregroundStyle(PulseColors.textSecondary) + Spacer(minLength: 12) + Text(value) + .font(mono ? .system(size: 14).monospacedDigit() : .system(size: 14)) + .foregroundStyle(PulseColors.textPrimary) + .multilineTextAlignment(.trailing) + } + .padding(.vertical, 12) + } + + private var divider: some View { + Rectangle().fill(PulseColors.borderSubtle).frame(height: 1) + } +} diff --git a/PulseLoop/Views/CoachView.swift b/PulseLoop/Views/CoachView.swift index 2a107b6..b0e0a87 100644 --- a/PulseLoop/Views/CoachView.swift +++ b/PulseLoop/Views/CoachView.swift @@ -12,6 +12,7 @@ private let coldStartPrompts = [ ] struct CoachView: View { + @Binding var path: NavigationPath @Environment(\.modelContext) private var modelContext @Environment(RingSyncCoordinator.self) private var coordinator @Query(sort: \CoachMessage.createdAt) private var allMessages: [CoachMessage] @@ -20,6 +21,7 @@ struct CoachView: View { @State private var viewModel = CoachViewModel() @State private var activeConversationId: UUID? @State private var showHistory = false + @State private var showUsage = false @State private var keyboardHeight: CGFloat = 0 @State private var nav = CoachNavigation.shared @State private var settingsStore = CoachSettingsStore.shared @@ -78,7 +80,8 @@ struct CoachView: View { message: message, onChipTap: { send($0) }, onConfirm: { viewModel.confirmPendingAction(message, context: modelContext) }, - onCancel: { viewModel.cancelPendingAction(message, context: modelContext) } + onCancel: { viewModel.cancelPendingAction(message, context: modelContext) }, + onOpenWorkout: { openWorkout($0) } ).id(message.id) } if viewModel.isSending { @@ -149,6 +152,26 @@ struct CoachView: View { activeConversationId = id } } + .sheet(isPresented: $showUsage) { + CoachUsageSheet( + conversation: activeConversation, + messages: messages, + settings: settingsStore.settings + ) + } + } + + /// The currently selected conversation object (for usage totals). + private var activeConversation: CoachConversation? { + guard let id = activeConversationId else { return conversations.first } + return conversations.first { $0.id == id } + } + + /// Push the activity detail for a workout logged in chat, dismissing the + /// composer/keyboard first so the transition reads cleanly. + private func openWorkout(_ id: UUID) { + composerFocused = false + path.append(AppRoute.activityDetail(id)) } private var header: some View { @@ -159,6 +182,10 @@ struct CoachView: View { Text("Using your latest ring sync").font(.system(size: 11)).foregroundStyle(PulseColors.textMuted) } Spacer() + Button { composerFocused = false; showUsage = true } label: { + Image(systemName: "info.circle").font(.system(size: 16)).foregroundStyle(PulseColors.textSecondary) + .frame(width: 36, height: 36).overlay(Circle().stroke(PulseColors.borderSubtle, lineWidth: 1)) + } Button { newConversation() } label: { Image(systemName: "plus").font(.system(size: 16)).foregroundStyle(PulseColors.textSecondary) .frame(width: 36, height: 36).overlay(Circle().stroke(PulseColors.borderSubtle, lineWidth: 1)) @@ -422,6 +449,17 @@ struct CoachBubble: View { var onChipTap: ((String) -> Void)? var onConfirm: (() -> Void)? var onCancel: (() -> Void)? + var onOpenWorkout: ((UUID) -> Void)? + + /// Activity ids logged/edited by this turn — drive the in-chat workout card. + private var loggedActivityIds: [UUID] { + guard let json = message.loggedActivityIdsJSON, let data = json.data(using: .utf8) else { return [] } + return (try? JSONDecoder().decode([UUID].self, from: data)) ?? [] + } + + private var isAssistantOrError: Bool { + message.role == "assistant" || message.role == "error" + } private var structured: CoachResponse? { message.role == "assistant" ? CoachResponse.decode(fromJSON: message.cardsJSON) : nil @@ -452,6 +490,12 @@ struct CoachBubble: View { onCancel: { onCancel?() } ) } + if isAssistantOrError { + ForEach(loggedActivityIds, id: \.self) { id in + CoachWorkoutCard(activityId: id, onOpen: { onOpenWorkout?($0) }) + } + CoachToolTraceDisclosure(messageId: message.id) + } } if message.role != "user" { Spacer(minLength: 40) } } @@ -538,25 +582,77 @@ struct CoachErrorBubble: View { } } -/// Live progress strip shown while a turn runs (in-process trace). +/// Live progress strip shown while a turn runs (in-process trace). Folds the +/// event stream into completed tool steps (shown with a status icon) above the +/// current phase's spinner row, so the user sees the turn's work accumulate. struct CoachTraceStrip: View { let events: [CoachTraceEvent] - private var label: String { - events.last(where: { $0.status != .done })?.label ?? "Thinking…" + /// One finished tool step: a friendly label plus success/failure. + private struct Step: Identifiable { + let id = UUID() + let label: String + let failed: Bool + } + + /// Fold the event stream into completed tool steps. A `.runningTool` opens a + /// step; the matching `.completedTool`/`.failedTool` (same toolName) closes the + /// most recent still-open step. Shows the last ≤4 finished steps. + private var completedSteps: [Step] { + var open: [(toolName: String?, label: String)] = [] + var done: [Step] = [] + for event in events { + switch event.status { + case .runningTool: + open.append((event.toolName, event.label)) + case .completedTool, .failedTool: + if let index = open.lastIndex(where: { $0.toolName == event.toolName }) { + let entry = open.remove(at: index) + done.append(Step(label: entry.label, failed: event.status == .failedTool)) + } else { + done.append(Step(label: event.label, failed: event.status == .failedTool)) + } + default: + break + } + } + return Array(done.suffix(4)) + } + + /// Current phase label for the spinner row (thinking / writing / working). + private var currentLabel: String { + events.last(where: { $0.status == .thinking || $0.status == .writingAnswer })?.label + ?? events.last(where: { $0.status == .runningTool })?.label + ?? "Thinking…" } var body: some View { - HStack(spacing: 8) { - ProgressView().controlSize(.small).tint(PulseColors.accent) - Text(label) - .font(.system(size: 12)) - .foregroundStyle(PulseColors.textMuted) - Spacer(minLength: 0) + VStack(alignment: .leading, spacing: 6) { + ForEach(completedSteps) { step in + HStack(spacing: 6) { + Image(systemName: step.failed ? "xmark.circle" : "checkmark.circle") + .font(.system(size: 11)) + .foregroundStyle(step.failed ? PulseColors.danger : PulseColors.success) + Text(step.label) + .font(.system(size: 12)) + .foregroundStyle(PulseColors.textSecondary) + .lineLimit(1) + Spacer(minLength: 0) + } + } + HStack(spacing: 8) { + ProgressView().controlSize(.small).tint(PulseColors.accent) + Text(currentLabel) + .font(.system(size: 12)) + .foregroundStyle(PulseColors.textMuted) + .lineLimit(1) + Spacer(minLength: 0) + } } .padding(.horizontal, 14).padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) .background(PulseColors.card, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous).stroke(PulseColors.borderSubtle, lineWidth: 1)) + .animation(.easeOut(duration: 0.18), value: completedSteps.count) } } diff --git a/PulseLoop/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index f6238a4..e159bb5 100644 --- a/PulseLoop/Views/RootViews.swift +++ b/PulseLoop/Views/RootViews.swift @@ -169,7 +169,7 @@ struct MainTabView: View { ActivityView(path: $path).tag(MainTab.activity) SleepView().tag(MainTab.sleep) if coachEnabled { - CoachView().tag(MainTab.coach) + CoachView(path: $path).tag(MainTab.coach) } } .tabViewStyle(.page(indexDisplayMode: .never)) diff --git a/PulseLoopTests/CoachTransparencyTests.swift b/PulseLoopTests/CoachTransparencyTests.swift new file mode 100644 index 0000000..a482f8c --- /dev/null +++ b/PulseLoopTests/CoachTransparencyTests.swift @@ -0,0 +1,132 @@ +import Foundation +import SwiftData +import XCTest +@testable import PulseLoop + +/// WS2 — chat transparency: persisted tool-call metadata (label/status/sequence) +/// and the workout ids a turn logs (`TurnResult.loggedActivityIds`). +@MainActor +final class CoachTransparencyTests: XCTestCase { + + private func writeFlags() -> CoachFeatureFlags { + var s = TestSupport.enabledCoachSettings() + s.enableWriteTools = true + return CoachFeatureFlags(settings: s, hasAPIKey: true) + } + + private func packet(_ c: ModelContext) -> CoachContextPacket { CoachContextBuilder.build(context: c) } + + private func finalJSON() -> String { + """ + {"response_type":"insight","title":"Done","summary":"Logged it.","bullets":[],\ + "chart":null,"safety_note":null,"data_quality_note":null,"sources":[],\ + "follow_up_chips":[],"actions_taken":[],"confidence":"high"} + """ + } + + // MARK: persistTrace writes label / status / sequence + + func testPersistTraceWritesLabelStatusSequence() throws { + let c = try TestSupport.makeContext() + let vm = CoachViewModel() + let convoId = UUID() + let messageId = UUID() + let now = Date() + + let trace = [ + CoachToolCallTrace(toolName: "get_daily_summary", label: "Got HR data", status: "success", + argsRedacted: "{}", resultSummary: "hr, steps", startedAt: now, finishedAt: now), + CoachToolCallTrace(toolName: "make_chart", label: "Drew chart", status: "error", + argsRedacted: "{}", resultSummary: "error: boom", startedAt: now, finishedAt: now), + ] + vm.persistTrace(trace, messageId: messageId, conversationId: convoId, context: c) + try c.save() + + let saved = try c.fetch(FetchDescriptor( + sortBy: [SortDescriptor(\.sequence, order: .forward)])) + XCTAssertEqual(saved.count, 2) + XCTAssertEqual(saved[0].label, "Got HR data") + XCTAssertEqual(saved[0].statusRaw, "success") + XCTAssertEqual(saved[0].sequence, 0) + XCTAssertEqual(saved[1].label, "Drew chart") + XCTAssertEqual(saved[1].statusRaw, "error") + XCTAssertEqual(saved[1].sequence, 1) + XCTAssertTrue(saved.allSatisfy { $0.messageId == messageId }) + } + + // MARK: TurnResult.loggedActivityIds populated by create tool + + func testCreateActivityPopulatesLoggedActivityIds() async throws { + let c = try TestSupport.makeContext() + let flags = writeFlags() + let today = CoachDataAccess.localDateString(Date()) + let createArgs = #"{"activity_type":"run","date":"\#(today)","start_time":null,"duration_min":30,"distance_km":5,"notes":"","confidence":"high"}"# + let stub = StubResponsesClient([ + OpenAIResponse(id: "r1", outputItems: [ + .functionCall(.init(name: "create_activity_session_from_description", callID: "c1", arguments: createArgs))]), + OpenAIResponse(id: "r2", outputItems: [.message(text: finalJSON())]), + ]) + let o = CoachOrchestrator(client: stub, registry: ToolRegistry(flags: flags), flags: flags, + toolContext: ToolExecutionContext(modelContext: c, flags: flags)) + let result = await o.runTurn(userText: "log a 30 min run today", packet: packet(c), recentMessages: []) + + XCTAssertEqual(result.loggedActivityIds.count, 1) + let sessions = ActivityRepository.sessions(context: c) + XCTAssertEqual(result.loggedActivityIds.first, sessions.first?.id) + } + + // MARK: TurnResult.loggedActivityIds populated by an immediate (today) update + + func testUpdateTodaySessionPopulatesLoggedActivityIds() async throws { + let c = try TestSupport.makeContext() + let flags = writeFlags() + let session = ActivitySession(type: "run", status: .finished, startedAt: Date(), endedAt: Date()) + c.insert(session) + try c.save() + + let updateArgs = #"{"activity_id":"\#(session.id.uuidString)","type":"cycle","notes":null,"distance_km":null,"duration_min":null,"perceived_effort":null,"start_time":null,"reason":"fix"}"# + let stub = StubResponsesClient([ + OpenAIResponse(id: "r1", outputItems: [ + .functionCall(.init(name: "update_activity_session", callID: "c1", arguments: updateArgs))]), + OpenAIResponse(id: "r2", outputItems: [.message(text: finalJSON())]), + ]) + let o = CoachOrchestrator(client: stub, registry: ToolRegistry(flags: flags), flags: flags, + toolContext: ToolExecutionContext(modelContext: c, flags: flags)) + let result = await o.runTurn(userText: "change today's run to a ride", packet: packet(c), recentMessages: []) + + XCTAssertEqual(result.loggedActivityIds, [session.id]) + } + + // MARK: an old-session update stays a pending confirmation, not a logged id + + func testUpdateOldSessionDoesNotLogActivityId() async throws { + let c = try TestSupport.makeContext() + let flags = writeFlags() + let session = ActivitySession(type: "run", status: .finished, startedAt: TestSupport.day(-5), endedAt: TestSupport.day(-5)) + c.insert(session) + try c.save() + + let updateArgs = #"{"activity_id":"\#(session.id.uuidString)","type":"cycle","notes":null,"distance_km":null,"duration_min":null,"perceived_effort":null,"start_time":null,"reason":"fix"}"# + let stub = StubResponsesClient([ + OpenAIResponse(id: "r1", outputItems: [ + .functionCall(.init(name: "update_activity_session", callID: "c1", arguments: updateArgs))]), + OpenAIResponse(id: "r2", outputItems: [.message(text: finalJSON())]), + ]) + let o = CoachOrchestrator(client: stub, registry: ToolRegistry(flags: flags), flags: flags, + toolContext: ToolExecutionContext(modelContext: c, flags: flags)) + let result = await o.runTurn(userText: "fix that old run", packet: packet(c), recentMessages: []) + + XCTAssertTrue(result.loggedActivityIds.isEmpty) + XCTAssertEqual(result.pendingActions.first?.kind, .updateActivitySession) + } + + // MARK: encodeActivityIds round-trips (and stays nil for the empty case) + + func testEncodeActivityIdsRoundTrips() throws { + XCTAssertNil(CoachViewModel.encodeActivityIds([])) + let ids = [UUID(), UUID()] + let json = try XCTUnwrap(CoachViewModel.encodeActivityIds(ids)) + let decoded = try JSONDecoder().decode([UUID].self, from: Data(json.utf8)) + XCTAssertEqual(decoded, ids) + } +} diff --git a/PulseLoopTests/CoachUsageTests.swift b/PulseLoopTests/CoachUsageTests.swift new file mode 100644 index 0000000..b12d86b --- /dev/null +++ b/PulseLoopTests/CoachUsageTests.swift @@ -0,0 +1,187 @@ +import Foundation +import SwiftData +import XCTest +@testable import PulseLoop + +/// A valid coach_response JSON body (mirrors the one in CoachTests) so a stubbed +/// final turn parses. Duplicated here to keep this file self-contained. +private func usageFinalJSON() -> String { + """ + {"response_type":"insight","title":"Today","summary":"You did well.","bullets":["A","B"],\ + "chart":null,"safety_note":null,"data_quality_note":null,"sources":[],\ + "follow_up_chips":["More"],"actions_taken":[],"confidence":"high"} + """ +} + +// MARK: - Usage summation across a turn + +@MainActor +final class CoachUsageTallyTests: XCTestCase { + private func packet(_ c: ModelContext) -> CoachContextPacket { CoachContextBuilder.build(context: c) } + + /// A multi-call tool-loop turn must sum usage across every model call: the + /// initial tool-call send + the final send that returns the answer. + func testUsageSummedAcrossToolLoopTurn() async throws { + let c = try TestSupport.makeContext() + TestSupport.insertActivity(date: Date(), steps: 8200, into: c) + let today = CoachDataAccess.localDateString(Date()) + let flags = CoachFeatureFlags(settings: TestSupport.enabledCoachSettings(), hasAPIKey: true) + + // Two model calls, each reporting usage (including a cached-input hit). + let call1 = OpenAIResponse( + id: "r1", + outputItems: [.functionCall(.init(name: "get_daily_summary", callID: "c1", arguments: #"{"date":"\#(today)"}"#))], + usage: CoachTokenUsage(inputTokens: 100, outputTokens: 20, cachedInputTokens: 10)) + let call2 = OpenAIResponse( + id: "r2", + outputItems: [.message(text: usageFinalJSON())], + usage: CoachTokenUsage(inputTokens: 200, outputTokens: 40, cachedInputTokens: 30)) + let stub = StubResponsesClient([call1, call2]) + + let o = CoachOrchestrator(client: stub, registry: ToolRegistry(flags: flags), flags: flags, + toolContext: ToolExecutionContext(modelContext: c, flags: flags)) + let result = await o.runTurn(userText: "today?", packet: packet(c), recentMessages: []) + + let usage = try XCTUnwrap(result.usage) + XCTAssertEqual(usage.inputTokens, 300) // 100 + 200 + XCTAssertEqual(usage.outputTokens, 60) // 20 + 40 + XCTAssertEqual(usage.cachedInputTokens, 40) // 10 + 30 + } + + /// A turn whose calls report no usage (e.g. on-device) leaves `usage` nil. + func testNilUsageWhenNoCallReports() async throws { + let c = try TestSupport.makeContext() + let flags = CoachFeatureFlags(settings: TestSupport.enabledCoachSettings(), hasAPIKey: true) + let stub = StubResponsesClient([OpenAIResponse(id: "r1", outputItems: [.message(text: usageFinalJSON())])]) + let o = CoachOrchestrator(client: stub, registry: ToolRegistry(flags: flags), flags: flags, + toolContext: ToolExecutionContext(modelContext: c, flags: flags)) + let result = await o.runTurn(userText: "hi", packet: packet(c), recentMessages: []) + XCTAssertNil(result.usage) + } +} + +// MARK: - OpenAIResponse.parse usage fixture + +final class OpenAIResponseUsageParseTests: XCTestCase { + func testParsesUsageBlock() throws { + let data = Data(""" + {"id":"resp_1","output":[{"type":"message","content":[{"type":"output_text","text":"hi"}]}], + "usage":{"input_tokens":1234,"output_tokens":567,"input_tokens_details":{"cached_tokens":800}}} + """.utf8) + let response = try OpenAIResponse.parse(data) + let usage = try XCTUnwrap(response.usage) + XCTAssertEqual(usage.inputTokens, 1234) + XCTAssertEqual(usage.outputTokens, 567) + XCTAssertEqual(usage.cachedInputTokens, 800) + XCTAssertNil(usage.reportedCostUSD) + } + + func testNoUsageBlockLeavesNil() throws { + let data = Data(#"{"id":"resp_2","output":[{"type":"message","content":[{"type":"output_text","text":"hi"}]}]}"#.utf8) + let response = try OpenAIResponse.parse(data) + XCTAssertNil(response.usage) + } +} + +// MARK: - Pricing catalog lookup + +final class CoachPricingCatalogTests: XCTestCase { + /// 1M input tokens (no cache) prices at exactly the catalog's input rate. + func testExactMatchUsesCatalogRate() throws { + let price = try XCTUnwrap(CoachPricingCatalog.price(for: "gpt-5.4")) + let cost = try XCTUnwrap(CoachPricingCatalog.cost( + model: "gpt-5.4", usage: CoachTokenUsage(inputTokens: 1_000_000, outputTokens: 0))) + XCTAssertEqual(cost, price.inputPer1M, accuracy: 1e-9) + } + + /// A Custom slug that isn't an exact key prices off its longest matching prefix. + func testLongestPrefixFallback() throws { + let base = try XCTUnwrap(CoachPricingCatalog.price(for: "openai/gpt-5.5")) + let variant = try XCTUnwrap(CoachPricingCatalog.price(for: "openai/gpt-5.5-preview")) + XCTAssertEqual(variant, base) + } + + /// The `:online` web-search suffix is stripped before lookup. + func testOnlineSuffixStripped() throws { + let plain = try XCTUnwrap(CoachPricingCatalog.price(for: "anthropic/claude-sonnet-4.6")) + let online = try XCTUnwrap(CoachPricingCatalog.price(for: "anthropic/claude-sonnet-4.6:online")) + XCTAssertEqual(online, plain) + } + + /// An unknown free-form model returns nil (UI shows "cost unavailable"). + func testUnknownModelReturnsNil() { + XCTAssertNil(CoachPricingCatalog.price(for: "totally-made-up/model-x")) + XCTAssertNil(CoachPricingCatalog.cost( + model: "totally-made-up/model-x", usage: CoachTokenUsage(inputTokens: 1000, outputTokens: 100))) + } + + /// On-device / offline models always cost $0. + func testOnDeviceIsZero() throws { + let cost = try XCTUnwrap(CoachPricingCatalog.cost( + model: "apple-on-device", usage: CoachTokenUsage(inputTokens: 5000, outputTokens: 500))) + XCTAssertEqual(cost, 0, accuracy: 1e-12) + let offline = try XCTUnwrap(CoachPricingCatalog.cost( + model: "offline-stub", usage: CoachTokenUsage(inputTokens: 5000, outputTokens: 500))) + XCTAssertEqual(offline, 0, accuracy: 1e-12) + } + + /// Cached input bills at the (cheaper) cached rate; the cost formula combines + /// uncached input + cached input + output. Asserted via the catalog's own + /// rates so the check survives a later real-pricing fill. + func testCachedInputUsesCachedRate() throws { + let model = "gpt-5.4" + let price = try XCTUnwrap(CoachPricingCatalog.price(for: model)) + let usage = CoachTokenUsage(inputTokens: 1_000_000, outputTokens: 500_000, cachedInputTokens: 400_000) + // Explicit Double locals: mixing untyped integer literals with the Double + // rates through `??`/`*`/`+`/`/` in one expression makes literal-overload + // resolution blow up the type-checker on slow CI runners. + let cachedRate: Double = price.cachedInputPer1M ?? price.inputPer1M + let uncachedInputCost: Double = 600_000 * price.inputPer1M + let cachedInputCost: Double = 400_000 * cachedRate + let outputCost: Double = 500_000 * price.outputPer1M + let expected: Double = (uncachedInputCost + cachedInputCost + outputCost) / 1_000_000 + let cost = try XCTUnwrap(CoachPricingCatalog.cost(model: model, usage: usage)) + XCTAssertEqual(cost, expected, accuracy: 1e-9) + } +} + +// MARK: - Compact context builder limits + +@MainActor +final class CoachContextBudgetTests: XCTestCase { + func testCompactBudgetLimitsPacketContents() throws { + let c = try TestSupport.makeContext() + let now = Date() + + // 5 memories (compact keeps ≤3), 4 finished workouts (compact keeps ≤2). + for i in 0..<5 { + c.insert(CoachMemory(key: "k\(i)", value: String(repeating: "x", count: 300), importance: 5 - i)) + } + for i in 0..<4 { + let start = now.addingTimeInterval(Double(-i) * 3600) + c.insert(ActivitySession( + type: "run", status: .finished, startedAt: start, endedAt: start.addingTimeInterval(1800))) + } + try? c.save() + + let compact = CoachContextBuilder.build(context: c, now: now, budget: .compact) + XCTAssertLessThanOrEqual(compact.memories.count, 3) + XCTAssertLessThanOrEqual(compact.recentWorkouts.count, 2) + XCTAssertLessThanOrEqual(compact.dataQualityWarnings.count, 2) + // Long memory values are truncated under the compact value cap (120 chars). + for memory in compact.memories { + XCTAssertLessThanOrEqual(memory.value.count, 121) // 120 + ellipsis + } + + // Full budget keeps more. + let full = CoachContextBuilder.build(context: c, now: now, budget: .full) + XCTAssertGreaterThan(full.memories.count, compact.memories.count) + } + + func testCompactBudgetCapsConversationSummary() throws { + let c = try TestSupport.makeContext() + let long = String(repeating: "summary ", count: 200) // >400 chars + let compact = CoachContextBuilder.build(context: c, conversationSummary: long, budget: .compact) + XCTAssertLessThanOrEqual((compact.conversationSummary ?? "").count, 401) // 400 + ellipsis + } +} diff --git a/PulseLoopTests/CoachWS3Tests.swift b/PulseLoopTests/CoachWS3Tests.swift new file mode 100644 index 0000000..4aaffe4 --- /dev/null +++ b/PulseLoopTests/CoachWS3Tests.swift @@ -0,0 +1,222 @@ +import Foundation +import SwiftData +import XCTest +@testable import PulseLoop + +private struct WS3KeyStore: APIKeyStore { + let key: String? + func readKey() throws -> String? { key } + func saveKey(_ key: String) throws {} + func deleteKey() throws {} +} + +private func ws3SummaryJSON(title: String = "Solid night", body: String = "Slept well.", chips: [String] = ["Why?"]) -> String { + let chipsJSON = (try? String(data: JSONEncoder().encode(chips), encoding: .utf8)!) ?? "[]" + return "{\"title\":\"\(title)\",\"body\":\"\(body)\",\"chips\":\(chipsJSON)}" +} + +// MARK: - Variety hints (pure) + +final class CoachVarietyHintsTests: XCTestCase { + func testAngleIsDeterministicForSameSeed() { + let a = CoachVarietyHints.angle(seed: "2026-07-08morning") + let b = CoachVarietyHints.angle(seed: "2026-07-08morning") + XCTAssertEqual(a, b) + XCTAssertFalse(a.isEmpty) + XCTAssertTrue(CoachVarietyHints.angles.contains(a)) + } + + func testDifferentSeedsRotateAngles() { + // Across a spread of seeds we should touch more than one angle (rotation), + // and every result must be a real angle. + var seen = Set() + for i in 0..<40 { + let angle = CoachVarietyHints.angle(seed: "seed-\(i)") + XCTAssertTrue(CoachVarietyHints.angles.contains(angle)) + seen.insert(angle) + } + XCTAssertGreaterThan(seen.count, 1) + } + + func testFnv1aIsStableAcrossCalls() { + XCTAssertEqual(CoachVarietyHints.fnv1a("pulseloop"), CoachVarietyHints.fnv1a("pulseloop")) + XCTAssertNotEqual(CoachVarietyHints.fnv1a("a"), CoachVarietyHints.fnv1a("b")) + } +} + +// MARK: - Sleep-sync gate + signature regeneration + +@MainActor +final class CoachSleepGateTests: XCTestCase { + private func service(_ c: ModelContext, json: String = ws3SummaryJSON()) -> CoachSummaryService { + let store = CoachSettingsStore(defaults: UserDefaults(suiteName: UUID().uuidString)!) + store.settings.coachMasterEnabled = true + return CoachSummaryService( + modelContext: c, + keyStore: WS3KeyStore(key: "sk-test"), + settingsStore: store, + clientFactory: { _ in StubResponsesClient([OpenAIResponse(id: "s", outputItems: [.message(text: json)])]) } + ) + } + + /// Insert a night that wakes at 06:00 today, spanning `minutes`; returns endAt. + @discardableResult + private func seedNight(_ c: ModelContext, minutes: Int, deep: Int) -> Date { + let wake = Calendar.current.date(bySettingHour: 6, minute: 0, second: 0, of: TestSupport.day(0)) ?? Date() + let start = wake.addingTimeInterval(-Double(minutes) * 60) + var stages: [SleepStage] = Array(repeating: .light, count: max(0, minutes - deep)) + stages += Array(repeating: .deep, count: deep) + let session = TestSupport.insertSleep(nightStart: start, stages: stages, into: c) + return session.endAt + } + + private func sleepCount(_ c: ModelContext) throws -> Int { + (try c.fetch(FetchDescriptor())).filter { $0.kind == "sleep_day" }.count + } + + private func setFullSync(_ c: ModelContext, at date: Date?) { + let device = Device() + device.lastFullSyncAt = date + c.insert(device) + try? c.save() + } + + /// Streaming device (no full-sync stamp): gate falls back to now >= endAt + 2h. + func testNoFullSyncStampFallsBackToTwoHours() async throws { + let c = try TestSupport.makeContext() + let end = seedNight(c, minutes: 420, deep: 90) // ~7h night ending 06:00 + let svc = service(c) + + // Just after wake (30min < 2h fallback) → gated off. + await svc.refreshSleepDayIfNeeded(now: end.addingTimeInterval(40 * 60)) + XCTAssertEqual(try sleepCount(c), 0) + + // 2h+ after wake → generates. + await svc.refreshSleepDayIfNeeded(now: end.addingTimeInterval(2 * 3600 + 60)) + XCTAssertEqual(try sleepCount(c), 1) + } + + /// Full-sync stamp BEFORE the night ended → mid-sync, gated off. + func testFullSyncBeforeEndIsGated() async throws { + let c = try TestSupport.makeContext() + let end = seedNight(c, minutes: 420, deep: 90) + setFullSync(c, at: end.addingTimeInterval(-30 * 60)) // stamped before wake + let svc = service(c) + + await svc.refreshSleepDayIfNeeded(now: end.addingTimeInterval(3 * 3600)) + XCTAssertEqual(try sleepCount(c), 0) + } + + /// Full-sync stamp AT/AFTER the night ended (and past the 30min floor) → generates. + func testFullSyncAfterEndGenerates() async throws { + let c = try TestSupport.makeContext() + let end = seedNight(c, minutes: 420, deep: 90) + setFullSync(c, at: end.addingTimeInterval(10 * 60)) // stamped after wake + let svc = service(c) + + await svc.refreshSleepDayIfNeeded(now: end.addingTimeInterval(45 * 60)) + XCTAssertEqual(try sleepCount(c), 1) + } + + /// now < endAt + 30min → gated off regardless of sync. + func testBeforeThirtyMinuteFloorIsGated() async throws { + let c = try TestSupport.makeContext() + let end = seedNight(c, minutes: 420, deep: 90) + setFullSync(c, at: end.addingTimeInterval(5 * 60)) + let svc = service(c) + + await svc.refreshSleepDayIfNeeded(now: end.addingTimeInterval(10 * 60)) // <30min + XCTAssertEqual(try sleepCount(c), 0) + } + + /// A late sync that grows the night (new signature) after the minInterval floor + /// triggers exactly one corrective regeneration. + func testSignatureRegenerationAfterGrowth() async throws { + let c = try TestSupport.makeContext() + let end = seedNight(c, minutes: 300, deep: 60) + setFullSync(c, at: end.addingTimeInterval(10 * 60)) + let svc = service(c) + + let t0 = end.addingTimeInterval(45 * 60) + await svc.refreshSleepDayIfNeeded(now: t0) + var rows = (try c.fetch(FetchDescriptor())).filter { $0.kind == "sleep_day" } + XCTAssertEqual(rows.count, 1) + let firstSig = rows.first!.dataSignature + + // Same data again → no regeneration (signature unchanged, still one row). + await svc.refreshSleepDayIfNeeded(now: t0.addingTimeInterval(3 * 3600)) + rows = (try c.fetch(FetchDescriptor())).filter { $0.kind == "sleep_day" } + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows.first!.dataSignature, firstSig) + + // Late sync grows the SAME night (same waking day) → new signature. Replace the + // session so there's exactly one for the scope, then regenerate past the 2h floor. + for s in SleepRepository.sessions(context: c) { c.delete(s) } + try c.save() + seedNight(c, minutes: 420, deep: 90) + // Backdate the existing card so it's clearly past the 2h minInterval floor. + rows.first!.updatedAt = t0.addingTimeInterval(-3 * 3600) + try c.save() + await svc.refreshSleepDayIfNeeded(now: t0.addingTimeInterval(3 * 3600)) + rows = (try c.fetch(FetchDescriptor())).filter { $0.kind == "sleep_day" } + XCTAssertEqual(rows.count, 1) // corrective upsert, not a duplicate + XCTAssertNotEqual(rows.first!.dataSignature, firstSig) // regenerated with the grown night + } +} + +// MARK: - sleepDataSynced gate (morning notifications) + +@MainActor +final class CoachSleepDataSyncedTests: XCTestCase { + private func service(_ c: ModelContext) -> CoachNotificationService { + CoachNotificationService( + modelContext: c, coordinator: nil, + keyStore: WS3KeyStore(key: "sk-test"), + settingsStore: CoachSettingsStore(defaults: UserDefaults(suiteName: UUID().uuidString)!) + ) + } + + private func seedRecentNight(_ c: ModelContext, now: Date) -> Date { + let start = now.addingTimeInterval(-8 * 3600) + let session = TestSupport.insertSleep(nightStart: start, stages: Array(repeating: .light, count: 420), into: c) + return session.endAt + } + + /// No session at all → passes (nothing to wait on). + func testNoSessionPasses() throws { + let c = try TestSupport.makeContext() + XCTAssertTrue(service(c).sleepDataSynced(now: Date())) + } + + /// Recent night, no device/full-sync stamp → passes permissively. + func testNoDeviceStampPasses() throws { + let c = try TestSupport.makeContext() + let now = Date() + _ = seedRecentNight(c, now: now) + XCTAssertTrue(service(c).sleepDataSynced(now: now)) + } + + /// Recent night, full sync BEFORE the night ended → fails (still syncing). + func testFullSyncBeforeEndFails() throws { + let c = try TestSupport.makeContext() + let now = Date() + let end = seedRecentNight(c, now: now) + let device = Device() + device.lastFullSyncAt = end.addingTimeInterval(-20 * 60) + c.insert(device) + try c.save() + XCTAssertFalse(service(c).sleepDataSynced(now: now)) + } + + /// Recent night, full sync AT/AFTER the night ended → passes. + func testFullSyncAfterEndPasses() throws { + let c = try TestSupport.makeContext() + let now = Date() + let end = seedRecentNight(c, now: now) + let device = Device() + device.lastFullSyncAt = end.addingTimeInterval(30 * 60) + c.insert(device) + try c.save() + XCTAssertTrue(service(c).sleepDataSynced(now: now)) + } +} diff --git a/docs/getting-started/ios.md b/docs/getting-started/ios.md index e6ffb96..c30f851 100644 --- a/docs/getting-started/ios.md +++ b/docs/getting-started/ios.md @@ -52,6 +52,15 @@ to request an invite, or ask on the [Discord](https://discord.gg/t9y85ebaKD). give it your team and a unique bundle ID as well, or the build will fail to sign. + !!! note "WeatherKit capability (optional weather-aware coaching)" + The Coach can share your **city name and current weather** with the AI + provider (opt-in under **Settings → Coach → "Use location & weather"**). + This uses **WeatherKit**, which needs the *WeatherKit* capability enabled + on your App ID in the Apple Developer portal — the `PulseLoop.entitlements` + already declares `com.apple.developer.weatherkit`. If you skip this step + the coach still works; the weather block is simply omitted. Only the + reverse-geocoded city is ever shared — never your precise location. + 4. Build & run (`⌘R`). 5. On first launch, complete onboarding, then keep the ring nearby — the app auto-scans and connects when Bluetooth powers on.