Read spending from zenmoney in the poller, answer it from facts (#125)
The trust boundary is zenmoney, not maven — they already hold his bank sessions. So the poller reads /v8/diff/ and writes totals as facts(kind=env, source=poll:zenmoney); core reads those back when he asks and never sees the token. internal/zenmoney sums transactions per currency over a window, skipping tombstoned rows and transfers between his own accounts, and refuses to encode a summary built from zero transactions. That refusal is the whole design: a failed or empty read writes nothing and leaves the last good total alone, because a zero recited as fact is worse than silence. No currency conversion either — a figure he can check against his bank beats one he cannot. Off unless configured, and the token is read from a FILE rather than a flag so it never lands in `ps`, in docker-compose.yml, or in shell history. Nothing about the money is search input, no tick rule reads the keys, and the log lines name keys, never figures. The live-credential half is BLOCKED: there is no zenmoney account or token here, so everything is verified against a recorded diff fixture.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
package router
|
||||
|
||||
import "strings"
|
||||
|
||||
// Money questions, matched deterministically (Vikunja #125).
|
||||
//
|
||||
// No new intent, for the same reason as tasks: the intent enum is a contract
|
||||
// with the relabelling prompt. "сколько я потратил?" is a query; which figure
|
||||
// it asks for is a lookup, not something to ask a 1.7B — and a model asked to
|
||||
// invent a spending total will happily do it.
|
||||
|
||||
// MoneyWindow — which period a money question asks about.
|
||||
type MoneyWindow int
|
||||
|
||||
const (
|
||||
MoneyNone MoneyWindow = iota
|
||||
MoneyToday
|
||||
MoneyMonth
|
||||
)
|
||||
|
||||
// moneyNouns — the words that make a question be about his money.
|
||||
var moneyNouns = []string{
|
||||
"потратил", "потратила", "тратил", "траты", "трат", "расходы", "расходов",
|
||||
"заработал", "потрачено", "денег", "spend", "spent", "expenses",
|
||||
}
|
||||
|
||||
// ParseMoneyQuery reports whether an utterance asks about spending or income,
|
||||
// and over which window. Defaults to the month: "сколько я потратил?" without a
|
||||
// period is the month-to-date question, which is the one worth answering.
|
||||
//
|
||||
// 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) {
|
||||
toks := planTokens(text)
|
||||
if len(toks) == 0 {
|
||||
return MoneyNone, false
|
||||
}
|
||||
hasNoun := false
|
||||
for _, t := range toks {
|
||||
for _, n := range moneyNouns {
|
||||
if t == n {
|
||||
hasNoun = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasNoun {
|
||||
return MoneyNone, false
|
||||
}
|
||||
// "весь день", "время", "силы" — spending that is not money.
|
||||
for _, t := range toks {
|
||||
switch t {
|
||||
case "день", "дня", "время", "времени", "силы", "сил", "нервы":
|
||||
return MoneyNone, false
|
||||
}
|
||||
}
|
||||
asking := hasTok(toks, "сколько") || hasTok(toks, "какие") || hasTok(toks, "покажи") ||
|
||||
hasTok(toks, "how") || hasTok(toks, "much") || hasTok(toks, "my") ||
|
||||
hasTok(toks, "мои") || hasTok(toks, "траты") || hasTok(toks, "расходы")
|
||||
if !asking {
|
||||
return MoneyNone, false
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
switch {
|
||||
case hasTok(toks, "сегодня") || strings.Contains(lower, "today"):
|
||||
return MoneyToday, true
|
||||
case hasTok(toks, "месяц") || hasTok(toks, "месяце") || strings.Contains(lower, "month"):
|
||||
return MoneyMonth, true
|
||||
}
|
||||
return MoneyMonth, true
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseMoneyQuery(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
window MoneyWindow
|
||||
ok bool
|
||||
}{
|
||||
{"сколько я потратил сегодня?", MoneyToday, true},
|
||||
{"сколько я потратил в этом месяце?", MoneyMonth, true},
|
||||
{"сколько я потратил?", MoneyMonth, true}, // month-to-date by default
|
||||
{"покажи мои траты", MoneyMonth, true},
|
||||
{"какие у меня расходы за месяц", MoneyMonth, true},
|
||||
{"how much did I spend today", MoneyToday, true},
|
||||
{"сколько я заработал в этом месяце", MoneyMonth, true},
|
||||
// Not about money.
|
||||
{"я потратил весь день на это", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user