220 lines
8.0 KiB
Go
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)
|
|
}
|