8c36e7ef84
A clock time read aloud now inflects its nouns and drops its leading zeros.
The old rewrite said "часов" for every hour and "минут" for every minute, so
21:00 came out as "21 часов" and 14:00 as "14 часов 00 минут". Russian
inflects a noun after a numeral and internal/say already owns that rule, so
spokenTime calls say.CountWord for both halves and omits the minutes when
there are none. 21:00 is "21 час", 22:02 is "22 часа 2 минуты", 14:00 is
"14 часов".
internal/persona held its own copies of the twelve months and the seven
weekdays. Both are closed classes and both already live in internal/lexicon,
which is where cmd/mavend/ruwords.go sent its copy. The block now reads
lexicon.Weekday and lexicon.MonthGenitive and carries no word list of its own.
LoadSummaries asserted that stall_sitting keeps its two counts and not the two
count words beside them. A variant dropping {word} or {dayword} would have
loaded and spoken a bare number. Both are required now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
139 lines
5.9 KiB
Go
139 lines
5.9 KiB
Go
// Package persona builds the one shared context block that goes in front of
|
||
// every LLM system prompt: who the owner is, how to address him, and what
|
||
// time it is right now.
|
||
//
|
||
// Why one block and not a line pasted into each prompt: there are five
|
||
// prompts (nudges, action replies, chat, note queries, general knowledge) and
|
||
// the "address him as ты" rule had only reached two of them. Five copies drift.
|
||
// One block cannot.
|
||
//
|
||
// The rules here are defaults in code, not config. Maven is feminine and the
|
||
// owner is a man addressed informally — that is a hard constraint of the
|
||
// product, so it must hold with an empty config file. Config only ADDS
|
||
// optional facts (his name, his city).
|
||
package persona
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/kami/maven/internal/lexicon"
|
||
)
|
||
|
||
// Facts — the optional, deployment-specific half of the block. All fields may
|
||
// be empty; the block is still correct and useful without them.
|
||
type Facts struct {
|
||
OwnerName string // his name, e.g. "Ками"
|
||
City string // where he is, e.g. "Москва"
|
||
Static string // the free-text `persona` config string, appended verbatim
|
||
|
||
// The two config-gated capabilities. They are listed only when this
|
||
// deployment actually has them, because a capability she names and cannot
|
||
// do is worse than one she never mentions.
|
||
Weather bool // an open-meteo provider is configured
|
||
Telegram bool // a telegram bot token + chat id are configured
|
||
Tools bool // at least one shell act is on the allowlist
|
||
}
|
||
|
||
// The weekday and month names are closed classes and live in internal/lexicon,
|
||
// which indexes weekdays from Sunday the way time.Weekday does and months from
|
||
// one. This file used to carry its own copies, making four copies of the twelve
|
||
// months in the tree after cmd/mavend/ruwords.go gave up its own (Vikunja #525).
|
||
|
||
// Block renders the context block for one turn. Russian even in front of the
|
||
// English prompts: the rules it states are Russian grammar (ты/тебя, feminine
|
||
// verbs), and a Russian rule reads best stated in Russian.
|
||
//
|
||
// Keep it short. It ships on every turn to a 0.8B on laptop CPU, so every
|
||
// line here is latency.
|
||
func (f Facts) Block(now time.Time) string {
|
||
var b strings.Builder
|
||
|
||
b.WriteString("Ты — Maven, домашняя ассистентка. О себе говоришь в женском роде: \"я записала\", \"я проверила\".\n")
|
||
|
||
// The address form gets its own line. It is the thing that kept getting
|
||
// lost when it was buried in prose.
|
||
b.WriteString("ОБРАЩЕНИЕ: владелец — мужчина, всегда на \"ты\" (ты, тебя, тебе, твой) и в единственном числе (\"выпей\", \"посмотри\"). Никогда \"вы\"/\"вас\"/\"ваш\". Никогда \"он\"/\"его\" о нём — ты говоришь ему, а не о нём. Глаголы о нём — в мужском роде (\"ты забыл\").\n")
|
||
|
||
if who := f.who(); who != "" {
|
||
b.WriteString(who + "\n")
|
||
}
|
||
|
||
b.WriteString(fmt.Sprintf("Сейчас: %s, %d %s %d, %02d:%02d (местное время).\n",
|
||
lexicon.Weekday(int(now.Weekday())), now.Day(), lexicon.MonthGenitive(int(now.Month())), now.Year(),
|
||
now.Hour(), now.Minute()))
|
||
|
||
b.WriteString("Умеешь: " + strings.Join(f.can(), "; ") +
|
||
". Других ДЕЙСТВИЙ не умеешь — если просят такое, скажи прямо.\n")
|
||
|
||
if s := strings.TrimSpace(f.Static); s != "" {
|
||
b.WriteString(s + "\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// can lists what she can really do. Every entry here is a code path that
|
||
// exists in the daemon today:
|
||
// - reminders: IntentReminder → CoreAPI.CreateReminder, fired by the tick.
|
||
// - notes and facts: IntentNote/IntentFact write, IntentQuery reads them back.
|
||
// - calendar: IntentQuery answers "что у меня сегодня" from CalendarEvents.
|
||
// - weather / telegram / shell acts: only when configured (see Facts).
|
||
//
|
||
// Nothing speculative goes in this list. A capability she offers and cannot
|
||
// perform is worse than one she never mentions.
|
||
func (f Facts) can() []string {
|
||
c := []string{
|
||
// Talking comes first, and the closing line says "действий" rather than
|
||
// "ничего", because this same block sits in front of the chat and
|
||
// general-knowledge prompts. A flat "you can do nothing else" would
|
||
// tell her to refuse the exact thing those two prompts are for.
|
||
"разговаривать и отвечать на вопросы",
|
||
"ставить напоминания",
|
||
"записывать заметки и факты и отвечать по ним",
|
||
"смотреть календарь",
|
||
}
|
||
if f.Weather {
|
||
c = append(c, "говорить погоду")
|
||
}
|
||
if f.Telegram {
|
||
c = append(c, "писать в телеграм")
|
||
}
|
||
if f.Tools {
|
||
c = append(c, "запускать разрешённые команды на сервере")
|
||
}
|
||
return c
|
||
}
|
||
|
||
// who renders the optional name/city line, or "" when neither is configured.
|
||
//
|
||
// Written as labels ("Имя владельца: ..."), not as a sentence with pronouns:
|
||
// the block's own "ты" is Maven, so "тебя зовут" would read as her name and
|
||
// "его" would model the third-person form she must never use about him.
|
||
func (f Facts) who() string {
|
||
name := strings.TrimSpace(f.OwnerName)
|
||
city := strings.TrimSpace(f.City)
|
||
switch {
|
||
case name != "" && city != "":
|
||
return "Имя владельца: " + name + ". Город: " + city + "."
|
||
case name != "":
|
||
return "Имя владельца: " + name + "."
|
||
case city != "":
|
||
return "Город: " + city + "."
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// Prepend puts the block in front of a system prompt. Nil-safe: a nil renderer
|
||
// (tests, the stub paths) returns the prompt untouched.
|
||
func Prepend(block func() string, prompt string) string {
|
||
if block == nil {
|
||
return prompt
|
||
}
|
||
s := strings.TrimSpace(block())
|
||
if s == "" {
|
||
return prompt
|
||
}
|
||
return s + "\n\n" + prompt
|
||
}
|