6c07409452
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.
91 lines
3.0 KiB
Go
91 lines
3.0 KiB
Go
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) }
|