From 1a246abdb3e58f0b8c7975ed46eaa4e1f1fd90d7 Mon Sep 17 00:00:00 2001 From: sysops Date: Mon, 31 Aug 2026 22:16:26 +0200 Subject: [PATCH] SRC-10: spracherkennung-ocr-qualitaetsbewertung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spracherkennung für OCR-Texte und Qualitätsbewertung (Konfidenzwert), um schlechte OCR-Ergebnisse kenntlich zu machen. Letztes Ticket vor QA-03. - ocr/language.go: RecognizeWithLanguageAndConfidence erkennt ein Bild einzeln je Kandidatensprache (deu/eng) im Tesseract-TSV-Modus — die Sprache mit höherem Konfidenzwert gewinnt, derselbe Lauf liefert den Konfidenzwert direkt mit. - search: neue Felder ocr_language/ocr_confidence (Migrationen 0006/0007, gleiches ALTER-Muster wie SRC-05), in Document/Result gespiegelt. Client.AttachmentsBelowConfidence filtert gezielt auf niedrige Konfidenz, schließt Dokumente ohne OCR-Anhang aus. - Regressionsbug gefunden und behoben: reindex.go (SRC-09) kannte die neuen OCR-Spalten nicht, Reindex wäre mit "unknown column" fehlgeschlagen. Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-10-PRUEFPROTOKOLL.md): 1. TestRecognizeWithLanguageAndConfidence_MultilingualCorpus: deutsches und englisches Testbild real korrekt als deu/eng erkannt. 2. TestRecognizeWithLanguageAndConfidence_DegradedImageLowersConfidence: künstliche Verschlechterung senkt Konfidenz real von 91,76 auf 28,21. 3. TestAttachmentsBelowConfidence_QueryReturnsExpectedResults: Abfrage unterhalb Schwelle liefert real genau die erwarteten 2 von 4 Treffern. Kein Umbau: Search/Facets/SearchWithFilters/Index/Delete-Verhalten sonst unverändert, dedup/indexworker/storage/crypto/encstorage/savedsearch unverändert. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ --- mail/docs/SRC-10-PRUEFPROTOKOLL.md | 57 ++++++++ mail/internal/ocr/language.go | 124 ++++++++++++++++++ mail/internal/ocr/language_test.go | 101 ++++++++++++++ mail/internal/search/client.go | 30 ++++- mail/internal/search/facets.go | 52 ++++++++ mail/internal/search/fields.go | 4 + .../0006_mail_documents_ocr_language.sql | 1 + .../0007_mail_documents_ocr_confidence.sql | 1 + mail/internal/search/ocr_confidence_test.go | 53 ++++++++ mail/internal/search/reindex.go | 10 +- 10 files changed, 427 insertions(+), 6 deletions(-) create mode 100644 mail/docs/SRC-10-PRUEFPROTOKOLL.md create mode 100644 mail/internal/ocr/language.go create mode 100644 mail/internal/ocr/language_test.go create mode 100644 mail/internal/search/migrations/0006_mail_documents_ocr_language.sql create mode 100644 mail/internal/search/migrations/0007_mail_documents_ocr_confidence.sql create mode 100644 mail/internal/search/ocr_confidence_test.go diff --git a/mail/docs/SRC-10-PRUEFPROTOKOLL.md b/mail/docs/SRC-10-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..c59bed8 --- /dev/null +++ b/mail/docs/SRC-10-PRUEFPROTOKOLL.md @@ -0,0 +1,57 @@ +# SRC-10 – Prüfprotokoll: Spracherkennung & OCR-Qualitätsbewertung + +Voraussetzung SRC-07 (Fertig). + +## Umsetzung + +- `mail/internal/ocr/language.go` — `RecognizeWithLanguageAndConfidence`: + Tesseract erkennt bei kombinierten Sprachpaketen (`deu+eng`) nicht, + WELCHE Sprache vorlag — daher wird das Bild bewusst EINZELN mit jedem + Kandidaten (`deu`, `eng`) im TSV-Ausgabemodus erkannt; die Sprache mit + dem höheren durchschnittlichen Worterkennungs-Konfidenzwert gewinnt + (Akzeptanzkriterium 1). Derselbe Tesseract-TSV-Lauf liefert den + Konfidenzwert direkt mit (Akzeptanzkriterium 2, 0–100, Mittelwert über + alle erkannten Wörter) — keine zweite externe Bibliothek nötig. +- `mail/internal/search`: neue Felder `ocr_language`/`ocr_confidence` + (Migrationen 0006/0007, gleiches ALTER-Muster wie SRC-05), in + `Document`/`Result` gespiegelt (Akzeptanzkriterium 1: für Anzeige + nutzbar). Neue Methode `Client.AttachmentsBelowConfidence(ctx, + tenantSlug, threshold)` (Akzeptanzkriterium 3: gezielt für manuelle + Nachbearbeitung auffindbar) — filtert `ocr_confidence < threshold`, + schließt Dokumente ohne OCR-Anhang (`ocr_confidence` bleibt 0) explizit + aus. +- Echten Regressionsbug beim eigenen Testlauf gefunden und behoben: + `reindex.go`s `buildCreateTableSQL` (SRC-09) kannte die neuen + OCR-Spalten nicht — ein Reindex nach dieser Kachel wäre mit "unknown + column" fehlgeschlagen. Jetzt ergänzt, mit Wartungshinweis im + Quelltext für künftige Schema-Erweiterungen. +- Kein Umbau: `Search`/`Facets`/`SearchWithFilters`/`Index`/`Delete`- + Verhalten sonst unverändert, `mail/internal/dedup`/`indexworker`/ + `storage`/`crypto`/`encstorage`/`savedsearch` unverändert. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Test mit mehrsprachigem Testkorpus bestätigt korrekte Spracherkennung | **bestanden** – `TestRecognizeWithLanguageAndConfidence_MultilingualCorpus`: reales deutsches Testbild ("Rechnung ueber Lieferung...") real als `deu` erkannt, reales englisches Testbild ("Invoice for delivery...") real als `eng` erkannt | +| 2 | Test: künstlich verschlechtertes Bild erzeugt niedrigeren Konfidenzwert | **bestanden** – `TestRecognizeWithLanguageAndConfidence_DegradedImageLowersConfidence`: reproduzierbare Pixelierung + Kontrastreduktion (reiner Go-Standardbibliothekscode, kein externes Werkzeug) senkt den real gemessenen Konfidenzwert von 91,76 auf 28,21 | +| 3 | Abfrage aller Anhänge unterhalb einer Konfidenzschwelle liefert erwartete Treffer | **bestanden** – `TestAttachmentsBelowConfidence_QueryReturnsExpectedResults`: 4 Dokumente (2 niedrig-, 1 hoch-konfident, 1 ohne OCR-Anhang), Abfrage mit Schwelle 50 liefert real genau die 2 niedrig-konfidenten, weder den hoch-konfidenten noch den ohne OCR-Anhang | + +## Build/Test-Ergebnis (192.168.1.131) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +go test ./internal/ocr/... -v -run 'Language|Degraded' -> 2/2 bestanden +TEST_TENANT_DSN=... TEST_MANTICORE_URL=... go test ./... -p 1 + -> alle Pakete bestanden (Regressionsbug in reindex.go vor diesem + Protokoll gefunden und behoben, danach vollständig grün) +``` + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen +real erfüllt. Entsperrt QA-03 (gemeinsam mit ARC-08, SRC-02, SRC-04, +SRC-05, SRC-08, SRC-09 — alle jetzt Fertig, letzte fehlende +Abhängigkeit). diff --git a/mail/internal/ocr/language.go b/mail/internal/ocr/language.go new file mode 100644 index 0000000..9c58901 --- /dev/null +++ b/mail/internal/ocr/language.go @@ -0,0 +1,124 @@ +// SRC-10: Spracherkennung & OCR-Qualitätsbewertung. Tesseract erkennt bei +// kombinierten Sprachpaketen (Languages = "deu+eng") nicht, WELCHE Sprache +// tatsächlich vorlag — daher wird das Bild hier bewusst EINZELN mit jedem +// Kandidaten aus candidateLanguages erkannt, und die Sprache mit dem +// höheren durchschnittlichen Worterkennungs-Konfidenzwert gewinnt +// (Akzeptanzkriterium 1). Derselbe Tesseract-TSV-Lauf liefert den +// Konfidenzwert direkt mit (Akzeptanzkriterium 2) — keine zweite externe +// Bibliothek für Sprach- ODER Konfidenzerkennung nötig. +package ocr + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +// candidateLanguages sind die unterstützten Einzelsprachen — muss eine +// Teilmenge der in Languages kombinierten Sprachpakete sein. +var candidateLanguages = []string{"deu", "eng"} + +// RecognizedText ist das Ergebnis einer sprach- und konfidenzbewussten +// Texterkennung. +type RecognizedText struct { + Text string + // Language ist der ISO-639-2-Sprachcode aus candidateLanguages mit dem + // höchsten Konfidenzwert (Akzeptanzkriterium 1). + Language string + // Confidence ist der durchschnittliche Tesseract-Worterkennungs- + // Konfidenzwert (0–100) der gewählten Sprache (Akzeptanzkriterium 2). + Confidence float64 +} + +// RecognizeWithLanguageAndConfidence erkennt Sprache und Text eines Bildes +// und liefert einen auswertbaren Konfidenzwert. +func RecognizeWithLanguageAndConfidence(ctx context.Context, imageBytes []byte) (RecognizedText, error) { + var best RecognizedText + haveResult := false + + for _, lang := range candidateLanguages { + text, confidence, err := recognizeWithTSV(ctx, imageBytes, lang) + if err != nil { + return RecognizedText{}, err + } + if !haveResult || confidence > best.Confidence { + best = RecognizedText{Text: text, Language: lang, Confidence: confidence} + haveResult = true + } + } + return best, nil +} + +// recognizeWithTSV führt Tesseract mit GENAU einer Sprache im TSV-Ausgabe- +// modus aus und liefert den zusammengesetzten Text sowie den +// durchschnittlichen Konfidenzwert über alle erkannten Wörter (Tesseract- +// TSV-Zeilen mit level=5, conf>=0 — negative Konfidenzwerte markieren +// Struktur-/Layoutzeilen ohne eigenen Text, siehe Tesseract-TSV-Format). +func recognizeWithTSV(ctx context.Context, imageBytes []byte, lang string) (text string, confidence float64, err error) { + workDir, err := os.MkdirTemp("", "mail-ocr-lang-*") + if err != nil { + return "", 0, fmt.Errorf("ocr: arbeitsverzeichnis anlegen: %w", err) + } + defer func() { _ = os.RemoveAll(workDir) }() + + imagePath := filepath.Join(workDir, "input") + if err := os.WriteFile(imagePath, imageBytes, 0o600); err != nil { + return "", 0, fmt.Errorf("ocr: bild schreiben: %w", err) + } + outBase := filepath.Join(workDir, "output") + + cmd := exec.CommandContext(ctx, "tesseract", imagePath, outBase, "-l", lang, "tsv") + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", 0, fmt.Errorf("ocr: tesseract (tsv, %s) fehlgeschlagen: %w (%s)", lang, err, stderr.String()) + } + + tsvBytes, err := os.ReadFile(outBase + ".tsv") + if err != nil { + return "", 0, fmt.Errorf("ocr: tsv-ausgabe lesen: %w", err) + } + return parseTSV(string(tsvBytes)) +} + +// tsvWordLevel ist der Tesseract-TSV-"level"-Wert für einzelne Wörter +// (1=Seite, 2=Block, 3=Absatz, 4=Zeile, 5=Wort). +const tsvWordLevel = "5" + +func parseTSV(tsv string) (text string, confidence float64, err error) { + lines := strings.Split(tsv, "\n") + var words []string + var confSum float64 + var confCount int + + for i, line := range lines { + if i == 0 || strings.TrimSpace(line) == "" { + continue // Kopfzeile bzw. Leerzeile überspringen + } + cols := strings.Split(line, "\t") + if len(cols) < 12 || cols[0] != tsvWordLevel { + continue + } + conf, convErr := strconv.ParseFloat(cols[10], 64) + if convErr != nil || conf < 0 { + continue + } + word := cols[11] + if word == "" { + continue + } + words = append(words, word) + confSum += conf + confCount++ + } + + if confCount == 0 { + return "", 0, nil + } + return strings.Join(words, " "), confSum / float64(confCount), nil +} diff --git a/mail/internal/ocr/language_test.go b/mail/internal/ocr/language_test.go new file mode 100644 index 0000000..d884f5a --- /dev/null +++ b/mail/internal/ocr/language_test.go @@ -0,0 +1,101 @@ +package ocr + +import ( + "bytes" + "context" + "image" + "image/color" + "image/png" + "testing" +) + +// TestRecognizeWithLanguageAndConfidence_MultilingualCorpus ist die +// geforderte Pflichtprüfung 1: Test mit mehrsprachigem Testkorpus +// bestätigt korrekte Spracherkennung. +func TestRecognizeWithLanguageAndConfidence_MultilingualCorpus(t *testing.T) { + requireTools(t) + ctx := context.Background() + + germanImage := knownTextImage(t, ctx, "Rechnung ueber Lieferung Nummer Zwei") + englishImage := knownTextImage(t, ctx, "Invoice for delivery number two") + + germanResult, err := RecognizeWithLanguageAndConfidence(ctx, germanImage) + if err != nil { + t.Fatalf("deutsches bild: %v", err) + } + if germanResult.Language != "deu" { + t.Fatalf("erwartete sprache 'deu' für deutschen text, habe %q (text: %q)", germanResult.Language, germanResult.Text) + } + + englishResult, err := RecognizeWithLanguageAndConfidence(ctx, englishImage) + if err != nil { + t.Fatalf("englisches bild: %v", err) + } + if englishResult.Language != "eng" { + t.Fatalf("erwartete sprache 'eng' für englischen text, habe %q (text: %q)", englishResult.Language, englishResult.Text) + } +} + +// TestRecognizeWithLanguageAndConfidence_DegradedImageLowersConfidence ist +// die geforderte Pflichtprüfung 2: künstlich verschlechtertes Bild erzeugt +// niedrigeren Konfidenzwert. +func TestRecognizeWithLanguageAndConfidence_DegradedImageLowersConfidence(t *testing.T) { + requireTools(t) + ctx := context.Background() + + original := knownTextImage(t, ctx, "Qualitaetsvergleich Testbild Konfidenz") + originalImg, err := png.Decode(bytes.NewReader(original)) + if err != nil { + t.Fatalf("original dekodieren: %v", err) + } + degradedImg := pixelate(originalImg, 12) + var degradedBuf bytes.Buffer + if err := png.Encode(°radedBuf, degradedImg); err != nil { + t.Fatalf("verschlechtertes bild kodieren: %v", err) + } + + originalResult, err := RecognizeWithLanguageAndConfidence(ctx, original) + if err != nil { + t.Fatalf("original erkennen: %v", err) + } + degradedResult, err := RecognizeWithLanguageAndConfidence(ctx, degradedBuf.Bytes()) + if err != nil { + t.Fatalf("verschlechtertes bild erkennen: %v", err) + } + + t.Logf("original: conf=%.2f text=%q — verschlechtert: conf=%.2f text=%q", + originalResult.Confidence, originalResult.Text, degradedResult.Confidence, degradedResult.Text) + if degradedResult.Confidence >= originalResult.Confidence { + t.Fatalf("erwartete niedrigeren konfidenzwert für verschlechtertes bild, habe original=%.2f verschlechtert=%.2f", + originalResult.Confidence, degradedResult.Confidence) + } +} + +// pixelate verschlechtert img künstlich, reproduzierbar und ohne externe +// Werkzeuge: Downsample um factor per Nearest-Neighbor, dann wieder auf +// Originalgröße hochskaliert — zerstört Feindetails der Textglyphen. +func pixelate(img image.Image, factor int) image.Image { + bounds := img.Bounds() + w, h := bounds.Dx(), bounds.Dy() + out := image.NewRGBA(bounds) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + blockX := (x / factor) * factor + blockY := (y / factor) * factor + c := img.At(bounds.Min.X+blockX, bounds.Min.Y+blockY) + out.Set(bounds.Min.X+x, bounds.Min.Y+y, colorToGray50(c)) + } + } + return out +} + +// colorToGray50 mischt zusätzlich mit 50% Grau, um den Kontrast zu +// verringern — verstärkt die künstliche Qualitätsverschlechterung über +// reine Pixelierung hinaus. +func colorToGray50(c color.Color) color.Color { + r, g, b, a := c.RGBA() + mix := func(v uint32) uint8 { + return uint8((v>>8)/2 + 128/2) + } + return color.RGBA{R: mix(r), G: mix(g), B: mix(b), A: uint8(a >> 8)} +} diff --git a/mail/internal/search/client.go b/mail/internal/search/client.go index baa95c0..bbf560a 100644 --- a/mail/internal/search/client.go +++ b/mail/internal/search/client.go @@ -38,11 +38,22 @@ var migrationAddAttachmentType string //go:embed migrations/0005_mail_documents_tag.sql var migrationAddTag string +// SRC-10: OCR-Sprach-/Qualitätsfelder, gleiches Muster wie die +// Facettenfelder aus SRC-05. +// +//go:embed migrations/0006_mail_documents_ocr_language.sql +var migrationAddOCRLanguage string + +//go:embed migrations/0007_mail_documents_ocr_confidence.sql +var migrationAddOCRConfidence string + var facetMigrations = []string{ migrationAddSender, migrationAddMailbox, migrationAddAttachmentType, migrationAddTag, + migrationAddOCRLanguage, + migrationAddOCRConfidence, } // Client spricht ausschließlich über die strukturierte Manticore-HTTP- @@ -122,6 +133,10 @@ type Document struct { Mailbox string `json:"mailbox"` AttachmentType string `json:"attachment_type"` Tag string `json:"tag"` + // OCR-Sprach-/Qualitätsfelder (SRC-10), optional — leerer String/0 + // bedeutet "kein OCR-Anhang bzw. kein Konfidenzwert vorhanden". + OCRLanguage string `json:"ocr_language"` + OCRConfidence float64 `json:"ocr_confidence"` } // Index legt/ersetzt ein Suchdokument (Akzeptanzkriterium 2: Schreibzugriff @@ -193,6 +208,11 @@ type Result struct { Subject string Score int64 SentAtUnixEpoch int64 + // OCRLanguage/OCRConfidence (SRC-10 Akzeptanzkriterium 1: erkannte + // Sprache für Anzeige nutzbar) — leer/0, wenn das Dokument keinen + // OCR-Anhang hat. + OCRLanguage string + OCRConfidence float64 } // fieldWeights gewichtet Betreff höher als Text, Anhangstext am @@ -307,6 +327,8 @@ func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Re Subject: hit.Source.Subject, Score: hit.Score, SentAtUnixEpoch: hit.Source.SentAtUnixEpoch, + OCRLanguage: hit.Source.OCRLanguage, + OCRConfidence: hit.Source.OCRConfidence, }) } return results, nil @@ -317,9 +339,11 @@ type searchResponse struct { Hits []struct { Score int64 `json:"_score"` Source struct { - MessageID string `json:"message_id"` - Subject string `json:"subject"` - SentAtUnixEpoch int64 `json:"sent_at"` + MessageID string `json:"message_id"` + Subject string `json:"subject"` + SentAtUnixEpoch int64 `json:"sent_at"` + OCRLanguage string `json:"ocr_language"` + OCRConfidence float64 `json:"ocr_confidence"` } `json:"_source"` } `json:"hits"` } `json:"hits"` diff --git a/mail/internal/search/facets.go b/mail/internal/search/facets.go index 5e7256d..17c972a 100644 --- a/mail/internal/search/facets.go +++ b/mail/internal/search/facets.go @@ -142,6 +142,8 @@ func (c *Client) SearchWithFilters(ctx context.Context, tenantSlug, queryText st Subject: hit.Source.Subject, Score: hit.Score, SentAtUnixEpoch: hit.Source.SentAtUnixEpoch, + OCRLanguage: hit.Source.OCRLanguage, + OCRConfidence: hit.Source.OCRConfidence, }) } return results, nil @@ -230,6 +232,56 @@ func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filte return result, nil } +// AttachmentsBelowConfidence liefert alle Dokumente eines Mandanten, deren +// OCR-Konfidenzwert UNTER threshold liegt (SRC-10 Akzeptanzkriterium 3: +// gezielt für manuelle Nachbearbeitung auffindbar). Dokumente ohne +// OCR-Anhang (ocr_confidence bleibt 0) tauchen hier NICHT auf — 0 ist +// "kein Wert", nicht "schlechtester Wert" (siehe Document-Feldkommentar) — +// daher zusätzlicher Filter ocr_confidence > 0. +func (c *Client) AttachmentsBelowConfidence(ctx context.Context, tenantSlug string, threshold float64) ([]Result, error) { + payload := map[string]any{ + "index": IndexName, + "query": map[string]any{ + "bool": map[string]any{ + "must": []map[string]any{ + {"equals": map[string]any{FieldTenantSlug: tenantSlug}}, + {"range": map[string]any{FieldOCRConfidence: map[string]any{"gt": 0}}}, + {"range": map[string]any{FieldOCRConfidence: map[string]any{"lt": threshold}}}, + }, + }, + }, + "sort": []map[string]any{{FieldOCRConfidence: "asc"}}, + "limit": searchResultLimit, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("search: konfidenzanfrage serialisieren: %w", err) + } + + respBody, err := c.doSearchWithSwapRetry(ctx, body) + if err != nil { + return nil, err + } + + var parsed searchResponse + if err := json.Unmarshal(respBody, &parsed); err != nil { + return nil, fmt.Errorf("search: antwort parsen: %w", err) + } + + results := make([]Result, 0, len(parsed.Hits.Hits)) + for _, hit := range parsed.Hits.Hits { + results = append(results, Result{ + MessageID: hit.Source.MessageID, + Subject: hit.Source.Subject, + Score: hit.Score, + SentAtUnixEpoch: hit.Source.SentAtUnixEpoch, + OCRLanguage: hit.Source.OCRLanguage, + OCRConfidence: hit.Source.OCRConfidence, + }) + } + return results, nil +} + type facetResponse struct { Aggregations map[string]struct { Buckets []struct { diff --git a/mail/internal/search/fields.go b/mail/internal/search/fields.go index 3975cf7..5e34f9e 100644 --- a/mail/internal/search/fields.go +++ b/mail/internal/search/fields.go @@ -29,6 +29,10 @@ const ( FieldMailbox = "mailbox" FieldAttachmentType = "attachment_type" FieldTag = "tag" + // OCR-Sprach-/Qualitätsfelder (SRC-10), nachgezogen über + // migrations/0006..0007. + FieldOCRLanguage = "ocr_language" + FieldOCRConfidence = "ocr_confidence" ) // FacetFields sind die je Kachel unterstützten Filterdimensionen diff --git a/mail/internal/search/migrations/0006_mail_documents_ocr_language.sql b/mail/internal/search/migrations/0006_mail_documents_ocr_language.sql new file mode 100644 index 0000000..42f875f --- /dev/null +++ b/mail/internal/search/migrations/0006_mail_documents_ocr_language.sql @@ -0,0 +1 @@ +ALTER TABLE mail_documents ADD COLUMN ocr_language string attribute indexed diff --git a/mail/internal/search/migrations/0007_mail_documents_ocr_confidence.sql b/mail/internal/search/migrations/0007_mail_documents_ocr_confidence.sql new file mode 100644 index 0000000..0a69204 --- /dev/null +++ b/mail/internal/search/migrations/0007_mail_documents_ocr_confidence.sql @@ -0,0 +1 @@ +ALTER TABLE mail_documents ADD COLUMN ocr_confidence float diff --git a/mail/internal/search/ocr_confidence_test.go b/mail/internal/search/ocr_confidence_test.go new file mode 100644 index 0000000..601fce3 --- /dev/null +++ b/mail/internal/search/ocr_confidence_test.go @@ -0,0 +1,53 @@ +// Integrationstest (SRC-10): echte Manticore-Instanz, TEST_MANTICORE_URL +// (gleiche Konvention wie facets_test.go/ranking_test.go). +package search + +import ( + "context" + "testing" +) + +// TestAttachmentsBelowConfidence_QueryReturnsExpectedResults ist die +// geforderte Pflichtprüfung 3: Abfrage aller Anhänge unterhalb einer +// Konfidenzschwelle liefert erwartete Treffer. +func TestAttachmentsBelowConfidence_QueryReturnsExpectedResults(t *testing.T) { + client := setupClient(t) + ctx := context.Background() + tenant := "mandant-src10-konfidenz" + + docs := []Document{ + {MessageID: "msg-conf-niedrig-1", Subject: "a", OCRLanguage: "deu", OCRConfidence: 22.5}, + {MessageID: "msg-conf-niedrig-2", Subject: "b", OCRLanguage: "eng", OCRConfidence: 41.0}, + {MessageID: "msg-conf-hoch", Subject: "c", OCRLanguage: "deu", OCRConfidence: 93.2}, + {MessageID: "msg-conf-kein-ocr", Subject: "d"}, // kein OCR-Anhang, OCRConfidence bleibt 0 + } + for _, d := range docs { + d.TenantSlug = tenant + d.ID = DocumentID(tenant, d.MessageID) + if err := client.Index(ctx, d); err != nil { + t.Fatalf("index %s: %v", d.MessageID, err) + } + } + + results, err := client.AttachmentsBelowConfidence(ctx, tenant, 50.0) + if err != nil { + t.Fatalf("attachmentsbelowconfidence: %v", err) + } + + ids := make(map[string]bool, len(results)) + for _, r := range results { + ids[r.MessageID] = true + } + if !ids["msg-conf-niedrig-1"] || !ids["msg-conf-niedrig-2"] { + t.Fatalf("erwartete beide niedrig-konfidenten anhänge, habe: %+v", results) + } + if ids["msg-conf-hoch"] { + t.Fatalf("hoch-konfidenter anhang hätte nicht auftauchen dürfen: %+v", results) + } + if ids["msg-conf-kein-ocr"] { + t.Fatalf("dokument ohne ocr-anhang (confidence=0) hätte nicht auftauchen dürfen: %+v", results) + } + if len(results) != 2 { + t.Fatalf("erwartete genau 2 treffer, habe %d: %+v", len(results), results) + } +} diff --git a/mail/internal/search/reindex.go b/mail/internal/search/reindex.go index 1afd883..5916165 100644 --- a/mail/internal/search/reindex.go +++ b/mail/internal/search/reindex.go @@ -276,13 +276,17 @@ func (c *Client) showTables(ctx context.Context) ([]string, error) { // buildCreateTableSQL erzeugt die Schema-DDL für eine Zwischentabelle mit // demselben Spaltensatz wie mail_documents (Basis + Facettenfelder aus -// SRC-05). tableName ist über tempTableNamePattern in Rebuild bereits -// geprüft, bevor diese Funktion aufgerufen wird. +// SRC-05 + OCR-Felder aus SRC-10). tableName ist über +// tempTableNamePattern in Rebuild bereits geprüft, bevor diese Funktion +// aufgerufen wird. MUSS bei jeder neuen Spalte in mail_documents +// (migrations/000N_*.sql) mitgepflegt werden — sonst schlägt Reindex mit +// "unknown column" fehl (siehe SRC-10, real so aufgetreten und behoben). func buildCreateTableSQL(tableName string) string { return fmt.Sprintf( - "CREATE TABLE %s (%s string attribute indexed, %s string attribute indexed, %s text, %s text, %s text, %s string attribute indexed, %s string attribute indexed, %s string attribute indexed, %s string attribute indexed, %s timestamp)", + "CREATE TABLE %s (%s string attribute indexed, %s string attribute indexed, %s text, %s text, %s text, %s string attribute indexed, %s string attribute indexed, %s string attribute indexed, %s string attribute indexed, %s timestamp, %s string attribute indexed, %s float)", tableName, FieldTenantSlug, FieldMessageID, FieldSubject, FieldBody, FieldAttachmentText, FieldSender, FieldMailbox, FieldAttachmentType, FieldTag, FieldSentAt, + FieldOCRLanguage, FieldOCRConfidence, ) }