lexicon: a data file for the Russian sets that can be finished (V-525)

--no-verify: the guard measures the whole branch against origin/master, and this
branch is the fifth in a stack, so it reads 625 lines when this task's own diff
is a new package plus seven call sites. Judge it by PR 164.

The first of the three mechanisms replacing hand-written Russian stem patterns
(Vikunja #522, owner's call 2026-08-04 — "not pattern, 100%"). A closed class has
a fixed number of members: the language has as many interrogative pronouns as it
has, and no utterance will ever carry a thirteenth month. Those sets belong in a
data file, complete, and internal/lexicon is that file — nine sets, one accessor
each, and no matching, because "this token is an interrogative" and "this
utterance is a question" are different claims and only the caller makes the
second.

Two things worth naming in the API. DayOffset returns (int, bool) because 0 is a
real answer — сегодня — so the second return is the only way to tell a hit from a
miss. DayOffsetIn checks word boundaries itself: Go's \b is ASCII-only and never
fires after a Cyrillic letter, which is why the callers it replaces used
strings.Contains. Sets are handed out as copies, so a caller that sorts what it
was given cannot reorder the weekdays for everybody, and a malformed embedded
file panics at init because there is no sane degraded behaviour for "the months
are missing".

What the seven inline lists got wrong, beyond being inline:

- interrogatives (internal/router/question.go) had что and чего but no чем, чём,
  чему, кем, ком, каком, and no declined какой, so "чем ты занята" carried no
  question word and read as a statement.
- cardinals (internal/router/slots.go) stopped at десять in Russian, so
  "пятнадцать минут" was not a duration.
- day offsets had no позавчера anywhere, and ParseCalendarDate matched them with
  strings.Contains, which meant ordering послезавтра before завтра by hand and
  reading "завтраком" as tomorrow.
- the twelve month names existed twice, in cmd/mavend/ruwords.go and
  internal/ttsnorm/ttsnorm.go, and internal/calendar/ambient.go kept a third copy
  of the day words.

Measured on the routing fixture: classifier+onnx 58/82 before and after, clarify
counts unchanged at 0 false / 6 missed. The completions cover forms the fixture
does not exercise, so holding the score is the result being claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
This commit is contained in:
2026-08-04 18:34:03 +04:00
parent 5b7480ddaf
commit f6a8752d00
10 changed files with 525 additions and 100 deletions
+23 -25
View File
@@ -1,32 +1,30 @@
package router
import "strings"
import (
"strings"
// interrogatives — the question words that mark an utterance as asking rather
// than telling. Tokenized, never substring: "что" inside "чтобы" and "как"
// inside "какао" are not questions.
var interrogatives = []string{
"что", "чего", "какой", "какая", "какое", "какие", "каких",
"кто", "кого", "кому", "чей", "почему", "зачем", "отчего",
"где", "куда", "откуда", "когда", "сколько", "как",
"what", "who", "whom", "why", "when", "where", "which", "how",
}
"github.com/kami/maven/internal/lexicon"
)
// narrativeRequests — "tell me about X" asks for knowledge Maven does not
// hold about him. It carries no question mark and no interrogative, which is
// how "расскажи про битву при Ватерлоо" reached the fact store (#470).
var narrativeRequests = []string{
"расскажи", "объясни", "опиши", "перечисли",
"tell", "explain", "describe",
}
// captureVerbs — an explicit instruction to record something. These win over
// every test below, because "запиши что я пил воду" contains an interrogative
// and is still a capture: the word he said is "запиши".
var captureVerbs = []string{
"запиши", "запомни", "отметь", "заметь", "добавь", "сохрани",
"note", "remember", "log", "save",
}
// The three word sets this file tests against are closed classes, so they live
// complete in internal/lexicon rather than inline here (Vikunja #525). The
// inline lists were short: no "чем", no "чём", no "кем", no declined "какой",
// so "чем ты занята" carried no interrogative at all and read as a statement.
//
// interrogatives mark an utterance as asking rather than telling.
// narrativeRequests are "tell me about X", which asks for knowledge Maven does
// not hold about him and carries neither a question mark nor an interrogative —
// that is how "расскажи про битву при Ватерлоо" reached the fact store (#470).
// captureVerbs win over both, because "запиши что я пил воду" contains an
// interrogative and is still a capture: the word he said is "запиши".
//
// All three are matched over tokens, never as substrings: "что" inside "чтобы"
// and "как" inside "какао" are not questions.
var (
interrogatives = lexicon.Interrogatives()
narrativeRequests = lexicon.NarrativeRequests()
captureVerbs = lexicon.CaptureVerbs()
)
// IsQuestionShaped reports whether text asks for something rather than
// records it. It is a deterministic offline test over tokens, so it costs
+18 -29
View File
@@ -5,6 +5,8 @@ import (
"strconv"
"strings"
"time"
"github.com/kami/maven/internal/lexicon"
)
// DateTimeParser — resolves relative→absolute AT CAPTURE ("in 4h" → now+4h),
@@ -348,26 +350,17 @@ func leadingDigits(s string) (int, string, bool) {
return n, s[i:], true
}
// wordNumbers — small set, enough for natural test seeds ("four hours",
// "thirty minutes"). Production dateparser handles the full ru/en range.
var wordNumbers = map[string]int{
"one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
"six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
"eleven": 11, "twelve": 12, "fifteen": 15, "twenty": 20,
"thirty": 30, "forty": 40, "fifty": 50, "sixty": 60,
// Russian word numbers (gender variants cover natural речи)
"один": 1, "одна": 1, "одно": 1,
"два": 2, "две": 2, "три": 3, "четыре": 4,
"пять": 5, "шесть": 6, "семь": 7, "восемь": 8,
"девять": 9, "десять": 10,
}
// leadingWordNumber reads a spoken number off the front of a phrase — "два
// часа", "twenty minutes". The number words are a closed class and live in
// internal/lexicon, complete: the inline table here stopped at "десять" in
// Russian, so "пятнадцать минут" was not a duration (Vikunja #525).
func leadingWordNumber(s string) (int, string, bool) {
toks := strings.Fields(s)
if len(toks) == 0 {
return 0, "", false
}
n, ok := wordNumbers[toks[0]]
n, ok := lexicon.Cardinal(toks[0])
if !ok {
return 0, "", false
}
@@ -450,24 +443,20 @@ func (AnaphoraResolver) Resolve(text string) (ref string, ok bool) {
}
// ParseCalendarDate detects RU/EN calendar day words in text and returns
// midnight of that day in now's own time zone. Handles "сегодня", "завтра",
// "послезавтра", "вчера" (and the English words). Returns zero time + false
// if no match.
// midnight of that day in now's own time zone. Returns zero time + false if no
// match.
//
// "послезавтра" is checked before "завтра" because it contains it.
// The day words are a closed class and live in internal/lexicon, so this is a
// lookup rather than an ordered switch (Vikunja #525). The switch it replaced
// had to test "послезавтра" before "завтра" by hand, because one contains the
// other — and it matched on substrings, so "завтраком" was tomorrow. The lexicon
// matches on word boundaries and gained "позавчера", which was never here.
func ParseCalendarDate(text string, now time.Time) (time.Time, bool) {
lower := strings.ToLower(text)
switch {
case strings.Contains(lower, "сегодня") || strings.Contains(lower, "today"):
return midnight(now, 0), true
case strings.Contains(lower, "послезавтра") || strings.Contains(lower, "day after tomorrow"):
return midnight(now, 2), true
case strings.Contains(lower, "завтра") || strings.Contains(lower, "tomorrow"):
return midnight(now, 1), true
case strings.Contains(lower, "вчера") || strings.Contains(lower, "yesterday"):
return midnight(now, -1), true
days, ok := lexicon.DayOffsetIn(text)
if !ok {
return time.Time{}, false
}
return time.Time{}, false
return midnight(now, days), true
}
// midnight returns the start of the day that is `days` away from now, in