f6a8752d00
--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
205 lines
6.5 KiB
Go
205 lines
6.5 KiB
Go
// Package lexicon holds the Russian word sets that can be finished.
|
|
//
|
|
// A closed class has a fixed number of members: the language has as many
|
|
// interrogative pronouns as it has, and no utterance will ever contain a
|
|
// thirteenth month. Those sets belong in a data file, complete, and that is what
|
|
// this package is (Vikunja #522, owner's call 2026-08-04 — "not pattern, 100%").
|
|
//
|
|
// It is the first of three mechanisms that replaced hand-written Russian
|
|
// patterns, and the only one that answers with certainty. The other two are the
|
|
// embedder, for recognising an open set of phrasings, and a morphological
|
|
// dictionary, for questions about grammar. A list that can never be finished is
|
|
// a guess dressed as a rule and does not go here.
|
|
//
|
|
// What this package does NOT do is match. It hands out sets and lookups; the
|
|
// caller decides what to do with a hit, because "this token is an interrogative"
|
|
// and "this utterance is a question" are different claims.
|
|
package lexicon
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
//go:embed lexicon_ru_v1.json
|
|
var files embed.FS
|
|
|
|
// ruFile is the versioned file this package reads. A new version is a new file,
|
|
// not an edit to this one, so a caller pinned to v1 keeps the words it was
|
|
// measured against.
|
|
const ruFile = "lexicon_ru_v1.json"
|
|
|
|
type lexiconFile struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
Name string `json:"name"`
|
|
Sets map[string]lexiSet `json:"sets"`
|
|
}
|
|
|
|
type lexiSet struct {
|
|
Note string `json:"note"`
|
|
Words []string `json:"words"`
|
|
Values map[string]int `json:"values"`
|
|
}
|
|
|
|
// ru is parsed once at init. A malformed embedded file is a build-time mistake
|
|
// that survived to runtime, and there is no sane degraded behaviour for "the
|
|
// months are missing", so it panics rather than answering with an empty set.
|
|
var ru = mustLoad()
|
|
|
|
func mustLoad() lexiconFile {
|
|
data, err := files.ReadFile(ruFile)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("lexicon: read %s: %v", ruFile, err))
|
|
}
|
|
var f lexiconFile
|
|
if err := json.Unmarshal(data, &f); err != nil {
|
|
panic(fmt.Sprintf("lexicon: parse %s: %v", ruFile, err))
|
|
}
|
|
for _, name := range []string{
|
|
"interrogatives", "capture_verbs", "narrative_requests", "cardinals",
|
|
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
|
|
"not_place_after_v",
|
|
} {
|
|
s, ok := f.Sets[name]
|
|
if !ok || (len(s.Words) == 0 && len(s.Values) == 0) {
|
|
panic(fmt.Sprintf("lexicon: %s has no set %q", ruFile, name))
|
|
}
|
|
}
|
|
return f
|
|
}
|
|
|
|
// words returns a copy of a word set, so a caller cannot edit the lexicon by
|
|
// holding onto what it was given.
|
|
func words(set string) []string {
|
|
src := ru.Sets[set].Words
|
|
out := make([]string, len(src))
|
|
copy(out, src)
|
|
return out
|
|
}
|
|
|
|
// Interrogatives returns the question words, Russian and English.
|
|
func Interrogatives() []string { return words("interrogatives") }
|
|
|
|
// CaptureVerbs returns the imperatives that mean "record this".
|
|
func CaptureVerbs() []string { return words("capture_verbs") }
|
|
|
|
// NarrativeRequests returns the imperatives that mean "tell me about".
|
|
func NarrativeRequests() []string { return words("narrative_requests") }
|
|
|
|
// NotPlaceAfterV returns the words that follow "в" without naming a place.
|
|
func NotPlaceAfterV() []string { return words("not_place_after_v") }
|
|
|
|
// Cardinal reports the value of a spoken number word. The word is compared
|
|
// lowercased and trimmed, because it arrives from a tokenizer that may not have
|
|
// done either.
|
|
func Cardinal(word string) (int, bool) {
|
|
n, ok := ru.Sets["cardinals"].Values[norm(word)]
|
|
return n, ok
|
|
}
|
|
|
|
// DayOffset reports how many days a relative day word moves from today.
|
|
//
|
|
// The zero value is a real answer here — "сегодня" is offset 0 — so the second
|
|
// return is the only way to tell a hit from a miss. Callers that used to switch
|
|
// on strings.Contains had to order "послезавтра" before "завтра" by hand,
|
|
// because one contains the other; a lookup has no such trap.
|
|
func DayOffset(word string) (int, bool) {
|
|
n, ok := ru.Sets["day_offsets"].Values[norm(word)]
|
|
return n, ok
|
|
}
|
|
|
|
// DayOffsetIn finds a relative day word anywhere in a phrase and reports its
|
|
// offset. Where two words appear, the one that moves furthest from today wins in
|
|
// absolute terms: "не сегодня, а послезавтра" is about the day after tomorrow,
|
|
// and the longest-match rule that picking the first word would need is exactly
|
|
// what the old Contains switch got wrong.
|
|
func DayOffsetIn(text string) (int, bool) {
|
|
lower := norm(text)
|
|
best, found := 0, false
|
|
for word, n := range ru.Sets["day_offsets"].Values {
|
|
if !containsWord(lower, word) {
|
|
continue
|
|
}
|
|
if !found || abs(n) > abs(best) {
|
|
best, found = n, true
|
|
}
|
|
}
|
|
return best, found
|
|
}
|
|
|
|
// Weekday returns the Russian name of a weekday index, Sunday first, matching
|
|
// Go's time.Weekday. An index off the end returns "".
|
|
func Weekday(i int) string { return at("weekdays", i) }
|
|
|
|
// MonthGenitive returns the month name a date takes — "10 июля", not "июль".
|
|
// The set is 1-indexed, so MonthGenitive(int(t.Month())) is the whole call.
|
|
func MonthGenitive(m int) string { return at("months_genitive", m) }
|
|
|
|
// HourSpoken returns an hour spelled out for the voice. 0 to 23.
|
|
func HourSpoken(h int) string { return at("hours_spoken", h) }
|
|
|
|
func at(set string, i int) string {
|
|
w := ru.Sets[set].Words
|
|
if i < 0 || i >= len(w) {
|
|
return ""
|
|
}
|
|
return w[i]
|
|
}
|
|
|
|
func norm(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
|
|
|
|
func abs(n int) int {
|
|
if n < 0 {
|
|
return -n
|
|
}
|
|
return n
|
|
}
|
|
|
|
// containsWord reports whether haystack holds needle on word boundaries. Go's
|
|
// \b is ASCII-only and never fires after a Cyrillic letter, so the boundary is
|
|
// checked here instead: a rune on either side must not be a letter or a digit.
|
|
func containsWord(haystack, needle string) bool {
|
|
if needle == "" {
|
|
return false
|
|
}
|
|
from := 0
|
|
for {
|
|
i := strings.Index(haystack[from:], needle)
|
|
if i < 0 {
|
|
return false
|
|
}
|
|
i += from
|
|
if boundaryBefore(haystack, i) && boundaryAfter(haystack, i+len(needle)) {
|
|
return true
|
|
}
|
|
from = i + len(needle)
|
|
if from >= len(haystack) {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
func boundaryBefore(s string, i int) bool {
|
|
if i == 0 {
|
|
return true
|
|
}
|
|
r, _ := utf8.DecodeLastRuneInString(s[:i])
|
|
return !wordRune(r)
|
|
}
|
|
|
|
func boundaryAfter(s string, i int) bool {
|
|
if i >= len(s) {
|
|
return true
|
|
}
|
|
r, _ := utf8.DecodeRuneInString(s[i:])
|
|
return !wordRune(r)
|
|
}
|
|
|
|
func wordRune(r rune) bool {
|
|
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
|
|
}
|