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:
@@ -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 |
|
||||
@@ -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 }
|
||||
@@ -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` |
|
||||
@@ -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` |
|
||||
@@ -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` |
|
||||
@@ -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` |
|
||||
@@ -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` |
|
||||
@@ -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` |
|
||||
@@ -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 |
|
||||
Reference in New Issue
Block a user