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 <noreply@anthropic.com>
This commit is contained in:
Christoph Kroczek
2026-07-13 15:20:48 +02:00
parent f52e4f62b0
commit 6e6cb0dc8d
10 changed files with 1291 additions and 0 deletions
+594
View File
@@ -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<br/>(curl, Skripte, späteres Frontend)"]
user["Nutzer<br/>(legt Dateien ab)"]
llm["LLM-Dienst<br/>OpenAI-kompatibel<br/>(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<br/>Router, Handler, Auth-Middleware"]
end
subgraph app["Anwendungslogik"]
ingest["internal/ingest<br/>Aufnahme: UUID, Hash, Dedup, Staging"]
pipeline["internal/pipeline<br/>Runner, Queue, Stages"]
importer["internal/importer<br/>fsnotify + Rescan + Stabilität"]
end
subgraph domain_layer["Domäne"]
domain["internal/domain<br/>Document, Status, MediaType"]
events["internal/events<br/>Event-Store, Replay, Projektion"]
end
subgraph infra["Infrastruktur"]
db["internal/db<br/>SQLite, Schema, Stores"]
search["internal/search<br/>FTS5-Indexer/Searcher"]
storage["internal/storage<br/>Datei-Moves, Jahresordner, Hash"]
pdf["internal/pdf<br/>pdftotext"]
ocr["internal/ocr<br/>ocrmypdf / tesseract"]
llm["internal/llm<br/>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 {
<<interface>>
+Append(ctx, aggregateID, expectedVersion, payloads) error
+Load(ctx, aggregateID) List~Event~
}
class Stage {
<<interface>>
+Name() string
+Skip(doc Document) bool
+Run(ctx, doc Document) List~Payload~
}
class Queue {
<<interface>>
+Enqueue(ctx, documentID, stage) error
+Claim(ctx, workerID) *Work
+Complete(ctx, documentID) error
+Defer(ctx, documentID, notBefore) error
}
class TextExtractor {
<<interface>>
+Extract(ctx, path) ExtractResult
}
class OCRProcessor {
<<interface>>
+Recognize(ctx, path, mediaType) OCRResult
}
class Enricher {
<<interface>>
+Enrich(ctx, text) Enrichment
}
class Indexer {
<<interface>>
+Index(ctx, doc Document) error
+Rebuild(ctx) error
}
class Filer {
<<interface>>
+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<br/>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<br/>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:<br/>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<br/>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<br/>(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:<br/>erste Stage der kanonischen Reihenfolge,<br/>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<br/>:80/:443, TLS/ACME"]
subgraph compose["/opt/documenter (Docker Compose)"]
app["documenter-api-1<br/>:9060 intern<br/>debian-slim + ocrmypdf/tesseract/poppler/libheif"]
vol1[("config/")]
vol2[("data/ — SQLite, Staging, Logs")]
vol3[("import/")]
vol4[("archive/")]
end
caddy -- "proxy-Netzwerk<br/>(Host-Header-Routing)" --> app
app --- vol1 & vol2 & vol3 & vol4
end
subgraph xps["XPS15 (NetBird-VPN, ggf. offline)"]
localai["LocalAI :8080<br/>OpenAI-kompatibel"]
end
subgraph git2["git2.adelante-it.de"]
gitea["Gitea: Repo + Registry + Actions<br/>act_runner (go-ci)"]
end
app -. "Chat-Completions<br/>(optional)" .-> localai
gitea -- "Release: Image push,<br/>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 |
+288
View File
@@ -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 }
+56
View File
@@ -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` |
+54
View File
@@ -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` |
+58
View File
@@ -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` |
+47
View File
@@ -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` |
+58
View File
@@ -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` |
+48
View File
@@ -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` |
@@ -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` |
+27
View File
@@ -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 |