Files
go-documenter/doc/architecture.md
T
Christoph Kroczek 6e6cb0dc8d 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>
2026-07-13 15:20:48 +02:00

595 lines
26 KiB
Markdown

# 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 |