diff --git a/cmd/server/main.go b/cmd/server/main.go index 03edd13..1c72b02 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -103,6 +103,7 @@ func main() { legacyDB.EnsureEnergySnapshotsTable() legacyDB.EnsureReadinessRedesignTables() legacyDB.EnsureSubjectiveCheckinsTable() + legacyDB.EnsureContextPromptInteractionsTable() legacyDB.EnsureAuthSessionsTable() passwordHash := "" @@ -154,7 +155,7 @@ func main() { // One-time backfill of installation-wide Gemini config for installs // that pre-date PR #16 (where AI settings were per-tenant). When the // global table is empty but an admin tenant already has gemini_* rows, - // copy them up so non-admin tenants (Maria-style accounts) inherit. + // copy them up so non-admin tenants inherit. if err := migrateGlobalAIIfNeeded(ctx, reg, mgr, users); err != nil { log.Printf("global AI migration: %v", err) } @@ -174,6 +175,7 @@ func main() { db.EnsureEnergySnapshotsTable() db.EnsureReadinessRedesignTables() db.EnsureSubjectiveCheckinsTable() + db.EnsureContextPromptInteractionsTable() db.EnsureAuthSessionsTable() startTenant(ctx, mgr, reg, db, u.SchemaName, envNotifyDefaults, envAIDefaults, baseURL) } @@ -229,6 +231,7 @@ func main() { db.EnsureEnergySnapshotsTable() db.EnsureReadinessRedesignTables() db.EnsureSubjectiveCheckinsTable() + db.EnsureContextPromptInteractionsTable() db.EnsureAuthSessionsTable() startTenant(ctx, mgr, reg, db, schema, envNotifyDefaults, envAIDefaults, baseURL) }) @@ -667,6 +670,9 @@ type liveCheckinRouter struct { func (r *liveCheckinRouter) SaveAnswer(date, source, answer string, answeredAt time.Time) (string, error) { return r.db.SaveCheckinAnswer(date, source, answer, answeredAt) } +func (r *liveCheckinRouter) SaveContextPromptAnswer(promptID, category, source string, answeredAt time.Time) (string, error) { + return r.db.SaveContextPromptAnswer(promptID, category, source, answeredAt) +} func (r *liveCheckinRouter) AnswerCallbackQuery(qid, text string) error { return r.bot.AnswerCallbackQuery(qid, text) } @@ -706,13 +712,19 @@ func makeReportTrigger(mgr *tenants.Manager, schema string, defaults storage.Not sendMu := mgr.MorningSendMuFor(schema) if sendMu != nil { sendMu.Lock() - defer sendMu.Unlock() } + sentReport := false if db.HasSentMorningReport(today) { + if sendMu != nil { + sendMu.Unlock() + } return } sent, reason, err := notify.SendMorningSmart(bot, db, ncfg, false) if err != nil { + if sendMu != nil { + sendMu.Unlock() + } log.Printf("checkin-trigger: send: %v", err) return } @@ -720,6 +732,13 @@ func makeReportTrigger(mgr *tenants.Manager, schema string, defaults storage.Not if perr := db.MarkMorningReportSent(today); perr != nil { log.Printf("checkin-trigger: mark sent: %v", perr) } + sentReport = true + } + if sendMu != nil { + sendMu.Unlock() + } + if sentReport { + trySendContextPromptAfterMorning(bot, db, ncfg, today, time.Now().In(loc)) log.Printf("checkin-trigger: sent (reason=%s) for %s", reason, today) } } @@ -832,8 +851,8 @@ func makeMorningTrigger(db *storage.DB, sendMu *sync.Mutex, mgr *tenants.Manager // both observe HasSent=false in the narrow window between // the outer check and the actual Telegram POST. sendMu.Lock() - defer sendMu.Unlock() if db.HasSentMorningReport(today) { + sendMu.Unlock() return } sent, reason, err := notify.SendMorningSmartOpts(bot, db, ncfg, notify.MorningSendOpts{ @@ -841,16 +860,20 @@ func makeMorningTrigger(db *storage.DB, sendMu *sync.Mutex, mgr *tenants.Manager CheckinExpired: action == notify.MorningActionExpireAndForce, }) if err != nil { + sendMu.Unlock() log.Printf("morning trigger: send telegram: %v", err) return } if !sent { + sendMu.Unlock() log.Printf("morning trigger: deferring — %s", reason) return } if err := db.MarkMorningReportSent(today); err != nil { log.Printf("morning trigger: mark sent: %v", err) } + sendMu.Unlock() + trySendContextPromptAfterMorning(bot, db, ncfg, today, now) log.Printf("morning trigger: sent (reason=%s, forced=%v, action=%s)", reason, force, action) } } @@ -1090,6 +1113,7 @@ func runMorningSmartRetry(bot *notify.Bot, db *storage.DB, mgr *tenants.Manager, } if sent { log.Printf("morning smart-retry: sent (reason=%s, forced=%v, action=%s)", reason, past, action) + trySendContextPromptAfterMorning(bot, db, ncfg, today, time.Now().In(loc)) return } if past { @@ -1105,6 +1129,26 @@ func runMorningSmartRetry(bot *notify.Bot, db *storage.DB, mgr *tenants.Manager, } } +func trySendContextPromptAfterMorning(bot *notify.Bot, db *storage.DB, cfg notify.Config, signalDate string, now time.Time) { + if !storage.IsContextPromptsEnabled(db) { + return + } + promptDate := now.Format("2006-01-02") + prompt, reserved, err := db.ReserveLowSleepContextPrompt(signalDate, promptDate, now, now.Add(36*time.Hour)) + if err != nil { + log.Printf("context prompt: reserve low_sleep date=%s: %v", signalDate, err) + return + } + if !reserved || prompt == nil { + return + } + if err := notify.SendContextPrompt(bot, db, cfg.Lang, prompt, now); err != nil { + log.Printf("context prompt: send low_sleep prompt=%s date=%s: %v", prompt.PromptID, signalDate, err) + return + } + log.Printf("context prompt: sent low_sleep prompt=%s date=%s", prompt.PromptID, signalDate) +} + // runDailyQualityScan ticks once per day at 03:00 (REPORT_TZ or system local) // and calls MarkSuspectPoints to flag z-score outliers in the last 7 days of // autonomic metrics. Cheap (~one query per metric) and idempotent — re-running @@ -1146,6 +1190,11 @@ func runDailyQualityScan(db *storage.DB, schema string, defaults storage.NotifyC now = time.Now().In(loc) today := now.Format("2006-01-02") db.RunReadinessRedesignBackfillForDatesAt([]string{today}, now) + if deleted, err := db.PruneContextPromptInteractions(now); err != nil { + log.Printf("[%s] context prompt retention: %v", schema, err) + } else if deleted > 0 { + log.Printf("[%s] context prompt retention: pruned %d rows", schema, deleted) + } } } diff --git a/docs/ai-plans/2026-06-18-checkin-calendar-sla.html b/docs/ai-plans/2026-06-18-checkin-calendar-sla.html index fd7df56..5e7c5a2 100644 --- a/docs/ai-plans/2026-06-18-checkin-calendar-sla.html +++ b/docs/ai-plans/2026-06-18-checkin-calendar-sla.html @@ -186,7 +186,7 @@

Implemented Result

Checks Run

@@ -202,10 +202,9 @@

Known Limitations

-

Approval Gate

+

Approval Gate Status

- Implementation must not start until the user explicitly approves this plan. - Suggested approval phrase: погнали. + This was the historical approval gate for the implementation plan. Approval was received and the implementation results above reflect the completed work.

diff --git a/docs/ai-plans/2026-06-20-proactive-context-prompts-mvp.html b/docs/ai-plans/2026-06-20-proactive-context-prompts-mvp.html new file mode 100644 index 0000000..627d568 --- /dev/null +++ b/docs/ai-plans/2026-06-20-proactive-context-prompts-mvp.html @@ -0,0 +1,475 @@ + + + + + Implementation Plan: Proactive Context Prompts MVP + + + + +
+

Implementation Plan: Proactive Context Prompts MVP

+

Implemented on branch codex/185-proactive-context-prompts

+

Issue: #185. Revised 2026-06-20 after plan review. Scope: one non-safety detector, stateful Telegram prompt lifecycle, privacy-safe detector-specific annotations, and illness-safety non-regression.

+ +
+

Task Summary

+

Build a narrow first slice of interactive proactivity: when one selected non-safety detector notices a strong unusual pattern, reserve and send one Telegram prompt asking for broad context, then store the user's closed-enum answer. The annotation applies to the specific detector only and is used in future reports; it does not alter readiness scoring or illness-safety logic.

+

The plan intentionally reduces the earlier broad design. First slice means one detector, one prompt lifecycle, one storage model, one callback path, one tenant rollout, and measurable stop/go criteria.

+

The implementation separates two roles: low_sleep_v2 is a candidate detector, while a separate prompt-usefulness gate decides whether asking the user is worth it.

+
+ +
+

Review-Driven Changes

+ +
+ +
+

Inspected Current Architecture

+ +
+ +
+

Illness-Safety Boundary

+

This feature must complement illness detection, not compete with it.

+ +
+ +
+

Privacy Boundary

+ +
+ +
+

MVP Detector Choice

+

Use low_sleep as the first detector if settled sleep data is reliable enough in code. It is non-safety, understandable, common enough to validate UX, and less likely to conflict with illness-safety logic than autonomic or respiratory signals.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DetectorDecisionReason
low_sleepFirst slice candidateClear user-facing explanation; uses settled sleep data; non-safety; report context is easy to apply detector-specifically.
unusual_daytime_restFollow-up detectorUseful, but needs more careful segmentation and false-positive handling.
low_activityFollow-up detectorGood candidate, but device coverage and illness/travel/recovery ambiguity need more handling.
high_autonomic_loadDeferIntersects heavily with illness prodrome and stress flags.
respiratory_or_oxygen_shiftOut of MVPSafety-sensitive. Validate basic prompt UX before asking about this class of signal.
+
+ +
+

State Model

+

Use one tenant-local interaction table for MVP. It represents the detected signal, reservation, prompt lifecycle, and eventual answer.

+
CREATE TABLE IF NOT EXISTS context_prompt_interactions (
+  prompt_id          TEXT PRIMARY KEY,
+  signal_date        DATE NOT NULL,
+  prompt_local_date  DATE NOT NULL,
+  detected_reason    TEXT NOT NULL,
+  detector_version   TEXT NOT NULL,
+  status             TEXT NOT NULL, -- detected | reserved | prompted | answered | skipped | expired | send_failed
+  category           TEXT,          -- NULL until answered/skipped
+  source             TEXT,          -- NULL until answer; telegram for MVP
+  prompt_message_id  BIGINT,
+  prompted_at        TIMESTAMPTZ,
+  expires_at         TIMESTAMPTZ NOT NULL,
+  answered_at        TIMESTAMPTZ,
+  allowed_categories JSONB NOT NULL DEFAULT '[]'::jsonb,
+  metadata           JSONB NOT NULL DEFAULT '{}'::jsonb,
+  created_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
+  updated_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
+  UNIQUE (signal_date, detected_reason)
+);
+

Add a partial unique index for one sent prompt per tenant-local prompt day:

+
CREATE UNIQUE INDEX IF NOT EXISTS idx_context_prompt_one_sent_per_day
+ON context_prompt_interactions (prompt_local_date)
+WHERE status IN ('reserved', 'prompted', 'answered', 'skipped', 'expired', 'send_failed');
+ +
+ +
+

Prompt Lifecycle

+ + + + + + + + + + + +
StatusMeaningTransitions
detectedSignal exists but no prompt is reserved.Optional; MVP may skip and reserve directly inside a transaction.
reservedScheduler claimed the daily prompt slot before Telegram send.prompted after send success; send_failed after send failure.
promptedTelegram prompt was sent and can be answered until expires_at.answered, skipped, or expired.
answeredUser selected a concrete context category.Terminal; repeated same callback is idempotent.
skippedUser selected skip_context or unknown_context.Terminal; suppresses future prompt for same signal.
expiredPrompt lifetime elapsed.Terminal for MVP; do not re-prompt same signal.
send_failedReservation happened but Telegram send failed.Terminal for MVP, or eligible for cleanup/retry only if explicitly implemented.
+

Delivery policy: prefer rare missed prompts over duplicate prompts. The MVP should bias toward at-most-once prompt delivery.

+
+ +
+

Telegram Callback Design

+ +
+ +
+

MVP Bot UX

+
+
+
+

Prompt

+

Sleep was unusually short

+

Your latest sleep was well below your usual range. What context should be noted?

+
+
Bad sleep
+
Stress
+
Travel
+
Not sure
+
+
+
+
+
+

Acknowledgement

+

Saved for future reports.

+
+
+
+
+

Future report caveat

+

Sleep context: stress.

+

This explains the low-sleep detector only; other health signals are evaluated normally.

+
+
+
+
+ +
+

Report Semantics

+ +
+ +
+

Feature Flags and Kill Switches

+ +
+ +
+

Reliability Scenarios

+ + + + + + + + + + + +
ScenarioExpected MVP behavior
Two scheduler runs in parallelUnique constraints allow at most one reservation and one prompt-local-date slot.
Crash after reservation before Telegram sendRow remains reserved and occupies the daily prompt slot. No duplicate prompt.
Telegram send succeeds but status update failsPrefer no re-send. Log loudly; row may need admin cleanup. Avoid duplicate UX.
Telegram send failsMark send_failed if possible; send_failed occupies the daily prompt slot and is terminal in MVP.
Repeated webhook deliveryIdempotent ack, no timestamp/category churn.
Double tap different buttonsFirst valid final answer wins; second receives neutral ack.
Feature flag disabled after sendAlready-sent answer is accepted unless a hard kill switch is added later.
+
+ +
+

Retention and Deletion

+ +
+ +
+

Files Likely to Change

+ +
+ +
+

Implementation Steps

+
    +
  1. Add storage schema and constants for statuses, categories, detected reasons, and feature flags.
  2. +
  3. Add reservation transaction: choose eligible low-sleep signal, enforce uniqueness, create reserved row with prompt_id, prompt_local_date, expires_at, and allowed categories.
  4. +
  5. Add Telegram send path that turns reserved into prompted only after send success.
  6. +
  7. Add callback handler for ctx: payloads with tenant/chat ownership, expiry, allowed-category validation, and first-answer-wins idempotency.
  8. +
  9. Add low-sleep detector with conservative threshold and enough settled-data checks.
  10. +
  11. Add report caveat rendering for answered/skipped context only when context_caveats_enabled is true.
  12. +
  13. Add cleanup/expiry for stale prompted/reserved rows.
  14. +
  15. Update this plan with actual implementation notes and verification after approval and code completion.
  16. +
+
+ +
+

Test Plan

+ +

Required Test Scenarios

+ +
+ +
+

Manual QA Checklist

+ +
+ +
+

Rollout Metrics

+

Define stop/go criteria before enabling beyond one tenant.

+ + + + + + + + + + + +
MetricGo signalStop/revisit signal
Prompts per user per rolling 7 days≤ 2 prompts/user/7 days.> 3 prompts/user/7 days or prompts on 3 consecutive days.
Response rate≥ 50% answered or skipped within 36h.< 30% answered or skipped after at least 5 prompts.
Skip/unknown rate≤ 50% of answered prompts.> 70% after at least 5 answers.
False ordinary-day prompt rate0 obvious false positives in manual review of first 5 prompts.≥ 1 obvious false positive pauses rollout.
Duplicate prompts0 duplicate prompt messages per prompt-local-date.Any duplicate is a blocker.
Expired callbacks≤ 30% of sent prompts expire unanswered.> 50% after at least 5 prompts.
Illness safety changes0 context-driven changes to illness confidence, caps, or EnergyBank safety overrides.Any context-driven safety change is a blocker.
+
+ +
+

Risks and Mitigations

+ +
+ +
+

Rollout Plan

+
    +
  1. Merge code with both proactive_context_prompts=false and context_caveats_enabled=false.
  2. +
  3. Deploy and verify DDL, webhook routing, and no prompt sends while disabled.
  4. +
  5. Enable prompt sending for one tenant only.
  6. +
  7. Review rollout metrics after a fixed small window, for example 7-14 days or at least 3 eligible prompts.
  8. +
  9. Enable caveats only after prompt lifecycle is clean and duplicate rate is zero.
  10. +
  11. Do not add second detector until first detector meets stop/go criteria.
  12. +
+
+ +
+

Rollback Plan

+ +
+ +
+

Out of Scope

+ +
+ +
+

Resolved Decisions and Follow-Ups

+ +
+ +
+

Implementation Result

+ +
+ +
+

Checks Run

+ +
+ +
+

Known Limitations

+ +
+ +
+

Approval Gate

+

Approved in chat on 2026-06-20. Implementation completed on branch codex/185-proactive-context-prompts. Do not enable proactive_context_prompts or context_caveats_enabled in production until the deployment checklist and rollout owner approve the tenant-level rollout.

+
+
+ + diff --git a/internal/health/i18n_en.go b/internal/health/i18n_en.go index e1ac6b6..22a44a2 100644 --- a/internal/health/i18n_en.go +++ b/internal/health/i18n_en.go @@ -227,6 +227,23 @@ var en = LangStrings{ "checkin_ack_late": "Logged after the morning report — saved for analytics.", "checkin_expired_note": "Want the report to reflect your state better? Answer the one-tap morning question tomorrow.", + // Proactive context prompts. Answers are categorical only; never store + // free text or the concrete real-world event behind the anomaly. + "context_prompt_low_sleep_text": "Your latest sleep was unusually low for you. Was there context worth noting?", + "context_prompt_btn_poor_sleep_context": "Bad sleep", + "context_prompt_btn_stress_context": "Stress", + "context_prompt_btn_travel_context": "Travel", + "context_prompt_btn_unknown_context": "Not sure", + "context_prompt_btn_skip_context": "Skip", + "context_prompt_ack_saved": "Context saved for future reports.", + "context_prompt_ack_skipped": "No context saved.", + "context_prompt_ack_expired": "This prompt expired.", + "context_prompt_label_poor_sleep_context": "You marked this low-sleep signal as sleep-quality context.", + "context_prompt_label_stress_context": "You marked this low-sleep signal as stress context.", + "context_prompt_label_travel_context": "You marked this low-sleep signal as travel context.", + "context_prompt_label_unknown_context": "You marked this low-sleep signal as unexplained context.", + "tg_context_notes": "Context notes", + "energy_note_capacity": "morning capacity carried over from sleep %.1fh + recovery markers", "energy_component_morning_capacity": "Morning capacity", "energy_component_activity_load": "Activity load (today vs 28-day chronic)", diff --git a/internal/health/i18n_ru.go b/internal/health/i18n_ru.go index adf35ff..13474a3 100644 --- a/internal/health/i18n_ru.go +++ b/internal/health/i18n_ru.go @@ -201,6 +201,23 @@ var ru = LangStrings{ "checkin_ack_late": "Записал после отчёта — пойдёт в аналитику.", "checkin_expired_note": "Хотите, чтобы отчёт точнее отражал ваше состояние? Ответьте одним нажатием на утренний вопрос завтра.", + // Proactive context prompts. Answers are categorical only; never store + // free text or the concrete real-world event behind the anomaly. + "context_prompt_low_sleep_text": "Последний сон был заметно ниже вашей нормы. Был какой-то контекст, который стоит отметить?", + "context_prompt_btn_poor_sleep_context": "Плохой сон", + "context_prompt_btn_stress_context": "Стресс", + "context_prompt_btn_travel_context": "Поездка", + "context_prompt_btn_unknown_context": "Не уверен(а)", + "context_prompt_btn_skip_context": "Пропустить", + "context_prompt_ack_saved": "Контекст сохранён для будущих отчётов.", + "context_prompt_ack_skipped": "Контекст не сохранён.", + "context_prompt_ack_expired": "Этот вопрос уже устарел.", + "context_prompt_label_poor_sleep_context": "Вы отметили этот сигнал низкого сна как контекст качества сна.", + "context_prompt_label_stress_context": "Вы отметили этот сигнал низкого сна как стрессовый контекст.", + "context_prompt_label_travel_context": "Вы отметили этот сигнал низкого сна как контекст поездки.", + "context_prompt_label_unknown_context": "Вы отметили этот сигнал низкого сна как необъяснённый контекст.", + "tg_context_notes": "Контекстные заметки", + "energy_note_capacity": "утренняя капасити = сон %.1fч + маркеры восстановления", "energy_component_morning_capacity": "Утренняя капасити", "energy_component_activity_load": "Нагрузка (сегодня vs 28-дн норма)", diff --git a/internal/health/i18n_sr.go b/internal/health/i18n_sr.go index e85db4a..2d5feb6 100644 --- a/internal/health/i18n_sr.go +++ b/internal/health/i18n_sr.go @@ -201,6 +201,23 @@ var sr = LangStrings{ "checkin_ack_late": "Zabeleženo posle izveštaja — ide u analitiku.", "checkin_expired_note": "Želite da izveštaj bolje odražava vaše stanje? Odgovorite jednim dodirom sutra.", + // Proactive context prompts. Answers are categorical only; never store + // free text or the concrete real-world event behind the anomaly. + "context_prompt_low_sleep_text": "Poslednji san je bio primetno ispod vaše norme. Ima li konteksta koji vredi zabeležiti?", + "context_prompt_btn_poor_sleep_context": "Loš san", + "context_prompt_btn_stress_context": "Stres", + "context_prompt_btn_travel_context": "Putovanje", + "context_prompt_btn_unknown_context": "Nisam siguran/na", + "context_prompt_btn_skip_context": "Preskoči", + "context_prompt_ack_saved": "Kontekst sačuvan za buduće izveštaje.", + "context_prompt_ack_skipped": "Kontekst nije sačuvan.", + "context_prompt_ack_expired": "Ovo pitanje je isteklo.", + "context_prompt_label_poor_sleep_context": "Ovaj signal niskog sna označen je kao kontekst kvaliteta sna.", + "context_prompt_label_stress_context": "Ovaj signal niskog sna označen je kao stresni kontekst.", + "context_prompt_label_travel_context": "Ovaj signal niskog sna označen je kao kontekst putovanja.", + "context_prompt_label_unknown_context": "Ovaj signal niskog sna označen je kao neobjašnjen kontekst.", + "tg_context_notes": "Beleške konteksta", + "energy_note_capacity": "jutarnji kapacitet iz sna %.1fh i markera oporavka", "energy_component_morning_capacity": "Jutarnji kapacitet", "energy_component_activity_load": "Opterećenje (danas vs 28d hronični)", diff --git a/internal/health/types.go b/internal/health/types.go index 84f27f5..9711fb9 100644 --- a/internal/health/types.go +++ b/internal/health/types.go @@ -453,15 +453,16 @@ type BriefingResponse struct { RecoveryPct int `json:"recovery_pct"` ReadinessToday int `json:"readiness_today"` // today only vs baseline // ReadinessTodayBand mirrors ReadinessBand for the today-only score. - ReadinessTodayBand string `json:"readiness_today_band"` - ReadinessTodayLabel string `json:"readiness_today_label"` - Correlation []CorrelationPoint `json:"correlation"` - Insights []Insight `json:"insights"` - Alerts []Alert `json:"alerts,omitempty"` - Sleep *SleepAnalysis `json:"sleep"` - MetricCards []MetricCard `json:"metric_cards"` - EnergyBank *EnergyBank `json:"energy_bank,omitempty"` - IllnessSuspicion *IllnessSuspicion `json:"illness_suspicion,omitempty"` + ReadinessTodayBand string `json:"readiness_today_band"` + ReadinessTodayLabel string `json:"readiness_today_label"` + Correlation []CorrelationPoint `json:"correlation"` + Insights []Insight `json:"insights"` + Alerts []Alert `json:"alerts,omitempty"` + Sleep *SleepAnalysis `json:"sleep"` + MetricCards []MetricCard `json:"metric_cards"` + EnergyBank *EnergyBank `json:"energy_bank,omitempty"` + IllnessSuspicion *IllnessSuspicion `json:"illness_suspicion,omitempty"` + ContextAnnotations []ContextAnnotationSummary `json:"context_annotations,omitempty"` // SubjectiveCheckin is the morning self-report (Telegram one-tap). // Populated from subjective_checkins when a row exists for today // in the tenant's REPORT_TZ. nil when no row — dashboard renders @@ -474,6 +475,15 @@ type BriefingResponse struct { AIInsight string `json:"ai_insight,omitempty"` } +type ContextAnnotationSummary struct { + Date string `json:"date"` + DetectedReason string `json:"detected_reason"` + Category string `json:"category"` + SleepHours float64 `json:"sleep_hours,omitempty"` + BaselineAvg float64 `json:"baseline_avg,omitempty"` + ZScore float64 `json:"z_score,omitempty"` +} + const ( ReadinessConfidenceFinal = "final" ReadinessConfidenceProvisional = "provisional" diff --git a/internal/notify/checkin_callback.go b/internal/notify/checkin_callback.go index a2349c4..ad823de 100644 --- a/internal/notify/checkin_callback.go +++ b/internal/notify/checkin_callback.go @@ -30,6 +30,10 @@ type CheckinAnswerRouter interface { // in-time answer. No-op when the report has already been sent // for today (idempotent on the tenant side). TriggerReport(schema string) + // SaveContextPromptAnswer persists an opaque proactive-context + // answer. It does not trigger reports; answers only affect future + // caveats. + SaveContextPromptAnswer(promptID, category, source string, answeredAt time.Time) (string, error) } // CheckinTenant carries the per-tenant routing context the webhook @@ -123,6 +127,26 @@ func NewWebhookHandler(cfg WebhookConfig) http.HandlerFunc { fmt.Fprintln(w, "ignored: unknown chat") return } + // Context callbacks intentionally do not check the send feature + // flag. `proactive_context_prompts=false` stops new prompt sends, + // but a user who already received a prompt must still be able to + // answer it until expires_at; otherwise a rollout kill switch would + // turn delivered buttons into dead UI. + if promptID, category, ok := parseContextPromptCallback(upd.CallbackQuery.Data); ok { + status, err := tenant.Router.SaveContextPromptAnswer(promptID, category, storage.ContextPromptSourceTelegram, time.Now()) + if err != nil { + log.Printf("telegram webhook: save context prompt tenant=%s prompt=%s category=%s err=%v", tenant.Schema, promptID, category, err) + _ = tenant.Router.AnswerCallbackQuery(upd.CallbackQuery.ID, "") + fmt.Fprintln(w, "ignored: context save error") + return + } + ack := contextAckText(tenant.Lang, status) + if err := tenant.Router.AnswerCallbackQuery(upd.CallbackQuery.ID, ack); err != nil { + log.Printf("telegram webhook: context ack: %v", err) + } + fmt.Fprintln(w, "ok") + return + } answer, date, ok := parseCheckinCallback(upd.CallbackQuery.Data) if !ok { log.Printf("telegram webhook: malformed callback %q from chat %s", upd.CallbackQuery.Data, chat) @@ -201,3 +225,17 @@ func ackText(lang, answer, status string) string { } return "" } + +func contextAckText(lang, status string) string { + strs := health.GetStrings(lang) + switch status { + case storage.ContextPromptStatusAnswered: + return strs["context_prompt_ack_saved"] + case storage.ContextPromptStatusSkipped: + return strs["context_prompt_ack_skipped"] + case storage.ContextPromptStatusExpired: + return strs["context_prompt_ack_expired"] + default: + return "" + } +} diff --git a/internal/notify/checkin_callback_test.go b/internal/notify/checkin_callback_test.go index a3f4e0b..4572e11 100644 --- a/internal/notify/checkin_callback_test.go +++ b/internal/notify/checkin_callback_test.go @@ -9,17 +9,22 @@ import ( "strings" "testing" "time" + + "health-receiver/internal/storage" ) type fakeRouter struct { - saveStatus string - saveErr error - saveCalls int - ackCalls int - triggers []string - lastDate string - lastSource string - lastAnswer string + saveStatus string + saveErr error + saveCalls int + ackCalls int + triggers []string + lastDate string + lastSource string + lastAnswer string + contextCalls int + lastPromptID string + lastCategory string } func (f *fakeRouter) SaveAnswer(date, source, answer string, _ time.Time) (string, error) { @@ -29,6 +34,11 @@ func (f *fakeRouter) SaveAnswer(date, source, answer string, _ time.Time) (strin } func (f *fakeRouter) AnswerCallbackQuery(qid, text string) error { f.ackCalls++; return nil } func (f *fakeRouter) TriggerReport(schema string) { f.triggers = append(f.triggers, schema) } +func (f *fakeRouter) SaveContextPromptAnswer(promptID, category, source string, _ time.Time) (string, error) { + f.contextCalls++ + f.lastPromptID, f.lastCategory, f.lastSource = promptID, category, source + return f.saveStatus, f.saveErr +} func buildUpdateBody(t *testing.T, chatID, callbackData string) []byte { t.Helper() @@ -174,6 +184,37 @@ func TestWebhook_LateAnswered_DoesNotTriggerReport(t *testing.T) { } } +func TestWebhook_ContextPromptAnswer_DoesNotTriggerReport(t *testing.T) { + router := &fakeRouter{saveStatus: storage.ContextPromptStatusAnswered} + h := NewWebhookHandler(WebhookConfig{ + Secret: "good", + TenantFinder: func(chat string) (CheckinTenant, bool) { + return CheckinTenant{Schema: "health", Lang: "ru", Router: router}, true + }, + }) + req := httptest.NewRequest("POST", "/api/telegram/webhook/good", bytes.NewReader(buildUpdateBody(t, "111", "ctx:cp_abc123:stress"))) + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + if router.saveCalls != 0 { + t.Fatalf("check-in save must not run for context prompt; got %d", router.saveCalls) + } + if router.contextCalls != 1 { + t.Fatalf("context save not called: %d", router.contextCalls) + } + if router.lastPromptID != "cp_abc123" || router.lastCategory != storage.ContextPromptCategoryStress || router.lastSource != storage.ContextPromptSourceTelegram { + t.Fatalf("wrong context payload: %+v", router) + } + if router.ackCalls != 1 { + t.Fatalf("ack not called: %d", router.ackCalls) + } + if len(router.triggers) != 0 { + t.Fatalf("context answer must not trigger report; got %v", router.triggers) + } +} + func TestWebhook_RejectsMalformedCallback(t *testing.T) { router := &fakeRouter{} h := NewWebhookHandler(WebhookConfig{ diff --git a/internal/notify/context_prompt.go b/internal/notify/context_prompt.go new file mode 100644 index 0000000..6b92dc8 --- /dev/null +++ b/internal/notify/context_prompt.go @@ -0,0 +1,99 @@ +package notify + +import ( + "fmt" + "strings" + "time" + + "health-receiver/internal/health" + "health-receiver/internal/storage" +) + +const ContextPromptCallbackPrefix = "ctx" + +var contextChoiceCodes = map[string]string{ + "poor": storage.ContextPromptCategoryPoorSleep, + "stress": storage.ContextPromptCategoryStress, + "travel": storage.ContextPromptCategoryTravel, + "unknown": storage.ContextPromptCategoryUnknown, + "skip": storage.ContextPromptCategorySkip, +} + +var contextCategoryCodes = map[string]string{ + storage.ContextPromptCategoryPoorSleep: "poor", + storage.ContextPromptCategoryStress: "stress", + storage.ContextPromptCategoryTravel: "travel", + storage.ContextPromptCategoryUnknown: "unknown", + storage.ContextPromptCategorySkip: "skip", +} + +type ContextPromptBot interface { + SendInlineKeyboard(text string, rows [][]InlineButton) (int64, error) +} + +type ContextPromptStore interface { + MarkContextPromptSent(promptID string, msgID int64, promptedAt time.Time) error + MarkContextPromptSendFailed(promptID string, failedAt time.Time) error +} + +func buildContextPromptButtons(lang string, prompt storage.ContextPromptInteraction) ([][]InlineButton, string) { + t := health.GetStrings(lang) + buttonFor := func(category string) InlineButton { + code := contextCategoryCodes[category] + return InlineButton{ + Text: t["context_prompt_btn_"+category], + CallbackData: fmt.Sprintf("%s:%s:%s", ContextPromptCallbackPrefix, prompt.PromptID, code), + } + } + row1 := []InlineButton{} + row2 := []InlineButton{} + for i, category := range prompt.AllowedCategories { + if _, ok := contextCategoryCodes[category]; !ok { + continue + } + if i < 3 { + row1 = append(row1, buttonFor(category)) + } else { + row2 = append(row2, buttonFor(category)) + } + } + rows := [][]InlineButton{} + if len(row1) > 0 { + rows = append(rows, row1) + } + if len(row2) > 0 { + rows = append(rows, row2) + } + return rows, t["context_prompt_low_sleep_text"] +} + +func parseContextPromptCallback(payload string) (string, string, bool) { + parts := strings.Split(payload, ":") + if len(parts) != 3 || parts[0] != ContextPromptCallbackPrefix { + return "", "", false + } + if parts[1] == "" { + return "", "", false + } + category, ok := contextChoiceCodes[parts[2]] + if !ok { + return "", "", false + } + return parts[1], category, true +} + +func SendContextPrompt(bot ContextPromptBot, store ContextPromptStore, lang string, prompt *storage.ContextPromptInteraction, now time.Time) error { + if prompt == nil { + return nil + } + rows, text := buildContextPromptButtons(lang, *prompt) + if len(rows) == 0 { + return nil + } + msgID, err := bot.SendInlineKeyboard(text, rows) + if err != nil { + _ = store.MarkContextPromptSendFailed(prompt.PromptID, now) + return err + } + return store.MarkContextPromptSent(prompt.PromptID, msgID, now) +} diff --git a/internal/notify/context_prompt_test.go b/internal/notify/context_prompt_test.go new file mode 100644 index 0000000..4e2db10 --- /dev/null +++ b/internal/notify/context_prompt_test.go @@ -0,0 +1,62 @@ +package notify + +import ( + "strings" + "testing" + + "health-receiver/internal/storage" +) + +func TestParseContextPromptCallback(t *testing.T) { + promptID, category, ok := parseContextPromptCallback("ctx:cp_abcdef:travel") + if !ok { + t.Fatal("callback should parse") + } + if promptID != "cp_abcdef" || category != storage.ContextPromptCategoryTravel { + t.Fatalf("got prompt=%q category=%q", promptID, category) + } + for _, payload := range []string{ + "", + "checkin:great:2026-06-20", + "ctx::travel", + "ctx:cp_abcdef:illness", + "ctx:cp_abcdef:travel:2026-06-20", + } { + if _, _, ok := parseContextPromptCallback(payload); ok { + t.Fatalf("payload %q should be rejected", payload) + } + } +} + +func TestBuildContextPromptButtons_UsesOpaqueCallbackData(t *testing.T) { + prompt := storage.ContextPromptInteraction{ + PromptID: "cp_abcdef", + AllowedCategories: []string{ + storage.ContextPromptCategoryPoorSleep, + storage.ContextPromptCategoryStress, + storage.ContextPromptCategoryTravel, + storage.ContextPromptCategoryUnknown, + storage.ContextPromptCategorySkip, + }, + } + rows, text := buildContextPromptButtons("en", prompt) + if text == "" { + t.Fatal("prompt text should be localized") + } + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2", len(rows)) + } + for _, row := range rows { + for _, btn := range row { + if len(btn.CallbackData) > 64 { + t.Fatalf("callback too long: %q (%d)", btn.CallbackData, len(btn.CallbackData)) + } + if !strings.HasPrefix(btn.CallbackData, "ctx:cp_abcdef:") { + t.Fatalf("callback should be opaque prompt id + choice code, got %q", btn.CallbackData) + } + if strings.Contains(btn.CallbackData, "2026") || strings.Contains(btn.CallbackData, "low_sleep") || strings.Contains(btn.CallbackData, "context") { + t.Fatalf("callback leaks semantic context: %q", btn.CallbackData) + } + } + } +} diff --git a/internal/notify/report.go b/internal/notify/report.go index 9dba58c..c71a79c 100644 --- a/internal/notify/report.go +++ b/internal/notify/report.go @@ -530,6 +530,21 @@ func renderAlerts(sb *strings.Builder, alerts []health.Alert, lang string) { sb.WriteByte('\n') } +func renderContextAnnotations(sb *strings.Builder, annotations []health.ContextAnnotationSummary, lang string) { + if len(annotations) == 0 { + return + } + fmt.Fprintf(sb, "📝 %s\n", tr(lang, "tg_context_notes")) + for _, a := range annotations { + label := tr(lang, "context_prompt_label_"+a.Category) + if label == "context_prompt_label_"+a.Category { + label = tr(lang, "context_prompt_label_unknown_context") + } + fmt.Fprintf(sb, " • %s\n", label) + } + sb.WriteByte('\n') +} + func abs(x int) int { if x < 0 { return -x @@ -562,6 +577,7 @@ func formatMorning(b *health.BriefingResponse, aiBlocks map[string]string, lang renderEnergyBank(&sb, b.EnergyBank, lang) renderReadiness(&sb, b, lang) renderAlerts(&sb, b.Alerts, lang) + renderContextAnnotations(&sb, b.ContextAnnotations, lang) // Sleep — rule-based bullets always; AI take layered underneath. If sleep // data is silent for ≥36h, the briefing is from a stale night and the diff --git a/internal/notify/report_rich.go b/internal/notify/report_rich.go index b1e23c6..78c9c74 100644 --- a/internal/notify/report_rich.go +++ b/internal/notify/report_rich.go @@ -34,6 +34,7 @@ func formatMorningRich(b *health.BriefingResponse, aiBlocks map[string]string, l renderRichEnergyBank(&sb, b.EnergyBank, lang) renderRichReadiness(&sb, b, lang) renderRichAlerts(&sb, b.Alerts, lang) + renderRichContextAnnotations(&sb, b.ContextAnnotations, lang) switch { case f.sleepStale() && f.sleepKnown: @@ -153,6 +154,21 @@ func renderRichHeadline(sb *strings.Builder, h *health.HeadlineSignal) { sb.WriteString("

\n") } +func renderRichContextAnnotations(sb *strings.Builder, annotations []health.ContextAnnotationSummary, lang string) { + if len(annotations) == 0 { + return + } + fmt.Fprintf(sb, "

📝 %s

\n\n") +} + func renderRichMorningSummary(sb *strings.Builder, b *health.BriefingResponse, f freshness, lang string) { sleepValue := "—" sleepRead := tr(lang, "tg_warn_no_sleep") diff --git a/internal/storage/briefing.go b/internal/storage/briefing.go index c02e763..2673e0c 100644 --- a/internal/storage/briefing.go +++ b/internal/storage/briefing.go @@ -670,6 +670,23 @@ func (s *DB) GetHealthBriefing(lang string) (*health.BriefingResponse, error) { resp.IllnessSuspicion = health.ComputeIllnessSuspicion(illnessInput) health.ApplyIllnessSafetyCap(resp, health.GetStrings(lang)) + if IsContextCaveatsEnabled(s) { + if annotations, aerr := s.GetRecentContextAnnotationsThroughDate(*lastDate, 3); aerr == nil { + for _, a := range annotations { + resp.ContextAnnotations = append(resp.ContextAnnotations, health.ContextAnnotationSummary{ + Date: a.Date, + DetectedReason: a.DetectedReason, + Category: a.Category, + SleepHours: a.SleepHours, + BaselineAvg: a.BaselineAvg, + ZScore: a.ZScore, + }) + } + } else { + log.Printf("context annotations: %v", aerr) + } + } + if resp.EnergyBank != nil { go s.SaveEnergyBankSnapshot(*lastDate, resp.EnergyBank) } diff --git a/internal/storage/context_prompt.go b/internal/storage/context_prompt.go new file mode 100644 index 0000000..a5f2da4 --- /dev/null +++ b/internal/storage/context_prompt.go @@ -0,0 +1,805 @@ +package storage + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "math" + "time" + + "github.com/jackc/pgx/v5" + "health-receiver/internal/health" +) + +const ( + SettingProactiveContextPrompts = "proactive_context_prompts" + SettingContextCaveatsEnabled = "context_caveats_enabled" + SettingContextPromptRetentionDays = "context_prompt_retention_days" + + ContextPromptDetectedReasonLowSleep = "low_sleep" + ContextPromptDetectorVersionLowSleepV2 = "low_sleep_v2" + + ContextPromptStatusReserved = "reserved" + ContextPromptStatusPrompted = "prompted" + ContextPromptStatusAnswered = "answered" + ContextPromptStatusSkipped = "skipped" + ContextPromptStatusExpired = "expired" + ContextPromptStatusSendFailed = "send_failed" + + ContextPromptCategoryPoorSleep = "poor_sleep_context" + ContextPromptCategoryStress = "stress_context" + ContextPromptCategoryTravel = "travel_context" + ContextPromptCategoryUnknown = "unknown_context" + ContextPromptCategorySkip = "skip_context" + + ContextPromptSourceTelegram = "telegram" +) + +var DefaultContextPromptCategories = []string{ + ContextPromptCategoryPoorSleep, + ContextPromptCategoryStress, + ContextPromptCategoryTravel, + ContextPromptCategoryUnknown, + ContextPromptCategorySkip, +} + +type ContextPromptInteraction struct { + PromptID string + SignalDate string + PromptLocalDate string + DetectedReason string + DetectorVersion string + Status string + Category string + Source string + PromptMessageID int64 + PromptedAt time.Time + ExpiresAt time.Time + AnsweredAt time.Time + AllowedCategories []string + Metadata map[string]any +} + +type LowSleepContextDetection struct { + SignalDate string + SleepHours float64 + BaselineAvg float64 + BaselineStdDev float64 + BaselineDays int + ZScore float64 + Eligible bool + Reason string +} + +type LowSleepPromptGate struct { + Eligible bool + Reason string + SleepStructureDisrupted bool + TimingDeviation bool + RepeatedShortNights int + RecentEquivalentPrompt bool + ExistingCheckinAnswer string + IllnessConfidence string +} + +type lowSleepPromptGateInput struct { + Candidate LowSleepContextDetection + SleepAwakeHours float64 + TimingDeviation bool + RepeatedShortNights int + RecentEquivalentPrompt bool + ExistingCheckinAnswer string + IllnessConfidence string +} + +type contextSleepDay struct { + Date string + SleepHours float64 + SourceCount int + MaxSourceSleep float64 +} + +type ContextAnnotation struct { + Date string `json:"date"` + DetectedReason string `json:"detected_reason"` + Category string `json:"category"` + SleepHours float64 `json:"sleep_hours,omitempty"` + BaselineAvg float64 `json:"baseline_avg,omitempty"` + ZScore float64 `json:"z_score,omitempty"` +} + +func contextPromptInteractionsTableDDL() string { + return `CREATE TABLE IF NOT EXISTS context_prompt_interactions ( + prompt_id TEXT PRIMARY KEY, + signal_date DATE NOT NULL, + prompt_local_date DATE NOT NULL, + detected_reason TEXT NOT NULL, + detector_version TEXT NOT NULL, + status TEXT NOT NULL, + category TEXT, + source TEXT, + prompt_message_id BIGINT, + prompted_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL, + answered_at TIMESTAMPTZ, + allowed_categories JSONB NOT NULL DEFAULT '[]'::jsonb, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (signal_date, detected_reason) + )` +} + +func contextPromptDailyDedupeIndexDDL() string { + return `CREATE UNIQUE INDEX IF NOT EXISTS idx_context_prompt_one_sent_per_day + ON context_prompt_interactions (prompt_local_date) + WHERE status IN ('reserved','prompted','answered','skipped','expired','send_failed')` +} + +func (s *DB) EnsureContextPromptInteractionsTable() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + stmts := []string{ + contextPromptInteractionsTableDDL(), + contextPromptDailyDedupeIndexDDL(), + `CREATE INDEX IF NOT EXISTS idx_context_prompt_status_expires + ON context_prompt_interactions (status, expires_at)`, + } + for _, stmt := range stmts { + if _, err := s.pool.Exec(ctx, stmt); err != nil { + log.Printf("EnsureContextPromptInteractionsTable: %v", err) + } + } +} + +func ValidateContextPromptCategory(category string) error { + switch category { + case ContextPromptCategoryPoorSleep, + ContextPromptCategoryStress, + ContextPromptCategoryTravel, + ContextPromptCategoryUnknown, + ContextPromptCategorySkip: + return nil + } + return fmt.Errorf("invalid context prompt category %q", category) +} + +func IsContextPromptsEnabled(s *DB) bool { + return getSettingBool(s, SettingProactiveContextPrompts, false) +} + +func IsContextCaveatsEnabled(s *DB) bool { + return getSettingBool(s, SettingContextCaveatsEnabled, false) +} + +func (s *DB) ContextPromptRetentionDays() int { + days := getSettingInt(s, SettingContextPromptRetentionDays, 180) + if days < 30 { + return 30 + } + if days > 730 { + return 730 + } + return days +} + +func (s *DB) PruneContextPromptInteractions(now time.Time) (int64, error) { + cutoff := now.AddDate(0, 0, -s.ContextPromptRetentionDays()) + ctx, cancel := queryCtx() + defer cancel() + tag, err := s.pool.Exec(ctx, ` + DELETE FROM context_prompt_interactions + WHERE created_at < $1`, cutoff) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +func (s *DB) DetectLowSleepContextPrompt(signalDate string) (LowSleepContextDetection, error) { + out := LowSleepContextDetection{SignalDate: signalDate} + if _, err := time.Parse("2006-01-02", signalDate); err != nil { + out.Reason = "bad_signal_date" + return out, fmt.Errorf("signalDate must be YYYY-MM-DD: %w", err) + } + ctx, cancel := queryCtx() + defer cancel() + var sleep *float64 + if err := s.pool.QueryRow(ctx, ` + SELECT sleep_total + FROM daily_scores + WHERE date = $1`, signalDate).Scan(&sleep); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + out.Reason = "missing_signal_day" + return out, nil + } + return out, err + } + if sleep == nil || *sleep <= 0 { + out.Reason = "missing_sleep_total" + return out, nil + } + targetStats, err := s.contextSleepSourceStats(ctx, signalDate) + if err != nil { + return out, err + } + target := contextSleepDay{ + Date: signalDate, + SleepHours: *sleep, + SourceCount: targetStats.SourceCount, + MaxSourceSleep: targetStats.MaxSourceSleep, + } + + var baseline []contextSleepDay + rows, err := s.pool.Query(ctx, ` + SELECT date, sleep_total + FROM daily_scores + WHERE date >= $1 + AND date < $2 + AND sleep_total IS NOT NULL + AND sleep_total > 0 + ORDER BY date DESC`, + subtractDays(signalDate, 30), signalDate) + if err != nil { + return out, err + } + defer rows.Close() + for rows.Next() { + var date string + var v float64 + if err := rows.Scan(&date, &v); err != nil { + return out, err + } + stats, err := s.contextSleepSourceStats(ctx, date) + if err != nil { + return out, err + } + baseline = append(baseline, contextSleepDay{ + Date: date, + SleepHours: v, + SourceCount: stats.SourceCount, + MaxSourceSleep: stats.MaxSourceSleep, + }) + } + if err := rows.Err(); err != nil { + return out, err + } + return evaluateLowSleepContext(target, baseline), nil +} + +type contextSleepSourceStats struct { + SourceCount int + MaxSourceSleep float64 +} + +func (s *DB) contextSleepSourceStats(ctx context.Context, date string) (contextSleepSourceStats, error) { + var stats contextSleepSourceStats + err := s.pool.QueryRow(ctx, ` + SELECT COUNT(*)::int, COALESCE(MAX(source_total), 0)::double precision + FROM ( + SELECT source, SUM(avg_val)::double precision AS source_total + FROM hourly_metrics + WHERE metric_name = 'sleep_total' + AND SUBSTRING(hour, 1, 10) = $1 + GROUP BY source + ) src`, date).Scan(&stats.SourceCount, &stats.MaxSourceSleep) + return stats, err +} + +func evaluateLowSleepContext(target contextSleepDay, baseline []contextSleepDay) LowSleepContextDetection { + out := LowSleepContextDetection{SignalDate: target.Date, SleepHours: target.SleepHours} + prelim := make([]float64, 0, len(baseline)) + for _, day := range baseline { + if day.SleepHours >= 3.0 { + prelim = append(prelim, day.SleepHours) + } + } + baselineRef := 6.0 + if len(prelim) > 0 { + baselineRef, _ = avgStdDev(prelim) + } + + validBaseline := make([]float64, 0, len(baseline)) + for _, day := range baseline { + if reason := contextSleepQualityRejectReason(day, baselineRef); reason != "" { + continue + } + validBaseline = append(validBaseline, day.SleepHours) + } + out.BaselineDays = len(validBaseline) + if len(validBaseline) < 14 { + out.Reason = "baseline_warmup" + return out + } + avg, sd := avgStdDev(validBaseline) + out.BaselineAvg = avg + out.BaselineStdDev = sd + + if reason := contextSleepQualityRejectReason(target, avg); reason != "" { + out.Reason = reason + return out + } + if sd <= 0.15 { + out.Reason = "baseline_flat" + return out + } + out.ZScore = (out.SleepHours - avg) / sd + if out.SleepHours <= 6.0 && out.ZScore <= -1.5 { + out.Eligible = true + out.Reason = "eligible" + return out + } + out.Reason = "within_expected_range" + return out +} + +func contextSleepQualityRejectReason(day contextSleepDay, baselineAvg float64) string { + if day.SleepHours < 3.0 { + return "capture_gap" + } + if day.SourceCount > 1 && day.MaxSourceSleep-day.SleepHours >= 2.0 { + conflictFloor := math.Max(6.0, baselineAvg-1.0) + if day.MaxSourceSleep >= conflictFloor { + return "source_conflict" + } + } + return "" +} + +func evaluateLowSleepPromptGate(input lowSleepPromptGateInput) LowSleepPromptGate { + out := LowSleepPromptGate{ + SleepStructureDisrupted: input.SleepAwakeHours >= 1.0 || + (input.Candidate.SleepHours > 0 && input.SleepAwakeHours/input.Candidate.SleepHours >= 0.12), + TimingDeviation: input.TimingDeviation, + RepeatedShortNights: input.RepeatedShortNights, + RecentEquivalentPrompt: input.RecentEquivalentPrompt, + ExistingCheckinAnswer: input.ExistingCheckinAnswer, + IllnessConfidence: input.IllnessConfidence, + } + if !input.Candidate.Eligible { + out.Reason = "candidate_" + input.Candidate.Reason + return out + } + if input.RecentEquivalentPrompt { + out.Reason = "recent_equivalent_prompt" + return out + } + if input.ExistingCheckinAnswer == CheckinAnswerSick { + out.Reason = "existing_sick_checkin" + return out + } + if input.IllnessConfidence == "moderate" || input.IllnessConfidence == "high" { + out.Reason = "active_illness_flow" + return out + } + strongAnomaly := input.Candidate.SleepHours <= 4.5 || input.Candidate.ZScore <= -2.5 + if strongAnomaly || out.SleepStructureDisrupted || out.TimingDeviation || out.RepeatedShortNights >= 2 { + out.Eligible = true + out.Reason = "eligible" + return out + } + out.Reason = "low_usefulness" + return out +} + +func avgStdDev(vals []float64) (float64, float64) { + if len(vals) == 0 { + return 0, 0 + } + var sum float64 + for _, v := range vals { + sum += v + } + avg := sum / float64(len(vals)) + var ss float64 + for _, v := range vals { + d := v - avg + ss += d * d + } + return avg, math.Sqrt(ss / float64(len(vals))) +} + +func (s *DB) ReserveLowSleepContextPrompt(signalDate, promptLocalDate string, now, expiresAt time.Time) (*ContextPromptInteraction, bool, error) { + detection, err := s.DetectLowSleepContextPrompt(signalDate) + if err != nil || !detection.Eligible { + return nil, false, err + } + gate, err := s.EvaluateLowSleepPromptUsefulness(signalDate, detection) + if err != nil || !gate.Eligible { + return nil, false, err + } + metadata := map[string]any{ + "sleep_hours": round1(detection.SleepHours), + "baseline_avg": round1(detection.BaselineAvg), + "z_score": round2(detection.ZScore), + "baseline_days": detection.BaselineDays, + "prompt_gate_reason": gate.Reason, + "sleep_structure_disrupted": gate.SleepStructureDisrupted, + "timing_deviation": gate.TimingDeviation, + "repeated_short_nights": gate.RepeatedShortNights, + } + return s.reserveContextPrompt(ContextPromptDetectedReasonLowSleep, ContextPromptDetectorVersionLowSleepV2, + signalDate, promptLocalDate, DefaultContextPromptCategories, metadata, now, expiresAt) +} + +func (s *DB) EvaluateLowSleepPromptUsefulness(signalDate string, candidate LowSleepContextDetection) (LowSleepPromptGate, error) { + ctx, cancel := queryCtx() + defer cancel() + awake, err := s.contextSleepAwakeHours(ctx, signalDate) + if err != nil { + return LowSleepPromptGate{}, err + } + repeated, err := s.contextRepeatedShortNights(ctx, signalDate, candidate.BaselineAvg) + if err != nil { + return LowSleepPromptGate{}, err + } + timing, err := s.contextSleepTimingDeviation(ctx, signalDate) + if err != nil { + return LowSleepPromptGate{}, err + } + recent, err := s.contextRecentEquivalentPrompt(ctx, signalDate) + if err != nil { + return LowSleepPromptGate{}, err + } + checkinAnswer := "" + var subjective *health.SubjectiveCheckinSummary + // daily_scores.sleep_total is keyed by the local wake/sleep-summary + // date. The subjective morning check-in for that same local date is + // the user-provided context for the analyzed sleep; do not look at + // promptLocalDate here. + if row, err := s.GetTodayCheckin(signalDate, CheckinSourceTelegram); err == nil && row != nil { + checkinAnswer = row.Answer + subjective = &health.SubjectiveCheckinSummary{Status: row.Status, Answer: row.Answer} + } else if err != nil { + return LowSleepPromptGate{}, err + } + illness := health.ComputeIllnessSuspicion(s.BuildIllnessEvidenceInput(signalDate, subjective)) + return evaluateLowSleepPromptGate(lowSleepPromptGateInput{ + Candidate: candidate, + SleepAwakeHours: awake, + TimingDeviation: timing, + RepeatedShortNights: repeated, + RecentEquivalentPrompt: recent, + ExistingCheckinAnswer: checkinAnswer, + IllnessConfidence: illness.Confidence, + }), nil +} + +func (s *DB) contextSleepAwakeHours(ctx context.Context, date string) (float64, error) { + var awake *float64 + err := s.pool.QueryRow(ctx, ` + SELECT sleep_awake + FROM daily_scores + WHERE date = $1`, date).Scan(&awake) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return 0, nil + } + return 0, err + } + if awake == nil { + return 0, nil + } + return *awake, nil +} + +func (s *DB) contextRepeatedShortNights(ctx context.Context, signalDate string, baselineAvg float64) (int, error) { + rows, err := s.pool.Query(ctx, ` + SELECT date, sleep_total + FROM daily_scores + WHERE date >= $1 + AND date <= $2 + AND sleep_total IS NOT NULL + AND sleep_total > 0`, + subtractDays(signalDate, 2), signalDate) + if err != nil { + return 0, err + } + defer rows.Close() + n := 0 + for rows.Next() { + var date string + var sleep float64 + if err := rows.Scan(&date, &sleep); err != nil { + return 0, err + } + stats, err := s.contextSleepSourceStats(ctx, date) + if err != nil { + return 0, err + } + day := contextSleepDay{Date: date, SleepHours: sleep, SourceCount: stats.SourceCount, MaxSourceSleep: stats.MaxSourceSleep} + if contextSleepQualityRejectReason(day, baselineAvg) == "" && sleep <= 6.0 { + n++ + } + } + return n, rows.Err() +} + +func (s *DB) contextRecentEquivalentPrompt(ctx context.Context, signalDate string) (bool, error) { + var exists bool + err := s.pool.QueryRow(ctx, ` + SELECT EXISTS( + SELECT 1 + FROM context_prompt_interactions + WHERE detected_reason = $1 + AND signal_date >= $2::date - INTERVAL '14 days' + AND signal_date < $2::date + AND status IN ('reserved','prompted','answered','skipped','expired','send_failed') + )`, + ContextPromptDetectedReasonLowSleep, signalDate).Scan(&exists) + return exists, err +} + +func (s *DB) contextSleepTimingDeviation(ctx context.Context, signalDate string) (bool, error) { + // Disabled for MVP send decisions. A raw metric_points timing read can + // mix sources after the candidate detector already filtered source + // conflicts. Re-enable only with a source-qualified timing extractor + // that uses the same normalized sleep day/window as low_sleep_v2. + return false, nil +} + +func (s *DB) contextSleepTimingForDate(ctx context.Context, date string) (int, int, bool, error) { + var minTime, maxTime *string + err := s.pool.QueryRow(ctx, ` + SELECT MIN(SUBSTRING(date, 12, 5)), MAX(SUBSTRING(date, 12, 5)) + FROM metric_points + WHERE metric_name IN ('sleep_deep','sleep_rem','sleep_core','sleep_unspecified') + AND SUBSTRING(date, 1, 10) = $1 + AND SUBSTRING(date, 12, 8) <> '00:00:00'`, date).Scan(&minTime, &maxTime) + if err != nil { + return 0, 0, false, err + } + if minTime == nil || maxTime == nil { + return 0, 0, false, nil + } + start, ok := hhmmToMinute(*minTime) + if !ok { + return 0, 0, false, nil + } + end, ok := hhmmToMinute(*maxTime) + if !ok { + return 0, 0, false, nil + } + return start, end, true, nil +} + +func hhmmToMinute(v string) (int, bool) { + if len(v) != 5 || v[2] != ':' { + return 0, false + } + for _, idx := range []int{0, 1, 3, 4} { + if v[idx] < '0' || v[idx] > '9' { + return 0, false + } + } + h := int(v[0]-'0')*10 + int(v[1]-'0') + m := int(v[3]-'0')*10 + int(v[4]-'0') + if h < 0 || h > 23 || m < 0 || m > 59 { + return 0, false + } + return h*60 + m, true +} + +func (s *DB) reserveContextPrompt(reason, detectorVersion, signalDate, promptLocalDate string, allowed []string, metadata map[string]any, now, expiresAt time.Time) (*ContextPromptInteraction, bool, error) { + for _, category := range allowed { + if err := ValidateContextPromptCategory(category); err != nil { + return nil, false, err + } + } + promptID, err := newContextPromptID() + if err != nil { + return nil, false, err + } + allowedJSON, err := json.Marshal(allowed) + if err != nil { + return nil, false, err + } + metadataJSON, err := json.Marshal(metadata) + if err != nil { + return nil, false, err + } + ctx, cancel := queryCtx() + defer cancel() + row := &ContextPromptInteraction{} + var allowedRaw []byte + var metadataRaw []byte + err = s.pool.QueryRow(ctx, ` + INSERT INTO context_prompt_interactions + (prompt_id, signal_date, prompt_local_date, detected_reason, detector_version, status, expires_at, allowed_categories, metadata, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10, $10) + ON CONFLICT DO NOTHING + RETURNING prompt_id, signal_date::text, prompt_local_date::text, detected_reason, detector_version, status, + COALESCE(category, ''), COALESCE(source, ''), COALESCE(prompt_message_id, 0), + COALESCE(prompted_at, '0001-01-01'::timestamptz), expires_at, + COALESCE(answered_at, '0001-01-01'::timestamptz), allowed_categories, metadata`, + promptID, signalDate, promptLocalDate, reason, detectorVersion, ContextPromptStatusReserved, expiresAt, json.RawMessage(allowedJSON), json.RawMessage(metadataJSON), now). + Scan(&row.PromptID, &row.SignalDate, &row.PromptLocalDate, &row.DetectedReason, &row.DetectorVersion, &row.Status, + &row.Category, &row.Source, &row.PromptMessageID, &row.PromptedAt, &row.ExpiresAt, &row.AnsweredAt, &allowedRaw, &metadataRaw) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, nil + } + return nil, false, err + } + if err := json.Unmarshal(allowedRaw, &row.AllowedCategories); err != nil { + return nil, false, err + } + if err := json.Unmarshal(metadataRaw, &row.Metadata); err != nil { + return nil, false, err + } + return row, true, nil +} + +func (s *DB) MarkContextPromptSent(promptID string, msgID int64, promptedAt time.Time) error { + ctx, cancel := queryCtx() + defer cancel() + tag, err := s.pool.Exec(ctx, ` + UPDATE context_prompt_interactions + SET status = $2, source = $3, prompt_message_id = $4, prompted_at = $5, updated_at = $5 + WHERE prompt_id = $1 + AND status = $6`, + promptID, ContextPromptStatusPrompted, ContextPromptSourceTelegram, msgID, promptedAt, ContextPromptStatusReserved) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("context prompt %s was not reserved", promptID) + } + return nil +} + +func (s *DB) MarkContextPromptSendFailed(promptID string, failedAt time.Time) error { + ctx, cancel := queryCtx() + defer cancel() + _, err := s.pool.Exec(ctx, ` + UPDATE context_prompt_interactions + SET status = $2, updated_at = $3 + WHERE prompt_id = $1 + AND status = $4`, + promptID, ContextPromptStatusSendFailed, failedAt, ContextPromptStatusReserved) + return err +} + +func (s *DB) SaveContextPromptAnswer(promptID, category, source string, answeredAt time.Time) (string, error) { + if err := ValidateContextPromptCategory(category); err != nil { + return "", err + } + ctx, cancel := queryCtx() + defer cancel() + tx, err := s.pool.Begin(ctx) + if err != nil { + return "", err + } + defer tx.Rollback(ctx) + + var status string + var expiresAt time.Time + var allowedRaw []byte + if err := tx.QueryRow(ctx, ` + SELECT status, expires_at, allowed_categories + FROM context_prompt_interactions + WHERE prompt_id = $1 + FOR UPDATE`, promptID).Scan(&status, &expiresAt, &allowedRaw); err != nil { + return "", err + } + if status == ContextPromptStatusAnswered || status == ContextPromptStatusSkipped || status == ContextPromptStatusExpired { + return status, tx.Commit(ctx) + } + if !contextPromptCanAcceptAnswer(status) { + return status, tx.Commit(ctx) + } + if !answeredAt.Before(expiresAt) { + if _, err := tx.Exec(ctx, ` + UPDATE context_prompt_interactions + SET status = $2, updated_at = $3 + WHERE prompt_id = $1`, + promptID, ContextPromptStatusExpired, answeredAt); err != nil { + return "", err + } + return ContextPromptStatusExpired, tx.Commit(ctx) + } + var allowed []string + if err := json.Unmarshal(allowedRaw, &allowed); err != nil { + return "", err + } + if !stringInSlice(category, allowed) { + return "", fmt.Errorf("category %q not allowed for prompt %s", category, promptID) + } + nextStatus := ContextPromptStatusAnswered + if category == ContextPromptCategoryUnknown || category == ContextPromptCategorySkip { + nextStatus = ContextPromptStatusSkipped + } + if _, err := tx.Exec(ctx, ` + UPDATE context_prompt_interactions + SET status = $2, category = $3, source = $4, answered_at = $5, updated_at = $5 + WHERE prompt_id = $1`, + promptID, nextStatus, category, source, answeredAt); err != nil { + return "", err + } + return nextStatus, tx.Commit(ctx) +} + +func contextPromptCanAcceptAnswer(status string) bool { + return status == ContextPromptStatusPrompted || status == ContextPromptStatusReserved +} + +func (s *DB) GetContextAnnotationsForDate(signalDate string) ([]ContextAnnotation, error) { + return s.GetRecentContextAnnotationsThroughDate(signalDate, 1) +} + +func (s *DB) GetRecentContextAnnotationsThroughDate(toDate string, days int) ([]ContextAnnotation, error) { + if days < 1 { + days = 1 + } + if days > 14 { + days = 14 + } + fromDate := subtractDays(toDate, days-1) + ctx, cancel := queryCtx() + defer cancel() + rows, err := s.pool.Query(ctx, ` + SELECT signal_date::text, detected_reason, category, metadata + FROM context_prompt_interactions + WHERE signal_date >= $1 + AND signal_date <= $2 + AND status = $3 + AND category IS NOT NULL + ORDER BY signal_date DESC, answered_at DESC`, fromDate, toDate, ContextPromptStatusAnswered) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ContextAnnotation + for rows.Next() { + var a ContextAnnotation + var metadataRaw []byte + if err := rows.Scan(&a.Date, &a.DetectedReason, &a.Category, &metadataRaw); err != nil { + return nil, err + } + var metadata map[string]any + _ = json.Unmarshal(metadataRaw, &metadata) + a.SleepHours = floatFromMetadata(metadata, "sleep_hours") + a.BaselineAvg = floatFromMetadata(metadata, "baseline_avg") + a.ZScore = floatFromMetadata(metadata, "z_score") + out = append(out, a) + } + return out, rows.Err() +} + +func newContextPromptID() (string, error) { + var b [10]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return "cp_" + hex.EncodeToString(b[:]), nil +} + +func stringInSlice(v string, vals []string) bool { + for _, x := range vals { + if v == x { + return true + } + } + return false +} + +func round1(v float64) float64 { return math.Round(v*10) / 10 } +func round2(v float64) float64 { return math.Round(v*100) / 100 } + +func floatFromMetadata(m map[string]any, key string) float64 { + if m == nil { + return 0 + } + switch v := m[key].(type) { + case float64: + return v + case int: + return float64(v) + default: + return 0 + } +} diff --git a/internal/storage/context_prompt_test.go b/internal/storage/context_prompt_test.go new file mode 100644 index 0000000..381d384 --- /dev/null +++ b/internal/storage/context_prompt_test.go @@ -0,0 +1,279 @@ +package storage + +import ( + "strings" + "testing" +) + +func TestContextPromptInteractionsDDL_HasPrivacyAndLifecycleColumns(t *testing.T) { + ddl := contextPromptInteractionsTableDDL() + for _, col := range []string{ + "prompt_id TEXT PRIMARY KEY", + "signal_date DATE NOT NULL", + "prompt_local_date DATE NOT NULL", + "detected_reason TEXT NOT NULL", + "detector_version TEXT NOT NULL", + "status TEXT NOT NULL", + "category TEXT", + "source TEXT", + "prompt_message_id BIGINT", + "prompted_at TIMESTAMPTZ", + "expires_at TIMESTAMPTZ NOT NULL", + "answered_at TIMESTAMPTZ", + "allowed_categories JSONB NOT NULL DEFAULT '[]'::jsonb", + "metadata JSONB NOT NULL DEFAULT '{}'::jsonb", + "UNIQUE (signal_date, detected_reason)", + } { + if !strings.Contains(ddl, col) { + t.Errorf("DDL missing %q\n\nfull DDL:\n%s", col, ddl) + } + } + if strings.Contains(strings.ToLower(ddl), "free_text") || strings.Contains(strings.ToLower(ddl), "note") { + t.Fatalf("context prompt DDL must not add free-text note fields:\n%s", ddl) + } +} + +func TestContextPromptDailyDedupeIncludesSendFailed(t *testing.T) { + indexDDL := contextPromptDailyDedupeIndexDDL() + if !strings.Contains(indexDDL, "'send_failed'") { + t.Fatal("send_failed must occupy the daily prompt slot to preserve at-most-once semantics") + } +} + +func TestValidateContextPromptCategory(t *testing.T) { + for _, category := range DefaultContextPromptCategories { + if err := ValidateContextPromptCategory(category); err != nil { + t.Fatalf("category %q should be valid: %v", category, err) + } + } + for _, category := range []string{"", "illness_context", "procedure", "custom_text"} { + if err := ValidateContextPromptCategory(category); err == nil { + t.Fatalf("category %q should be rejected", category) + } + } +} + +func TestAvgStdDev(t *testing.T) { + avg, sd := avgStdDev([]float64{7, 7, 8, 6}) + if avg != 7 { + t.Fatalf("avg = %v, want 7", avg) + } + if sd < 0.70 || sd > 0.71 { + t.Fatalf("sd = %v, want about 0.707", sd) + } +} + +func TestContextPromptRetentionSettingName(t *testing.T) { + if SettingContextPromptRetentionDays != "context_prompt_retention_days" { + t.Fatalf("retention setting name changed: %q", SettingContextPromptRetentionDays) + } +} + +func TestContextPromptCanAcceptAnswer_RecoversReservedPrompt(t *testing.T) { + for _, status := range []string{ContextPromptStatusPrompted, ContextPromptStatusReserved} { + if !contextPromptCanAcceptAnswer(status) { + t.Fatalf("status %q should accept answers", status) + } + } + for _, status := range []string{ContextPromptStatusAnswered, ContextPromptStatusSkipped, ContextPromptStatusExpired, ContextPromptStatusSendFailed} { + if contextPromptCanAcceptAnswer(status) { + t.Fatalf("status %q should not accept answers", status) + } + } +} + +func TestEvaluateLowSleepContext_RejectsCaptureGap(t *testing.T) { + got := evaluateLowSleepContext(contextSleepDay{ + Date: "2026-06-20", + SleepHours: 2.99, + SourceCount: 1, + MaxSourceSleep: 2.99, + }, baselineDays(7.2)) + if got.Eligible { + t.Fatalf("capture gap must not be eligible: %+v", got) + } + if got.Reason != "capture_gap" { + t.Fatalf("reason = %q, want capture_gap", got.Reason) + } +} + +func TestEvaluateLowSleepContext_RejectsMaterialSourceConflict(t *testing.T) { + got := evaluateLowSleepContext(contextSleepDay{ + Date: "2026-01-03", + SleepHours: 4.92, + SourceCount: 2, + MaxSourceSleep: 8.01, + }, baselineDays(7.2)) + if got.Eligible { + t.Fatalf("source conflict must not be eligible: %+v", got) + } + if got.Reason != "source_conflict" { + t.Fatalf("reason = %q, want source_conflict", got.Reason) + } +} + +func TestEvaluateLowSleepContext_AllSourcesComparableLowPrompts(t *testing.T) { + got := evaluateLowSleepContext(contextSleepDay{ + Date: "2026-06-20", + SleepHours: 4.9, + SourceCount: 2, + MaxSourceSleep: 5.4, + }, baselineDays(7.2)) + if !got.Eligible { + t.Fatalf("comparable low sleep should be eligible: %+v", got) + } + if got.Reason != "eligible" { + t.Fatalf("reason = %q, want eligible", got.Reason) + } +} + +func TestEvaluateLowSleepContext_FiltersInvalidBaselineBeforeZScore(t *testing.T) { + baseline := baselineDays(7.2) + baseline = append(baseline, + contextSleepDay{Date: "gap-1", SleepHours: 0.8, SourceCount: 1, MaxSourceSleep: 0.8}, + contextSleepDay{Date: "conflict-1", SleepHours: 4.9, SourceCount: 2, MaxSourceSleep: 8.1}, + ) + got := evaluateLowSleepContext(contextSleepDay{ + Date: "2026-06-20", + SleepHours: 5.4, + SourceCount: 1, + MaxSourceSleep: 5.4, + }, baseline) + if !got.Eligible { + t.Fatalf("valid baseline after filtering should keep target eligible: %+v", got) + } + if got.BaselineDays != 14 { + t.Fatalf("baseline days = %d, want 14 valid days after filtering", got.BaselineDays) + } + if got.BaselineAvg < 7.1 || got.BaselineAvg > 7.3 { + t.Fatalf("baseline avg = %.3f, want invalid low days excluded", got.BaselineAvg) + } +} + +func TestEvaluateLowSleepPromptGate_VetoesExistingSickCheckin(t *testing.T) { + got := evaluateLowSleepPromptGate(lowSleepPromptGateInput{ + Candidate: eligibleLowSleepCandidate(), + ExistingCheckinAnswer: CheckinAnswerSick, + RepeatedShortNights: 2, + }) + if got.Eligible { + t.Fatalf("sick check-in should veto context prompt: %+v", got) + } + if got.Reason != "existing_sick_checkin" { + t.Fatalf("reason = %q, want existing_sick_checkin", got.Reason) + } +} + +func TestEvaluateLowSleepPromptGate_VetoesActiveIllnessFlow(t *testing.T) { + got := evaluateLowSleepPromptGate(lowSleepPromptGateInput{ + Candidate: eligibleLowSleepCandidate(), + IllnessConfidence: "moderate", + RepeatedShortNights: 2, + }) + if got.Eligible { + t.Fatalf("illness flow should veto context prompt: %+v", got) + } + if got.Reason != "active_illness_flow" { + t.Fatalf("reason = %q, want active_illness_flow", got.Reason) + } +} + +func TestEvaluateLowSleepPromptGate_VetoesRecentEquivalentPrompt(t *testing.T) { + got := evaluateLowSleepPromptGate(lowSleepPromptGateInput{ + Candidate: eligibleLowSleepCandidate(), + RecentEquivalentPrompt: true, + RepeatedShortNights: 2, + }) + if got.Eligible { + t.Fatalf("recent equivalent prompt should veto: %+v", got) + } + if got.Reason != "recent_equivalent_prompt" { + t.Fatalf("reason = %q, want recent_equivalent_prompt", got.Reason) + } +} + +func TestEvaluateLowSleepPromptGate_AllowsUsefulSignals(t *testing.T) { + cases := []struct { + name string + input lowSleepPromptGateInput + }{ + {"sleep structure", lowSleepPromptGateInput{Candidate: eligibleLowSleepCandidate(), SleepAwakeHours: 1.1}}, + {"timing", lowSleepPromptGateInput{Candidate: eligibleLowSleepCandidate(), TimingDeviation: true}}, + {"trend", lowSleepPromptGateInput{Candidate: eligibleLowSleepCandidate(), RepeatedShortNights: 2}}, + {"strong anomaly", lowSleepPromptGateInput{Candidate: strongLowSleepCandidate()}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := evaluateLowSleepPromptGate(tc.input) + if !got.Eligible { + t.Fatalf("expected useful prompt: %+v", got) + } + }) + } +} + +func TestEvaluateLowSleepPromptGate_RejectsLowUsefulnessCandidate(t *testing.T) { + candidate := eligibleLowSleepCandidate() + candidate.SleepHours = 5.8 + candidate.ZScore = -1.6 + got := evaluateLowSleepPromptGate(lowSleepPromptGateInput{Candidate: candidate}) + if got.Eligible { + t.Fatalf("weak isolated candidate should be low usefulness: %+v", got) + } + if got.Reason != "low_usefulness" { + t.Fatalf("reason = %q, want low_usefulness", got.Reason) + } +} + +func TestEvaluateLowSleepPromptGate_CandidateQualityCannotBeBypassed(t *testing.T) { + candidate := eligibleLowSleepCandidate() + candidate.Eligible = false + candidate.Reason = "source_conflict" + got := evaluateLowSleepPromptGate(lowSleepPromptGateInput{ + Candidate: candidate, + SleepAwakeHours: 1.4, + TimingDeviation: true, + RepeatedShortNights: 3, + }) + if got.Eligible { + t.Fatalf("quality-rejected candidate must not be rescued by usefulness evidence: %+v", got) + } + if got.Reason != "candidate_source_conflict" { + t.Fatalf("reason = %q, want candidate_source_conflict", got.Reason) + } +} + +func eligibleLowSleepCandidate() LowSleepContextDetection { + return LowSleepContextDetection{ + SignalDate: "2026-06-20", + SleepHours: 5.4, + BaselineAvg: 7.2, + BaselineStdDev: 0.8, + BaselineDays: 14, + ZScore: -2.25, + Eligible: true, + Reason: "eligible", + } +} + +func strongLowSleepCandidate() LowSleepContextDetection { + c := eligibleLowSleepCandidate() + c.SleepHours = 4.4 + c.ZScore = -2.7 + return c +} + +func baselineDays(center float64) []contextSleepDay { + pattern := []float64{-0.4, -0.3, -0.2, -0.1, 0, 0.1, 0.2, 0.3, 0.4, -0.25, 0.25, -0.15, 0.15, 0.05} + out := make([]contextSleepDay, 0, len(pattern)) + for _, delta := range pattern { + sleep := center + delta + out = append(out, contextSleepDay{ + Date: "baseline", + SleepHours: sleep, + SourceCount: 1, + MaxSourceSleep: sleep, + }) + } + return out +} diff --git a/internal/tenants/manager.go b/internal/tenants/manager.go index 3ed89f7..4318ab8 100644 --- a/internal/tenants/manager.go +++ b/internal/tenants/manager.go @@ -335,6 +335,8 @@ func (m *Manager) CreateUserSchema(ctx context.Context, schemaName string) error db.EnsureAIBriefingBlocksTable() db.EnsureEnergySnapshotsTable() db.EnsureReadinessRedesignTables() + db.EnsureSubjectiveCheckinsTable() + db.EnsureContextPromptInteractionsTable() // Verify the readiness-redesign schema landed cleanly. Ensure is // log-and-continue so startup never blocks, but a new tenant must // not be handed back to the caller with broken Phase 0 storage —