diff --git a/internal/phraser/replier.go b/internal/phraser/replier.go new file mode 100644 index 0000000..b61246a --- /dev/null +++ b/internal/phraser/replier.go @@ -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 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) } diff --git a/internal/phraser/replier_test.go b/internal/phraser/replier_test.go new file mode 100644 index 0000000..9e3e026 --- /dev/null +++ b/internal/phraser/replier_test.go @@ -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) }