Version, authenticate and fully trace ecosystem calls #84
@@ -139,11 +139,16 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
||||
|
||||
// queryDayPlan — "какие планы на сегодня?", "что у меня по плану?", "что
|
||||
// дальше?" (Vikunja #128). Recites the day: calendar events, pending
|
||||
// reminders, and any morning checklist still outstanding.
|
||||
// reminders, and every morning checklist item today still has no evidence for,
|
||||
// including the ones whose window has closed.
|
||||
//
|
||||
// Read-only by construction — the plan is assembled and rendered core-side and
|
||||
// nothing here schedules or announces. "что дальше?" asks for the rest of the
|
||||
// day, so that phrasing trims what has already passed.
|
||||
//
|
||||
// What surface this belongs on is still open, tracked as Vikunja #431 ("Board
|
||||
// surface: Maven holds the work board, runs the intake form, never argues").
|
||||
// The spoken recital here is the current answer, not the decided one.
|
||||
func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !router.IsDayPlanQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
@@ -153,7 +158,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
||||
log.Printf("voice: day plan: %v", err)
|
||||
return "не получилось собрать план.", true
|
||||
}
|
||||
if !isRestOfDayQuery(t.dec.Utterance) {
|
||||
if !router.IsRestOfDayQuery(t.dec.Utterance) {
|
||||
return plan.Spoken, true
|
||||
}
|
||||
// Rebuild the pure plan so the rest-of-day rendering is the same code that
|
||||
@@ -170,13 +175,6 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
||||
return p.After(h.now()).FormatRU(), true
|
||||
}
|
||||
|
||||
// isRestOfDayQuery — "что дальше?" and its English form, the only plan phrasing
|
||||
// that means "from now on" rather than "the whole day".
|
||||
func isRestOfDayQuery(text string) bool {
|
||||
s := strings.ToLower(text)
|
||||
return strings.Contains(s, "дальше") || strings.Contains(s, "next")
|
||||
}
|
||||
|
||||
// habitFactWindow — how many recent facts the behaviour profile is counted
|
||||
// over. Enough for a season of habits without scanning the whole store on every
|
||||
// question; the profile is recomputed on read, so the bound is the cost control.
|
||||
|
||||
@@ -2,13 +2,16 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// planAPI answers only DayPlan; every other call is unimplemented, which is
|
||||
@@ -85,12 +88,40 @@ func TestQueryDayPlanTrimsToRestOfDay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// "что дальше?" after the last item of the day. The day was not empty, it is
|
||||
// over, and the whole-day empty line says something false about a day he just
|
||||
// lived through.
|
||||
func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) {
|
||||
plan := samplePlan()
|
||||
h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time {
|
||||
return time.Date(2026, 8, 3, 23, 0, 0, 0, time.UTC)
|
||||
}}
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the plan source to claim it")
|
||||
}
|
||||
if strings.Contains(reply, plan.Date.Format("02.01.2006")) {
|
||||
t.Errorf("the day had things on it and they are done, not empty: %q", reply)
|
||||
}
|
||||
if reply != "на сегодня больше ничего не запланировано." {
|
||||
t.Errorf("reply = %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A question that is not about the plan must fall through, or the plan buries
|
||||
// the calendar listing and the weather behind it.
|
||||
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
|
||||
for _, q := range []string{
|
||||
"что у меня сегодня?",
|
||||
"какие планы на завтра?",
|
||||
// The plan can only be built for the clock's own day. Naming another
|
||||
// one has to fall through, not get answered with today.
|
||||
"какие планы на понедельник?",
|
||||
"какие планы на неделю?",
|
||||
"какие планы на выходные?",
|
||||
"what are my plans for friday?",
|
||||
"когда планёрка?",
|
||||
"какая погода?",
|
||||
"",
|
||||
@@ -225,3 +256,57 @@ func TestHabitSourcePrecedesCalendar(t *testing.T) {
|
||||
t.Errorf("habits at %d must come before calendar at %d", habits, cal)
|
||||
}
|
||||
}
|
||||
|
||||
// The plan reads the store on the owner's clock: one line per event, the hour
|
||||
// printed once, and reminders selected by fire time rather than by how
|
||||
// recently they were stated.
|
||||
func TestTickDayPlanReadsTheStore(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||
|
||||
now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.Local)
|
||||
day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.Local)
|
||||
ev := calendar.Event{
|
||||
Summary: "Standup",
|
||||
Start: day.Add(14 * time.Hour),
|
||||
End: day.Add(14*time.Hour + 30*time.Minute),
|
||||
}
|
||||
// Rescheduled: same key, a second row.
|
||||
if _, err := st.WriteFact(ctx, ev.Start, store.KindEnv, calendar.FactKey(ev),
|
||||
calendar.FactValue(ev), calendar.SourcePersonal, 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFact: %v", err)
|
||||
}
|
||||
moved := ev
|
||||
moved.Start, moved.End = day.Add(16*time.Hour), day.Add(16*time.Hour+30*time.Minute)
|
||||
if _, err := st.WriteFact(ctx, moved.Start, store.KindEnv, calendar.FactKey(moved),
|
||||
calendar.FactValue(moved), calendar.SourcePersonal, 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFact: %v", err)
|
||||
}
|
||||
// One reminder today, one next year. Both are pending; only today's is a
|
||||
// plan for today.
|
||||
if _, err := st.CreateReminder(ctx, day.Add(18*time.Hour), "позвонить маме", ""); err != nil {
|
||||
t.Fatalf("CreateReminder: %v", err)
|
||||
}
|
||||
if _, err := st.CreateReminder(ctx, day.AddDate(1, 0, 0), "продлить страховку", ""); err != nil {
|
||||
t.Fatalf("CreateReminder: %v", err)
|
||||
}
|
||||
|
||||
plan := tl.dayPlan(ctx, now)
|
||||
if len(plan.Items) != 2 {
|
||||
t.Fatalf("got %d items, want the moved standup and today's reminder: %+v", len(plan.Items), plan.Items)
|
||||
}
|
||||
ev0 := plan.Items[0]
|
||||
if ev0.Kind != "event" || ev0.At.In(time.Local).Format("15:04") != "16:00" {
|
||||
t.Errorf("event = %+v, want the 16:00 one", ev0)
|
||||
}
|
||||
if ev0.Text != "Standup" {
|
||||
t.Errorf("text = %q — the plan prints the hour itself", ev0.Text)
|
||||
}
|
||||
if plan.Items[1].Text != "позвонить маме" {
|
||||
t.Errorf("second item = %+v", plan.Items[1])
|
||||
}
|
||||
if strings.Contains(plan.Spoken, "страховку") {
|
||||
t.Errorf("a reminder for next year is not today's plan: %q", plan.Spoken)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-9
@@ -19,6 +19,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
@@ -808,8 +809,10 @@ func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
||||
}
|
||||
for _, f := range facts {
|
||||
events = append(events, morning.PlanEntry{
|
||||
At: f.Ts,
|
||||
Text: f.Value,
|
||||
At: f.Ts,
|
||||
// The plan prints the hour itself, so the "@ 14:00-14:30" tail the
|
||||
// fact value carries would say it twice.
|
||||
Text: calendar.FactSummary(f.Value),
|
||||
Kind: morning.PlanEvent,
|
||||
// Provenance below a calendar read (an ambient relay, #126) is
|
||||
// hedged rather than recited as fact.
|
||||
@@ -818,12 +821,12 @@ func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
||||
}
|
||||
|
||||
var reminders []morning.PlanEntry
|
||||
rems, err := t.store.ListReminders(ctx, dayPlanMaxReminders)
|
||||
rems, err := t.store.PendingReminders(ctx, dayStart, dayEnd)
|
||||
if err != nil {
|
||||
log.Printf("tick: day plan: list reminders: %v", err)
|
||||
log.Printf("tick: day plan: pending reminders: %v", err)
|
||||
}
|
||||
for _, r := range rems {
|
||||
if r.Status != "pending" {
|
||||
if r.Status != store.ReminderPending {
|
||||
continue
|
||||
}
|
||||
fire := r.NextFireTs
|
||||
@@ -856,10 +859,6 @@ func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
||||
return out
|
||||
}
|
||||
|
||||
// dayPlanMaxReminders bounds the reminder scan. The plan covers one day; a
|
||||
// pending queue longer than this is a bug elsewhere, not a plan to recite.
|
||||
const dayPlanMaxReminders = 500
|
||||
|
||||
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
||||
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
||||
// rule:
|
||||
|
||||
@@ -140,6 +140,29 @@ func Evaluate(r Routine, facts map[string]store.Fact, now time.Time) Status {
|
||||
return st
|
||||
}
|
||||
|
||||
// Outstanding reports the items of a routine that today has no evidence for,
|
||||
// whether or not the window is still open. Evaluate answers "what is missing
|
||||
// right now" and goes silent the moment the window closes; the day plan asks a
|
||||
// different question, "what did today still not get done", and a skipped
|
||||
// routine is exactly what it is worth telling him. Nothing before the window
|
||||
// opens is outstanding yet, so the morning routine is not a complaint at 06:00.
|
||||
func Outstanding(r Routine, facts map[string]store.Fact, now time.Time) []Item {
|
||||
if !appliesToday(r, now) {
|
||||
return nil
|
||||
}
|
||||
start, ok := todayAt(r.WindowStart, now)
|
||||
if !ok || now.Before(start) {
|
||||
return nil
|
||||
}
|
||||
var missing []Item
|
||||
for _, it := range r.Items {
|
||||
if !evidenced(it, facts, start, now) {
|
||||
missing = append(missing, it)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// Due returns the routines that have reached their nudge time today with at
|
||||
// least one item still missing, and records `now` in `last` for each one
|
||||
// returned so it fires at most once per calendar day. The caller owns
|
||||
|
||||
@@ -48,10 +48,13 @@ type PlanEntry struct {
|
||||
Uncertain bool
|
||||
}
|
||||
|
||||
// Plan — the ordered day. Date is the calendar day it describes.
|
||||
// Plan — the ordered day. Date is the calendar day it describes. Rest marks a
|
||||
// plan trimmed by After, which changes what an empty one means: a day with
|
||||
// nothing on it and a day whose last item has passed are different answers.
|
||||
type Plan struct {
|
||||
Date time.Time
|
||||
Items []PlanEntry
|
||||
Rest bool
|
||||
}
|
||||
|
||||
// BuildPlan orders everything known about the day Now falls on: calendar
|
||||
@@ -98,17 +101,23 @@ func BuildPlan(routines []Routine, facts map[string]store.Fact, events, reminder
|
||||
|
||||
// checklistEntries renders one line per routine with work left in it, placed at
|
||||
// the routine's nudge time — where the checklist actually matters in the day.
|
||||
// A routine that does not apply today, is not in its window, or is already
|
||||
// A routine that does not apply today, has not opened yet, or is already
|
||||
// complete contributes nothing: the plan says what is left, not what was done.
|
||||
//
|
||||
// A closed window still counts. Asked at 14:00 with the morning routine
|
||||
// unfinished, the plan used to say nothing about it, because Evaluate reports
|
||||
// Active only inside the window. What he skipped is the one thing the plan can
|
||||
// tell him that the calendar cannot, and the entry sorts to its nudge time, not
|
||||
// to the moment of asking.
|
||||
func checklistEntries(routines []Routine, facts map[string]store.Fact, now time.Time) []PlanEntry {
|
||||
var out []PlanEntry
|
||||
for _, r := range routines {
|
||||
st := Evaluate(r, facts, now)
|
||||
if !st.Active || len(st.Missing) == 0 {
|
||||
missing := Outstanding(r, facts, now)
|
||||
if len(missing) == 0 {
|
||||
continue
|
||||
}
|
||||
labels := make([]string, 0, len(st.Missing))
|
||||
for _, it := range st.Missing {
|
||||
labels := make([]string, 0, len(missing))
|
||||
for _, it := range missing {
|
||||
label := it.Label
|
||||
if label == "" {
|
||||
label = it.Key
|
||||
@@ -136,7 +145,7 @@ func checklistEntries(routines []Routine, facts map[string]store.Fact, now time.
|
||||
// "что дальше?" as opposed to "какие планы на сегодня?". The Date is kept, so an
|
||||
// empty result still knows which day it is empty for.
|
||||
func (p Plan) After(now time.Time) Plan {
|
||||
out := Plan{Date: p.Date}
|
||||
out := Plan{Date: p.Date, Rest: true}
|
||||
for _, it := range p.Items {
|
||||
if it.At.Before(now) {
|
||||
continue
|
||||
@@ -151,6 +160,12 @@ func (p Plan) After(now time.Time) Plan {
|
||||
// she does not tell him to get on with it.
|
||||
func (p Plan) FormatRU() string {
|
||||
if len(p.Items) == 0 {
|
||||
// "что дальше?" after the last item of the day. The day was not empty,
|
||||
// it is over, and saying it was empty is a false statement about a day
|
||||
// he just lived.
|
||||
if p.Rest {
|
||||
return "на сегодня больше ничего не запланировано."
|
||||
}
|
||||
return fmt.Sprintf("на %s ничего не запланировано.", p.Date.Format("02.01.2006"))
|
||||
}
|
||||
parts := make([]string, len(p.Items))
|
||||
|
||||
@@ -163,7 +163,55 @@ func TestPlanAfter(t *testing.T) {
|
||||
if len(empty.Items) != 0 {
|
||||
t.Errorf("got %+v", empty.Items)
|
||||
}
|
||||
if !strings.Contains(empty.FormatRU(), "ничего не запланировано") {
|
||||
t.Errorf("empty plan reads %q", empty.FormatRU())
|
||||
// An empty rest-of-day is not an empty day. Saying "на 03.08.2026 ничего
|
||||
// не запланировано" at 23:00 denies the day he just lived.
|
||||
if got, want := empty.FormatRU(), "на сегодня больше ничего не запланировано."; got != want {
|
||||
t.Errorf("empty rest-of-day reads %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The plan says what today still has not got done, and a closed window does not
|
||||
// make a skipped routine untrue. Evaluate reports Active only inside the
|
||||
// window, so keying the checklist line off it meant the one thing the plan can
|
||||
// tell him that the calendar cannot went silent at 11:00.
|
||||
func TestBuildPlanKeepsAClosedWindowOutstanding(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 14, 0, 0, 0, time.UTC)
|
||||
routines := []Routine{{
|
||||
Name: "утро", WindowStart: "07:00", WindowEnd: "11:00", NudgeAt: "10:30",
|
||||
Items: []Item{
|
||||
{Key: "water", FactKey: "drank_water", Label: "выпить воды"},
|
||||
{Key: "pills", FactKey: "took_pills", Label: "витамины"},
|
||||
},
|
||||
}}
|
||||
facts := map[string]store.Fact{"drank_water": {Ts: planAt(now, 8, 0)}}
|
||||
|
||||
p := BuildPlan(routines, facts, nil, nil, now)
|
||||
if len(p.Items) != 1 {
|
||||
t.Fatalf("got %+v, want the unfinished morning routine", p.Items)
|
||||
}
|
||||
it := p.Items[0]
|
||||
if it.Kind != PlanChecklist {
|
||||
t.Errorf("kind = %q", it.Kind)
|
||||
}
|
||||
// Placed at the nudge time, so it sorts to the top of the day rather than
|
||||
// to the moment of asking.
|
||||
if got := it.At.Format("15:04"); got != "10:30" {
|
||||
t.Errorf("placed at %s, want 10:30", got)
|
||||
}
|
||||
if !strings.Contains(it.Text, "витамины") || strings.Contains(it.Text, "выпить воды") {
|
||||
t.Errorf("line = %q", it.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// A routine whose window has not opened yet is not outstanding. Nothing has
|
||||
// been skipped at 06:00.
|
||||
func TestBuildPlanIgnoresAnUnopenedWindow(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 6, 0, 0, 0, time.UTC)
|
||||
routines := []Routine{{
|
||||
Name: "утро", WindowStart: "07:00", WindowEnd: "11:00",
|
||||
Items: []Item{{Key: "water", FactKey: "drank_water", Label: "выпить воды"}},
|
||||
}}
|
||||
if p := BuildPlan(routines, nil, nil, nil, now); len(p.Items) != 0 {
|
||||
t.Fatalf("got %+v", p.Items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,15 @@ type Reminder struct {
|
||||
Collapsed []Reminder
|
||||
}
|
||||
|
||||
// Reminder lifecycle states. Named for the same reason DigestStatus is: a
|
||||
// caller filtering on the string literal "pending" is one typo away from a
|
||||
// filter that silently matches nothing.
|
||||
const (
|
||||
ReminderPending = "pending"
|
||||
ReminderFired = "fired"
|
||||
ReminderCancelled = "cancelled"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrReminderNotFound = errors.New("store: reminder not found")
|
||||
ErrReminderState = errors.New("store: reminder not in a mutable state")
|
||||
@@ -93,6 +102,36 @@ func (s *Store) DueReminders(ctx context.Context, now time.Time) ([]Reminder, er
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PendingReminders returns the pending reminders whose next fire time falls in
|
||||
// [from, to), earliest first.
|
||||
//
|
||||
// The day plan used to take the newest 500 rows out of ListReminders, which
|
||||
// orders by creation, and then filter them by day. A reminder stated long ago
|
||||
// for today fell off the end of that scan while a reminder stated this morning
|
||||
// for next year stayed on it. Bounding by fire time drops what is out of range
|
||||
// instead of what is old.
|
||||
func (s *Store) PendingReminders(ctx context.Context, from, to time.Time) ([]Reminder, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
|
||||
FROM reminders
|
||||
WHERE status = ? AND next_fire_ts >= ? AND next_fire_ts < ?
|
||||
ORDER BY next_fire_ts ASC, id ASC`,
|
||||
ReminderPending, from.UnixMilli(), to.UnixMilli())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pending reminders: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Reminder
|
||||
for rows.Next() {
|
||||
r, err := scanReminder(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MarkReminder sets a reminder's status. Only valid transitions: pending→fired,
|
||||
// pending→cancelled. Anything else is a programming error.
|
||||
func (s *Store) MarkReminder(ctx context.Context, id int64, status string) error {
|
||||
|
||||
Reference in New Issue
Block a user