zenmoney: bound the day fact to its own day and stamp when it was read

The day total rolls over at midnight and the poller had nothing to write until
the first spend of the new day, so at 09:00 the latest money_today fact was
yesterday's spending and looked perfectly fresh. The value now carries the
first instant of the window it covers, and a today question that the stored
window does not cover is refused rather than answered with yesterday's number.
Staleness was measured off the fact timestamp, which only moved when the figure
moved, so a quiet month was reported as data from three days ago while being
current. The value now carries when it was last read and the poller writes on
every read.

Amounts in an instrument the window diff never named were spoken with a numeric
instrument id as the currency. Instruments are resolved from one cursor-zero
diff, cached for the process, and an amount still unnamed is dropped from
speech rather than recited wrongly. "сколько я потратил вчера" was answered
with the month total, a real number to a different question, and is now
refused by naming the two windows she keeps. Income questions led with the
spending.

Found in review of #62.
This commit is contained in:
kami
2026-08-01 14:16:56 +04:00
parent 88d25d31ac
commit 6316354518
8 changed files with 472 additions and 74 deletions
+31 -16
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"log"
"github.com/kami/maven/internal/ipc"
@@ -28,12 +29,18 @@ import (
// tracking is not connected". It never computes, estimates or rounds a total of
// its own — an invented number about his money is the worst thing this could do.
func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string, bool) {
window, ok := router.ParseMoneyQuery(t.dec.Utterance)
q, ok := router.ParseMoneyQuery(t.dec.Utterance)
if !ok {
return "", false
}
if q.Window == router.MoneyUnsupported {
// Two windows are stored and no others. Answering "сколько я потратил
// вчера?" with the month-to-date total answers a different question
// with a real number, which is the shape of a lie he cannot spot.
return "я храню только сегодняшние траты и за этот месяц.", true
}
key, phrase := zenmoney.KeySpentMonth, "в этом месяце"
if window == router.MoneyToday {
if q.Window == router.MoneyToday {
key, phrase = zenmoney.KeySpentToday, "сегодня"
}
fact, err := h.api.LatestFactBySource(ctx, key, zenmoney.Source)
@@ -51,28 +58,36 @@ func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string,
log.Printf("voice: money fact: decode: %v", err)
return "не получилось прочитать траты.", true
}
now := h.now()
if q.Window == router.MoneyToday && !val.CoversDay(now) {
// The day window rolled over and the poller had nothing to write,
// because he has not spent anything yet today. The fact is fresh by ts
// and covers yesterday, so no staleness check can catch it — only the
// window stamp inside the value can.
return "сегодня пока ничего не вижу.", true
}
reply := val.FormatRU(phrase)
if q.Income {
reply = val.FormatIncomeRU(phrase)
}
if reply == "" {
return "по тратам пока нечего сказать.", true
}
// A stale fact is reported as stale rather than spoken as today's number.
if h.now().Sub(fact.Ts) > zenmoney.StaleAfter {
return "данные от " + fact.Ts.Local().Format("02.01") + ": " + reply, true
// The age is measured from when the figure was last READ, not from when it
// last changed: a month with no spending in it does not go stale.
asOf := val.AsOf
if asOf.IsZero() {
asOf = fact.Ts
}
if now.Sub(asOf) > zenmoney.StaleAfter {
return "данные от " + asOf.Local().Format("02.01") + ": " + reply, true
}
return reply, true
}
// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it.
// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it. The
// hand-rolled loop this replaces missed any error implementing Is(error) bool.
func isNoFactErr(err error) bool {
for e := err; e != nil; {
if e == ipc.ErrNoFact {
return true
}
u, ok := e.(interface{ Unwrap() error })
if !ok {
return false
}
e = u.Unwrap()
}
return false
return errors.Is(err, ipc.ErrNoFact)
}
+89
View File
@@ -137,3 +137,92 @@ func TestQuerySourcesOrderMoneyBeforeRecall(t *testing.T) {
t.Errorf("money source at %d, after notes at %d", moneyAt, notesAt)
}
}
// The day window rolls over at midnight and the poller writes nothing until the
// first spend of the new day, so the last money_today fact is fresh by ts and
// covers yesterday. No staleness check can catch that.
func TestQueryMoneyRefusesYesterdaysDayTotal(t *testing.T) {
yesterday, _ := zenmoney.DayWindow(moneyNow().AddDate(0, 0, -1))
sum := zenmoney.Summary{From: yesterday, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 1749.5}}, Count: 3}
val, ok := sum.Value(moneyNow().AddDate(0, 0, -1).Add(2 * time.Hour))
if !ok {
t.Fatal("want a fact value")
}
api := &moneyAPI{fact: ipc.Fact{
Kind: "env", Key: zenmoney.KeySpentToday, Value: val,
Source: zenmoney.Source, Ts: moneyNow().Add(-11 * time.Hour),
}}
h := &reactiveHandler{api: api, now: moneyNow}
reply, claimed := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я потратил сегодня?"},
})
if !claimed {
t.Fatal("expected the source to claim it")
}
if strings.Contains(reply, "1749.5") {
t.Errorf("reply = %q — that is yesterday's spending spoken as today's", reply)
}
}
// Ts advances only when the number moves, so a quiet month used to be reported
// as stale while being current. The read stamp inside the value is what the
// staleness check means.
func TestQueryMoneyMeasuresStalenessFromTheRead(t *testing.T) {
from, _ := zenmoney.MonthWindow(moneyNow())
sum := zenmoney.Summary{From: from, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 100}}, Count: 1}
val, _ := sum.Value(moneyNow().Add(-time.Hour))
// The fact itself last CHANGED three days ago: nothing was spent since.
api := &moneyAPI{fact: ipc.Fact{
Kind: "env", Key: zenmoney.KeySpentMonth, Value: val,
Source: zenmoney.Source, Ts: moneyNow().Add(-72 * time.Hour),
}}
h := &reactiveHandler{api: api, now: moneyNow}
reply, _ := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"},
})
if strings.Contains(reply, "данные от") {
t.Errorf("reply = %q — the figure was read an hour ago and is current", reply)
}
}
// Two windows are stored and no others. Answering "вчера" with the
// month-to-date total answers a different question with a real number.
func TestQueryMoneyRefusesWindowsItDoesNotKeep(t *testing.T) {
api := &moneyAPI{}
h := &reactiveHandler{api: api, now: moneyNow}
reply, ok := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я потратил вчера?"},
})
if !ok {
t.Fatal("a money question must be claimed, not passed to recall")
}
if !strings.Contains(reply, "только") {
t.Errorf("reply = %q, want her to say which windows she keeps", reply)
}
if api.callCnt != 0 {
t.Error("a window she does not keep must not read a fact")
}
}
// "сколько я заработал" reads the same fact and must lead with the income.
func TestQueryMoneyLeadsWithIncomeWhenAsked(t *testing.T) {
from, _ := zenmoney.MonthWindow(moneyNow())
sum := zenmoney.Summary{
From: from,
Spent: []zenmoney.Money{{Currency: "RUB", Amount: 100}},
Earned: []zenmoney.Money{{Currency: "RUB", Amount: 3000}},
Count: 2,
}
val, _ := sum.Value(moneyNow())
api := &moneyAPI{fact: ipc.Fact{
Kind: "env", Key: zenmoney.KeySpentMonth, Value: val,
Source: zenmoney.Source, Ts: moneyNow(),
}}
h := &reactiveHandler{api: api, now: moneyNow}
reply, _ := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я заработал в этом месяце?"},
})
if strings.Index(reply, "3000") > strings.Index(reply, "100") {
t.Errorf("reply = %q, want the income he asked about first", reply)
}
}