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
67 lines
2.0 KiB
Go
67 lines
2.0 KiB
Go
// Package ttsnorm rewrites machine-formatted dates/times/numbers into RU text
|
|
// a TTS voice speaks naturally — so "10.07.2026" is not read as "number dot
|
|
// number dot number". Pure, deterministic; runs on reply/nudge text before synth.
|
|
package ttsnorm
|
|
|
|
import (
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/kami/maven/internal/lexicon"
|
|
)
|
|
|
|
// The month names are a closed class and live in internal/lexicon, 1-indexed,
|
|
// which is also where the voice reply path reads them. There used to be a second
|
|
// copy of the twelve names in cmd/mavend/ruwords.go (Vikunja #525).
|
|
|
|
var (
|
|
reDateY = regexp.MustCompile(`\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b`)
|
|
reDate = regexp.MustCompile(`\b(\d{1,2})\.(\d{1,2})\b`)
|
|
reTime = regexp.MustCompile(`\b(\d{1,2}):(\d{2})\b`)
|
|
reDots = regexp.MustCompile(`\b\d+(?:\.\d+){2,}\b`)
|
|
)
|
|
|
|
// Speakable rewrites d.m.y, d.m, h:mm, and residual dotted-number runs.
|
|
// Order matters: dates with year first, then times, then multi-dot numbers
|
|
// (3+ parts — never valid dates), then 2-part dates.
|
|
func Speakable(s string) string {
|
|
s = reDateY.ReplaceAllStringFunc(s, func(m string) string {
|
|
p := reDateY.FindStringSubmatch(m)
|
|
return spokenDate(p[1], p[2], p[3])
|
|
})
|
|
s = reTime.ReplaceAllStringFunc(s, func(m string) string {
|
|
p := reTime.FindStringSubmatch(m)
|
|
return p[1] + " часов " + p[2] + " минут"
|
|
})
|
|
s = reDots.ReplaceAllStringFunc(s, func(m string) string {
|
|
return strings.Join(strings.Split(m, "."), " точка ")
|
|
})
|
|
s = reDate.ReplaceAllStringFunc(s, func(m string) string {
|
|
p := reDate.FindStringSubmatch(m)
|
|
return spokenDate(p[1], p[2], "")
|
|
})
|
|
return s
|
|
}
|
|
|
|
func spokenDate(dd, mm, yyyy string) string {
|
|
mi, _ := strconv.Atoi(mm)
|
|
if mi < 1 || mi > 12 {
|
|
return dd + " " + mm + gap(yyyy)
|
|
}
|
|
day := strconv.Itoa(mustInt(dd))
|
|
out := day + " " + lexicon.MonthGenitive(mi)
|
|
if yyyy != "" {
|
|
out += " " + yyyy
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mustInt(s string) int { n, _ := strconv.Atoi(s); return n }
|
|
func gap(y string) string {
|
|
if y == "" {
|
|
return ""
|
|
}
|
|
return " " + y
|
|
}
|