SRC-10: spracherkennung-ocr-qualitaetsbewertung

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
This commit is contained in:
sysops
2026-08-31 22:16:26 +02:00
co-authored by Claude Sonnet 5
parent bd37de529f
commit 1a246abdb3
10 changed files with 427 additions and 6 deletions
+27 -3
View File
@@ -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"`
+52
View File
@@ -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 {
+4
View File
@@ -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
@@ -0,0 +1 @@
ALTER TABLE mail_documents ADD COLUMN ocr_language string attribute indexed
@@ -0,0 +1 @@
ALTER TABLE mail_documents ADD COLUMN ocr_confidence float
@@ -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)
}
}
+7 -3
View File
@@ -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,
)
}