Files
Maven/internal/capture/summarize_test.go
kami aa1a26532c Add meeting capture with explicit start and stop (#253)
Maven can record a meeting when she is told to, transcribe it through the
STT she already has, and write a summary note. The audio lives in the blob
store #252 introduced, under the same retention loop.

Nothing here listens. Recorder.Append is the only way audio enters and it
refuses every frame unless someone explicitly started a session, so audio
arriving at an idle core is dropped rather than buffered. The plan document
asked for a keyword trigger ("maven record" heard in the room) and that is
refused: noticing a keyword means listening to the room, which is the one
behaviour this capability must not have.

Off unless configured twice over. No media block means nowhere to keep
audio, no capture block means no recorder, and in either case the four IPC
methods answer ErrUnknownMethod. On an unconfigured box there is no wire
path that begins a recording at all.

A forgotten session ends itself at max_minutes, checked on every append,
and the audio collected before the cap is kept. Stop with discard set is
what "забудь, не записывай" maps to and it leaves nothing behind. The
verbatim transcript is not saved unless save_transcript says so; the
summary is.

Long audio against n_ctx 4096 is handled by map-reduce over 3000-rune
windows rather than by truncation, because a truncated meeting summary
reads as complete and is not. Transcription is windowed at five minutes so
the whisper worker stays responsive to the voice path.

No second STT: internal/capture takes the stt.Transcriber the voice path
already holds. Capture with voice off is refused rather than degraded,
since hours of unreadable audio of other people is worse than no recording.

The three write methods are AuthWrite, not AuthStepUp: step-up needs a
passkey gesture the voice path cannot make, which would leave "запиши
встречу" impossible by voice. capture_status is AuthRead.

make build and make test both pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 05:08:08 +04:00

245 lines
7.9 KiB
Go

package capture
import (
"context"
"errors"
"fmt"
"strings"
"testing"
)
func TestNilSummarizerWithoutAModel(t *testing.T) {
if s := NewSummarizer(nil, 0, 0, nil); s != nil {
t.Fatal("a summarizer with no model is not nil")
}
var s *Summarizer
if _, _, err := s.Summarize(context.Background(), "x", "текст"); !errors.Is(err, ErrDisabled) {
t.Fatalf("got %v, want ErrDisabled", err)
}
}
// The common case: a short meeting fits in one prompt, so there is exactly one
// model call and no reduce step.
func TestShortTranscriptSkipsTheReduceStep(t *testing.T) {
f := &fakeCompleter{replies: []string{"— договорились о смете"}}
s := NewSummarizer(f, 0, 0, nil)
out, chunks, err := s.Summarize(context.Background(), "смета", "Обсудили смету. Решили подписать.")
if err != nil {
t.Fatal(err)
}
if chunks != 1 {
t.Errorf("chunks = %d, want 1", chunks)
}
if len(f.users) != 1 {
t.Fatalf("%d model calls, want 1", len(f.users))
}
if !strings.Contains(out, "смете") || !strings.HasPrefix(out, "смета") {
t.Errorf("summary = %q", out)
}
}
func TestLongTranscriptIsMappedThenReduced(t *testing.T) {
f := &roleCompleter{mapReply: "часть", reduceReply: "общий итог"}
s := NewSummarizer(f, 40, 0, nil)
long := strings.Repeat("Говорили про насос и трубы. ", 12)
out, chunks, err := s.Summarize(context.Background(), "", long)
if err != nil {
t.Fatal(err)
}
if chunks < 2 {
t.Fatalf("chunks = %d, want the transcript split", chunks)
}
// One map call per chunk, then exactly one reduce.
if f.maps != chunks {
t.Errorf("%d map calls for %d chunks", f.maps, chunks)
}
if f.reduces != 1 {
t.Errorf("%d reduce calls, want 1", f.reduces)
}
if out != "общий итог" {
t.Errorf("summary = %q, want the reduced text", out)
}
}
// Losing every per-chunk summary because the last call failed would throw away
// most of the work.
func TestReduceFailureReturnsTheJoinedParts(t *testing.T) {
f := &roleCompleter{mapReply: "часть", reduceFails: true}
s := NewSummarizer(f, 40, 0, nil)
long := strings.Repeat("Говорили про насос и трубы. ", 12)
out, _, err := s.Summarize(context.Background(), "", long)
if err == nil {
t.Fatal("reduce failure was not reported")
}
if !strings.Contains(out, "часть1") || !strings.Contains(out, "часть2") {
t.Errorf("per-chunk work was lost: %q", out)
}
}
func TestChunkFailureIsReported(t *testing.T) {
f := &fakeCompleter{err: errors.New("llama is down")}
s := NewSummarizer(f, 0, 0, nil)
if _, _, err := s.Summarize(context.Background(), "", "текст"); err == nil {
t.Fatal("chunk failure was not reported")
}
}
// "пусто" chunks are noise; they must not pad the reduce prompt, and a
// transcript that is entirely empty chunks is an honest ErrNoSummary rather than
// an invented summary.
func TestEmptyChunksAreDropped(t *testing.T) {
f := &roleCompleter{mapReply: "пусто", literalMap: true, reduceReply: "не должно вызываться"}
s := NewSummarizer(f, 40, 0, nil)
long := strings.Repeat("Тишина в комнате. ", 12)
if _, _, err := s.Summarize(context.Background(), "", long); !errors.Is(err, ErrNoSummary) {
t.Fatalf("got %v, want ErrNoSummary", err)
}
}
func TestEmptyTranscriptIsRefused(t *testing.T) {
s := NewSummarizer(&fakeCompleter{}, 0, 0, nil)
if _, _, err := s.Summarize(context.Background(), "", " \n "); !errors.Is(err, ErrEmptyCapture) {
t.Fatalf("got %v, want ErrEmptyCapture", err)
}
}
// A summary that silently covers the first fraction of a long meeting is the
// failure mode; it has to say so.
func TestTruncationIsStatedInTheSummary(t *testing.T) {
f := &fakeCompleter{replies: []string{"a", "b", "итог"}}
s := NewSummarizer(f, 30, 2, nil)
long := strings.Repeat("Говорили про насос и про трубы. ", 20)
out, chunks, err := s.Summarize(context.Background(), "", long)
if err != nil {
t.Fatal(err)
}
if chunks != 2 {
t.Errorf("chunks = %d, want the cap of 2", chunks)
}
if !strings.Contains(out, "обрезана") {
t.Errorf("truncation not stated: %q", out)
}
}
// The persona block belongs to the daemon, not this package, and must reach the
// model when it is supplied.
func TestContextBlockIsPrependedToEveryPrompt(t *testing.T) {
f := &fakeCompleter{replies: []string{"итог"}}
s := NewSummarizer(f, 0, 0, func() string { return "ПЕРСОНА\n\n" })
if _, _, err := s.Summarize(context.Background(), "", "Обсудили смету."); err != nil {
t.Fatal(err)
}
for i, sys := range f.systems {
if !strings.HasPrefix(sys, "ПЕРСОНА") {
t.Errorf("call %d lost the context block: %q", i, sys)
}
}
}
// The map/reduce prompts must contain no first person at all: the persona's
// feminine forms live in the replier, and a first-person instruction here is a
// place for the model to write "я рад".
func TestPromptsHaveNoFirstPerson(t *testing.T) {
for name, p := range map[string]string{"chunk": chunkPrompt, "reduce": reducePrompt} {
for _, bad := range []string{" я ", "рад", "поняла", "мне ", "вы ", "ваш"} {
if strings.Contains(strings.ToLower(" "+p+" "), bad) {
t.Errorf("%s prompt contains %q", name, bad)
}
}
}
}
func TestChunkTextSplitsOnSentenceBoundaries(t *testing.T) {
text := "Раз два три. Четыре пять шесть. Семь восемь девять."
got := ChunkText(text, 20)
if len(got) != 3 {
t.Fatalf("got %d chunks: %q", len(got), got)
}
for _, c := range got {
if !strings.HasSuffix(c, ".") {
t.Errorf("chunk does not end on a sentence: %q", c)
}
}
}
func TestChunkTextPacksSentencesUpToTheLimit(t *testing.T) {
text := "Раз. Два. Три. Четыре."
got := ChunkText(text, 12)
if len(got) < 2 {
t.Fatalf("nothing was split: %q", got)
}
for _, c := range got {
if n := len([]rune(c)); n > 12 {
t.Errorf("chunk of %d runes exceeds the limit: %q", n, c)
}
}
}
// whisper does emit long unpunctuated runs; those must be cut on whitespace, not
// dropped and not run past the context limit.
func TestChunkTextCutsUnpunctuatedRuns(t *testing.T) {
text := strings.TrimSpace(strings.Repeat("слово ", 50))
got := ChunkText(text, 30)
if len(got) < 2 {
t.Fatalf("unpunctuated run was not split: %d chunks", len(got))
}
total := 0
for _, c := range got {
if n := len([]rune(c)); n > 30 {
t.Errorf("chunk of %d runes exceeds the limit", n)
}
total += strings.Count(c, "слово")
}
if total != 50 {
t.Errorf("%d of 50 words survived chunking", total)
}
}
// A single token longer than the window must still come out, hard-cut.
func TestChunkTextHandlesOneOversizedWord(t *testing.T) {
text := strings.Repeat("я", 70)
got := ChunkText(text, 20)
if len(got) != 4 {
t.Fatalf("got %d chunks, want 4", len(got))
}
if joined := strings.Join(got, ""); len([]rune(joined)) != 70 {
t.Errorf("%d runes survived, want 70", len([]rune(joined)))
}
}
func TestChunkTextShortInputAndEmpty(t *testing.T) {
if got := ChunkText("коротко", 100); len(got) != 1 || got[0] != "коротко" {
t.Errorf("got %q", got)
}
if got := ChunkText(" ", 100); got != nil {
t.Errorf("blank text produced %q", got)
}
}
// roleCompleter answers by which prompt it was handed, so a test does not have
// to predict how many chunks the text splits into. Map replies are numbered
// ("часть1", "часть2", …) unless literalMap is set.
type roleCompleter struct {
mapReply string
literalMap bool
reduceReply string
reduceFails bool
maps int
reduces int
}
func (f *roleCompleter) Complete(_ context.Context, system, _ string) (string, error) {
if strings.Contains(system, "конспекты фрагментов") {
f.reduces++
if f.reduceFails {
return "", errors.New("llama fell over")
}
return f.reduceReply, nil
}
f.maps++
if f.literalMap {
return f.mapReply, nil
}
return fmt.Sprintf("%s%d", f.mapReply, f.maps), nil
}