diff --git a/cmd/mavend/replier_llm.go b/cmd/mavend/replier_llm.go index bbba111..e215a1d 100644 --- a/cmd/mavend/replier_llm.go +++ b/cmd/mavend/replier_llm.go @@ -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 { diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index c61a3e1..05624a8 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -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 {