Files
claude 5187f3bd14 a captured fact is confirmed in his words, not the model's (V-592)
"я выпил воды" came back as "Проверила, что ты выпел стакан воды". The
verb is not a Russian word, the glass was never mentioned, and nothing
had been checked.

The store was right throughout: DefaultFactParser files this as
key=water value="drank", and no row anywhere held "стакан". Every
Russian word in that sentence was generated. replyContext hands the
model "записала факт: water \"drank\"", so the model had nothing to
phrase FROM and reached for the nearest plausible sentence — the example
in ReplySystemPrompt, which was literally "Записала, что ты выпил стакан
воды."

So the fact path stops generating, the way the note payload did in
V-576. The confirmation is a fixed deck frame with his own sentence in
it, in both repliers, and the prompt example is contentless now. The
stub also read the parser's KEY back at him, which is machine
vocabulary he never said.

The clarify half of this — a fact clarified out of "запиши" answers with
"запиши" and nothing else — lands with V-593, which touches the same
lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 02:58:37 +04:00

118 lines
5.1 KiB
Go

// 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 <think> 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) }