Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd37de529f | ||
|
|
ff4d716b94 |
@@ -0,0 +1,69 @@
|
|||||||
|
# SRC-07 – Prüfprotokoll: OCR für Anhänge
|
||||||
|
|
||||||
|
Voraussetzung ARC-01, ARC-03 (beide Fertig). SRC-07 ist die direkte
|
||||||
|
Vorbedingung für SRC-10 (Spracherkennung & OCR-Qualitätsbewertung), nicht
|
||||||
|
nur eine nette Ergänzung — ohne SRC-07 gibt es keinen erkannten Text, den
|
||||||
|
SRC-10 mit Sprache/Konfidenz bewerten könnte.
|
||||||
|
|
||||||
|
## Umsetzung
|
||||||
|
|
||||||
|
- `mail/internal/ocr/ocr.go` — zustandsloses Paket, kennt weder Mandant
|
||||||
|
noch Speicher (dieselbe Bauart wie `mail/internal/crypto`/`dedup`):
|
||||||
|
nimmt Anhangs-Bytes entgegen, liefert erkannten Text zurück. Kein
|
||||||
|
geteilter Zustand zwischen Aufrufen (jeder Aufruf bekommt ein eigenes
|
||||||
|
Temp-Verzeichnis) — Tenant-Trennung ist dadurch strukturell gegeben,
|
||||||
|
nicht nur konventionell: ein Mandant kann prinzipbedingt nie Zwischen-
|
||||||
|
daten eines anderen sehen. Zuordnung des erkannten Textes zum
|
||||||
|
Mail-Suchdokument (Akzeptanzkriterium 2) erfolgt beim Aufrufer über das
|
||||||
|
bereits vorhandene `search.Document.AttachmentText`-Feld (SRC-01) — kein
|
||||||
|
neues Feld nötig.
|
||||||
|
- `ExtractTextFromImage`: ruft `tesseract` (Sprachen `deu+eng`) mit
|
||||||
|
fester Argumentliste auf, kein Shell-String-Zusammenbau.
|
||||||
|
- `HasTextLayer`/`ExtractTextFromPDF`: nutzt `pdftotext`, um eine
|
||||||
|
vorhandene Textebene zu erkennen und direkt zu übernehmen
|
||||||
|
(Akzeptanzkriterium 3) — nur wenn keine Textebene vorhanden ist
|
||||||
|
(< 10 Zeichen), wird über `pdftoppm` (300dpi) jede Seite gerastert und
|
||||||
|
per Tesseract erkannt (Akzeptanzkriterium 1).
|
||||||
|
- Bekannten Fehler vermieden (dupliziertes Sprintf-WHERE-Muster aus
|
||||||
|
archivmail, siehe repos-analyse-mail-reuse.md): dieses Paket baut keine
|
||||||
|
SQL-Klauseln — ausschließlich externe Kommandozeilenwerkzeuge mit
|
||||||
|
festen Argumentlisten (`exec.CommandContext`, keine Shell).
|
||||||
|
- Kein Umbau: `mail/internal/search`/`dedup`/`indexworker`/`storage`/
|
||||||
|
`crypto`/`encstorage`/`savedsearch` unverändert.
|
||||||
|
|
||||||
|
## Prüfungen
|
||||||
|
|
||||||
|
| # | Prüfung | Ergebnis |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Test: Bildanhang mit bekanntem Text liefert erwartete Texterkennung | **bestanden** – `TestExtractTextFromImage_KnownTextRecognized`: reales, über `pdftoppm` gerastertes Bild mit dem Text "Rechnungsnummer 4711", `ExtractTextFromImage` erkennt real beide Wortbestandteile |
|
||||||
|
| 2 | Test: PDF mit vorhandener Textebene wird korrekt übersprungen | **bestanden** – `TestExtractTextFromPDF_SkipsOCRWhenTextLayerPresent`: real erzeugtes Vektor-Text-PDF (echte PDF-Textebene, kein Bild), `ExtractTextFromPDF` liefert `OCRPerformed=false` und den Text direkt aus der Textebene |
|
||||||
|
| 3 | Durchsatztest bestätigt akzeptable Verarbeitungszeit je Anhang | **bestanden** – `TestExtractTextFromPDF_ThroughputIsAcceptable`: 3 reale Anhänge (Rasterung 300dpi + OCR) in durchschnittlich 3,48s/Anhang (Ziel 8s/Anhang) |
|
||||||
|
|
||||||
|
Zusätzlich (Akzeptanzkriterium 1, gescannte PDFs end-zu-Ende):
|
||||||
|
`TestExtractTextFromPDF_PerformsOCRWhenNoTextLayer` — ein reales,
|
||||||
|
ausschließlich rasterbildbasiertes PDF (kein Textelement, JPEG-Bild via
|
||||||
|
`/DCTDecode` eingebettet) wird real per OCR erkannt, `OCRPerformed=true`.
|
||||||
|
|
||||||
|
Testfixtures (`testpdf_test.go`) werden vollständig in Go erzeugt (Hand-
|
||||||
|
gebautes PDF mit Helvetica-Textebene bzw. eingebettetem JPEG) — keine
|
||||||
|
externe Bibliothek, keine Testdateien im Repository, reproduzierbar.
|
||||||
|
|
||||||
|
## Build/Test-Ergebnis (192.168.1.131)
|
||||||
|
|
||||||
|
```
|
||||||
|
go build ./... -> clean
|
||||||
|
go vet ./... -> clean
|
||||||
|
golangci-lint run ./... -> 0 issues
|
||||||
|
go test ./internal/ocr/... -v -> 4/4 bestanden (14,99s gesamt)
|
||||||
|
TEST_TENANT_DSN=... TEST_MANTICORE_URL=... go test ./... -p 1
|
||||||
|
-> alle Pakete bestanden, keine Regression
|
||||||
|
```
|
||||||
|
|
||||||
|
Werkzeugversionen auf 192.168.1.131: `tesseract 5.5.0` (Sprachpakete
|
||||||
|
`deu`, `eng`), `pdftotext`/`pdftoppm` (poppler-utils) — bereits vorhanden,
|
||||||
|
keine Installation durch diese Sitzung nötig.
|
||||||
|
|
||||||
|
## Gesamtergebnis
|
||||||
|
|
||||||
|
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
|
||||||
|
real erfüllt. Entsperrt SRC-10.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# SRC-08 – Prüfprotokoll: Gespeicherte Suchanfragen
|
||||||
|
|
||||||
|
Voraussetzung SRC-03 (Fertig).
|
||||||
|
|
||||||
|
## Umsetzung
|
||||||
|
|
||||||
|
- `mail/internal/savedsearch/store.go` — `Store` (Postgres,
|
||||||
|
`mail_saved_searches`): `Save` (Upsert über `UNIQUE(tenant_slug,
|
||||||
|
user_id, name)`, Akzeptanzkriterium 1), `List`/`Get` streng auf
|
||||||
|
Mandant UND Benutzer beschränkt (Akzeptanzkriterium 3), `Delete`
|
||||||
|
löscht genau eine Zeile über `tenant_slug + user_id + id`.
|
||||||
|
„Benutzer" ist bis zu einer zentralen Session-/IAM-Anbindung
|
||||||
|
(Core-Board-Scope) ein vom Aufrufer mitgegebener opaker
|
||||||
|
`userID`-String — dieselbe Konvention wie der Tenant-Kontext in
|
||||||
|
`web/mail-search` (SRC-04).
|
||||||
|
- `Execute(ctx, client, saved)` führt die gespeicherte Suche LIVE gegen
|
||||||
|
`search.Client` aus — speichert selbst keine Treffer, jeder Aufruf
|
||||||
|
fragt Manticore neu ab (Akzeptanzkriterium 2).
|
||||||
|
- `mail/internal/search/facets.go` — kleinste nötige Erweiterung: neue
|
||||||
|
Methode `Client.SearchWithFilters` (gemeinsame `buildFilteredMust`-
|
||||||
|
Hilfsfunktion mit `Facets` extrahiert) liefert TATSÄCHLICH gefilterte
|
||||||
|
Treffer statt nur Facettenzählungen — ohne dies gäbe es keinen echten
|
||||||
|
Weg, gespeicherte Filter beim Wiederausführen anzuwenden.
|
||||||
|
- Kein Umbau: `Search`/`Facets`/`Index`/`Delete`-Verhalten sonst
|
||||||
|
unverändert, `mail/internal/dedup`/`indexworker`/`storage`/`crypto`/
|
||||||
|
`encstorage` unverändert.
|
||||||
|
|
||||||
|
## Prüfungen
|
||||||
|
|
||||||
|
| # | Prüfung | Ergebnis |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Test: gespeicherte Suche mit mehreren Filtern wird korrekt reproduziert | **bestanden** – `TestExecute_SavedSearchWithMultipleFiltersReproducesCorrectly`: 3 Dokumente, 2 Filter (Sender+Postfach) gespeichert, `Execute` liefert real genau das eine Dokument, das beide Filter erfüllt |
|
||||||
|
| 2 | Test: Benutzer sieht keine gespeicherten Suchen anderer Mandanten | **bestanden** – `TestList_UserSeesNoOtherTenantsSavedSearches`: zwei Mandanten mit je einer gespeicherten Suche, `List` bei Mandant B liefert real nur die eigene, nicht die von Mandant A |
|
||||||
|
| 3 | Test: Löschen einer gespeicherten Suche entfernt nur diese | **bestanden** – `TestDelete_RemovesOnlyThatSavedSearch`: zwei gespeicherte Suchen, eine gelöscht, `Get` liefert für die gelöschte real `ErrNotFound`, die andere bleibt real unverändert abrufbar |
|
||||||
|
|
||||||
|
Zusätzlich (Akzeptanzkriterium 2, kein eingefrorener Snapshot):
|
||||||
|
`TestExecute_ReturnsCurrentResultsNotFrozenSnapshot` — Ausführung vor
|
||||||
|
einer neuen Indexierung liefert real 0 Treffer, danach real 1 Treffer,
|
||||||
|
ohne dass die gespeicherte Suche selbst verändert wurde.
|
||||||
|
|
||||||
|
## Build/Test-Ergebnis (192.168.1.131)
|
||||||
|
|
||||||
|
```
|
||||||
|
go build ./... -> clean
|
||||||
|
go vet ./... -> clean
|
||||||
|
golangci-lint run ./... -> 0 issues
|
||||||
|
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
|
||||||
|
TEST_MANTICORE_URL=http://127.0.0.1:9308 \
|
||||||
|
go test ./... -p 1 -> alle Pakete bestanden, inkl. internal/savedsearch (4 Tests, neu),
|
||||||
|
keine Regression in dedup/indexworker/storage/encstorage/example/mimeparse/pflichttestgate/
|
||||||
|
crypto/search
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gesamtergebnis
|
||||||
|
|
||||||
|
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
|
||||||
|
real erfüllt. Trägt (gemeinsam mit ARC-08, SRC-02, SRC-04, SRC-05,
|
||||||
|
SRC-09) zu QA-03 bei — QA-03 bleibt weiterhin blockiert, bis auch
|
||||||
|
SRC-10 fertig ist.
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// Package ocr implementiert SRC-07: eine OCR-Pipeline für Bild-/PDF-
|
||||||
|
// Anhänge, als eigener Mail-OCR-Pfad unabhängig vom DMS-Board (siehe
|
||||||
|
// repos-analyse-mail-reuse.md: dupliziertes Sprintf-WHERE-Muster aus
|
||||||
|
// archivmail wird NICHT übernommen — dieses Paket baut keine SQL-Klauseln,
|
||||||
|
// ruft ausschließlich externe Kommandozeilenwerkzeuge mit festen
|
||||||
|
// Argumentlisten auf, niemals über eine Shell/String-Konkatenation).
|
||||||
|
//
|
||||||
|
// Zustandslos: dieses Paket kennt weder Mandanten noch Speicher — es
|
||||||
|
// nimmt Anhangs-Bytes entgegen und liefert erkannten Text zurück, exakt
|
||||||
|
// wie mail/internal/crypto und mail/internal/dedup. Tenant-Trennung
|
||||||
|
// entsteht dadurch strukturell: es gibt keinen geteilten Zustand
|
||||||
|
// zwischen Aufrufen, den unterschiedliche Mandanten sehen könnten (jeder
|
||||||
|
// Aufruf bekommt sein eigenes Temp-Verzeichnis, siehe workDir).
|
||||||
|
//
|
||||||
|
// Bekannter Fehler vermeiden (siehe ARC-03-Ticket): OCR darf erst NACH
|
||||||
|
// abgeschlossener Duplikatsprüfung laufen — das ist Sache des Aufrufers
|
||||||
|
// (spätere Ingest-Tickets), dieses Paket kennt mail/internal/dedup nicht.
|
||||||
|
package ocr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Languages ist die feste Tesseract-Sprachkombination (Akzeptanzkriterium
|
||||||
|
// 1 aus SRC-10 baut hierauf auf) — Deutsch und Englisch, passend zum
|
||||||
|
// deutschsprachigen NEXARCH-Einsatzkontext mit englischsprachigen
|
||||||
|
// Geschäftskorrespondenzen.
|
||||||
|
const Languages = "deu+eng"
|
||||||
|
|
||||||
|
// ExtractTextFromImage führt Tesseract-OCR auf einem einzelnen Bild aus
|
||||||
|
// (PNG/JPEG/TIFF/BMP — von Tesseract/Leptonica unterstützte Formate).
|
||||||
|
func ExtractTextFromImage(ctx context.Context, imageBytes []byte) (string, error) {
|
||||||
|
workDir, err := os.MkdirTemp("", "mail-ocr-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", 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 "", fmt.Errorf("ocr: bild schreiben: %w", err)
|
||||||
|
}
|
||||||
|
outBase := filepath.Join(workDir, "output")
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "tesseract", imagePath, outBase, "-l", Languages)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("ocr: tesseract fehlgeschlagen: %w (%s)", err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
text, err := os.ReadFile(outBase + ".txt")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ocr: erkannten text lesen: %w", err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(text)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// minTextLayerLength ist die Mindestanzahl nicht-leerer Zeichen, ab der
|
||||||
|
// ein PDF als "hat bereits eine Textebene" gilt (Akzeptanzkriterium 3).
|
||||||
|
// Ein paar Steuerzeichen/Leerzeichen aus pdftotext bei einem rein
|
||||||
|
// bildbasierten PDF dürfen keine OCR-Umgehung auslösen.
|
||||||
|
const minTextLayerLength = 10
|
||||||
|
|
||||||
|
// HasTextLayer prüft über pdftotext, ob pdfBytes bereits durchsuchbaren
|
||||||
|
// Text enthält (Akzeptanzkriterium 3: bereits durchsuchbare PDFs werden
|
||||||
|
// nicht unnötig erneut per OCR verarbeitet).
|
||||||
|
func HasTextLayer(ctx context.Context, pdfBytes []byte) (bool, error) {
|
||||||
|
text, err := extractPDFText(ctx, pdfBytes)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return len(strings.TrimSpace(text)) >= minTextLayerLength, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractPDFText(ctx context.Context, pdfBytes []byte) (string, error) {
|
||||||
|
workDir, err := os.MkdirTemp("", "mail-ocr-pdf-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ocr: arbeitsverzeichnis anlegen: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.RemoveAll(workDir) }()
|
||||||
|
|
||||||
|
pdfPath := filepath.Join(workDir, "input.pdf")
|
||||||
|
if err := os.WriteFile(pdfPath, pdfBytes, 0o600); err != nil {
|
||||||
|
return "", fmt.Errorf("ocr: pdf schreiben: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "pdftotext", pdfPath, "-")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("ocr: pdftotext fehlgeschlagen: %w (%s)", err, stderr.String())
|
||||||
|
}
|
||||||
|
return stdout.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDFResult ist das Ergebnis von ExtractTextFromPDF.
|
||||||
|
type PDFResult struct {
|
||||||
|
Text string
|
||||||
|
// OCRPerformed ist false, wenn eine vorhandene Textebene genutzt
|
||||||
|
// wurde (Akzeptanzkriterium 3), true, wenn tatsächlich per Tesseract
|
||||||
|
// erkannt wurde (Akzeptanzkriterium 1).
|
||||||
|
OCRPerformed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractTextFromPDF liefert den Text eines PDF-Anhangs: hat das PDF
|
||||||
|
// bereits eine Textebene, wird sie direkt übernommen (schneller,
|
||||||
|
// zuverlässiger als OCR, Akzeptanzkriterium 3). Sonst wird jede Seite
|
||||||
|
// gerastert und per Tesseract erkannt (Akzeptanzkriterium 1: gescannte
|
||||||
|
// PDF-Anhänge).
|
||||||
|
func ExtractTextFromPDF(ctx context.Context, pdfBytes []byte) (PDFResult, error) {
|
||||||
|
existingText, err := extractPDFText(ctx, pdfBytes)
|
||||||
|
if err != nil {
|
||||||
|
return PDFResult{}, err
|
||||||
|
}
|
||||||
|
if len(strings.TrimSpace(existingText)) >= minTextLayerLength {
|
||||||
|
return PDFResult{Text: strings.TrimSpace(existingText), OCRPerformed: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pageImages, err := rasterizePDF(ctx, pdfBytes)
|
||||||
|
if err != nil {
|
||||||
|
return PDFResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var pages []string
|
||||||
|
for _, imageBytes := range pageImages {
|
||||||
|
pageText, err := ExtractTextFromImage(ctx, imageBytes)
|
||||||
|
if err != nil {
|
||||||
|
return PDFResult{}, err
|
||||||
|
}
|
||||||
|
pages = append(pages, pageText)
|
||||||
|
}
|
||||||
|
return PDFResult{Text: strings.TrimSpace(strings.Join(pages, "\n")), OCRPerformed: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rasterizePDF rastert jede Seite von pdfBytes über pdftoppm in ein PNG
|
||||||
|
// und liefert die Seiten in Reihenfolge.
|
||||||
|
func rasterizePDF(ctx context.Context, pdfBytes []byte) ([][]byte, error) {
|
||||||
|
workDir, err := os.MkdirTemp("", "mail-ocr-raster-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ocr: arbeitsverzeichnis anlegen: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.RemoveAll(workDir) }()
|
||||||
|
|
||||||
|
pdfPath := filepath.Join(workDir, "input.pdf")
|
||||||
|
if err := os.WriteFile(pdfPath, pdfBytes, 0o600); err != nil {
|
||||||
|
return nil, fmt.Errorf("ocr: pdf schreiben: %w", err)
|
||||||
|
}
|
||||||
|
outBase := filepath.Join(workDir, "page")
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "pdftoppm", "-png", "-r", "300", pdfPath, outBase)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("ocr: pdftoppm fehlgeschlagen: %w (%s)", err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(workDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ocr: gerasterte seiten auflisten: %w", err)
|
||||||
|
}
|
||||||
|
var pagePaths []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.HasSuffix(e.Name(), ".png") {
|
||||||
|
pagePaths = append(pagePaths, filepath.Join(workDir, e.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(pagePaths)
|
||||||
|
|
||||||
|
var pages [][]byte
|
||||||
|
for _, p := range pagePaths {
|
||||||
|
data, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ocr: gerasterte seite lesen: %w", err)
|
||||||
|
}
|
||||||
|
pages = append(pages, data)
|
||||||
|
}
|
||||||
|
return pages, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package ocr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"image/png"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func requireTools(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
for _, tool := range []string{"tesseract", "pdftotext", "pdftoppm"} {
|
||||||
|
if _, err := exec.LookPath(tool); err != nil {
|
||||||
|
t.Skipf("%s nicht im PATH, Integrationstest übersprungen", tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownTextImage rastert ein hand-gebautes Text-PDF (bekannter Inhalt)
|
||||||
|
// über die echte pdftoppm-Pipeline in ein reales PNG-Bild — liefert damit
|
||||||
|
// ein Bild mit tatsächlich gerenderten, OCR-fähigen Glyphen, ohne eine
|
||||||
|
// zusätzliche Font-Rendering-Bibliothek einzuführen.
|
||||||
|
func knownTextImage(t *testing.T, ctx context.Context, text string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
pdfBytes := buildTextLayerPDF(text)
|
||||||
|
pages, err := rasterizePDF(ctx, pdfBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("testbild rastern: %v", err)
|
||||||
|
}
|
||||||
|
if len(pages) != 1 {
|
||||||
|
t.Fatalf("erwartete genau 1 gerasterte seite, habe %d", len(pages))
|
||||||
|
}
|
||||||
|
return pages[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractTextFromImage_KnownTextRecognized ist die geforderte
|
||||||
|
// Pflichtprüfung 1: Bildanhang mit bekanntem Text liefert erwartete
|
||||||
|
// Texterkennung.
|
||||||
|
func TestExtractTextFromImage_KnownTextRecognized(t *testing.T) {
|
||||||
|
requireTools(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
imageBytes := knownTextImage(t, ctx, "Rechnungsnummer 4711")
|
||||||
|
|
||||||
|
got, err := ExtractTextFromImage(ctx, imageBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extracttextfromimage: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "Rechnungsnummer") || !strings.Contains(got, "4711") {
|
||||||
|
t.Fatalf("erwarteten text nicht erkannt, habe: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractTextFromPDF_SkipsOCRWhenTextLayerPresent ist die geforderte
|
||||||
|
// Pflichtprüfung 2: PDF mit vorhandener Textebene wird korrekt
|
||||||
|
// übersprungen.
|
||||||
|
func TestExtractTextFromPDF_SkipsOCRWhenTextLayerPresent(t *testing.T) {
|
||||||
|
requireTools(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
pdfBytes := buildTextLayerPDF("Vertragsentwurf Version 3")
|
||||||
|
|
||||||
|
hasLayer, err := HasTextLayer(ctx, pdfBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hastextlayer: %v", err)
|
||||||
|
}
|
||||||
|
if !hasLayer {
|
||||||
|
t.Fatal("erwartete erkannte textebene im vektor-pdf")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ExtractTextFromPDF(ctx, pdfBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extracttextfrompdf: %v", err)
|
||||||
|
}
|
||||||
|
if result.OCRPerformed {
|
||||||
|
t.Fatal("ocr wurde durchgeführt, obwohl eine textebene vorhanden war")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Text, "Vertragsentwurf") {
|
||||||
|
t.Fatalf("erwarteten text aus textebene nicht gefunden, habe: %q", result.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractTextFromPDF_PerformsOCRWhenNoTextLayer deckt
|
||||||
|
// Akzeptanzkriterium 1 (gescannte PDF-Anhänge) zusätzlich end-zu-Ende ab:
|
||||||
|
// ein PDF ohne Textebene, das nur ein Rasterbild enthält, wird real per
|
||||||
|
// OCR erkannt.
|
||||||
|
func TestExtractTextFromPDF_PerformsOCRWhenNoTextLayer(t *testing.T) {
|
||||||
|
requireTools(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pngBytes := knownTextImage(t, ctx, "Gescannter Anhang Test")
|
||||||
|
img, err := png.Decode(bytes.NewReader(pngBytes))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("testbild dekodieren: %v", err)
|
||||||
|
}
|
||||||
|
scannedPDF, err := buildImageOnlyPDF(img)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bild-pdf bauen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasLayer, err := HasTextLayer(ctx, scannedPDF)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hastextlayer: %v", err)
|
||||||
|
}
|
||||||
|
if hasLayer {
|
||||||
|
t.Fatal("bild-only-pdf wurde fälschlich als textebene erkannt")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ExtractTextFromPDF(ctx, scannedPDF)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extracttextfrompdf: %v", err)
|
||||||
|
}
|
||||||
|
if !result.OCRPerformed {
|
||||||
|
t.Fatal("erwartete durchgeführte ocr bei fehlender textebene")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Text, "Gescannter") {
|
||||||
|
t.Fatalf("erwarteten text aus ocr nicht gefunden, habe: %q", result.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractTextFromPDF_ThroughputIsAcceptable ist die geforderte
|
||||||
|
// Pflichtprüfung 3: Durchsatztest bestätigt akzeptable Verarbeitungszeit
|
||||||
|
// je Anhang. Zielzeit 8s je Anhang (großzügig für Rasterung + OCR bei
|
||||||
|
// 300dpi, deckt aber real ab, dass kein pathologischer Ausreißer
|
||||||
|
// entsteht).
|
||||||
|
func TestExtractTextFromPDF_ThroughputIsAcceptable(t *testing.T) {
|
||||||
|
requireTools(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const attachments = 3
|
||||||
|
const targetPerAttachment = 8 * time.Second
|
||||||
|
|
||||||
|
pngBytes := knownTextImage(t, ctx, "Durchsatztest Anhang")
|
||||||
|
img, err := png.Decode(bytes.NewReader(pngBytes))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("testbild dekodieren: %v", err)
|
||||||
|
}
|
||||||
|
scannedPDF, err := buildImageOnlyPDF(img)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bild-pdf bauen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
for i := 0; i < attachments; i++ {
|
||||||
|
if _, err := ExtractTextFromPDF(ctx, scannedPDF); err != nil {
|
||||||
|
t.Fatalf("anhang %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
perAttachment := elapsed / attachments
|
||||||
|
t.Logf("Durchsatz: %d Anhänge in %s (%s/Anhang, Ziel %s/Anhang)", attachments, elapsed, perAttachment, targetPerAttachment)
|
||||||
|
if perAttachment > targetPerAttachment {
|
||||||
|
t.Fatalf("verarbeitung zu langsam: %s/anhang, ziel %s/anhang", perAttachment, targetPerAttachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package ocr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildTextLayerPDF erzeugt ein minimales, aber ECHTES PDF mit einer
|
||||||
|
// vektorbasierten Textebene (Standardfont Helvetica, kein eingebettetes
|
||||||
|
// Fontprogramm nötig) — von Hand gebaut, keine externe Bibliothek/kein
|
||||||
|
// externes Werkzeug, damit der Test reproduzierbar und unabhängig von
|
||||||
|
// Systempaketen bleibt. pdftotext extrahiert text unmittelbar daraus.
|
||||||
|
func buildTextLayerPDF(text string) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
offsets := make([]int, 0, 6)
|
||||||
|
|
||||||
|
buf.WriteString("%PDF-1.4\n")
|
||||||
|
|
||||||
|
writeObj := func(n int, body string) {
|
||||||
|
offsets = append(offsets, buf.Len())
|
||||||
|
fmt.Fprintf(&buf, "%d 0 obj\n%s\nendobj\n", n, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeObj(1, "<< /Type /Catalog /Pages 2 0 R >>")
|
||||||
|
writeObj(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
|
||||||
|
writeObj(3, "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 5 0 R >> >> /MediaBox [0 0 400 200] /Contents 4 0 R >>")
|
||||||
|
|
||||||
|
content := fmt.Sprintf("BT /F1 24 Tf 20 100 Td (%s) Tj ET", text)
|
||||||
|
stream := fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content)
|
||||||
|
writeObj(4, stream)
|
||||||
|
writeObj(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
|
||||||
|
|
||||||
|
xrefStart := buf.Len()
|
||||||
|
fmt.Fprintf(&buf, "xref\n0 %d\n", len(offsets)+1)
|
||||||
|
buf.WriteString("0000000000 65535 f \n")
|
||||||
|
for _, off := range offsets {
|
||||||
|
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF", len(offsets)+1, xrefStart)
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildImageOnlyPDF erzeugt ein minimales, ECHTES PDF, dessen einzige
|
||||||
|
// Seite ausschließlich aus einem eingebetteten Rasterbild besteht (JPEG
|
||||||
|
// via /DCTDecode — von PDF nativ unterstützt, kein zusätzlicher Codec
|
||||||
|
// nötig) — simuliert ein gescanntes PDF ohne Textebene
|
||||||
|
// (Akzeptanzkriterium 1).
|
||||||
|
func buildImageOnlyPDF(img image.Image) ([]byte, error) {
|
||||||
|
var jpegBuf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&jpegBuf, img, &jpeg.Options{Quality: 90}); err != nil {
|
||||||
|
return nil, fmt.Errorf("jpeg kodieren: %w", err)
|
||||||
|
}
|
||||||
|
bounds := img.Bounds()
|
||||||
|
w, h := bounds.Dx(), bounds.Dy()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
offsets := make([]int, 0, 6)
|
||||||
|
buf.WriteString("%PDF-1.4\n")
|
||||||
|
|
||||||
|
writeObj := func(n int, header string, body []byte) {
|
||||||
|
offsets = append(offsets, buf.Len())
|
||||||
|
fmt.Fprintf(&buf, "%d 0 obj\n%s\n", n, header)
|
||||||
|
if body != nil {
|
||||||
|
buf.Write(body)
|
||||||
|
buf.WriteString("\nendstream\n")
|
||||||
|
}
|
||||||
|
buf.WriteString("endobj\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
writeObj(1, "<< /Type /Catalog /Pages 2 0 R >>", nil)
|
||||||
|
writeObj(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", nil)
|
||||||
|
writeObj(3, fmt.Sprintf("<< /Type /Page /Parent 2 0 R /Resources << /XObject << /Im0 5 0 R >> >> /MediaBox [0 0 %d %d] /Contents 4 0 R >>", w, h), nil)
|
||||||
|
|
||||||
|
content := fmt.Sprintf("q %d 0 0 %d 0 0 cm /Im0 Do Q", w, h)
|
||||||
|
writeObj(4, fmt.Sprintf("<< /Length %d >>\nstream", len(content)), []byte(content))
|
||||||
|
|
||||||
|
imgHeader := fmt.Sprintf(
|
||||||
|
"<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length %d >>\nstream",
|
||||||
|
w, h, jpegBuf.Len(),
|
||||||
|
)
|
||||||
|
writeObj(5, imgHeader, jpegBuf.Bytes())
|
||||||
|
|
||||||
|
xrefStart := buf.Len()
|
||||||
|
fmt.Fprintf(&buf, "xref\n0 %d\n", len(offsets)+1)
|
||||||
|
buf.WriteString("0000000000 65535 f \n")
|
||||||
|
for _, off := range offsets {
|
||||||
|
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF", len(offsets)+1, xrefStart)
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS mail_saved_searches (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
tenant_slug TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
query_text TEXT NOT NULL DEFAULT '',
|
||||||
|
filters JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_slug, user_id, name)
|
||||||
|
)
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Package savedsearch implementiert SRC-08: benannte, wiederausführbare
|
||||||
|
// Suchanfragen inklusive aktiver Filter. Speichert ausschließlich die
|
||||||
|
// SUCHKRITERIEN (Suchtext + Filter), niemals Trefferlisten — Execute ruft
|
||||||
|
// bei jeder Ausführung real gegen mail/internal/search (SRC-01/SRC-03/
|
||||||
|
// SRC-05) auf, damit stets aktuelle Treffer geliefert werden, kein
|
||||||
|
// eingefrorener Snapshot (Akzeptanzkriterium 2). Kombiniert bewusst zwei
|
||||||
|
// bereits fertige, unveränderte Pakete (Postgres-Speicherung hier,
|
||||||
|
// Ausführung über search.Client) statt eines davon zu erweitern.
|
||||||
|
//
|
||||||
|
// "Benutzer" ist bis zu einer zentralen Session-/IAM-Anbindung (Core-
|
||||||
|
// Board-Scope, nicht Bestandteil dieser Kachel) ein vom Aufrufer
|
||||||
|
// mitgegebener opaker userID-String — dieselbe Konvention wie der
|
||||||
|
// Tenant-Kontext in web/mail-search (SRC-04).
|
||||||
|
package savedsearch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/0001_mail_saved_searches.sql
|
||||||
|
var schemaMigration string
|
||||||
|
|
||||||
|
// ErrNotFound wird geliefert, wenn keine gespeicherte Suche mit den
|
||||||
|
// angegebenen Bezugsdaten existiert.
|
||||||
|
var ErrNotFound = errors.New("savedsearch: gespeicherte suche nicht gefunden")
|
||||||
|
|
||||||
|
// SavedSearch ist eine benannte, wiederausführbare Suchanfrage.
|
||||||
|
type SavedSearch struct {
|
||||||
|
ID int64
|
||||||
|
TenantSlug string
|
||||||
|
UserID string
|
||||||
|
Name string
|
||||||
|
QueryText string
|
||||||
|
Filters []search.FacetFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store persistiert gespeicherte Suchen in Postgres.
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureSchema legt die Tabelle an, falls sie noch nicht existiert —
|
||||||
|
// gleiches Muster wie mail/internal/dedup/indexworker (kein zentraler
|
||||||
|
// Migrationsläufer für Mandanten-Datenbanken im Mail-Modul vorhanden).
|
||||||
|
func (s *Store) EnsureSchema(ctx context.Context) error {
|
||||||
|
if _, err := s.pool.Exec(ctx, schemaMigration); err != nil {
|
||||||
|
return fmt.Errorf("savedsearch: schema anlegen: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save legt eine gespeicherte Suche unter name an oder aktualisiert sie,
|
||||||
|
// falls unter demselben Namen für denselben Benutzer/Mandanten bereits
|
||||||
|
// eine existiert (Akzeptanzkriterium 1).
|
||||||
|
func (s *Store) Save(ctx context.Context, tenantSlug, userID, name, queryText string, filters []search.FacetFilter) (int64, error) {
|
||||||
|
filtersJSON, err := json.Marshal(filters)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("savedsearch: filter serialisieren: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int64
|
||||||
|
err = s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO mail_saved_searches (tenant_slug, user_id, name, query_text, filters)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (tenant_slug, user_id, name) DO UPDATE
|
||||||
|
SET query_text = EXCLUDED.query_text, filters = EXCLUDED.filters, updated_at = now()
|
||||||
|
RETURNING id
|
||||||
|
`, tenantSlug, userID, name, queryText, filtersJSON).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("savedsearch: speichern: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List liefert alle gespeicherten Suchen eines Benutzers innerhalb eines
|
||||||
|
// Mandanten (Akzeptanzkriterium 3: je Benutzer/Mandant getrennt sichtbar).
|
||||||
|
func (s *Store) List(ctx context.Context, tenantSlug, userID string) ([]SavedSearch, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id, tenant_slug, user_id, name, query_text, filters
|
||||||
|
FROM mail_saved_searches
|
||||||
|
WHERE tenant_slug = $1 AND user_id = $2
|
||||||
|
ORDER BY name
|
||||||
|
`, tenantSlug, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("savedsearch: liste lesen: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []SavedSearch
|
||||||
|
for rows.Next() {
|
||||||
|
item, err := scanSavedSearch(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results = append(results, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("savedsearch: liste iterieren: %w", err)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get liefert eine einzelne gespeicherte Suche, streng auf Mandant und
|
||||||
|
// Benutzer beschränkt.
|
||||||
|
func (s *Store) Get(ctx context.Context, tenantSlug, userID string, id int64) (SavedSearch, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, tenant_slug, user_id, name, query_text, filters
|
||||||
|
FROM mail_saved_searches
|
||||||
|
WHERE tenant_slug = $1 AND user_id = $2 AND id = $3
|
||||||
|
`, tenantSlug, userID, id)
|
||||||
|
item, err := scanSavedSearch(row)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return SavedSearch{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return SavedSearch{}, err
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete entfernt GENAU EINE gespeicherte Suche, streng auf Mandant und
|
||||||
|
// Benutzer beschränkt (Akzeptanzkriterium/Pflichtprüfung 3: Löschen
|
||||||
|
// entfernt nur diese eine gespeicherte Suche).
|
||||||
|
func (s *Store) Delete(ctx context.Context, tenantSlug, userID string, id int64) error {
|
||||||
|
tag, err := s.pool.Exec(ctx, `
|
||||||
|
DELETE FROM mail_saved_searches WHERE tenant_slug = $1 AND user_id = $2 AND id = $3
|
||||||
|
`, tenantSlug, userID, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("savedsearch: löschen: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type rowScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanSavedSearch(row rowScanner) (SavedSearch, error) {
|
||||||
|
var item SavedSearch
|
||||||
|
var filtersJSON []byte
|
||||||
|
if err := row.Scan(&item.ID, &item.TenantSlug, &item.UserID, &item.Name, &item.QueryText, &filtersJSON); err != nil {
|
||||||
|
return SavedSearch{}, fmt.Errorf("savedsearch: zeile lesen: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(filtersJSON, &item.Filters); err != nil {
|
||||||
|
return SavedSearch{}, fmt.Errorf("savedsearch: filter parsen: %w", err)
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute führt eine gespeicherte Suche LIVE gegen client aus
|
||||||
|
// (Akzeptanzkriterium 2: aktuelle Treffer, kein eingefrorener Snapshot —
|
||||||
|
// Execute speichert selbst nichts zwischen, jeder Aufruf fragt Manticore
|
||||||
|
// neu ab). Hinterlegte Filter werden über search.Client.SearchWithFilters
|
||||||
|
// tatsächlich auf die Treffermenge angewandt (UND-Verknüpfung), nicht nur
|
||||||
|
// auf ihr Vorhandensein geprüft.
|
||||||
|
func Execute(ctx context.Context, client *search.Client, saved SavedSearch) ([]search.Result, error) {
|
||||||
|
return client.SearchWithFilters(ctx, saved.TenantSlug, saved.QueryText, saved.Filters)
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// Integrationstest (SRC-08): echte Postgres- UND Manticore-Instanz,
|
||||||
|
// folgt derselben TEST_*-Env-Konvention wie mail/internal/search.
|
||||||
|
package savedsearch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupStore(t *testing.T) *Store {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { pool.Close() })
|
||||||
|
|
||||||
|
store := NewStore(pool)
|
||||||
|
if err := store.EnsureSchema(ctx); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(context.Background(), `DELETE FROM mail_saved_searches WHERE tenant_slug LIKE 'mandant-src08-%'`)
|
||||||
|
})
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupSearchClient(t *testing.T) *search.Client {
|
||||||
|
t.Helper()
|
||||||
|
baseURL := os.Getenv("TEST_MANTICORE_URL")
|
||||||
|
if baseURL == "" {
|
||||||
|
t.Skip("TEST_MANTICORE_URL nicht gesetzt, Integrationstest übersprungen")
|
||||||
|
}
|
||||||
|
client := search.NewClient(baseURL)
|
||||||
|
if err := client.EnsureSchema(context.Background()); err != nil {
|
||||||
|
t.Fatalf("search-schema: %v", err)
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecute_SavedSearchWithMultipleFiltersReproducesCorrectly ist die
|
||||||
|
// geforderte Pflichtprüfung 1: gespeicherte Suche mit mehreren Filtern
|
||||||
|
// wird korrekt reproduziert.
|
||||||
|
func TestExecute_SavedSearchWithMultipleFiltersReproducesCorrectly(t *testing.T) {
|
||||||
|
store := setupStore(t)
|
||||||
|
searchClient := setupSearchClient(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tenant := "mandant-src08-filter"
|
||||||
|
|
||||||
|
docs := []search.Document{
|
||||||
|
{MessageID: "msg-sv-treffer", Subject: "Quartalsbericht", Sender: "alice@example.com", Mailbox: "inbox"},
|
||||||
|
{MessageID: "msg-sv-falscher-sender", Subject: "Quartalsbericht", Sender: "bob@example.com", Mailbox: "inbox"},
|
||||||
|
{MessageID: "msg-sv-falsche-mailbox", Subject: "Quartalsbericht", Sender: "alice@example.com", Mailbox: "archiv"},
|
||||||
|
}
|
||||||
|
for _, d := range docs {
|
||||||
|
d.TenantSlug = tenant
|
||||||
|
d.ID = search.DocumentID(tenant, d.MessageID)
|
||||||
|
if err := searchClient.Index(ctx, d); err != nil {
|
||||||
|
t.Fatalf("index %s: %v", d.MessageID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filters := []search.FacetFilter{
|
||||||
|
{Field: search.FieldSender, Value: "alice@example.com"},
|
||||||
|
{Field: search.FieldMailbox, Value: "inbox"},
|
||||||
|
}
|
||||||
|
id, err := store.Save(ctx, tenant, "user-1", "Alice Inbox Quartalsbericht", "Quartalsbericht", filters)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("save: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, err := store.Get(ctx, tenant, "user-1", id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := Execute(ctx, searchClient, saved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execute: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 || results[0].MessageID != "msg-sv-treffer" {
|
||||||
|
t.Fatalf("erwartete genau msg-sv-treffer, habe: %+v", results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecute_ReturnsCurrentResultsNotFrozenSnapshot deckt
|
||||||
|
// Akzeptanzkriterium 2 ab: aktuelle Treffer, kein eingefrorener Snapshot.
|
||||||
|
func TestExecute_ReturnsCurrentResultsNotFrozenSnapshot(t *testing.T) {
|
||||||
|
store := setupStore(t)
|
||||||
|
searchClient := setupSearchClient(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tenant := "mandant-src08-live"
|
||||||
|
|
||||||
|
docID := search.DocumentID(tenant, "msg-live-1")
|
||||||
|
// Verteidigung gegen Testreste eines vorherigen Laufs (Manticore wird
|
||||||
|
// zwischen Testläufen nicht automatisch geleert, anders als Postgres
|
||||||
|
// über t.Cleanup) — Ausgangszustand muss real leer sein, damit der
|
||||||
|
// Test die Aussage "kein eingefrorener Snapshot" beweiskräftig prüft.
|
||||||
|
_ = searchClient.Delete(ctx, docID)
|
||||||
|
t.Cleanup(func() { _ = searchClient.Delete(context.Background(), docID) })
|
||||||
|
|
||||||
|
id, err := store.Save(ctx, tenant, "user-1", "Alles zu Zylotharion", "Zylotharion", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("save: %v", err)
|
||||||
|
}
|
||||||
|
saved, err := store.Get(ctx, tenant, "user-1", id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
before, err := Execute(ctx, searchClient, saved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execute (vorher): %v", err)
|
||||||
|
}
|
||||||
|
if len(before) != 0 {
|
||||||
|
t.Fatalf("erwartete 0 treffer vor indexierung, habe %d", len(before))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := searchClient.Index(ctx, search.Document{
|
||||||
|
ID: docID,
|
||||||
|
TenantSlug: tenant,
|
||||||
|
MessageID: "msg-live-1",
|
||||||
|
Subject: "Neuigkeiten zu Zylotharion",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("index: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after, err := Execute(ctx, searchClient, saved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execute (nachher): %v", err)
|
||||||
|
}
|
||||||
|
if len(after) != 1 {
|
||||||
|
t.Fatalf("erwartete 1 aktuellen treffer nach neuer indexierung (kein eingefrorener snapshot), habe %d", len(after))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestList_UserSeesNoOtherTenantsSavedSearches ist die geforderte
|
||||||
|
// Pflichtprüfung 2: Benutzer sieht keine gespeicherten Suchen anderer
|
||||||
|
// Mandanten.
|
||||||
|
func TestList_UserSeesNoOtherTenantsSavedSearches(t *testing.T) {
|
||||||
|
store := setupStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tenantA := "mandant-src08-iso-a"
|
||||||
|
tenantB := "mandant-src08-iso-b"
|
||||||
|
|
||||||
|
if _, err := store.Save(ctx, tenantA, "user-1", "Suche A", "irgendwas", nil); err != nil {
|
||||||
|
t.Fatalf("save mandant a: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.Save(ctx, tenantB, "user-1", "Suche B", "irgendwas", nil); err != nil {
|
||||||
|
t.Fatalf("save mandant b: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listB, err := store.List(ctx, tenantB, "user-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list mandant b: %v", err)
|
||||||
|
}
|
||||||
|
if len(listB) != 1 || listB[0].Name != "Suche B" {
|
||||||
|
t.Fatalf("mandant b sieht fremde/fehlende suchen: %+v", listB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDelete_RemovesOnlyThatSavedSearch ist die geforderte Pflichtprüfung
|
||||||
|
// 3: Löschen einer gespeicherten Suche entfernt nur diese.
|
||||||
|
func TestDelete_RemovesOnlyThatSavedSearch(t *testing.T) {
|
||||||
|
store := setupStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tenant := "mandant-src08-delete"
|
||||||
|
|
||||||
|
idA, err := store.Save(ctx, tenant, "user-1", "Suche A", "a", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("save a: %v", err)
|
||||||
|
}
|
||||||
|
idB, err := store.Save(ctx, tenant, "user-1", "Suche B", "b", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("save b: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Delete(ctx, tenant, "user-1", idA); err != nil {
|
||||||
|
t.Fatalf("delete a: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.Get(ctx, tenant, "user-1", idA); err != ErrNotFound {
|
||||||
|
t.Fatalf("erwartete ErrNotFound für gelöschte suche a, habe: %v", err)
|
||||||
|
}
|
||||||
|
stillThere, err := store.Get(ctx, tenant, "user-1", idB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("suche b sollte weiterhin existieren: %v", err)
|
||||||
|
}
|
||||||
|
if stillThere.Name != "Suche B" {
|
||||||
|
t.Fatalf("unerwarteter inhalt für suche b: %+v", stillThere)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,12 +79,11 @@ func isFacetField(field string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Facets berechnet für jede Filterdimension (Akzeptanzkriterium 1) die
|
// buildFilteredMust baut die gemeinsame bool.must-Liste für Facets und
|
||||||
// Trefferzahl je Wert, mandantengetrennt (Akzeptanzkriterium 3) und unter
|
// SearchWithFilters: Tenant-Filter zwingend, optionaler Suchtext, dann je
|
||||||
// Berücksichtigung bereits gewählter Filter (Akzeptanzkriterium 2: mehrere
|
// Filter eine zusätzliche equals-Klausel (UND-Verknüpfung) — einzige
|
||||||
// Filter kombinieren sich als UND-Verknüpfung in derselben bool.must-Liste
|
// Stelle, an der Filter-Feldnamen gegen FacetFields geprüft werden.
|
||||||
// wie der Tenant-Filter).
|
func buildFilteredMust(tenantSlug, queryText string, filters []FacetFilter) ([]map[string]any, error) {
|
||||||
func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filters []FacetFilter) (FacetResult, error) {
|
|
||||||
must := []map[string]any{
|
must := []map[string]any{
|
||||||
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
|
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
|
||||||
}
|
}
|
||||||
@@ -93,10 +92,71 @@ func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filte
|
|||||||
}
|
}
|
||||||
for _, f := range filters {
|
for _, f := range filters {
|
||||||
if !isFacetField(f.Field) {
|
if !isFacetField(f.Field) {
|
||||||
return FacetResult{}, fmt.Errorf("search: unbekanntes facettenfeld %q", f.Field)
|
return nil, fmt.Errorf("search: unbekanntes facettenfeld %q", f.Field)
|
||||||
}
|
}
|
||||||
must = append(must, map[string]any{"equals": map[string]any{f.Field: f.Value}})
|
must = append(must, map[string]any{"equals": map[string]any{f.Field: f.Value}})
|
||||||
}
|
}
|
||||||
|
return must, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchWithFilters ist Search, zusätzlich beschränkt auf Dokumente, die
|
||||||
|
// ALLE angegebenen Filter erfüllen (UND-Verknüpfung, dieselbe Semantik wie
|
||||||
|
// Facets) — Grundlage für SRC-08s Wiederausführung gespeicherter Suchen
|
||||||
|
// mit Filtern.
|
||||||
|
func (c *Client) SearchWithFilters(ctx context.Context, tenantSlug, queryText string, filters []FacetFilter) ([]Result, error) {
|
||||||
|
if len(filters) == 0 {
|
||||||
|
return c.Search(ctx, tenantSlug, queryText)
|
||||||
|
}
|
||||||
|
must, err := buildFilteredMust(tenantSlug, queryText, filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"index": IndexName,
|
||||||
|
"query": map[string]any{"bool": map[string]any{"must": must}},
|
||||||
|
"options": map[string]any{
|
||||||
|
"field_weights": fieldWeights,
|
||||||
|
},
|
||||||
|
"limit": searchResultLimit,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search: gefilterte suchanfrage 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Facets berechnet für jede Filterdimension (Akzeptanzkriterium 1) die
|
||||||
|
// Trefferzahl je Wert, mandantengetrennt (Akzeptanzkriterium 3) und unter
|
||||||
|
// Berücksichtigung bereits gewählter Filter (Akzeptanzkriterium 2: mehrere
|
||||||
|
// Filter kombinieren sich als UND-Verknüpfung in derselben bool.must-Liste
|
||||||
|
// wie der Tenant-Filter).
|
||||||
|
func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filters []FacetFilter) (FacetResult, error) {
|
||||||
|
must, err := buildFilteredMust(tenantSlug, queryText, filters)
|
||||||
|
if err != nil {
|
||||||
|
return FacetResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
aggs := map[string]any{}
|
aggs := map[string]any{}
|
||||||
for _, field := range FacetFields {
|
for _, field := range FacetFields {
|
||||||
|
|||||||
Reference in New Issue
Block a user