"что дальше?" answers with the next three, not the whole day (V-618)

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 05:08:48 +04:00
parent 0b1efe4911
commit 10975eff07
6 changed files with 201 additions and 4 deletions
+8 -2
View File
@@ -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
+53
View File
@@ -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) {