From a2f7631e5a45fa8366aeca03a24adb7366f76a81 Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 27 Sep 2026 10:12:40 +0200 Subject: [PATCH] CLS-04: erkennung wichtiger inhalte Co-Authored-By: Claude Sonnet 5 --- ai/docs/CLS-04-PRUEFPROTOKOLL.md | 43 ++++ ai/internal/highlight/highlight.go | 207 +++++++++++++++++++ ai/internal/highlight/highlight_test.go | 257 ++++++++++++++++++++++++ 3 files changed, 507 insertions(+) create mode 100644 ai/docs/CLS-04-PRUEFPROTOKOLL.md create mode 100644 ai/internal/highlight/highlight.go create mode 100644 ai/internal/highlight/highlight_test.go diff --git a/ai/docs/CLS-04-PRUEFPROTOKOLL.md b/ai/docs/CLS-04-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..b69f3a75 --- /dev/null +++ b/ai/docs/CLS-04-PRUEFPROTOKOLL.md @@ -0,0 +1,43 @@ +# CLS-04 – Prüfprotokoll: Erkennung wichtiger Inhalte + +## Umsetzung + +Neues Paket `internal/highlight`, aufbauend auf `internal/classify` (CLS-01) und `internal/provider` (ENG-01), gleiches Muster: synchroner `provider.Provider`-Aufruf, Temperatur 0, JSON-Ausschnitt aus Modellantwort, einheitliche Providerfehler. + +`Detector.DetectImportant` segmentiert den Dokumenttext in Byte-begrenzte Abschnitte (Standard 4000 Bytes, an Absatzgrenzen bevorzugt, nie mitten in einer Multi-Byte-Rune), ruft je Segment das Sprachmodell auf und bittet um ein JSON-Array wörtlicher Zitate mit Konfidenz und Begründung. Jedes Zitat wird per `strings.Index` im Segmenttext lokalisiert und auf globale Byte-Offsets im Originaldokument umgerechnet. + +- **AK1 (Position markiert):** `Passage{Start, End, Text, Confidence, Reasoning, Approx}`, Start/End sind Byte-Offsets wie bei gewöhnlicher Go-String-Indizierung. +- **AK2 (stabil bei langen Dokumenten):** Segmentierung in feste, begrenzte Abschnitte statt eines einzelnen überladenen Modellaufrufs; Verarbeitung sequenziell, je Segment ein eigener Aufruf mit eigenem Kontext. +- **AK3 (kein stillschweigendes Verwerfen):** + - Schlägt ein Segmentaufruf fehl (Providerfehler), werden die bereits erkannten Passagen vorheriger Segmente zusammen mit dem Fehler zurückgegeben statt das Segment kommentarlos zu überspringen. + - Ein vom Modell paraphrasiertes Zitat, das sich nicht wörtlich im Segment wiederfindet, wird nicht verworfen, sondern mit `Approx: true` und der gesamten Segmentposition markiert. + - Kaputtes JSON eines einzelnen Segments wird als "keine Passagen in diesem Segment" gewertet (kein Fehlerabbruch des gesamten Dokuments wegen eines Formatfehlers), aber auch nicht als Vorwand genutzt, tatsächlich erkannte Passagen anderer Segmente zu verlieren. + - Konfidenzwerte werden nicht gefiltert/geschwellt (Produkt-DNA: vorschlagen statt entscheiden) — jede erkannte Passage wird mit ihrem Sicherheitsgrad zurückgegeben, die Entscheidung über Anzeige/Freigabe bleibt der aufrufenden UI überlassen. + +## Prüfungen + +| # | Prüfung | Ziel | Ergebnis | Bewertung | +|---|---|---|---|---| +| 1 | Erkennung an mehrseitigem Testdokument | Fristenpassage wird über mehrere Segmente hinweg korrekt lokalisiert | Real gegen `office.sysops.de:11435`, Modell `gpt-oss:20b`: 12-seitiges Testdokument (1821 Runen, 3 Segmente à 800 Bytes), Kündigungsfrist-Passage mit Konfidenz 0,99 exakt positioniert (`doc[Start:End]` == Zitat), Dauer 16,5 s | bestanden | +| 2 | Stabilität bei sehr langen Dokumenten | Mehrere Modellaufrufe statt eines überladenen Kontexts, korrekte globale Position je Segment | Unit-Test mit Dokument aus wiederholtem Füllabsatz (>3000 Bytes, mehrere Segmente über Absatzgrenzen), Marker-Passage im mittleren Segment korrekt auf globale Byte-Position umgerechnet; zusätzlich Fehlerfall: bricht ein Segment ab, bleiben Ergebnisse vorheriger Segmente erhalten (kein Totalverlust bei Teilausfall) | bestanden | +| 3 | Nachvollziehbarkeit der Markierung | Jede Passage trägt Position, Zitat und Begründung, die sich im Dokument verifizieren lässt | Test prüft `doc[Start:End] == Text` bei exaktem Fund sowie nicht-leere `Reasoning`; bei nicht wörtlich auffindbarem Zitat wird die Position als `Approx` gekennzeichnet statt eine falsche exakte Position vorzutäuschen | bestanden | + +## Läufe auf 204 + +- `go build ./...`, `go vet ./...`: sauber. +- `gofmt -l .`: leer. +- `go test -count=1 ./internal/highlight/...` (ohne echten Ollama-Zugriff): 7 Unit-Tests grün, 1 Integrationstest übersprungen (fehlende Env-Variablen). +- `go test -run TestDetectImportant_RealOllama -count=1 ./internal/highlight/...` mit `TEST_AI_OLLAMA_BASE_URL=http://office.sysops.de:11435`, `TEST_AI_OLLAMA_MODEL=gpt-oss:20b`: 1 Integrationstest grün. +- Vollständiger Modul-Lauf `go test ./...`: alle Pakete `ok`, keine Regression an `classify`, `provider`, `queue`, `modelconfig`, `prompttemplate`. + +## QA und Entscheidungen + +- **Byte-Offsets statt Rune-Offsets:** Erste Fassung nutzte Rune-Offsets, was bei Umlauten zu falschen Positionen führte, sobald `doc[Start:End]` (gewöhnliche Go-String-Indizierung, byteweise) verwendet wird. Umgestellt auf Byte-Offsets mit Schutz gegen das Zerschneiden von Multi-Byte-Runen an Segmentgrenzen — konsistent mit üblicher Go-String-Indizierung, die jede aufrufende Stelle ohnehin erwartet. +- **Segmentierung an Absatzgrenzen, feste Bytegröße als Fallback:** Vermeidet das Zerschneiden von Sätzen/Absätzen, bleibt aber auch bei Dokumenten ohne Absätze (z. B. durchlaufender Fließtext) durch die Bytegrenze beschränkt. +- **Keine Schwellenwert-Filterung in diesem Baustein:** Passt zur Produkt-DNA (Konfidenz statt Ja/Nein) und zu CLS-01 (`Classifier` liefert ebenfalls immer alle Information, keine Filterung im Kern-Baustein). Eine Anzeige-Schwelle ist Sache der aufrufenden UI/eines Folgetickets. +- **Modell `gpt-oss:20b` über `:11435`:** Für den Text-Anteil dieses Tickets analog zu CLS-01 gewählt (Ollama-Textmodell auf `office.sysops.de:11435`), das dort zusätzlich verfügbare `qwen3:14b` existiert auf diesem Server nicht (`404 model not found`), daher nicht verwendet. +- **0 Iterationen über Design hinaus**, ein Fix (Byte- statt Rune-Offsets) direkt beim ersten Testlauf gefunden und behoben. + +## Nicht Teil dieser Kachel + +Schwellenwert-/Feedback-Loop für Konfidenz (CLS-05), UI-Hervorhebung selbst, Persistenz der Passagen in DMS/Mail — alles Folgearbeit, `Detector`/`Passage` sind bewusst als generischer Baustein gehalten. diff --git a/ai/internal/highlight/highlight.go b/ai/internal/highlight/highlight.go new file mode 100644 index 00000000..5aac3bbe --- /dev/null +++ b/ai/internal/highlight/highlight.go @@ -0,0 +1,207 @@ +// Package highlight erkennt besonders relevante Textabschnitte in einem Dokument +// (Fristen, Kernaussagen) mit Position zur Hervorhebung (CLS-04). Lange Dokumente +// werden segmentweise verarbeitet, damit das Ergebnis unabhängig von der +// Dokumentlänge stabil bleibt (AK2); kein Segment wird bei einem Modellfehler +// stillschweigend übersprungen (AK3). +package highlight + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" +) + +// DefaultChunkBytes ist die Standard-Segmentgröße in Bytes, bevor ein Dokument +// aufgeteilt wird. Grob bemessen für stabile Modellkontexte auf sehr langen Dokumenten. +const DefaultChunkBytes = 4000 + +// Passage ist ein als relevant erkannter Textabschnitt mit Position im +// ursprünglichen Dokumenttext (AK1). Start/End sind Byte-Offsets, wie bei +// gewöhnlicher Go-String-Indizierung. Confidence ist ein Sicherheitsgrad statt +// einer binären Ja/Nein-Entscheidung (Produkt-DNA: vorschlagen statt entscheiden). +type Passage struct { + Start int `json:"start"` + End int `json:"end"` + Text string `json:"text"` + Confidence float64 `json:"confidence"` + Reasoning string `json:"reasoning,omitempty"` + Approx bool `json:"approx"` +} + +// Detector erkennt wichtige Abschnitte über einen Provider (ENG-01). +type Detector struct { + Provider provider.Provider + Model string + ChunkBytes int +} + +func (d *Detector) chunkBytes() int { + if d.ChunkBytes > 0 { + return d.ChunkBytes + } + return DefaultChunkBytes +} + +type modelPassage struct { + Text string `json:"text"` + Confidence float64 `json:"confidence"` + Reasoning string `json:"reasoning"` +} + +// DetectImportant segmentiert documentText und erkennt je Segment wichtige +// Abschnitte (Fristen, Kernaussagen). Bricht ein Segmentaufruf mit Fehler ab, +// werden die bis dahin erkannten Passagen zusammen mit dem Fehler zurückgegeben, +// statt das betroffene Segment stillschweigend zu verwerfen (AK3). +func (d *Detector) DetectImportant(ctx context.Context, documentText string) ([]Passage, error) { + if d.Provider == nil { + return nil, fmt.Errorf("highlight: kein provider konfiguriert") + } + if strings.TrimSpace(documentText) == "" { + return nil, nil + } + + segments := segment(documentText, d.chunkBytes()) + var result []Passage + for i, seg := range segments { + found, err := d.detectInSegment(ctx, seg) + if err != nil { + return result, fmt.Errorf("highlight: segment %d/%d (Position %d-%d): %w", i+1, len(segments), seg.start, seg.end, err) + } + result = append(result, found...) + } + return result, nil +} + +func (d *Detector) detectInSegment(ctx context.Context, seg segmentSpan) ([]Passage, error) { + resp, err := d.Provider.Chat(ctx, provider.ChatRequest{ + Model: d.Model, + Messages: []provider.Message{ + {Role: provider.RoleSystem, Content: "Du erkennst besonders relevante Textabschnitte in Dokumenten (z. B. Fristen, Kernaussagen). Antworte ausschließlich mit einem JSON-Array, ohne Erklärtext davor oder danach."}, + {Role: provider.RoleUser, Content: buildPrompt(seg.text)}, + }, + Temperature: 0, + }) + if err != nil { + return nil, err + } + + raw := extractJSONArray(resp.Message.Content) + if raw == "" { + return nil, nil + } + var parsed []modelPassage + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return nil, nil + } + + passages := make([]Passage, 0, len(parsed)) + for _, mp := range parsed { + text := strings.TrimSpace(mp.Text) + if text == "" { + continue + } + conf := mp.Confidence + if conf < 0 { + conf = 0 + } + if conf > 1 { + conf = 1 + } + + idx := strings.Index(seg.text, text) + if idx < 0 { + passages = append(passages, Passage{ + Start: seg.start, + End: seg.end, + Text: text, + Confidence: conf, + Reasoning: mp.Reasoning, + Approx: true, + }) + continue + } + passages = append(passages, Passage{ + Start: seg.start + idx, + End: seg.start + idx + len(text), + Text: text, + Confidence: conf, + Reasoning: mp.Reasoning, + }) + } + return passages, nil +} + +func buildPrompt(segmentText string) string { + var b strings.Builder + b.WriteString("Finde in folgendem Textabschnitt alle besonders relevanten Stellen, insbesondere Fristen und Kernaussagen. ") + b.WriteString("Gib jede Stelle als wörtliches Zitat aus dem Abschnitt an, nicht umformuliert. ") + b.WriteString("Wenn nichts relevant ist, antworte mit einem leeren Array.\n\n") + b.WriteString("Antworte NUR mit JSON in diesem Format: ") + b.WriteString(`[{"text": string, "confidence": number zwischen 0 und 1, "reasoning": string}]`) + b.WriteString("\n\nTextabschnitt:\n") + b.WriteString(segmentText) + return b.String() +} + +func extractJSONArray(raw string) string { + start := strings.IndexByte(raw, '[') + end := strings.LastIndexByte(raw, ']') + if start < 0 || end < start { + return "" + } + return raw[start : end+1] +} + +type segmentSpan struct { + text string + start int + end int +} + +// segment teilt text in Abschnitte von höchstens chunkBytes Bytes, bevorzugt an +// Absatzgrenzen (Leerzeile), damit lange Dokumente in stabiler, begrenzter +// Kontextgröße verarbeitet werden (AK2). Positionen sind Byte-Offsets in text, +// und Grenzen werden nie mitten in einer Multi-Byte-Rune gezogen. +func segment(text string, chunkBytes int) []segmentSpan { + if len(text) <= chunkBytes { + return []segmentSpan{{text: text, start: 0, end: len(text)}} + } + + var spans []segmentSpan + pos := 0 + for pos < len(text) { + end := pos + chunkBytes + if end >= len(text) { + end = len(text) + } else { + if breakAt := lastParagraphBreak(text, pos, end); breakAt > pos { + end = breakAt + } + for end > pos && !isRuneBoundary(text, end) { + end-- + } + } + spans = append(spans, segmentSpan{text: text[pos:end], start: pos, end: end}) + pos = end + } + return spans +} + +func isRuneBoundary(text string, i int) bool { + if i <= 0 || i >= len(text) { + return true + } + return text[i]&0xC0 != 0x80 +} + +// lastParagraphBreak sucht die letzte Leerzeile im Bereich (from, to], um dort +// zu segmentieren statt mitten in einem Satz. +func lastParagraphBreak(text string, from, to int) int { + if idx := strings.LastIndex(text[from:to], "\n\n"); idx >= 0 { + return from + idx + 2 + } + return to +} diff --git a/ai/internal/highlight/highlight_test.go b/ai/internal/highlight/highlight_test.go new file mode 100644 index 00000000..4d3d3aa6 --- /dev/null +++ b/ai/internal/highlight/highlight_test.go @@ -0,0 +1,257 @@ +package highlight + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/ollama" +) + +// sequenceProvider liefert je Aufruf die nächste vorgegebene Antwort/Fehler, +// um Segmentierung über mehrere Modellaufrufe hinweg zu testen. +type sequenceProvider struct { + responses []provider.ChatResponse + errs []error + calls int + prompts []string +} + +func (p *sequenceProvider) Chat(_ context.Context, req provider.ChatRequest) (provider.ChatResponse, error) { + i := p.calls + p.calls++ + if len(req.Messages) > 0 { + p.prompts = append(p.prompts, req.Messages[len(req.Messages)-1].Content) + } + if i < len(p.errs) && p.errs[i] != nil { + return provider.ChatResponse{}, p.errs[i] + } + if i < len(p.responses) { + return p.responses[i], nil + } + return provider.ChatResponse{}, nil +} + +func (p *sequenceProvider) Complete(context.Context, provider.CompletionRequest) (provider.CompletionResponse, error) { + return provider.CompletionResponse{}, nil +} + +func (p *sequenceProvider) Embed(context.Context, provider.EmbeddingRequest) (provider.EmbeddingResponse, error) { + return provider.EmbeddingResponse{}, nil +} + +func resp(content string) provider.ChatResponse { + return provider.ChatResponse{Message: provider.Message{Content: content}} +} + +// AK1 + Prüfung 3: relevante Abschnitte werden mit korrekter Position im +// Dokumenttext zurückgegeben, nachvollziehbar über Start/End und Zitat. +func TestDetectImportant_ReturnsPositionsMatchingDocumentText(t *testing.T) { + doc := "Einleitung ohne Bedeutung. Die Frist zur Einreichung endet am 15.03.2026. Weiterer Fülltext." + p := &sequenceProvider{responses: []provider.ChatResponse{ + resp(`[{"text":"Die Frist zur Einreichung endet am 15.03.2026.","confidence":0.9,"reasoning":"Frist"}]`), + }} + d := &Detector{Provider: p, Model: "test-model"} + + got, err := d.DetectImportant(context.Background(), doc) + if err != nil { + t.Fatalf("DetectImportant: %v", err) + } + if len(got) != 1 { + t.Fatalf("len(got) = %d, want 1", len(got)) + } + pass := got[0] + if pass.Approx { + t.Fatal("Approx = true, want exaktes Match") + } + if doc[pass.Start:pass.End] != pass.Text { + t.Errorf("doc[%d:%d] = %q, want %q", pass.Start, pass.End, doc[pass.Start:pass.End], pass.Text) + } + if pass.Confidence != 0.9 { + t.Errorf("Confidence = %v, want 0.9", pass.Confidence) + } + if pass.Reasoning == "" { + t.Error("Reasoning leer, Nachvollziehbarkeit der Markierung erfordert eine Begründung") + } +} + +// AK2 + Prüfung 2: sehr lange Dokumente werden in mehreren Segmenten stabil +// verarbeitet (mehrere Modellaufrufe statt eines überladenen Kontexts), und +// Passagen aus jedem Segment landen mit korrekter globaler Position im Ergebnis. +func TestDetectImportant_LongDocument_ProcessedInStableSegments(t *testing.T) { + para := "Belangloser Fülltext ohne Relevanz. Noch mehr Fülltext ohne jede Bedeutung." + filler := strings.Repeat(para+"\n\n", 60) + marker := "Kündigungsfrist beträgt drei Monate zum Quartalsende." + doc := filler + marker + "\n\n" + filler + + respFor := func(seg segmentSpan) provider.ChatResponse { + if strings.Contains(seg.text, marker) { + return resp(fmt.Sprintf(`[{"text":%q,"confidence":0.8,"reasoning":"Frist"}]`, marker)) + } + return resp(`[]`) + } + segments := segment(doc, 3000) + responses := make([]provider.ChatResponse, len(segments)) + for i, s := range segments { + responses[i] = respFor(s) + } + p := &sequenceProvider{responses: responses} + d := &Detector{Provider: p, Model: "test-model", ChunkBytes: 3000} + + got, err := d.DetectImportant(context.Background(), doc) + if err != nil { + t.Fatalf("DetectImportant: %v", err) + } + if len(segments) < 2 { + t.Fatalf("Testaufbau erwartet mehrere Segmente, hat %d", len(segments)) + } + if p.calls != len(segments) { + t.Fatalf("calls = %d, want %d (ein Aufruf je Segment)", p.calls, len(segments)) + } + if len(got) != 1 { + t.Fatalf("len(got) = %d, want 1", len(got)) + } + if doc[got[0].Start:got[0].End] != marker { + t.Errorf("Position im langen Dokument stimmt nicht: doc[%d:%d] = %q, want %q", got[0].Start, got[0].End, doc[got[0].Start:got[0].End], marker) + } +} + +// AK3: bricht ein Segment mit Providerfehler ab, werden bereits erkannte +// Passagen vorheriger Segmente nicht verworfen, und der Fehler wird nicht +// stillschweigend geschluckt. +func TestDetectImportant_SegmentError_PartialResultsAndErrorReturned(t *testing.T) { + filler := strings.Repeat("Text. ", 500) + doc := filler + "\n\n" + filler + + p := &sequenceProvider{ + responses: []provider.ChatResponse{resp(`[{"text":"Text.","confidence":0.5,"reasoning":"x"}]`)}, + errs: []error{nil, &provider.Error{Provider: "test", Code: provider.CodeUnavailable, Message: "down"}}, + } + d := &Detector{Provider: p, Model: "test-model", ChunkBytes: len(filler) - 100} + + got, err := d.DetectImportant(context.Background(), doc) + if err == nil { + t.Fatal("erwarte Fehler bei fehlgeschlagenem Segment, statt stillschweigend zu verwerfen") + } + if !provider.IsCode(err, provider.CodeUnavailable) { + t.Errorf("Fehler nicht durchgereicht: %v", err) + } + if len(got) != 1 { + t.Fatalf("len(got) = %d, want Ergebnisse des erfolgreichen Segments erhalten", len(got)) + } +} + +// AK3: eine vom Modell gemeldete Passage, die sich nicht wörtlich im +// Segmenttext wiederfindet, wird nicht verworfen, sondern als ungefähre +// Position (Approx) mit dem gesamten Segmentbereich markiert. +func TestDetectImportant_ParaphrasedPassage_KeptAsApproximate(t *testing.T) { + doc := "Der Vertrag endet am 01.01.2027 automatisch." + p := &sequenceProvider{responses: []provider.ChatResponse{ + resp(`[{"text":"Vertragsende zum Jahresanfang 2027","confidence":0.4,"reasoning":"umformuliert"}]`), + }} + d := &Detector{Provider: p, Model: "test-model"} + + got, err := d.DetectImportant(context.Background(), doc) + if err != nil { + t.Fatalf("DetectImportant: %v", err) + } + if len(got) != 1 { + t.Fatalf("len(got) = %d, want 1 (nicht stillschweigend verworfen)", len(got)) + } + if !got[0].Approx { + t.Error("Approx = false, want true bei nicht wörtlich auffindbarem Zitat") + } + if got[0].Start != 0 || got[0].End != len(doc) { + t.Errorf("Approx-Position = [%d,%d), want gesamtes Segment", got[0].Start, got[0].End) + } +} + +// Kein Modellaufruf, kein Fehler, leeres Ergebnis bei leerem Dokument. +func TestDetectImportant_EmptyDocument_NoCallNoError(t *testing.T) { + p := &sequenceProvider{} + d := &Detector{Provider: p, Model: "test-model"} + + got, err := d.DetectImportant(context.Background(), " ") + if err != nil { + t.Fatalf("DetectImportant: %v", err) + } + if got != nil { + t.Errorf("got = %v, want nil", got) + } + if p.calls != 0 { + t.Errorf("calls = %d, want 0", p.calls) + } +} + +func TestDetectImportant_NoProvider_ReturnsError(t *testing.T) { + d := &Detector{Model: "test-model"} + if _, err := d.DetectImportant(context.Background(), "text"); err == nil { + t.Fatal("erwarte Fehler ohne konfigurierten Provider") + } +} + +// Fehlerantwort des Modells (kaputtes JSON) wird für dieses Segment als "keine +// erkannten Passagen" gewertet statt eines Fehlers durchzureichen, damit ein +// einzelnes verunglücktes Modellformat nicht das ganze Dokument abbricht. +func TestDetectImportant_MalformedModelJSON_TreatedAsNoPassagesNotError(t *testing.T) { + p := &sequenceProvider{responses: []provider.ChatResponse{resp("Ich habe keine relevanten Stellen gefunden.")}} + d := &Detector{Provider: p, Model: "test-model"} + + got, err := d.DetectImportant(context.Background(), "irrelevanter Text ohne relevanten Inhalt") + if err != nil { + t.Fatalf("DetectImportant: %v", err) + } + if len(got) != 0 { + t.Errorf("len(got) = %d, want 0", len(got)) + } +} + +// Prüfung 1 + 2: Erkennung an mehrseitigem/sehr langem Testdokument, real +// gegen den externen Ollama-Server. +func TestDetectImportant_RealOllama_MultiPageDocument(t *testing.T) { + base := os.Getenv("TEST_AI_OLLAMA_BASE_URL") + model := os.Getenv("TEST_AI_OLLAMA_MODEL") + if base == "" || model == "" { + t.Skip("TEST_AI_OLLAMA_BASE_URL/TEST_AI_OLLAMA_MODEL nicht gesetzt, Integrationstest übersprungen") + } + p := ollama.New(base) + if err := p.Health(context.Background()); err != nil { + t.Skipf("Ollama nicht erreichbar: %v", err) + } + + page := func(n int) string { + return fmt.Sprintf("Seite %d: Allgemeine Vertragsbedingungen ohne besondere Relevanz für diesen Abschnitt. Es gelten die üblichen Regelungen zwischen den Parteien.\n\n", n) + } + var doc strings.Builder + for i := 1; i <= 6; i++ { + doc.WriteString(page(i)) + } + doc.WriteString("Wichtiger Hinweis: Die Kündigungsfrist beträgt sechs Wochen zum Ende des Kalenderjahres.\n\n") + for i := 7; i <= 12; i++ { + doc.WriteString(page(i)) + } + text := doc.String() + + d := &Detector{Provider: p, Model: model, ChunkBytes: 800} + got, err := d.DetectImportant(context.Background(), text) + if err != nil { + t.Fatalf("DetectImportant: %v", err) + } + t.Logf("mehrseitiges Testdokument (%d Runen, mehrere Segmente): %d erkannte Passagen", len([]rune(text)), len(got)) + foundDeadline := false + for _, pass := range got { + if pass.Start < 0 || pass.End > len(text) || pass.Start > pass.End { + t.Errorf("ungültige Position: %+v", pass) + } + t.Logf("Passage: %q (Konfidenz %.2f, Approx=%v) Begründung: %s", pass.Text, pass.Confidence, pass.Approx, pass.Reasoning) + if strings.Contains(pass.Text, "Kündigungsfrist") || strings.Contains(pass.Reasoning, "Frist") || strings.Contains(pass.Reasoning, "Kündigung") { + foundDeadline = true + } + } + if !foundDeadline { + t.Error("erwartete Fristenpassage wurde nicht erkannt") + } +} -- 2.47.3