From 6e6cb0dc8d0fbebc27dea1e430543c010512c52b Mon Sep 17 00:00:00 2001 From: Christoph Kroczek Date: Mon, 13 Jul 2026 15:20:48 +0200 Subject: [PATCH] M0: arc42-Konzeptdokument, Gherkin-Anforderungen und OpenAPI-Vertrag - doc/architecture.md: arc42-Detaildokument mit Mermaid-Diagrammen (Bausteinsicht, Klassendiagramm, Statusmodell, 5 Sequenzdiagramme), Speicherkonzept, DEC-01..19-Entscheidungen mit Trade-offs - doc/requirements/: 7 Features als Gherkin-Szenarien (TDD-Grundlage) mit Traceability-Tabellen Szenario <-> Stdlib-Testname - doc/openapi.yaml: API-Vertrag fuer alle 9 Endpunkte (Quelle der Wahrheit) Co-Authored-By: Claude Fable 5 --- doc/architecture.md | 594 +++++++++++++++++++++ doc/openapi.yaml | 288 ++++++++++ doc/requirements/01-import-api.md | 56 ++ doc/requirements/02-import-ordner.md | 54 ++ doc/requirements/03-textextraktion-ocr.md | 58 ++ doc/requirements/04-llm-anreicherung.md | 47 ++ doc/requirements/05-volltextsuche.md | 58 ++ doc/requirements/06-ablage.md | 48 ++ doc/requirements/07-nachvollziehbarkeit.md | 61 +++ doc/requirements/README.md | 27 + 10 files changed, 1291 insertions(+) create mode 100644 doc/architecture.md create mode 100644 doc/openapi.yaml create mode 100644 doc/requirements/01-import-api.md create mode 100644 doc/requirements/02-import-ordner.md create mode 100644 doc/requirements/03-textextraktion-ocr.md create mode 100644 doc/requirements/04-llm-anreicherung.md create mode 100644 doc/requirements/05-volltextsuche.md create mode 100644 doc/requirements/06-ablage.md create mode 100644 doc/requirements/07-nachvollziehbarkeit.md create mode 100644 doc/requirements/README.md diff --git a/doc/architecture.md b/doc/architecture.md new file mode 100644 index 0000000..de77452 --- /dev/null +++ b/doc/architecture.md @@ -0,0 +1,594 @@ +# go-documenter — Architekturdokumentation (arc42) + +> Detaildokument zur Architektur. Das [README](../README.md) bleibt der fachliche Überblick; +> dieses Dokument konkretisiert Bausteine, Abläufe, Technologien und Entscheidungen (DEC-*). +> Anforderungen als testbare Gherkin-Szenarien: [doc/requirements/](requirements/). +> API-Vertrag: [doc/openapi.yaml](openapi.yaml). + +## 1. Einführung und Ziele + +Schlankes, serverseitiges Dokumentenmanagement-System (Single-User) in Go: + +- Import von **PDF- und Bilddateien** (JPG, PNG, HEIC) über API oder überwachten Ordner +- Verarbeitung: Textextraktion, OCR, optionale LLM-Anreicherung, Volltextindexierung +- Finale Ablage in Jahresordnern; **Originaldateien werden niemals inhaltlich verändert** +- Nachvollziehbarkeit aller Zustandsänderungen durch Event Sourcing +- API-first: kein Frontend in der ersten Ausbaustufe + +Qualitätsziele (Rangfolge): Einfachheit, Testbarkeit, Nachvollziehbarkeit, Zuverlässigkeit, Erweiterbarkeit. + +## 2. Randbedingungen + +| Randbedingung | Ausprägung | +|---|---| +| Sprache | Go (~1.26) | +| Betrieb | Eigener VServer (Strato, 8 GB RAM) hinter Caddy-Reverse-Proxy, Docker Compose | +| CI/CD | Gitea + Actions (git2.adelante-it.de, `go-ci`-Runner-Image, gespiegelte Actions) | +| Vorgehen | **Dokumentation vor Implementierung** (dieses Dokument ist das Gate), TDD über Gherkin-Szenarien | +| Dokumentation | arc42, deutsch; Diagramme in Mermaid (DEC-DOC-01) | +| Referenzprojekte | pamietnik (Go-Konventionen), infranas (Betriebsumgebung) | + +## 3. Kontextabgrenzung + +```mermaid +flowchart LR + client["API-Client
(curl, Skripte, späteres Frontend)"] + user["Nutzer
(legt Dateien ab)"] + llm["LLM-Dienst
OpenAI-kompatibel
(LocalAI via VPN oder Cloud)"] + + subgraph host["Server (Docker)"] + sys["go-documenter"] + import["import/ (überwacht)"] + archive["archive/{Jahr}/"] + data["data/ (SQLite, Staging, Logs)"] + end + + client -- "HTTPS + Bearer-Token" --> sys + user -- "Datei speichern" --> import + sys -- "liest / verschiebt" --> import + sys -- "legt ab (unverändert)" --> archive + sys -- "Events, Projektion, FTS-Index" --> data + sys -- "Chat-Completions (optional)" --> llm +``` + +Externe Schnittstellen: + +- **API-Clients**: REST/JSON, Bearer-Token (`API_TOKEN`), Vertrag in `doc/openapi.yaml` +- **Dateisystem**: `import/` (Eingang), `data/staging/` (Verarbeitung), `archive/` (Ablage) +- **LLM**: OpenAI-kompatible Chat-Completions-API; erreichbar oder nicht — das System funktioniert ohne +- **OCR**: keine externe Schnittstelle — `ocrmypdf`/`tesseract` sind im Container gebündelt + +## 4. Lösungsstrategie + +| Bereich | Wahl | Begründung | +|---|---|---| +| HTTP | `github.com/go-chi/chi/v5` | minimal, stdlib-kompatibel, pamietnik-Standard | +| Persistenz | `modernc.org/sqlite` (pure Go) | eine Datei, WAL, kein CGo, kein DB-Server | +| Volltextsuche | SQLite FTS5 (external content, BM25) | transaktional konsistent mit den Daten, kein Zweitsystem | +| OCR | `ocrmypdf --sidecar --output-type none` (PDF), `tesseract` (Bilder) | nur Text wird gewonnen, Original bleibt byte-identisch | +| PDF-Text | `pdftotext` (poppler) via `exec.Command` | beste Extraktionsqualität, Interface-gekapselt | +| LLM | eigener Minimal-Client (Stdlib-HTTP) | LocalAI ↔ Cloud nur per Config umschaltbar, kein SDK | +| Event Sourcing | handgerollt, nur Dokument-Aggregat | erfüllt Nachvollziehbarkeit ohne Framework-Komplexität | +| Watcher | `fsnotify` + periodischer Rescan | Rescan fängt verpasste Events und Startbestand | +| Config | YAML + Env-Override (Secrets nur Env) | pamietnik-Muster | +| Logging | `log/slog` JSON + lumberjack-Rotation | strukturiert, pamietnik-Muster | +| Tests | nur Stdlib `testing`, handgeschriebene Fakes | keine Mock-Frameworks, Interfaces als Test-Seams | + +Vollständige Dependency-Liste: `chi/v5`, `modernc.org/sqlite`, `fsnotify`, `google/uuid`, `yaml.v3`, `lumberjack.v2`. Sonst nichts. + +## 5. Bausteinsicht + +### 5.1 Überblick + +```mermaid +flowchart TB + subgraph api_layer["API-Schicht"] + api["internal/api
Router, Handler, Auth-Middleware"] + end + + subgraph app["Anwendungslogik"] + ingest["internal/ingest
Aufnahme: UUID, Hash, Dedup, Staging"] + pipeline["internal/pipeline
Runner, Queue, Stages"] + importer["internal/importer
fsnotify + Rescan + Stabilität"] + end + + subgraph domain_layer["Domäne"] + domain["internal/domain
Document, Status, MediaType"] + events["internal/events
Event-Store, Replay, Projektion"] + end + + subgraph infra["Infrastruktur"] + db["internal/db
SQLite, Schema, Stores"] + search["internal/search
FTS5-Indexer/Searcher"] + storage["internal/storage
Datei-Moves, Jahresordner, Hash"] + pdf["internal/pdf
pdftotext"] + ocr["internal/ocr
ocrmypdf / tesseract"] + llm["internal/llm
OpenAI-kompatibler Client"] + config["internal/config"] + end + + api --> ingest + api --> db + api --> search + api --> events + importer --> ingest + ingest --> events + ingest --> storage + ingest --> pipeline + pipeline --> events + pipeline --> pdf + pipeline --> ocr + pipeline --> llm + pipeline --> search + pipeline --> storage + events --> db + search --> db +``` + +Abhängigkeitsregel: `domain` und `events` importieren keine Infrastruktur. Stages und Handler +hängen nur von schmalen Interfaces ab; die konkreten Implementierungen (exec, SQLite, HTTP) +werden in `cmd/server/main.go` verdrahtet. + +### 5.2 Pakete + +| Paket | Verantwortung | +|---|---| +| `cmd/server` | Wiring: Config → Logger → DB → Stores → Worker + Importer → HTTP-Server; graceful shutdown | +| `internal/domain` | Reine Typen: `Document`, Status-Konstanten, `MediaType` — keine Abhängigkeiten | +| `internal/events` | Event-Envelope + Payload-Structs, `Store`-Interface, SQLite-Store (Append + synchrone Projektion in einer Transaktion), `Replay` | +| `internal/pipeline` | `Stage`-Interface, kanonische Reihenfolge, Worker-Loop, DB-backed Work-Queue mit Lease | +| `internal/ingest` | Gemeinsame Aufnahme für API und Ordner: UUID, SHA-256, Dedup, Staging, `DocumentReceived`, Enqueue | +| `internal/pdf` | `TextExtractor`-Interface + pdftotext-Implementierung, `needs_ocr`-Heuristik | +| `internal/ocr` | `Processor`-Interface; ocrmypdf-Sidecar (PDF), tesseract (Bilder), HEIC→temp-PNG | +| `internal/llm` | `Client`-Interface, OpenAI-kompatible Implementierung, `Enricher` (Prompt + JSON-Parsing) | +| `internal/search` | `Indexer`/`Searcher`-Interfaces, FTS5-Implementierung inkl. Rebuild | +| `internal/importer` | Ordnerüberwachung: fsnotify, Stabilitäts-Detektor, periodischer Rescan | +| `internal/storage` | Datei-Lebenszyklus: Staging-Pfade, Jahresordner, Umbenennung, Hash, atomare Moves — **nie Inhaltsänderung** | +| `internal/api` | `NewRouter(RouterDeps)`, Handler je Ressource, Bearer-Token-Middleware, `writeError`/`writeJSON` | +| `internal/db` | `Open` (WAL, busy_timeout, foreign_keys), idempotentes Schema (go:embed), Store-Interfaces + Sentinel-Errors | +| `internal/config` | YAML + Env-Override; Secrets ausschließlich per Env | + +### 5.3 Zentrale Typen (Klassendiagramm) + +```mermaid +classDiagram + class Document { + +string ID «UUIDv4» + +Status Status + +string OriginalName + +MediaType MediaType + +string Origin «api | import_folder» + +string ContentHash «SHA-256» + +int64 SizeBytes + +int PageCount + +string StoragePath + +string ArchivePath + +string Text + +bool NeedsOCR + +string Title + +string Summary + +List~string~ Tags + +string DocDate + +string Language + +bool Enriched + +string FailedStage + +string LastError + +int Attempts + +int Version «Seq des letzten Events» + } + + class Event { + +string AggregateID + +int Seq + +string Type + +json.RawMessage Payload + +time.Time CreatedAt + } + + class Store { + <> + +Append(ctx, aggregateID, expectedVersion, payloads) error + +Load(ctx, aggregateID) List~Event~ + } + + class Stage { + <> + +Name() string + +Skip(doc Document) bool + +Run(ctx, doc Document) List~Payload~ + } + + class Queue { + <> + +Enqueue(ctx, documentID, stage) error + +Claim(ctx, workerID) *Work + +Complete(ctx, documentID) error + +Defer(ctx, documentID, notBefore) error + } + + class TextExtractor { + <> + +Extract(ctx, path) ExtractResult + } + class OCRProcessor { + <> + +Recognize(ctx, path, mediaType) OCRResult + } + class Enricher { + <> + +Enrich(ctx, text) Enrichment + } + class Indexer { + <> + +Index(ctx, doc Document) error + +Rebuild(ctx) error + } + class Filer { + <> + +File(ctx, doc Document) string «archive_path» + } + + Store ..> Event + Store ..> Document : Replay(events) → Document + Stage ..> Document + Stage <|.. ExtractStage + Stage <|.. OCRStage + Stage <|.. EnrichStage + Stage <|.. IndexStage + Stage <|.. FileStage + ExtractStage --> TextExtractor + OCRStage --> OCRProcessor + EnrichStage --> Enricher + IndexStage --> Indexer + FileStage --> Filer +``` + +### 5.4 Event-Modell + +| Event | Payload (snake_case) | Wirkung im Replay | +|---|---|---| +| `DocumentReceived` | original_name, media_type, origin, content_hash, size_bytes, staging_path, imported_at | Aggregat entsteht, Status `received` | +| `ProcessingStarted` | attempt | Status `processing` | +| `TextExtracted` | text, page_count, char_count, method, needs_ocr | Text/Seiten gesetzt | +| `OCRCompleted` | text, engine, languages, duration_ms | Text gesetzt, Status `ocr_done` | +| `DocumentEnriched` | title, summary, tags[], doc_date, language, model, prompt_version | Metadaten gesetzt, Status `enriched` | +| `EnrichmentSkipped` | reason, attempts | enriched=false, Pipeline läuft weiter | +| `DocumentIndexed` | char_count, indexed_at | Status `indexed` | +| `DocumentFiled` | archive_path, filed_at, timestamp_applied | Status `filed` (Endzustand) | +| `ProcessingFailed` | stage, attempt, error, retryable | Status `failed`, FailedStage/LastError gesetzt | +| `RetryRequested` | requested_by, from_stage | Status `processing`, Wiederaufnahme | + +`Replay(events) → Document` ist eine pure Fold-Funktion — der Zustand ist vollständig aus +Events rekonstruierbar. Der **Volltext liegt im Event-Payload** (DEC-ES-02), damit auch die +Projektion und der Suchindex jederzeit aus dem Event-Store neu aufgebaut werden können. + +### 5.5 Statusmodell + +```mermaid +stateDiagram-v2 + [*] --> received : DocumentReceived + received --> processing : ProcessingStarted + processing --> ocr_done : OCRCompleted + processing --> enriched : DocumentEnriched + ocr_done --> enriched : DocumentEnriched + processing --> indexed : DocumentIndexed (Enrich übersprungen) + ocr_done --> indexed : DocumentIndexed (Enrich übersprungen) + enriched --> indexed : DocumentIndexed + indexed --> filed : DocumentFiled + processing --> failed : ProcessingFailed + ocr_done --> failed : ProcessingFailed + enriched --> failed : ProcessingFailed + indexed --> failed : ProcessingFailed + failed --> processing : RetryRequested + filed --> [*] +``` + +## 6. Laufzeitsicht + +### 6.1 Import über überwachten Ordner + +```mermaid +sequenceDiagram + actor N as Nutzer + participant W as importer (Watcher) + participant I as ingest + participant S as storage + participant E as events (Store) + participant Q as work_queue + + N->>W: Datei in import/ speichern + W->>W: fsnotify-Event → Datei "pending" + loop bis Größe+mtime stable_seconds unverändert + W->>W: Stabilität prüfen + end + W->>I: Ingest(pfad, origin=import_folder) + I->>S: SHA-256 berechnen (streaming) + I->>E: Dedup-Check (content_hash) + alt Duplikat + I->>S: Datei nach import/rejected/ verschieben + I-->>W: Warnung geloggt, kein Aggregat + else neu + I->>I: UUID vergeben + I->>S: Move nach data/staging/{uuid}.{ext} + I->>E: Append(DocumentReceived) + Note over E: Event + documents-Projektion
in EINER Transaktion + I->>Q: Enqueue(uuid, next_stage=extract) + end +``` + +### 6.2 Verarbeitung durch die Pipeline (Worker) + +```mermaid +sequenceDiagram + participant Q as work_queue + participant R as pipeline (Runner) + participant E as events (Store) + participant X as ExtractStage (pdftotext) + participant O as OCRStage (ocrmypdf/tesseract) + participant L as EnrichStage (LLM) + participant F as IndexStage / FileStage + + loop Poll alle 2 s + R->>Q: Claim (Lease 15 min) + end + Q-->>R: document_id, next_stage + R->>E: Load + Replay → Document + alt Stage = extract (nur lesend!) + R->>X: Run(doc) + X-->>R: TextExtracted{needs_ocr} + else Stage = ocr (Original bleibt byte-identisch) + R->>O: Run(doc) + Note over O: PDF: ocrmypdf --sidecar --output-type none
Bild: tesseract direkt (HEIC → temp-PNG) + O-->>R: OCRCompleted{text} + else Stage = enrich (optional) + R->>L: Run(doc) + alt LLM erreichbar + L-->>R: DocumentEnriched{title, tags, …} + else LLM offline + L-->>R: Fehler (retryable) + R->>E: Append(ProcessingFailed{retryable}) + R->>Q: Defer(not_before = now + Backoff) + Note over R,Q: nach max. Versuchen:
EnrichmentSkipped → weiter zu index + end + else Stage = index / file + R->>F: Run(doc) + F-->>R: DocumentIndexed / DocumentFiled{archive_path} + end + R->>E: Append(Events, expectedVersion) + R->>Q: next_stage weiterschalten bzw. Complete bei filed +``` + +### 6.3 Import über API + +```mermaid +sequenceDiagram + actor C as API-Client + participant A as api (Handler) + participant I as ingest + participant Q as work_queue + + C->>A: POST /v1/documents (multipart, Bearer-Token) + A->>A: Auth-Middleware (constant-time Vergleich) + A->>I: Ingest(stream, origin=api) + Note over I: Stream → staging/{uuid}.{ext}.part
TeeReader → SHA-256, dann Rename + alt Duplikat + I-->>A: ErrDuplicate + existing_id + A-->>C: 409 {error, existing_document_id} + else neu + I->>Q: DocumentReceived + Enqueue + A-->>C: 202 {id, status: received} + end + Note over C: weiterer Ablauf identisch zu 6.2 +``` + +### 6.4 Suche + +```mermaid +sequenceDiagram + actor C as API-Client + participant A as api + participant S as search (FTS5) + + C->>A: GET /v1/search?q=rechnung+stadtwerke + A->>S: Search(q, limit, offset) + S->>S: MATCH + bm25() + snippet() + S-->>A: [{id, title, snippet, rank}, …] + A-->>C: 200 {results, total} +``` + +### 6.5 Fehlerfall und Wiederaufnahme (Retry/Resume) + +```mermaid +sequenceDiagram + actor C as API-Client + participant A as api + participant E as events + participant Q as work_queue + participant R as Runner + + Note over E: Dokument steht auf failed
(ProcessingFailed{stage=ocr, retryable=false}) + C->>A: POST /v1/documents/{id}/reprocess + A->>Q: geclaimt? → 409, sonst weiter + A->>E: Append(RetryRequested{from_stage}) + A->>Q: Enqueue(id) + A-->>C: 202 + R->>E: Load + Replay + Note over R: Resume-Stage ableitbar aus Zustand:
erste Stage der kanonischen Reihenfolge,
deren Ergebnis fehlt — fertige Stages laufen nie erneut + R->>R: Pipeline ab OCR fortsetzen +``` + +## 7. Verteilungssicht + +```mermaid +flowchart TB + subgraph vserver["Strato VServer (Ubuntu 24.04)"] + caddy["Caddy
:80/:443, TLS/ACME"] + subgraph compose["/opt/documenter (Docker Compose)"] + app["documenter-api-1
:9060 intern
debian-slim + ocrmypdf/tesseract/poppler/libheif"] + vol1[("config/")] + vol2[("data/ — SQLite, Staging, Logs")] + vol3[("import/")] + vol4[("archive/")] + end + caddy -- "proxy-Netzwerk
(Host-Header-Routing)" --> app + app --- vol1 & vol2 & vol3 & vol4 + end + subgraph xps["XPS15 (NetBird-VPN, ggf. offline)"] + localai["LocalAI :8080
OpenAI-kompatibel"] + end + subgraph git2["git2.adelante-it.de"] + gitea["Gitea: Repo + Registry + Actions
act_runner (go-ci)"] + end + app -. "Chat-Completions
(optional)" .-> localai + gitea -- "Release: Image push,
Deploy: compose pull && up -d" --> compose +``` + +- Keine veröffentlichten Container-Ports; Caddy erreicht den Container über das externe `proxy`-Netzwerk +- Secrets (`API_TOKEN`, `LLM_API_KEY`) via `/opt/documenter/.env`, nie im Repo +- Dockerfile: Builder `golang:1.26-alpine` (CGO_ENABLED=0) → Runtime `debian:trixie-slim` mit `ocrmypdf tesseract-ocr-deu tesseract-ocr-eng poppler-utils libheif-examples`, non-root (DEC-RUNTIME-01) +- Offener Punkt: Erreichbarkeit von LocalAI (VPN-Adresse) aus dem Container früh verifizieren + +## 8. Querschnittliche Konzepte + +### 8.1 Speicherkonzept (DEC-STORAGE-01) + +Genau **zwei Speicherorte**, keine weiteren Systeme: + +1. **Eine SQLite-Datei** (`data/documenter.db`): Event-Store (Quelle der Wahrheit), + `documents`-Projektion (Read Model), FTS5-Index, Work-Queue. Eine Datei = eine + Backup-Einheit; transaktionale Konsistenz gratis; WAL für parallele API-/Worker-Zugriffe. +2. **Plain-Dateisystem**: `import/` (+ `rejected/`), `data/staging/`, `archive/{JJJJ}/`. + Keine Blobs in der DB — Dateien bleiben normale, menschenlesbare Dateien. + +Robustheit: + +- Originale byte-identisch; nur `os.Rename` (cross-device: copy+fsync+rename), `.part`-Suffix + während Schreibvorgängen — nie halbe Dateien sichtbar +- Verknüpfung Datei↔Kontext doppelt: **UUID im Dateinamen** (Datei→DB) + **SHA-256 in der DB** (DB→Datei) +- Gestaffelte Wiederherstellung: FTS kaputt → Rebuild-API; Projektion kaputt → Replay aus Events; + DB weg → Dateien bleiben lesbar und über die UUID identifizierbar +- Backup: genau zwei Verzeichnisse (`data/` + `archive/`) per rsync/Snapshot + +### 8.2 Unveränderlichkeit der Originale (DEC-IMMUTABLE-01) + +Dateien werden ab Aufnahme nur gelesen, verschoben und umbenannt — nie inhaltlich verändert. +OCR erzeugt ausschließlich Sidecar-Text (`ocrmypdf --output-type none` bzw. tesseract-Ausgabe); +es wird kein Textlayer eingebettet, keine Bild→PDF-Konvertierung für die Ablage vorgenommen. +Kontext (Text, Tags, Titel, Events) lebt vollständig extern in der DB. +Verifikation: SHA-256 vor Import == SHA-256 der archivierten Datei (fester Testfall). + +### 8.3 Datei-Lebenszyklus und Benennung (DEC-NAME-01, DEC-YEAR-01, DEC-DUP-01) + +- Ablagepfad: `archive/{JJJJ}/{JJJJMMTT-HHMMSS_}bereinigter-name_{uuid}.{ext}` +- Jahr = **Importzeitpunkt** (deterministisch, LLM-unabhängig); Zeitstempel-Präfix per Config +- Dedup per SHA-256 (UNIQUE-Index): API → `409` + `existing_document_id`; Watcher → `import/rejected/` +- Grenze: erneuter Scan desselben Papierdokuments erzeugt andere Bytes und wird nicht erkannt + +### 8.4 Ereignisorientierung und Konsistenz (DEC-ES-01, DEC-ES-02) + +Event Sourcing nur für das Dokument-Aggregat, handgerollt (~200 Zeilen): +append-only `events`-Tabelle, `PRIMARY KEY(aggregate_id, seq)` als optimistische +Nebenläufigkeitskontrolle, **synchrone Projektion** in derselben Transaktion — das Read +Model ist nie stale. Retries und Übersprünge sind selbst Events (`RetryRequested`, +`EnrichmentSkipped`), die Wiederaufnahme-Stage ist rein aus dem Replay-Zustand ableitbar. + +### 8.5 Fehlerbehandlung und Wiederanlauf (DEC-QUEUE-01) + +- DB-backed Work-Queue mit Lease (15 min): Neustart- und Crash-sicher, verwaiste Claims + werden übernommen; 1 Worker (konfigurierbar), Poll-Intervall 2 s +- Retryable Fehler (LLM offline, OCR-Timeout): exponentieller Backoff 30s·2ⁿ (Cap 1 h), + max. Versuche konfigurierbar (Default 10) +- Enrichment ist **optional**: nach erschöpften Versuchen `EnrichmentSkipped` und die + Pipeline läuft weiter — ein offlines LLM blockiert nie die Ablage +- Nicht-retryable Fehler (korrupte Datei, passwortgeschütztes PDF): Status `failed`, + manuell per `POST /v1/documents/{id}/reprocess` wiederaufnehmbar + +### 8.6 Logging + +- `log/slog`, JSON-Handler; stdout + optional Datei mit lumberjack-Rotation + (`max_size_mb`/`max_backups`/`max_age_days`); Level per Config/Env +- HTTP-Zugriffe über chi-Middleware; jede Pipeline-Stage loggt strukturiert: + `slog.Info("stage done", "document_id", id, "stage", name, "duration_ms", ms)` +- Fehler immer mit `"err"`, `"stage"`, `"attempt"` — Korrelation über `document_id` + +### 8.7 Testbarkeit und TDD (DEC-REQ-01) + +- Anforderungen als **Gherkin-Szenarien** in [doc/requirements/](requirements/), je Szenario + ein gleichnamiger Stdlib-Test; Traceability-Tabelle je Datei. Workflow: Szenario → roter Test → Implementierung +- Nur Stdlib `testing`, table-driven, handgeschriebene In-Memory-Fakes; Interfaces als Seams +- exec-basierte Komponenten (ocrmypdf, pdftotext, tesseract) über injizierbaren + `runner func(ctx, name, args...)`-Seam testbar; LLM über `httptest.Server` +- Echte SQLite (in-memory/tmpfile) inkl. FTS5 in DB- und Integrationstests +- Zwei Integrationstests im normalen `go test ./...`: API-Upload → `filed` + suchbar; + Ordner-Import → gleiches Ergebnis + +### 8.8 Sicherheit (DEC-AUTH-01) + +- Statischer Bearer-Token (`API_TOKEN`, nur Env), Vergleich mit `crypto/subtle.ConstantTimeCompare` +- `/healthz` öffentlich, alles andere tokenpflichtig; TLS terminiert Caddy +- Upload-Limit per Config (Default 100 MB); nur Whitelist-Endungen (`pdf|jpg|jpeg|png|heic`) + +## 9. Architekturentscheidungen (DEC-*) + +| ID | Entscheidung | Begründung / Trade-off | +|---|---|---| +| DEC-STACK-01 | Go + chi + SQLite + slog, pamietnik-Konventionen | bewährt, minimal, ein Binary; bewusst kein Framework | +| DEC-DB-01 | `modernc.org/sqlite`, kein DB-Server | pure Go, eine Datei; Grenze: eine Schreib-Verbindung (für Single-User irrelevant) | +| DEC-FTS-01 | SQLite FTS5 external-content, Sync explizit in Go, Rebuild per API | kein Zweitsystem; offen: `trigram`-Tokenizer, falls Recall bei deutschen Komposita schlecht | +| DEC-ES-01 | Event Sourcing nur fürs Dokument-Aggregat, synchrone Projektion | volle Nachvollziehbarkeit ohne ES-Framework; keine Eventual-Consistency-Probleme | +| DEC-ES-02 | Volltext im Event-Payload | vollständige Rekonstruierbarkeit; DB-Wachstum durch Upload-Limit + Text-Cap begrenzt | +| DEC-QUEUE-01 | DB-backed Work-Claim-Queue mit Lease statt Go-Channels | Neustart-/Crash-sicher, kein Verlust; Trade-off: Poll-Latenz ≤ 2 s (akzeptiert) | +| DEC-IMMUTABLE-01 | **Originale nie verändern**; OCR nur als Sidecar-Text; Kontext extern | Hashes/Signaturen bleiben gültig, keine Render-Regressionen; Trade-off: archivierte PDFs haben keinen eingebetteten Textlayer — Suche läuft immer über das System | +| DEC-MEDIA-01 | PDF und Bilder (JPG/PNG/HEIC) in identischer Pipeline | ein Ablauf, eine Ablage; HEIC nur temporär für OCR konvertiert | +| DEC-NAME-01 | UUID im Archiv-Dateinamen + SHA-256 in DB | bidirektionaler, DB-unabhängiger Link; Trade-off: längere Dateinamen | +| DEC-YEAR-01 | Jahresordner aus Importdatum, nicht LLM-`doc_date` | deterministisch, funktioniert ohne LLM | +| DEC-DUP-01 | Dedup via SHA-256, Duplikate abgelehnt | einfach, exakt; erkennt keine Re-Scans desselben Papiers | +| DEC-OCR-01 | ocrmypdf (PDF) / tesseract (Bilder) via exec hinter Interface | beste Qualität, ein Container; Trade-off: ~600-MB-Image | +| DEC-EXTRACT-01 | `pdftotext` (poppler) via exec statt pure-Go-Lib | bessere Extraktion; Fat-Image ohnehin nötig | +| DEC-LLM-01 | OpenAI-kompatibler Minimal-Client; Anreicherung optional/retryable/nachholbar | LocalAI ↔ Cloud per Config; offlines LLM blockiert nie | +| DEC-RUNTIME-01 | Runtime-Image debian-slim statt distroless (Abweichung von pamietnik) | ocrmypdf/tesseract/poppler/libheif erforderlich | +| DEC-AUTH-01 | Statischer Bearer-Token, Single-User | ausreichend für v1; Revision bei Frontend/Multi-User | +| DEC-STORAGE-01 | Nur SQLite + Plain-Dateisystem | s. Kap. 8.1 | +| DEC-DOC-01 | Mermaid für alle Diagramme | nativ in Gitea gerendert, diffbar; Trade-off: kein Voll-UML (akzeptiert) | +| DEC-REQ-01 | Gherkin als Doku-Format + Stdlib-Tests (kein godog) | TDD-tauglich ohne Framework; Drift-Kontrolle über Namenskonvention + Traceability-Tabellen | + +## 10. Qualitätsanforderungen + +| Qualitätsziel | Konkretisierung / Prüfung | +|---|---| +| Einfachheit | 6 Dependencies, ein Binary, ein Container, eine DB-Datei | +| Testbarkeit | jedes Gherkin-Szenario hat einen benannten Test; `go test ./...` ohne externe Dienste | +| Nachvollziehbarkeit | `GET /v1/documents/{id}/events` zeigt lückenlosen fachlichen Verlauf | +| Konsistenz | Events + Projektion + FTS in einer Transaktion; Replay == Projektion (Testfall) | +| Zuverlässigkeit | Crash-Recovery über Queue-Lease; Stabilitäts-Detektor gegen halbe Dateien | +| Unversehrtheit | SHA-256 vor Import == SHA-256 im Archiv (Testfall) | +| Suchbarkeit | jedes verarbeitete Dokument per Inhalt findbar; Rebuild jederzeit möglich | +| Erweiterbarkeit | Frontend später gegen `doc/openapi.yaml`; neue Stages als weitere `Stage`-Implementierung | + +## 11. Risiken und offene Punkte + +| Risiko / offener Punkt | Einordnung / Maßnahme | +|---|---| +| OCR-Qualität schwankt | Original bleibt erhalten → jederzeit Re-OCR per `reprocess` möglich | +| LocalAI-Erreichbarkeit aus dem Container (VPN) | früh in M8 verifizieren; ggf. `extra_hosts`/Host-Forwarding | +| FTS5-Recall bei deutschen Komposita | beobachten; ggf. `trigram`-Tokenizer (DEC-FTS-01 offen) | +| Passwortgeschützte/korrupte PDFs | non-retryable `failed`; Szenario definiert | +| RAM bei parallelem OCR (VServer 8 GB) | v1: 1 Worker; OCR-Parallelität erst bei Bedarf | +| Spec-Drift Gherkin ↔ Tests | Namenskonvention + Traceability-Tabelle; optional später Lint-Skript | +| Backup-Automatisierung | offen; Muster: rsync wie pamietnik (infranas) | +| Watcher-Unterordner | v1: nein (nur Top-Level von `import/`) | +| Domain/Caddyfile-Eintrag | vor M9 festlegen | + +## 12. Glossar + +| Begriff | Bedeutung | +|---|---| +| Aggregat | Das Dokument als Einheit der Event-Sourcing-Konsistenz (eine UUID = ein Event-Stream) | +| Projektion / Read Model | `documents`-Tabelle, abgeleitet aus Events, für Abfragen und Suche | +| Sidecar-Text | OCR-Ausgabe als reine Textdatei, ohne das Quelldokument zu verändern | +| Staging | `data/staging/` — Zwischenbereich während der Verarbeitung | +| Stage | Ein Pipeline-Schritt (extract, ocr, enrich, index, file) hinter dem `Stage`-Interface | +| Work-Queue | DB-Tabelle, aus der der Worker Arbeit per Lease claimt | +| Lease | Zeitlich begrenzter Claim; läuft er ab, übernimmt ein Worker die Arbeit erneut | +| FTS5 | SQLite-Volltextsuchmodul (BM25-Ranking, Snippets) | +| external content | FTS5-Modus, bei dem der Index auf die `documents`-Tabelle zeigt statt Text zu duplizieren | diff --git a/doc/openapi.yaml b/doc/openapi.yaml new file mode 100644 index 0000000..480d62e --- /dev/null +++ b/doc/openapi.yaml @@ -0,0 +1,288 @@ +openapi: "3.1.0" +info: + title: go-documenter API + description: | + API-first Dokumentenmanagement-System. Dieses Dokument ist die Quelle der Wahrheit + für den API-Vertrag; Endpunkte werden hier VOR der Implementierung spezifiziert. + Architektur: doc/architecture.md, Anforderungen: doc/requirements/. + version: "0.1.0" + +servers: + - url: / + description: Hinter Caddy-Reverse-Proxy + +security: + - bearerAuth: [] + +paths: + /healthz: + get: + summary: Liveness-Check mit Versionsinformation + security: [] + responses: + "200": + description: Dienst läuft + content: + application/json: + schema: + type: object + properties: + status: { type: string, example: ok } + commit: { type: string } + tag: { type: string } + + /v1/documents: + post: + summary: Dokument hochladen (PDF, JPG, PNG, HEIC) + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + responses: + "202": + description: Angenommen, Verarbeitung gestartet + content: + application/json: + schema: + type: object + properties: + id: { $ref: "#/components/schemas/UUID" } + status: { type: string, example: received } + "401": { $ref: "#/components/responses/Unauthorized" } + "409": + description: Byte-identisches Dokument existiert bereits + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Error" + - type: object + properties: + existing_document_id: { $ref: "#/components/schemas/UUID" } + "413": { description: Upload überschreitet das konfigurierte Größenlimit } + "422": { description: Nicht unterstützter Dateityp } + get: + summary: Dokumente auflisten (Projektion) + parameters: + - { name: status, in: query, schema: { $ref: "#/components/schemas/Status" } } + - { name: tag, in: query, schema: { type: string } } + - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } } + - { name: offset, in: query, schema: { type: integer, default: 0 } } + responses: + "200": + description: Liste + content: + application/json: + schema: + type: object + properties: + documents: + type: array + items: { $ref: "#/components/schemas/Document" } + total: { type: integer } + "401": { $ref: "#/components/responses/Unauthorized" } + + /v1/documents/{id}: + get: + summary: Einzelnes Dokument abrufen + parameters: + - { $ref: "#/components/parameters/DocumentID" } + - name: include_text + in: query + description: Volltext im Response mitliefern + schema: { type: boolean, default: false } + responses: + "200": + description: Dokument + content: + application/json: + schema: { $ref: "#/components/schemas/Document" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + + /v1/documents/{id}/file: + get: + summary: Originaldatei streamen (byte-identisch zum Import) + parameters: + - { $ref: "#/components/parameters/DocumentID" } + responses: + "200": + description: Dateiinhalt + content: + application/pdf: { schema: { type: string, format: binary } } + image/jpeg: { schema: { type: string, format: binary } } + image/png: { schema: { type: string, format: binary } } + image/heic: { schema: { type: string, format: binary } } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + + /v1/documents/{id}/events: + get: + summary: Audit-Trail — alle Events des Dokument-Aggregats + parameters: + - { $ref: "#/components/parameters/DocumentID" } + responses: + "200": + description: Eventfolge in seq-Reihenfolge + content: + application/json: + schema: + type: object + properties: + events: + type: array + items: { $ref: "#/components/schemas/Event" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + + /v1/documents/{id}/reprocess: + post: + summary: Verarbeitung wiederaufnehmen (nach Fehler oder für Nachanreicherung) + parameters: + - { $ref: "#/components/parameters/DocumentID" } + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + from_stage: + type: string + enum: [extract, ocr, enrich, index, file] + responses: + "202": { description: Wiederaufnahme eingeplant } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { description: Dokument ist aktuell in Verarbeitung (geclaimt) } + + /v1/search: + get: + summary: Volltextsuche (FTS5, BM25-Ranking) + parameters: + - { name: q, in: query, required: true, schema: { type: string } } + - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } } + - { name: offset, in: query, schema: { type: integer, default: 0 } } + responses: + "200": + description: Treffer nach Relevanz sortiert + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + type: object + properties: + id: { $ref: "#/components/schemas/UUID" } + title: { type: string } + original_name: { type: string } + archive_path: { type: string } + snippet: { type: string, description: Treffer-Ausschnitt mit Hervorhebung } + rank: { type: number } + total: { type: integer } + "400": { description: Fehlender oder ungültiger Suchbegriff } + "401": { $ref: "#/components/responses/Unauthorized" } + + /v1/admin/search/rebuild: + post: + summary: FTS-Index vollständig aus der documents-Projektion neu aufbauen + responses: + "200": + description: Rebuild abgeschlossen + content: + application/json: + schema: + type: object + properties: + indexed_documents: { type: integer } + duration_ms: { type: integer } + "401": { $ref: "#/components/responses/Unauthorized" } + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: Statischer API-Token (Env `API_TOKEN`) + + parameters: + DocumentID: + name: id + in: path + required: true + schema: { $ref: "#/components/schemas/UUID" } + + responses: + Unauthorized: + description: Fehlender oder ungültiger Token + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + NotFound: + description: Dokument nicht gefunden + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + schemas: + UUID: + type: string + format: uuid + example: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d + + Status: + type: string + enum: [received, processing, ocr_done, enriched, indexed, filed, failed] + + Error: + type: object + properties: + error: + type: object + properties: + code: { type: string, example: duplicate_document } + message: { type: string } + + Event: + type: object + properties: + seq: { type: integer } + event_type: { type: string, example: DocumentReceived } + payload: { type: object, description: Event-spezifischer Payload (snake_case) } + created_at: { type: string, format: date-time } + + Document: + type: object + properties: + id: { $ref: "#/components/schemas/UUID" } + status: { $ref: "#/components/schemas/Status" } + original_name: { type: string } + media_type: { type: string, enum: [pdf, jpg, png, heic] } + origin: { type: string, enum: [api, import_folder] } + content_hash: { type: string, description: SHA-256 (hex) } + size_bytes: { type: integer } + page_count: { type: integer } + title: { type: string } + summary: { type: string } + tags: + type: array + items: { type: string } + doc_date: { type: string, description: Vom LLM erkanntes Dokumentdatum (Metadatum) } + language: { type: string } + enriched: { type: boolean } + archive_path: { type: string, description: Relativ zum Archiv-Wurzelverzeichnis } + failed_stage: { type: string } + last_error: { type: string } + text: { type: string, description: Nur bei include_text=true } + imported_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } diff --git a/doc/requirements/01-import-api.md b/doc/requirements/01-import-api.md new file mode 100644 index 0000000..f8aa7c6 --- /dev/null +++ b/doc/requirements/01-import-api.md @@ -0,0 +1,56 @@ +# Feature: Dokumenten-Upload über die API + +Als API-Client möchte ich Dokumente (PDF, JPG, PNG, HEIC) hochladen, +damit sie automatisch verarbeitet, durchsuchbar gemacht und abgelegt werden. + +```gherkin +Funktionalität: Dokumenten-Upload über die API + + Szenario: Erfolgreicher PDF-Upload + Angenommen ein gültiger API-Token + Wenn ein PDF per POST /v1/documents (multipart, Feld "file") hochgeladen wird + Dann antwortet das System mit 202 und einer neuen Dokument-UUID + Und das Dokument hat den Status "received" + Und die Datei liegt unter data/staging/{uuid}.pdf + Und ein Event "DocumentReceived" mit origin "api" wurde aufgezeichnet + + Szenario: Upload eines Bildes + Angenommen ein gültiger API-Token + Wenn eine JPG-, PNG- oder HEIC-Datei hochgeladen wird + Dann antwortet das System mit 202 + Und der media_type des Dokuments entspricht dem Dateityp + + Szenario: Duplikat-Upload wird abgelehnt + Angenommen ein bereits importiertes Dokument mit bekanntem SHA-256-Hash + Wenn eine byte-identische Datei erneut hochgeladen wird + Dann antwortet das System mit 409 + Und die Antwort enthält error.code "duplicate_document" und die existing_document_id + Und es wurde kein neues Aggregat angelegt + + Szenario: Upload ohne Token wird abgelehnt + Angenommen kein oder ein falscher API-Token + Wenn ein Dokument hochgeladen wird + Dann antwortet das System mit 401 + + Szenario: Nicht unterstützter Dateityp wird abgelehnt + Angenommen ein gültiger API-Token + Wenn eine Datei mit nicht unterstützter Endung (z.B. .docx) hochgeladen wird + Dann antwortet das System mit 422 und error.code "unsupported_media_type" + + Szenario: Upload über dem Größenlimit wird abgelehnt + Angenommen ein konfiguriertes Upload-Limit von 100 MB + Wenn eine größere Datei hochgeladen wird + Dann antwortet das System mit 413 + Und es verbleibt keine .part-Datei im Staging +``` + +## Traceability + +| Szenario | Go-Test (Paket `internal/api`, `internal/ingest`) | +|---|---| +| Erfolgreicher PDF-Upload | `TestUpload_PDFReturns202AndReceived` | +| Upload eines Bildes | `TestUpload_ImageSetsMediaType` | +| Duplikat-Upload wird abgelehnt | `TestUpload_DuplicateReturns409` | +| Upload ohne Token wird abgelehnt | `TestUpload_MissingTokenReturns401` | +| Nicht unterstützter Dateityp wird abgelehnt | `TestUpload_UnsupportedTypeReturns422` | +| Upload über dem Größenlimit wird abgelehnt | `TestUpload_TooLargeReturns413AndCleansUp` | diff --git a/doc/requirements/02-import-ordner.md b/doc/requirements/02-import-ordner.md new file mode 100644 index 0000000..2fbf8d2 --- /dev/null +++ b/doc/requirements/02-import-ordner.md @@ -0,0 +1,54 @@ +# Feature: Automatischer Import aus dem überwachten Ordner + +Als Nutzer möchte ich Dateien einfach im Import-Ordner ablegen, +damit sie ohne manuellen Eingriff verarbeitet werden. + +```gherkin +Funktionalität: Automatischer Import aus dem überwachten Ordner + + Szenario: Neue Datei wird automatisch erkannt und verarbeitet + Angenommen der Watcher überwacht den Import-Ordner + Wenn eine PDF-Datei im Import-Ordner gespeichert wird + Dann wird die Datei nach Abschluss des Schreibvorgangs in das Staging übernommen + Und ein Event "DocumentReceived" mit origin "import_folder" wurde aufgezeichnet + Und die Datei liegt nicht mehr im Import-Ordner + + Szenario: Unvollständig geschriebene Datei wird nicht vorzeitig übernommen + Angenommen eine Datei wird langsam in den Import-Ordner geschrieben + Wenn sich Größe oder Änderungszeit innerhalb der Stabilitätsfrist noch ändern + Dann wird die Datei noch nicht übernommen + Und erst nach unveränderter Stabilitätsfrist beginnt die Verarbeitung + + Szenario: Beim Start vorhandene Dateien werden übernommen + Angenommen im Import-Ordner liegen Dateien, während das System nicht läuft + Wenn das System startet + Dann werden diese Dateien durch den Start-Scan erkannt und verarbeitet + + Szenario: Periodischer Rescan fängt verpasste Ereignisse + Angenommen eine Datei wurde vom Dateisystem-Watcher nicht gemeldet + Wenn der periodische Rescan läuft + Dann wird die Datei erkannt und verarbeitet + + Szenario: Duplikat aus dem Import-Ordner wird aussortiert + Angenommen ein bereits importiertes Dokument mit bekanntem SHA-256-Hash + Wenn eine byte-identische Datei im Import-Ordner gespeichert wird + Dann wird die Datei nach import/rejected/ verschoben + Und eine Warnung mit der existing_document_id wird geloggt + Und es wurde kein neues Aggregat angelegt + + Szenario: Nicht unterstützte und versteckte Dateien werden ignoriert + Angenommen der Watcher überwacht den Import-Ordner + Wenn eine .docx-, .part- oder versteckte Datei dort gespeichert wird + Dann wird sie ignoriert und verbleibt unverändert +``` + +## Traceability + +| Szenario | Go-Test (Paket `internal/importer`) | +|---|---| +| Neue Datei wird automatisch erkannt und verarbeitet | `TestImporter_NewFileIsIngested` | +| Unvollständig geschriebene Datei wird nicht vorzeitig übernommen | `TestImporter_WaitsForStableFile` | +| Beim Start vorhandene Dateien werden übernommen | `TestImporter_StartupScanPicksUpExistingFiles` | +| Periodischer Rescan fängt verpasste Ereignisse | `TestImporter_RescanPicksUpMissedFile` | +| Duplikat aus dem Import-Ordner wird aussortiert | `TestImporter_DuplicateMovedToRejected` | +| Nicht unterstützte und versteckte Dateien werden ignoriert | `TestImporter_IgnoresUnsupportedAndHiddenFiles` | diff --git a/doc/requirements/03-textextraktion-ocr.md b/doc/requirements/03-textextraktion-ocr.md new file mode 100644 index 0000000..26fdfed --- /dev/null +++ b/doc/requirements/03-textextraktion-ocr.md @@ -0,0 +1,58 @@ +# Feature: Textextraktion und OCR + +Als System möchte ich den Textinhalt jedes Dokuments gewinnen, +ohne die Originaldatei jemals zu verändern (DEC-IMMUTABLE-01), +damit Suche und Anreicherung auf externem Kontext arbeiten können. + +```gherkin +Funktionalität: Textextraktion und OCR + + Szenario: Born-digital-PDF braucht kein OCR + Angenommen ein PDF mit maschinenlesbarem Textlayer + Wenn die Extract-Stage läuft + Dann wird der Text per pdftotext gewonnen + Und ein Event "TextExtracted" mit needs_ocr=false wird aufgezeichnet + Und die OCR-Stage wird übersprungen + + Szenario: Gescanntes PDF wird per OCR erschlossen + Angenommen ein PDF ohne bzw. mit unzureichendem Textlayer + Wenn die OCR-Stage läuft + Dann wird der Text per ocrmypdf im Sidecar-Modus gewonnen + Und ein Event "OCRCompleted" mit engine "ocrmypdf" wird aufgezeichnet + Und es wird kein Ausgabe-PDF erzeugt + + Szenario: Bilddateien werden immer per OCR erschlossen + Angenommen ein importiertes JPG-, PNG- oder HEIC-Bild + Wenn die Verarbeitung läuft + Dann liefert die Extract-Stage needs_ocr=true + Und der Text wird per tesseract direkt aus dem Bild gewonnen + + Szenario: HEIC wird nur temporär konvertiert + Angenommen ein importiertes HEIC-Bild + Wenn die OCR-Stage läuft + Dann wird für die Erkennung ein temporäres PNG erzeugt + Und das temporäre PNG wird nach der Erkennung gelöscht + Und die archivierte Datei ist das unveränderte HEIC-Original + + Szenario: Originaldatei bleibt byte-identisch + Angenommen ein Dokument mit bekanntem SHA-256-Hash bei der Aufnahme + Wenn das Dokument vollständig verarbeitet und abgelegt wurde + Dann ist der SHA-256-Hash der archivierten Datei identisch mit dem Hash bei der Aufnahme + + Szenario: Korrupte oder passwortgeschützte Datei schlägt endgültig fehl + Angenommen eine nicht lesbare oder passwortgeschützte PDF-Datei + Wenn Extraktion und OCR fehlschlagen + Dann wird ein Event "ProcessingFailed" mit retryable=false aufgezeichnet + Und das Dokument hat den Status "failed" mit gesetzter failed_stage +``` + +## Traceability + +| Szenario | Go-Test (Pakete `internal/pdf`, `internal/ocr`, `internal/pipeline`) | +|---|---| +| Born-digital-PDF braucht kein OCR | `TestExtractStage_TextPDFSkipsOCR` | +| Gescanntes PDF wird per OCR erschlossen | `TestOCRStage_ScannedPDFUsesSidecarOnly` | +| Bilddateien werden immer per OCR erschlossen | `TestExtractStage_ImageAlwaysNeedsOCR` | +| HEIC wird nur temporär konvertiert | `TestOCR_HEICTempConversionIsCleanedUp` | +| Originaldatei bleibt byte-identisch | `TestPipeline_OriginalHashUnchangedAfterProcessing` | +| Korrupte oder passwortgeschützte Datei schlägt endgültig fehl | `TestPipeline_CorruptPDFFailsNonRetryable` | diff --git a/doc/requirements/04-llm-anreicherung.md b/doc/requirements/04-llm-anreicherung.md new file mode 100644 index 0000000..331f924 --- /dev/null +++ b/doc/requirements/04-llm-anreicherung.md @@ -0,0 +1,47 @@ +# Feature: Optionale LLM-Anreicherung + +Als Nutzer möchte ich, dass Dokumente semantisch angereichert werden (Titel, Zusammenfassung, +Tags, Dokumentdatum, Sprache), ohne dass ein nicht erreichbares LLM die Verarbeitung blockiert. + +```gherkin +Funktionalität: Optionale LLM-Anreicherung + + Szenario: Erfolgreiche Anreicherung + Angenommen ein Dokument mit extrahiertem Text und ein erreichbares LLM + Wenn die Enrich-Stage läuft + Dann wird ein Event "DocumentEnriched" mit title, summary, tags, doc_date und language aufgezeichnet + Und das verwendete Modell und die Prompt-Version sind im Event dokumentiert + + Szenario: LLM nicht erreichbar führt zu Wiederholung mit Backoff + Angenommen das LLM ist nicht erreichbar + Wenn die Enrich-Stage fehlschlägt + Dann wird ein Event "ProcessingFailed" mit retryable=true aufgezeichnet + Und der nächste Versuch wird mit exponentiellem Backoff eingeplant + + Szenario: Nach erschöpften Versuchen läuft die Pipeline weiter + Angenommen das LLM bleibt über die maximale Versuchszahl hinaus nicht erreichbar + Wenn der letzte Versuch fehlschlägt + Dann wird ein Event "EnrichmentSkipped" aufgezeichnet + Und das Dokument wird trotzdem indexiert und abgelegt + + Szenario: Unbrauchbare LLM-Antwort ist wiederholbar + Angenommen das LLM liefert kein valides JSON + Wenn die Enrich-Stage die Antwort nicht parsen kann + Dann wird der Fehler als retryable behandelt + + Szenario: Übersprungene Anreicherung ist nachholbar + Angenommen ein abgelegtes Dokument mit "EnrichmentSkipped" + Wenn POST /v1/documents/{id}/reprocess mit from_stage "enrich" aufgerufen wird + Dann wird nur die Anreicherung erneut ausgeführt + Und bereits abgeschlossene Schritte laufen nicht erneut +``` + +## Traceability + +| Szenario | Go-Test (Pakete `internal/llm`, `internal/pipeline`) | +|---|---| +| Erfolgreiche Anreicherung | `TestEnrichStage_SuccessEmitsDocumentEnriched` | +| LLM nicht erreichbar führt zu Wiederholung mit Backoff | `TestEnrichStage_UnreachableLLMIsRetryableWithBackoff` | +| Nach erschöpften Versuchen läuft die Pipeline weiter | `TestPipeline_EnrichExhaustedSkipsAndContinues` | +| Unbrauchbare LLM-Antwort ist wiederholbar | `TestEnricher_MalformedJSONIsRetryable` | +| Übersprungene Anreicherung ist nachholbar | `TestReprocess_FromEnrichOnlyRunsEnrich` | diff --git a/doc/requirements/05-volltextsuche.md b/doc/requirements/05-volltextsuche.md new file mode 100644 index 0000000..f85979f --- /dev/null +++ b/doc/requirements/05-volltextsuche.md @@ -0,0 +1,58 @@ +# Feature: Volltextsuche und Index-Rebuild + +Als API-Client möchte ich Dokumente über ihren Inhalt und ihre Metadaten finden +und den Suchindex jederzeit neu aufbauen können. + +```gherkin +Funktionalität: Volltextsuche + + Szenario: Dokument ist über seinen Inhalt findbar + Angenommen ein verarbeitetes Dokument, dessen Text "Stromabrechnung Stadtwerke" enthält + Wenn GET /v1/search?q=stadtwerke aufgerufen wird + Dann enthält das Ergebnis das Dokument mit id, title, original_name, archive_path und snippet + + Szenario: Suche berücksichtigt Metadaten + Angenommen ein angereichertes Dokument mit dem Tag "versicherung" + Wenn GET /v1/search?q=versicherung aufgerufen wird + Dann wird das Dokument gefunden, auch wenn der Begriff nicht im Text vorkommt + + Szenario: Umlaute und Diakritika werden tolerant behandelt + Angenommen ein Dokument, dessen Text "Gebührenbescheid" enthält + Wenn nach "gebuhrenbescheid" gesucht wird + Dann wird das Dokument gefunden + + Szenario: Treffer sind nach Relevanz sortiert + Angenommen mehrere Dokumente enthalten den Suchbegriff unterschiedlich häufig + Wenn gesucht wird + Dann sind die Treffer nach BM25-Relevanz absteigend sortiert + + Szenario: OCR-Inhalte sind durchsuchbar + Angenommen ein gescanntes Dokument, dessen Text nur per OCR gewonnen wurde + Wenn nach einem Begriff aus dem OCR-Text gesucht wird + Dann wird das Dokument gefunden + +Funktionalität: Index-Rebuild über die API + + Szenario: Rebuild stellt einen konsistenten Index wieder her + Angenommen ein inkonsistenter oder leerer FTS-Index bei gefüllter documents-Projektion + Wenn POST /v1/admin/search/rebuild aufgerufen wird + Dann wird der Index vollständig aus der Projektion neu aufgebaut + Und alle zuvor findbaren Dokumente sind wieder findbar + + Szenario: Rebuild erfordert Authentifizierung + Angenommen kein oder ein falscher API-Token + Wenn POST /v1/admin/search/rebuild aufgerufen wird + Dann antwortet das System mit 401 +``` + +## Traceability + +| Szenario | Go-Test (Pakete `internal/search`, `internal/api`) | +|---|---| +| Dokument ist über seinen Inhalt findbar | `TestSearch_FindsDocumentByContent` | +| Suche berücksichtigt Metadaten | `TestSearch_FindsDocumentByTag` | +| Umlaute und Diakritika werden tolerant behandelt | `TestSearch_DiacriticsInsensitive` | +| Treffer sind nach Relevanz sortiert | `TestSearch_ResultsOrderedByBM25` | +| OCR-Inhalte sind durchsuchbar | `TestSearch_FindsOCRContent` | +| Rebuild stellt einen konsistenten Index wieder her | `TestSearchRebuild_RestoresConsistentIndex` | +| Rebuild erfordert Authentifizierung | `TestSearchRebuild_RequiresAuth` | diff --git a/doc/requirements/06-ablage.md b/doc/requirements/06-ablage.md new file mode 100644 index 0000000..86d4c73 --- /dev/null +++ b/doc/requirements/06-ablage.md @@ -0,0 +1,48 @@ +# Feature: Finale Ablage in Jahresordnern + +Als Nutzer möchte ich, dass verarbeitete Dokumente unverändert und nachvollziehbar benannt +in einer Jahresstruktur abgelegt werden und über die UUID im Dateinamen jederzeit +ihrem Kontext in der Datenbank zuzuordnen sind. + +```gherkin +Funktionalität: Finale Ablage + + Szenario: Verarbeitetes Dokument landet im Jahresordner + Angenommen ein am 2026-07-12 importiertes Dokument + Wenn die File-Stage läuft + Dann liegt die Datei unter archive/2026/ + Und ein Event "DocumentFiled" mit dem relativen archive_path wird aufgezeichnet + Und die Datei liegt nicht mehr im Staging + + Szenario: Dateiname enthält UUID und optional den Importzeitstempel + Angenommen die Konfiguration filename_timestamp=true + Wenn ein Dokument "Rechnung Stadtwerke.pdf" abgelegt wird + Dann entspricht der Dateiname dem Muster {JJJJMMTT-HHMMSS}_rechnung-stadtwerke_{uuid}.pdf + Und bei filename_timestamp=false entfällt nur das Zeitstempel-Präfix + + Szenario: Jahresordner folgt dem Importzeitpunkt + Angenommen ein Dokument mit LLM-erkanntem doc_date "2019-03-01", importiert 2026 + Wenn es abgelegt wird + Dann liegt es in archive/2026/ (Importjahr, nicht Dokumentjahr) + + Szenario: Ablage über Volume-Grenzen ist atomar sicher + Angenommen Staging und Archiv liegen auf unterschiedlichen Dateisystemen + Wenn die File-Stage läuft + Dann wird per Kopie mit fsync und anschließendem Rename verschoben + Und zu keinem Zeitpunkt ist im Archiv eine unvollständige Datei sichtbar + + Szenario: Datei ist über die API abrufbar + Angenommen ein abgelegtes Dokument + Wenn GET /v1/documents/{id}/file aufgerufen wird + Dann wird die unveränderte Originaldatei mit korrektem Content-Type gestreamt +``` + +## Traceability + +| Szenario | Go-Test (Pakete `internal/storage`, `internal/pipeline`, `internal/api`) | +|---|---| +| Verarbeitetes Dokument landet im Jahresordner | `TestFileStage_MovesToYearFolder` | +| Dateiname enthält UUID und optional den Importzeitstempel | `TestFiler_FilenamePatternWithUUIDAndTimestamp` | +| Jahresordner folgt dem Importzeitpunkt | `TestFiler_YearFromImportDateNotDocDate` | +| Ablage über Volume-Grenzen ist atomar sicher | `TestStorage_CrossDeviceMoveIsAtomic` | +| Datei ist über die API abrufbar | `TestGetFile_StreamsOriginal` | diff --git a/doc/requirements/07-nachvollziehbarkeit.md b/doc/requirements/07-nachvollziehbarkeit.md new file mode 100644 index 0000000..450433b --- /dev/null +++ b/doc/requirements/07-nachvollziehbarkeit.md @@ -0,0 +1,61 @@ +# Feature: Nachvollziehbarkeit, Event Sourcing und Wiederaufnahme + +Als Betreiber möchte ich jede Zustandsänderung eines Dokuments lückenlos nachvollziehen, +den Zustand aus den Ereignissen rekonstruieren und fehlgeschlagene Verarbeitung +gezielt wiederaufnehmen können. + +```gherkin +Funktionalität: Event Sourcing und Audit-Trail + + Szenario: Jede Zustandsänderung ist als Event nachvollziehbar + Angenommen ein vollständig verarbeitetes Dokument + Wenn GET /v1/documents/{id}/events aufgerufen wird + Dann enthält die Antwort die lückenlose Eventfolge von DocumentReceived bis DocumentFiled + Und jedes Event enthält seq, event_type, payload und created_at + + Szenario: Der Zustand ist aus den Events rekonstruierbar + Angenommen die aufgezeichnete Eventfolge eines Dokuments + Wenn die Events per Replay gefaltet werden + Dann entspricht das Ergebnis exakt der documents-Projektion + + Szenario: Konkurrierende Änderungen werden erkannt + Angenommen zwei Schreiber mit derselben erwarteten Version eines Aggregats + Wenn beide Events anhängen wollen + Dann gelingt genau ein Append und der zweite erhält einen Konsistenzfehler + + Szenario: Events und Projektion sind transaktional konsistent + Angenommen ein Append von Events schlägt in der Projektion fehl + Wenn die Transaktion zurückgerollt wird + Dann sind weder Events noch Projektionsänderung gespeichert + +Funktionalität: Wiederaufnahme nach Fehlern + + Szenario: Neustart verliert keine Arbeit + Angenommen ein Dokument war beim Absturz des Systems in Verarbeitung + Wenn das System neu startet und der Claim-Lease abläuft + Dann wird die Verarbeitung automatisch wiederaufgenommen + + Szenario: Wiederaufnahme setzt an der richtigen Stelle an + Angenommen ein Dokument mit fehlgeschlagener OCR-Stage und Status "failed" + Wenn POST /v1/documents/{id}/reprocess aufgerufen wird + Dann wird ein Event "RetryRequested" aufgezeichnet + Und die Verarbeitung setzt bei der OCR-Stage wieder an + Und bereits abgeschlossene Stages laufen nicht erneut + + Szenario: Wiederaufnahme während laufender Verarbeitung wird abgelehnt + Angenommen ein Dokument, das aktuell von einem Worker geclaimt ist + Wenn POST /v1/documents/{id}/reprocess aufgerufen wird + Dann antwortet das System mit 409 +``` + +## Traceability + +| Szenario | Go-Test (Pakete `internal/events`, `internal/pipeline`, `internal/api`) | +|---|---| +| Jede Zustandsänderung ist als Event nachvollziehbar | `TestGetEvents_ReturnsCompleteTrail` | +| Der Zustand ist aus den Events rekonstruierbar | `TestReplay_MatchesProjection` | +| Konkurrierende Änderungen werden erkannt | `TestAppend_ConcurrencyConflictReturnsError` | +| Events und Projektion sind transaktional konsistent | `TestAppend_RollbackLeavesNoPartialState` | +| Neustart verliert keine Arbeit | `TestQueue_ExpiredLeaseIsReclaimed` | +| Wiederaufnahme setzt an der richtigen Stelle an | `TestReprocess_ResumesFromFailedStage` | +| Wiederaufnahme während laufender Verarbeitung wird abgelehnt | `TestReprocess_ClaimedDocumentReturns409` | diff --git a/doc/requirements/README.md b/doc/requirements/README.md new file mode 100644 index 0000000..6f6b57e --- /dev/null +++ b/doc/requirements/README.md @@ -0,0 +1,27 @@ +# Anforderungen als Gherkin-Szenarien + +Anforderungen werden als Gherkin-Szenarien (deutsche Schlüsselwörter: *Angenommen / Wenn / Dann*) +dokumentiert und im Sinne von TDD umgesetzt (DEC-REQ-01, siehe [architecture.md](../architecture.md)): + +1. **Szenario schreiben** (hier, vor der Implementierung) +2. **Roten Test schreiben** — Stdlib `testing`, Testname = Szenario-Referenz aus der Traceability-Tabelle +3. **Implementieren**, bis der Test grün ist + +Regeln: + +- Jedes Szenario hat genau einen gleichnamigen Go-Test; die Zuordnung steht in der + Traceability-Tabelle am Ende jeder Datei. +- Wird ein Test geändert, wird das Szenario im selben Commit nachgezogen (Drift-Kontrolle). +- Szenarien beschreiben fachliches Verhalten, keine Implementierungsdetails. + +## Übersicht + +| Datei | Feature | +|---|---| +| [01-import-api.md](01-import-api.md) | Dokumenten-Upload über die API | +| [02-import-ordner.md](02-import-ordner.md) | Automatischer Import aus dem überwachten Ordner | +| [03-textextraktion-ocr.md](03-textextraktion-ocr.md) | Textextraktion und OCR (PDF + Bilder), Unversehrtheit der Originale | +| [04-llm-anreicherung.md](04-llm-anreicherung.md) | Optionale LLM-Anreicherung | +| [05-volltextsuche.md](05-volltextsuche.md) | Volltextsuche und Index-Rebuild | +| [06-ablage.md](06-ablage.md) | Finale Ablage in Jahresordnern | +| [07-nachvollziehbarkeit.md](07-nachvollziehbarkeit.md) | Event Sourcing, Audit-Trail, Wiederaufnahme |