// phraser/replier.go — reactive reply phrasing, the confirmation he hears // after every fact, note and reminder. // // It lived in cmd/mavend as package main until Vikunja #396, which meant the // most frequently heard sentence Maven says was the one path the phrasing eval // could not import, let alone score. Nothing here talks to the daemon: the // caller supplies the completer and the context block, and cmd/mavend keeps the // stub fallback so a model error still answers. package phraser import ( "context" "strings" "time" "github.com/kami/maven/internal/llm" "github.com/kami/maven/internal/persona" "github.com/kami/maven/internal/router" ) // Completer is the model seam for the replier, a subset of router.Completer. // *llm.Client satisfies it. type Completer interface { Complete(ctx context.Context, r llm.Req) (string, error) } // replyTimeout bounds one reply. Generous because the resident model on the CPU // floor is slow and the caller has a deterministic fallback anyway. const replyTimeout = 60 * time.Second // ReplySystemPrompt — the reactive confirmation contract: one short Russian // sentence, feminine self-reference, informal address, no question. // // The example is deliberately contentless. It used to be "Записала, что ты // выпил стакан воды.", and the model copied the glass into a real reply about // water he never described that way (V-592). An example carrying a plausible // completion of the input is an invitation to reuse it. const ReplySystemPrompt = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Владелец — мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), по-русски, спокойно и без официальных формулировок. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused). Пример: {"response": "Хорошо, напомню.", "mood": "neutral"} Никогда не пиши "..." в поле response.` // Replier phrases reactive confirmations with the resident model. It has no // fallback of its own: an error is returned, and the daemon answers from the // deterministic stub. That is also what makes it scorable — a dead server shows // up as an error rather than as bad phrasing. type Replier struct { c Completer // block renders the shared context block per turn (who he is, the time). // nil ⇒ the prompt stands alone. block func() string } // NewReplier builds a replier over c. block may be nil. func NewReplier(c Completer, block func() string) *Replier { return &Replier{c: c, block: block} } // PhraseReply returns the confirmation for one decision. An empty string with a // nil error means the model produced nothing usable, which the caller must // treat exactly like an error. func (r *Replier) PhraseReply(ctx context.Context, d router.Decision) (string, error) { ctx, cancel := context.WithTimeout(ctx, replyTimeout) defer cancel() out, err := r.c.Complete(ctx, llm.Req{ System: persona.Prepend(r.block, ReplySystemPrompt), User: replyContext(d), Grammar: ResponseGrammar, MaxTokens: 512, RepeatPenalty: 1.3, }) if err != nil { return "", err } out = stripThink(out) if response, _, perr := parseResponseMood(out); perr != nil { return "", perr } else if response != "" { return response, nil } // fallback: the model answered in bare prose, which is fine here. return firstSentence(out), nil } // firstSentence trims the model's output to a single clean confirmation: first // line, first sentence, whitespace-normalized — the last-line defense against a // small model that rambles past the first period despite the prompt + stop. func firstSentence(s string) string { s = strings.TrimSpace(s) if i := strings.IndexByte(s, '\n'); i >= 0 { s = s[:i] } // keep up to and including the first sentence-ending punctuation. if i := strings.IndexAny(s, ".!?"); i >= 0 { s = s[:i+1] } return strings.TrimSpace(s) } // replyContext renders the decision into a compact RU description for the model. func replyContext(d router.Decision) string { switch d.Intent { case router.IntentFact: return "записала факт: " + d.Slots.Key + " " + d.Slots.Value case router.IntentNote: return "сохранила заметку: " + d.Slots.Text case router.IntentReminder: return "поставила напоминание: " + d.Slots.Text default: return string(d.Intent) + ": " + d.Slots.Text } } // StripThink removes the block a Thinking-variant model emits before its // answer. Exported for the daemon's own model callers, which parse output that // never passes through a phraser method. func StripThink(s string) string { return stripThink(s) }