Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 231 additions & 10 deletions internal/provider/openaichat/fastpath.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"regexp"
"strconv"
"strings"
"time"

"github.com/yawaflua/marusya-gemini/internal/provider"
"github.com/yawaflua/marusya-gemini/internal/provider/pipeline"
Expand All @@ -33,12 +34,13 @@ import (
var (
// Go's \b is ASCII-only, so Cyrillic commands need an explicit
// separator class instead of word boundaries.
commandRE = regexp.MustCompile(`(?i)^(сделай|поставь|включи|выключи|отключи|убери|верни|убавь|прибавь|увеличь|уменьши|громче|погромче|тише|потише|громкость|следующий|следующую|предыдущий|предыдущую|пауза|паузу|продолжи|продолжай|играй|дальше|назад|стоп|next|prev|play)(?:[\s,.:;!?-]|$)`)
commandRE = regexp.MustCompile(`(?i)^(сделай|поставь|установи|включи|выключи|отключи|убери|верни|убавь|прибавь|увеличь|уменьши|громче|погромче|тише|потише|громкость|яркость|следующий|следующую|предыдущий|предыдущую|пауза|паузу|продолжи|продолжай|играй|дальше|назад|стоп|запусти|активируй|выполни|без|next|prev|play)(?:[\s,.:;!?-]|$)`)

volumeSetRE = regexp.MustCompile(`(?i)громкость\s+(?:на\s+)?(\d{1,3})`)
volumeWordRE = regexp.MustCompile(`(?i)(?:громкост|громко|звук)[а-яё]*\s+(?:на\s+|с\s+|в\s+|до\s+)?(половин[а-яё]*|максимум[а-яё]*|минимум[а-яё]*|всю|все|весь)`)
volumeUpRE = regexp.MustCompile(`(?i)(громче|погромче|прибавь|увеличь)`)
volumeDownRE = regexp.MustCompile(`(?i)(тише|потише|убавь|уменьши)`)
muteOnRE = regexp.MustCompile(`(?i)(выключи|отключи|убери)\s+(звук|громкость)`)
muteOnRE = regexp.MustCompile(`(?i)(выключи|отключи|убери)\s+(звук|громкость)|без\s+звука`)
muteOffRE = regexp.MustCompile(`(?i)(включи|верни)\s+(звук|громкость)`)

mediaNextRE = regexp.MustCompile(`(?i)((следующий|следующую|следующая)\s+(трек|песн)\w*|дальше|next)`)
Expand All @@ -51,6 +53,22 @@ var (

homeOnRE = regexp.MustCompile(`(?i)^(включи|вруби|зажги)\s+(.+)`)
homeOffRE = regexp.MustCompile(`(?i)^(выключи|отключи|погаси)\s+(.+)`)

// Relative volume: "сделай громче на 20", "тише на 10".
volumeDeltaRE = regexp.MustCompile(`(?i)(?:на|плюс|\+)\s*(\d{1,3})`)

// Date and weekday: the time skill already returns both.
dateRE = regexp.MustCompile(`(?i)(какое\s+(сегодня\s+)?число|какая\s+(сегодня\s+)?дата|какой\s+(сегодня\s+)?день(?:\s+недели)?)`)

// Brightness: "яркость в зале 50", "поставь яркость торшера 50".
brightnessPlaceRE = regexp.MustCompile(`(?i)^(?:поставь|установи|сделай|включи)?\s*яркость\s+(?:на\s+)?(\d{1,3})(?:\s*(?:%|процент\w*))?(?:\s+(?:в|на|у)\s+(.+))?$`)
brightnessDeviceRE = regexp.MustCompile(`(?i)^(?:поставь|установи|сделай|включи)?\s*яркость\s+(.+?)\s+(?:на\s+)?(\d{1,3})(?:\s*(?:%|процент\w*))?$`)

// Room first: "яркость в зале 40" ("яркость на кухне 30").
brightnessRoomRE = regexp.MustCompile(`(?i)^(?:поставь|установи|сделай|включи)?\s*яркость\s+(?:в|на|у)\s+(.+?)\s+(?:на\s+)?(\d{1,3})(?:\s*(?:%|процент\w*))?$`)

// Scenes: "включи сцену Спокойной ночи".
sceneRE = regexp.MustCompile(`(?i)^(запусти|включи|активируй|выполни)\s+сцену\s+(.+)$`)
)

var conditionsRU = map[string]string{
Expand Down Expand Up @@ -84,12 +102,7 @@ var conditionsRU = map[string]string{
// the same as for a model-issued call.
func (c *conversation) fastPath(ctx context.Context, text string, out *pipeline.Output) (message, bool) {
t := strings.ToLower(strings.TrimSpace(text))
// Speech recognition sometimes keeps the wake word in the transcript.
for _, w := range []string{"маруся", "алиса"} {
if strings.HasPrefix(t, w) {
t = strings.TrimLeft(strings.TrimPrefix(t, w), " ,.!?-")
}
}
t = stripWakeWord(t)
if t == "" {
return message{}, false
}
Expand Down Expand Up @@ -124,6 +137,19 @@ func (c *conversation) fastPath(ctx context.Context, text string, out *pipeline.
return message{Role: "assistant", Content: reply}, true
}

if dateRE.MatchString(t) {
res, fail, ok := c.callTool(ctx, "time_now", map[string]any{}, out)
if !ok {
return message{}, true
}
reply := fail
if reply == "" {
reply = dateReply(res)
}
out.Say(reply)
return message{Role: "assistant", Content: reply}, true
}

if !commandRE.MatchString(t) {
return message{}, false
}
Expand All @@ -138,17 +164,21 @@ func (c *conversation) fastPath(ctx context.Context, text string, out *pipeline.
}
call.Name, call.Args = "volume_set", map[string]any{"level": level}
reply = fmt.Sprintf("Громкость %d процентов.", level)
case volumeWordRE.MatchString(t):
level := volumeWordLevel(volumeWordRE.FindStringSubmatch(t)[1])
call.Name, call.Args = "volume_set", map[string]any{"level": level}
reply = fmt.Sprintf("Громкость %d процентов.", level)
case muteOnRE.MatchString(t):
call.Name, call.Args = "volume_mute", map[string]any{"muted": true}
reply = "Звук выключен."
case muteOffRE.MatchString(t):
call.Name, call.Args = "volume_mute", map[string]any{"muted": false}
reply = "Звук включён."
case volumeUpRE.MatchString(t):
call.Name, call.Args = "volume_adjust", map[string]any{"delta": 10}
call.Name, call.Args = "volume_adjust", map[string]any{"delta": volumeDelta(t)}
reply = "Сделала громче."
case volumeDownRE.MatchString(t):
call.Name, call.Args = "volume_adjust", map[string]any{"delta": -10}
call.Name, call.Args = "volume_adjust", map[string]any{"delta": -volumeDelta(t)}
reply = "Сделала тише."
case mediaNextRE.MatchString(t):
call.Name, call.Args = "media_control", map[string]any{"operation": "next"}
Expand All @@ -163,6 +193,12 @@ func (c *conversation) fastPath(ctx context.Context, text string, out *pipeline.
call.Name, call.Args = "media_control", map[string]any{"operation": "play"}
reply = "Продолжаю."
default:
if msg, ok := c.sceneFastPath(ctx, t, out); ok {
return msg, true
}
if msg, ok := c.brightnessFastPath(ctx, t, out); ok {
return msg, true
}
if msg, ok := c.homeFastPath(ctx, t, out); ok {
return msg, true
}
Expand Down Expand Up @@ -347,3 +383,188 @@ func deviceName(d map[string]any) string {
}
return name
}

// stripWakeWord removes a leading wake word that speech recognition kept in
// the transcript.
func stripWakeWord(t string) string {
for _, w := range []string{"маруся"} {
if strings.HasPrefix(t, w) {
return strings.TrimLeft(strings.TrimPrefix(t, w), " ,.!?-")
}
}
return t
}

// volumeWordLevel turns "на половину" and "на максимум" into a level.
func volumeWordLevel(word string) int {
switch {
case strings.HasPrefix(word, "половин"):
return 50
case strings.HasPrefix(word, "минимум"):
return 10
default: // максимум, всю, все, весь
return 100
}
}

// volumeDelta reads "на 20" from a relative volume command; the default step
// is ten, and the step is capped so one phrase cannot jump the whole range.
func volumeDelta(t string) int {
m := volumeDeltaRE.FindStringSubmatch(t)
if m == nil {
return 10
}
d, err := strconv.Atoi(m[1])
if err != nil || d <= 0 {
return 10
}
return min(d, 50)
}

var monthsRU = [...]string{"января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"}

var weekdaysRU = map[string]string{
"Monday": "понедельник", "Tuesday": "вторник", "Wednesday": "среда",
"Thursday": "четверг", "Friday": "пятница", "Saturday": "суббота", "Sunday": "воскресенье",
}

// dateReply answers "какое сегодня число" and "какой сегодня день" from the
// time skill, which already returns the date and the weekday.
func dateReply(res map[string]any) string {
date, _ := res["date"].(string)
t, err := time.Parse("2006-01-02", date)
if err != nil {
return "Не получилось узнать дату."
}
day := fmt.Sprintf("%d %s", t.Day(), monthsRU[t.Month()-1])
weekday, _ := res["weekday"].(string)
if w := weekdaysRU[weekday]; w != "" {
return fmt.Sprintf("Сегодня %s, %s.", w, day)
}
return "Сегодня " + day + "."
}

// sceneFastPath runs a Yandex Smart Home scene by name, e.g. "включи сцену
// Спокойной ночи".
func (c *conversation) sceneFastPath(ctx context.Context, t string, out *pipeline.Output) (message, bool) {
m := sceneRE.FindStringSubmatch(t)
if m == nil {
return message{}, false
}
query := strings.TrimSuffix(strings.TrimSpace(m[2]), " пожалуйста")
if query == "" {
return message{}, false
}
res, fail, ok := c.callTool(ctx, "yandex_home_find", map[string]any{"query": query}, out)
if !ok {
return message{}, true
}
if fail != "" {
out.Say(fail)
return message{Role: "assistant", Content: fail}, true
}
var found map[string]any
for _, sc := range scenarioList(res) {
name, _ := sc["name"].(string)
if strings.Contains(strings.ToLower(name), strings.ToLower(query)) {
found = sc
break
}
}
if found == nil {
reply := "Не нашла такую сцену."
out.Say(reply)
return message{Role: "assistant", Content: reply}, true
}
id, _ := found["id"].(string)
name, _ := found["name"].(string)
_, fail, ok = c.callTool(ctx, "yandex_home_scene", map[string]any{"id": id}, out)
if !ok {
return message{}, true
}
reply := fmt.Sprintf("Запустила сцену «%s».", name)
if fail != "" {
reply = fail
}
out.Say(reply)
return message{Role: "assistant", Content: reply}, true
}

// brightnessFastPath sets a light's brightness, e.g. "яркость в зале 50" or
// "поставь яркость торшера 50". Without a device or a room it stays with the
// model, which can ask a question.
func (c *conversation) brightnessFastPath(ctx context.Context, t string, out *pipeline.Output) (message, bool) {
level := 0
query := ""
switch {
case brightnessRoomRE.MatchString(t):
m := brightnessRoomRE.FindStringSubmatch(t)
query = strings.TrimSpace(m[1])
level, _ = strconv.Atoi(m[2])
case brightnessDeviceRE.MatchString(t):
m := brightnessDeviceRE.FindStringSubmatch(t)
query = strings.TrimSpace(m[1])
level, _ = strconv.Atoi(m[2])
case brightnessPlaceRE.MatchString(t):
m := brightnessPlaceRE.FindStringSubmatch(t)
level, _ = strconv.Atoi(m[1])
query = strings.TrimSpace(m[2])
default:
return message{}, false
}
query = strings.TrimSuffix(query, " пожалуйста")
if query == "" {
return message{}, false
}
level = max(1, min(100, level))
res, fail, ok := c.callTool(ctx, "yandex_home_find", map[string]any{"query": query}, out)
if !ok {
return message{}, true
}
if fail != "" {
out.Say(fail)
return message{Role: "assistant", Content: fail}, true
}
devices := deviceList(res)
if len(devices) == 0 {
reply := "Не нашла такое устройство."
out.Say(reply)
return message{Role: "assistant", Content: reply}, true
}
if len(devices) > 1 {
names := make([]string, 0, min(len(devices), 5))
for _, d := range devices[:min(len(devices), 5)] {
names = append(names, deviceName(d))
}
reply := "Нашла несколько: " + strings.Join(names, ", ") + ". Уточни комнату."
out.Say(reply)
return message{Role: "assistant", Content: reply}, true
}
id, _ := devices[0]["id"].(string)
_, fail, ok = c.callTool(ctx, "yandex_home_control", map[string]any{"id": id, "action": "set_brightness", "value": level}, out)
if !ok {
return message{}, true
}
reply := fmt.Sprintf("Яркость %d процентов.", level)
if fail != "" {
reply = fail
}
out.Say(reply)
return message{Role: "assistant", Content: reply}, true
}

func scenarioList(res map[string]any) []map[string]any {
switch s := res["scenarios"].(type) {
case []any:
out := make([]map[string]any, 0, len(s))
for _, v := range s {
if m, ok := v.(map[string]any); ok {
out = append(out, m)
}
}
return out
case []map[string]any:
return s
}
return nil
}
Loading
Loading