SUM-04: Zusammenfassungs-Qualitaetsbewertung #240

Merged
patrick merged 1 commits from feature/sum-04-zusammenfassungs-qualitaetsbewertung into main 2026-09-28 03:41:23 +02:00
3 changed files with 311 additions and 0 deletions

No files matched your search

+69
View File
@@ -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-<timestamp>` — kein Zugriff auf `/root/nexarch-code` oder
Host 131/archivdms.
@@ -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
}
@@ -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)
}
}