From 10975eff074881cfbf4e67e3991ece61b9c58ec1 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 05:08:48 +0400 Subject: [PATCH] =?UTF-8?q?"=D1=87=D1=82=D0=BE=20=D0=B4=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D1=88=D0=B5=3F"=20answers=20with=20the=20next=20three,=20not?= =?UTF-8?q?=20the=20whole=20day=20(V-618)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on the box at 04:45: "что дальше?" read 43 entries, 05:45 to 21:12, as one spoken sentence. The rest-of-day path already trimmed to what had not happened yet, and at 04:45 that trim removes nothing — the whole day is still ahead. Trimming was never the narrowing; nothing capped the read. Plan.Next(now, n) is After with a cap, and the overflow is counted rather than dropped. The cap is three. One entry is defensible and reads as an oracle: it says what is next and says nothing about whether the day is full. Three is what the feed already reads back for headlines, it fits in one breath, and the reply is spoken — he cannot scroll it back. Above three the answer stops being an answer and becomes a recital, which is the defect. The sentence says whether more remains: plan_next is "дальше: …" and plan_next_more appends "и ещё 40 дел до конца дня." So a capped answer never implies the day ends after the third line. After is now strictly after now. An entry at exactly the asking minute is the thing happening, not the thing next. "что у меня сегодня?" is untouched and was never on this path: it carries no plan word, so IsDayPlanQuery declines it and the calendar listing answers the whole day. TestWholeDayQuestionIsNotTheRestOfTheDay pins the two apart. The empty case already said the right thing — plan_rest_empty, "на сегодня больше ничего не запланировано", not the whole-day empty line that would deny a day he just lived — and now has a test at the cap boundary too. Routing fixture unchanged, 64/91 (70.3% full, 70.3% intent-only) before and after: no router file is touched. Suite green. Co-Authored-By: Claude Opus 5 --- cmd/mavend/actions_query.go | 10 ++++- cmd/mavend/dayplan_test.go | 53 ++++++++++++++++++++++ internal/morning/plan.go | 44 +++++++++++++++++- internal/morning/plan_test.go | 79 +++++++++++++++++++++++++++++++++ internal/say/summary.go | 11 +++++ internal/say/summary_ru_v1.json | 8 ++++ 6 files changed, 201 insertions(+), 4 deletions(-) diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index c41984b..8edba2b 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -235,7 +235,13 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str // // 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. +// day, so that phrasing trims what has already passed and reads only the next +// morning.NextSpoken entries. Trimming alone was not enough: asked early it cuts +// nothing, and she read 43 entries aloud in one sentence (V-618). +// +// "что у меня сегодня?" is a different question and is not narrowed here — it +// carries no plan word, so IsDayPlanQuery declines it and the calendar source +// answers the whole day. // // 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"). @@ -263,7 +269,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin Uncertain: it.Uncertain, }) } - return p.After(h.now()).FormatRU(), true + return p.Next(h.now(), morning.NextSpoken).FormatRU(), true } // habitFactWindow — how many recent SELF facts the behaviour profile is counted diff --git a/cmd/mavend/dayplan_test.go b/cmd/mavend/dayplan_test.go index 52a0aa9..6662b21 100644 --- a/cmd/mavend/dayplan_test.go +++ b/cmd/mavend/dayplan_test.go @@ -4,12 +4,14 @@ import ( "context" "database/sql" "errors" + "fmt" "strings" "testing" "time" "github.com/kami/maven/internal/calendar" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/morning" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" @@ -111,6 +113,57 @@ func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) { } } +// The defect V-618 fixes, at the handler: asked at 04:45 the trim removes +// nothing, because the whole day is still ahead. She read 43 entries aloud as +// one sentence. The zone is three hours off UTC so the test also fails under +// TZ=UTC if the rendering ever slips zones. +func TestQueryDayPlanCapsWhatItReadsAloud(t *testing.T) { + zone := time.FixedZone("MSK", 3*60*60) + mid := time.Date(2026, 8, 3, 0, 0, 0, 0, zone) + plan := ipc.DayPlan{Date: mid, Spoken: "план на 03.08.2026: …"} + for i := 0; i < 43; i++ { + plan.Items = append(plan.Items, ipc.DayPlanItem{ + At: mid.Add(time.Duration(345+i*20) * time.Minute), // 05:45 onward + Text: fmt.Sprintf("пункт %d", i), + Kind: "event", + }) + } + h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time { + return time.Date(2026, 8, 3, 4, 45, 0, 0, zone) + }} + 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 n := strings.Count(reply, "пункт "); n != morning.NextSpoken { + t.Errorf("read %d entries aloud, want %d: %q", n, morning.NextSpoken, reply) + } + if !strings.HasPrefix(reply, "дальше: 05:45 — пункт 0;") { + t.Errorf("the next thing is not first: %q", reply) + } + // The rest is counted, not silently dropped. + if !strings.Contains(reply, "и ещё 40 дел до конца дня.") { + t.Errorf("the sentence hides that the day goes on: %q", reply) + } +} + +// "что у меня сегодня?" is the whole day and is not narrowed. It carries no +// plan word, so the plan source declines it and the calendar listing answers — +// asserted here beside the cap so the two questions cannot drift together. +func TestWholeDayQuestionIsNotTheRestOfTheDay(t *testing.T) { + if router.IsDayPlanQuery("что у меня сегодня?") { + t.Error("the plan source claims the whole-day question") + } + if !router.IsDayPlanQuery("что дальше?") { + t.Error("the plan source stopped claiming the rest-of-day question") + } + if router.IsRestOfDayQuery("какие планы на сегодня?") { + t.Error("the whole-day plan question got narrowed to the rest of the day") + } +} + // 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) { diff --git a/internal/morning/plan.go b/internal/morning/plan.go index ee863a2..80c853d 100644 --- a/internal/morning/plan.go +++ b/internal/morning/plan.go @@ -52,10 +52,13 @@ type PlanEntry struct { // 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. +// More counts what Next dropped off the end, so the sentence can say that more +// remains instead of implying the day ends after the third line. type Plan struct { Date time.Time Items []PlanEntry Rest bool + More int } // BuildPlan orders everything known about the day Now falls on: calendar @@ -142,13 +145,24 @@ func checklistEntries(routines []Routine, facts map[string]store.Fact, now time. return out } +// NextSpoken — how many entries "что дальше?" reads aloud. Three, for the same +// reason the feed reads three headlines: the answer is spoken once and cannot be +// scrolled back, and a list longer than a breath is not an answer, it is a +// recital. Asked at 04:45 on a day with 43 entries, the trim below removes +// nothing — everything is still ahead — so the cap is what makes "дальше" mean +// next rather than today (V-618). +const NextSpoken = 3 + // After returns the part of the plan that has not happened yet — the answer to // "что дальше?" as opposed to "какие планы на сегодня?". The Date is kept, so an // empty result still knows which day it is empty for. +// +// Strictly after: an entry at exactly now is the thing happening, not the thing +// next. func (p Plan) After(now time.Time) Plan { out := Plan{Date: p.Date, Rest: true} for _, it := range p.Items { - if it.At.Before(now) { + if !it.At.After(now) { continue } out.Items = append(out.Items, it) @@ -156,6 +170,18 @@ func (p Plan) After(now time.Time) Plan { return out } +// Next is After with a spoken cap — what "что дальше?" actually answers with. +// The overflow is counted rather than dropped, because "дальше: 10:00 …" with +// forty entries hidden behind it is a false picture of the day. +func (p Plan) Next(now time.Time, n int) Plan { + out := p.After(now) + if n > 0 && len(out.Items) > n { + out.More = len(out.Items) - n + out.Items = out.Items[:n] + } + return out +} + // FormatRU renders the plan as maven says it. Feminine self-reference, // informal address, no pet names — and no exhortation: she reads the day back, // she does not tell him to get on with it. @@ -177,8 +203,22 @@ func (p Plan) FormatRU() string { } parts[i] = line } + items := strings.Join(parts, "; ") + // The rest of the day is a different sentence, not a shorter day plan. It + // carries no date — he asked what is next, and he knows which day he is in — + // and it says out loud when there is more behind the cap. + if p.Rest { + if p.More > 0 { + return say.S(say.PlanNextMore, map[string]string{ + "items": items, + "n": fmt.Sprint(p.More), + "word": say.CountWord(p.More, "дело", "дела", "дел"), + }) + } + return say.S(say.PlanNext, map[string]string{"items": items}) + } return say.S(say.PlanDay, map[string]string{ "date": p.Date.Format("02.01.2006"), - "items": strings.Join(parts, "; "), + "items": items, }) } diff --git a/internal/morning/plan_test.go b/internal/morning/plan_test.go index 3448412..99671c9 100644 --- a/internal/morning/plan_test.go +++ b/internal/morning/plan_test.go @@ -1,6 +1,7 @@ package morning import ( + "fmt" "strings" "testing" "time" @@ -171,6 +172,84 @@ func TestPlanAfter(t *testing.T) { } } +// nextFixture — a day with more entries than the cap, built in a zone three +// hours off UTC so the test fails under TZ=UTC as well as under the machine's +// own zone if the plan ever renders in the wrong one. +func nextFixture(t *testing.T) (Plan, time.Time) { + t.Helper() + zone := time.FixedZone("MSK", 3*60*60) + now := time.Date(2026, 8, 3, 4, 45, 0, 0, zone) + var events []PlanEntry + for _, hhmm := range [][2]int{{5, 45}, {10, 0}, {14, 0}, {18, 30}, {21, 12}} { + events = append(events, PlanEntry{ + At: planAt(now, hhmm[0], hhmm[1]), + Text: fmt.Sprintf("событие %02d:%02d", hhmm[0], hhmm[1]), + Kind: PlanEvent, + }) + } + return BuildPlan(nil, nil, events, nil, now), now +} + +// "что дальше?" asked at 04:45 on a day with everything still ahead. The trim +// removes nothing there, so before V-618 she read the whole day out loud. +func TestPlanNextCapsWhatIsSpoken(t *testing.T) { + p, now := nextFixture(t) + got := p.Next(now, NextSpoken).FormatRU() + want := "дальше: 05:45 — событие 05:45; 10:00 — событие 10:00; " + + "14:00 — событие 14:00. и ещё 2 дела до конца дня." + if got != want { + t.Errorf("got %q\nwant %q", got, want) + } + // No date: he asked what is next, not what day it is. + if strings.Contains(got, "03.08.2026") { + t.Errorf("rest-of-day answer stamps a date: %q", got) + } +} + +// Nothing hidden means nothing claimed hidden. +func TestPlanNextWithinTheCapSaysNoMore(t *testing.T) { + p, now := nextFixture(t) + got := p.Next(planAt(now, 15, 0), NextSpoken).FormatRU() + want := "дальше: 18:30 — событие 18:30; 21:12 — событие 21:12" + if got != want { + t.Errorf("got %q\nwant %q", got, want) + } +} + +// The whole-day question is not narrowed: same plan, no trim, no cap. +func TestPlanWholeDayIsNotNarrowed(t *testing.T) { + p, _ := nextFixture(t) + got := p.FormatRU() + if n := strings.Count(got, "событие"); n != 5 { + t.Errorf("whole day read %d of 5 entries: %q", n, got) + } + if !strings.HasPrefix(got, "план на 03.08.2026: ") { + t.Errorf("whole day lost its date: %q", got) + } +} + +// The empty case says the day is over rather than returning an empty sentence, +// and it does not say the day was empty. +func TestPlanNextEmptySaysSo(t *testing.T) { + p, now := nextFixture(t) + got := p.Next(planAt(now, 23, 30), NextSpoken).FormatRU() + if got != "на сегодня больше ничего не запланировано." { + t.Errorf("got %q", got) + } +} + +// An entry at exactly the asking minute is what is happening, not what is next. +func TestPlanNextIsStrictlyAfterNow(t *testing.T) { + p, now := nextFixture(t) + rest := p.Next(planAt(now, 5, 45), NextSpoken) + if len(rest.Items) != 3 || rest.Items[0].At.Hour() != 10 { + t.Errorf("got %+v", rest.Items) + } + if rest.More != 1 { + t.Errorf("More = %d, want 1", rest.More) + } +} + // 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 diff --git a/internal/say/summary.go b/internal/say/summary.go index bc2b575..7a72899 100644 --- a/internal/say/summary.go +++ b/internal/say/summary.go @@ -32,6 +32,12 @@ const ( PlanDay = "plan_day" PlanUncertain = "plan_uncertain" + // "что дальше?" — the next few entries, not the day. PlanNextMore is the + // same sentence when the cap hid something, so the count it states is the + // only signal that the day is not over after the last line read. + PlanNext = "plan_next" + PlanNextMore = "plan_next_more" + TasksNone = "tasks_none" TasksFirst = "tasks_first" TasksCandidates = "tasks_candidates" @@ -70,6 +76,7 @@ const ( var summaryKeys = []string{ PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain, + PlanNext, PlanNextMore, TasksNone, TasksFirst, TasksCandidates, StallOverdue, StallSitting, StallUnconfirmed, ReasonOverdue, ReasonOverdueDays, ReasonToday, ReasonTomorrow, @@ -89,6 +96,8 @@ var summaryFloor = map[string]string{ PlanDayEmpty: "на {date} ничего не запланировано.", PlanDay: "план на {date}: {items}", PlanUncertain: "похоже, {line}", + PlanNext: "дальше: {items}", + PlanNextMore: "дальше: {items}. и ещё {n} {word} до конца дня.", TasksNone: "задач нет.", TasksFirst: "сначала: {items}", @@ -139,6 +148,8 @@ func LoadSummaries(src rand.Source) (*Summaries, error) { for _, req := range []struct{ key, ph string }{ {PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"}, {PlanUncertain, "{line}"}, + {PlanNext, "{items}"}, + {PlanNextMore, "{items}"}, {PlanNextMore, "{n}"}, {PlanNextMore, "{word}"}, {TasksFirst, "{items}"}, {TasksCandidates, "{items}"}, {StallOverdue, "{n}"}, {StallOverdue, "{word}"}, {StallSitting, "{n}"}, {StallSitting, "{word}"}, diff --git a/internal/say/summary_ru_v1.json b/internal/say/summary_ru_v1.json index 74048fc..6a0af41 100644 --- a/internal/say/summary_ru_v1.json +++ b/internal/say/summary_ru_v1.json @@ -30,6 +30,14 @@ "fixed": true, "variants": ["похоже, {line}"] }, + "plan_next": { + "fixed": true, + "variants": ["дальше: {items}"] + }, + "plan_next_more": { + "fixed": true, + "variants": ["дальше: {items}. и ещё {n} {word} до конца дня."] + }, "tasks_none": { "fixed": true,