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
+45 -12
View File
@@ -16,12 +16,29 @@ const (
MoneyNone MoneyWindow = iota
MoneyToday
MoneyMonth
// MoneyUnsupported — a money question over a window nothing is stored for
// ("вчера", "на прошлой неделе"). Claimed, not answered: the poller keeps
// today and the month, and answering a question about yesterday with the
// month-to-date total is worse than saying she does not keep it.
MoneyUnsupported
)
// MoneyQuery — a parsed money question. Income is set when he asked what he
// EARNED rather than what he spent; the two read the same fact and differ only
// in which half of it leads the answer.
type MoneyQuery struct {
Window MoneyWindow
Income bool
}
// incomeNouns — the words that make a money question be about income.
var incomeNouns = []string{"заработал", "заработала", "получил", "доход", "доходы", "earned", "income"}
// moneyNouns — the words that make a question be about his money.
var moneyNouns = []string{
"потратил", "потратила", "тратил", "траты", "трат", "расходы", "расходов",
"заработал", "потрачено", енег", "spend", "spent", "expenses",
"заработал", "заработала", оход", "доходы", "потрачено", "денег",
"spend", "spent", "expenses", "earned", "income",
}
// ParseMoneyQuery reports whether an utterance asks about spending or income,
@@ -30,11 +47,14 @@ var moneyNouns = []string{
//
// Narrow on purpose. A money noun alone is not enough — "я потратил весь день
// на это" is him talking about his day, so an amount word or an explicit
// question word has to be there too.
func ParseMoneyQuery(text string) (MoneyWindow, bool) {
// question word has to be there too. The two evidence halves are INDEPENDENT:
// "траты" and "расходы" used to sit in both lists, so either word alone
// satisfied the whole gate and "у меня в этом месяце большие траты", a
// statement, came back with a figure.
func ParseMoneyQuery(text string) (MoneyQuery, bool) {
toks := planTokens(text)
if len(toks) == 0 {
return MoneyNone, false
return MoneyQuery{}, false
}
hasNoun := false
for _, t := range toks {
@@ -45,27 +65,40 @@ func ParseMoneyQuery(text string) (MoneyWindow, bool) {
}
}
if !hasNoun {
return MoneyNone, false
return MoneyQuery{}, false
}
// "весь день", "время", "силы" — spending that is not money.
for _, t := range toks {
switch t {
case "день", "дня", "время", "времени", "силы", "сил", "нервы":
return MoneyNone, false
return MoneyQuery{}, false
}
}
asking := hasTok(toks, "сколько") || hasTok(toks, "какие") || hasTok(toks, "покажи") ||
hasTok(toks, "how") || hasTok(toks, "much") || hasTok(toks, "my") ||
hasTok(toks, "мои") || hasTok(toks, "траты") || hasTok(toks, "расходы")
hasTok(toks, "мои")
if !asking {
return MoneyNone, false
return MoneyQuery{}, false
}
income := false
for _, t := range toks {
for _, n := range incomeNouns {
if t == n {
income = true
}
}
}
lower := strings.ToLower(text)
switch {
// Windows nothing is stored for, named explicitly so they are refused
// rather than silently answered with the month.
case hasTok(toks, "вчера") || hasTok(toks, "позавчера") || strings.Contains(lower, "yesterday"),
hasTok(toks, "неделю") || hasTok(toks, "неделе") || hasTok(toks, "неделя") ||
strings.Contains(lower, "week"),
hasTok(toks, "год") || hasTok(toks, "году") || strings.Contains(lower, "year"):
return MoneyQuery{Window: MoneyUnsupported, Income: income}, true
case hasTok(toks, "сегодня") || strings.Contains(lower, "today"):
return MoneyToday, true
case hasTok(toks, "месяц") || hasTok(toks, "месяце") || strings.Contains(lower, "month"):
return MoneyMonth, true
return MoneyQuery{Window: MoneyToday, Income: income}, true
}
return MoneyMonth, true
return MoneyQuery{Window: MoneyMonth, Income: income}, true
}
+11 -3
View File
@@ -15,17 +15,25 @@ func TestParseMoneyQuery(t *testing.T) {
{"какие у меня расходы за месяц", MoneyMonth, true},
{"how much did I spend today", MoneyToday, true},
{"сколько я заработал в этом месяце", MoneyMonth, true},
// Windows nothing is stored for are claimed and refused, never answered
// with the month-to-date figure.
{"сколько я потратил вчера?", MoneyUnsupported, true},
{"сколько я потратил на прошлой неделе?", MoneyUnsupported, true},
{"how much did I spend yesterday", MoneyUnsupported, true},
// Not about money.
{"я потратил весь день на это", MoneyNone, false},
// A statement, not a question: the noun and the ask must be independent
// evidence, and "траты" used to satisfy both halves on its own.
{"у меня в этом месяце большие траты", MoneyNone, false},
{"потратил много сил", MoneyNone, false},
{"какая погода?", MoneyNone, false},
{"я купил молоко", MoneyNone, false},
{"", MoneyNone, false},
}
for _, c := range cases {
w, ok := ParseMoneyQuery(c.in)
if ok != c.ok || w != c.window {
t.Errorf("ParseMoneyQuery(%q) = (%v, %v), want (%v, %v)", c.in, w, ok, c.window, c.ok)
q, ok := ParseMoneyQuery(c.in)
if ok != c.ok || q.Window != c.window {
t.Errorf("ParseMoneyQuery(%q) = (%v, %v), want (%v, %v)", c.in, q.Window, ok, c.window, c.ok)
}
}
}