From 2c27e2ce1f23907c0276c5cb596f62ecfc379506 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 15:55:30 +0400 Subject: [PATCH] Give every prompt one shared context block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- cmd/mavend/main.go | 54 ++++++++------ cmd/mavend/replier_llm.go | 11 ++- cmd/mavend/replier_llm_test.go | 10 +-- cmd/mavend/voice.go | 2 +- internal/config/config.go | 7 ++ internal/persona/persona.go | 92 ++++++++++++++++++++++++ internal/persona/persona_test.go | 50 +++++++++++++ internal/phraser/context_block_test.go | 33 +++++++++ internal/phraser/eval/llmphraser_test.go | 4 ++ internal/phraser/llmphraser.go | 31 ++++---- 10 files changed, 246 insertions(+), 48 deletions(-) create mode 100644 internal/persona/persona.go create mode 100644 internal/persona/persona_test.go create mode 100644 internal/phraser/context_block_test.go diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index bc436c4..99f3c74 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -58,6 +58,7 @@ import ( "github.com/kami/maven/internal/delivery/telegramsink" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/loop" + "github.com/kami/maven/internal/persona" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/store" "github.com/kami/maven/internal/webauthn" @@ -271,13 +272,13 @@ func run(args []string) error { phr = phraser.NewStub() if cfg.Phraser != nil { pc := phraser.Config{ - ModelPath: cfg.Phraser.ModelPath, - BinPath: cfg.Phraser.BinPath, - Listen: cfg.Phraser.Listen, - NGpuLayers: cfg.Phraser.NGpuLayers, - NCtx: cfg.Phraser.NCtx, - Timeout: time.Duration(cfg.Phraser.Timeout), - Persona: personaFromCfg(cfg), + ModelPath: cfg.Phraser.ModelPath, + BinPath: cfg.Phraser.BinPath, + Listen: cfg.Phraser.Listen, + NGpuLayers: cfg.Phraser.NGpuLayers, + NCtx: cfg.Phraser.NCtx, + Timeout: time.Duration(cfg.Phraser.Timeout), + ContextBlock: contextBlockFn(cfg, time.Now), } if pc.BinPath == "" { pc.BinPath = "llama-server" @@ -441,13 +442,13 @@ func run(args []string) error { phr = phraser.NewStub() if cfg.Phraser != nil { pc := phraser.Config{ - ModelPath: cfg.Phraser.ModelPath, - BinPath: cfg.Phraser.BinPath, - Listen: cfg.Phraser.Listen, - NGpuLayers: cfg.Phraser.NGpuLayers, - NCtx: cfg.Phraser.NCtx, - Timeout: time.Duration(cfg.Phraser.Timeout), - Persona: personaFromCfg(cfg), + ModelPath: cfg.Phraser.ModelPath, + BinPath: cfg.Phraser.BinPath, + Listen: cfg.Phraser.Listen, + NGpuLayers: cfg.Phraser.NGpuLayers, + NCtx: cfg.Phraser.NCtx, + Timeout: time.Duration(cfg.Phraser.Timeout), + ContextBlock: contextBlockFn(cfg, time.Now), } if pc.BinPath == "" { pc.BinPath = "llama-server" @@ -601,12 +602,23 @@ func run(args []string) error { return nil } -// personaFromCfg extracts the voice persona from the config, or returns "" -// when voice isn't configured. Used to pass a character prompt into the -// LLM phraser without requiring voice to be enabled. -func personaFromCfg(cfg *config.Config) string { - if cfg.Voice != nil { - return cfg.Voice.Persona +// personaFacts reads the optional, deployment-specific facts (his name, his +// city, the free-text persona string) out of the config. Everything here may +// be empty — the context block is correct without any of it. +func personaFacts(cfg *config.Config) persona.Facts { + if cfg.Voice == nil { + return persona.Facts{} + } + return persona.Facts{ + OwnerName: cfg.Voice.OwnerName, + City: cfg.Voice.City, + Static: cfg.Voice.Persona, } - return "" +} + +// contextBlockFn returns the per-turn renderer of the shared context block. +// Per turn, not once at startup, because the block states the current time. +func contextBlockFn(cfg *config.Config, now func() time.Time) func() string { + f := personaFacts(cfg) + return func() string { return f.Block(now()) } } diff --git a/cmd/mavend/replier_llm.go b/cmd/mavend/replier_llm.go index 637ce0e..e633aa4 100644 --- a/cmd/mavend/replier_llm.go +++ b/cmd/mavend/replier_llm.go @@ -7,6 +7,7 @@ import ( "time" "github.com/kami/maven/internal/llm" + "github.com/kami/maven/internal/persona" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/voice" ) @@ -23,10 +24,14 @@ type completer interface { type llmReplier struct { c completer stub *voice.StubReplier + + // block renders the shared context block per turn (who he is, the time). + // nil ⇒ the prompt stands alone. + block func() string } -func newLLMReplier(c completer) *llmReplier { - return &llmReplier{c: c, stub: voice.NewStubReplier()} +func newLLMReplier(c completer, block func() string) *llmReplier { + return &llmReplier{c: c, stub: voice.NewStubReplier(), block: block} } const replySystem = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Владелец — мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), тепло и по-русски. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused). @@ -40,7 +45,7 @@ func (r *llmReplier) Reply(d router.Decision) string { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() out, err := r.c.Complete(ctx, llm.Req{ - System: replySystem, + System: persona.Prepend(r.block, replySystem), User: replyContext(d), MaxTokens: 512, RepeatPenalty: 1.3, diff --git a/cmd/mavend/replier_llm_test.go b/cmd/mavend/replier_llm_test.go index b2d132d..6e1f08c 100644 --- a/cmd/mavend/replier_llm_test.go +++ b/cmd/mavend/replier_llm_test.go @@ -17,7 +17,7 @@ type mockCompleter struct { func (m mockCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err } func TestLLMReplierReturnsLLMReply(t *testing.T) { - r := newLLMReplier(mockCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}) + r := newLLMReplier(mockCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil) got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}}) if got != "записала, кофе закончился" { t.Errorf("got %q, want %q", got, "записала, кофе закончился") @@ -25,7 +25,7 @@ func TestLLMReplierReturnsLLMReply(t *testing.T) { } func TestLLMReplierFallsBackToPlainText(t *testing.T) { - r := newLLMReplier(mockCompleter{out: "записала, кофе закончился"}) + r := newLLMReplier(mockCompleter{out: "записала, кофе закончился"}, nil) got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}}) if got != "записала, кофе закончился" { t.Errorf("got %q, want %q", got, "записала, кофе закончился") @@ -33,7 +33,7 @@ func TestLLMReplierFallsBackToPlainText(t *testing.T) { } func TestLLMReplierFallsBackToStubOnError(t *testing.T) { - r := newLLMReplier(mockCompleter{err: errTestLLMDown}) + r := newLLMReplier(mockCompleter{err: errTestLLMDown}, nil) noteDec := router.Decision{Intent: router.IntentNote} got := r.Reply(noteDec) want := voice.NewStubReplier().Reply(noteDec) @@ -43,7 +43,7 @@ func TestLLMReplierFallsBackToStubOnError(t *testing.T) { } func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) { - r := newLLMReplier(mockCompleter{out: ""}) + r := newLLMReplier(mockCompleter{out: ""}, nil) noteDec := router.Decision{Intent: router.IntentNote} got := r.Reply(noteDec) want := voice.NewStubReplier().Reply(noteDec) @@ -53,7 +53,7 @@ func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) { } func TestLLMReplierClarifyUsesStub(t *testing.T) { - r := newLLMReplier(mockCompleter{out: "я всё поняла"}) + r := newLLMReplier(mockCompleter{out: "я всё поняла"}, nil) clarifyDec := router.Decision{Clarify: true} got := r.Reply(clarifyDec) want := voice.NewStubReplier().Reply(clarifyDec) diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index f736b73..af5feb0 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -246,7 +246,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem // ----- replier (LLM-backed when the engine is on, Stub floor otherwise) ----- replier := voice.Replier(voice.NewStubReplier()) if llmClient != nil { - replier = newLLMReplier(llmClient) + replier = newLLMReplier(llmClient, contextBlockFn(cfg, time.Now)) } // ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) ----- diff --git a/internal/config/config.go b/internal/config/config.go index 4554bd7..37220f2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -302,6 +302,13 @@ type VoiceConfig struct { // Russian self-reference). Example: "Be formal and answer in English only." Persona string `json:"persona,omitempty"` + // OwnerName / City — optional facts about the owner, added to the shared + // context block (internal/persona). Empty is fine: the block still states + // who he is grammatically (a man, addressed as "ты") and the current time. + // Nothing about correct behaviour may depend on these being filled in. + OwnerName string `json:"owner_name,omitempty"` + City string `json:"city,omitempty"` + // Weather — the weather provider config. nil ⇒ the daemon wires // the stub provider (returns ErrNotConfigured — "погода не настроена"). // Set provider to "open-meteo" to use the keyless Open-Meteo API. diff --git a/internal/persona/persona.go b/internal/persona/persona.go new file mode 100644 index 0000000..365dd13 --- /dev/null +++ b/internal/persona/persona.go @@ -0,0 +1,92 @@ +// 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 +} diff --git a/internal/persona/persona_test.go b/internal/persona/persona_test.go new file mode 100644 index 0000000..a6a2fb2 --- /dev/null +++ b/internal/persona/persona_test.go @@ -0,0 +1,50 @@ +package persona + +import ( + "strings" + "testing" + "time" +) + +var ref = time.Date(2026, 7, 31, 14, 5, 0, 0, time.UTC) + +// The block must be correct with an empty config: the address form and the +// gender rules are hard constraints, not preferences. +func TestBlockWorksWithZeroConfig(t *testing.T) { + b := Facts{}.Block(ref) + for _, want := range []string{"женском роде", "ОБРАЩЕНИЕ", "\"ты\"", "31 июля 2026", "пятница", "14:05"} { + if !strings.Contains(b, want) { + t.Errorf("block missing %q:\n%s", want, b) + } + } +} + +func TestBlockAddsOptionalFacts(t *testing.T) { + b := Facts{OwnerName: "Ками", City: "Москва", Static: "Будь краткой."}.Block(ref) + for _, want := range []string{"Ками", "Москва", "Будь краткой."} { + if !strings.Contains(b, want) { + t.Errorf("block missing %q:\n%s", want, b) + } + } +} + +// The time changes between turns, so two renders must differ. +func TestBlockRendersTimePerTurn(t *testing.T) { + a := Facts{}.Block(ref) + c := Facts{}.Block(ref.Add(time.Hour)) + if a == c { + t.Errorf("block did not change with the clock:\n%s", a) + } +} + +func TestPrependNilIsSafe(t *testing.T) { + if got := Prepend(nil, "PROMPT"); got != "PROMPT" { + t.Errorf("Prepend(nil) = %q", got) + } + if got := Prepend(func() string { return " " }, "PROMPT"); got != "PROMPT" { + t.Errorf("Prepend(blank) = %q", got) + } + if got := Prepend(func() string { return "CTX" }, "PROMPT"); got != "CTX\n\nPROMPT" { + t.Errorf("Prepend = %q", got) + } +} diff --git a/internal/phraser/context_block_test.go b/internal/phraser/context_block_test.go new file mode 100644 index 0000000..30e0413 --- /dev/null +++ b/internal/phraser/context_block_test.go @@ -0,0 +1,33 @@ +package phraser + +import ( + "strings" + "testing" +) + +// Every phrasing prompt must carry the shared context block. This is the +// regression guard for the bug that started this: the "ты" rule reached only +// two of the five prompts because each prompt had its own copy of the rules. +func TestEveryPromptCarriesTheContextBlock(t *testing.T) { + block := func() string { return "CTXBLOCK" } + p := &LLMPhraser{cfg: Config{ContextBlock: block}} + + prompts := map[string]string{ + "nudge": p.systemPrompt(), + "query": p.querySystemPrompt(), + "chat": chatSystemPrompt(block), + } + for name, got := range prompts { + if !strings.HasPrefix(got, "CTXBLOCK\n\n") { + t.Errorf("%s prompt does not start with the context block:\n%s", name, got) + } + } +} + +// Without a block the prompts are unchanged — the stub and test paths pass nil. +func TestPromptsWithoutBlockAreUnchanged(t *testing.T) { + p := &LLMPhraser{} + if p.systemPrompt() != nudgeSystem { + t.Errorf("nudge prompt changed with no block set") + } +} diff --git a/internal/phraser/eval/llmphraser_test.go b/internal/phraser/eval/llmphraser_test.go index 316eaa2..bca5005 100644 --- a/internal/phraser/eval/llmphraser_test.go +++ b/internal/phraser/eval/llmphraser_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/kami/maven/internal/llm" + "github.com/kami/maven/internal/persona" "github.com/kami/maven/internal/phraser" ) @@ -43,6 +44,9 @@ func TestLLMPhrasingBaseline(t *testing.T) { // Generous: an unconstrained 0.8B can spend a minute thinking before it // writes a word, and a timeout would be scored as a model failure. cfg.Timeout = 5 * time.Minute + // The same shared context block the daemon prepends (internal/persona), + // with an empty config — that is the deployment we actually ship. + cfg.ContextBlock = func() string { return persona.Facts{}.Block(time.Now()) } p := phraser.NewLLMPhraserAt(base, cfg) defer p.Close() diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index 98f06a1..48ba72a 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -18,6 +18,7 @@ import ( "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/loop" + "github.com/kami/maven/internal/persona" "github.com/kami/maven/internal/router" ) @@ -39,7 +40,11 @@ type Config struct { NGpuLayers int NCtx int Timeout time.Duration - Persona string // optional prompt prefix tuning maven's character + + // ContextBlock renders the shared context block (who he is, how to + // address him, the time) fresh for each turn. See internal/persona. + // nil ⇒ no block, the prompts stand alone. + ContextBlock func() string } func DefaultConfig(modelPath string) Config { @@ -202,7 +207,7 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes [] if len(notes) == 0 { // General knowledge — no notes to ground the answer. The system // prompt is the single tested source in router.KnowledgePrompt. - sys := router.KnowledgePrompt() + sys := persona.Prepend(p.cfg.ContextBlock, router.KnowledgePrompt()) prompt := fmt.Sprintf("Пользователь спрашивает: \"%s\".", utterance) resp, err := p.chatWithSystem(ctx, sys, prompt, 256) if err != nil || resp == "" { @@ -238,7 +243,7 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes [] // message array from dialogue history + the current user utterance. Falls back // to a simple greeting on any LLM error — better to say something than nothing. func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) { - sys := chatSystemPrompt(p.cfg.Persona) + sys := chatSystemPrompt(p.cfg.ContextBlock) msgs := []chatMsg{ {Role: "system", Content: sys}, } @@ -267,17 +272,14 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [ } // chatSystemPrompt returns the system prompt for conversational chat. -// Prepends the configured persona when set. -func chatSystemPrompt(persona string) string { +// Prepends the shared context block when the phraser has one. +func chatSystemPrompt(block func() string) string { base := `You are maven, a self-hosted personal assistant. You're talking with your owner. Keep replies brief (1-3 sentences) and natural. You're helpful, curious, and a little warm. Respond in the user's language (Russian or English, matching their last message). Never roleplay emotions you don't have, but stay friendly. Respond ONLY with valid JSON: {"response": "...", "mood": "neutral"}. "response" is your reply text; "mood" reflects your tone (neutral/happy/thinking/tired/confused).` - if persona != "" { - base = persona + "\n\n" + base - } - return base + return persona.Prepend(block, base) } // chatWithMessages sends a full message array (system + history + current) to @@ -459,21 +461,14 @@ const nudgeSystem = `Ты — Maven, домашняя ассистентка. О Это примеры ФОРМЫ, а не темы. Пиши только про ту ситуацию, которую тебе дали в запросе. Не копируй примеры и никогда не пиши "..." в поле response.` func (p *LLMPhraser) systemPrompt() string { - base := nudgeSystem - if p.cfg.Persona != "" { - base = p.cfg.Persona + "\n\n" + base - } - return base + return persona.Prepend(p.cfg.ContextBlock, nudgeSystem) } // querySystemPrompt returns the system prompt for PhraseQuery (notes + general // knowledge). Prepends the configured persona when set. func (p *LLMPhraser) querySystemPrompt() string { base := "You are maven, a self-hosted personal assistant answering from your notes. Answer briefly and naturally in Russian starting with \"вот что я нашла: \". Respond ONLY with valid JSON: {\"response\": \"...\", \"mood\": \"neutral\"}." - if p.cfg.Persona != "" { - base = p.cfg.Persona + "\n\n" + base - } - return base + return persona.Prepend(p.cfg.ContextBlock, base) } // ruleTopics — Russian gloss for each built-in rule name. The rule names are