612ca8cf1b
The replier and the meeting summariser were the two call sites without a
GBNF. Both are exactly the shape that makes a Thinking variant answer with
its reasoning as prose, and neither had anything downstream that could
remove it.
The replier already parses {"response","mood"}, so it now sends the phraser's
grammar for that contract, exported once as phraser.ResponseGrammar so the
two definitions cannot drift.
The summariser stays text-in/text-out. The JSON wrapper is attached and
unwrapped in the daemon's Completer, so internal/capture is unchanged and a
Completer without a grammar still works.
The simulator told routing from phrasing by "has a grammar", which stopped
being true here; it now looks for the intent enum.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
88 lines
2.9 KiB
Go
88 lines
2.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"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 {
|
|
out string
|
|
err error
|
|
}
|
|
|
|
func (m mockCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.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)
|
|
got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
|
if got != "записала, кофе закончился" {
|
|
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
|
r := newLLMReplier(mockCompleter{out: "я всё поняла"}, nil)
|
|
clarifyDec := router.Decision{Clarify: true}
|
|
got := r.Reply(clarifyDec)
|
|
want := voice.NewStubReplier().Reply(clarifyDec)
|
|
if got != want {
|
|
t.Errorf("on clarify: got %q, want stub %q", got, want)
|
|
}
|
|
}
|
|
|
|
var errTestLLMDown = 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)
|
|
}
|
|
}
|