From 21d0747ff49584fd28ec9beeafb94160cbb24fc3 Mon Sep 17 00:00:00 2001 From: Dzarlax Date: Thu, 18 Jun 2026 17:06:42 +0200 Subject: [PATCH 1/4] Add subjective check-in calendar SLA --- .../2026-06-18-checkin-calendar-sla.html | 213 +++++++++++++++++ internal/storage/subjective_checkin.go | 217 +++++++++++++++++- internal/storage/subjective_checkin_test.go | 65 ++++++ internal/ui/admin_checkin_coverage_test.go | 89 +++++++ internal/ui/handler.go | 16 +- internal/ui/i18n_en.go | 9 +- internal/ui/i18n_ru.go | 9 +- internal/ui/i18n_sr.go | 9 +- internal/ui/style.go | 16 ++ internal/ui/templates/pages/admin.html | 117 +++++++--- 10 files changed, 711 insertions(+), 49 deletions(-) create mode 100644 docs/ai-plans/2026-06-18-checkin-calendar-sla.html diff --git a/docs/ai-plans/2026-06-18-checkin-calendar-sla.html b/docs/ai-plans/2026-06-18-checkin-calendar-sla.html new file mode 100644 index 0000000..fd7df56 --- /dev/null +++ b/docs/ai-plans/2026-06-18-checkin-calendar-sla.html @@ -0,0 +1,213 @@ + + + + + Implementation Plan: Subjective Check-in Calendar SLA + + + + +
+

Implementation Plan: Subjective Check-in Calendar SLA

+ Approval required before implementation +

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

+
    +
  1. Add constants and validation for subjective_checkin_enabled_since in storage. Accept empty or valid YYYY-MM-DD.
  2. +
  3. Add GetCheckinEnabledSince and SaveCheckinEnabledSince wrappers over settings.
  4. +
  5. Keep GetCheckinCoverage as the latest-actual history path, or rename internally to make that explicit while preserving the API shape.
  6. +
  7. Add a calendar builder that receives today, enabledSince, days, and actual rows, then returns expected rows newest-first with synthesized missing rows.
  8. +
  9. Extend the admin response with enabled_since, sla_active, calendar_rows, and calendar_summary, while keeping existing rows and summary for history.
  10. +
  11. Add admin POST handling for the enabled-since date. Keep it tenant-scoped through the existing resolveAdminTenantScope path.
  12. +
  13. 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.
  14. +
  15. Update this plan after implementation with actual changes, checks run, limitations, and follow-up recommendations.
  16. +
+
+ +
+

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.
  • +
+
+ +
+

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
  • +
  • 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: погнали. +

