diff --git a/cmd/mavend/capture.go b/cmd/mavend/capture.go index 604f96a..8209a74 100644 --- a/cmd/mavend/capture.go +++ b/cmd/mavend/capture.go @@ -100,7 +100,7 @@ func (l llmCompleter) Complete(ctx context.Context, system, user string) (string // grammar, or a llama-server too old to honour one, gets the plain text it used // to get rather than an empty meeting summary. func unwrapSummary(raw string) string { - s := stripThink(strings.TrimSpace(raw)) + s := phraser.StripThink(strings.TrimSpace(raw)) start := strings.Index(s, "{") end := strings.LastIndex(s, "}") if start < 0 || end <= start { diff --git a/cmd/mavend/replier_llm.go b/cmd/mavend/replier_llm.go index db6110d..20967dd 100644 --- a/cmd/mavend/replier_llm.go +++ b/cmd/mavend/replier_llm.go @@ -2,122 +2,33 @@ package main import ( "context" - "encoding/json" - "strings" - "time" - "github.com/kami/maven/internal/llm" - "github.com/kami/maven/internal/persona" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/voice" ) -// completer is the LLM seam for the replier (subset of router.Completer). -// *llm.Client satisfies it. -type completer interface { - Complete(ctx context.Context, r llm.Req) (string, error) -} - -// llmReplier phrases reactive confirmations with the resident model -// (Qwen3-1.7B). Stub is the -// floor on any error (offline-safe). Maven speaks as "she", feminine RU. +// llmReplier is the daemon-side wiring around phraser.Replier: it owns the +// deterministic floor, and nothing else. The phrasing itself, the prompt and the +// output parsing live in internal/phraser so the eval can score them (#396). type llmReplier struct { - c completer + p *phraser.Replier stub *voice.StubReplier - - // block renders the shared context block per turn (who he is, the time). - // nil ⇒ the prompt stands alone. - block func() string } -func newLLMReplier(c completer, block func() string) *llmReplier { - return &llmReplier{c: c, stub: voice.NewStubReplier(), block: block} +func newLLMReplier(c phraser.Completer, block func() string) *llmReplier { + return &llmReplier{p: phraser.NewReplier(c, block), stub: voice.NewStubReplier()} } -const replySystem = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Владелец — мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), по-русски, спокойно и без официальных формулировок. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused). -Пример: {"response": "Записала, что ты выпил стакан воды.", "mood": "neutral"} -Никогда не пиши "..." в поле response.` - +// Reply never fails: a clarify, a model error and an unusable generation all +// answer from the stub, which is what keeps a turn from breaking on the model. func (r *llmReplier) Reply(d router.Decision) string { if d.Clarify { return r.stub.Reply(d) } - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - out, err := r.c.Complete(ctx, llm.Req{ - System: persona.Prepend(r.block, replySystem), - User: replyContext(d), - Grammar: phraser.ResponseGrammar, - MaxTokens: 512, - RepeatPenalty: 1.3, - }) - if err != nil { + out, err := r.p.PhraseReply(context.Background(), d) + if err != nil || out == "" { return r.stub.Reply(d) } - out = stripThink(out) - if response, _ := parseResponseMood(out); response != "" { - return response - } - // fallback: try plain-text parsing - if out = firstSentence(out); out != "" { - return out - } - return r.stub.Reply(d) -} - -// 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. -// stripThink removes the block that Thinking-variant models emit. -func stripThink(s string) string { - if i := strings.LastIndex(s, ""); i >= 0 { - s = strings.TrimSpace(s[i+8:]) - } - return s -} - -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) -} - -// parseResponseMood extracts {"response","mood"} from LLM output, tolerant -// of thinking tokens and extra text before/after the JSON block. -func parseResponseMood(raw string) (response, mood string) { - cleaned := strings.TrimSpace(raw) - start := strings.Index(cleaned, "{") - end := strings.LastIndex(cleaned, "}") - if start < 0 || end < 0 || end <= start { - return "", "" - } - var parsed struct { - Response string `json:"response"` - Mood string `json:"mood"` - } - if err := json.Unmarshal([]byte(cleaned[start:end+1]), &parsed); err != nil { - return "", "" - } - return parsed.Response, parsed.Mood -} - -// 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 - } + return out } diff --git a/cmd/mavend/replier_llm_test.go b/cmd/mavend/replier_llm_test.go index 5e084f9..fae6d39 100644 --- a/cmd/mavend/replier_llm_test.go +++ b/cmd/mavend/replier_llm_test.go @@ -5,28 +5,22 @@ import ( "testing" "github.com/kami/maven/internal/llm" - "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/voice" ) -type mockCompleter struct { +// The phrasing itself is tested in internal/phraser. What is left here is the +// only thing the daemon adds: the stub floor, on the three ways a reply can +// fail to arrive. +type stubCompleter struct { out string err error } -func (m mockCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err } +func (s stubCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return s.out, s.err } -func TestLLMReplierReturnsLLMReply(t *testing.T) { - r := newLLMReplier(mockCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil) - got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}}) - if got != "записала, кофе закончился" { - t.Errorf("got %q, want %q", got, "записала, кофе закончился") - } -} - -func TestLLMReplierFallsBackToPlainText(t *testing.T) { - r := newLLMReplier(mockCompleter{out: "записала, кофе закончился"}, nil) +func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) { + r := newLLMReplier(stubCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil) got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}}) if got != "записала, кофе закончился" { t.Errorf("got %q, want %q", got, "записала, кофе закончился") @@ -34,54 +28,30 @@ func TestLLMReplierFallsBackToPlainText(t *testing.T) { } func TestLLMReplierFallsBackToStubOnError(t *testing.T) { - r := newLLMReplier(mockCompleter{err: errTestLLMDown}, nil) - noteDec := router.Decision{Intent: router.IntentNote} - got := r.Reply(noteDec) - want := voice.NewStubReplier().Reply(noteDec) - if got != want { - t.Errorf("on llm error: got %q, want stub %q", got, want) - } + r := newLLMReplier(stubCompleter{err: errReplierTest}, nil) + assertStub(t, r, router.Decision{Intent: router.IntentNote}, "llm error") } func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) { - r := newLLMReplier(mockCompleter{out: ""}, nil) - noteDec := router.Decision{Intent: router.IntentNote} - got := r.Reply(noteDec) - want := voice.NewStubReplier().Reply(noteDec) - if got != want { - t.Errorf("on empty llm: got %q, want stub %q", got, want) - } + r := newLLMReplier(stubCompleter{out: ""}, nil) + assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm") } func TestLLMReplierClarifyUsesStub(t *testing.T) { - r := newLLMReplier(mockCompleter{out: "я всё поняла"}, nil) - clarifyDec := router.Decision{Clarify: true} - got := r.Reply(clarifyDec) - want := voice.NewStubReplier().Reply(clarifyDec) + r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil) + assertStub(t, r, router.Decision{Clarify: true}, "clarify") +} + +func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) { + t.Helper() + got, want := r.Reply(d), voice.NewStubReplier().Reply(d) if got != want { - t.Errorf("on clarify: got %q, want stub %q", got, want) + t.Errorf("on %s: got %q, want stub %q", what, got, want) } } -var errTestLLMDown = errTest("llm down") +var errReplierTest = errTest("llm down") type errTest string func (e errTest) Error() string { return string(e) } - -// 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 TestLLMReplierCarriesTheResponseGrammar(t *testing.T) { - rec := &grammarRecorder{} - r := newLLMReplier(rec, nil) - r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}}) - if rec.req.Grammar != phraser.ResponseGrammar { - t.Errorf("grammar = %q, want phraser.ResponseGrammar", rec.req.Grammar) - } -}