From 5e1a1fbfea23bc22060c80200ce92a932126f636 Mon Sep 17 00:00:00 2001 From: Trent Hoverman Date: Sun, 28 Jun 2026 17:06:52 -0400 Subject: [PATCH 1/2] Add Apple on-device (FoundationModels) coach provider + richer notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "On-device (Apple)" provider that runs the AI coach entirely on the device via FoundationModels — no API key, no network, fully private. It falls back to a chosen cloud provider (OpenAI/Gemini/OpenRouter) when the local model is unavailable or a generation fails. Provider: - AppleFoundationModelsClient: a ResponsesClient adapter (one-shot, tool-less in v1) using FoundationModels guided generation for structured cards, with a text/JSON fallback. Mirrors the GeminiClient request-body translation. - AppleOnDeviceAvailability: friendly wrapper over SystemLanguageModel availability; guarded with #if canImport(FoundationModels) / @available so the project still compiles on older SDKs. - FallbackResponsesClient + CoachClientResolver: unify provider resolution across chat/summaries/notifications and add the on-device → cloud-backup chain. - Settings: On-device provider option, cloud-backup picker, privacy card. Notifications (leveraging free/private local inference): - Richer check-ins: actionable tip + tappable follow-up + adaptive skip. - New midday slot alongside morning/evening. - Proactive anomaly alerts (low SpO2, short sleep), event-driven and on-device-only, deduped per kind per day. - On-device check-ins use a BGProcessingTask (longer budget, no network required); cloud providers keep BGAppRefreshTask. Adds the com.pulseloop.coach.process background-task identifier. - Fixes a latent crash: background scheduling now gates on actual BGTask registration (the host app could otherwise submit an unregistered identifier under XCTest). Co-Authored-By: Claude Opus 4.8 --- .../Apple/AppleFoundationModelsClient.swift | 231 ++++++++++++++++++ .../Coach/Apple/AppleModelAvailability.swift | 60 +++++ .../Coach/Apple/FallbackResponsesClient.swift | 28 +++ .../Coach/Config/CoachClientResolver.swift | 99 ++++++++ .../Coach/Config/CoachFeatureFlags.swift | 11 + PulseLoop/Coach/Config/CoachSettings.swift | 14 ++ .../Coach/Config/CoachSettingsSection.swift | 105 ++++++-- .../Notifications/CoachAnomalyDetector.swift | 50 ++++ .../Notifications/CoachAnomalyMonitor.swift | 53 ++++ .../Notifications/CoachNotification.swift | 35 ++- .../CoachNotificationGenerator.swift | 54 ++++ .../CoachNotificationModels.swift | 24 +- .../CoachNotificationScheduler.swift | 47 +++- .../CoachNotificationService.swift | 132 ++++++++-- .../NotificationPromptBuilder.swift | 57 ++++- .../Coach/Summaries/CoachSummaryService.swift | 22 +- .../Coach/ViewModels/CoachViewModel.swift | 22 +- PulseLoop/Info.plist | 1 + PulseLoop/PulseLoopApp.swift | 4 + .../Settings/NotificationsSettingsView.swift | 15 ++ PulseLoopTests/CoachNotificationTests.swift | 21 +- 21 files changed, 984 insertions(+), 101 deletions(-) create mode 100644 PulseLoop/Coach/Apple/AppleFoundationModelsClient.swift create mode 100644 PulseLoop/Coach/Apple/AppleModelAvailability.swift create mode 100644 PulseLoop/Coach/Apple/FallbackResponsesClient.swift create mode 100644 PulseLoop/Coach/Config/CoachClientResolver.swift create mode 100644 PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift create mode 100644 PulseLoop/Coach/Notifications/CoachAnomalyMonitor.swift diff --git a/PulseLoop/Coach/Apple/AppleFoundationModelsClient.swift b/PulseLoop/Coach/Apple/AppleFoundationModelsClient.swift new file mode 100644 index 0000000..0354f2d --- /dev/null +++ b/PulseLoop/Coach/Apple/AppleFoundationModelsClient.swift @@ -0,0 +1,231 @@ +import Foundation +#if canImport(FoundationModels) +import FoundationModels +#endif + +/// Adapts the app's `ResponsesClient` protocol to Apple's on-device +/// FoundationModels (`LanguageModelSession`). Everything runs locally: no API +/// key, no network, fully private. +/// +/// v1 is **tool-less** — it ignores the OpenAI function / `web_search` tool +/// specs in the request body and does one-shot generation over the pre-built +/// context packet. That covers summaries, daily check-ins and plain chat; +/// chart/action/memory tools stay disabled on-device. When the request carries +/// a strict JSON schema (`text.format`), it uses FoundationModels guided +/// generation to constrain the output, falling back to schema-in-prompt text if +/// guided generation can't be built for that schema (the orchestrator's +/// `JSONRepair` then recovers the object). +/// +/// Like `GeminiClient`, one instance covers a single agent turn and accumulates +/// the conversation across `send` calls so repair turns keep prior context. +final class AppleFoundationModelsClient: ResponsesClient, @unchecked Sendable { + /// Flattened system + developer text → session instructions. + private var systemText: String = "" + /// Ordered conversation turns (role: "user" | "assistant"). + private var turns: [(role: String, text: String)] = [] + /// responseId → assistant text, so a continuation turn can replay it. + private var storedAssistant: [String: String] = [:] + + /// On-device context window is small; keep the assembled prompt within a + /// conservative character budget (~roughly 3–4k tokens) so we degrade to a + /// shorter answer rather than overflowing the model. + private let promptCharBudget = 12_000 + + init() {} + + func send(requestBody: Data) async throws -> OpenAIResponse { + #if canImport(FoundationModels) + if #available(iOS 26.0, *) { + return try await generate(requestBody: requestBody) + } else { + throw ResponsesError.http(status: 503, body: "On-device AI requires iOS 26 or later.") + } + #else + throw ResponsesError.http(status: 503, body: "On-device AI is unavailable in this build.") + #endif + } + + // MARK: - Request-body parsing (shared, framework-independent) + + /// Splits the OpenAI-shaped body into instructions, the running transcript, + /// and the optional strict output schema. Mirrors `GeminiClient`'s setup so + /// behavior matches across providers. + private func ingest(_ requestBody: Data) throws -> (schema: [String: Any]?, schemaName: String) { + guard let req = try? JSONSerialization.jsonObject(with: requestBody) as? [String: Any] else { + throw ResponsesError.decoding("AppleFoundationModelsClient: invalid request body") + } + let input = req["input"] as? [[String: Any]] ?? [] + let previousResponseId = req["previous_response_id"] as? String + + if previousResponseId == nil { + setupConversation(from: input) + } else { + appendContinuation(previousId: previousResponseId!, input: input) + } + + let format = (req["text"] as? [String: Any])?["format"] as? [String: Any] + let schema = format?["schema"] as? [String: Any] + let schemaName = (format?["name"] as? String) ?? "Response" + return (schema, schemaName) + } + + private func setupConversation(from input: [[String: Any]]) { + turns = [] + var systemParts: [String] = [] + for item in input { + let role = item["role"] as? String ?? "" + let content = item["content"] as? String ?? "" + if role == "system" || role == "developer" { + if !content.isEmpty { systemParts.append(content) } + } else if !content.isEmpty { + turns.append((role: role == "assistant" ? "assistant" : "user", text: content)) + } + } + systemText = systemParts.joined(separator: "\n\n") + } + + private func appendContinuation(previousId: String, input: [[String: Any]]) { + if let assistant = storedAssistant[previousId] { + turns.append((role: "assistant", text: assistant)) + } + for item in input { + // Tool results can't be produced on-device (v1 is tool-less), but a + // repair/continuation turn still arrives as a plain message — fold it + // in as a user turn so context is preserved. + if let role = item["role"] as? String, let content = item["content"] as? String, !content.isEmpty { + turns.append((role: role == "assistant" ? "assistant" : "user", text: content)) + } else if (item["type"] as? String) == "function_call_output", + let output = item["output"] as? String, !output.isEmpty { + turns.append((role: "user", text: "Tool result: \(output)")) + } + } + } + + /// Renders the running transcript into a single prompt string, trimmed to the + /// on-device character budget (oldest turns dropped first). + 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)) + } + return prompt + } + + #if canImport(FoundationModels) + // MARK: - Generation + + @available(iOS 26.0, *) + private func generate(requestBody: Data) async throws -> OpenAIResponse { + guard SystemLanguageModel.default.isAvailable else { + throw ResponsesError.http(status: 503, body: AppleOnDeviceAvailability.current.statusMessage) + } + + let (schema, schemaName) = try ingest(requestBody) + let prompt = buildPrompt() + guard !prompt.isEmpty else { throw ResponsesError.emptyOutput } + + let instructions = systemText.isEmpty + ? "You are a concise, supportive personal health coach." + : systemText + let session = LanguageModelSession(instructions: instructions) + + let text: String + if let schema, let generated = try? await respondGuided(session: session, prompt: prompt, schema: schema, name: schemaName) { + text = generated + } else if let schema { + // Guided generation unavailable for this schema — ask for raw JSON and + // let the orchestrator's JSONRepair recover it. + let directive = "\n\nRespond with a single JSON object only — no prose, no code fences." + text = try await respondText(session: session, prompt: prompt + jsonHint(schema) + directive) + } else { + text = try await respondText(session: session, prompt: prompt) + } + + let responseId = UUID().uuidString + storedAssistant[responseId] = text + return OpenAIResponse(id: responseId, outputItems: [.message(text: text)]) + } + + @available(iOS 26.0, *) + private func respondText(session: LanguageModelSession, prompt: String) async throws -> String { + do { + let response = try await session.respond(to: prompt) + return response.content + } catch { + throw ResponsesError.transport(error) + } + } + + /// Constrained decoding against a runtime-built schema. Returns the generated + /// content as a JSON string the existing decoders understand. Returns `nil` + /// (caller falls back) if the schema can't be translated. + @available(iOS 26.0, *) + private func respondGuided(session: LanguageModelSession, prompt: String, schema: [String: Any], name: String) async throws -> String? { + guard let dynamic = dynamicSchema(from: schema, name: name) else { return nil } + let generationSchema = try GenerationSchema(root: dynamic, dependencies: []) + let response = try await session.respond(to: prompt, schema: generationSchema) + return response.content.jsonString + } + + /// Recursively translates the strict JSON Schema the orchestrator emits into + /// a FoundationModels `DynamicGenerationSchema`. Supports the subset the coach + /// uses (objects, arrays, strings + enums, numbers, booleans). + @available(iOS 26.0, *) + private func dynamicSchema(from schema: [String: Any], name: String) -> DynamicGenerationSchema? { + let type = schema["type"] as? String + switch type { + case "object": + let props = schema["properties"] as? [String: Any] ?? [:] + let required = Set(schema["required"] as? [String] ?? []) + var properties: [DynamicGenerationSchema.Property] = [] + for (key, raw) in props { + guard let sub = raw as? [String: Any], + let child = dynamicSchema(from: sub, name: "\(name)_\(key)") else { continue } + properties.append(DynamicGenerationSchema.Property( + name: key, + description: sub["description"] as? String, + schema: child, + isOptional: !required.contains(key) + )) + } + return DynamicGenerationSchema(name: name, description: schema["description"] as? String, properties: properties) + case "array": + guard let items = schema["items"] as? [String: Any], + let element = dynamicSchema(from: items, name: "\(name)_item") else { return nil } + return DynamicGenerationSchema(arrayOf: element) + case "string": + if let choices = schema["enum"] as? [String], !choices.isEmpty { + return DynamicGenerationSchema(name: name, anyOf: choices) + } + return DynamicGenerationSchema(type: String.self) + case "integer": + return DynamicGenerationSchema(type: Int.self) + case "number": + return DynamicGenerationSchema(type: Double.self) + case "boolean": + return DynamicGenerationSchema(type: Bool.self) + default: + // Union types like ["string", "null"] collapse to the first concrete + // type; anything unrecognized becomes a free string. + if let types = schema["type"] as? [String], + let concrete = types.first(where: { $0 != "null" }) { + return dynamicSchema(from: schema.merging(["type": concrete]) { _, new in new }, name: name) + } + return DynamicGenerationSchema(type: String.self) + } + } + #endif + + /// A compact textual hint describing the desired JSON shape, used only on the + /// non-guided fallback path. + private func jsonHint(_ schema: [String: Any]) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: schema, options: [.sortedKeys]), + let str = String(data: data, encoding: .utf8) else { return "" } + return "\n\nMatch this JSON schema exactly:\n\(str)" + } +} diff --git a/PulseLoop/Coach/Apple/AppleModelAvailability.swift b/PulseLoop/Coach/Apple/AppleModelAvailability.swift new file mode 100644 index 0000000..4fc0496 --- /dev/null +++ b/PulseLoop/Coach/Apple/AppleModelAvailability.swift @@ -0,0 +1,60 @@ +import Foundation +#if canImport(FoundationModels) +import FoundationModels +#endif + +/// Friendly wrapper over Apple's on-device `SystemLanguageModel` availability so +/// the rest of the app can gate the `appleOnDevice` provider without importing +/// FoundationModels everywhere — and so the project still compiles on an SDK or +/// device where the framework is absent. +enum AppleOnDeviceAvailability: Equatable { + case available + case deviceNotEligible + case appleIntelligenceNotEnabled + case modelNotReady + /// Built against an SDK / running on an OS without FoundationModels. + case frameworkUnavailable + + /// Current availability of the default system language model. + static var current: AppleOnDeviceAvailability { + #if canImport(FoundationModels) + if #available(iOS 26.0, *) { + switch SystemLanguageModel.default.availability { + case .available: + return .available + case .unavailable(let reason): + switch reason { + case .deviceNotEligible: return .deviceNotEligible + case .appleIntelligenceNotEnabled: return .appleIntelligenceNotEnabled + case .modelNotReady: return .modelNotReady + @unknown default: return .modelNotReady + } + @unknown default: + return .modelNotReady + } + } else { + return .frameworkUnavailable + } + #else + return .frameworkUnavailable + #endif + } + + var isAvailable: Bool { self == .available } + + /// One-line, user-facing status for the Settings UI. + var statusMessage: String { + switch self { + case .available: + return "Ready · runs entirely on your iPhone" + case .deviceNotEligible: + return "Not supported on this device — needs an Apple Intelligence-capable iPhone." + case .appleIntelligenceNotEnabled: + return "Turn on Apple Intelligence in Settings to enable on-device coaching." + case .modelNotReady: + return "On-device model is preparing — try again shortly." + case .frameworkUnavailable: + return "On-device AI requires iOS 26 or later." + } + } +} diff --git a/PulseLoop/Coach/Apple/FallbackResponsesClient.swift b/PulseLoop/Coach/Apple/FallbackResponsesClient.swift new file mode 100644 index 0000000..4851049 --- /dev/null +++ b/PulseLoop/Coach/Apple/FallbackResponsesClient.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Wraps a primary `ResponsesClient` with a secondary one used only when the +/// primary throws. Built for the on-device provider: try Apple's local model +/// first, and if it's unavailable or a generation fails, transparently retry the +/// same request on a chosen cloud provider. +/// +/// Each `send` carries the full request body, so the secondary can serve a turn +/// the primary never saw. (A mid-conversation failover loses prior on-device +/// turn state — acceptable: the common case is the primary being unusable from +/// the start, which the resolver routes straight to the secondary instead.) +final class FallbackResponsesClient: ResponsesClient, @unchecked Sendable { + private let primary: ResponsesClient + private let secondary: ResponsesClient + + init(primary: ResponsesClient, secondary: ResponsesClient) { + self.primary = primary + self.secondary = secondary + } + + func send(requestBody: Data) async throws -> OpenAIResponse { + do { + return try await primary.send(requestBody: requestBody) + } catch { + return try await secondary.send(requestBody: requestBody) + } + } +} diff --git a/PulseLoop/Coach/Config/CoachClientResolver.swift b/PulseLoop/Coach/Config/CoachClientResolver.swift new file mode 100644 index 0000000..f4d9e90 --- /dev/null +++ b/PulseLoop/Coach/Config/CoachClientResolver.swift @@ -0,0 +1,99 @@ +import Foundation + +/// Single source of truth for "which `ResponsesClient` runs, given the user's +/// settings + stored keys." Shared by the chat view-model, the summary service, +/// and the notification service so provider logic (including the on-device +/// cloud-backup fallback) lives in exactly one place. +/// +/// The returned `key` is a readiness sentinel: non-`nil` means the provider can +/// run (used to build `CoachFeatureFlags.hasAPIKey`). For cloud providers it's +/// the actual key; for on-device it's a `"on-device"` placeholder. +@MainActor +enum CoachClientResolver { + static func resolve( + settings: CoachSettings, + openAIKeyStore: APIKeyStore, + geminiKeyStore: APIKeyStore, + openRouterKeyStore: APIKeyStore, + openAIClientFactory: (String) -> ResponsesClient = { OpenAIResponsesClient(apiKey: $0) } + ) -> (key: String?, client: ResponsesClient) { + switch settings.providerMode { + case .appleOnDevice: + let onDevice = AppleFoundationModelsClient() + let available = AppleOnDeviceAvailability.current.isAvailable + // A usable cloud backup is one whose key is actually present. + let backup = settings.appleFallbackProvider.flatMap { mode in + usableCloudClient( + mode, settings: settings, + openAIKeyStore: openAIKeyStore, geminiKeyStore: geminiKeyStore, + openRouterKeyStore: openRouterKeyStore, openAIClientFactory: openAIClientFactory + ) + } + if available { + let client: ResponsesClient = backup.map { FallbackResponsesClient(primary: onDevice, secondary: $0) } ?? onDevice + return ("on-device", client) + } else if let backup { + // On-device unusable on this device → run the cloud backup directly. + return ("on-device", backup) + } else { + // Nothing usable: hand back the on-device client (it throws a clear + // error) and signal "not ready" so generators degrade to scripted. + return (nil, onDevice) + } + default: + return directClient( + settings.providerMode, settings: settings, + openAIKeyStore: openAIKeyStore, geminiKeyStore: geminiKeyStore, + openRouterKeyStore: openRouterKeyStore, openAIClientFactory: openAIClientFactory + ) + } + } + + /// Builds a client for a concrete (non-on-device) provider, mirroring the + /// prior per-call-site logic. Returns a client even when the key is absent + /// (`key == nil`); the feature-flags gate prevents an empty-key call. + private static func directClient( + _ mode: CoachProviderMode, + settings: CoachSettings, + openAIKeyStore: APIKeyStore, + geminiKeyStore: APIKeyStore, + openRouterKeyStore: APIKeyStore, + openAIClientFactory: (String) -> ResponsesClient + ) -> (key: String?, client: ResponsesClient) { + switch mode { + case .userGeminiKey: + let key = (try? geminiKeyStore.readKey()) ?? nil + return (key, GeminiClient(apiKey: key ?? "")) + case .userOpenRouterKey: + let key = (try? openRouterKeyStore.readKey()) ?? nil + return (key, OpenRouterClient( + apiKey: key ?? "", + model: settings.openRouterModel, + privacyRouting: settings.orEnablePrivacyRouting, + providerSort: settings.orProviderSort)) + default: + // userOpenAIKey / offlineStub / backendProxy (and appleOnDevice never + // reaches here) all use the OpenAI key + factory. + let key = (try? openAIKeyStore.readKey()) ?? nil + return (key, openAIClientFactory(key ?? "")) + } + } + + /// A cloud client only when its key is present — used to decide whether the + /// on-device backup is actually usable. + private static func usableCloudClient( + _ mode: CoachProviderMode, + settings: CoachSettings, + openAIKeyStore: APIKeyStore, + geminiKeyStore: APIKeyStore, + openRouterKeyStore: APIKeyStore, + openAIClientFactory: (String) -> ResponsesClient + ) -> ResponsesClient? { + let (key, client) = directClient( + mode, settings: settings, + openAIKeyStore: openAIKeyStore, geminiKeyStore: geminiKeyStore, + openRouterKeyStore: openRouterKeyStore, openAIClientFactory: openAIClientFactory + ) + return key != nil ? client : nil + } +} diff --git a/PulseLoop/Coach/Config/CoachFeatureFlags.swift b/PulseLoop/Coach/Config/CoachFeatureFlags.swift index 5034e84..aee9e12 100644 --- a/PulseLoop/Coach/Config/CoachFeatureFlags.swift +++ b/PulseLoop/Coach/Config/CoachFeatureFlags.swift @@ -19,6 +19,10 @@ struct CoachFeatureFlags { switch settings.providerMode { case .offlineStub: return false + case .appleOnDevice: + // Ready when the local model is usable, or a cloud backup is set (its + // key is checked at resolve time; a missing key degrades to scripted). + return AppleOnDeviceAvailability.current.isAvailable || settings.appleFallbackProvider != nil case .userOpenAIKey, .userGeminiKey, .userOpenRouterKey: return hasAPIKey case .backendProxy: @@ -40,6 +44,13 @@ struct CoachFeatureFlags { switch settings.providerMode { case .offlineStub: return "Offline — scripted replies only." + case .appleOnDevice: + let availability = AppleOnDeviceAvailability.current + if availability.isAvailable { return availability.statusMessage } + if let backup = settings.appleFallbackProvider { + return "On-device unavailable — using \(backup.label) backup." + } + return availability.statusMessage case .userOpenAIKey: return hasAPIKey ? "Ready · \(settings.model)" : "Add an OpenAI key to enable." case .userGeminiKey: diff --git a/PulseLoop/Coach/Config/CoachSettings.swift b/PulseLoop/Coach/Config/CoachSettings.swift index efc34a0..e6f3eae 100644 --- a/PulseLoop/Coach/Config/CoachSettings.swift +++ b/PulseLoop/Coach/Config/CoachSettings.swift @@ -5,6 +5,7 @@ import Foundation /// and is treated as disabled until implemented. enum CoachProviderMode: String, Codable, CaseIterable, Identifiable { case offlineStub + case appleOnDevice case userOpenAIKey case userGeminiKey case userOpenRouterKey @@ -15,6 +16,7 @@ enum CoachProviderMode: String, Codable, CaseIterable, Identifiable { var label: String { switch self { case .offlineStub: return "Offline" + case .appleOnDevice: return "On-device (Apple)" case .userOpenAIKey: return "OpenAI (your key)" case .userGeminiKey: return "Gemini (your key)" case .userOpenRouterKey: return "OpenRouter (your key)" @@ -110,6 +112,10 @@ struct CoachSettings: Codable, Equatable { /// Off by default — users who only want metrics get a coach-free app. var coachMasterEnabled: Bool = false var providerMode: CoachProviderMode = .userOpenAIKey + /// Cloud backup for the on-device provider: when on-device is unavailable or a + /// generation fails, fall back to this provider (using its stored key). `nil` + /// = no backup. Only the key-based cloud providers are valid values. + var appleFallbackProvider: CoachProviderMode? = nil /// Default matches the web app; user-configurable (never hard-coded in the client). var model: String = CoachModel.gpt54.rawValue /// Optional reasoning effort hint ("low"/"medium"/"high") when the model supports it. @@ -131,7 +137,12 @@ struct CoachSettings: Codable, Equatable { // Milestone D — automated daily check-in notifications. var notificationsEnabled: Bool = false var morningHour: Int = 8 + var middayHour: Int = 13 var eveningHour: Int = 19 + /// Proactive, event-driven anomaly alerts (resting-HR drift, low SpO₂, poor + /// 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 /// 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. @@ -151,6 +162,7 @@ struct CoachSettings: Codable, Equatable { let d = CoachSettings.default coachMasterEnabled = try c.decodeIfPresent(Bool.self, forKey: .coachMasterEnabled) ?? d.coachMasterEnabled providerMode = try c.decodeIfPresent(CoachProviderMode.self, forKey: .providerMode) ?? d.providerMode + appleFallbackProvider = try c.decodeIfPresent(CoachProviderMode.self, forKey: .appleFallbackProvider) model = try c.decodeIfPresent(String.self, forKey: .model) ?? d.model reasoningEffort = try c.decodeIfPresent(String.self, forKey: .reasoningEffort) enableWebSearch = try c.decodeIfPresent(Bool.self, forKey: .enableWebSearch) ?? d.enableWebSearch @@ -162,7 +174,9 @@ struct CoachSettings: Codable, Equatable { maxRounds = try c.decodeIfPresent(Int.self, forKey: .maxRounds) ?? d.maxRounds notificationsEnabled = try c.decodeIfPresent(Bool.self, forKey: .notificationsEnabled) ?? d.notificationsEnabled morningHour = try c.decodeIfPresent(Int.self, forKey: .morningHour) ?? d.morningHour + 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 } } diff --git a/PulseLoop/Coach/Config/CoachSettingsSection.swift b/PulseLoop/Coach/Config/CoachSettingsSection.swift index 487efdc..12bf00a 100644 --- a/PulseLoop/Coach/Config/CoachSettingsSection.swift +++ b/PulseLoop/Coach/Config/CoachSettingsSection.swift @@ -51,6 +51,21 @@ struct CoachSettingsSection: View { !OpenRouterModel.allCases.contains { $0.rawValue == store.settings.model } } + /// Which provider's key field to surface: the active cloud provider, or — in + /// on-device mode — the chosen cloud backup (so its key can be entered). + private var effectiveKeyProvider: CoachProviderMode? { + store.settings.providerMode == .appleOnDevice + ? store.settings.appleFallbackProvider + : store.settings.providerMode + } + + private var fallbackBinding: Binding { + Binding( + get: { store.settings.appleFallbackProvider }, + set: { store.settings.appleFallbackProvider = $0 } + ) + } + var body: some View { SectionHeader(title: "AI Coach", action: nil) StatusCopy(title: "Status", body: flags.statusLine) @@ -67,33 +82,52 @@ struct CoachSettingsSection: View { .tint(PulseColors.accent) } - labeledRow("Model") { - Picker("Model", selection: modelPickerBinding) { - switch store.settings.providerMode { - case .userGeminiKey: - ForEach(GeminiModel.allCases) { model in - Text(model.label).tag(model.rawValue) - } - case .userOpenRouterKey: - ForEach(OpenRouterModel.allCases) { model in - Text(model.label).tag(model.rawValue) - } - Text("Custom…").tag(customModelTag) - default: - ForEach(CoachModel.allCases) { model in - Text(model.label).tag(model.rawValue) + // On-device has a single fixed model — show a privacy/availability + // card + a cloud-backup chooser instead of a model picker. + if store.settings.providerMode == .appleOnDevice { + appleOnDeviceCard + labeledRow("Cloud backup") { + Picker("Cloud backup", selection: fallbackBinding) { + Text("None").tag(CoachProviderMode?.none) + Text("OpenAI").tag(CoachProviderMode?.some(.userOpenAIKey)) + Text("Gemini").tag(CoachProviderMode?.some(.userGeminiKey)) + Text("OpenRouter").tag(CoachProviderMode?.some(.userOpenRouterKey)) + } + .pickerStyle(.menu) + .tint(PulseColors.accent) + } + } else { + labeledRow("Model") { + Picker("Model", selection: modelPickerBinding) { + switch store.settings.providerMode { + case .userGeminiKey: + ForEach(GeminiModel.allCases) { model in + Text(model.label).tag(model.rawValue) + } + case .userOpenRouterKey: + ForEach(OpenRouterModel.allCases) { model in + Text(model.label).tag(model.rawValue) + } + Text("Custom…").tag(customModelTag) + default: + ForEach(CoachModel.allCases) { model in + Text(model.label).tag(model.rawValue) + } } } + .pickerStyle(.menu) + .tint(PulseColors.accent) } - .pickerStyle(.menu) - .tint(PulseColors.accent) } if store.settings.providerMode == .userOpenRouterKey, isCustomOpenRouterModel { customModelField } - if store.settings.providerMode == .userOpenAIKey { + // The key field tracks the *effective* provider — the active cloud + // provider, or (in on-device mode) the chosen cloud backup — so the + // backup's key can be entered without leaving on-device mode. + if effectiveKeyProvider == .userOpenAIKey { apiKeyField( placeholder: "sk-…", hint: "Stored only in your device Keychain. Used to call OpenAI directly.", @@ -104,7 +138,7 @@ struct CoachSettingsSection: View { onSave: saveOpenAIKey, onRemove: removeOpenAIKey ) - } else if store.settings.providerMode == .userGeminiKey { + } else if effectiveKeyProvider == .userGeminiKey { apiKeyField( placeholder: "AIza…", hint: "Stored only in your device Keychain. Used to call Gemini directly.", @@ -115,7 +149,7 @@ struct CoachSettingsSection: View { onSave: saveGeminiKey, onRemove: removeGeminiKey ) - } else if store.settings.providerMode == .userOpenRouterKey { + } else if effectiveKeyProvider == .userOpenRouterKey { apiKeyField( placeholder: "sk-or-v1-…", hint: "Stored only in your device Keychain. Used to call OpenRouter directly.", @@ -259,6 +293,37 @@ struct CoachSettingsSection: View { .onAppear(perform: refreshKeyState) } + // MARK: - On-device (Apple) info card + + /// Privacy + availability panel shown when the on-device provider is picked. + /// Replaces the model picker (the model is fixed) and explains the v1 limits. + private var appleOnDeviceCard: some View { + let availability = AppleOnDeviceAvailability.current + return VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: availability.isAvailable ? "lock.iphone" : "exclamationmark.triangle") + .font(.system(size: 15)) + .foregroundStyle(availability.isAvailable ? PulseColors.accent : PulseColors.danger) + Text(availability.isAvailable ? "On-device · private" : "On-device unavailable") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(PulseColors.textPrimary) + } + Text(availability.isAvailable + ? "Your health data is analyzed entirely on your iPhone and never leaves the device. No API key, no network — works offline and free of charge." + : availability.statusMessage) + .font(.system(size: 12)) + .foregroundStyle(PulseColors.textSecondary) + Text("On-device coaching gives summaries, check-ins and chat. Charts, AI actions and web search need a cloud provider.") + .font(.caption) + .foregroundStyle(PulseColors.textMuted) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(PulseColors.card) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 20, style: .continuous).stroke(PulseColors.borderSubtle, lineWidth: 1)) + } + // MARK: - Custom OpenRouter model field private var customModelField: some View { diff --git a/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift b/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift new file mode 100644 index 0000000..d0813b3 --- /dev/null +++ b/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift @@ -0,0 +1,50 @@ +import Foundation + +/// A notable, gently-actionable pattern detected in the user's recent data. +enum CoachAnomalyKind: String, Codable, Equatable { + case lowSpO2 + case poorSleep + /// Reserved for a future baseline-aware detector (needs multi-day history). + case restingHRDrift +} + +struct CoachAnomaly: Equatable { + let kind: CoachAnomalyKind + /// Short, factual, already-grounded description used both for the prompt and + /// the deterministic fallback copy. + let facts: String + + /// Once-per-kind-per-day dedupe key (stored as a notification record slot). + var dedupeKey: String { "anomaly:\(kind.rawValue)" } +} + +/// Pure, conservative anomaly detection over the notification context packet. +/// Thresholds are intentionally cautious — a missed alert is far better than a +/// false alarm on health data. Returns at most one anomaly, highest-priority +/// first. (Resting-HR drift is deferred — it needs a multi-day baseline the +/// 12-hour packet doesn't carry.) +enum CoachAnomalyDetector { + static func detect(_ packet: NotificationContextPacket) -> CoachAnomaly? { + // 1. Low SpO₂ — most clinically meaningful. Require a few readings so a + // single noisy sample doesn't trigger an alert. + if packet.spo2Last12h.count >= 3, let lowest = packet.spo2Last12h.min, lowest < 90 { + let pct = Int(lowest.rounded()) + return CoachAnomaly( + kind: .lowSpO2, + facts: "The lowest blood-oxygen reading in the last 12 hours was \(pct)%, below the typical 95–100% range." + ) + } + + // 2. Short sleep — fires after a sleep download, when it's most relevant. + if let sleep = packet.latestSleep, (1..<300).contains(sleep.totalMin) { + let h = sleep.totalMin / 60, m = sleep.totalMin % 60 + let target = packet.goals.sleepHours + return CoachAnomaly( + kind: .poorSleep, + facts: "Last night's sleep was \(h)h \(m)m, well under the \(Int(target))h target." + ) + } + + return nil + } +} diff --git a/PulseLoop/Coach/Notifications/CoachAnomalyMonitor.swift b/PulseLoop/Coach/Notifications/CoachAnomalyMonitor.swift new file mode 100644 index 0000000..98c12a2 --- /dev/null +++ b/PulseLoop/Coach/Notifications/CoachAnomalyMonitor.swift @@ -0,0 +1,53 @@ +import Foundation +import SwiftData + +/// Subscribes to `PulseEventBus` and runs a proactive anomaly check when fresh +/// SpO₂ or sleep data lands, debounced so a burst of streamed packets coalesces +/// into one check. The service self-gates (on-device only, enabled, deduped), so +/// this just nudges it. Lives for the app lifetime, like `CoachSummaryCoordinator`. +@MainActor +final class CoachAnomalyMonitor { + private let service: CoachNotificationService + private var streamTask: Task? + private var debounce: Task? + + /// Debounce window — long enough for an SpO₂ batch / sleep download to settle. + private let debounceSeconds: UInt64 = 20 + + init(context: ModelContext) { + self.service = CoachNotificationService(modelContext: context) + } + + func start() { + guard streamTask == nil else { return } + streamTask = Task { [weak self] in + let stream = await PulseEventBus.shared.stream() + for await event in stream { + self?.handle(event) + } + } + } + + private func handle(_ event: PulseEvent) { + switch event { + case .spo2Result, .sleepTimeline, .historyMeasurement: + scheduleCheck() + default: + break + } + } + + private func scheduleCheck() { + // Cheap pre-gate so we don't spin up a debounce when the feature is off. + let settings = CoachSettingsStore.shared.settings + guard settings.coachMasterEnabled, settings.proactiveAlertsEnabled, + settings.providerMode == .appleOnDevice else { return } + + debounce?.cancel() + debounce = Task { [weak self] in + try? await Task.sleep(nanoseconds: (self?.debounceSeconds ?? 20) * 1_000_000_000) + guard let self, !Task.isCancelled else { return } + await service.runProactiveAlertIfNeeded() + } + } +} diff --git a/PulseLoop/Coach/Notifications/CoachNotification.swift b/PulseLoop/Coach/Notifications/CoachNotification.swift index 050cfde..04b635f 100644 --- a/PulseLoop/Coach/Notifications/CoachNotification.swift +++ b/PulseLoop/Coach/Notifications/CoachNotification.swift @@ -1,9 +1,34 @@ import Foundation -/// A generated daily check-in: a push-notification title + body. +/// A generated daily check-in: a push-notification title + body, plus the +/// richer fields the on-device model can produce for free — a concrete tip, a +/// tappable follow-up question, and an adaptive `skip` flag (the model may +/// decide there's nothing worth interrupting the user for). struct CoachNotification: Codable, Equatable { var title: String var body: String + var tip: String + var followUp: String + var skip: Bool + + init(title: String, body: String, tip: String = "", followUp: String = "", skip: Bool = false) { + self.title = title + self.body = body + self.tip = tip + self.followUp = followUp + self.skip = skip + } + + /// Tolerant decode — older payloads (and cloud providers) may omit the richer + /// fields, so they default rather than failing the whole object. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + title = try c.decode(String.self, forKey: .title) + body = try c.decode(String.self, forKey: .body) + tip = try c.decodeIfPresent(String.self, forKey: .tip) ?? "" + followUp = try c.decodeIfPresent(String.self, forKey: .followUp) ?? "" + skip = try c.decodeIfPresent(Bool.self, forKey: .skip) ?? false + } func encodedJSON() -> String? { (try? JSONEncoder().encode(self)).flatMap { String(data: $0, encoding: .utf8) } @@ -36,8 +61,14 @@ enum CoachNotificationSchema { "properties": [ "title": ["type": "string", "maxLength": 50], "body": ["type": "string", "maxLength": 160], + "tip": ["type": "string", "maxLength": 80, + "description": "One concrete, actionable suggestion, or empty string."], + "followUp": ["type": "string", "maxLength": 80, + "description": "A short question the user can tap to start a chat, or empty string."], + "skip": ["type": "boolean", + "description": "True only when there is genuinely nothing useful or new to say."], ], - "required": ["title", "body"], + "required": ["title", "body", "tip", "followUp", "skip"], "additionalProperties": false, ] } diff --git a/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift b/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift index e9d0731..c9da420 100644 --- a/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift +++ b/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift @@ -30,6 +30,53 @@ enum CoachNotificationGenerator { } } + /// Proactive anomaly alert. Same shape as a check-in, but framed as a calm, + /// non-alarming heads-up. Falls back to the grounded `anomaly.facts` copy if + /// the model is disabled or fails. + static func generateAnomaly( + anomaly: CoachAnomaly, + packet: NotificationContextPacket, + flags: CoachFeatureFlags, + client: ResponsesClient + ) async -> CoachNotification { + guard flags.coachEnabled else { return scriptedAnomaly(anomaly) } + do { + let input: [[String: Any]] = [ + OpenAIRequestBuilder.message(role: "system", content: NotificationPromptBuilder.anomalySystemPrompt()), + OpenAIRequestBuilder.message(role: "developer", content: NotificationPromptBuilder.anomalyDeveloperMessage(packet: packet, anomaly: anomaly)), + ] + let body = try OpenAIRequestBuilder.data( + model: flags.model, input: input, tools: [], + textFormat: CoachNotificationSchema.textFormat, + previousResponseId: nil, reasoningEffort: flags.settings.reasoningEffort + ) + let response = try await client.send(requestBody: body) + // An anomaly alert always sends — ignore a stray skip from the model. + var n = CoachNotification.decode(fromJSON: response.outputText) ?? scriptedAnomaly(anomaly) + n.skip = false + return n + } catch { + return scriptedAnomaly(anomaly) + } + } + + static func scriptedAnomaly(_ anomaly: CoachAnomaly) -> CoachNotification { + switch anomaly.kind { + case .lowSpO2: + return CoachNotification(title: "A quick heads-up", + body: anomaly.facts, + tip: "Rest a moment and re-measure when you're settled.", + followUp: "Want to talk through what might affect your readings?") + case .poorSleep: + return CoachNotification(title: "Short night", + body: anomaly.facts, + tip: "Go easy today and aim for an earlier wind-down.", + followUp: "Want tips for a better night tonight?") + case .restingHRDrift: + return CoachNotification(title: "A quick heads-up", body: anomaly.facts) + } + } + /// Grounded, deterministic fallback. static func scripted(slot: CoachNotificationSlot, packet: NotificationContextPacket) -> CoachNotification { let name = packet.profileName.map { ", \($0)" } ?? "" @@ -42,6 +89,13 @@ enum CoachNotificationGenerator { } return CoachNotification(title: "Good morning\(name)", body: "Ready to start the day? Take a measurement and I'll help you plan it.") + case .midday: + if let steps = packet.today.steps { + return CoachNotification(title: "Midday check-in", + body: "\(steps) steps so far. A short walk now keeps the momentum going.") + } + return CoachNotification(title: "Midday check-in", + body: "How's the day going? A quick movement break is a great reset.") case .evening: if let steps = packet.today.steps { let goal = packet.goals.stepsDaily diff --git a/PulseLoop/Coach/Notifications/CoachNotificationModels.swift b/PulseLoop/Coach/Notifications/CoachNotificationModels.swift index 1ba64ad..064b0d3 100644 --- a/PulseLoop/Coach/Notifications/CoachNotificationModels.swift +++ b/PulseLoop/Coach/Notifications/CoachNotificationModels.swift @@ -4,18 +4,20 @@ import SwiftData /// Which daily check-in this is. enum CoachNotificationSlot: String, Codable, CaseIterable { case morning + case midday case evening var label: String { rawValue.capitalized } /// The active slot for `date` given the user's configured hours, or nil when - /// outside both windows. Morning window = [morningHour, morningHour+4]; - /// evening = [eveningHour, eveningHour+4] (clamped to the same day). + /// outside all windows. Each window opens at its hour and stays open ~4h, + /// clamped to end before the next window opens so they never overlap. static func current( - for date: Date, morningHour: Int, eveningHour: Int, calendar: Calendar = .current + for date: Date, morningHour: Int, middayHour: Int, eveningHour: Int, calendar: Calendar = .current ) -> CoachNotificationSlot? { let hour = calendar.component(.hour, from: date) - if hour >= morningHour, hour <= min(morningHour + 4, eveningHour - 1) { return .morning } + if hour >= morningHour, hour <= min(morningHour + 4, middayHour - 1) { return .morning } + if hour >= middayHour, hour <= min(middayHour + 4, eveningHour - 1) { return .midday } if hour >= eveningHour, hour <= eveningHour + 4 { return .evening } return nil } @@ -23,9 +25,9 @@ enum CoachNotificationSlot: String, Codable, CaseIterable { /// Next time a slot window opens at or after `date` — used to schedule the /// next background wake. static func nextWindowStart( - after date: Date, morningHour: Int, eveningHour: Int, calendar: Calendar = .current + after date: Date, morningHour: Int, middayHour: Int, eveningHour: Int, calendar: Calendar = .current ) -> Date { - let candidates = [morningHour, eveningHour].sorted() + let candidates = [morningHour, middayHour, eveningHour].sorted() for hour in candidates { if let d = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: date), d > date { return d @@ -48,9 +50,15 @@ final class CoachNotificationRecord { var body: String var createdAt: Date - init(id: UUID = UUID(), slot: CoachNotificationSlot, dateKey: String, title: String, body: String) { + convenience init(id: UUID = UUID(), slot: CoachNotificationSlot, dateKey: String, title: String, body: String) { + self.init(id: id, slotRaw: slot.rawValue, dateKey: dateKey, title: title, body: body) + } + + /// Raw-slot init for non-slot records (e.g. proactive anomaly alerts, whose + /// `slotRaw` is an `anomaly:` dedupe key rather than a daily slot). + init(id: UUID = UUID(), slotRaw: String, dateKey: String, title: String, body: String) { self.id = id - self.slotRaw = slot.rawValue + self.slotRaw = slotRaw self.dateKey = dateKey self.title = title self.body = body diff --git a/PulseLoop/Coach/Notifications/CoachNotificationScheduler.swift b/PulseLoop/Coach/Notifications/CoachNotificationScheduler.swift index 938d8de..2874374 100644 --- a/PulseLoop/Coach/Notifications/CoachNotificationScheduler.swift +++ b/PulseLoop/Coach/Notifications/CoachNotificationScheduler.swift @@ -7,34 +7,68 @@ import Foundation @MainActor final class CoachNotificationScheduler { static let shared = CoachNotificationScheduler() + /// App-refresh task (short budget) — used for the lightweight cloud providers. static let taskIdentifier = "com.pulseloop.coach.refresh" + /// Processing task (longer budget, no network required) — used for the + /// on-device provider, whose local inference needs more CPU time than a + /// network round-trip. Must also appear in Info.plist's + /// `BGTaskSchedulerPermittedIdentifiers`. + static let processingTaskIdentifier = "com.pulseloop.coach.process" /// Builds a service bound to the live model context + ring coordinator. private var serviceProvider: (() -> CoachNotificationService)? + /// Submitting a `BGTaskRequest` whose identifier wasn't registered is a hard + /// assertion crash. Registration is skipped under XCTest (the live subsystems + /// don't start), so gate scheduling on it — otherwise the test host app's + /// scene-phase `scheduleNext()` crashes. + private var isRegistered = false func register(serviceProvider: @escaping () -> CoachNotificationService) { self.serviceProvider = serviceProvider - BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.taskIdentifier, using: nil) { task in + isRegistered = true + let handler: (BGTask) -> Void = { task in // `using: nil` runs the launch handler on a private background queue, // so hop to the main actor rather than asserting we're already on it. Task { @MainActor in Self.shared.handle(task) } } + BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.taskIdentifier, using: nil, launchHandler: handler) + BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.processingTaskIdentifier, using: nil, launchHandler: handler) } - /// Queue the next wake at the next morning/evening window. No-op when disabled + /// Queue the next wake at the next check-in window. No-op when disabled /// (either the master switch or the per-feature notifications toggle is off). + /// On-device uses a processing task (longer budget); cloud uses app-refresh. func scheduleNext(now: Date = Date()) { + guard isRegistered else { return } let settings = CoachSettingsStore.shared.settings guard settings.coachMasterEnabled, settings.notificationsEnabled else { return } - let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier) - request.earliestBeginDate = CoachNotificationSlot.nextWindowStart( - after: now, morningHour: settings.morningHour, eveningHour: settings.eveningHour + + // Clear any pending request of the other kind so switching provider doesn't + // leave a stale wake queued. + cancel() + + let begin = CoachNotificationSlot.nextWindowStart( + after: now, morningHour: settings.morningHour, + middayHour: settings.middayHour, eveningHour: settings.eveningHour ) + + let request: BGTaskRequest + if settings.providerMode == .appleOnDevice { + let processing = BGProcessingTaskRequest(identifier: Self.processingTaskIdentifier) + processing.requiresNetworkConnectivity = false // local inference, no network + processing.requiresExternalPower = false + request = processing + } else { + request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier) + } + request.earliestBeginDate = begin try? BGTaskScheduler.shared.submit(request) } func cancel() { + guard isRegistered else { return } BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier) + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.processingTaskIdentifier) } private func handle(_ task: BGTask) { @@ -45,6 +79,9 @@ final class CoachNotificationScheduler { } let work = Task { _ = await service.runDueSlot() + // Background is a good moment to also catch a proactive alert; the + // call self-gates (on-device only, enabled, deduped). + _ = await service.runProactiveAlertIfNeeded() task.setTaskCompleted(success: true) } task.expirationHandler = { work.cancel() } diff --git a/PulseLoop/Coach/Notifications/CoachNotificationService.swift b/PulseLoop/Coach/Notifications/CoachNotificationService.swift index 5682e1a..2fffb87 100644 --- a/PulseLoop/Coach/Notifications/CoachNotificationService.swift +++ b/PulseLoop/Coach/Notifications/CoachNotificationService.swift @@ -14,6 +14,12 @@ final class CoachNotificationService { case skippedDuplicate case skippedDisabled case skippedNoData + /// The model decided there was nothing worth interrupting the user for. + case skippedAdaptive + /// A proactive alert fired for a detected anomaly. + case alerted(CoachAnomaly) + /// Proactive check ran but found nothing notable (or was rate-limited). + case noAnomaly } private let modelContext: ModelContext @@ -51,7 +57,10 @@ final class CoachNotificationService { func runDueSlot(force: Bool = false, now: Date = Date()) async -> Outcome { let settings = settingsStore.settings - let resolved = CoachNotificationSlot.current(for: now, morningHour: settings.morningHour, eveningHour: settings.eveningHour) + let resolved = CoachNotificationSlot.current( + for: now, morningHour: settings.morningHour, + middayHour: settings.middayHour, eveningHour: settings.eveningHour + ) guard let slot = resolved ?? (force ? forcedSlot(now: now) : nil) else { return .skippedNoSlot } if !force, isDuplicate(slot: slot, now: now) { return .skippedDuplicate } @@ -69,11 +78,55 @@ final class CoachNotificationService { let notification = await CoachNotificationGenerator.generate( slot: slot, packet: packet, flags: flags, client: activeClient ) + // Adaptive skip: when the model says there's nothing useful to add, stay + // quiet (but the test button still forces a delivery). + if !force, notification.skip { return .skippedAdaptive } + let conversation = record(notification, slot: slot, now: now) await deliver(notification, conversationId: conversation.id) return .sent(slot) } + /// Proactive, event-driven alert path. Detects a notable pattern in recent + /// data and, when on-device coaching is active, delivers a calm heads-up. + /// Gated to on-device specifically so we never fire paid cloud calls on every + /// data event; deduped to once per anomaly kind per day. + @discardableResult + func runProactiveAlertIfNeeded(force: Bool = false, now: Date = Date()) async -> Outcome { + let settings = settingsStore.settings + guard force || (settings.coachMasterEnabled + && settings.notificationsEnabled + && settings.proactiveAlertsEnabled + && settings.providerMode == .appleOnDevice + && AppleOnDeviceAvailability.current.isAvailable) else { + return .skippedDisabled + } + + let slot = forcedSlot(now: now) // only for building the context packet + let packet = NotificationContextBuilder.build(slot: slot, context: modelContext, now: now) + guard let anomaly = CoachAnomalyDetector.detect(packet) else { return .noAnomaly } + + if !force, isAnomalyDuplicate(anomaly, now: now) { return .noAnomaly } + + let (apiKey, activeClient) = resolveClient() + let flags = CoachFeatureFlags(settings: settings, hasAPIKey: apiKey != nil) + let notification = await CoachNotificationGenerator.generateAnomaly( + anomaly: anomaly, packet: packet, flags: flags, client: activeClient + ) + let conversation = recordAnomaly(notification, anomaly: anomaly, now: now) + await deliver(notification, conversationId: conversation.id) + return .alerted(anomaly) + } + + func isAnomalyDuplicate(_ anomaly: CoachAnomaly, now: Date) -> Bool { + let key = CoachNotificationRecord.dateKey(for: now) + let raw = anomaly.dedupeKey + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.dateKey == key && $0.slotRaw == raw } + ) + return ((try? modelContext.fetch(descriptor)) ?? []).isEmpty == false + } + // MARK: - Gates (testable) func isDuplicate(slot: CoachNotificationSlot, now: Date) -> Bool { @@ -93,25 +146,20 @@ final class CoachNotificationService { } private func resolveClient() -> (key: String?, client: ResponsesClient) { - switch settingsStore.settings.providerMode { - case .userGeminiKey: - let key = (try? geminiKeyStore.readKey()) ?? nil - return (key, GeminiClient(apiKey: key ?? "")) - case .userOpenRouterKey: - let key = (try? openRouterKeyStore.readKey()) ?? nil - return (key, OpenRouterClient( - apiKey: key ?? "", - model: settingsStore.settings.openRouterModel, - privacyRouting: settingsStore.settings.orEnablePrivacyRouting, - providerSort: settingsStore.settings.orProviderSort)) - default: - let key = (try? keyStore.readKey()) ?? nil - return (key, clientFactory(key ?? "")) - } + CoachClientResolver.resolve( + settings: settingsStore.settings, + openAIKeyStore: keyStore, + geminiKeyStore: geminiKeyStore, + openRouterKeyStore: openRouterKeyStore, + openAIClientFactory: clientFactory + ) } private func forcedSlot(now: Date) -> CoachNotificationSlot { - Calendar.current.component(.hour, from: now) < 14 ? .morning : .evening + let hour = Calendar.current.component(.hour, from: now) + if hour < 11 { return .morning } + if hour < 16 { return .midday } + return .evening } private func latestMeasurementTimestamp() -> Date? { @@ -143,12 +191,39 @@ final class CoachNotificationService { )) let convo = CoachConversation(title: notificationConversationTitle(notification, slot: slot, at: now)) modelContext.insert(convo) - // Render as a structured card so the chip + card UI matches the rest of the coach. + // Render as a structured card so the chip + card UI matches the rest of the + // coach. The richer tip/follow-up land in the chat thread (not the terse push). + let summary = chatSummary(for: notification) + let response = CoachResponse(responseType: .insight, title: notification.title, + summary: summary, confidence: .medium) + modelContext.insert(CoachMessage( + conversationId: convo.id, role: "assistant", + body: "\(notification.title)\n\n\(summary)", + cardsJSON: response.encodedJSON(), createdAt: now + )) + convo.updatedAt = now + try? modelContext.save() + return convo + } + + /// Persist a proactive alert (deduped by `anomaly.dedupeKey`) and seed a + /// fresh conversation, mirroring `record` for daily check-ins. + @discardableResult + func recordAnomaly(_ notification: CoachNotification, anomaly: CoachAnomaly, now: Date) -> CoachConversation { + modelContext.insert(CoachNotificationRecord( + slotRaw: anomaly.dedupeKey, dateKey: CoachNotificationRecord.dateKey(for: now), + title: notification.title, body: notification.body + )) + let f = DateFormatter() + f.dateFormat = "MMM d" + let convo = CoachConversation(title: "Heads-up · \(f.string(from: now))") + modelContext.insert(convo) + let summary = chatSummary(for: notification) let response = CoachResponse(responseType: .insight, title: notification.title, - summary: notification.body, confidence: .medium) + summary: summary, confidence: .medium) modelContext.insert(CoachMessage( conversationId: convo.id, role: "assistant", - body: "\(notification.title)\n\n\(notification.body)", + body: "\(notification.title)\n\n\(summary)", cardsJSON: response.encodedJSON(), createdAt: now )) convo.updatedAt = now @@ -164,10 +239,25 @@ final class CoachNotificationService { static let dailyCheckinsTitle = "Daily check-ins" + /// The richer body for the in-app chat card: the check-in plus the actionable + /// tip and a tappable follow-up question when present. + private func chatSummary(for n: CoachNotification) -> String { + var parts = [n.body] + if !n.tip.isEmpty { parts.append("💡 \(n.tip)") } + if !n.followUp.isEmpty { parts.append(n.followUp) } + return parts.joined(separator: "\n\n") + } + private func deliver(_ notification: CoachNotification, conversationId: UUID) async { let content = UNMutableNotificationContent() content.title = notification.title - content.body = notification.body + // Keep the push terse: body, plus the tip if it stays within a glanceable + // length. The full tip + follow-up live in the chat thread. + var pushBody = notification.body + if !notification.tip.isEmpty, pushBody.count + notification.tip.count < 150 { + pushBody += " \(notification.tip)" + } + content.body = pushBody content.sound = .default content.userInfo = [CoachNotificationService.conversationIdKey: conversationId.uuidString] let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) diff --git a/PulseLoop/Coach/Notifications/NotificationPromptBuilder.swift b/PulseLoop/Coach/Notifications/NotificationPromptBuilder.swift index 7e07ed8..20081da 100644 --- a/PulseLoop/Coach/Notifications/NotificationPromptBuilder.swift +++ b/PulseLoop/Coach/Notifications/NotificationPromptBuilder.swift @@ -4,19 +4,30 @@ import Foundation /// push notifications in the spirit of Oura/Whoop/Fitbit nudges. enum NotificationPromptBuilder { static func systemPrompt(slot: CoachNotificationSlot) -> String { - let slotGuidance = slot == .morning - ? "This is the MORNING check-in: lead with how last night's sleep went and set up the day — a light, motivating plan or one small nudge (move, hydrate, a step target)." - : "This is the EVENING check-in: recap the day's activity (steps, active minutes, workouts) and ease toward wind-down — a calm nudge about recovery or tomorrow." + let slotGuidance: String + switch slot { + case .morning: + slotGuidance = "This is the MORNING check-in: lead with how last night's sleep went and set up the day — a light, motivating plan or one small nudge (move, hydrate, a step target)." + case .midday: + slotGuidance = "This is the MIDDAY check-in: a quick pulse on how the day is going so far " + + "(movement, heart rate) and a small nudge to stay on track — a short walk, hydration, a posture/breathing reset." + case .evening: + slotGuidance = "This is the EVENING check-in: recap the day's activity (steps, active minutes, workouts) and ease toward wind-down — a calm nudge about recovery or tomorrow." + } return """ You write a single push notification for PulseLoop, a smart-ring health app. It is a short, friendly daily check-in grounded in the user's own ring data. \(slotGuidance) + Output ONLY JSON {"title","body","tip","followUp","skip"}: + - title: ≤ ~6 words. + - body: 1–2 short sentences grounded in today's real numbers (steps, sleep, heart rate, SpO2, workouts) — never generic filler. Surface ONE clear insight, not a list. + - tip: ONE concrete, immediately actionable suggestion (≤ ~12 words), or "" if none fits. + - followUp: a short, inviting question the user could tap to start a chat with you (≤ ~12 words), or "". + - skip: true ONLY if there is genuinely nothing useful or new to say right now (e.g. no fresh data, or it would just repeat an obvious point). Be conservative — default false. + Rules: - - Output ONLY JSON {"title","body"}. Title ≤ ~6 words; body 1–2 short sentences. - - Be specific to today's actual numbers (steps, sleep, heart rate, SpO2, workouts) — never generic filler. Mention a real number when you have one. - - Surface ONE clear insight or nudge, not a list. - 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. @@ -29,7 +40,39 @@ enum NotificationPromptBuilder { Context (last ~12 hours): \(json) - Write a fresh \(packet.slot) check-in now as {"title","body"}. + Write a fresh \(packet.slot) check-in now as {"title","body","tip","followUp","skip"}. + """ + } + + // MARK: - Proactive anomaly alerts + + static func anomalySystemPrompt() -> String { + """ + You write a single, gentle push notification for PulseLoop, a smart-ring health app, because something in the user's recent data is worth a soft heads-up. + + Output ONLY JSON {"title","body","tip","followUp","skip"}: + - title: ≤ ~6 words, calm and non-alarming. + - body: 1–2 short sentences naming the specific reading and what it might mean in everyday terms. + - tip: ONE gentle, concrete suggestion (rest, hydrate, re-measure, take it easy), or "". + - followUp: a short, caring question inviting the user to chat about it, or "". + - skip: false (an anomaly was detected — always send). + + Rules: + - Calm, supportive, NON-alarming. This is wellness guidance, not a diagnosis. Never imply emergency or disease. + - Use only the provided facts and numbers — never invent or escalate. + - If a reading could be a measurement glitch, it's fine to suggest re-measuring. + """ + } + + static func anomalyDeveloperMessage(packet: NotificationContextPacket, anomaly: CoachAnomaly) -> String { + let json = encode(packet) + return """ + Detected: \(anomaly.facts) + + Supporting context (last ~12 hours): + \(json) + + Write a calm heads-up now as {"title","body","tip","followUp","skip"}. """ } diff --git a/PulseLoop/Coach/Summaries/CoachSummaryService.swift b/PulseLoop/Coach/Summaries/CoachSummaryService.swift index 6535f81..e23f0bb 100644 --- a/PulseLoop/Coach/Summaries/CoachSummaryService.swift +++ b/PulseLoop/Coach/Summaries/CoachSummaryService.swift @@ -91,21 +91,13 @@ final class CoachSummaryService { } private func resolveClient() -> (key: String?, client: ResponsesClient) { - switch settingsStore.settings.providerMode { - case .userGeminiKey: - let key = (try? geminiKeyStore.readKey()) ?? nil - return (key, GeminiClient(apiKey: key ?? "")) - case .userOpenRouterKey: - let key = (try? openRouterKeyStore.readKey()) ?? nil - return (key, OpenRouterClient( - apiKey: key ?? "", - model: settingsStore.settings.openRouterModel, - privacyRouting: settingsStore.settings.orEnablePrivacyRouting, - providerSort: settingsStore.settings.orProviderSort)) - default: - let key = (try? keyStore.readKey()) ?? nil - return (key, clientFactory(key ?? "")) - } + CoachClientResolver.resolve( + settings: settingsStore.settings, + openAIKeyStore: keyStore, + geminiKeyStore: geminiKeyStore, + openRouterKeyStore: openRouterKeyStore, + openAIClientFactory: clientFactory + ) } private func generateAndUpsert( diff --git a/PulseLoop/Coach/ViewModels/CoachViewModel.swift b/PulseLoop/Coach/ViewModels/CoachViewModel.swift index 63d57bf..569c71c 100644 --- a/PulseLoop/Coach/ViewModels/CoachViewModel.swift +++ b/PulseLoop/Coach/ViewModels/CoachViewModel.swift @@ -76,21 +76,13 @@ final class CoachViewModel { // MARK: - Provider resolution private func resolveClient() -> (key: String?, client: ResponsesClient) { - switch settingsStore.settings.providerMode { - case .userGeminiKey: - let key = (try? geminiKeyStore.readKey()) ?? nil - return (key, GeminiClient(apiKey: key ?? "")) - case .userOpenRouterKey: - let key = (try? openRouterKeyStore.readKey()) ?? nil - return (key, OpenRouterClient( - apiKey: key ?? "", - model: settingsStore.settings.openRouterModel, - privacyRouting: settingsStore.settings.orEnablePrivacyRouting, - providerSort: settingsStore.settings.orProviderSort)) - default: - let key = (try? keyStore.readKey()) ?? nil - return (key, clientFactory(key ?? "")) - } + CoachClientResolver.resolve( + settings: settingsStore.settings, + openAIKeyStore: keyStore, + geminiKeyStore: geminiKeyStore, + openRouterKeyStore: openRouterKeyStore, + openAIClientFactory: clientFactory + ) } // MARK: - Confirmation cards diff --git a/PulseLoop/Info.plist b/PulseLoop/Info.plist index 6c54f59..cb30f80 100644 --- a/PulseLoop/Info.plist +++ b/PulseLoop/Info.plist @@ -12,6 +12,7 @@ BGTaskSchedulerPermittedIdentifiers com.pulseloop.coach.refresh + com.pulseloop.coach.process diff --git a/PulseLoop/PulseLoopApp.swift b/PulseLoop/PulseLoopApp.swift index e2673ab..40294e3 100644 --- a/PulseLoop/PulseLoopApp.swift +++ b/PulseLoop/PulseLoopApp.swift @@ -21,6 +21,8 @@ struct PulseLoopApp: App { private let persistence: EventPersistenceSubscriber /// Retained so it keeps regenerating Today/Sleep coach summaries on new data. private let summaryCoordinator: CoachSummaryCoordinator + /// Retained so it keeps watching for on-device proactive anomaly alerts. + private let anomalyMonitor: CoachAnomalyMonitor /// Retained so it keeps recording the structured wearable diagnostics timeline. private let diagnostics: DiagnosticsSubscriber /// Retained so the UNUserNotificationCenter delegate stays alive. @@ -63,6 +65,7 @@ struct PulseLoopApp: App { let subscriber = EventPersistenceSubscriber(context: container.mainContext) self.persistence = subscriber self.summaryCoordinator = CoachSummaryCoordinator(context: container.mainContext) + self.anomalyMonitor = CoachAnomalyMonitor(context: container.mainContext) let diagnostics = DiagnosticsSubscriber(context: container.mainContext) self.diagnostics = diagnostics @@ -75,6 +78,7 @@ struct PulseLoopApp: App { subscriber.start() coordinator.start() summaryCoordinator.start() + anomalyMonitor.start() diagnostics.start() // Daily check-in notifications: route taps + register the background wake. diff --git a/PulseLoop/Views/Settings/NotificationsSettingsView.swift b/PulseLoop/Views/Settings/NotificationsSettingsView.swift index 5a26bcb..48de5dc 100644 --- a/PulseLoop/Views/Settings/NotificationsSettingsView.swift +++ b/PulseLoop/Views/Settings/NotificationsSettingsView.swift @@ -43,11 +43,26 @@ struct NotificationsSettingsView: View { if store.settings.notificationsEnabled { labeledRow("Morning") { hourPicker(hourBinding(\.morningHour)) } + labeledRow("Midday") { hourPicker(hourBinding(\.middayHour)) } labeledRow("Evening") { hourPicker(hourBinding(\.eveningHour)) } QuickActionButton(label: "Send a test check-in now") { sendTestCheckin() } if let testStatus { Text(testStatus).font(.caption).foregroundStyle(PulseColors.textMuted) } + + // Proactive anomaly alerts — on-device only (free/private local + // inference makes "watch the stream and speak up" practical). + SectionHeader(title: "Proactive alerts", action: nil) + toggleRow("Anomaly heads-ups (on-device)", isOn: Binding( + get: { store.settings.proactiveAlertsEnabled }, + set: { store.settings.proactiveAlertsEnabled = $0 } + )) + Text(store.settings.providerMode == .appleOnDevice + ? "When something looks off (low SpO₂, short sleep), I'll send a calm heads-up — generated privately on your iPhone." + : "Requires the On-device (Apple) provider. Switch to it in AI Coach settings to enable.") + .font(.caption).foregroundStyle(PulseColors.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) } if notifPermissionDenied { Text("Notifications are disabled for PulseLoop in iOS Settings.") diff --git a/PulseLoopTests/CoachNotificationTests.swift b/PulseLoopTests/CoachNotificationTests.swift index fa49258..9c99219 100644 --- a/PulseLoopTests/CoachNotificationTests.swift +++ b/PulseLoopTests/CoachNotificationTests.swift @@ -15,17 +15,22 @@ final class CoachNotificationSlotTests: XCTestCase { } func testSlotWindows() { - XCTAssertEqual(CoachNotificationSlot.current(for: at(8), morningHour: 8, eveningHour: 19), .morning) - XCTAssertEqual(CoachNotificationSlot.current(for: at(11), morningHour: 8, eveningHour: 19), .morning) - XCTAssertEqual(CoachNotificationSlot.current(for: at(19), morningHour: 8, eveningHour: 19), .evening) - XCTAssertEqual(CoachNotificationSlot.current(for: at(21), morningHour: 8, eveningHour: 19), .evening) - XCTAssertNil(CoachNotificationSlot.current(for: at(3), morningHour: 8, eveningHour: 19)) // too early - XCTAssertNil(CoachNotificationSlot.current(for: at(15), morningHour: 8, eveningHour: 19)) // between windows + func slot(_ h: Int) -> CoachNotificationSlot? { + CoachNotificationSlot.current(for: at(h), morningHour: 8, middayHour: 13, eveningHour: 19) + } + XCTAssertEqual(slot(8), .morning) + XCTAssertEqual(slot(11), .morning) + XCTAssertEqual(slot(13), .midday) + XCTAssertEqual(slot(15), .midday) + XCTAssertEqual(slot(19), .evening) + XCTAssertEqual(slot(21), .evening) + XCTAssertNil(slot(3)) // too early + XCTAssertNil(slot(18)) // between midday and evening windows } func testNextWindowStartIsInFuture() { - let now = at(15) // between windows → evening today - let next = CoachNotificationSlot.nextWindowStart(after: now, morningHour: 8, eveningHour: 19) + let now = at(15) // inside midday window → next start is evening today + let next = CoachNotificationSlot.nextWindowStart(after: now, morningHour: 8, middayHour: 13, eveningHour: 19) XCTAssertGreaterThan(next, now) XCTAssertEqual(Calendar.current.component(.hour, from: next), 19) } From a80f9af1c4d7e004ed7440da1d45c998940ecd5a Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Tue, 30 Jun 2026 00:06:42 -0400 Subject: [PATCH 2/2] removed fallback and updated on device configurations --- .../Apple/AppleFoundationModelsClient.swift | 25 ++++++++++- .../Coach/Apple/FallbackResponsesClient.swift | 28 ------------ .../Coach/Config/CoachClientResolver.swift | 45 +++---------------- .../Coach/Config/CoachFeatureFlags.swift | 14 +++--- PulseLoop/Coach/Config/CoachSettings.swift | 5 --- .../Coach/Config/CoachSettingsSection.swift | 43 +++++++----------- PulseLoop/Views/CoachView.swift | 9 +++- 7 files changed, 59 insertions(+), 110 deletions(-) delete mode 100644 PulseLoop/Coach/Apple/FallbackResponsesClient.swift diff --git a/PulseLoop/Coach/Apple/AppleFoundationModelsClient.swift b/PulseLoop/Coach/Apple/AppleFoundationModelsClient.swift index 0354f2d..72e93cc 100644 --- a/PulseLoop/Coach/Apple/AppleFoundationModelsClient.swift +++ b/PulseLoop/Coach/Apple/AppleFoundationModelsClient.swift @@ -16,6 +16,11 @@ import FoundationModels /// guided generation can't be built for that schema (the orchestrator's /// `JSONRepair` then recovers the object). /// +/// **Text-only:** the shipping FoundationModels SDK has no image-input API +/// (`PromptBuilder` accepts only text), so image attachments aren't supported +/// on-device. The Settings UI hides the image-input option when this provider is +/// selected, so image turns never reach this client. +/// /// Like `GeminiClient`, one instance covers a single agent turn and accumulates /// the conversation across `send` calls so repair turns keep prior context. final class AppleFoundationModelsClient: ResponsesClient, @unchecked Sendable { @@ -74,7 +79,7 @@ final class AppleFoundationModelsClient: ResponsesClient, @unchecked Sendable { var systemParts: [String] = [] for item in input { let role = item["role"] as? String ?? "" - let content = item["content"] as? String ?? "" + let content = text(from: item) if role == "system" || role == "developer" { if !content.isEmpty { systemParts.append(content) } } else if !content.isEmpty { @@ -92,7 +97,8 @@ final class AppleFoundationModelsClient: ResponsesClient, @unchecked Sendable { // Tool results can't be produced on-device (v1 is tool-less), but a // repair/continuation turn still arrives as a plain message — fold it // in as a user turn so context is preserved. - if let role = item["role"] as? String, let content = item["content"] as? String, !content.isEmpty { + let content = text(from: item) + if let role = item["role"] as? String, !content.isEmpty { turns.append((role: role == "assistant" ? "assistant" : "user", text: content)) } else if (item["type"] as? String) == "function_call_output", let output = item["output"] as? String, !output.isEmpty { @@ -101,6 +107,21 @@ final class AppleFoundationModelsClient: ResponsesClient, @unchecked Sendable { } } + /// Pulls the text out of an input item. Text turns carry `content` as a plain + /// `String`; a multimodal item (which shouldn't reach this client — image + /// input is hidden on-device) carries the content-part array, from which we + /// keep only the `input_text`/`text` parts and drop images. + private func text(from item: [String: Any]) -> String { + if let content = item["content"] as? String { return content } + guard let parts = item["content"] as? [[String: Any]] else { return "" } + return parts.compactMap { part -> String? in + switch part["type"] as? String { + case "input_text", "text": return part["text"] as? String + default: return nil + } + }.joined(separator: "\n") + } + /// Renders the running transcript into a single prompt string, trimmed to the /// on-device character budget (oldest turns dropped first). private func buildPrompt() -> String { diff --git a/PulseLoop/Coach/Apple/FallbackResponsesClient.swift b/PulseLoop/Coach/Apple/FallbackResponsesClient.swift deleted file mode 100644 index 4851049..0000000 --- a/PulseLoop/Coach/Apple/FallbackResponsesClient.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -/// Wraps a primary `ResponsesClient` with a secondary one used only when the -/// primary throws. Built for the on-device provider: try Apple's local model -/// first, and if it's unavailable or a generation fails, transparently retry the -/// same request on a chosen cloud provider. -/// -/// Each `send` carries the full request body, so the secondary can serve a turn -/// the primary never saw. (A mid-conversation failover loses prior on-device -/// turn state — acceptable: the common case is the primary being unusable from -/// the start, which the resolver routes straight to the secondary instead.) -final class FallbackResponsesClient: ResponsesClient, @unchecked Sendable { - private let primary: ResponsesClient - private let secondary: ResponsesClient - - init(primary: ResponsesClient, secondary: ResponsesClient) { - self.primary = primary - self.secondary = secondary - } - - func send(requestBody: Data) async throws -> OpenAIResponse { - do { - return try await primary.send(requestBody: requestBody) - } catch { - return try await secondary.send(requestBody: requestBody) - } - } -} diff --git a/PulseLoop/Coach/Config/CoachClientResolver.swift b/PulseLoop/Coach/Config/CoachClientResolver.swift index f4d9e90..48e1429 100644 --- a/PulseLoop/Coach/Config/CoachClientResolver.swift +++ b/PulseLoop/Coach/Config/CoachClientResolver.swift @@ -2,8 +2,7 @@ import Foundation /// Single source of truth for "which `ResponsesClient` runs, given the user's /// settings + stored keys." Shared by the chat view-model, the summary service, -/// and the notification service so provider logic (including the on-device -/// cloud-backup fallback) lives in exactly one place. +/// and the notification service so provider logic lives in exactly one place. /// /// The returned `key` is a readiness sentinel: non-`nil` means the provider can /// run (used to build `CoachFeatureFlags.hasAPIKey`). For cloud providers it's @@ -19,27 +18,13 @@ enum CoachClientResolver { ) -> (key: String?, client: ResponsesClient) { switch settings.providerMode { case .appleOnDevice: + // On-device only — no cloud backup. When the local model is usable, + // run it; otherwise hand back the client (it throws a clear error + // that surfaces in chat) and signal "not ready" so generators degrade + // to scripted. let onDevice = AppleFoundationModelsClient() let available = AppleOnDeviceAvailability.current.isAvailable - // A usable cloud backup is one whose key is actually present. - let backup = settings.appleFallbackProvider.flatMap { mode in - usableCloudClient( - mode, settings: settings, - openAIKeyStore: openAIKeyStore, geminiKeyStore: geminiKeyStore, - openRouterKeyStore: openRouterKeyStore, openAIClientFactory: openAIClientFactory - ) - } - if available { - let client: ResponsesClient = backup.map { FallbackResponsesClient(primary: onDevice, secondary: $0) } ?? onDevice - return ("on-device", client) - } else if let backup { - // On-device unusable on this device → run the cloud backup directly. - return ("on-device", backup) - } else { - // Nothing usable: hand back the on-device client (it throws a clear - // error) and signal "not ready" so generators degrade to scripted. - return (nil, onDevice) - } + return (available ? "on-device" : nil, onDevice) default: return directClient( settings.providerMode, settings: settings, @@ -78,22 +63,4 @@ enum CoachClientResolver { return (key, openAIClientFactory(key ?? "")) } } - - /// A cloud client only when its key is present — used to decide whether the - /// on-device backup is actually usable. - private static func usableCloudClient( - _ mode: CoachProviderMode, - settings: CoachSettings, - openAIKeyStore: APIKeyStore, - geminiKeyStore: APIKeyStore, - openRouterKeyStore: APIKeyStore, - openAIClientFactory: (String) -> ResponsesClient - ) -> ResponsesClient? { - let (key, client) = directClient( - mode, settings: settings, - openAIKeyStore: openAIKeyStore, geminiKeyStore: geminiKeyStore, - openRouterKeyStore: openRouterKeyStore, openAIClientFactory: openAIClientFactory - ) - return key != nil ? client : nil - } } diff --git a/PulseLoop/Coach/Config/CoachFeatureFlags.swift b/PulseLoop/Coach/Config/CoachFeatureFlags.swift index 78b823e..24bf6b2 100644 --- a/PulseLoop/Coach/Config/CoachFeatureFlags.swift +++ b/PulseLoop/Coach/Config/CoachFeatureFlags.swift @@ -20,9 +20,10 @@ struct CoachFeatureFlags { case .offlineStub: return false case .appleOnDevice: - // Ready when the local model is usable, or a cloud backup is set (its - // key is checked at resolve time; a missing key degrades to scripted). - return AppleOnDeviceAvailability.current.isAvailable || settings.appleFallbackProvider != nil + // On-device only — ready when the local model is usable on this + // device. Otherwise the coach degrades to scripted and on-device + // failures surface as an error in chat. + return AppleOnDeviceAvailability.current.isAvailable case .userOpenAIKey, .userGeminiKey, .userOpenRouterKey: return hasAPIKey case .backendProxy: @@ -46,12 +47,7 @@ struct CoachFeatureFlags { case .offlineStub: return "Offline — scripted replies only." case .appleOnDevice: - let availability = AppleOnDeviceAvailability.current - if availability.isAvailable { return availability.statusMessage } - if let backup = settings.appleFallbackProvider { - return "On-device unavailable — using \(backup.label) backup." - } - return availability.statusMessage + return AppleOnDeviceAvailability.current.statusMessage case .userOpenAIKey: return hasAPIKey ? "Ready · \(settings.model)" : "Add an OpenAI key to enable." case .userGeminiKey: diff --git a/PulseLoop/Coach/Config/CoachSettings.swift b/PulseLoop/Coach/Config/CoachSettings.swift index d5e77f2..dc4d8e2 100644 --- a/PulseLoop/Coach/Config/CoachSettings.swift +++ b/PulseLoop/Coach/Config/CoachSettings.swift @@ -112,10 +112,6 @@ struct CoachSettings: Codable, Equatable { /// Off by default — users who only want metrics get a coach-free app. var coachMasterEnabled: Bool = false var providerMode: CoachProviderMode = .userOpenAIKey - /// Cloud backup for the on-device provider: when on-device is unavailable or a - /// generation fails, fall back to this provider (using its stored key). `nil` - /// = no backup. Only the key-based cloud providers are valid values. - var appleFallbackProvider: CoachProviderMode? = nil /// Default matches the web app; user-configurable (never hard-coded in the client). var model: String = CoachModel.gpt54.rawValue /// Optional reasoning effort hint ("low"/"medium"/"high") when the model supports it. @@ -165,7 +161,6 @@ struct CoachSettings: Codable, Equatable { let d = CoachSettings.default coachMasterEnabled = try c.decodeIfPresent(Bool.self, forKey: .coachMasterEnabled) ?? d.coachMasterEnabled providerMode = try c.decodeIfPresent(CoachProviderMode.self, forKey: .providerMode) ?? d.providerMode - appleFallbackProvider = try c.decodeIfPresent(CoachProviderMode.self, forKey: .appleFallbackProvider) model = try c.decodeIfPresent(String.self, forKey: .model) ?? d.model reasoningEffort = try c.decodeIfPresent(String.self, forKey: .reasoningEffort) enableWebSearch = try c.decodeIfPresent(Bool.self, forKey: .enableWebSearch) ?? d.enableWebSearch diff --git a/PulseLoop/Coach/Config/CoachSettingsSection.swift b/PulseLoop/Coach/Config/CoachSettingsSection.swift index 39866b3..4c5e42a 100644 --- a/PulseLoop/Coach/Config/CoachSettingsSection.swift +++ b/PulseLoop/Coach/Config/CoachSettingsSection.swift @@ -51,21 +51,14 @@ struct CoachSettingsSection: View { !OpenRouterModel.allCases.contains { $0.rawValue == store.settings.model } } - /// Which provider's key field to surface: the active cloud provider, or — in - /// on-device mode — the chosen cloud backup (so its key can be entered). + /// Which provider's key field to surface. The on-device provider needs no + /// key (and has no cloud backup), so it surfaces none. private var effectiveKeyProvider: CoachProviderMode? { store.settings.providerMode == .appleOnDevice - ? store.settings.appleFallbackProvider + ? nil : store.settings.providerMode } - private var fallbackBinding: Binding { - Binding( - get: { store.settings.appleFallbackProvider }, - set: { store.settings.appleFallbackProvider = $0 } - ) - } - var body: some View { SectionHeader(title: "AI Coach", action: nil) StatusCopy(title: "Status", body: flags.statusLine) @@ -82,20 +75,11 @@ struct CoachSettingsSection: View { .tint(PulseColors.accent) } - // On-device has a single fixed model — show a privacy/availability - // card + a cloud-backup chooser instead of a model picker. + // On-device has a single fixed model and runs only on-device (no + // cloud backup) — show a privacy/availability card instead of a + // model picker. if store.settings.providerMode == .appleOnDevice { appleOnDeviceCard - labeledRow("Cloud backup") { - Picker("Cloud backup", selection: fallbackBinding) { - Text("None").tag(CoachProviderMode?.none) - Text("OpenAI").tag(CoachProviderMode?.some(.userOpenAIKey)) - Text("Gemini").tag(CoachProviderMode?.some(.userGeminiKey)) - Text("OpenRouter").tag(CoachProviderMode?.some(.userOpenRouterKey)) - } - .pickerStyle(.menu) - .tint(PulseColors.accent) - } } else { labeledRow("Model") { Picker("Model", selection: modelPickerBinding) { @@ -125,8 +109,7 @@ struct CoachSettingsSection: View { } // The key field tracks the *effective* provider — the active cloud - // provider, or (in on-device mode) the chosen cloud backup — so the - // backup's key can be entered without leaving on-device mode. + // provider (none in on-device mode, which needs no key). if effectiveKeyProvider == .userOpenAIKey { apiKeyField( placeholder: "sk-…", @@ -162,7 +145,11 @@ struct CoachSettingsSection: View { ) } - toggleRow("Web search", isOn: webSearchBinding) + // The on-device provider is tool-less: it ignores web search, so the + // toggle isn't offered there. + if store.settings.providerMode != .appleOnDevice { + toggleRow("Web search", isOn: webSearchBinding) + } // OpenRouter-only routing controls. OpenRouter exposes a unified // reasoning-effort hint plus provider-level privacy and sort options @@ -195,7 +182,11 @@ struct CoachSettingsSection: View { toggleRow("AI actions (set goals, log, edit)", isOn: writeToolsBinding) toggleRow("Live ring measurements", isOn: liveMeasurementsBinding) - toggleRow("Image input (attach photos)", isOn: imageInputBinding) + // The on-device model has no image-input API in the shipping SDK, so + // the option isn't offered there. + if store.settings.providerMode != .appleOnDevice { + toggleRow("Image input (attach photos)", isOn: imageInputBinding) + } if !memories.isEmpty { SectionHeader(title: "Coach memory", action: nil) diff --git a/PulseLoop/Views/CoachView.swift b/PulseLoop/Views/CoachView.swift index 033e294..2a107b6 100644 --- a/PulseLoop/Views/CoachView.swift +++ b/PulseLoop/Views/CoachView.swift @@ -32,7 +32,14 @@ struct CoachView: View { @State private var showCamera = false @State private var photosPickerItem: PhotosPickerItem? - private var imageInputEnabled: Bool { settingsStore.settings.enableImageInput } + /// Image attach is offered only when enabled *and* the provider supports it. + /// The on-device model has no image-input API in the shipping SDK, so the + /// composer hides the attach button there even if the stored flag is on (e.g. + /// left over from another provider). + private var imageInputEnabled: Bool { + settingsStore.settings.enableImageInput + && settingsStore.settings.providerMode != .appleOnDevice + } /// Bottom inset for the composer: clears the overlaid nav bar (~60) when the /// keyboard is hidden, and sits just above the keyboard when shown. Computed