2c27e2ce1f
The "address him as ты" rule had only reached two of the five system prompts. Instead of pasting it into the other three (five copies drift — that is how this happened), there is now one block, in internal/persona, prepended to all five: nudges, action replies, chat, note queries and general knowledge. The block says who he is and how to address him (a man, always "ты", never "вы", never "он" about him; Maven stays feminine), plus the current local date and time. It is rendered fresh each turn because the time changes, and it is correct with an empty config — the address and gender rules are defaults in code. Config only adds optional facts: owner_name, city, and the existing free-text `persona` string, which is now the static half of the block. Russian even in front of the English prompts: the rules are Russian grammar, so they read best stated in Russian, and there is one copy. Vikunja #394. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
93 lines
3.7 KiB
Go
93 lines
3.7 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"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
var ruWeekdays = [...]string{"воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"}
|
|
|
|
var ruMonths = [...]string{
|
|
"января", "февраля", "марта", "апреля", "мая", "июня",
|
|
"июля", "августа", "сентября", "октября", "ноября", "декабря",
|
|
}
|
|
|
|
// 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",
|
|
ruWeekdays[int(now.Weekday())], now.Day(), ruMonths[int(now.Month())-1], now.Year(),
|
|
now.Hour(), now.Minute()))
|
|
|
|
if s := strings.TrimSpace(f.Static); s != "" {
|
|
b.WriteString(s + "\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// who renders the optional name/city line, or "" when neither is configured.
|
|
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
|
|
}
|