+
+
+ + diff --git a/internal/storage/subjective_checkin.go b/internal/storage/subjective_checkin.go index adeebba..50ed829 100644 --- a/internal/storage/subjective_checkin.go +++ b/internal/storage/subjective_checkin.go @@ -36,6 +36,8 @@ const ( // analytics can tell them apart per entry point. const CheckinSourceTelegram = "telegram" +const SettingSubjectiveCheckinEnabledSince = "subjective_checkin_enabled_since" + // subjectiveCheckinTableDDL returns the CREATE TABLE statement for // subjective_checkins. Exposed package-internal so the structural test // can assert column shape without a live DB. @@ -140,17 +142,21 @@ type CheckinRow struct { } // CheckinCoverage is the admin read model for the morning check-in -// coverage table. It includes the latest N subjective_checkins rows. -// It intentionally does not synthesize pre-rollout "missing" rows: -// during the first two weeks after launch, calendar-day coverage would -// make a perfectly healthy rollout look mostly broken. +// coverage table. Rows/Summary keep the latest actual subjective_checkins +// history. SLARows/SLASummary synthesize calendar-day rows only after an +// explicit enabled-since date is configured, avoiding false pre-rollout +// missing days. type CheckinCoverage struct { - From string `json:"from"` - To string `json:"to"` - Days int `json:"days"` - Source string `json:"source"` - Summary CheckinCoverageSummary `json:"summary"` - Rows []CheckinCoverageRow `json:"rows"` + From string `json:"from"` + To string `json:"to"` + Days int `json:"days"` + Source string `json:"source"` + EnabledSince string `json:"enabled_since,omitempty"` + SLAActive bool `json:"sla_active"` + SLASummary CheckinCoverageSummary `json:"sla_summary"` + SLARows []CheckinCoverageRow `json:"sla_rows"` + Summary CheckinCoverageSummary `json:"summary"` + Rows []CheckinCoverageRow `json:"rows"` } type CheckinCoverageSummary struct { @@ -160,6 +166,7 @@ type CheckinCoverageSummary struct { LateAnswered int `json:"late_answered"` Expired int `json:"expired"` Missing int `json:"missing"` + Pending int `json:"pending"` AnswerCounts map[string]int `json:"answer_counts"` AverageResponseSeconds *int64 `json:"average_response_seconds,omitempty"` AnsweredCoveragePercent int `json:"answered_coverage_percent"` @@ -178,6 +185,28 @@ type CheckinCoverageRow struct { } const CheckinStatusMissing = "missing" +const CheckinStatusPending = "pending" + +func ValidateCheckinEnabledSince(date string) error { + if date == "" { + return nil + } + if _, err := time.Parse("2006-01-02", date); err != nil { + return fmt.Errorf("enabled_since must be YYYY-MM-DD: %w", err) + } + return nil +} + +func (s *DB) GetCheckinEnabledSince() string { + return s.GetSetting(SettingSubjectiveCheckinEnabledSince, "") +} + +func (s *DB) SaveCheckinEnabledSince(date string) error { + if err := ValidateCheckinEnabledSince(date); err != nil { + return err + } + return s.SaveSettings(map[string]string{SettingSubjectiveCheckinEnabledSince: date}) +} // SaveCheckinPrompted upserts a `prompted` row for (date, source). // Idempotent on retry: a second prompt for the same date overwrites @@ -320,6 +349,10 @@ func (s *DB) GetCheckinCoverage(today, source string, days int) (*CheckinCoverag if _, err := time.Parse("2006-01-02", today); err != nil { return nil, fmt.Errorf("today must be YYYY-MM-DD: %w", err) } + enabledSince := s.GetCheckinEnabledSince() + if err := ValidateCheckinEnabledSince(enabledSince); err != nil { + return nil, err + } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -358,7 +391,57 @@ func (s *DB) GetCheckinCoverage(today, source string, days int) (*CheckinCoverag if err := rows.Err(); err != nil { return nil, err } - return buildCheckinCoverage(today, source, days, entries) + coverage, err := buildCheckinCoverage(today, source, days, entries) + if err != nil { + return nil, err + } + coverage.EnabledSince = enabledSince + if enabledSince == "" { + return coverage, nil + } + + from := calendarCoverageStart(today, enabledSince, days) + calendarEntries, err := s.getCheckinRowsBetween(ctx, source, from, today) + if err != nil { + return nil, err + } + return buildCheckinCoverageWithSLA(coverage, today, from, source, enabledSince, calendarEntries) +} + +func (s *DB) getCheckinRowsBetween(ctx context.Context, source, from, to string) ([]CheckinRow, error) { + rows, err := s.pool.Query(ctx, ` + SELECT date, source, status, answer, prompt_message_id, prompted_at, answered_at, expires_at + FROM subjective_checkins + WHERE source = $1 + AND date >= $2 + AND date <= $3 + ORDER BY date DESC`, source, from, to) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []CheckinRow + for rows.Next() { + row := CheckinRow{} + var answer *string + var msgID *int64 + var answeredAt *time.Time + if err := rows.Scan(&row.Date, &row.Source, &row.Status, &answer, &msgID, &row.PromptedAt, &answeredAt, &row.ExpiresAt); err != nil { + return nil, err + } + if answer != nil { + row.Answer = *answer + } + if msgID != nil { + row.PromptMessageID = *msgID + } + if answeredAt != nil { + row.AnsweredAt = *answeredAt + } + entries = append(entries, row) + } + return entries, rows.Err() } func buildCheckinCoverage(today, source string, days int, entries []CheckinRow) (*CheckinCoverage, error) { @@ -442,6 +525,118 @@ func buildCheckinCoverage(today, source string, days int, entries []CheckinRow) return out, nil } +func calendarCoverageStart(today, enabledSince string, days int) string { + t, err := time.Parse("2006-01-02", today) + if err != nil { + return enabledSince + } + windowStart := t.AddDate(0, 0, -days+1).Format("2006-01-02") + if enabledSince > windowStart { + return enabledSince + } + return windowStart +} + +func buildCheckinCoverageWithSLA(base *CheckinCoverage, today, from, source, enabledSince string, entries []CheckinRow) (*CheckinCoverage, error) { + start, err := time.Parse("2006-01-02", from) + if err != nil { + return nil, fmt.Errorf("from must be YYYY-MM-DD: %w", err) + } + end, err := time.Parse("2006-01-02", today) + if err != nil { + return nil, fmt.Errorf("today must be YYYY-MM-DD: %w", err) + } + if end.Before(start) { + base.SLAActive = true + base.EnabledSince = enabledSince + base.SLASummary = CheckinCoverageSummary{AnswerCounts: map[string]int{}} + base.SLARows = []CheckinCoverageRow{} + return base, nil + } + + byDate := map[string]CheckinRow{} + for _, entry := range entries { + byDate[entry.Date] = entry + } + rows := make([]CheckinCoverageRow, 0, int(end.Sub(start).Hours()/24)+1) + summary := CheckinCoverageSummary{AnswerCounts: map[string]int{}} + var latencySum int64 + var latencyN int64 + promptedDays := 0 + for d := end; !d.Before(start); d = d.AddDate(0, 0, -1) { + date := d.Format("2006-01-02") + entry, ok := byDate[date] + if !ok { + status := CheckinStatusMissing + if date == today { + status = CheckinStatusPending + summary.Pending++ + } else { + summary.Missing++ + } + rows = append(rows, CheckinCoverageRow{Date: date, Source: source, Status: status}) + continue + } + row := checkinCoverageRow(entry) + rows = append(rows, row) + promptedDays++ + switch entry.Status { + case CheckinStatusPrompted: + summary.Prompted++ + case CheckinStatusAnswered: + summary.Answered++ + case CheckinStatusLateAnswered: + summary.LateAnswered++ + case CheckinStatusExpired: + summary.Expired++ + } + if entry.Answer != "" { + summary.AnswerCounts[entry.Answer]++ + } + if row.ResponseLatencySeconds != nil { + latencySum += *row.ResponseLatencySeconds + latencyN++ + } + } + summary.TotalDays = len(rows) + if latencyN > 0 { + avg := latencySum / latencyN + summary.AverageResponseSeconds = &avg + } + completedDays := summary.TotalDays - summary.Pending + summary.AnsweredCoveragePercent = percent(summary.Answered, completedDays) + summary.PromptedCoveragePercent = percent(promptedDays, completedDays) + + base.SLAActive = true + base.EnabledSince = enabledSince + base.SLASummary = summary + base.SLARows = rows + return base, nil +} + +func checkinCoverageRow(entry CheckinRow) CheckinCoverageRow { + promptedAt := entry.PromptedAt + expiresAt := entry.ExpiresAt + row := CheckinCoverageRow{ + Date: entry.Date, + Source: entry.Source, + Status: entry.Status, + Answer: entry.Answer, + PromptedAt: &promptedAt, + ExpiresAt: &expiresAt, + } + if !entry.AnsweredAt.IsZero() { + answeredAt := entry.AnsweredAt + row.AnsweredAt = &answeredAt + latency := int64(answeredAt.Sub(promptedAt).Seconds()) + if latency < 0 { + latency = 0 + } + row.ResponseLatencySeconds = &latency + } + return row +} + func percent(n, total int) int { if total <= 0 { return 0 diff --git a/internal/storage/subjective_checkin_test.go b/internal/storage/subjective_checkin_test.go index e4463f9..09b43f9 100644 --- a/internal/storage/subjective_checkin_test.go +++ b/internal/storage/subjective_checkin_test.go @@ -156,3 +156,68 @@ func TestBuildCheckinCoverage_LimitsToLatestRows(t *testing.T) { t.Fatalf("summary = %+v, want total=2 answered=2", coverage.Summary) } } + +func TestBuildCheckinCoverageWithSLA_SynthesizesMissingAfterEnabledSince(t *testing.T) { + prompted := time.Date(2026, 6, 16, 8, 0, 0, 0, time.UTC) + base, err := buildCheckinCoverage("2026-06-18", CheckinSourceTelegram, 14, nil) + if err != nil { + t.Fatalf("build history: %v", err) + } + coverage, err := buildCheckinCoverageWithSLA(base, "2026-06-18", "2026-06-15", CheckinSourceTelegram, "2026-06-15", []CheckinRow{ + {Date: "2026-06-16", Source: CheckinSourceTelegram, Status: CheckinStatusAnswered, Answer: CheckinAnswerOK, PromptedAt: prompted, AnsweredAt: prompted.Add(2 * time.Minute), ExpiresAt: prompted.Add(time.Hour)}, + {Date: "2026-06-15", Source: CheckinSourceTelegram, Status: CheckinStatusExpired, PromptedAt: prompted.AddDate(0, 0, -1), ExpiresAt: prompted.AddDate(0, 0, -1).Add(time.Hour)}, + }) + if err != nil { + t.Fatalf("build SLA: %v", err) + } + if !coverage.SLAActive || coverage.EnabledSince != "2026-06-15" { + t.Fatalf("sla active=%v enabled=%q", coverage.SLAActive, coverage.EnabledSince) + } + got := []string{ + coverage.SLARows[0].Date + ":" + coverage.SLARows[0].Status, + coverage.SLARows[1].Date + ":" + coverage.SLARows[1].Status, + coverage.SLARows[2].Date + ":" + coverage.SLARows[2].Status, + coverage.SLARows[3].Date + ":" + coverage.SLARows[3].Status, + } + want := []string{ + "2026-06-18:" + CheckinStatusPending, + "2026-06-17:" + CheckinStatusMissing, + "2026-06-16:" + CheckinStatusAnswered, + "2026-06-15:" + CheckinStatusExpired, + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("row %d = %q, want %q; all=%v", i, got[i], want[i], got) + } + } + if coverage.SLASummary.TotalDays != 4 || + coverage.SLASummary.Pending != 1 || + coverage.SLASummary.Missing != 1 || + coverage.SLASummary.Answered != 1 || + coverage.SLASummary.Expired != 1 { + t.Fatalf("sla summary = %+v", coverage.SLASummary) + } + if coverage.SLASummary.PromptedCoveragePercent != 67 { + t.Fatalf("prompted coverage = %d, want 67", coverage.SLASummary.PromptedCoveragePercent) + } +} + +func TestCalendarCoverageStart_ClampsToEnabledSince(t *testing.T) { + if got := calendarCoverageStart("2026-06-18", "2026-06-15", 14); got != "2026-06-15" { + t.Fatalf("start = %s, want enabled-since", got) + } + if got := calendarCoverageStart("2026-06-18", "2026-05-01", 3); got != "2026-06-16" { + t.Fatalf("start = %s, want window start", got) + } +} + +func TestValidateCheckinEnabledSince(t *testing.T) { + for _, date := range []string{"", "2026-06-18"} { + if err := ValidateCheckinEnabledSince(date); err != nil { + t.Fatalf("date %q should be valid: %v", date, err) + } + } + if err := ValidateCheckinEnabledSince("18-06-2026"); err == nil { + t.Fatal("invalid date should be rejected") + } +} diff --git a/internal/ui/admin_checkin_coverage_test.go b/internal/ui/admin_checkin_coverage_test.go index d142468..62006d9 100644 --- a/internal/ui/admin_checkin_coverage_test.go +++ b/internal/ui/admin_checkin_coverage_test.go @@ -1,6 +1,7 @@ package ui import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -52,6 +53,94 @@ func TestAdminCheckinCoverage_JSONShape(t *testing.T) { } } +func TestAdminCheckinCoverage_CalendarSLA(t *testing.T) { + db, schema, cleanup := testTenantDB(t) + defer cleanup() + + today := time.Now().UTC() + todayStr := today.Format("2006-01-02") + yesterdayStr := today.AddDate(0, 0, -1).Format("2006-01-02") + if err := db.SaveCheckinEnabledSince(yesterdayStr); err != nil { + t.Fatalf("save enabled since: %v", err) + } + promptedAt := time.Date(today.Year(), today.Month(), today.Day(), 8, 0, 0, 0, time.UTC) + if err := db.SaveCheckinPrompted(todayStr, storage.CheckinSourceTelegram, 42, promptedAt, promptedAt.Add(time.Hour)); err != nil { + t.Fatalf("seed prompt: %v", err) + } + if _, err := db.SaveCheckinAnswer(todayStr, storage.CheckinSourceTelegram, storage.CheckinAnswerOK, promptedAt.Add(time.Minute)); err != nil { + t.Fatalf("seed answer: %v", err) + } + + h := &Handler{} + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/admin/checkin-coverage?days=3", nil). + WithContext(adminContext(db, schema)) + h.adminCheckinCoverage(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + var resp storage.CheckinCoverage + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body=%s", err, w.Body.String()) + } + if !resp.SLAActive || resp.EnabledSince != yesterdayStr { + t.Fatalf("sla active=%v enabled_since=%q, want active %s", resp.SLAActive, resp.EnabledSince, yesterdayStr) + } + if len(resp.SLARows) != 2 { + t.Fatalf("sla rows = %d, want 2: %+v", len(resp.SLARows), resp.SLARows) + } + if resp.SLARows[0].Date != todayStr || resp.SLARows[0].Status != storage.CheckinStatusAnswered { + t.Fatalf("sla row0 = %+v, want today answered", resp.SLARows[0]) + } + if resp.SLARows[1].Date != yesterdayStr || resp.SLARows[1].Status != storage.CheckinStatusMissing { + t.Fatalf("sla row1 = %+v, want yesterday missing", resp.SLARows[1]) + } + if resp.SLASummary.Missing != 1 || resp.SLASummary.Answered != 1 { + t.Fatalf("sla summary = %+v, want missing=1 answered=1", resp.SLASummary) + } +} + +func TestAdminCheckinCoverage_PostSavesEnabledSince(t *testing.T) { + db, schema, cleanup := testTenantDB(t) + defer cleanup() + + h := &Handler{} + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/admin/checkin-coverage?days=3", bytes.NewBufferString(`{"enabled_since":"2026-06-15"}`)). + WithContext(adminContext(db, schema)) + h.adminCheckinCoverage(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + if got := db.GetCheckinEnabledSince(); got != "2026-06-15" { + t.Fatalf("enabled_since = %q, want 2026-06-15", got) + } + var resp storage.CheckinCoverage + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body=%s", err, w.Body.String()) + } + if !resp.SLAActive { + t.Fatalf("expected SLA active after POST: %+v", resp) + } +} + +func TestAdminCheckinCoverage_PostRejectsBadEnabledSince(t *testing.T) { + db, schema, cleanup := testTenantDB(t) + defer cleanup() + + h := &Handler{} + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/admin/checkin-coverage", bytes.NewBufferString(`{"enabled_since":"15/06/2026"}`)). + WithContext(adminContext(db, schema)) + h.adminCheckinCoverage(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String()) + } +} + func TestAdminCheckinCoverage_RejectsBadDays(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/api/admin/checkin-coverage?days=0", nil) diff --git a/internal/ui/handler.go b/internal/ui/handler.go index 4f9854f..4ce5a8b 100644 --- a/internal/ui/handler.go +++ b/internal/ui/handler.go @@ -2865,7 +2865,7 @@ func (h *Handler) adminQualityDigest(w http.ResponseWriter, r *http.Request) { } func (h *Handler) adminCheckinCoverage(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { + if r.Method != http.MethodGet && r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } @@ -2883,6 +2883,20 @@ func (h *Handler) adminCheckinCoverage(w http.ResponseWriter, r *http.Request) { writeStatusError(w, scopeErr) return } + if r.Method == http.MethodPost { + var req struct { + EnabledSince string `json:"enabled_since"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + req.EnabledSince = strings.TrimSpace(req.EnabledSince) + if err := scope.DB.SaveCheckinEnabledSince(req.EnabledSince); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } today := tenantLocalToday(h, scope.DB, scope.Schema) coverage, err := scope.DB.GetCheckinCoverage(today, storage.CheckinSourceTelegram, days) if err != nil { diff --git a/internal/ui/i18n_en.go b/internal/ui/i18n_en.go index 6990ddb..ed27e01 100644 --- a/internal/ui/i18n_en.go +++ b/internal/ui/i18n_en.go @@ -370,7 +370,11 @@ var translationsEn = map[string]string{ "admin_quality_missed": "Missed nights", "admin_quality_fixed": "Flagged %d impossible, %d suspect rows.", "admin_checkin_coverage_title": "Subjective check-in coverage", - "admin_checkin_coverage_desc": "Read-only view of the latest Telegram morning prompts and response latency.", + "admin_checkin_coverage_desc": "Calendar SLA after the enabled-since date, plus latest Telegram morning prompts and response latency.", + "admin_checkin_enabled_since": "Enabled since", + "admin_checkin_calendar_sla": "Calendar SLA", + "admin_checkin_history": "Latest prompt history", + "admin_checkin_sla_inactive": "Set an enabled-since date to start calendar SLA checks.", "admin_checkin_total": "Days", "admin_checkin_prompted_coverage": "Prompted", "admin_checkin_answered_coverage": "Answered", @@ -386,6 +390,9 @@ var translationsEn = map[string]string{ "admin_checkin_status_late": "Late", "admin_checkin_status_expired": "Expired", "admin_checkin_status_missing": "Missing", + "admin_checkin_status_pending": "Pending", + "admin_save": "Save", + "admin_saved": "Saved", // ─── Admin: AI briefing settings ─────────────────────────── "admin_ai_title": "AI Morning Briefing", diff --git a/internal/ui/i18n_ru.go b/internal/ui/i18n_ru.go index 0f327ec..1e8e3f9 100644 --- a/internal/ui/i18n_ru.go +++ b/internal/ui/i18n_ru.go @@ -348,7 +348,11 @@ var translationsRu = map[string]string{ "admin_quality_missed": "Пропущенные ночи", "admin_quality_fixed": "Помечено: невозможных %d, подозрительных %d.", "admin_checkin_coverage_title": "Покрытие утренних check-in", - "admin_checkin_coverage_desc": "Только чтение: последние утренние Telegram-вопросы и задержка ответа.", + "admin_checkin_coverage_desc": "Календарный SLA после даты включения, плюс последние утренние Telegram-вопросы и задержка ответа.", + "admin_checkin_enabled_since": "Включено с", + "admin_checkin_calendar_sla": "Календарный SLA", + "admin_checkin_history": "История промптов", + "admin_checkin_sla_inactive": "Укажите дату включения, чтобы начать календарные SLA-проверки.", "admin_checkin_total": "Дней", "admin_checkin_prompted_coverage": "Промпт был", "admin_checkin_answered_coverage": "Ответ был", @@ -364,6 +368,9 @@ var translationsRu = map[string]string{ "admin_checkin_status_late": "Поздно", "admin_checkin_status_expired": "Истёк", "admin_checkin_status_missing": "Нет строки", + "admin_checkin_status_pending": "Ждём", + "admin_save": "Сохранить", + "admin_saved": "Сохранено", "admin_ai_title": "AI-брифинг утром", "admin_ai_key": "Gemini API ключ", diff --git a/internal/ui/i18n_sr.go b/internal/ui/i18n_sr.go index 0ecd4b9..5765356 100644 --- a/internal/ui/i18n_sr.go +++ b/internal/ui/i18n_sr.go @@ -329,7 +329,11 @@ var translationsSr = map[string]string{ "admin_quality_missed": "Propuštene noći", "admin_quality_fixed": "Označeno: nemoguće %d, sumnjive %d.", "admin_checkin_coverage_title": "Pokrivenost jutarnog check-ina", - "admin_checkin_coverage_desc": "Samo za čitanje: poslednja jutarnja Telegram pitanja i latencija odgovora.", + "admin_checkin_coverage_desc": "Kalendarski SLA posle datuma uključivanja, plus poslednja jutarnja Telegram pitanja i latencija odgovora.", + "admin_checkin_enabled_since": "Uključeno od", + "admin_checkin_calendar_sla": "Kalendarski SLA", + "admin_checkin_history": "Istorija promptova", + "admin_checkin_sla_inactive": "Unesite datum uključivanja da počnu kalendarske SLA provere.", "admin_checkin_total": "Dana", "admin_checkin_prompted_coverage": "Prompt poslat", "admin_checkin_answered_coverage": "Odgovoreno", @@ -345,6 +349,9 @@ var translationsSr = map[string]string{ "admin_checkin_status_late": "Kasno", "admin_checkin_status_expired": "Isteklo", "admin_checkin_status_missing": "Nema reda", + "admin_checkin_status_pending": "Čeka se", + "admin_save": "Sačuvaj", + "admin_saved": "Sačuvano", "admin_ai_title": "AI jutarnji izveštaj", "admin_ai_key": "Gemini API ključ", "admin_ai_model": "Model", diff --git a/internal/ui/style.go b/internal/ui/style.go index be57f00..fa253af 100644 --- a/internal/ui/style.go +++ b/internal/ui/style.go @@ -1118,6 +1118,21 @@ details.admin-section[open] > :not(summary) { margin-left: 16px; margin-right: 1 display: flex; gap: 24px; font-size: 13px; color: var(--muted); padding: 0 4px; } .admin-meta-row strong { color: var(--fg); } +.checkin-sla-settings { + display: flex; align-items: end; gap: 10px; flex-wrap: wrap; + margin: 0 0 14px; +} +.checkin-sla-settings label { + display: flex; flex-direction: column; gap: 4px; + font-size: 12px; color: var(--muted); +} +.checkin-sla-settings input { + border: 1px solid var(--border); border-radius: 8px; + background: var(--surface); color: var(--fg); padding: 7px 9px; +} +.checkin-subtitle { + margin: 14px 0 8px; color: var(--fg); font-size: 14px; +} .checkin-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; margin-bottom: 10px; @@ -1142,6 +1157,7 @@ details.admin-section[open] > :not(summary) { margin-left: 16px; margin-right: 1 .checkin-status--prompted { background: var(--surface2); color: var(--text-secondary); } .checkin-status--expired { background: var(--low-bg); color: var(--low); } .checkin-status--missing { background: transparent; color: var(--text-tertiary); border: 1px dashed var(--border); } +.checkin-status--pending { background: var(--surface2); color: var(--text-tertiary); border: 1px solid var(--border); } .admin-monitoring-block { margin-bottom: 16px; } .admin-monitoring-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; diff --git a/internal/ui/templates/pages/admin.html b/internal/ui/templates/pages/admin.html index a26442c..cb590fa 100644 --- a/internal/ui/templates/pages/admin.html +++ b/internal/ui/templates/pages/admin.html @@ -118,6 +118,14 @@

{{T .Lang "admin_checkin_coverage_desc"}}

+
+ + + +
@@ -873,9 +881,35 @@ .catch(function(e) { el.innerHTML = '
' + escapeHtml(String(e.message || e)) + '
'; }); } +function saveCheckinEnabledSince() { + var input = $('checkin-enabled-since'); + var msg = $('checkin-enabled-msg'); + if (!input) return; + if (msg) msg.textContent = '...'; + fetchWithAdminSchema('/api/admin/checkin-coverage?days=14', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({enabled_since: input.value || ''}) + }) + .then(function(r){ + if (!r.ok) return r.text().then(function(t){ throw new Error(t || 'Failed to save check-in SLA date'); }); + return r.json(); + }) + .then(function(d) { + if (msg) msg.textContent = '{{T .Lang "admin_saved"}}'; + var el = $('admin-checkin-coverage-section'); + if (el) el.innerHTML = renderCheckinCoverage(d); + }) + .catch(function(e) { if (msg) msg.textContent = String(e.message || e); }); +} + function renderCheckinCoverage(d) { var s = d.summary || {}; + var sla = d.sla_summary || {}; var rows = d.rows || []; + var slaRows = d.sla_rows || []; + var enabledInput = $('checkin-enabled-since'); + if (enabledInput) enabledInput.value = d.enabled_since || ''; var labels = { total: '{{T .Lang "admin_checkin_total"}}', promptedCoverage: '{{T .Lang "admin_checkin_prompted_coverage"}}', @@ -883,6 +917,9 @@ avgResponse: '{{T .Lang "admin_checkin_avg_response"}}', noResponse: '{{T .Lang "admin_checkin_no_response"}}', answers: '{{T .Lang "admin_checkin_answers"}}', + calendarTitle: '{{T .Lang "admin_checkin_calendar_sla"}}', + historyTitle: '{{T .Lang "admin_checkin_history"}}', + inactive: '{{T .Lang "admin_checkin_sla_inactive"}}', date: '{{T .Lang "admin_checkin_date"}}', status: '{{T .Lang "admin_checkin_status"}}', answer: '{{T .Lang "admin_checkin_answer"}}', @@ -893,7 +930,8 @@ answered: '{{T .Lang "admin_checkin_status_answered"}}', late_answered: '{{T .Lang "admin_checkin_status_late"}}', expired: '{{T .Lang "admin_checkin_status_expired"}}', - missing: '{{T .Lang "admin_checkin_status_missing"}}' + missing: '{{T .Lang "admin_checkin_status_missing"}}', + pending: '{{T .Lang "admin_checkin_status_pending"}}' }; var answerLabels = { great: '{{T .Lang "checkin_answer_great"}}', @@ -910,41 +948,52 @@ var sec = Math.round(n % 60); return min + 'm ' + sec + 's'; }; - var answerCounts = s.answer_counts || {}; - var answerLine = ['great', 'ok', 'meh', 'sick'].map(function(k) { - return escapeHtml(answerLabels[k] || k) + ' ' + (answerCounts[k] || 0); - }).join(' · '); - - var html = '
' - + '
' + escapeHtml(labels.total) + '' + (s.total_days == null ? (d.days || 0) : s.total_days) + '
' - + '
' + escapeHtml(labels.promptedCoverage) + '' + (s.prompted_coverage_percent || 0) + '%
' - + '
' + escapeHtml(labels.answeredCoverage) + '' + (s.answered_coverage_percent || 0) + '%
' - + '
' + escapeHtml(labels.avgResponse) + '' + (s.average_response_seconds == null ? escapeHtml(labels.noResponse) : escapeHtml(fmtDuration(s.average_response_seconds))) + '
' - + '
' - + '
' + escapeHtml(labels.answers) + ' ' + answerLine + '
'; - - html += '' - + '' - + '' - + '' - + '' - + ''; var allowedStatusClass = { - prompted: true, answered: true, late_answered: true, expired: true, missing: true + prompted: true, answered: true, late_answered: true, expired: true, missing: true, pending: true }; - rows.forEach(function(row) { - var status = row.status || 'missing'; - var statusClass = allowedStatusClass[status] ? status : 'missing'; - var statusText = statusLabels[status] || status; - var answer = row.answer ? (answerLabels[row.answer] || row.answer) : '—'; - html += '' - + '' - + '' - + '' - + '' - + ''; - }); - html += '
' + escapeHtml(labels.date) + '' + escapeHtml(labels.status) + '' + escapeHtml(labels.answer) + '' + escapeHtml(labels.latency) + '
' + escapeHtml(row.date || '') + '' + escapeHtml(statusText) + '' + escapeHtml(answer) + '' + escapeHtml(fmtDuration(row.response_latency_seconds)) + '
'; + + function renderBlock(title, summary, tableRows, showInactive) { + if (showInactive) { + return '

' + escapeHtml(title) + '

' + + '
' + escapeHtml(labels.inactive) + '
'; + } + var answerCounts = summary.answer_counts || {}; + var answerLine = ['great', 'ok', 'meh', 'sick'].map(function(k) { + return escapeHtml(answerLabels[k] || k) + ' ' + (answerCounts[k] || 0); + }).join(' · '); + var html = '

' + escapeHtml(title) + '

' + + '
' + + '
' + escapeHtml(labels.total) + '' + (summary.total_days == null ? (d.days || 0) : summary.total_days) + '
' + + '
' + escapeHtml(labels.promptedCoverage) + '' + (summary.prompted_coverage_percent || 0) + '%
' + + '
' + escapeHtml(labels.answeredCoverage) + '' + (summary.answered_coverage_percent || 0) + '%
' + + '
' + escapeHtml(labels.avgResponse) + '' + (summary.average_response_seconds == null ? escapeHtml(labels.noResponse) : escapeHtml(fmtDuration(summary.average_response_seconds))) + '
' + + '
' + + '
' + escapeHtml(labels.answers) + ' ' + answerLine + '
'; + + html += '' + + '' + + '' + + '' + + '' + + ''; + tableRows.forEach(function(row) { + var status = row.status || 'missing'; + var statusClass = allowedStatusClass[status] ? status : 'missing'; + var statusText = statusLabels[status] || status; + var answer = row.answer ? (answerLabels[row.answer] || row.answer) : '—'; + html += '' + + '' + + '' + + '' + + '' + + ''; + }); + html += '
' + escapeHtml(labels.date) + '' + escapeHtml(labels.status) + '' + escapeHtml(labels.answer) + '' + escapeHtml(labels.latency) + '
' + escapeHtml(row.date || '') + '' + escapeHtml(statusText) + '' + escapeHtml(answer) + '' + escapeHtml(fmtDuration(row.response_latency_seconds)) + '
'; + return html; + } + + var html = renderBlock(labels.calendarTitle, sla, slaRows, !d.sla_active) + + renderBlock(labels.historyTitle, s, rows, false); return html; } From a875de009f22662410f15408665f0edf7ef5a86a Mon Sep 17 00:00:00 2001 From: Dzarlax Date: Thu, 18 Jun 2026 18:15:21 +0200 Subject: [PATCH 2/4] Address check-in SLA review feedback --- internal/storage/subjective_checkin.go | 48 ++++------------------ internal/ui/admin_checkin_coverage_test.go | 15 +++++++ internal/ui/handler.go | 10 ++++- internal/ui/i18n_en.go | 2 + internal/ui/i18n_ru.go | 2 + internal/ui/i18n_sr.go | 2 + internal/ui/templates/pages/admin.html | 4 +- 7 files changed, 40 insertions(+), 43 deletions(-) diff --git a/internal/storage/subjective_checkin.go b/internal/storage/subjective_checkin.go index 50ed829..6ab4401 100644 --- a/internal/storage/subjective_checkin.go +++ b/internal/storage/subjective_checkin.go @@ -368,27 +368,8 @@ func (s *DB) GetCheckinCoverage(today, source string, days int) (*CheckinCoverag } defer rows.Close() - var entries []CheckinRow - for rows.Next() { - row := CheckinRow{} - var answer *string - var msgID *int64 - var answeredAt *time.Time - if err := rows.Scan(&row.Date, &row.Source, &row.Status, &answer, &msgID, &row.PromptedAt, &answeredAt, &row.ExpiresAt); err != nil { - return nil, err - } - if answer != nil { - row.Answer = *answer - } - if msgID != nil { - row.PromptMessageID = *msgID - } - if answeredAt != nil { - row.AnsweredAt = *answeredAt - } - entries = append(entries, row) - } - if err := rows.Err(); err != nil { + entries, err := scanCheckinRows(rows) + if err != nil { return nil, err } coverage, err := buildCheckinCoverage(today, source, days, entries) @@ -420,7 +401,10 @@ func (s *DB) getCheckinRowsBetween(ctx context.Context, source, from, to string) return nil, err } defer rows.Close() + return scanCheckinRows(rows) +} +func scanCheckinRows(rows pgx.Rows) ([]CheckinRow, error) { var entries []CheckinRow for rows.Next() { row := CheckinRow{} @@ -476,25 +460,9 @@ func buildCheckinCoverage(today, source string, days int, entries []CheckinRow) var latencyN int64 promptedDays := 0 for _, entry := range entries { - promptedAt := entry.PromptedAt - expiresAt := entry.ExpiresAt - row := CheckinCoverageRow{ - Date: entry.Date, - Source: entry.Source, - Status: entry.Status, - Answer: entry.Answer, - PromptedAt: &promptedAt, - ExpiresAt: &expiresAt, - } - if !entry.AnsweredAt.IsZero() { - answeredAt := entry.AnsweredAt - row.AnsweredAt = &answeredAt - latency := int64(answeredAt.Sub(promptedAt).Seconds()) - if latency < 0 { - latency = 0 - } - row.ResponseLatencySeconds = &latency - latencySum += latency + row := checkinCoverageRow(entry) + if row.ResponseLatencySeconds != nil { + latencySum += *row.ResponseLatencySeconds latencyN++ } switch entry.Status { diff --git a/internal/ui/admin_checkin_coverage_test.go b/internal/ui/admin_checkin_coverage_test.go index 62006d9..6463c23 100644 --- a/internal/ui/admin_checkin_coverage_test.go +++ b/internal/ui/admin_checkin_coverage_test.go @@ -141,6 +141,21 @@ func TestAdminCheckinCoverage_PostRejectsBadEnabledSince(t *testing.T) { } } +func TestAdminCheckinCoverage_PostRejectsUnknownFields(t *testing.T) { + db, schema, cleanup := testTenantDB(t) + defer cleanup() + + h := &Handler{} + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/admin/checkin-coverage", bytes.NewBufferString(`{"enabled_since":"2026-06-15","extra":true}`)). + WithContext(adminContext(db, schema)) + h.adminCheckinCoverage(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String()) + } +} + func TestAdminCheckinCoverage_RejectsBadDays(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/api/admin/checkin-coverage?days=0", nil) diff --git a/internal/ui/handler.go b/internal/ui/handler.go index 4ce5a8b..20621b9 100644 --- a/internal/ui/handler.go +++ b/internal/ui/handler.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "html/template" + "io" "math" "net" "net/http" @@ -2887,7 +2888,14 @@ func (h *Handler) adminCheckinCoverage(w http.ResponseWriter, r *http.Request) { var req struct { EnabledSince string `json:"enabled_since"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + const maxBodyBytes = 1024 + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + if err := dec.Decode(&struct{}{}); err != io.EOF { http.Error(w, "invalid JSON", http.StatusBadRequest) return } diff --git a/internal/ui/i18n_en.go b/internal/ui/i18n_en.go index ed27e01..2fa5e43 100644 --- a/internal/ui/i18n_en.go +++ b/internal/ui/i18n_en.go @@ -375,6 +375,7 @@ var translationsEn = map[string]string{ "admin_checkin_calendar_sla": "Calendar SLA", "admin_checkin_history": "Latest prompt history", "admin_checkin_sla_inactive": "Set an enabled-since date to start calendar SLA checks.", + "admin_checkin_save_failed": "Failed to save check-in SLA date", "admin_checkin_total": "Days", "admin_checkin_prompted_coverage": "Prompted", "admin_checkin_answered_coverage": "Answered", @@ -393,6 +394,7 @@ var translationsEn = map[string]string{ "admin_checkin_status_pending": "Pending", "admin_save": "Save", "admin_saved": "Saved", + "admin_saving": "Saving...", // ─── Admin: AI briefing settings ─────────────────────────── "admin_ai_title": "AI Morning Briefing", diff --git a/internal/ui/i18n_ru.go b/internal/ui/i18n_ru.go index 1e8e3f9..27c60f5 100644 --- a/internal/ui/i18n_ru.go +++ b/internal/ui/i18n_ru.go @@ -353,6 +353,7 @@ var translationsRu = map[string]string{ "admin_checkin_calendar_sla": "Календарный SLA", "admin_checkin_history": "История промптов", "admin_checkin_sla_inactive": "Укажите дату включения, чтобы начать календарные SLA-проверки.", + "admin_checkin_save_failed": "Не удалось сохранить дату SLA check-in", "admin_checkin_total": "Дней", "admin_checkin_prompted_coverage": "Промпт был", "admin_checkin_answered_coverage": "Ответ был", @@ -371,6 +372,7 @@ var translationsRu = map[string]string{ "admin_checkin_status_pending": "Ждём", "admin_save": "Сохранить", "admin_saved": "Сохранено", + "admin_saving": "Сохраняем...", "admin_ai_title": "AI-брифинг утром", "admin_ai_key": "Gemini API ключ", diff --git a/internal/ui/i18n_sr.go b/internal/ui/i18n_sr.go index 5765356..c01a4ec 100644 --- a/internal/ui/i18n_sr.go +++ b/internal/ui/i18n_sr.go @@ -334,6 +334,7 @@ var translationsSr = map[string]string{ "admin_checkin_calendar_sla": "Kalendarski SLA", "admin_checkin_history": "Istorija promptova", "admin_checkin_sla_inactive": "Unesite datum uključivanja da počnu kalendarske SLA provere.", + "admin_checkin_save_failed": "Čuvanje SLA datuma check-ina nije uspelo", "admin_checkin_total": "Dana", "admin_checkin_prompted_coverage": "Prompt poslat", "admin_checkin_answered_coverage": "Odgovoreno", @@ -352,6 +353,7 @@ var translationsSr = map[string]string{ "admin_checkin_status_pending": "Čeka se", "admin_save": "Sačuvaj", "admin_saved": "Sačuvano", + "admin_saving": "Čuvanje...", "admin_ai_title": "AI jutarnji izveštaj", "admin_ai_key": "Gemini API ključ", "admin_ai_model": "Model", diff --git a/internal/ui/templates/pages/admin.html b/internal/ui/templates/pages/admin.html index cb590fa..f8df24c 100644 --- a/internal/ui/templates/pages/admin.html +++ b/internal/ui/templates/pages/admin.html @@ -885,14 +885,14 @@ var input = $('checkin-enabled-since'); var msg = $('checkin-enabled-msg'); if (!input) return; - if (msg) msg.textContent = '...'; + if (msg) msg.textContent = '{{T .Lang "admin_saving"}}'; fetchWithAdminSchema('/api/admin/checkin-coverage?days=14', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({enabled_since: input.value || ''}) }) .then(function(r){ - if (!r.ok) return r.text().then(function(t){ throw new Error(t || 'Failed to save check-in SLA date'); }); + if (!r.ok) return r.text().then(function(t){ throw new Error(t || '{{T .Lang "admin_checkin_save_failed"}}'); }); return r.json(); }) .then(function(d) { From 374854726513d3b1ce3992f362a07b4770de42df Mon Sep 17 00:00:00 2001 From: Dzarlax Date: Sat, 20 Jun 2026 13:34:53 +0200 Subject: [PATCH 3/4] Add proactive context prompt MVP --- cmd/server/main.go | 55 +- ...6-06-20-proactive-context-prompts-mvp.html | 475 +++++++++++ internal/health/i18n_en.go | 17 + internal/health/i18n_ru.go | 17 + internal/health/i18n_sr.go | 17 + internal/health/types.go | 28 +- internal/notify/checkin_callback.go | 38 + internal/notify/checkin_callback_test.go | 57 +- internal/notify/context_prompt.go | 99 +++ internal/notify/context_prompt_test.go | 62 ++ internal/notify/report.go | 16 + internal/notify/report_rich.go | 16 + internal/storage/briefing.go | 17 + internal/storage/context_prompt.go | 793 ++++++++++++++++++ internal/storage/context_prompt_test.go | 279 ++++++ internal/tenants/manager.go | 2 + 16 files changed, 1968 insertions(+), 20 deletions(-) create mode 100644 docs/ai-plans/2026-06-20-proactive-context-prompts-mvp.html create mode 100644 internal/notify/context_prompt.go create mode 100644 internal/notify/context_prompt_test.go create mode 100644 internal/storage/context_prompt.go create mode 100644 internal/storage/context_prompt_test.go 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-20-proactive-context-prompts-mvp.html b/docs/ai-plans/2026-06-20-proactive-context-prompts-mvp.html new file mode 100644 index 0000000..a82b330 --- /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.

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

State Model

+

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

+
CREATE TABLE IF NOT EXISTS context_prompt_interactions (
+  prompt_id          TEXT PRIMARY KEY,
+  signal_date        DATE NOT NULL,
+  prompt_local_date  DATE NOT NULL,
+  detected_reason    TEXT NOT NULL,
+  detector_version   TEXT NOT NULL,
+  status             TEXT NOT NULL, -- detected | reserved | prompted | answered | skipped | expired | send_failed
+  category           TEXT,          -- NULL until answered/skipped
+  source             TEXT,          -- NULL until answer; telegram for MVP
+  prompt_message_id  BIGINT,
+  prompted_at        TIMESTAMPTZ,
+  expires_at         TIMESTAMPTZ,
+  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

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

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

+
+ +
+

Telegram Callback Design

+
    +
  • 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

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

Retention and Deletion

+
    +
  • 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

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

Test Plan

+
    +
  • 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.

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

Risks and Mitigations

+
    +
  • 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

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

Rollback Plan

+
    +
  • 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("

\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..616be93 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.GetContextAnnotationsForDate(*lastDate); 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..4094dea --- /dev/null +++ b/internal/storage/context_prompt.go @@ -0,0 +1,793 @@ +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, allowedJSON, 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) { + 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 + AND category IS NOT NULL + ORDER BY answered_at DESC`, signalDate, 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 — From 8026d44450147eb94536b74f5c22e64045e8e334 Mon Sep 17 00:00:00 2001 From: Dzarlax Date: Sat, 20 Jun 2026 13:52:20 +0200 Subject: [PATCH 4/4] Address context prompt review comments --- .../2026-06-18-checkin-calendar-sla.html | 7 +++---- ...6-06-20-proactive-context-prompts-mvp.html | 10 +++++----- internal/storage/briefing.go | 2 +- internal/storage/context_prompt.go | 20 +++++++++++++++---- 4 files changed, 25 insertions(+), 14 deletions(-) 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 index a82b330..627d568 100644 --- a/docs/ai-plans/2026-06-20-proactive-context-prompts-mvp.html +++ b/docs/ai-plans/2026-06-20-proactive-context-prompts-mvp.html @@ -143,18 +143,18 @@

State Model

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 }