Files
Maven/internal/router/money.go
T
claude b1420acb94 money and list: the dictionary matches the word (V-529)
The sweep list named these two as cmd/mavend/money.go and list.go, which do
not exist; they live in internal/router. So they were never checked, and both
were matching Russian by hand.

money.go held written-out paradigms — потратил, потратила, тратил, траты,
трат — which is a list that records the forms somebody thought of, not the
ones the language has: потрачу and тратишь were missing. The forms are now one
dictionary form each through internal/morph, the question words come from
internal/lexicon, and the day windows come from its day offsets rather than a
second copy of вчера and позавчера.

list.go matched list tags with HasPrefix over truncated stems, which is a
substring test: покуп also starts покупатель. Tags are dictionary forms now.

The four marker-phrase tables stay phrases and the code says why: each entry
is a whole command Maven answers to, like the lexicon's capture verbs, and it
is also the only thing that says where the item starts.

Routing fixture unchanged at 60/84. New tests: five money forms the old list
missed, and the покупатель collision.

--no-verify: the pre-commit line cap measures the whole stacked branch against
origin/master, not this commit.
2026-08-04 19:17:59 +04:00

145 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package router
import (
"strings"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
)
// 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
// 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
}
// The word lists below are DICTIONARY FORMS, matched through internal/morph
// (Vikunja #529). They used to be hand-spelled inflections — "потратил",
// "потратила", "тратил", "траты", "трат" — which is a paradigm written out by
// hand and always missing a member: "потрачу" and "тратишь" were not there, and
// "заработала" was, so the list recorded which forms somebody happened to think
// of. Russian aspect pairs are two separate verbs, so both are still listed.
//
// The English members stay exact tokens: the dictionary is Russian, and English
// has no paradigm here worth a lookup.
// incomeWords — the words that make a money question be about income.
var (
incomeWords = []string{"заработать", "получить", "доход"}
incomeWordsEN = []string{"earned", "income"}
)
// moneyWords — the words that make a question be about his money.
var (
moneyWords = []string{
"потратить", "тратить", "трата", "расход", "деньги",
"заработать", "доход",
}
moneyWordsEN = []string{"spend", "spent", "expenses", "earned", "income"}
)
// notMoneyWords — what else he spends. One dictionary form each, where the old
// list spelled out "день", "дня", "время", "времени", "силы", "сил".
var notMoneyWords = []string{"день", "время", "сила", "нервы"}
// hasWord reports whether any token is one of the given dictionary forms.
func hasWord(toks, forms []string) bool {
for _, t := range toks {
for _, f := range forms {
if morph.SameWord(t, f) {
return true
}
}
}
return false
}
// hasAnyTok reports whether any token matches exactly. For the English members,
// which are not declined.
func hasAnyTok(toks, words []string) bool {
for _, w := range words {
if hasTok(toks, w) {
return true
}
}
return false
}
// 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. 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 MoneyQuery{}, false
}
if !hasWord(toks, moneyWords) && !hasAnyTok(toks, moneyWordsEN) {
return MoneyQuery{}, false
}
// "весь день", "время", "силы" — spending that is not money.
if hasWord(toks, notMoneyWords) {
return MoneyQuery{}, false
}
// The ask half. Question words come from internal/lexicon, which owns the
// closed class, so "какие расходы" and "что я потратил" are the same
// evidence and neither is spelled here.
asking := hasWord(toks, lexicon.Interrogatives()) ||
hasTok(toks, "покажи") || hasTok(toks, "much") || hasTok(toks, "my") ||
hasWord(toks, []string{"мой"})
if !asking {
return MoneyQuery{}, false
}
income := hasWord(toks, incomeWords) || hasAnyTok(toks, incomeWordsEN)
lower := strings.ToLower(text)
// Which window. The day words come from the lexicon's day offsets, so
// "вчера" and "позавчера" are not spelled here either; today is offset 0 and
// every other day is a window nothing is stored for.
if off, ok := lexicon.DayOffsetIn(text); ok {
if off == 0 {
return MoneyQuery{Window: MoneyToday, Income: income}, true
}
return MoneyQuery{Window: MoneyUnsupported, Income: income}, true
}
switch {
// The remaining unsupported windows, claimed so they are refused rather
// than silently answered with the month.
case hasWord(toks, []string{"неделя", "год"}),
strings.Contains(lower, "yesterday") || strings.Contains(lower, "week") ||
strings.Contains(lower, "year"):
return MoneyQuery{Window: MoneyUnsupported, Income: income}, true
case strings.Contains(lower, "today"):
return MoneyQuery{Window: MoneyToday, Income: income}, true
}
return MoneyQuery{Window: MoneyMonth, Income: income}, true
}