Serves the static OpenAPI YAML via go:embed. Completes the last open acceptance criterion for PROJ-13. PROJ-44 marked Deployed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
10 KiB
PROJ-44: OCR-GUI-Integration (Status, Download, Such-Highlight)
Status: Deployed
Created: 2026-05-08 Last Updated: 2026-05-11
Dependencies
- Requires: PROJ-35 (OCR & Anhang-Volltext-Indexierung) — liefert
ocr_status+attachment_text - Requires: PROJ-6 (Volltext-Suche & Filterung) — Suche-Endpoint wird erweitert
- Requires: PROJ-7 (E-Mail-Ansicht) — Mail-Detail-Seite wird erweitert
Motivation
PROJ-35 hat den OCR-Pipeline gebaut, aber die Resultate sind bisher nur indirekt sichtbar (über Volltext-Suche, die plötzlich mehr findet). Nutzer und Auditoren sollen:
- erkennen können, dass OCR auf einer Mail gelaufen ist und den Status sehen
- den extrahierten Text als Reintext-Datei (
.txt) herunterladen können (für GoBD-konforme Archivierung des erkannten Inhalts neben dem Original-PDF) - bei Suchergebnissen sehen, dass ein Treffer aus dem OCR-Text kam — und welche Stelle es war (Snippet)
User Stories
- Als Nutzer möchte ich auf der Mail-Detail-Seite sehen, ob die Anhänge bereits OCR-verarbeitet wurden, damit ich weiß, ob die Suche im Volltext greift.
- Als Auditor möchte ich den extrahierten Reintext einer Mail als
.txt-Datei herunterladen, damit ich ihn unabhängig vom Original-PDF archivieren oder weiterverarbeiten kann. - Als Nutzer möchte ich in der Trefferliste der Volltext-Suche erkennen, ob ein Treffer aus dem Mail-Body, dem Subject oder dem OCR-Anhang-Text kam — und idealerweise ein Textauszug der Fundstelle.
Acceptance Criteria
MailDetail-Response (/api/mails/{id}) enthält neue Felderocr_statusundocr_chars(Anzahl extrahierter Zeichen, 0 wenn nichts gefunden)- Mail-Detail-Seite zeigt OCR-Badge im Header neben den anderen Status-Badges (verify_ok, threading)
done→ grünes Badge „OCR ✓"failed→ rotes Badge „OCR ✗"skipped→ graues Badge „OCR n/a"pending→ blaues Badge „OCR …"disabled→ kein Badge
- Neuer Endpoint
GET /api/mails/{id}/ocr-textliefert den extrahierten Text alstext/plain; charset=utf-8mitContent-Disposition: attachment; filename="<message-id>.ocr.txt" - Mail-Detail-Seite hat einen Button „OCR-Text herunterladen", der nur erscheint wenn
ocr_status='done'undocr_chars > 0 - Such-Endpoint
/api/searchliefert pro Treffer ein neues Feldsnippet(Markdown-style mit<b>-Tags um Match-Wörter) undmatch_field(z.B.body,subject,attachment_text) - Such-UI zeigt unter Subject/From eine zweite Zeile mit dem Snippet und einem kleinen Badge, das die Quelle benennt (z.B. „📄 aus PDF-Anhang")
- Audit-Log: jeder OCR-Text-Download wird als
mail:ocr_downloaderfasst - Externe v1-API: das gleiche
ocr_status-Feld liegt im JSON von/api/v1/mails/{id}
Edge Cases
- Mail ohne OCR-Daten (
skipped, keinattachment_text) → Download-Endpoint liefert 404 mit klarer Fehlermeldung - Mail mit
ocr_status='pending'→ 202 Accepted + Hinweis „OCR läuft noch, bitte später erneut" - Snippet-Generierung schlägt fehl (Manticore-Highlight-Fehler) → Treffer wird ohne Snippet zurückgegeben, kein Hard-Error
- Tenant-Isolation: OCR-Text-Endpoint prüft Mail-Zugriff genau wie
/raw(PROJ-7) - Sehr lange OCR-Texte (>1 MB) → Endpoint streamt direkt aus Manticore, kein Buffer im Memory
Technical Requirements
Backend
Erweiterung internal/api/mails.go:
type MailDetail struct {
// ... bestehende Felder
OCRStatus string `json:"ocr_status"` // pending|done|failed|skipped|disabled
OCRChars int `json:"ocr_chars"` // 0 wenn kein Text
}
Neuer Helper auf *storage.Store:
func (s *Store) GetOCRStatus(ctx context.Context, id string) (status string, chars int, err error)
Liest ocr_status aus PostgreSQL und holt die attachment_text-Länge aus Manticore (oder cached die Länge in einer neuen Spalte ocr_chars — siehe Tech Design).
Neuer Handler handleGetOCRText in internal/api/mails.go:
GET /api/mails/{id}/ocr-text
→ 200 OK + text/plain wenn vorhanden
→ 202 Accepted wenn ocr_status='pending'
→ 404 Not Found wenn skipped/failed/no text
→ 401/403 wie bei /raw
Erweiterung internal/index/index.go::Hit:
type Hit struct {
ID string `json:"id"`
Score float64 `json:"score"`
Snippet string `json:"snippet,omitempty"` // Manticore SNIPPET() output
MatchField string `json:"match_field,omitempty"` // body|subject|attachment_text|...
}
Erweiterung internal/index/manticore.go::Search:
- Pro Hit ein zweiter Query-Pass mit
CALL SNIPPET(text, query, table_name)— oder direkt im SELECT mitHIGHLIGHT()(Manticore-Funktion) MatchFieldwird heuristisch ermittelt: pro Feld separate Queries und sehen, welches Feld als erstes matcht — alternativ überWEIGHT()-Inspection
Frontend
src/components/ocr-badge.tsx (neu):
- Kleine wiederverwendbare Badge-Komponente, die anhand des
ocr_status-Werts die richtige Farbe + den richtigen Text rendert
src/app/mail/[id]/page.tsx:
- Badge in den Header (neben Verifikation)
- Download-Button „OCR-Text" wenn
ocr_chars > 0
src/app/search/page.tsx:
- Such-Treffer-Komponente um Snippet + Quellen-Badge erweitern
- Snippet wird über
dangerouslySetInnerHTMLgerendert (nur<b>-Tags, vorher per HTML-Sanitizer wrappen — Manticore liefert nur diese eine Markup-Form)
DB-Schema (optional)
Optional kann attachment_text-Länge als emails.ocr_chars BIGINT cached werden, um den Manticore SELECT length(attachment_text)-Roundtrip pro Mail-Detail zu sparen. Tradeoff: zusätzliche Spalte vs. eine zusätzliche Manticore-Query pro Detail-Aufruf. Entscheidung: Cache in PostgreSQL — wird beim OCR-Worker mit dem Status zusammen geschrieben.
ALTER TABLE emails ADD COLUMN IF NOT EXISTS ocr_chars BIGINT DEFAULT 0;
Tech Design
Datenfluss für OCR-Text-Download
Frontend (Mail-Detail)
├ getMail(id) → MailDetail mit ocr_status + ocr_chars
└ Wenn ocr_chars > 0: Button rendern
└ Click → GET /api/mails/{id}/ocr-text
├ Backend: ACL-Check (requireMailAccess)
├ Backend: hole attachment_text aus Manticore SELECT
├ Backend: Audit-Log "mail:ocr_download"
└ Response: text/plain + Content-Disposition
Datenfluss für Such-Snippet
Frontend (Suche)
└ POST /api/search { query: "rechnungsnummer" }
└ Backend Search:
├ Manticore SELECT mit MATCH() + WEIGHT() pro Treffer
├ Pro Treffer: CALL SNIPPET(field_value, query) für Snippet
├ Heuristik für match_field: prüfe welches Text-Feld den Query enthält
└ Response: Hits mit snippet + match_field
Audit-Log-Erweiterung
Neue Aktion: mail:ocr_download mit mail_id als Subject. Bestehende Audit-Pipeline (s.audlog.Log(...)) wird weiterverwendet.
Nicht in Scope
- OCR-Text-Bearbeitung oder -Korrektur durch Nutzer (nur Lesen + Download)
- Inline-Anzeige des OCR-Texts auf der Mail-Detail-Seite (nur Download — der Reintext wäre oft mehrere Seiten lang)
- Neuverarbeitung über die GUI (das geht nur per CLI
archivmail ocr-reprocess) - Sprachen jenseits dessen, was PROJ-35 ohnehin unterstützt (deu+eng)
Implementation Notes
Implementiert (alle Akzeptanzkriterien erfüllt):
- DB-Schema:
ALTER TABLE emails ADD COLUMN IF NOT EXISTS ocr_chars BIGINT DEFAULT 0als idempotente Migration ininternal/storage/storage.go. Storage-HelperSetOCRResult/GetOCRMetaininternal/storage/ocr.go. - OCR-Worker schreibt
ocr_charszusammen mitocr_status. Nutzt jetzt die kanonischeemails.tenant_idaus PostgreSQL (nicht mehrJob.TenantID), damit CLI-Reprocess (tenantID == nil) korrekt im Tenant-Index landet. - API-Endpoint
GET /api/mails/{id}/ocr-textininternal/api/ocr_handlers.gomit ACL wie/raw, Status-Mapping 200/202/404 und Audit-Eventmail:ocr_download. Endpoint löst den Manticore-Reader ebenfalls über die DB-Tenant-ID auf — gleicher Strukturfix wie im Worker. MailDetail(+ externes v1-API) liefertocr_statusundocr_chars.- Such-Hits liefern
snippet+match_field. Heuristik: probiertsubject > body > attachment_text > attachment_names > from_addr > to_addrdurch MATCH()-Tests; Snippet viaSELECT SNIPPET(text, query) FROM table(CALL SNIPPETS scheitert am Go-MySQL-Driver-Packet, Manticore 25 akzeptiert keine Options-Args). - Frontend:
OcrBadge-Komponente, Download-Button auf Mail-Detail-Seite, Snippet + Quellen-Badge in Trefferliste.sanitizeSnippetmit Whitelist nur für<b>-Tags. - IMAP- und POP3-Live-Import triggern jetzt den OCR-Worker via Function-Pointer-Pattern (
SetOCRSubmit) — vermeidet Zirkularimport. Vorher blieben neu importierte Mails ewig inocr_status='pending'.
Bugfixes während Implementierung:
- Live-Import-Bug: IMAP/POP3 hatten keinen
ocrWorker.Submit-Call → neue Mails standen 4 Tage auf 132 inpending(54 Mails bestätigt). Fix:SetOCRSubmit-Hook incmd/archivmail/main.goverdrahtet. - Tenant-Isolation-Strukturbug: Worker und Endpoint lasen unterschiedliche Tenant-Quellen (Submitter-Kontext vs. Session) → Schreib- und Lese-Pfad landeten in verschiedenen Manticore-Indizes; Tenant-User sahen eigenen OCR-Text nicht. Fix: Beide Pfade lesen
emails.tenant_idals Single Source of Truth. - Manticore 25 / Go-MySQL-Treiber: CALL SNIPPETS Options-Args weggekürzt, dann komplett auf
SELECT SNIPPET(...)umgestellt — CALL-Antworten lassen sich vom Treiber nicht parsen.
QA Test Results
Verifiziert auf 192.168.1.132 mit Commit 16013e8:
| Szenario | Erwartung | Ergebnis |
|---|---|---|
| Tenant-2-User, eigene Mail (Anhang mit OCR-Text) | 200 + Text | OK |
| Tenant-2-User, Mail aus Tenant-1 | 403/404 | 403 access denied — Isolation greift |
| Admin, Tenant-Mail | 403 (SEC-29: Admin sieht keine Mail-Inhalte) | 403 — korrekt |
| Auditor, globale Mail (tenant_id IS NULL) | 200 + Text | OK |
| Auditor, Tenant-Mail | 403 | OK |
DB-Migration ocr_chars |
Spalte vorhanden | Vorhanden + Index idx_emails_ocr_status |
| Manticore-Routing | attachment_text liegt in emails_tenant_N, nicht global | Bestätigt |
Deployment
- 2026-05-11 — 192.168.1.132 (Test): Commit
16013e8aktiv, alle Dienste running, QA grün. - 2026-05-29 — 192.168.1.131 (Prod): Commit
fa9f777aktiv (inkl. Security-Fixes), api 1.8 / storage 1.9, alle Dienste running.