Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de09471421 | |||
| d65c16a567 | |||
| 062d4252ef | |||
| 2c27e2ce1f |
+39
-21
@@ -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,29 @@ 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 {
|
||||
f := persona.Facts{
|
||||
// Telegram lives outside the voice block, so it counts either way.
|
||||
Telegram: cfg.Telegram != nil && cfg.Telegram.BotToken != "" && cfg.Telegram.ChatID != "",
|
||||
}
|
||||
return ""
|
||||
if cfg.Voice == nil {
|
||||
return f
|
||||
}
|
||||
f.OwnerName = cfg.Voice.OwnerName
|
||||
f.City = cfg.Voice.City
|
||||
f.Static = cfg.Voice.Persona
|
||||
// Same test wireVoice uses to pick the real provider over the stub.
|
||||
f.Weather = cfg.Voice.Weather != nil && cfg.Voice.Weather.Provider == "open-meteo"
|
||||
f.Tools = len(cfg.Voice.Tools) > 0
|
||||
return f
|
||||
}
|
||||
|
||||
// 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()) }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -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) -----
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// 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
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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()))
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// She may only offer what this deployment actually has.
|
||||
func TestCapabilitiesAreConfigGated(t *testing.T) {
|
||||
bare := Facts{}.Block(ref)
|
||||
for _, want := range []string{"напоминания", "заметки", "календарь"} {
|
||||
if !strings.Contains(bare, want) {
|
||||
t.Errorf("block missing always-on capability %q:\n%s", want, bare)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"погоду", "телеграм", "команды"} {
|
||||
if strings.Contains(bare, unwanted) {
|
||||
t.Errorf("block offers unconfigured %q:\n%s", unwanted, bare)
|
||||
}
|
||||
}
|
||||
|
||||
full := Facts{Weather: true, Telegram: true, Tools: true}.Block(ref)
|
||||
for _, want := range []string{"погоду", "телеграм", "команды"} {
|
||||
if !strings.Contains(full, want) {
|
||||
t.Errorf("block missing configured capability %q:\n%s", want, full)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,5 +3,8 @@ package router
|
||||
// KnowledgePrompt returns the system prompt for general knowledge questions
|
||||
// that the phraser uses when no notes match the query.
|
||||
func KnowledgePrompt() string {
|
||||
return `Ты — Мавена, персональный ассистент. Ответь кратко из своих знаний. Если не знаешь — скажи "не знаю". Не выдумывай. Respond ONLY with valid JSON: {"response": "...", "mood": "neutral"}.`
|
||||
// No self-introduction here: the shared persona block already says who she
|
||||
// is, and this line used to disagree with it — a different name ("Мавена")
|
||||
// and a masculine noun ("ассистент") in front of a feminine persona.
|
||||
return `Ответь кратко из своих знаний. Если не знаешь — скажи "не знаю". Не выдумывай. Respond ONLY with valid JSON: {"response": "...", "mood": "neutral"}.`
|
||||
}
|
||||
|
||||
@@ -11,10 +11,18 @@ func TestKnowledgePrompt(t *testing.T) {
|
||||
t.Fatal("KnowledgePrompt returned empty string")
|
||||
}
|
||||
// Must contain key instructions
|
||||
checks := []string{"Мавена", "не знаю", "не выдумывай"}
|
||||
checks := []string{"не знаю", "не выдумывай"}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(strings.ToLower(prompt), strings.ToLower(c)) {
|
||||
t.Errorf("KnowledgePrompt should mention %q", c)
|
||||
}
|
||||
}
|
||||
// Who she is comes from the shared persona block now. This prompt used to
|
||||
// say it too, with a different name and a masculine noun, which is the
|
||||
// drift the block exists to stop.
|
||||
for _, w := range []string{"Мавена", "ассистент"} {
|
||||
if strings.Contains(prompt, w) {
|
||||
t.Errorf("KnowledgePrompt should not introduce her (%q) — the persona block does", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user