Skip to content
Merged
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
20 changes: 12 additions & 8 deletions lib/ai/briefing_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,12 @@ Future<Map<String, dynamic>> collectBriefingInputs(

// ── prompt building (PURE — unit-tested on sample data) ───────────────────────

/// The reader's local time of day — used only for the briefing's greeting, and
/// deliberately distinct from [BriefingPeriod]: the morning briefing (last
/// night's sleep + recovery) is shown right up to 17:00, so a reader opening it
/// in the afternoon must be greeted for the afternoon, never the "morning".
/// The reader's local time of day at GENERATION time — passed into the prompt
/// as context only (the model is told not to write a greeting off it; the app
/// renders its own greeting fresh at read time, since a cached briefing can be
/// read hours after it was generated). Deliberately distinct from
/// [BriefingPeriod]: the morning briefing (last night's sleep + recovery) is
/// shown right up to 17:00.
String partOfDay(DateTime now) {
final h = now.hour;
if (h < 12) return 'morning';
Expand All @@ -164,15 +166,17 @@ String readinessBand(num v) {
return 'good';
}

String briefingSystemPrompt(BriefingPeriod period, String timeOfDay) {
String briefingSystemPrompt(BriefingPeriod period) {
final scope = period == BriefingPeriod.morning
? 'last night\'s sleep and recovery, and what they mean for the day ahead'
: 'today\'s activity, strain and stress, and how the day landed';
return 'You write a health briefing for a local-first fitness band app. '
'It is currently $timeOfDay for the reader — if you open with a greeting, '
'greet for the $timeOfDay and never assume a different time of day. '
'Summarize $scope.\n'
'HARD RULES:\n'
'- Do NOT open with a greeting or any reference to the time of day — '
'the app shows its own greeting separately, computed at the moment the '
'reader actually opens it, and this text may be read hours after it was '
'written. Start straight with the substance.\n'
'- Use ONLY the numbers provided. Never invent, estimate or mention a '
'metric that is not in the data. No medical advice or diagnosis.\n'
'- If a "readiness" value is given, its parenthesized band label '
Expand Down Expand Up @@ -286,7 +290,7 @@ class BriefingEngine {
(({required String system, required String user}) =>
CoachEngine.completeText(
config: config, system: system, user: user)))(
system: briefingSystemPrompt(period, tod),
system: briefingSystemPrompt(period),
user: buildBriefingUserPrompt(period, day, inputs, tod),
);
if (raw.trim().isEmpty) {
Expand Down
26 changes: 19 additions & 7 deletions lib/compute/crossday_pipeline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,30 @@ Map<String, dynamic> buildCrossDayBundle(
for (final d in days) _numOrNull(d['skin_temp_z']),
];

// A day flagged `unsettled` (today, still syncing / not finalized) is a
// truncated reading, not a physiological signal — it must not drive an
// illness/anomaly/temperature ALERT. It stays in `days` for everything else
// (readiness, RHR trend, load, sleep debt, `recent`), which is why this is a
// per-input null rather than dropping the row from the list.
final unsettled = <bool>[for (final d in days) d['unsettled'] == true];
T? settled<T>(int i, T? v) => unsettled[i] ? null : v;

// ── illness CUSUM (NightSignal) on nightly RHR ─────────────────────────────
final illness = ana.illnessCusum(dates, rhrList);
final illness = ana.illnessCusum(dates, [
for (var i = 0; i < n; i++) settled(i, rhrList[i]),
]);

// ── multivariate anomaly {RHR↑,HRV↓,temp↑,resp↑} ───────────────────────────
// Build one AnomalyFeatures per day (same length as dates). Days with all
// features null still occupy a slot — the detector handles the nulls
// internally (needs ≥2 present features tonight to compute a distance).
final feats = <ana.AnomalyFeatures>[
for (final d in days)
for (var i = 0; i < n; i++)
ana.AnomalyFeatures(
rhr: _numOrNull(d['rhr']),
hrv: _numOrNull(d['rmssd']),
temp: _numOrNull(d['skin_temp_z']),
resp: _numOrNull(d['resp_rate']),
rhr: settled(i, rhrList[i]),
hrv: settled(i, rmssdList[i]),
temp: settled(i, tempList[i]),
resp: settled(i, respList[i]),
),
];
final anomaly = ana.multivariateAnomaly(dates, feats);
Expand All @@ -81,7 +91,9 @@ Map<String, dynamic> buildCrossDayBundle(
final load = ana.ctlAtlTsb(dailyTrimp);

// ── skin-temp illness flag (Smarr, cycle-aware) ────────────────────────────
final tempIllness = ana.tempIllnessFlag(dates, tempList);
final tempIllness = ana.tempIllnessFlag(dates, [
for (var i = 0; i < n; i++) settled(i, tempList[i]),
]);

// ── circadian: mid-sleep, free/work split, jetlag, chronotype, sleep debt ──
// mid-sleep epoch = (onset+wake)/2; local clock-hours in [0,24) via mod-day.
Expand Down
Loading
Loading