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 }