5187f3bd14
"я выпил воды" 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>
118 lines
5.1 KiB
Go
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) }
|