From a906f2afad54ce5013323b3bf4c0b9f04ac00fdc Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 23:27:08 +0400 Subject: [PATCH] Extract the pure RU/string/weather helpers out of voice.go --- cmd/mavend/ruwords.go | 180 ++++++++++++++++++++++++++ cmd/mavend/strutil.go | 85 ++++++++++++ cmd/mavend/voice.go | 285 ----------------------------------------- cmd/mavend/weatherq.go | 50 ++++++++ 4 files changed, 315 insertions(+), 285 deletions(-) create mode 100644 cmd/mavend/ruwords.go create mode 100644 cmd/mavend/strutil.go create mode 100644 cmd/mavend/weatherq.go diff --git a/cmd/mavend/ruwords.go b/cmd/mavend/ruwords.go new file mode 100644 index 0000000..f14513f --- /dev/null +++ b/cmd/mavend/ruwords.go @@ -0,0 +1,180 @@ +// Package main — ruwords.go holds Russian language + calendar/time formatting +// helpers used by the voice reply paths (replySystem, the reminder/routine +// phrasing, etc). Pure functions, no receivers: weekday/month name tables, +// plural agreement, clock/date rendering, and the "do I actually know this +// place/day" guards that pick an honest reply over a confidently wrong one. +// Extend this file rather than voice.go for anything in that shape. +package main + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +var ruWeekdays = []string{ + "воскресенье", "понедельник", "вторник", "среда", + "четверг", "пятница", "суббота", +} + +var ruMonths = []string{ + "января", "февраля", "марта", "апреля", "мая", "июня", + "июля", "августа", "сентября", "октября", "ноября", "декабря", +} + +// onlyLocalTimeReply — the honest answer when the user asks the time somewhere +// other than here. She only keeps one clock, and saying so is better than +// naming the wrong city's time. +// +// There used to be a city→time-zone table here. It was removed on purpose: the +// user only ever asks for local time, so the table was a second list of cities +// to keep in step with the weather one for no gain. +const onlyLocalTimeReply = "я знаю только местное время, про другие города пока не скажу." + +// notPlaceAfterV — words that follow "в" without naming a place, so +// mentionsUnknownPlace does not mistake them for a city. +var notPlaceAfterV = map[string]bool{ + "данный": true, "данную": true, "этот": true, "эту": true, + "котором": true, "какое": true, "какой": true, "который": true, + "общем": true, "точности": true, "курсе": true, "сутках": true, + "часах": true, "минутах": true, "секундах": true, "неделе": true, +} + +// mentionsUnknownPlace reports whether the question has a "в <слово>" phrase +// that looks like a place we do not know ("который час в киеве"). Used only to +// pick the honest "local time only" reply instead of answering local time as +// if it were the city's. +func mentionsUnknownPlace(u string) bool { + toks := strings.Fields(u) + for i := 0; i+1 < len(toks); i++ { + if toks[i] != "в" && toks[i] != "во" { + continue + } + next := strings.Trim(toks[i+1], ".,?!") + if next == "" || notPlaceAfterV[next] { + continue + } + // A number after "в" is a clock ("в 5 часов"), not a place. + if _, err := strconv.Atoi(strings.SplitN(next, ":", 2)[0]); err == nil { + continue + } + return true + } + return false +} + +// onlyNearDaysReply — she can work out today, tomorrow, the day after and +// yesterday, and nothing further. Said out loud instead of answering today's +// date for a day she did not understand. +const onlyNearDaysReply = "я считаю только сегодня, завтра, послезавтра и вчера — про другие дни пока не скажу." + +// dayWords — day references the calendar parser cannot resolve. A weekday name +// or a "через …" phrase means he asked about a specific other day. +var dayWords = []string{ + "понедельник", "вторник", "сред", "четверг", "пятниц", "суббот", "воскресен", + "через", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", +} + +// mentionsUnknownDay reports whether the question names a day the calendar +// parser could not resolve. Mirror of mentionsUnknownPlace: it exists only to +// pick an honest reply over a confidently wrong one. +// +// Only called after ParseCalendarDate has already failed, so "завтра" and the +// other words it does know never reach here. +func mentionsUnknownDay(u string) bool { + for _, w := range dayWords { + if strings.Contains(u, w) { + return true + } + } + return false +} + +// ruClock renders the clock part of the time reply: "15 часов 4 минуты". +func ruClock(t time.Time) string { + h, m := t.Hour(), t.Minute() + hourWord := ruPlural(h, "час", "часа", "часов") + if m == 0 { + return fmt.Sprintf("%d %s ровно", h, hourWord) + } + return fmt.Sprintf("%d %s %d %s", h, hourWord, m, ruPlural(m, "минута", "минуты", "минут")) +} + +// dayPrefix names the day relative to now ("завтра", "вчера", …) so the date +// reply opens the way a person would say it. +func dayPrefix(now, day time.Time) string { + base := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + switch int(day.Sub(base).Hours() / 24) { + case -1: + return "вчера" + case 0: + return "сегодня" + case 1: + return "завтра" + case 2: + return "послезавтра" + } + return "это" +} + +func ruPlural(n int, one, two, many string) string { + n = n % 100 + if n > 10 && n < 20 { + return many + } + n = n % 10 + switch n { + case 1: + return one + case 2, 3, 4: + return two + default: + return many + } +} + +// hasDurationWords checks whether u is asking about elapsed/remaining time +// rather than the current clock — guards replySystem from replying "сейчас +// X часов" to "сколько времени прошло". Mirrors the stage0.go build filter. +func hasDurationWords(u string) bool { + s := strings.ToLower(strings.TrimSpace(u)) + // First-word duration markers (same keywords as timeQueryBuild in stage0). + first := strings.Fields(s) + if len(first) > 0 { + switch first[0] { + case "прошло", "осталось", "пройдет", "минуло", "проходит": + return true + } + } + // Broader duration keywords appearing anywhere in the utterance. + if strings.Contains(s, "прошло") || strings.Contains(s, "осталось") { + return true + } + if strings.Contains(s, " до ") { + return true + } + return false +} + +// formatTime returns a human-readable Russian time string for a fact timestamp. +// Used by the query handler when answering "когда я это сделал?"-style questions. +func formatTime(t time.Time) string { + now := time.Now() + if t.After(now.Add(-2*time.Minute)) && t.Before(now.Add(2*time.Minute)) { + return "только что" + } + diff := now.Sub(t) + switch { + case diff < 10*time.Minute: + return "несколько минут назад" + case diff < 60*time.Minute: + return fmt.Sprintf("%d минут назад", int(diff.Minutes())) + case diff < 2*time.Hour: + return "час назад" + case diff < 24*time.Hour: + return fmt.Sprintf("%d часа назад", int(diff.Hours())) + default: + return t.Format("2 января 15:04") + } +} diff --git a/cmd/mavend/strutil.go b/cmd/mavend/strutil.go new file mode 100644 index 0000000..714a152 --- /dev/null +++ b/cmd/mavend/strutil.go @@ -0,0 +1,85 @@ +// Package main — strutil.go holds small, receiver-free string utilities used +// across the voice reply paths: trimming a wake token, pulling out the first +// word or first line, and a minimal JSON string encoder for the one payload +// shape that needs it. Extend this file rather than voice.go for anything in +// that shape. +package main + +import ( + "fmt" + "strings" + + "github.com/kami/maven/internal/router" +) + +// stripWake removes a leading wake token (any script the STT phonetically +// transcribes "Maven" as) so the verb is the first word. +func stripWake(u string) string { + stripped, had := router.StripWakeToken(u) + if !had { + return strings.TrimSpace(u) + } + return stripped +} + +// firstWord returns the first whitespace-delimited token (lowercased) — the +// proposed tool's name. +func firstWord(s string) string { + f := strings.Fields(s) + if len(f) == 0 { + return "" + } + return strings.ToLower(f[0]) +} + +// firstLine — the first non-empty line of a tool's output, for a short spoken +// reply (the full output goes to the log, not the TTS). Trimmed to keep the +// utterance sane if a command dumps a wall of text. +func firstLine(s string) string { + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if line != "" { + if len(line) > 200 { + line = line[:200] + } + return line + } + } + return "" +} + +// jsonString — a one-line JSON string encoder without dragging encoding/json +// into the top of this file. Used to wrap a reminder payload's text field; +// the router's reminder Slots are already absolute (DateTimeParser resolved +// relative→absolute), the payload shape is conventional {"text":...}. +func jsonString(s string) string { + // minimal JSON string escape — quotes + backslash + control chars. + // adequate for the reminder payload's text field; not a general JSON + // encoder. The chroma / RAG modules (when they land) use a real json + // encoder for richer payloads. Keep it inline here so the import + // direction stays narrow. + var b []byte + b = append(b, '"') + for _, r := range s { + switch r { + case '"': + b = append(b, '\\', '"') + case '\\': + b = append(b, '\\', '\\') + case '\n': + b = append(b, '\\', 'n') + case '\r': + b = append(b, '\\', 'r') + case '\t': + b = append(b, '\\', 't') + default: + if r < 0x20 { + b = append(b, []byte(fmt.Sprintf("\\u%04x", r))...) + } else { + b = append(b, []byte(string(r))...) + } + } + } + b = append(b, '"') + return string(b) +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 8e59acc..e2e7381 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -905,127 +905,6 @@ func (h *reactiveHandler) detectPattern(ctx context.Context, factID int64, key, return phrase } -var ruWeekdays = []string{ - "воскресенье", "понедельник", "вторник", "среда", - "четверг", "пятница", "суббота", -} - -var ruMonths = []string{ - "января", "февраля", "марта", "апреля", "мая", "июня", - "июля", "августа", "сентября", "октября", "ноября", "декабря", -} - -// onlyLocalTimeReply — the honest answer when the user asks the time somewhere -// other than here. She only keeps one clock, and saying so is better than -// naming the wrong city's time. -// -// There used to be a city→time-zone table here. It was removed on purpose: the -// user only ever asks for local time, so the table was a second list of cities -// to keep in step with the weather one for no gain. -const onlyLocalTimeReply = "я знаю только местное время, про другие города пока не скажу." - -// notPlaceAfterV — words that follow "в" without naming a place, so -// mentionsUnknownPlace does not mistake them for a city. -var notPlaceAfterV = map[string]bool{ - "данный": true, "данную": true, "этот": true, "эту": true, - "котором": true, "какое": true, "какой": true, "который": true, - "общем": true, "точности": true, "курсе": true, "сутках": true, - "часах": true, "минутах": true, "секундах": true, "неделе": true, -} - -// mentionsUnknownPlace reports whether the question has a "в <слово>" phrase -// that looks like a place we do not know ("который час в киеве"). Used only to -// pick the honest "local time only" reply instead of answering local time as -// if it were the city's. -func mentionsUnknownPlace(u string) bool { - toks := strings.Fields(u) - for i := 0; i+1 < len(toks); i++ { - if toks[i] != "в" && toks[i] != "во" { - continue - } - next := strings.Trim(toks[i+1], ".,?!") - if next == "" || notPlaceAfterV[next] { - continue - } - // A number after "в" is a clock ("в 5 часов"), not a place. - if _, err := strconv.Atoi(strings.SplitN(next, ":", 2)[0]); err == nil { - continue - } - return true - } - return false -} - -// onlyNearDaysReply — she can work out today, tomorrow, the day after and -// yesterday, and nothing further. Said out loud instead of answering today's -// date for a day she did not understand. -const onlyNearDaysReply = "я считаю только сегодня, завтра, послезавтра и вчера — про другие дни пока не скажу." - -// dayWords — day references the calendar parser cannot resolve. A weekday name -// or a "через …" phrase means he asked about a specific other day. -var dayWords = []string{ - "понедельник", "вторник", "сред", "четверг", "пятниц", "суббот", "воскресен", - "через", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", -} - -// mentionsUnknownDay reports whether the question names a day the calendar -// parser could not resolve. Mirror of mentionsUnknownPlace: it exists only to -// pick an honest reply over a confidently wrong one. -// -// Only called after ParseCalendarDate has already failed, so "завтра" and the -// other words it does know never reach here. -func mentionsUnknownDay(u string) bool { - for _, w := range dayWords { - if strings.Contains(u, w) { - return true - } - } - return false -} - -// ruClock renders the clock part of the time reply: "15 часов 4 минуты". -func ruClock(t time.Time) string { - h, m := t.Hour(), t.Minute() - hourWord := ruPlural(h, "час", "часа", "часов") - if m == 0 { - return fmt.Sprintf("%d %s ровно", h, hourWord) - } - return fmt.Sprintf("%d %s %d %s", h, hourWord, m, ruPlural(m, "минута", "минуты", "минут")) -} - -// dayPrefix names the day relative to now ("завтра", "вчера", …) so the date -// reply opens the way a person would say it. -func dayPrefix(now, day time.Time) string { - base := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - switch int(day.Sub(base).Hours() / 24) { - case -1: - return "вчера" - case 0: - return "сегодня" - case 1: - return "завтра" - case 2: - return "послезавтра" - } - return "это" -} - -func ruPlural(n int, one, two, many string) string { - n = n % 100 - if n > 10 && n < 20 { - return many - } - n = n % 10 - switch n { - case 1: - return one - case 2, 3, 4: - return two - default: - return many - } -} - // resolveQuietToggle — pre-route keyword check. Returns (reply, true) when // the utterance is a quiet-on/off command; ("", false) otherwise. Called from // HandlePushToTalk BEFORE the router so a classifier miscue can't drop it. @@ -1144,29 +1023,6 @@ func (h *reactiveHandler) chatHistory() []dialogue.Turn { return out } -// hasDurationWords checks whether u is asking about elapsed/remaining time -// rather than the current clock — guards replySystem from replying "сейчас -// X часов" to "сколько времени прошло". Mirrors the stage0.go build filter. -func hasDurationWords(u string) bool { - s := strings.ToLower(strings.TrimSpace(u)) - // First-word duration markers (same keywords as timeQueryBuild in stage0). - first := strings.Fields(s) - if len(first) > 0 { - switch first[0] { - case "прошло", "осталось", "пройдет", "минуло", "проходит": - return true - } - } - // Broader duration keywords appearing anywhere in the utterance. - if strings.Contains(s, "прошло") || strings.Contains(s, "осталось") { - return true - } - if strings.Contains(s, " до ") { - return true - } - return false -} - // reply wraps a text reply through TTS to produce a PushToTalkResp. If TTS // fails, the response carries an empty audio + the text — the client can // still display text if it can't play. The routedChannels field is @@ -1532,14 +1388,6 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI return "команда выполнена для " + displayName + "." } -// jsonString — a one-line JSON string encoder without dragging encoding/json -// into the top of this file. Used to wrap a reminder payload's text field; -// the router's reminder Slots are already absolute (DateTimeParser resolved -// relative→absolute), the payload shape is conventional {"text":...}. -func jsonString(s string) string { - return jsonStringImpl(s) -} - // park stores a destructive act awaiting confirmation. Overwrites any prior // pending (last-asked wins — single-user box). func (h *reactiveHandler) park(fn string, args []string, phrase string) { @@ -1699,26 +1547,6 @@ func actPhrase(fn string, args []string) string { return fn + " " + strings.Join(args, " ") } -// stripWake removes a leading wake token (any script the STT phonetically -// transcribes "Maven" as) so the verb is the first word. -func stripWake(u string) string { - stripped, had := router.StripWakeToken(u) - if !had { - return strings.TrimSpace(u) - } - return stripped -} - -// firstWord returns the first whitespace-delimited token (lowercased) — the -// proposed tool's name. -func firstWord(s string) string { - f := strings.Fields(s) - if len(f) == 0 { - return "" - } - return strings.ToLower(f[0]) -} - // seedTools upserts the config-declared tools into the store as enabled. Editing // mavend.json is a human act, so a config tool is enabled by definition; this // makes the declarative config the reproducible bootstrap while the store stays @@ -1741,119 +1569,6 @@ func seedTools(api ipc.CoreAPI, tools []config.ToolConfig) { log.Printf("voice: seeded %d act tools from config", n) } -// firstLine — the first non-empty line of a tool's output, for a short spoken -// reply (the full output goes to the log, not the TTS). Trimmed to keep the -// utterance sane if a command dumps a wall of text. -func firstLine(s string) string { - for _, line := range strings.Split(s, "\n") { - line = strings.TrimSpace(line) - if line != "" { - if len(line) > 200 { - line = line[:200] - } - return line - } - } - return "" -} - -// isWeatherQuery returns true if the utterance is about weather. -func isWeatherQuery(u string) bool { - lower := strings.ToLower(u) - return strings.Contains(lower, "погод") || - strings.Contains(lower, "градус") || - strings.Contains(lower, "температур") || - strings.Contains(lower, "дожд") || - strings.Contains(lower, "холод") || - strings.Contains(lower, "тепл") || - strings.Contains(lower, "weather") || - strings.Contains(lower, "temperature") -} - -// extractWeatherLocation parses a location from the utterance, or falls back -// to the configured default. Very basic: just checks for known city names. -func extractWeatherLocation(u, defaultLoc string) string { - lower := strings.ToLower(u) - cities := map[string]string{ - "москв": "Moscow", - "moscow": "Moscow", - "питер": "Saint Petersburg", - "spb": "Saint Petersburg", - "петербур": "Saint Petersburg", - "лондон": "London", - "london": "London", - "париж": "Paris", - "paris": "Paris", - "берлин": "Berlin", - "berlin": "Berlin", - "нью-йорк": "New York", - "new york": "New York", - } - for substr, name := range cities { - if strings.Contains(lower, substr) { - return name - } - } - if defaultLoc != "" { - return defaultLoc - } - return "Moscow" -} - -// formatTime returns a human-readable Russian time string for a fact timestamp. -// Used by the query handler when answering "когда я это сделал?"-style questions. -func formatTime(t time.Time) string { - now := time.Now() - if t.After(now.Add(-2*time.Minute)) && t.Before(now.Add(2*time.Minute)) { - return "только что" - } - diff := now.Sub(t) - switch { - case diff < 10*time.Minute: - return "несколько минут назад" - case diff < 60*time.Minute: - return fmt.Sprintf("%d минут назад", int(diff.Minutes())) - case diff < 2*time.Hour: - return "час назад" - case diff < 24*time.Hour: - return fmt.Sprintf("%d часа назад", int(diff.Hours())) - default: - return t.Format("2 января 15:04") - } -} - -func jsonStringImpl(s string) string { - // minimal JSON string escape — quotes + backslash + control chars. - // adequate for the reminder payload's text field; not a general JSON - // encoder. The chroma / RAG modules (when they land) use a real json - // encoder for richer payloads. Keep it inline here so the import - // direction stays narrow. - var b []byte - b = append(b, '"') - for _, r := range s { - switch r { - case '"': - b = append(b, '\\', '"') - case '\\': - b = append(b, '\\', '\\') - case '\n': - b = append(b, '\\', 'n') - case '\r': - b = append(b, '\\', 'r') - case '\t': - b = append(b, '\\', 't') - default: - if r < 0x20 { - b = append(b, []byte(fmt.Sprintf("\\u%04x", r))...) - } else { - b = append(b, []byte(string(r))...) - } - } - } - b = append(b, '"') - return string(b) -} - // reembedOnStart is the -reembed flag (set in run()). Opt-in on purpose: see // runReembed. var reembedOnStart bool diff --git a/cmd/mavend/weatherq.go b/cmd/mavend/weatherq.go new file mode 100644 index 0000000..c844f8d --- /dev/null +++ b/cmd/mavend/weatherq.go @@ -0,0 +1,50 @@ +// Package main — weatherq.go holds the weather-query keyword helpers: does +// this utterance ask about weather at all, and which city (if any) did it +// name. Both are plain substring/lookup matching, not NLU — extend this file +// rather than voice.go for anything in that shape. +package main + +import "strings" + +// isWeatherQuery returns true if the utterance is about weather. +func isWeatherQuery(u string) bool { + lower := strings.ToLower(u) + return strings.Contains(lower, "погод") || + strings.Contains(lower, "градус") || + strings.Contains(lower, "температур") || + strings.Contains(lower, "дожд") || + strings.Contains(lower, "холод") || + strings.Contains(lower, "тепл") || + strings.Contains(lower, "weather") || + strings.Contains(lower, "temperature") +} + +// extractWeatherLocation parses a location from the utterance, or falls back +// to the configured default. Very basic: just checks for known city names. +func extractWeatherLocation(u, defaultLoc string) string { + lower := strings.ToLower(u) + cities := map[string]string{ + "москв": "Moscow", + "moscow": "Moscow", + "питер": "Saint Petersburg", + "spb": "Saint Petersburg", + "петербур": "Saint Petersburg", + "лондон": "London", + "london": "London", + "париж": "Paris", + "paris": "Paris", + "берлин": "Berlin", + "berlin": "Berlin", + "нью-йорк": "New York", + "new york": "New York", + } + for substr, name := range cities { + if strings.Contains(lower, substr) { + return name + } + } + if defaultLoc != "" { + return defaultLoc + } + return "Moscow" +}