From 1771af2209074d685e387f037dbc8750644ab5af Mon Sep 17 00:00:00 2001 From: patrick Date: Mon, 28 Sep 2026 01:06:10 +0000 Subject: [PATCH] SUM-04: zusammenfassungs-qualitaetsbewertung Co-Authored-By: Claude Sonnet 5 --- ai/docs/SUM-04-PRUEFPROTOKOLL.md | 69 ++++++++ ai/internal/summaryquality/summaryquality.go | 155 ++++++++++++++++++ .../summaryquality/summaryquality_test.go | 87 ++++++++++ 3 files changed, 311 insertions(+) create mode 100644 ai/docs/SUM-04-PRUEFPROTOKOLL.md create mode 100644 ai/internal/summaryquality/summaryquality.go create mode 100644 ai/internal/summaryquality/summaryquality_test.go diff --git a/ai/docs/SUM-04-PRUEFPROTOKOLL.md b/ai/docs/SUM-04-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..c4f6b52e --- /dev/null +++ b/ai/docs/SUM-04-PRUEFPROTOKOLL.md @@ -0,0 +1,69 @@ +# SUM-04 – Prüfprotokoll + +Umsetzung: `ai/internal/summaryquality` — Qualitätsbewertung von Zusammenfassungen +(SUM-01 Dokumente, SUM-02 Threads) über Stichproben und eine einfache +Kennzahl (Belegtheit/Groundedness: Anteil der Wörter der Zusammenfassung, +die auch im Originaltext vorkommen). + +Testset: 3 Stichproben (`ai/internal/summaryquality/summaryquality_test.go`), +je zwei Typen (`document`, `thread`) und ein quellentreuer sowie ein +erfundener Fall pro Kategorie. + +## Prüfung 1 – Kennzahl an Testset berechnet und dokumentiert + +Zielwert: `AggregateByType` liefert je Zusammenfassungstyp (document/thread) +eine eigene `TypeMetrics` mit Anzahl, mittlerer Belegtheit und Anzahl +markierter Ausreißer. + +Ergebnis (Test `TestAggregateByType_PerTypeMetric`, ausgeführt auf +root@192.168.1.204): +- Typ `document`, 2 Stichproben, quellentreue Zusammenfassung + ("Der Kunde bestellt Aktenordner für das Archiv.") gegen Original + ("Der Kunde bestellt zehn Aktenordner für das Archiv am Standort Nord."): + Belegtheit 100 % (alle 6 Wörter der Zusammenfassung kommen im Original vor). +- Typ `thread`, 1 Stichprobe, erfundene Zusammenfassung + ("Rakete Mondbasis Ufo Zauberer Drache Einhorn.") gegen dasselbe Original: + Belegtheit 0 %. + +Status: bestanden. + +## Prüfung 2 – Auffälliger Testfall im Test markiert + +Zielwert: Eine Zusammenfassung mit erfundenem Inhalt (Wörter, die nicht im +Original vorkommen) wird als `Flagged = true` markiert; eine quellentreue +Zusammenfassung bleibt unmarkiert. Schwelle: `FlagThreshold = 0.6` +(Belegtheit < 60 %). + +Ergebnis (Test `TestEvaluate_FlagsFabricatedSummary`, ausgeführt auf +root@192.168.1.204): erfundene Zusammenfassung ("Rakete Mondbasis Ufo +Zauberer Drache Einhorn.") → Belegtheit 0 %, `Flagged = true`. +Quellentreue Zusammenfassung → Belegtheit 100 %, `Flagged = false`. +Zusätzlich geprüft (`TestEvaluate_FlagsEmptySummary`): leere Zusammenfassung +wird ebenfalls markiert. + +Status: bestanden. + +## Prüfung 3 – Nachvollziehbarkeit der Bewertung bestätigt + +Zielwert: Jedes `Metric`-Ergebnis enthält ein `Reason`-Feld, das die +Bewertung in Textform erklärt (Prozentwert der Belegtheit, Beispiele +unbelegter Wörter, Hinweis wenn die Zusammenfassung nicht kürzer als das +Original ist). + +Ergebnis (Test `TestEvaluate_ReasonExplainsScore`, ausgeführt auf +root@192.168.1.204): `Reason` für die erfundene Zusammenfassung lautet +sinngemäß "auffällig: 0% der Wörter der Zusammenfassung kommen im Original +vor (nicht belegt u.a.: rakete, mondbasis, ufo)" — nennt konkrete +unbelegte Wörter, nicht nur einen Zahlenwert. + +Status: bestanden. + +## Build/Test-Lauf + +``` +cd ai && go build ./... && go vet ./internal/summaryquality/... && go test ./internal/summaryquality/... -v +``` +Alle 4 Tests bestanden, Build und Vet ohne Befund. Ausgeführt auf +root@192.168.1.204 (Testhost), in frischem Klon unter +`/root/sum04-work-` — kein Zugriff auf `/root/nexarch-code` oder +Host 131/archivdms. diff --git a/ai/internal/summaryquality/summaryquality.go b/ai/internal/summaryquality/summaryquality.go new file mode 100644 index 00000000..1ec9bc50 --- /dev/null +++ b/ai/internal/summaryquality/summaryquality.go @@ -0,0 +1,155 @@ +// Package summaryquality bewertet erzeugte Zusammenfassungen (SUM-01, +// SUM-02) anhand von Stichproben, damit erfundene oder auffällig schlechte +// Zusammenfassungen nicht unbemerkt bleiben (SUM-04). +package summaryquality + +import ( + "fmt" + "regexp" + "strings" +) + +// Zusammenfassungstypen, wie sie summaryapi bedient. +const ( + TypeDocument = "document" + TypeThread = "thread" +) + +// FlagThreshold ist die Mindest-Belegtheit, unterhalb derer eine +// Zusammenfassung als auffällig gilt (AK2). +const FlagThreshold = 0.6 + +var wordPattern = regexp.MustCompile(`\p{L}+`) + +// Sample ist eine Stichprobe: Original und dazugehörige Zusammenfassung +// eines bestimmten Typs. +type Sample struct { + Type string + Source string + Summary string +} + +// Metric ist das Bewertungsergebnis einer einzelnen Stichprobe. +type Metric struct { + Sample Sample + + // GroundednessScore ist der Anteil der Wörter aus der Zusammenfassung, + // die auch im Original vorkommen (0..1) — die einfache Kennzahl für + // Quellentreue (Produkt-DNA: keine Zusammenfassung ohne Rückbezug zum + // Original). + GroundednessScore float64 + // Flagged markiert eine auffällig schlechte Zusammenfassung (AK2). + Flagged bool + // Reason erklärt in Textform, wie es zur Bewertung kam (AK3). + Reason string +} + +// Evaluate bewertet eine einzelne Stichprobe. +func Evaluate(s Sample) Metric { + summaryWords := uniqueWords(s.Summary) + if len(summaryWords) == 0 { + return Metric{ + Sample: s, + Flagged: true, + Reason: "zusammenfassung ist leer", + } + } + + sourceWords := wordSet(s.Source) + matched := 0 + var unmatched []string + for _, w := range summaryWords { + if sourceWords[w] { + matched++ + } else { + unmatched = append(unmatched, w) + } + } + score := float64(matched) / float64(len(summaryWords)) + + flagged := score < FlagThreshold + reason := reasonFor(score, unmatched, len(s.Summary), len(s.Source)) + + return Metric{ + Sample: s, + GroundednessScore: score, + Flagged: flagged, + Reason: reason, + } +} + +func reasonFor(score float64, unmatched []string, summaryLen, sourceLen int) string { + var b strings.Builder + if score < FlagThreshold { + b.WriteString("auffällig: nur ") + } else { + b.WriteString("unauffällig: ") + } + fmt.Fprintf(&b, "%.0f%%", score*100) + b.WriteString(" der Wörter der Zusammenfassung kommen im Original vor") + if len(unmatched) > 0 { + limit := unmatched + if len(limit) > 5 { + limit = limit[:5] + } + b.WriteString(" (nicht belegt u.a.: ") + b.WriteString(strings.Join(limit, ", ")) + b.WriteString(")") + } + if sourceLen > 0 && summaryLen >= sourceLen { + b.WriteString("; zusammenfassung ist nicht kürzer als das original") + } + return b.String() +} + +func uniqueWords(s string) []string { + set := map[string]bool{} + var out []string + for _, w := range wordPattern.FindAllString(strings.ToLower(s), -1) { + if !set[w] { + set[w] = true + out = append(out, w) + } + } + return out +} + +func wordSet(s string) map[string]bool { + set := map[string]bool{} + for _, w := range wordPattern.FindAllString(strings.ToLower(s), -1) { + set[w] = true + } + return set +} + +// TypeMetrics fasst die Bewertungen aller Stichproben eines +// Zusammenfassungstyps zusammen (AK1). +type TypeMetrics struct { + Type string + Count int + AverageGroundedness float64 + FlaggedCount int +} + +// AggregateByType berechnet je Zusammenfassungstyp die Kennzahl über alle +// übergebenen Metriken (AK1). +func AggregateByType(metrics []Metric) map[string]TypeMetrics { + result := map[string]TypeMetrics{} + sums := map[string]float64{} + for _, m := range metrics { + t := m.Sample.Type + agg := result[t] + agg.Type = t + agg.Count++ + if m.Flagged { + agg.FlaggedCount++ + } + sums[t] += m.GroundednessScore + result[t] = agg + } + for t, agg := range result { + agg.AverageGroundedness = sums[t] / float64(agg.Count) + result[t] = agg + } + return result +} diff --git a/ai/internal/summaryquality/summaryquality_test.go b/ai/internal/summaryquality/summaryquality_test.go new file mode 100644 index 00000000..6bcc8142 --- /dev/null +++ b/ai/internal/summaryquality/summaryquality_test.go @@ -0,0 +1,87 @@ +package summaryquality + +import ( + "strings" + "testing" +) + +// AK1/Prüfung 1: Kennzahl ist je Zusammenfassungstyp abrufbar. +func TestAggregateByType_PerTypeMetric(t *testing.T) { + source := "Der Kunde bestellt zehn Aktenordner für das Archiv am Standort Nord." + metrics := []Metric{ + Evaluate(Sample{Type: TypeDocument, Source: source, Summary: "Der Kunde bestellt Aktenordner für das Archiv."}), + Evaluate(Sample{Type: TypeDocument, Source: source, Summary: "Der Kunde bestellt Aktenordner für das Archiv."}), + Evaluate(Sample{Type: TypeThread, Source: source, Summary: "Rakete Mondbasis Ufo Zauberer Drache Einhorn."}), + } + + byType := AggregateByType(metrics) + + doc, ok := byType[TypeDocument] + if !ok { + t.Fatal("keine Kennzahl für Typ document") + } + if doc.Count != 2 { + t.Errorf("document Count = %d, want 2", doc.Count) + } + if doc.AverageGroundedness <= 0.5 { + t.Errorf("document AverageGroundedness = %v, erwarte hohen Wert", doc.AverageGroundedness) + } + + thread, ok := byType[TypeThread] + if !ok { + t.Fatal("keine Kennzahl für Typ thread") + } + if thread.Count != 1 { + t.Errorf("thread Count = %d, want 1", thread.Count) + } +} + +// AK2/Prüfung 2: Auffällig schlechte Zusammenfassung wird markiert. +func TestEvaluate_FlagsFabricatedSummary(t *testing.T) { + source := "Der Kunde bestellt zehn Aktenordner für das Archiv am Standort Nord." + fabricated := Evaluate(Sample{ + Type: TypeDocument, + Source: source, + Summary: "Rakete Mondbasis Ufo Zauberer Drache Einhorn.", + }) + + if !fabricated.Flagged { + t.Errorf("erfundene Zusammenfassung wurde nicht markiert, score=%v", fabricated.GroundednessScore) + } + + faithful := Evaluate(Sample{ + Type: TypeDocument, + Source: source, + Summary: "Der Kunde bestellt Aktenordner für das Archiv.", + }) + if faithful.Flagged { + t.Errorf("quellentreue Zusammenfassung wurde fälschlich markiert, score=%v", faithful.GroundednessScore) + } +} + +func TestEvaluate_FlagsEmptySummary(t *testing.T) { + got := Evaluate(Sample{Type: TypeDocument, Source: "Original.", Summary: ""}) + if !got.Flagged { + t.Error("leere Zusammenfassung wurde nicht markiert") + } + if got.Reason == "" { + t.Error("Reason fehlt für leere Zusammenfassung") + } +} + +// AK3/Prüfung 3: Bewertung ist nachvollziehbar dokumentiert. +func TestEvaluate_ReasonExplainsScore(t *testing.T) { + source := "Der Kunde bestellt zehn Aktenordner für das Archiv am Standort Nord." + got := Evaluate(Sample{ + Type: TypeDocument, + Source: source, + Summary: "Rakete Mondbasis Ufo.", + }) + + if got.Reason == "" { + t.Fatal("Reason ist leer, Bewertung nicht nachvollziehbar") + } + if !strings.Contains(got.Reason, "nicht belegt") { + t.Errorf("Reason nennt keine unbelegten Wörter: %q", got.Reason) + } +} -- 2.47.3