Created 2026-06-18 for the overdue Todoist follow-up: Subjective check-in — calendar SLA after explicit enabled-since date.
+
+
+
Task Summary
+
+ Add an explicit per-tenant date from which subjective Telegram check-in prompts are expected,
+ then expose a calendar-day SLA view that marks missing expected prompt days as real failures.
+ Keep the current latest-prompts view because it remains useful for operator history and response latency.
+
+
+
+
+
Current Behavior
+
+
internal/storage/subjective_checkin.go owns subjective_checkins, with one row per (date, source).
+
GetCheckinCoverage(today, source, days) deliberately returns the latest N actual rows only. It does not synthesize missing calendar dates.
+
The code comment says this was intentional during rollout, because pre-rollout calendar gaps would create false failures.
+
CheckinStatusMissing already exists, and the admin UI already has CSS/status labels for missing, but no storage path emits missing rows today.
+
/api/admin/checkin-coverage?days=14 is admin-only, resolves the active tenant, computes tenant-local today, and returns the current read model.
+
internal/ui/templates/pages/admin.html renders one Subjective check-in coverage table from that response.
+
The feature-enabled check for the morning gate is currently webhook-secret based in cmd/server/main.go::morningCheckinEnabled; it is not persisted as a calendar SLA start date.
+
+
+
+
+
Desired Behavior
+
+
+
Explicit Start
+
Store a tenant-local YYYY-MM-DD setting such as subjective_checkin_enabled_since. Empty means calendar SLA is not active yet.
+
+
+
Calendar SLA
+
For dates from max(enabled_since, today-days+1) through today, synthesize one row per day. Missing DB rows become missing.
+
+
+
History Preserved
+
Continue returning/rendering latest actual prompt rows separately so response latency and late answers remain easy to inspect.
+
+
+
+
+
+
Assumptions and Unknowns
+
+
Use the existing per-tenant settings table instead of a schema migration. The table already supports arbitrary key/value tenant settings.
+
The enabled-since date should be set by an admin/operator, not inferred from the first prompt row, because the requirement says explicit enabled-since date.
+
Calendar missing means no row exists for (date, telegram). A row with expired is not missing; it means the prompt was sent and not answered before cap.
+
Open question: should the implementation provide a one-click "set to first prompt date" helper, or only a manual date input?
+
Open question: should today count as missing before the morning check-in window has elapsed? The conservative default is to include today only after the tenant-local morning cap has passed, or mark it as pending if the day is still in progress.
+
+
+
+
+
Files Likely to Change
+
+
internal/storage/subjective_checkin.go - add enabled-since helpers and a calendar SLA read model that overlays actual rows on expected dates.
+
internal/storage/subjective_checkin_test.go - unit tests for calendar synthesis, missing rows, lower bound at enabled-since, and current latest-row behavior.
+
internal/ui/handler.go - extend adminCheckinCoverage or add a small companion admin endpoint to save/read subjective_checkin_enabled_since.
+
internal/ui/admin_checkin_coverage_test.go - API JSON shape tests for calendar rows and invalid date handling.
+
internal/ui/templates/pages/admin.html - add date input/save control and render calendar SLA plus latest prompt history.
+
internal/ui/i18n_en.go, internal/ui/i18n_ru.go, internal/ui/i18n_sr.go - labels for enabled-since, SLA mode, history table, pending/missing explanation if needed.
+
internal/ui/style.go - small layout adjustments if the two-table admin section needs clearer spacing.
+
+
+
+
+
Proposed Implementation Steps
+
+
Add constants and validation for subjective_checkin_enabled_since in storage. Accept empty or valid YYYY-MM-DD.
+
Add GetCheckinEnabledSince and SaveCheckinEnabledSince wrappers over settings.
+
Keep GetCheckinCoverage as the latest-actual history path, or rename internally to make that explicit while preserving the API shape.
+
Add a calendar builder that receives today, enabledSince, days, and actual rows, then returns expected rows newest-first with synthesized missing rows.
+
Extend the admin response with enabled_since, sla_active, calendar_rows, and calendar_summary, while keeping existing rows and summary for history.
+
Add admin POST handling for the enabled-since date. Keep it tenant-scoped through the existing resolveAdminTenantScope path.
+
Update the admin UI to show a date control, a clear inactive state when no date is set, a calendar SLA table, and the existing latest prompt history table.
+
Update this plan after implementation with actual changes, checks run, limitations, and follow-up recommendations.
+
+
+
+
+
Data, API, Auth, and UX Impact
+
+
Data: no new table required if the enabled-since value lives in settings. No destructive migration.
+
API: admin-only check-in coverage response gains fields. Existing fields should remain stable to reduce template and test churn.
+
Auth: no change. Reads and writes stay behind adminGuard and tenant scope resolution.
+
UX: the admin page will distinguish "SLA not active" from "SLA active and missing prompt rows". This avoids false alarms before explicit enablement.
+
Deployment: safe deploy before setting the date. Calendar missing rows only appear after the admin setting is saved.
+
+
+
+
+
Test Plan
+
+
go test ./internal/storage -run "Test.*Checkin|Test.*CheckinCoverage" -count=1
+
go test ./internal/ui -run TestAdminCheckinCoverage -count=1
+
If DB-backed behavior is touched beyond pure builders, run the targeted DB-enabled UI/storage tests using the repo's usual DB environment.
+
Run gofmt -w on changed Go files.
+
Manual JSON check: call /api/admin/checkin-coverage?days=14 for a tenant with and without subjective_checkin_enabled_since.
+
+
+
+
+
Manual QA Checklist
+
+
Admin page shows check-in coverage without JavaScript errors when enabled-since is empty.
+
Saving an enabled-since date persists for the active tenant only.
+
Calendar SLA starts no earlier than enabled-since and no earlier than the selected day window.
+
Dates with actual answered, late_answered, expired, or prompted rows keep those statuses.
+
Dates with no row after enabled-since render as missing.
+
Latest prompt history still shows actual rows only and still reports average response latency from answered rows.
+
Multi-tenant admin tab switching reloads the correct tenant's SLA date and rows.
+
+
+
+
+
Risks and Edge Cases
+
+
Counting today too early could create noisy false missing rows. The implementation should either exclude in-progress today until cap or label it pending.
+
Timezone drift matters because dates are tenant-local. Use the same tenantLocalToday path as the current endpoint.
+
Manual date mistakes can create many historical missing rows. Keep the setting visible and easy to correct.
+
Calendar rows and latest rows have different denominators. The UI labels must make that difference explicit.
+
Do not make missing rows affect health scoring, illness evidence, or report generation. This is admin observability only.
+
+
+
+
+
Rollback Plan
+
+
Clear or delete settings.subjective_checkin_enabled_since to disable calendar SLA output without changing prompt delivery.
+
Revert the code changes if needed; no derived health data or raw health records are modified by this feature.
+
If the response extension causes UI trouble, keep the storage helper and temporarily render only the existing latest history table.
+
+
+
+
+
Open Questions
+
+
Should the first production enabled-since date be set to the actual rollout date from PR 2, the first prompt row, or a manually chosen date?
+
Should in-progress today be excluded, shown as prompted when a prompt exists, or shown as a separate pending state when no prompt exists yet?
+
Do we want a Todoist task close/update after implementation, or should that remain manual?
+
+
+
+
+
Implemented Result
+
+
Created GitHub issue #186: Add calendar-day SLA for subjective check-in prompts.
+
Added tenant-scoped setting subjective_checkin_enabled_since through the existing settings table.
+
Extended /api/admin/checkin-coverage: GET returns existing latest history plus new SLA fields; POST saves enabled_since for the active tenant and returns refreshed coverage.
+
Kept existing summary and rows as latest actual prompt history for compatibility.
+
Added sla_summary and sla_rows for calendar-day coverage after enabled-since.
+
Added pending for the current day when no row exists yet, so today is not prematurely counted as missing.
+
Updated Admin UI with an enabled-since date input, Calendar SLA table, and Latest prompt history table.
+
Added English, Russian, and Serbian labels plus minimal CSS for the new UI.
go test ./internal/storage -run "Test.*Checkin|Test.*CheckinCoverage" -count=1 - passed.
+
go test ./internal/ui -run TestAdminCheckinCoverage -count=1 - passed.
+
+
+
+
+
Known Limitations
+
+
The current day with no prompt row is shown as pending for the full day. This is deliberately conservative; a later refinement can switch it to missing after the tenant's morning cap passes.
+
Clearing enabled-since stores an empty settings value rather than deleting the row; existing settings helpers treat that as inactive.
+
No production setting was changed during implementation. Enabling SLA for a tenant is still an explicit admin action.
+
+
+
+
+
Approval Gate
+
+ Implementation must not start until the user explicitly approves this plan.
+ Suggested approval phrase: погнали.
+
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.
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,
+ answered_at TIMESTAMPTZ,
+ allowed_categories JSONB NOT NULL,
+ 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 ux_context_prompt_one_sent_per_day
+ON context_prompt_interactions (prompt_local_date)
+WHERE status IN ('reserved', 'prompted', 'answered', 'skipped', 'expired');
+
+
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?
+
+
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
+
+
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.
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 2025, after guards, 18. With timing evidence disabled for MVP, prompt-usefulness sends 3 of the 4 last-180-day candidates; 2026-05-01 becomes low_usefulness. 2025-12-22 remains no_trigger with sleep 5.94h and z-score -1.13.
+
+
+
+
+
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("
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.
source TEXT, -- NULL until answer; telegram for MVP
prompt_message_id BIGINT,
prompted_at TIMESTAMPTZ,
- expires_at TIMESTAMPTZ,
+ expires_at TIMESTAMPTZ NOT NULL,
answered_at TIMESTAMPTZ,
- allowed_categories JSONB NOT NULL,
+ 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 ux_context_prompt_one_sent_per_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');
+WHERE status IN ('reserved', 'prompted', 'answered', 'skipped', 'expired', 'send_failed');
source is an answer/source channel, not event identity.
category is nullable until answer.
@@ -452,7 +452,7 @@
Checks Run
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 2025, after guards, 18. With timing evidence disabled for MVP, prompt-usefulness sends 3 of the 4 last-180-day candidates; 2026-05-01 becomes low_usefulness. 2025-12-22 remains no_trigger with sleep 5.94h and z-score -1.13.
+
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.
diff --git a/internal/storage/briefing.go b/internal/storage/briefing.go
index 616be93..2673e0c 100644
--- a/internal/storage/briefing.go
+++ b/internal/storage/briefing.go
@@ -671,7 +671,7 @@ func (s *DB) GetHealthBriefing(lang string) (*health.BriefingResponse, error) {
health.ApplyIllnessSafetyCap(resp, health.GetStrings(lang))
if IsContextCaveatsEnabled(s) {
- if annotations, aerr := s.GetContextAnnotationsForDate(*lastDate); aerr == nil {
+ if annotations, aerr := s.GetRecentContextAnnotationsThroughDate(*lastDate, 3); aerr == nil {
for _, a := range annotations {
resp.ContextAnnotations = append(resp.ContextAnnotations, health.ContextAnnotationSummary{
Date: a.Date,
diff --git a/internal/storage/context_prompt.go b/internal/storage/context_prompt.go
index 4094dea..a5f2da4 100644
--- a/internal/storage/context_prompt.go
+++ b/internal/storage/context_prompt.go
@@ -616,7 +616,7 @@ func (s *DB) reserveContextPrompt(reason, detectorVersion, signalDate, promptLoc
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, allowedJSON, metadataJSON, now).
+ 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 {
@@ -728,15 +728,27 @@ func contextPromptCanAcceptAnswer(status string) bool {
}
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 status = $2
+ WHERE signal_date >= $1
+ AND signal_date <= $2
+ AND status = $3
AND category IS NOT NULL
- ORDER BY answered_at DESC`, signalDate, ContextPromptStatusAnswered)
+ ORDER BY signal_date DESC, answered_at DESC`, fromDate, toDate, ContextPromptStatusAnswered)
if err != nil {
return nil, err
}