diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index 6717e8f..1cb5fc9 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -13,8 +13,6 @@ 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" ) // errEmptyResponse — the server answered and said nothing. Separate from a @@ -365,42 +363,6 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [ return strings.TrimSpace(resp), nil } -// chatSystemPrompt returns the system prompt for conversational chat. -// Prepends the shared context block when the phraser has one. -func chatSystemPrompt(block func() string) string { - // No self-introduction here: the persona block prepended one line above - // already says who she is, same as router.KnowledgePrompt. - // - // The grammar examples used to be full clauses: ("я подумала", "я рада") - // for her, ("ты сказал", "ты забыл") for him. A 1.7B copies those rather - // than generalising from them. Observed on the box 2026-08-01: all three - // chat replies in one session opened with "Я подумала, что ...", and one - // ended "...немного тревожусь. ты сказал" — the second example pasted onto - // the end of a finished sentence, which reads as a truncation but is not. - // - // So: contrastive pairs instead of usable openers. "рада, не рад" states - // the rule as a correction, and short predicatives do not hand the model a - // sentence frame to start with. The him-examples are gone entirely; the - // "ты" instruction carries that on its own and those two produced the - // worst output. The last line says outright not to echo the instructions, - // because a small model will otherwise treat any quoted string as licence. - // - // Amended the same day: with the openers gone the tic went with them, but - // "не забыл ли я" appeared — masculine, about herself. The old "я подумала" - // had been suppressing that by accident, being a feminine past tense the - // model could copy. Two short predicatives are not enough signal on their - // own, so the rule is now stated as morphology (-ла) rather than as a pair - // of words. A suffix rule generalises where an example only gets copied. - base := `Ты разговариваешь с хозяином. - -О себе — в женском роде: "рада", не "рад"; "поняла", не "понял". Все свои глаголы в прошедшем времени оканчивай на -ла: сделала, забыла, записала, подумала. Он мужчина: обращайся к нему на "ты", в мужском роде. Никогда не "вы"/"ваш" и никогда "он"/"его" — ты говоришь ему, а не о нём. - -Отвечай по-русски, коротко: одна-три фразы, живым языком. Ты доброжелательная, тебе интересно, но чувства не изображай. Не повторяй формулировки из этой инструкции — отвечай своими словами. - -Отвечай ТОЛЬКО одним объектом JSON: {"response": "...", "mood": "neutral"}. В "response" — твой ответ. В "mood" — ровно одно из: neutral, happy, thinking, tired, confused.` - return persona.Prepend(block, base) -} - func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) { text := extractReminderText(d.Reminder.Payload) if text == "" { @@ -444,58 +406,6 @@ func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error return p.chatWithSystem(ctx, p.systemPrompt(), userPrompt, 512) } -// nudgeSystem — the phrasing contract for nudges. -// -// Written as filled-in examples, not as a schema with "..." in it. A 0.8B -// copies whatever sits in the response slot, so a literal placeholder there -// teaches it to answer with the placeholder. Measured: 7/15 nudges came back -// as "..." before this. See docs/evals/2026-07-31-phrasing.md. -// -// Russian only, feminine self-reference, second person masculine (the owner is -// a man). She talks TO him, informally, singular — never "вы", never "он". -// One short sentence — the nudge is spoken aloud. -// -// What the ban on обращения forbids is pet names ("дорогой", "милый"), not his -// name: "Ками, ноутбук на трёх процентах" is exactly how she talks, and the -// unqualified word read as forbidding that too. Hence "ласковые обращения". -// -// The examples also never claim a physical act. She has no hands and no smart -// plug — she can tell him the battery is at three percent, she cannot put the -// laptop on charge. An example that says she did teaches the model to invent -// actions Maven never took, which is worse than a missing nudge. -const nudgeSystem = `Ты — Maven, домашняя ассистентка. О себе говоришь в женском роде ("я проверила", "я записала"). Владелец — мужчина, обращайся к нему в мужском роде ("ты пил", "ты забыл"). -Говоришь с ним на "ты", в единственном числе ("выпей", "встань"). Никогда не "вы"/"вас"/"ваш" и никогда "он"/"его" — ты говоришь ему, а не о нём. - -Пиши ОДНО короткое напоминание по-русски: не больше 120 символов и не больше 16 слов. Только по делу. - -Запрещено: ласковые обращения ("дорогой", "милый"), эмодзи, извинения ("прости", "извини"), вопросы о самочувствии, похвала, больше одного восклицательного знака, английские слова кроме имён сервисов. - -Отвечай ТОЛЬКО одним объектом JSON с полями "response" и "mood". -"response" — сам текст напоминания. -"mood" — ровно одно из: neutral, happy, thinking, tired, confused. - -Так выглядит правильный ответ по форме. Темы здесь посторонние — их в запросе не будет: -{"response": "Стиральная машина закончила. Развесь бельё.", "mood": "neutral"} -{"response": "Ками, ноутбук на трёх процентах. Поставь его на зарядку.", "mood": "confused"} - -Это примеры ФОРМЫ, а не темы. Пиши только про ту ситуацию, которую тебе дали в запросе. Не копируй примеры и никогда не пиши "..." в поле response.` - -func (p *LLMPhraser) systemPrompt() string { - return persona.Prepend(p.cfg.ContextBlock, nudgeSystem) -} - -// knowledgePrompt — the no-sources branch: a world question, answered from -// weights alone. The system prompt is the single tested source in -// router.KnowledgePrompt. -// -// Split out of PhraseQuery so PhraseWorld sends the workstation model the same -// bytes the resident model gets. Prompt parity across two models is a stated -// constraint (CLAUDE.md), and two copies of a prompt is how it stops holding. -func (p *LLMPhraser) knowledgePrompt(utterance string) (sys, user string) { - return persona.Prepend(p.cfg.ContextBlock, router.KnowledgePrompt()), - fmt.Sprintf("Пользователь спрашивает: \"%s\".", utterance) -} - // PhraseSelf answers a question about her from her own description. Same // discipline as the evidence branch — say only what the text says — and a // different opener, because "вот что я нашла: я — твоя помощница" says she @@ -505,17 +415,7 @@ func (p *LLMPhraser) knowledgePrompt(utterance string) (sys, user string) { // On any error it reads the description out rather than ship a fragment. That // is already a readable answer, which is why this needs no separate fallback. func (p *LLMPhraser) PhraseSelf(ctx context.Context, utterance, description string) (string, error) { - sys := persona.Prepend(p.cfg.ContextBlock, - "Он спрашивает о тебе. Отвечай ТОЛЬКО по описанию, которое тебе дали: всё, что ты говоришь о себе, должно быть в нём. "+ - "Не добавляй умений, которых там нет, и не догадывайся. Не начинай с \"вот что я нашла\" — ты говоришь о себе, а не о находке. "+ - // The gender rule is stated WITHOUT the "-ла" example the other - // prompts carry. Measured on the box: a 1.7B reads that as an - // instruction to use the past tense and answers "я вела заметки, - // управляла домом" — she describes what she does, in the present, - // and the past tense makes a live capability sound finished. - "Отвечай по-русски, коротко и своими словами, в настоящем времени — ты описываешь, что делаешь сейчас. О себе говори в женском роде. "+ - "Он мужчина, обращайся к нему на \"ты\". Отвечай ТОЛЬКО одним объектом JSON: {\"response\": \"...\", \"mood\": \"neutral\"}.") - prompt := fmt.Sprintf("Он спрашивает: %q\n\nТвоё описание:\n%s\n\nОтветь ему на то, что он спросил.", utterance, description) + sys, prompt := p.selfPrompt(utterance, description) resp, err := p.chatWithSystem(ctx, sys, prompt, 768) text, _, perr := parseResponseMood(resp) if err != nil || perr != nil { @@ -530,70 +430,3 @@ func (p *LLMPhraser) PhraseSelf(ctx context.Context, utterance, description stri } return description, nil } - -// evidencePrompt — the sources branch: read these, add nothing. Shared with -// PhraseWorld for the same reason as knowledgePrompt. -func (p *LLMPhraser) evidencePrompt(utterance string, notes []string) (sys, user string) { - return p.querySystemPrompt(), fmt.Sprintf( - "Он спрашивает: \"%s\"\n\nИсточники:\n%s\nОтветь ему коротко и своими словами, опираясь только на эти источники. Если ответа в них нет — так и скажи.", - utterance, evidenceBlock(notes), - ) -} - -// querySystemPrompt returns the system prompt for the evidence branch of -// PhraseQuery. Prepends the configured persona when set. -// -// Evidence-first, and that is the whole point of this prompt. Every source that -// reaches PhraseQuery with something in hand — his notes, a stored fact, a page, -// a live search, a ZIM article — arrives as numbered sources, and the model's -// job here is to READ them, not to recall. A 1.7B asked a world question -// answers from its weights with total confidence and no signal that it is -// guessing; that is how "Война и мир" got Левитан as its author. The rule that -// prevents it is stated three ways, because one way did not hold: answer from -// the sources, say plainly when they do not answer, add nothing of your own. -// -// It no longer says "заметки". The sources are not always his notes, and -// calling a Wikipedia paragraph his note both misleads him and licenses the -// model to blur where an answer came from. -// -// No self-introduction here: the persona block prepended one line above already -// says who she is, same as router.KnowledgePrompt. -// -// The opener is deliberate and stays: the fixed prefix is what marks the answer -// as a lookup rather than as something she knows. The grammar examples are not -// deliberate — same defect chatSystemPrompt had, where a 1.7B copies a quoted -// word instead of generalising from it. Stated as morphology instead. -func (p *LLMPhraser) querySystemPrompt() string { - base := "Ты отвечаешь ему по источникам, которые тебе дали. Отвечай ТОЛЬКО по ним: всё, что ты говоришь, должно быть написано в источниках. " + - "Если ответа в них нет — так и скажи и на этом остановись; не добавляй ничего из своих знаний и не догадывайся. " + - "Не приплетай прошлые реплики разговора. " + - "Отвечай по-русски, коротко и своими словами, начинай с \"вот что я нашла: \". О себе — в женском роде, глаголы в прошедшем времени с окончанием -ла. Он мужчина, обращайся к нему на \"ты\". Отвечай ТОЛЬКО одним объектом JSON: {\"response\": \"...\", \"mood\": \"neutral\"}." - return persona.Prepend(p.cfg.ContextBlock, base) -} - -// evidenceBlock renders the sources for the evidence branch of PhraseQuery. -// -// Numbered lines, one source each, rather than the quoted semicolon-joined -// string this used to build. Two reasons, both measured on small models: a -// numbered list survives being long, where a run-on quoted string blurs into -// one claim the model then merges; and the numbering gives it something to -// answer FROM, which is what makes "этого в источниках нет" reachable at all. -func evidenceBlock(sources []string) string { - var b strings.Builder - for i, s := range sources { - fmt.Fprintf(&b, "[%d] %s\n", i+1, s) - } - return b.String() -} - -// nonEmpty drops blank sources and trims the rest, without touching the -// caller's slice. -func nonEmpty(sources []string) []string { - out := make([]string, 0, len(sources)) - for _, s := range sources { - if s = strings.TrimSpace(s); s != "" { - out = append(out, s) - } - } - return out -} diff --git a/internal/phraser/prompts.go b/internal/phraser/prompts.go new file mode 100644 index 0000000..a505894 --- /dev/null +++ b/internal/phraser/prompts.go @@ -0,0 +1,193 @@ +// phraser/prompts.go — every system prompt this package sends, and the two +// helpers that render a user turn. +// +// The text is load-bearing and none of it is edited here: llm/check_prompt_parity.py +// in the training workspace pins the Go prompts to the relabelling ones, so a +// reworded line breaks a contract silently. Each prompt carries the measurement +// that produced its shape; read the comment before touching the string. +// +// One rule runs through all of them. She is feminine about herself (-ла), he is +// male and addressed as "ты", and she talks TO him and never about him. See +// CheckFeminine, CheckAddress and CheckCringe in eval/checks.go. +package phraser + +import ( + "fmt" + "strings" + + "github.com/kami/maven/internal/persona" + "github.com/kami/maven/internal/router" +) + +// chatSystemPrompt returns the system prompt for conversational chat. +// Prepends the shared context block when the phraser has one. +func chatSystemPrompt(block func() string) string { + // No self-introduction here: the persona block prepended one line above + // already says who she is, same as router.KnowledgePrompt. + // + // The grammar examples used to be full clauses: ("я подумала", "я рада") + // for her, ("ты сказал", "ты забыл") for him. A 1.7B copies those rather + // than generalising from them. Observed on the box 2026-08-01: all three + // chat replies in one session opened with "Я подумала, что ...", and one + // ended "...немного тревожусь. ты сказал" — the second example pasted onto + // the end of a finished sentence, which reads as a truncation but is not. + // + // So: contrastive pairs instead of usable openers. "рада, не рад" states + // the rule as a correction, and short predicatives do not hand the model a + // sentence frame to start with. The him-examples are gone entirely; the + // "ты" instruction carries that on its own and those two produced the + // worst output. The last line says outright not to echo the instructions, + // because a small model will otherwise treat any quoted string as licence. + // + // Amended the same day: with the openers gone the tic went with them, but + // "не забыл ли я" appeared — masculine, about herself. The old "я подумала" + // had been suppressing that by accident, being a feminine past tense the + // model could copy. Two short predicatives are not enough signal on their + // own, so the rule is now stated as morphology (-ла) rather than as a pair + // of words. A suffix rule generalises where an example only gets copied. + base := `Ты разговариваешь с хозяином. + +О себе — в женском роде: "рада", не "рад"; "поняла", не "понял". Все свои глаголы в прошедшем времени оканчивай на -ла: сделала, забыла, записала, подумала. Он мужчина: обращайся к нему на "ты", в мужском роде. Никогда не "вы"/"ваш" и никогда "он"/"его" — ты говоришь ему, а не о нём. + +Отвечай по-русски, коротко: одна-три фразы, живым языком. Ты доброжелательная, тебе интересно, но чувства не изображай. Не повторяй формулировки из этой инструкции — отвечай своими словами. + +Отвечай ТОЛЬКО одним объектом JSON: {"response": "...", "mood": "neutral"}. В "response" — твой ответ. В "mood" — ровно одно из: neutral, happy, thinking, tired, confused.` + return persona.Prepend(block, base) +} + +// nudgeSystem — the phrasing contract for nudges. +// +// Written as filled-in examples, not as a schema with "..." in it. A 0.8B +// copies whatever sits in the response slot, so a literal placeholder there +// teaches it to answer with the placeholder. Measured: 7/15 nudges came back +// as "..." before this. See docs/evals/2026-07-31-phrasing.md. +// +// Russian only, feminine self-reference, second person masculine (the owner is +// a man). She talks TO him, informally, singular — never "вы", never "он". +// One short sentence — the nudge is spoken aloud. +// +// What the ban on обращения forbids is pet names ("дорогой", "милый"), not his +// name: "Ками, ноутбук на трёх процентах" is exactly how she talks, and the +// unqualified word read as forbidding that too. Hence "ласковые обращения". +// +// The examples also never claim a physical act. She has no hands and no smart +// plug — she can tell him the battery is at three percent, she cannot put the +// laptop on charge. An example that says she did teaches the model to invent +// actions Maven never took, which is worse than a missing nudge. +const nudgeSystem = `Ты — Maven, домашняя ассистентка. О себе говоришь в женском роде ("я проверила", "я записала"). Владелец — мужчина, обращайся к нему в мужском роде ("ты пил", "ты забыл"). +Говоришь с ним на "ты", в единственном числе ("выпей", "встань"). Никогда не "вы"/"вас"/"ваш" и никогда "он"/"его" — ты говоришь ему, а не о нём. + +Пиши ОДНО короткое напоминание по-русски: не больше 120 символов и не больше 16 слов. Только по делу. + +Запрещено: ласковые обращения ("дорогой", "милый"), эмодзи, извинения ("прости", "извини"), вопросы о самочувствии, похвала, больше одного восклицательного знака, английские слова кроме имён сервисов. + +Отвечай ТОЛЬКО одним объектом JSON с полями "response" и "mood". +"response" — сам текст напоминания. +"mood" — ровно одно из: neutral, happy, thinking, tired, confused. + +Так выглядит правильный ответ по форме. Темы здесь посторонние — их в запросе не будет: +{"response": "Стиральная машина закончила. Развесь бельё.", "mood": "neutral"} +{"response": "Ками, ноутбук на трёх процентах. Поставь его на зарядку.", "mood": "confused"} + +Это примеры ФОРМЫ, а не темы. Пиши только про ту ситуацию, которую тебе дали в запросе. Не копируй примеры и никогда не пиши "..." в поле response.` + +func (p *LLMPhraser) systemPrompt() string { + return persona.Prepend(p.cfg.ContextBlock, nudgeSystem) +} + +// knowledgePrompt — the no-sources branch: a world question, answered from +// weights alone. The system prompt is the single tested source in +// router.KnowledgePrompt. +// +// Split out of PhraseQuery so PhraseWorld sends the workstation model the same +// bytes the resident model gets. Prompt parity across two models is a stated +// constraint (CLAUDE.md), and two copies of a prompt is how it stops holding. +func (p *LLMPhraser) knowledgePrompt(utterance string) (sys, user string) { + return persona.Prepend(p.cfg.ContextBlock, router.KnowledgePrompt()), + fmt.Sprintf("Пользователь спрашивает: \"%s\".", utterance) +} + +// selfPrompt — the one subject she does not have to read about. Same +// discipline as the evidence branch, say only what the text says, and a +// different opener: "вот что я нашла: я — твоя помощница" says she looked +// herself up (Vikunja #555), and she did not. +func (p *LLMPhraser) selfPrompt(utterance, description string) (sys, user string) { + sys = persona.Prepend(p.cfg.ContextBlock, + "Он спрашивает о тебе. Отвечай ТОЛЬКО по описанию, которое тебе дали: всё, что ты говоришь о себе, должно быть в нём. "+ + "Не добавляй умений, которых там нет, и не догадывайся. Не начинай с \"вот что я нашла\" — ты говоришь о себе, а не о находке. "+ + // The gender rule is stated WITHOUT the "-ла" example the other + // prompts carry. Measured on the box: a 1.7B reads that as an + // instruction to use the past tense and answers "я вела заметки, + // управляла домом" — she describes what she does, in the present, + // and the past tense makes a live capability sound finished. + "Отвечай по-русски, коротко и своими словами, в настоящем времени — ты описываешь, что делаешь сейчас. О себе говори в женском роде. "+ + "Он мужчина, обращайся к нему на \"ты\". Отвечай ТОЛЬКО одним объектом JSON: {\"response\": \"...\", \"mood\": \"neutral\"}.") + return sys, fmt.Sprintf("Он спрашивает: %q\n\nТвоё описание:\n%s\n\nОтветь ему на то, что он спросил.", utterance, description) +} + +// evidencePrompt — the sources branch: read these, add nothing. Shared with +// PhraseWorld for the same reason as knowledgePrompt. +func (p *LLMPhraser) evidencePrompt(utterance string, notes []string) (sys, user string) { + return p.querySystemPrompt(), fmt.Sprintf( + "Он спрашивает: \"%s\"\n\nИсточники:\n%s\nОтветь ему коротко и своими словами, опираясь только на эти источники. Если ответа в них нет — так и скажи.", + utterance, evidenceBlock(notes), + ) +} + +// querySystemPrompt returns the system prompt for the evidence branch of +// PhraseQuery. Prepends the configured persona when set. +// +// Evidence-first, and that is the whole point of this prompt. Every source that +// reaches PhraseQuery with something in hand — his notes, a stored fact, a page, +// a live search, a ZIM article — arrives as numbered sources, and the model's +// job here is to READ them, not to recall. A 1.7B asked a world question +// answers from its weights with total confidence and no signal that it is +// guessing; that is how "Война и мир" got Левитан as its author. The rule that +// prevents it is stated three ways, because one way did not hold: answer from +// the sources, say plainly when they do not answer, add nothing of your own. +// +// It no longer says "заметки". The sources are not always his notes, and +// calling a Wikipedia paragraph his note both misleads him and licenses the +// model to blur where an answer came from. +// +// No self-introduction here: the persona block prepended one line above already +// says who she is, same as router.KnowledgePrompt. +// +// The opener is deliberate and stays: the fixed prefix is what marks the answer +// as a lookup rather than as something she knows. The grammar examples are not +// deliberate — same defect chatSystemPrompt had, where a 1.7B copies a quoted +// word instead of generalising from it. Stated as morphology instead. +func (p *LLMPhraser) querySystemPrompt() string { + base := "Ты отвечаешь ему по источникам, которые тебе дали. Отвечай ТОЛЬКО по ним: всё, что ты говоришь, должно быть написано в источниках. " + + "Если ответа в них нет — так и скажи и на этом остановись; не добавляй ничего из своих знаний и не догадывайся. " + + "Не приплетай прошлые реплики разговора. " + + "Отвечай по-русски, коротко и своими словами, начинай с \"вот что я нашла: \". О себе — в женском роде, глаголы в прошедшем времени с окончанием -ла. Он мужчина, обращайся к нему на \"ты\". Отвечай ТОЛЬКО одним объектом JSON: {\"response\": \"...\", \"mood\": \"neutral\"}." + return persona.Prepend(p.cfg.ContextBlock, base) +} + +// evidenceBlock renders the sources for the evidence branch of PhraseQuery. +// +// Numbered lines, one source each, rather than the quoted semicolon-joined +// string this used to build. Two reasons, both measured on small models: a +// numbered list survives being long, where a run-on quoted string blurs into +// one claim the model then merges; and the numbering gives it something to +// answer FROM, which is what makes "этого в источниках нет" reachable at all. +func evidenceBlock(sources []string) string { + var b strings.Builder + for i, s := range sources { + fmt.Fprintf(&b, "[%d] %s\n", i+1, s) + } + return b.String() +} + +// nonEmpty drops blank sources and trims the rest, without touching the +// caller's slice. +func nonEmpty(sources []string) []string { + out := make([]string, 0, len(sources)) + for _, s := range sources { + if s = strings.TrimSpace(s); s != "" { + out = append(out, s) + } + } + return out +}