phraser: add Replier, the reply path lifted out of package main (V-396)
llmReplier lived in cmd/mavend, so the confirmation he hears after every fact, note and reminder was the one phrasing path nothing could import or score. Replier owns the prompt, the call and the parsing, and returns its errors instead of hiding them — a dead model shows up as an error rather than as bad phrasing. It has no stub fallback of its own; the daemon keeps that. StripThink is exported for the daemon's own model callers.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
// 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.
|
||||
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) }
|
||||
@@ -0,0 +1,90 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
type mockCompleter struct {
|
||||
out string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m mockCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err }
|
||||
|
||||
func TestReplierReturnsLLMReply(t *testing.T) {
|
||||
r := NewReplier(mockCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil)
|
||||
got, err := r.PhraseReply(context.Background(), noteDecision())
|
||||
if err != nil || got != "записала, кофе закончился" {
|
||||
t.Errorf("got %q, %v, want %q, nil", got, err, "записала, кофе закончился")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplierFallsBackToPlainText(t *testing.T) {
|
||||
r := NewReplier(mockCompleter{out: "записала, кофе закончился"}, nil)
|
||||
got, err := r.PhraseReply(context.Background(), noteDecision())
|
||||
if err != nil || got != "записала, кофе закончился" {
|
||||
t.Errorf("got %q, %v, want %q, nil", got, err, "записала, кофе закончился")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplierReportsTheModelError(t *testing.T) {
|
||||
r := NewReplier(mockCompleter{err: errTestLLMDown}, nil)
|
||||
got, err := r.PhraseReply(context.Background(), noteDecision())
|
||||
if err == nil {
|
||||
t.Errorf("got %q, nil error — a dead model must be reported, not phrased around", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A fragment the grammar left half-open is a failed generation. It must come
|
||||
// back as an error so the daemon reaches its stub, not as a reply.
|
||||
func TestReplierRejectsBrokenJSON(t *testing.T) {
|
||||
r := NewReplier(mockCompleter{out: `{"response":"запис`}, nil)
|
||||
got, err := r.PhraseReply(context.Background(), noteDecision())
|
||||
if err == nil || got != "" {
|
||||
t.Errorf("got %q, %v, want empty and an error", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplierEmptyOutputIsEmpty(t *testing.T) {
|
||||
r := NewReplier(mockCompleter{out: ""}, nil)
|
||||
got, err := r.PhraseReply(context.Background(), noteDecision())
|
||||
if err != nil || got != "" {
|
||||
t.Errorf("got %q, %v, want empty and no error", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// grammarRecorder captures the request so the grammar can be asserted on.
|
||||
type grammarRecorder struct{ req llm.Req }
|
||||
|
||||
func (g *grammarRecorder) Complete(_ context.Context, r llm.Req) (string, error) {
|
||||
g.req = r
|
||||
return `{"response":"записала","mood":"neutral"}`, nil
|
||||
}
|
||||
|
||||
func TestReplierCarriesTheResponseGrammar(t *testing.T) {
|
||||
rec := &grammarRecorder{}
|
||||
r := NewReplier(rec, nil)
|
||||
if _, err := r.PhraseReply(context.Background(), noteDecision()); err != nil {
|
||||
t.Fatalf("PhraseReply: %v", err)
|
||||
}
|
||||
if rec.req.Grammar != ResponseGrammar {
|
||||
t.Errorf("grammar = %q, want ResponseGrammar", rec.req.Grammar)
|
||||
}
|
||||
if rec.req.System != ReplySystemPrompt {
|
||||
t.Errorf("system prompt = %q, want ReplySystemPrompt", rec.req.System)
|
||||
}
|
||||
}
|
||||
|
||||
func noteDecision() router.Decision {
|
||||
return router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}}
|
||||
}
|
||||
|
||||
var errTestLLMDown = errTest("llm down")
|
||||
|
||||
type errTest string
|
||||
|
||||
func (e errTest) Error() string { return string(e) }
|
||||
Reference in New Issue
Block a user