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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
125 lines
4.1 KiB
Go
125 lines
4.1 KiB
Go
// 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
|
||
}
|