Files
2026-09-26 00:05:55 +02:00

220 lines
8.0 KiB
Go

package threadsummarize
import (
"context"
"os"
"strings"
"testing"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/mock"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/ollama"
)
// capturingProvider merkt sich den zuletzt gesendeten Prompt, um zu prüfen
// was tatsächlich ans Modell geschickt wird (AK2/AK3-Nachweis).
type capturingProvider struct {
response string
lastRequest provider.ChatRequest
}
func (c *capturingProvider) Chat(ctx context.Context, req provider.ChatRequest) (provider.ChatResponse, error) {
c.lastRequest = req
return provider.ChatResponse{Message: provider.Message{Content: c.response}}, nil
}
func (c *capturingProvider) Complete(ctx context.Context, req provider.CompletionRequest) (provider.CompletionResponse, error) {
return provider.CompletionResponse{}, nil
}
func (c *capturingProvider) Embed(ctx context.Context, req provider.EmbeddingRequest) (provider.EmbeddingResponse, error) {
return provider.EmbeddingResponse{}, nil
}
func (c *capturingProvider) promptText() string {
for _, m := range c.lastRequest.Messages {
if m.Role == provider.RoleUser {
return m.Content
}
}
return ""
}
// AK1: Zusammenfassung bildet den Threadverlauf korrekt ab — alle
// Nachrichten fließen (unzitiert) in den Modellaufruf ein.
func TestSummarize_IncludesAllMessages(t *testing.T) {
p := &capturingProvider{response: "Zusammenfassung des Threads."}
s := &Summarizer{Provider: p, Model: "test-model"}
thread := Thread{Messages: []Message{
{ID: "m1", From: "a@example.com", Body: "Erste Nachricht zum Thema Rechnung."},
{ID: "m2", From: "b@example.com", Body: "Zweite Nachricht mit Rückfrage zur Rechnung."},
{ID: "m3", From: "a@example.com", Body: "Dritte Nachricht mit Antwort."},
}}
got, err := s.Summarize(context.Background(), thread, Options{})
if err != nil {
t.Fatalf("Summarize: %v", err)
}
if got.Summary != "Zusammenfassung des Threads." {
t.Errorf("Summary = %q", got.Summary)
}
if got.MessageCount != 3 {
t.Errorf("MessageCount = %d, want 3", got.MessageCount)
}
if got.LastMessageID != "m3" {
t.Errorf("LastMessageID = %q, want m3", got.LastMessageID)
}
prompt := p.promptText()
for _, want := range []string{"Erste Nachricht", "Zweite Nachricht", "Dritte Nachricht"} {
if !strings.Contains(prompt, want) {
t.Errorf("prompt fehlt %q:\n%s", want, prompt)
}
}
}
func TestSummarize_EmptyThread_Rejected(t *testing.T) {
s := &Summarizer{Provider: &mock.Provider{}, Model: "test-model"}
if _, err := s.Summarize(context.Background(), Thread{}, Options{}); err == nil {
t.Fatal("erwartet Fehler bei leerem Thread")
}
}
// AK2: zitierte Altnachrichten werden nicht doppelt zusammengefasst — der
// Zitatblock einer Antwort darf nicht erneut im Modellaufruf auftauchen.
func TestSummarize_QuotedOldMessages_NotDuplicated(t *testing.T) {
p := &capturingProvider{response: "ok"}
s := &Summarizer{Provider: p, Model: "test-model"}
thread := Thread{Messages: []Message{
{ID: "m1", From: "a@example.com", Body: "Bitte prüfen Sie die Rechnung Nr. 4711 EINDEUTIGER-URTEXT."},
{
ID: "m2",
From: "b@example.com",
Body: "Habe geprüft, passt.\n\nAm Montag schrieb a@example.com:\n> Bitte prüfen Sie die Rechnung Nr. 4711 EINDEUTIGER-URTEXT.",
},
}}
if _, err := s.Summarize(context.Background(), thread, Options{}); err != nil {
t.Fatalf("Summarize: %v", err)
}
prompt := p.promptText()
count := strings.Count(prompt, "EINDEUTIGER-URTEXT")
if count != 1 {
t.Errorf("EINDEUTIGER-URTEXT kommt %dx im Prompt vor, will 1x (Zitat wurde nicht entfernt)", count)
}
if strings.Contains(prompt, "Habe geprüft, passt.") == false {
t.Error("nicht-zitierter Inhalt der zweiten Nachricht fehlt")
}
}
func TestStripQuoted_RemovesGtPrefixedLines(t *testing.T) {
body := "Meine Antwort.\n> alte zeile 1\n> alte zeile 2\nNoch was von mir."
got := stripQuoted(body)
if strings.Contains(got, "alte zeile") {
t.Errorf("stripQuoted ließ Zitatzeilen durch: %q", got)
}
if !strings.Contains(got, "Meine Antwort.") || !strings.Contains(got, "Noch was von mir.") {
t.Errorf("stripQuoted entfernte auch Nicht-Zitat-Inhalt: %q", got)
}
}
func TestStripQuoted_CutsAtReplyHeader(t *testing.T) {
body := "Meine neue Antwort.\n\nOn Mon, Jan 1 wrote:\nAlter Text der nicht mehr auftauchen darf."
got := stripQuoted(body)
if strings.Contains(got, "Alter Text") {
t.Errorf("stripQuoted schnitt nicht an der Zitat-Kopfzeile ab: %q", got)
}
if !strings.Contains(got, "Meine neue Antwort.") {
t.Errorf("stripQuoted entfernte auch Nicht-Zitat-Inhalt: %q", got)
}
}
// AK3: eine neue Nachricht aktualisiert die Zusammenfassung, basierend auf
// der bisherigen Zusammenfassung statt einer Neuverarbeitung des ganzen
// Threads.
func TestUpdate_UsesPreviousSummaryAsBasis(t *testing.T) {
p := &capturingProvider{response: "Aktualisierte Zusammenfassung."}
s := &Summarizer{Provider: p, Model: "test-model"}
previous := Result{Summary: "Bisherige Zusammenfassung: Rechnung wird geprüft.", MessageCount: 2, LastMessageID: "m2"}
newMsg := Message{ID: "m3", From: "a@example.com", Body: "Rechnung ist jetzt bezahlt."}
got, err := s.Update(context.Background(), previous, newMsg, Options{})
if err != nil {
t.Fatalf("Update: %v", err)
}
if got.Summary != "Aktualisierte Zusammenfassung." {
t.Errorf("Summary = %q", got.Summary)
}
if got.MessageCount != 3 {
t.Errorf("MessageCount = %d, want 3", got.MessageCount)
}
if got.LastMessageID != "m3" {
t.Errorf("LastMessageID = %q, want m3", got.LastMessageID)
}
prompt := p.promptText()
if !strings.Contains(prompt, "Rechnung wird geprüft") {
t.Error("Update-Prompt enthält nicht die bisherige Zusammenfassung als Basis")
}
if !strings.Contains(prompt, "Rechnung ist jetzt bezahlt") {
t.Error("Update-Prompt enthält nicht die neue Nachricht")
}
}
func TestUpdate_NoPreviousSummary_Rejected(t *testing.T) {
s := &Summarizer{Provider: &mock.Provider{}, Model: "test-model"}
_, err := s.Update(context.Background(), Result{}, Message{ID: "m1", Body: "x"}, Options{})
if err == nil {
t.Fatal("erwartet Fehler ohne bisherige Zusammenfassung")
}
}
// Realtest gegen den echten externen Ollama-Server (gpt-oss:20b), analog zu
// SUM-01/EXT-01/EXT-02 dieser Session. Nur ausgeführt, wenn der Server
// erreichbar konfiguriert ist.
func TestSummarize_RealInference_ThreeMessageThread(t *testing.T) {
base := os.Getenv("TEST_OLLAMA_BASE_URL")
if base == "" {
t.Skip("TEST_OLLAMA_BASE_URL nicht gesetzt, echter Inferenztest übersprungen")
}
model := os.Getenv("TEST_OLLAMA_MODEL")
if model == "" {
model = "gpt-oss:20b"
}
p := ollama.New(base)
s := &Summarizer{Provider: p, Model: model}
thread := Thread{Messages: []Message{
{ID: "m1", From: "kunde@example.com", Body: "Sehr geehrte Damen und Herren, ich möchte Rechnung Nr. 815 reklamieren, der Betrag stimmt nicht."},
{ID: "m2", From: "support@example.com", Body: "Wir prüfen das und melden uns bis Freitag zurück."},
{
ID: "m3",
From: "kunde@example.com",
Body: "Danke, ich warte auf Rückmeldung.\n\nAm Montag schrieb support@example.com:\n> Wir prüfen das und melden uns bis Freitag zurück.",
},
}}
got, err := s.Summarize(context.Background(), thread, Options{MaxWords: 60})
if err != nil {
t.Fatalf("Summarize (real): %v", err)
}
if strings.TrimSpace(got.Summary) == "" {
t.Fatal("leere Zusammenfassung vom echten Modell")
}
if got.MessageCount != 3 {
t.Errorf("MessageCount = %d, want 3", got.MessageCount)
}
t.Logf("Echte Zusammenfassung: %s", got.Summary)
updated, err := s.Update(context.Background(), got, Message{ID: "m4", From: "support@example.com", Body: "Rechnung wurde korrigiert, neuer Betrag ist verbucht."}, Options{MaxWords: 60})
if err != nil {
t.Fatalf("Update (real): %v", err)
}
if strings.TrimSpace(updated.Summary) == "" {
t.Fatal("leere aktualisierte Zusammenfassung vom echten Modell")
}
if updated.LastMessageID != "m4" {
t.Errorf("LastMessageID = %q, want m4", updated.LastMessageID)
}
t.Logf("Echte aktualisierte Zusammenfassung: %s", updated.Summary)
}