Merge branch 'worktree-agent-a9e5cef90b263a5e5' into integration/small-batch

This commit is contained in:
kami
2026-07-31 23:27:36 +04:00
4 changed files with 315 additions and 285 deletions
+180
View File
@@ -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")
}
}
+85
View File
@@ -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)
}
-285
View File
@@ -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
+50
View File
@@ -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"
}