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>
145 lines
4.8 KiB
Go
145 lines
4.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/audio"
|
|
"github.com/kami/maven/internal/capture"
|
|
"github.com/kami/maven/internal/config"
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/media"
|
|
)
|
|
|
|
// silentTranscriber stands in for mavsttd: one fixed phrase per window, so the
|
|
// wiring can be tested without whisper.
|
|
type silentTranscriber struct{}
|
|
|
|
func (silentTranscriber) Transcribe(_ context.Context, _ audio.Audio) (string, float64, error) {
|
|
return "решили купить насос", 1.0, nil
|
|
}
|
|
|
|
func testCaptureWiring(t *testing.T) (*captureWiring, *sync.WaitGroup) {
|
|
t.Helper()
|
|
blobs, err := media.Open(t.TempDir(), 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec, err := capture.New(blobs, silentTranscriber{}, nil, capture.Config{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var wg sync.WaitGroup
|
|
return &captureWiring{
|
|
rec: rec,
|
|
st: newTestStore(t),
|
|
cfg: &config.CaptureConfig{},
|
|
now: time.Now,
|
|
ctx: context.Background(),
|
|
wg: &wg,
|
|
}, &wg
|
|
}
|
|
|
|
// A frame carrying the wrong token must not land in the running session. Append
|
|
// and stop used to address "whatever is running now", so a client whose session
|
|
// had already ended went on recording into somebody else's meeting, and any
|
|
// client could end a recording it never started.
|
|
func TestCaptureRefusesAnotherClientsToken(t *testing.T) {
|
|
c, _ := testCaptureWiring(t)
|
|
start, err := c.start(context.Background(), ipc.CaptureStartReq{Label: "встреча"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if start.Token == "" {
|
|
t.Fatal("start handed back no session token")
|
|
}
|
|
if _, err := c.append(context.Background(), ipc.CaptureAppendReq{
|
|
Token: "not-mine",
|
|
Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 3200)},
|
|
}); err == nil {
|
|
t.Error("a frame with the wrong token was accepted")
|
|
}
|
|
if _, err := c.stop(context.Background(), ipc.CaptureStopReq{Token: "not-mine"}); err == nil {
|
|
t.Error("a stop with the wrong token ended the session")
|
|
}
|
|
if st, _ := c.status(context.Background()); !st.Running {
|
|
t.Error("the session was ended by a client that does not own it")
|
|
}
|
|
}
|
|
|
|
// Stop answers with the transcript and does not wait for the summary. The
|
|
// summary is up to forty model calls, and holding the IPC request for them meant
|
|
// the client that said "стоп" sat with no answer for minutes.
|
|
//
|
|
// With no summariser wired the note still has to be written, from the transcript.
|
|
// save_transcript is about keeping the verbatim record IN ADDITION to a summary,
|
|
// not about whether the meeting is remembered at all — without this fallback a
|
|
// dead llama-server meant an hour of meeting left no note behind.
|
|
func TestStopReturnsTranscriptAndNotesItWithoutASummary(t *testing.T) {
|
|
c, wg := testCaptureWiring(t)
|
|
start, err := c.start(context.Background(), ipc.CaptureStartReq{Label: "планёрка"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := c.append(context.Background(), ipc.CaptureAppendReq{
|
|
Token: start.Token,
|
|
Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 32000)},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp, err := c.stop(context.Background(), ipc.CaptureStopReq{Token: start.Token})
|
|
if err != nil {
|
|
t.Fatalf("stop: %v", err)
|
|
}
|
|
if resp.Transcript == "" {
|
|
t.Fatal("stop returned no transcript")
|
|
}
|
|
if resp.Summary != "" {
|
|
t.Errorf("summary = %q, want none inside the request", resp.Summary)
|
|
}
|
|
wg.Wait()
|
|
|
|
notes, err := c.st.RecentNotes(context.Background(), 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var found bool
|
|
for _, n := range notes {
|
|
if strings.Contains(n.Text, "насос") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("the meeting left no note behind: %+v", notes)
|
|
}
|
|
}
|
|
|
|
// The summary path is JSON-wrapped by summaryGrammar, and internal/capture must
|
|
// keep seeing plain prose. These cover the wrapper and every way it can be
|
|
// absent or broken, because a meeting summary is written once and not retried.
|
|
func TestUnwrapSummary(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{"grammar output", `{"summary": "решили купить насос"}`, "решили купить насос"},
|
|
{"multiline field", `{"summary": "- насос\n- бюджет"}`, "- насос\n- бюджет"},
|
|
{"empty marker survives", `{"summary": "пусто"}`, "пусто"},
|
|
{"empty field says nothing", `{"summary": ""}`, ""},
|
|
{"thinking prefix", "<think>hm</think>\n{\"summary\": \"итог\"}", "итог"},
|
|
{"no grammar, plain prose", "решили купить насос", "решили купить насос"},
|
|
{"broken json falls back", `{"summary": "обрыв`, `{"summary": "обрыв`},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := unwrapSummary(c.in); got != c.want {
|
|
t.Errorf("unwrapSummary(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|