Files
LocalAI/core/services/studio/triggerreply.go
T
Ettore Di Giacinto 9422011f20 feat(connectors): write the bot's own sentences in the language of the person
The confirmations, refusals and notes that the host writes in a chat
without the model were fixed English: "Started a new chat", the question
about a proposal, the file notes, the pairing replies. They now come from
a catalog (core/services/studio/hostmsg) with a template for English,
Italian, German, Spanish, French and Portuguese (Brazil), and the same
{placeholders} in each. Text for the model (tool descriptions, the held
back note, the thread block) stays English.

A connection has a reply_language setting: auto (default), en, it, de,
es, fr or pt-BR. With auto the host scores the sender's message against
the common words of the six languages, with no model and no dependency.
A message that does not show a language ("ok", "yes", "thanks") changes
nothing, and a short message cannot move a conversation to another
language. The last clear language of a sender and of a chat is kept for
24 hours; a trigger that writes on its own uses the chat's. The setting
is stored in the JSON and the gorm store, is kept by an update that
leaves it out, and is on the connection page under "How it answers".

Every sentence goes through msg or a hostmsg.Voice. A guard test fails
when a fixed text reaches say, Post or Outgoing, when a key is not in the
catalog, or when a key is never used. The reply parser now reads the
yes, no and undo words of the six languages, which the new texts ask for.

Assisted-by: Claude:claude-sonnet-5-5
2026-11-04 18:03:27 +00:00

140 lines
5.2 KiB
Go

package studio
import (
"errors"
"regexp"
"slices"
"strconv"
"strings"
"time"
)
// The words a person answers a proposal with. The lists are short on purpose:
// a message is an answer only when all of it is one of these (with an
// optional code), so a longer message always goes to the agent. Words of other
// languages are optional; these cover the common ones.
var (
yesWords = wordSet(slices.Concat(
[]string{"yes", "y", "yep", "yeah", "ok", "okay", "sure", "confirm", "approve", "approved", "go ahead", "do it", "👍", "✅"},
// Italian
[]string{"si", "sì", "certo", "certamente", "conferma", "confermo", "confermato", "va bene", "d'accordo", "daccordo", "perfetto", "procedi", "salva", "avvia", "ok grazie", "ok va bene"},
// German
[]string{"ja", "jawohl", "klar", "natürlich", "bestätigen", "bestätige", "bestätigt", "einverstanden", "passt", "mach das", "speichern", "starten", "ok danke"},
// Spanish
[]string{"sí", "claro", "vale", "confirmar", "confirmo", "confirma", "de acuerdo", "adelante", "hazlo", "guardar", "guárdalo", "ok gracias"},
// French
[]string{"oui", "ouais", "d'accord", "daccord", "bien sûr", "confirmer", "confirme", "je confirme", "vas-y", "enregistre", "enregistrer", "ok merci"},
// Portuguese (Brazil)
[]string{"sim", "pode", "pode salvar", "beleza", "certo", "confirmar", "confirma", "de acordo", "bora", "salvar", "ok obrigado", "ok obrigada"},
)...)
noWords = wordSet(slices.Concat(
[]string{"no", "n", "nope", "cancel", "stop", "drop it", "no thanks", "no thank you", "never mind", "nevermind", "undo", "👎", "❌"},
// Italian
[]string{"annulla", "annullare", "annullato", "lascia stare", "no grazie", "scarta", "ferma"},
// German
[]string{"nein", "nö", "abbrechen", "abbruch", "stopp", "lass es", "lass gut sein", "nein danke", "verwerfen"},
// Spanish
[]string{"cancelar", "cancela", "déjalo", "olvídalo", "no gracias", "descartar"},
// French
[]string{"non", "annuler", "annule", "laisse tomber", "non merci", "oublie", "abandonner"},
// Portuguese (Brazil)
[]string{"não", "nao", "cancelar", "cancela", "deixa pra lá", "não obrigado", "nao obrigado", "descartar", "esquece"},
)...)
// undoWords take back a change that was just applied from the chat. A
// bare "no" or "cancel" after a save does not: it may mean anything. Each
// language has its own phrase, the one its confirmation text asks for.
undoWords = wordSet("undo", "annulla modifica", "annulla la modifica", "rückgängig", "rückgängig machen", "deshacer",
"annuler la modification", "défaire", "desfazer")
)
// isUndoReply says whether the whole message is the word that takes back the
// change that was just applied.
func isUndoReply(text string) bool { return undoWords[normalizeReply(text)] }
func wordSet(w ...string) map[string]bool {
m := make(map[string]bool, len(w))
for _, x := range w {
m[x] = true
}
return m
}
var replyCodeRE = regexp.MustCompile(`^(.+?)\s+([a-z0-9]{4})$`)
// normalizeReply lowercases a message and removes the space and the sentence
// marks around it.
func normalizeReply(text string) string {
text = strings.ToLower(strings.TrimSpace(text))
text = strings.Trim(text, " \t.!?,;:")
return strings.Join(strings.Fields(text), " ")
}
// ParseReply reads a short answer to a proposal: a word of agreement or
// refusal, alone ("yes", "ok", "cancel") or followed by the code of the
// proposal ("yes 7K2Q"). The verb is "confirm" or "cancel"; the code is in upper
// case, or empty for a bare word. Any other text is not an answer.
func ParseReply(text string) (verb, code string, ok bool) {
t := normalizeReply(text)
if t == "" || len(t) > 40 {
return "", "", false
}
if v, found := replyVerb(t); found {
return v, "", true
}
if m := replyCodeRE.FindStringSubmatch(t); m != nil {
if v, found := replyVerb(m[1]); found {
return v, strings.ToUpper(m[2]), true
}
}
return "", "", false
}
func replyVerb(t string) (string, bool) {
switch {
case yesWords[t]:
return "confirm", true
case noWords[t], undoWords[t]:
return "cancel", true
}
return "", false
}
// ---- Times that a chat gives ----
var (
delayPartRE = regexp.MustCompile(`(\d+)\s*(days?|d|hours?|hrs?|h|minutes?|mins?|m)`)
delayWholeRE = regexp.MustCompile(`^(\d+\s*(days?|d|hours?|hrs?|h|minutes?|mins?|m)\s*)+$`)
)
// ParseDelay reads how long to wait: "10m", "10 minutes", "an hour", "1h30m",
// "2 days", "half an hour". The unit is needed, and the result is positive.
func ParseDelay(text string) (time.Duration, error) {
t := normalizeReply(text)
t = strings.NewReplacer("half an hour", "30 minutes", "an hour", "1 hour", "a minute", "1 minute", "a day", "1 day").Replace(t)
if t == "" || !delayWholeRE.MatchString(t) {
return 0, errors.New(`the delay must look like "10 minutes", "1h30m" or "2 days"`)
}
var total time.Duration
for _, m := range delayPartRE.FindAllStringSubmatch(t, -1) {
n, err := strconv.Atoi(m[1])
if err != nil || n > 10000 {
return 0, errors.New("the delay is too large")
}
var unit time.Duration
switch m[2][0] {
case 'd':
unit = 24 * time.Hour
case 'h':
unit = time.Hour
default:
unit = time.Minute
}
total += time.Duration(n) * unit
}
if total <= 0 {
return 0, errors.New("the delay must be more than zero")
}
return total, nil
}