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 }