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
- gofmt -w internal\storage\subjective_checkin.go internal\storage\subjective_checkin_test.go internal\ui\handler.go internal\ui\admin_checkin_coverage_test.go internal\ui\i18n_en.go internal\ui\i18n_ru.go internal\ui\i18n_sr.go
+ gofmt -w internal/storage/subjective_checkin.go internal/storage/subjective_checkin_test.go internal/ui/handler.go internal/ui/admin_checkin_coverage_test.go internal/ui/i18n_en.go internal/ui/i18n_ru.go internal/ui/i18n_sr.go
go test ./internal/storage -run "Test.*Checkin|Test.*CheckinCoverage" -count=1 - passed.
go test ./internal/ui -run TestAdminCheckinCoverage -count=1 - passed.
@@ -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
+
+ - Replace
category NOT NULL annotation rows with an explicit prompt/interaction state machine where category is nullable until answered.
+ - Use
signal_date and prompt_local_date as separate DATE fields. Do not use ambiguous date TEXT.
+ - Deduplicate signals by
signal_date + detected_reason, not by source.
+ - Use an opaque
prompt_id in Telegram callbacks. Do not put date, detector reason, or category as the identity in callback payload.
+ - Expire callbacks by
expires_at, not by "callback date equals today". A morning prompt commonly explains yesterday's signal.
+ - Start with one non-safety detector. Defer respiratory/oxygen and high-autonomic-load prompts until the UX proves itself.
+ - Rename
ignore_signal to skip_context / unknown_context so users do not think safety warnings are disabled.
+ - Stored context applies to one detector only, not the whole day.
+ - Answers apply to future reports. First slice does not edit an already-sent report.
+
+
+
+
+ Inspected Current Architecture
+
+ internal/notify/proactive.go supports one-way proactive notifications with cadence and error isolation. It does not model a prompt lifecycle, reservation, expiry, or answered state.
+ internal/storage/subjective_checkin.go has a robust check-in state machine and stale callback protection, but its "date must equal today" rule is specific to morning check-ins and is not valid for context prompts about prior-day signals.
+ internal/notify/checkin_callback.go validates chat/tenant routing and rejects stale check-in callbacks before storage mutation. The context flow should reuse tenant lookup style but not reuse date semantics.
+ internal/storage/briefing.go computes illness suspicion and applies safety caps before report output. Context annotations must sit beside this, not inside it.
+ internal/health/illness.go already has non-diagnostic wording tests and safety cap tests. These become regression anchors for this feature.
+ internal/notify/report.go and internal/notify/report_rich.go can show future detector-specific caveats after a prompt is answered, gated behind a separate display setting.
+
+
+
+
+ Illness-Safety Boundary
+ This feature must complement illness detection, not compete with it.
+
+ IllnessSuspicion remains the safety layer. It can cap readiness confidence and EnergyBank recommendation when objective evidence is moderate/high.
+ - Context prompts are an explanation layer. They ask for broad context and mark one detector result as explained or skipped.
+ - Context answers must not lower illness confidence, remove illness signals, or undo illness safety caps.
+ illness_context is not part of MVP category choices. When added later, it must not feed ComputeIllnessSuspicion as subjective illness evidence without a separate validation plan.
+ - Safety-sensitive detectors such as respiratory/oxygen shifts are explicitly out of the first slice.
+
+
+
+
+ Privacy Boundary
+
+ - No free text in Telegram, storage, logs, issue comments, docs, Gemini prompts, or report caveats.
+ - No specific medical procedures, diagnoses, clinic names, physician names, locations, personal names, or real person/date examples in public artifacts.
+ - Allowed stored categories for MVP:
poor_sleep_context, stress_context, travel_context, unknown_context, skip_context.
+ - Allowed metadata: detector key/version, numeric value/baseline/z-score/percentile, prompt id, allowed categories, and lifecycle timestamps.
+ - Even closed categories such as stress/travel are sensitive. Add retention/delete policy in this plan and do not treat them as harmless telemetry.
+
+
+
+
+ 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.
+
+ | Detector | Decision | Reason |
+
+
+ low_sleep |
+ First slice candidate |
+ Clear user-facing explanation; uses settled sleep data; non-safety; report context is easy to apply detector-specifically. |
+
+
+ unusual_daytime_rest |
+ Follow-up detector |
+ Useful, but needs more careful segmentation and false-positive handling. |
+
+
+ low_activity |
+ Follow-up detector |
+ Good candidate, but device coverage and illness/travel/recovery ambiguity need more handling. |
+
+
+ high_autonomic_load |
+ Defer |
+ Intersects heavily with illness prodrome and stress flags. |
+
+
+ respiratory_or_oxygen_shift |
+ Out of MVP |
+ Safety-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');
+
+ source is an answer/source channel, not event identity.
+ category is nullable until answer.
+ signal_date is the date of the metric anomaly.
+ prompt_local_date is the local date on which the prompt is/was sent.
+ - Use
DATE, not TEXT, to prevent string/date confusion.
+
+
+
+
+ Prompt Lifecycle
+
+ | Status | Meaning | Transitions |
+
+ detected | Signal exists but no prompt is reserved. | Optional; MVP may skip and reserve directly inside a transaction. |
+ reserved | Scheduler claimed the daily prompt slot before Telegram send. | prompted after send success; send_failed after send failure. |
+ prompted | Telegram prompt was sent and can be answered until expires_at. | answered, skipped, or expired. |
+ answered | User selected a concrete context category. | Terminal; repeated same callback is idempotent. |
+ skipped | User selected skip_context or unknown_context. | Terminal; suppresses future prompt for same signal. |
+ expired | Prompt lifetime elapsed. | Terminal for MVP; do not re-prompt same signal. |
+ send_failed | Reservation 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
+
+ - Callback payload shape:
ctx:<prompt_id>:<choice_code>, where both IDs are short and opaque enough to fit Telegram's 64-byte limit.
+ - Server loads the prompt by
prompt_id and validates tenant/chat ownership, status, expires_at, and allowed category set.
+ - Do not encode signal date, detector reason, or full category as callback identity.
+ - Repeated webhook delivery is idempotent. A second click on the same final category returns an ack without rewriting timestamps.
+ - Double-clicking different categories after a final answer must not change the stored category in MVP. First valid answer wins.
+ - If feature flag is disabled after prompt send but before answer, accept already-sent prompt answers unless a separate kill switch requires hard rejection.
+
+
+
+
+ MVP Bot UX
+
+
+
+
Prompt
+
Sleep was unusually short
+
Your latest sleep was well below your usual range. What context should be noted?
+
+
+
+
+
+
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
+
+ - Answers apply to future reports only. MVP does not edit an already-sent Telegram report.
+ - Acknowledgement text should say "Saved for future reports" to avoid implying the current report changed.
+ - No pending caveat for non-safety MVP signals. Pending caveats add anxiety without user value.
+ - Context is detector-specific: "Sleep context: travel" or "Activity context: recovery", not "this whole day is incomparable".
+ - Future baseline exclusion must also be metric/detector-specific and requires a separate plan. MVP does not exclude annotations from baselines.
+
+
+
+
+ Feature Flags and Kill Switches
+
+ proactive_context_prompts: controls sending new prompts. Default false.
+ context_caveats_enabled: controls report rendering of saved context caveats. Default false.
+ - Disabling prompt sending does not delete stored rows and does not reject already-sent prompt answers unless explicitly configured.
+ - Disabling caveats hides context in reports without affecting prompt lifecycle storage.
+
+
+
+
+ Reliability Scenarios
+
+ | Scenario | Expected MVP behavior |
+
+ | Two scheduler runs in parallel | Unique constraints allow at most one reservation and one prompt-local-date slot. |
+ | Crash after reservation before Telegram send | Row remains reserved and occupies the daily prompt slot. No duplicate prompt. |
+ | Telegram send succeeds but status update fails | Prefer no re-send. Log loudly; row may need admin cleanup. Avoid duplicate UX. |
+ | Telegram send fails | Mark send_failed if possible; send_failed occupies the daily prompt slot and is terminal in MVP. |
+ | Repeated webhook delivery | Idempotent ack, no timestamp/category churn. |
+ | Double tap different buttons | First valid final answer wins; second receives neutral ack. |
+ | Feature flag disabled after send | Already-sent answer is accepted unless a hard kill switch is added later. |
+
+
+
+
+
+ Retention and Deletion
+
+ - MVP retention is 180 days by default via
context_prompt_retention_days, clamped to 30-730 days. Daily maintenance deletes older interaction rows.
+ - Numeric metadata should be minimal and derivable. Do not store raw metric series in annotations.
+ - Provide a future deletion path per tenant/date/detected_reason; dashboard/admin editing is a separate issue.
+ - Issue/PR/docs must not include real user examples.
+
+
+
+
+ Files Likely to Change
+
+ internal/storage/context_prompt.go - DDL, state machine, reservation, answer save, expiry, cleanup helpers.
+ internal/storage/context_prompt_test.go - state transitions, uniqueness, metadata allowlist, reliability scenarios.
+ internal/storage/context_anomaly.go - low-sleep detector and detector registry contract.
+ internal/notify/context_prompt.go - Telegram prompt rendering, callback parsing, ack text.
+ internal/notify/checkin_callback.go or new webhook dispatcher - route checkin: and ctx: prefixes safely.
+ internal/notify/report.go and internal/notify/report_rich.go - future-report detector-specific caveat rendering, behind kill switch.
+ internal/health/i18n_en.go, internal/health/i18n_ru.go, internal/health/i18n_sr.go - prompt labels, choices, acknowledgements, future caveats.
+ cmd/server/main.go - startup DDL and scheduler/webhook wiring.
+ AGENTS.md and CLAUDE.md only if durable architecture instructions are added; mirror both files.
+
+
+
+
+ Implementation Steps
+
+ - Add storage schema and constants for statuses, categories, detected reasons, and feature flags.
+ - Add reservation transaction: choose eligible low-sleep signal, enforce uniqueness, create
reserved row with prompt_id, prompt_local_date, expires_at, and allowed categories.
+ - Add Telegram send path that turns
reserved into prompted only after send success.
+ - Add callback handler for
ctx: payloads with tenant/chat ownership, expiry, allowed-category validation, and first-answer-wins idempotency.
+ - Add low-sleep detector with conservative threshold and enough settled-data checks.
+ - Add report caveat rendering for answered/skipped context only when
context_caveats_enabled is true.
+ - Add cleanup/expiry for stale prompted/reserved rows.
+ - Update this plan with actual implementation notes and verification after approval and code completion.
+
+
+
+
+ Test Plan
+
+ go test ./internal/storage -run Context -count=1
+ go test ./internal/notify -run Context -count=1
+ go test ./internal/notify -run Checkin -count=1 to prove existing check-in callbacks still work.
+ go test ./internal/health -run Illness -count=1 to prove illness logic is unchanged.
+ go test ./internal/notify ./internal/storage ./internal/health -count=1
+ - DB-backed smoke if a test DB is configured: DDL, uniqueness, reservation race, answer idempotency, expiry, metadata allowlist.
+
+ Required Test Scenarios
+
+ - Two concurrent scheduler attempts reserve only one prompt.
+ - Same signal from a later source cannot create a second event row.
+ - One sent prompt per prompt-local-date.
+ - Callback for yesterday's signal is valid if prompt is not expired.
+ - Expired prompt rejects answer without mutation.
+ - Category not in allowed set rejects answer.
+ - Repeated webhook delivery is idempotent.
+ - Double tap different categories keeps first answer.
+ - Feature flag off prevents new prompts.
+ - Context answer does not alter illness confidence or safety caps.
+
+
+
+
+ Manual QA Checklist
+
+ - Ordinary day: no prompt.
+ - Low-sleep synthetic day: one prompt only.
+ - Prompt answer ack says saved for future reports.
+ - Current already-sent report is not edited.
+ - Next report shows detector-specific caveat only when caveats are enabled.
+ - Check-in prompt still triggers morning report when answered in time.
+ - Context prompt answer never triggers a morning report.
+ - Disabling prompt flag stops new prompts.
+ - Disabling caveat flag hides report caveats.
+
+
+
+
+ Rollout Metrics
+ Define stop/go criteria before enabling beyond one tenant.
+
+ | Metric | Go signal | Stop/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 rate | 0 obvious false positives in manual review of first 5 prompts. | ≥ 1 obvious false positive pauses rollout. |
+ | Duplicate prompts | 0 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 changes | 0 context-driven changes to illness confidence, caps, or EnergyBank safety overrides. | Any context-driven safety change is a blocker. |
+
+
+
+
+
+ Risks and Mitigations
+
+ - State mismatch: explicit statuses and nullable category fix awaiting-classification modeling.
+ - Duplicate prompts: unique constraints, reservation transaction, and at-most-once policy.
+ - Wrong stale callback semantics: prompt expiry is based on
expires_at, not signal date equals today.
+ - UX fatigue: one detector, one prompt per day, rolling cooldown, and stop/go metrics.
+ - Safety conflict: non-safety MVP detector and regression tests around illness caps.
+ - Privacy leak: closed enums, no free text, metadata allowlist, retention policy.
+ - Over-broad context: detector-specific caveats only.
+
+
+
+
+ Rollout Plan
+
+ - Merge code with both
proactive_context_prompts=false and context_caveats_enabled=false.
+ - Deploy and verify DDL, webhook routing, and no prompt sends while disabled.
+ - Enable prompt sending for one tenant only.
+ - Review rollout metrics after a fixed small window, for example 7-14 days or at least 3 eligible prompts.
+ - Enable caveats only after prompt lifecycle is clean and duplicate rate is zero.
+ - Do not add second detector until first detector meets stop/go criteria.
+
+
+
+
+ Rollback Plan
+
+ - Disable
proactive_context_prompts to stop new prompts.
+ - Disable
context_caveats_enabled to hide report caveats.
+ - Stored rows remain until the 180-day default retention job prunes them, unless the operator manually deletes rows earlier.
+ - No readiness or EnergyBank recalculation is required because MVP does not change scoring.
+
+
+
+
+ Out of Scope
+
+ - Respiratory/oxygen prompts.
+ - High-autonomic-load prompts.
+ - Dashboard/admin editing of annotations.
+ - Editing already-sent Telegram reports.
+ - Sending context to Gemini.
+ - Automatic baseline exclusion.
+ - Free-text explanations.
+
+
+
+
+ Resolved Decisions and Follow-Ups
+
+ - First detector:
low_sleep.
+ - Low-sleep threshold:
sleep_total <= 6.0h and z-score <= -1.5 against at least 14 valid prior baseline days in the last 30 days.
+ - Sleep data quality gate: reject target and baseline days with
sleep_total < 3.0h as capture_gap.
+ - Source-conflict gate: reject target and baseline days when another source in the same normalized sleep day is at least 2h higher and reaches
max(6.0h, baseline_avg - 1.0h). The detector does not decide which source is correct; material disagreement suppresses the prompt.
+ - Prompt-usefulness gate: after candidate detection, veto if there is a recent equivalent prompt, wake-date
sick subjective check-in for the analyzed sleep, or moderate/high illness suspicion.
+ - Prompt-usefulness positive evidence: strong anomaly (
sleep_total <= 4.5h or z-score <= -2.5), disrupted sleep structure/awake time, or at least 2 short nights in the last 3 days. Timing evidence is disabled in MVP until a source-qualified timing extractor exists.
+ - Usefulness evidence cannot rescue a candidate rejected by
capture_gap, source_conflict, baseline warmup, or another candidate-quality reason.
+ - Prompt expiry: 36 hours after prompt reservation/send attempt.
+ - Retention: default 180 days, configurable via
context_prompt_retention_days and clamped to 30-730 days.
+ - Follow-up: add admin/SQL rollout dashboard before enabling beyond the first tenant.
+
+
+
+
+ Implementation Result
+
+ - Added
context_prompt_interactions with opaque prompt_id, separate signal_date / prompt_local_date, nullable category, explicit lifecycle statuses, allowed-category JSON, and metadata allowlist.
+ - Added startup DDL wiring for legacy mode, existing tenants, newly created tenants, and the tenant manager provisioning path.
+ - Implemented the first detector as
low_sleep_v2: requires at least 14 valid prior baseline days, sleep_total <= 6.0h, and z-score <= -1.5 against the prior 30-day valid sleep baseline.
+ - Added merge-gate sleep quality guards:
capture_gap for selected sleep below 3h, and source_conflict for material same-day source disagreement. The same predicate filters baseline days before computing average and standard deviation.
+ - Added separate prompt-usefulness gate: it vetoes recent equivalent prompts, wake-date
sick check-ins for the analyzed sleep, and moderate/high illness flow; it requires at least one usefulness signal such as strong anomaly (sleep_total <= 4.5h or z-score <= -2.5), awake/efficiency disruption, or repeated short-night trend.
+ - Disabled timing deviation as send evidence for MVP because raw timing rows can mix sources after candidate source-quality checks. Re-enable only with a source-qualified timing extractor.
+ - Candidate quality remains first in the pipeline: sleep structure evidence is evaluated only after
low_sleep_v2 has passed coverage/source-quality checks, and tests pin that usefulness evidence cannot bypass a rejected candidate.
+ - Context prompt callbacks now accept both
prompted and reserved rows, so a Telegram send that succeeds before the sent-status DB update fails does not leave a delivered button unanswerable.
+ - Context prompt reservation/send runs after the morning report send mutex is released. Prompt dedupe relies on
context_prompt_interactions uniqueness instead of holding the morning report lock.
+ - Added post-morning-report prompt sending behind
settings.proactive_context_prompts. The prompt is reserved before send, marks send_failed on Telegram failure, and uses at-most-once semantics.
+ - Added Telegram callback format
ctx:<prompt_id>:<choice_code>. It does not include signal date, detector reason, category names, or any real-world event details.
+ - Extended the webhook router to handle context answers separately from subjective check-ins. Context answers do not trigger morning reports and expire via
expires_at.
+ - Added future-report context caveats behind
settings.context_caveats_enabled. Caveats are attached after illness suspicion and safety caps, so context cannot lower illness confidence or remove safety caps.
+ - Made
send_failed terminal for MVP and part of the one-prompt-per-local-day dedupe constraint.
+ - Kept callbacks accepted after prompt delivery even when
proactive_context_prompts is later disabled; the flag stops new sends, not answers to already delivered prompts.
+ - Added 180-day default retention with daily pruning and
context_prompt_retention_days bounds.
+ - Added English, Russian, and Serbian copy for prompt text, button labels, callback acknowledgements, and report caveats.
+
+
+
+
+ Checks Run
+
+ gofmt on all touched Go files.
+ go test ./internal/storage ./internal/notify ./internal/health
+ go test ./cmd/server
+ go test ./...
+ - Real DB dry-run on schema
health: before guards, 8 potential prompt days in the last 180 days; after guards, 4. Since the broader retrospective start, after guards, 18. With timing evidence disabled for MVP, prompt-usefulness sends 3 of the 4 last-180-day candidates; one low-usefulness candidate is rejected by the policy gate, and the known manually reviewed historical outlier remains no_trigger.
+
+
+
+
+ Known Limitations
+
+ - No live database race test was added; the project already uses structural DDL tests for this layer. Race prevention relies on SQL uniqueness plus transactional answer updates.
+ - Retention now has a default prune path, but there is still no user-facing deletion UI.
+ - There is no admin UI for prompt metrics yet. Rollout should use SQL until a later observability slice.
+ - The first detector uses a conservative hybrid threshold plus source-quality guards. It should be reviewed after real prompt volume is observed.
+
+
+
+
+ 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", richEsc(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", richText(label))
+ }
+ sb.WriteString("
\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 —