Fix the nudge phrasing prompt: stop teaching the model to echo the example

The system prompt showed the JSON contract as {"response": "..."} and the
user prompt repeated it. A 0.8B copies whatever sits in the response slot, so
7 of 15 nudges came back as literally "...".

Changes, all prompt-side — the {"response","mood"} contract is unchanged:
- nudge system prompt is Russian, feminine self-reference, with filled-in
  examples on topics that never appear as rules, so copying them is visible
- rule names get a Russian gloss and a required keyword, named last in the
  prompt where a small model weights it hardest
- durations render in Russian, not English
- the no-parse fallback says something Russian instead of "water — care",
  which was going straight to a Russian piper voice
- same "..." placeholder removed from replier_llm.go

Scored on internal/phraser/eval: 0/15 -> 13/15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
This commit is contained in:
kami
2026-07-31 12:23:03 +04:00
parent 1c4eab2107
commit a40bc559d5
2 changed files with 139 additions and 14 deletions
+3 -1
View File
@@ -29,7 +29,9 @@ func newLLMReplier(c completer) *llmReplier {
return &llmReplier{c: c, stub: voice.NewStubReplier()}
}
const replySystem = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), тепло и по-русски. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Respond ONLY with valid JSON: {"response": "...", "mood": "neutral"}.`
const replySystem = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), тепло и по-русски. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused).
Пример: {"response": "Записала, что ты выпил стакан воды.", "mood": "neutral"}
Никогда не пиши "..." в поле response.`
func (r *llmReplier) Reply(d router.Decision) string {
if d.Clarify {
+136 -13
View File
@@ -184,7 +184,10 @@ func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (deliver
body, _ = parsePhrase(resp)
}
if body == "" {
body = fmt.Sprintf("%s — %s", c.Rule.Name, sevLabel(c.Severity))
// The model said nothing usable. Say it in Russian anyway — this text
// goes straight to a Russian piper voice, so the old "water — care"
// fallback was unspeakable.
body = fallbackNudge(c)
}
if mood == "" {
mood = "neutral"
@@ -428,8 +431,33 @@ func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, ma
return stripThink(content), nil
}
// 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 PHRASING-EVAL-31-07-2026.md.
//
// Russian only, feminine self-reference, second person masculine (the owner is
// a man). One short sentence — the nudge is spoken aloud.
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 {
base := `You are maven, a self-hosted personal assistant. Generate brief, natural nudge messages in the user's language (Russian or English). Respond ONLY with valid JSON: {"response": "full voice message", "mood": "neutral"}. "response" is what the user hears; "mood" reflects maven's tone (neutral/happy/thinking/tired/confused).`
base := nudgeSystem
if p.cfg.Persona != "" {
base = p.cfg.Persona + "\n\n" + base
}
@@ -446,22 +474,117 @@ func (p *LLMPhraser) querySystemPrompt() string {
return base
}
// ruleTopics — Russian gloss for each built-in rule name. The rule names are
// English identifiers; a 0.8B asked to nudge about "netdata_critical" writes
// about nothing. The daemon knows what its own rules mean, so it says so.
var ruleTopics = map[string]string{
"water": "он давно не пил воду",
"meal": "он давно не ел",
"break": "он давно без перерыва, пора встать и размяться",
"service_down": "сервис не отвечает, лежит",
"netdata_critical": "критический алярм в netdata, проблема с диском или местом",
}
// ruleKeywords — the word the message must contain. The 0.8B drifts to
// whatever topic it saw last unless the required word is named outright.
var ruleKeywords = map[string]string{
"water": "воду",
"meal": "поешь",
"break": "перерыв",
"service_down": "сервис",
"netdata_critical": "диск",
}
// ruleTopic turns a rule name into a Russian description of the situation.
// "routine:зарядка" and "morning:утро" carry their own Russian suffix.
func ruleTopic(rule string) string {
if t, ok := ruleTopics[rule]; ok {
return t
}
if i := strings.IndexByte(rule, ':'); i > 0 && i+1 < len(rule) {
switch rule[:i] {
case "morning":
return "утро, пора начать день: " + rule[i+1:]
default:
return "пора сделать по распорядку: " + rule[i+1:]
}
}
return rule
}
// ruleKeyword — the word the nudge must contain, or "" when the rule name's
// own Russian suffix already is that word.
func ruleKeyword(rule string) string {
if k, ok := ruleKeywords[rule]; ok {
return k
}
if i := strings.IndexByte(rule, ':'); i > 0 && i+1 < len(rule) {
return rule[i+1:]
}
return ""
}
// ruDur — duration in Russian. humanDur is English and its output was landing
// verbatim in the message.
func ruDur(d time.Duration) string {
if d < 0 {
d = 0
}
h, m := int(d.Hours()), int(d.Minutes())%60
switch {
case h >= 2:
return fmt.Sprintf("%d ч", h)
case h == 1 && m >= 30:
return "полтора часа"
case h == 1:
return "час"
default:
return fmt.Sprintf("%d мин", m)
}
}
// fallbackNudge — plain Russian for when the model returns nothing parseable.
var fallbackNudges = map[string]string{
"water": "Ты давно не пил воду.",
"meal": "Ты давно не ел, поешь.",
"break": "Пора сделать перерыв.",
"service_down": "Сервис не отвечает.",
"netdata_critical": "Критический алярм: проверь диск.",
}
func fallbackNudge(c loop.Candidate) string {
if s, ok := fallbackNudges[c.Rule.Name]; ok {
return s
}
if kw := ruleKeyword(c.Rule.Name); kw != "" {
return "Напоминаю: " + kw + "."
}
return "Напоминаю о деле."
}
func buildNudgePrompt(c loop.Candidate) string {
var ctxParts []string
ctxParts = append(ctxParts, fmt.Sprintf("Rule: %s", c.Rule.Name))
ctxParts = append(ctxParts, fmt.Sprintf("Severity: %s", sevLabel(c.Severity)))
ctxParts = append(ctxParts, "Ситуация: "+ruleTopic(c.Rule.Name))
if f, ok := c.State.Facts[c.Rule.Name]; ok && f.Key != "" && f.Key != c.Rule.Name {
ctxParts = append(ctxParts, "Что именно: "+f.Key)
}
if d, ok := c.State.Since(c.Rule.Name); ok {
ctxParts = append(ctxParts, fmt.Sprintf("Duration since last event: %s", humanDur(d)))
ctxParts = append(ctxParts, "Прошло: "+ruDur(d))
}
switch sevLabel(c.Severity) {
case "alarm":
ctxParts = append(ctxParts, "Срочно, скажи прямо.")
case "ops":
ctxParts = append(ctxParts, "Это про сервер, не про здоровье.")
}
tail := "Напиши напоминание про эту ситуацию. Одно предложение, по-русски, в JSON."
if kw := ruleKeyword(c.Rule.Name); kw != "" {
// Last line on purpose: a 0.8B weights the end of the prompt hardest,
// and without the required word it drifts back to the examples.
tail += " Ответ ДОЛЖЕН содержать слово «" + kw + "»."
}
return fmt.Sprintf(
`Generate a nudge message. Context:
%s
Respond as JSON: {"response": "...", "mood": "..."}`,
strings.Join(ctxParts, "\n"),
)
return strings.Join(ctxParts, "\n") + "\n\n" + tail
}
type responseMood struct {