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
+34 -16
View File
@@ -28,6 +28,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
@@ -193,6 +194,14 @@ func (p *poller) pollOnce(ctx context.Context) {
//
// Both windows are read from one diff call each. Two calls an hour against an
// API whose whole job is this is not worth caching.
//
// The write is UNCONDITIONAL, unlike every other poll in this file. The
// value-dedupe in writeIfChangedRaw only advances ts when the number moves, and
// for money that made ts mean "last changed" while the reader was asking it "as
// of when". A quiet 27 hours had core prefixing "данные от 30.07" to a figure
// that was current. The value now carries its own read stamp, so it differs
// every poll anyway and there is nothing left for the dedupe to catch.
// moneyWindow — one fact key and the period it covers.
type moneyWindow struct {
key string
@@ -215,12 +224,14 @@ func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
}
continue
}
val, ok := sum.Value()
val, ok := sum.Value(now)
if !ok {
// Nothing read. Silence, not a zero.
// Nothing read. Silence, not a zero. The last good fact stays, and
// the window stamp inside it is what stops core reciting yesterday's
// day total as today's after midnight.
continue
}
if err := p.writeIfChangedRaw(ctx, w.key, zenmoney.Source, val, now); err != nil && firstErr == nil {
if err := p.writeMoneyFact(ctx, w.key, val, now); err != nil && firstErr == nil {
firstErr = err
}
}
@@ -422,20 +433,27 @@ func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal str
return nil
}
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
// errors.Is is the right check; keep a helper so the switch above reads clean.
func isNoFact(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()
// writeMoneyFact writes a money fact every poll, with no value comparison. See
// the comment above pollZenmoney for why this one does not go through
// writeIfChangedRaw.
//
// The log line names the key only, never the figures: mavpoll's log is not the
// place his spending ends up.
func (p *poller) writeMoneyFact(ctx context.Context, key, jsonVal string, now time.Time) error {
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
Ts: now, Kind: "env", Key: key, Value: jsonVal,
Source: zenmoney.Source, Confidence: 1.0,
}); err != nil {
return fmt.Errorf("write %s: %w", key, err)
}
return false
log.Printf("mavpoll: %s read (%s)", key, zenmoney.Source)
return nil
}
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
// errors.Is is the right check.
func isNoFact(err error) bool {
return errors.Is(err, ipc.ErrNoFact)
}
func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) {