Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions PulseLoop/App/AppTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ enum AppRoute: Hashable {
case settingsPrivacyData
case settingsAbout
case settingsNutrition
case settingsReadiness
case readinessDetail
case nutrition
case mealDetail(UUID)
case pairing
Expand Down
8 changes: 8 additions & 0 deletions PulseLoop/Coach/Config/CoachFeatureFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ struct CoachFeatureFlags {
/// composes with the coach gates. Defaulted so existing construction sites and
/// tests keep compiling (default = feature off).
var nutritionPrefs: NutritionPrefs = .default
/// Snapshot of the readiness feature's prefs, bridged in the same way as `nutritionPrefs`.
/// Defaulted so existing construction sites and tests keep compiling.
var readinessPrefs: ReadinessPrefs = .default

/// User-facing master switch — when off, the coach tab, summaries and
/// notifications are all hidden. This is the gate the UI checks; the
Expand Down Expand Up @@ -46,6 +49,11 @@ struct CoachFeatureFlags {
/// The coach may log/edit meals: nutrition context is shared AND write tools are on.
var nutritionWriteEnabled: Bool { nutritionContextEnabled && writeToolsEnabled }

/// Readiness may reach the coach (context packet + read tool): the feature is on AND the user
/// shares it. Read-only — there is no write path, because a readiness score is derived and the
/// model has no business editing one.
var readinessContextEnabled: Bool { readinessPrefs.masterEnabled && readinessPrefs.shareWithCoach }

var maxToolCalls: Int { max(1, settings.maxToolCalls) }
var maxRounds: Int { max(1, settings.maxRounds) }
var model: String { settings.model }
Expand Down
32 changes: 30 additions & 2 deletions PulseLoop/Coach/Context/CoachContextBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ enum CoachContextBuilder {
now: Date = Date(),
budget: CoachContextBudget = .full,
environment: CoachContextPacket.EnvironmentContext? = nil,
includeNutrition: Bool = true
includeNutrition: Bool = true,
includeReadiness: Bool = true
) -> CoachContextPacket {
let summary = MetricsService.buildTodaySummary(context: context)
let profile = ProfileRepository.profile(context: context)
Expand Down Expand Up @@ -44,6 +45,10 @@ enum CoachContextBuilder {
let nutritionPrefs = NutritionPrefsStore.shared.prefs
let shareNutrition = includeNutrition && nutritionPrefs.masterEnabled && nutritionPrefs.shareWithCoach

// Same shape for readiness: on, shared, and not opted out by the caller.
let readinessPrefs = ReadinessPrefsStore.shared.prefs
let shareReadiness = includeReadiness && readinessPrefs.masterEnabled && readinessPrefs.shareWithCoach

let goals = CoachContextPacket.GoalContext(
stepsDaily: summary.goals.stepsDaily,
activeMinutesDaily: summary.goals.activeMinutesDaily,
Expand Down Expand Up @@ -125,7 +130,30 @@ enum CoachContextBuilder {
conversationSummary: cap(conversationSummary, to: budget.conversationSummaryCap),
dataQualityWarnings: Array(warnings.prefix(budget.maxWarnings)),
environment: environment,
nutrition: shareNutrition ? nutritionContext(summary: summary, context: context, now: now) : nil
nutrition: shareNutrition ? nutritionContext(summary: summary, context: context, now: now) : nil,
readiness: shareReadiness ? readinessContext(summary: summary) : nil
)
}

/// Flatten the stored readiness snapshot for the packet. Reads `summary.readiness`, which
/// already inherits the master-toggle gate, so a disabled feature can't leak through here.
private static func readinessContext(summary: TodaySummary) -> CoachContextPacket.ReadinessContext? {
guard let readiness = summary.readiness else { return nil }
return CoachContextPacket.ReadinessContext(
score: readiness.score,
band: readiness.band.rawValue,
coverage: readiness.coverage,
contributors: readiness.contributors
.sorted { $0.drag > $1.drag }
.map {
CoachContextPacket.ReadinessContext.ContributorBrief(
signal: $0.kind?.rawValue ?? $0.kindRaw,
pointsEarned: $0.earned,
pointsPossible: $0.maxPoints,
detail: $0.detail
)
},
notMeasured: readiness.missingKinds.map(\.rawValue)
)
}

Expand Down
23 changes: 23 additions & 0 deletions PulseLoop/Coach/Context/CoachContextPacket.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ struct CoachContextPacket: Encodable {
/// Opt-in nutrition tracking summary. Nil when the feature is off or the user
/// doesn't share it with the coach — absent from the JSON entirely.
var nutrition: NutritionContext?
/// Opt-in readiness score + the contributors behind it. Nil when the feature is off or not
/// shared — absent from the JSON entirely.
var readiness: ReadinessContext?

struct ProfileContext: Encodable {
var name: String?
Expand Down Expand Up @@ -139,6 +142,26 @@ struct CoachContextPacket: Encodable {
}
}

/// This morning's readiness score with the breakdown that produced it. The contributors are
/// the point: with them the coach can say "your HRV is 12% below baseline", and without them it
/// would have to guess at a reason for a number it can see.
struct ReadinessContext: Encodable {
var score: Int
var band: String
/// Share of the full 100-point picture this score is based on, 0–1.
var coverage: Double
var contributors: [ContributorBrief]
/// Signals the ring didn't capture. Named so the coach doesn't read absence as normality.
var notMeasured: [String]

struct ContributorBrief: Encodable {
var signal: String
var pointsEarned: Double
var pointsPossible: Double
var detail: String
}
}

/// 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.
Expand Down
147 changes: 147 additions & 0 deletions PulseLoop/Coach/Tools/ReadinessTools.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import Foundation
import SwiftData

/// Readiness retrieval. Read-only by design: a readiness score is derived from measurements the
/// user's ring took, so there is nothing for the model to write. Available whenever readiness is
/// enabled and shared with the coach.
///
/// Every row carries its contributor breakdown, which is the whole reason this tool exists. Without
/// it the model can see a 74 and would have to invent a reason for it; with it the model can say
/// "your HRV was 12% below your baseline" because that is what the app actually computed.
@MainActor
enum ReadinessTools {
static var readTools: [AnyCoachTool] { [getReadiness] }

/// A month is enough for "how has my recovery been lately" without flooding the context window.
private static let maxDays = 31

private struct RangeArgs: Decodable {
let startDate: String?
let endDate: String?
enum CodingKeys: String, CodingKey {
case startDate = "start_date"
case endDate = "end_date"
}
}

private struct ContributorPayload: Encodable {
let signal: String
let pointsEarned: Double
let pointsPossible: Double
let detail: String
}

private struct DayPayload: Encodable {
let date: String
let score: Int
let band: String
let coverage: Double
let contributors: [ContributorPayload]
let notMeasured: [String]
}

private struct Result: Encodable {
let days: [DayPayload]
let averageScore: Int?
/// Pinned so a stored score is never reinterpreted under weights it wasn't computed with.
let algorithmVersion: Int
/// Present only when there is genuinely nothing to report, so the model says "no scores yet"
/// rather than inferring poor recovery from an empty list.
let note: String?
}

/// Parse a `YYYY-MM-DD` argument to the start of that local day. nil for absent or unparseable
/// input, so the caller can fall back to its default window rather than erroring.
private static func startOfDay(_ value: String?, calendar: Calendar) -> Date? {
guard let value, !value.isEmpty else { return nil }
guard let parsed = CoachDataAccess.parseLocalDate(value) else { return nil }
return calendar.startOfDay(for: parsed)
}

/// Flatten one stored row for the model. Split out of the tool body because the nested
/// map-inside-initializer defeated the type checker.
private static func payload(for row: ReadinessDaily) -> DayPayload {
let snapshot = ReadinessSnapshot(row)
let ranked = snapshot.contributors.sorted { $0.drag > $1.drag }
var contributors: [ContributorPayload] = []
contributors.reserveCapacity(ranked.count)
for record in ranked {
contributors.append(
ContributorPayload(
signal: record.kind?.rawValue ?? record.kindRaw,
pointsEarned: record.earned,
pointsPossible: record.maxPoints,
detail: record.detail
)
)
}
return DayPayload(
date: CoachDataAccess.localDateString(row.date),
score: row.score,
band: row.band.rawValue,
coverage: snapshot.coverage,
contributors: contributors,
notMeasured: snapshot.missingKinds.map(\.rawValue)
)
}

// MARK: get_readiness

private static var getReadiness: AnyCoachTool {
.make(
name: "get_readiness",
label: "Checking your readiness",
description: "Get daily readiness (recovery) scores with the contributor breakdown that "
+ "produced each one. Readiness is 0–100 from overnight HRV, resting heart rate, "
+ "sleep, skin temperature, and the previous day's training load, each compared "
+ "against the user's own baseline. Use the contributors to explain a score — never "
+ "guess at a reason. Signals in not_measured were not captured and are excluded "
+ "from the score rather than counted as zero. Defaults to the last 7 days.",
parameters: JSONSchema.object([
"start_date": ["type": ["string", "null"]],
"end_date": ["type": ["string", "null"]],
], required: ["start_date", "end_date"]),
argsType: RangeArgs.self
) { args, ctx in
guard ctx.flags.readinessContextEnabled else {
return .error("readiness is not enabled or not shared with the coach")
}

let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())

// Inlined rather than a nested func: a nested `func` inside this closure would not
// inherit its main-actor isolation, and `CoachDataAccess` is main-actor bound.
let parsedEnd: Date? = Self.startOfDay(args.endDate, calendar: calendar)
let requestedEnd: Date = parsedEnd ?? today
let defaultStart: Date = calendar.date(byAdding: .day, value: -6, to: requestedEnd) ?? requestedEnd
let parsedStart: Date? = Self.startOfDay(args.startDate, calendar: calendar)
let requestedStart: Date = parsedStart ?? defaultStart

// Tolerate a reversed range rather than returning nothing — the model occasionally
// swaps them, and an empty result would read as "no recovery data".
let start = min(requestedStart, requestedEnd)
let end = max(requestedStart, requestedEnd)

// Clamp the window so a wide request can't blow the context budget.
let earliest = calendar.date(byAdding: .day, value: -(maxDays - 1), to: end) ?? end
let clampedStart = max(start, earliest)

let rows = ReadinessRepository.rows(from: clampedStart, to: end, context: ctx.modelContext)
let days = rows.map { payload(for: $0) }

let average = days.isEmpty ? nil : days.reduce(0) { $0 + $1.score } / days.count
return .encoding(
Result(
days: days,
averageScore: average,
algorithmVersion: ReadinessScore.algorithmVersion,
note: days.isEmpty
? "No readiness scores in this range. Readiness needs about a week of "
+ "overnight wear before it can compare a night to the user's baseline."
: nil
)
)
}
}
}
3 changes: 3 additions & 0 deletions PulseLoop/Coach/Tools/ToolRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ struct ToolRegistry {
if flags.nutritionContextEnabled {
all += NutritionTools.readTools
}
if flags.readinessContextEnabled {
all += ReadinessTools.readTools
}
if flags.nutritionWriteEnabled {
all += NutritionTools.writeTools
}
Expand Down
3 changes: 2 additions & 1 deletion PulseLoop/Coach/ViewModels/CoachViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ final class CoachViewModel {
let (apiKey, activeClient) = resolveClient()
let flags = CoachFeatureFlags(
settings: settingsStore.settings, hasAPIKey: apiKey != nil,
nutritionPrefs: NutritionPrefsStore.shared.prefs)
nutritionPrefs: NutritionPrefsStore.shared.prefs,
readinessPrefs: ReadinessPrefsStore.shared.prefs)
let budget = flags.contextBudget
let environment = await CoachEnvironmentContextService.shared.snapshot()
let packet = CoachContextBuilder.build(context: context, budget: budget, environment: environment)
Expand Down
Loading