ADR-002: Tech Stack Evaluation für DocFlow¶
Status¶
Proposed – 2026-02-11
Context¶
DocFlow benötigt konkrete Technologie-Entscheidungen für jeden Adapter-Layer der hexagonalen Architektur. Diese ADR bewertet die Optionen entlang der Kriterien Austauschbarkeit, Enterprise-Tauglichkeit und einfache Einrichtung.
1. Document Extraction (Adapter-Layer)¶
Bewertungsmatrix¶
| Technologie | Formate | Self-Hosted | Genauigkeit | Setup | Enterprise |
|---|---|---|---|---|---|
| Apache Tika (HTTP) | PDF, Office, HTML, 1000+ | ✅ | ⭐⭐⭐ | Einfach (Docker) | ✅ |
| PyMuPDF (fitz) | PDF only | ✅ | ⭐⭐⭐⭐ | Sehr einfach (pip) | ✅ |
| python-docx/openpyxl/pptx | Office only | ✅ | ⭐⭐⭐⭐ | Einfach (pip) | ✅ |
| AWS Textract | PDF, Bilder | ❌ Cloud | ⭐⭐⭐⭐⭐ | Mittel (IAM) | ✅ |
| Google Document AI | PDF, Bilder | ❌ Cloud | ⭐⭐⭐⭐⭐ | Mittel (GCP) | ✅ |
| Unstructured.io | PDF, Office, HTML | ✅ | ⭐⭐⭐⭐ | Komplex | ⚠️ Lizenz |
Empfehlung: Gestaffelter Ansatz (3 Adapter)¶
| Phase | Adapter | Begründung |
|---|---|---|
| Phase 1 (MVP) | Apache Tika (HTTP) + PyMuPDF | Tika als Allrounder (1000+ Formate via Docker-Container). PyMuPDF für performantes PDF-Parsing ohne Netzwerk-Overhead. |
| Phase 2 (Enterprise) | + AWS Textract / Google Document AI | Cloud-Adapter für Kunden mit hohen Genauigkeitsanforderungen. |
| Nicht empfohlen | Unstructured.io | Zu viel eigene Abstraktion, kollidiert mit eurer hexagonalen Architektur. Übernimmt Routing-Logik, die in eurem ExtractorRouter gehört. |
Begründung Tika als Primary: - Docker-Image (apache/tika:3.0.0) → passt zu eurem docker-compose.yml - HTTP-API → saubere Entkopplung, Health-Checkbar, im Crash kein Python-Prozess betroffen - 1000+ MIME-Types → ein Adapter deckt 90% der Formate ab - Mature (Apache Foundation), Enterprise-proven
Begründung PyMuPDF als Secondary: - 10-50x schneller als Tika für reine PDF-Extraktion - Kein externer Service nötig → ideal für den synchronen Pfad (/api/v1/documents/sync) - Native Tabellen-Erkennung und Layout-Analyse
Port Interface (aus ARCHITECTURE.md bestätigt)¶
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any
class BlockType(StrEnum):
HEADING = "heading"
PARAGRAPH = "paragraph"
TABLE = "table"
LIST = "list"
IMAGE_REF = "image_ref"
@dataclass(frozen=True)
class ContentBlock:
block_type: BlockType
content: str
level: int | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class ExtractionResult:
document_id: str
raw_text: str
structured_blocks: list[ContentBlock]
metadata: dict[str, Any]
extractor_used: str
confidence: float # 0.0 – 1.0
class ExtractorPort(ABC):
"""Outbound Port für Dokumenten-Extraktion.
Jeder Adapter implementiert genau dieses Interface.
Die Domain kennt keine konkreten Extraktoren.
"""
@abstractmethod
async def extract(self, file_data: bytes, mime_type: str) -> ExtractionResult:
"""Extrahiert Rohtext und Struktur aus einem Dokument."""
...
@abstractmethod
def supports(self, mime_type: str) -> bool:
"""Prüft ob dieser Extraktor den MIME-Type verarbeiten kann."""
...
@property
@abstractmethod
def name(self) -> str:
"""Eindeutiger Adapter-Name für Registry und Logging."""
...
Adapter-Skeleton: Tika¶
import httpx
from docflow.domain.ports.extractor import ExtractorPort, ExtractionResult
class TikaExtractorAdapter(ExtractorPort):
"""Adapter: Apache Tika via HTTP API."""
_SUPPORTED_TYPES = frozenset({
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/html",
"text/plain",
})
def __init__(self, base_url: str = "http://tika:9998", timeout: float = 120.0) -> None:
self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout)
async def extract(self, file_data: bytes, mime_type: str) -> ExtractionResult:
response = await self._client.put(
"/tika",
content=file_data,
headers={"Content-Type": mime_type, "Accept": "text/plain"},
)
response.raise_for_status()
raw_text = response.text
# ... parse structured_blocks from Tika metadata endpoint ...
return ExtractionResult(
document_id="", # Set by caller
raw_text=raw_text,
structured_blocks=[],
metadata={"source": "tika"},
extractor_used=self.name,
confidence=0.85,
)
def supports(self, mime_type: str) -> bool:
return mime_type in self._SUPPORTED_TYPES
@property
def name(self) -> str:
return "tika"
Packages¶
# pyproject.toml – Extraction dependencies
[project.optional-dependencies]
tika = ["httpx>=0.27,<1.0"]
pymupdf = ["PyMuPDF>=1.24,<2.0"]
office = [
"python-docx>=1.1,<2.0",
"openpyxl>=3.1,<4.0",
"python-pptx>=1.0,<2.0",
]
textract = ["boto3>=1.35,<2.0"]
Fallstricke¶
| Problem | Lösung |
|---|---|
| Tika-Server crasht bei korrupten PDFs | Timeout + Circuit Breaker. Health-Check via /tika GET. |
tika-python Library startet eigenen JVM-Prozess | Nicht verwenden. Direkt HTTP via httpx ansprechen. |
| PyMuPDF hat C-Extension → Build-Probleme in Alpine | python:3.12-slim (Debian-basiert) statt Alpine verwenden. |
| Große Dateien (>50 MB) via Tika blockieren | Streaming via PUT /tika mit chunked transfer. Timeout konfigurierbar. |
2. OCR¶
Bewertungsmatrix¶
| Technologie | Sprachen | Self-Hosted | Genauigkeit | Latenz | Kosten |
|---|---|---|---|---|---|
| Tesseract 5 | 100+ | ✅ | ⭐⭐⭐ | Mittel | Kostenlos |
| EasyOCR | 80+ | ✅ | ⭐⭐⭐½ | Langsam (GPU) | Kostenlos |
| PaddleOCR | 80+ | ✅ | ⭐⭐⭐⭐ | Schnell (GPU) | Kostenlos |
| Google Vision API | 100+ | ❌ Cloud | ⭐⭐⭐⭐⭐ | Schnell | $1.50/1000 S. |
| Azure Computer Vision | 100+ | ❌ Cloud | ⭐⭐⭐⭐⭐ | Schnell | $1.00/1000 S. |
Empfehlung: Tesseract (Phase 1) + Cloud-Adapter (Phase 2)¶
Phase 1: Tesseract 5 via pytesseract - Zero-Cost, Self-Hosted, Docker-freundlich (apt install tesseract-ocr) - Gut genug für maschinell erstellte Scans (>90% Accuracy) - Sprach-Packs per apt install tesseract-ocr-deu tesseract-ocr-eng
Phase 2: Google Vision API als Premium-Adapter - Für handschriftliche Dokumente, schlechte Scan-Qualität - Deutlich höhere Accuracy bei schwierigen Vorlagen - Pay-per-Use → Enterprise-Kunden tragen die Kosten
Nicht empfohlen für Phase 1: - EasyOCR: Schwere PyTorch-Dependency (>2 GB), langsam ohne GPU - PaddleOCR: Gute Accuracy, aber PaddlePaddle-Framework ist exotisch, schlechte Docs in Englisch, große Installation
Port Interface¶
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
@dataclass(frozen=True)
class OCRRegion:
"""Ein erkannter Textbereich mit Bounding Box."""
text: str
confidence: float
bbox: tuple[int, int, int, int] # x, y, width, height
@dataclass(frozen=True)
class OCRResult:
full_text: str
regions: list[OCRRegion]
language_detected: str
average_confidence: float
engine_used: str
metadata: dict[str, object] = field(default_factory=dict)
class OCRPort(ABC):
"""Outbound Port für Optical Character Recognition.
Empfängt Bilddaten (PNG/JPEG/TIFF), liefert strukturierten Text.
"""
@abstractmethod
async def recognize(
self,
image_data: bytes,
language: str = "deu",
*,
dpi: int = 300,
) -> OCRResult:
"""Führt OCR auf den übergebenen Bilddaten durch."""
...
@abstractmethod
def supported_languages(self) -> frozenset[str]:
"""Menge der unterstützten Sprachcodes (ISO 639-3)."""
...
@property
@abstractmethod
def name(self) -> str:
"""Adapter-Name für Registry und Konfiguration."""
...
Adapter-Skeleton: Tesseract¶
import asyncio
from functools import partial
from pathlib import Path
from PIL import Image
import pytesseract
from docflow.domain.ports.ocr import OCRPort, OCRResult, OCRRegion
class TesseractOCRAdapter(OCRPort):
"""Adapter: Tesseract 5 via pytesseract."""
def __init__(
self,
executable: str = "/usr/bin/tesseract",
languages: list[str] | None = None,
) -> None:
pytesseract.pytesseract.tesseract_cmd = executable
self._languages = frozenset(languages or ["deu", "eng"])
async def recognize(
self,
image_data: bytes,
language: str = "deu",
*,
dpi: int = 300,
) -> OCRResult:
# Blocking Tesseract-Call in Thread-Pool auslagern
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
None,
partial(self._run_tesseract, image_data, language, dpi),
)
return result
def _run_tesseract(self, image_data: bytes, language: str, dpi: int) -> OCRResult:
import io
image = Image.open(io.BytesIO(image_data))
# TSV-Output für Bounding Boxes + Confidence
data = pytesseract.image_to_data(
image, lang=language, output_type=pytesseract.Output.DICT,
config=f"--dpi {dpi}",
)
full_text = pytesseract.image_to_string(image, lang=language)
regions = self._parse_regions(data)
avg_conf = sum(r.confidence for r in regions) / max(len(regions), 1)
return OCRResult(
full_text=full_text.strip(),
regions=regions,
language_detected=language,
average_confidence=avg_conf,
engine_used=self.name,
)
def _parse_regions(self, data: dict) -> list[OCRRegion]:
regions: list[OCRRegion] = []
for i, text in enumerate(data["text"]):
conf = float(data["conf"][i])
if conf > 0 and text.strip():
regions.append(OCRRegion(
text=text.strip(),
confidence=conf / 100.0,
bbox=(data["left"][i], data["top"][i],
data["width"][i], data["height"][i]),
))
return regions
def supported_languages(self) -> frozenset[str]:
return self._languages
@property
def name(self) -> str:
return "tesseract"
Packages¶
[project.optional-dependencies]
tesseract = [
"pytesseract>=0.3.13,<1.0",
"Pillow>=11.0,<12.0",
]
google-vision = ["google-cloud-vision>=3.8,<4.0"]
azure-ocr = ["azure-ai-vision-imageanalysis>=1.0,<2.0"]
Fallstricke¶
| Problem | Lösung |
|---|---|
| Tesseract ist blocking (kein async) | loop.run_in_executor() — niemals direkt in async aufrufen! |
| Tesseract braucht Sprachpakete im Container | Dockerfile: RUN apt-get install -y tesseract-ocr-deu tesseract-ocr-eng |
| Schlechte OCR-Qualität bei niedrigem DPI | Image-Preprocessing: Upscale auf 300 DPI, Binarisierung, Deskew vor OCR |
| Memory-Spikes bei großen Bildern | Pillow max-pixel-Limit setzen: Image.MAX_IMAGE_PIXELS = 200_000_000 |
pytesseract gibt leise leeren String bei fehlender Sprache | supported_languages() in Router prüfen, bevor OCR gestartet wird |
3. LLM Post-Processing¶
Bewertungsmatrix¶
| Technologie | Self-Hosted | Qualität | Latenz | Kosten | Datenschutz |
|---|---|---|---|---|---|
| OpenAI GPT-4o | ❌ Cloud | ⭐⭐⭐⭐⭐ | 2-10s | $2.50/1M in | ⚠️ Daten an OpenAI |
| Anthropic Claude 3.5 | ❌ Cloud | ⭐⭐⭐⭐⭐ | 2-8s | $3.00/1M in | ⚠️ Daten an Anthropic |
| Ollama (llama3, mistral) | ✅ | ⭐⭐⭐ | 5-30s | Hardware | ✅ On-Premise |
| LangChain | N/A (Layer) | N/A | N/A | N/A | N/A |
Empfehlung: Direkte SDK-Integration (kein LangChain)¶
Phase 1 (MVP): Kein LLM – regelbasiertes Processing - CleanupProcessor und StructureProcessor reichen für 80% der Fälle - Kein API-Cost, kein Latenz-Overhead, deterministisch
Phase 3 (Intelligence): OpenAI + Ollama als zwei Adapter - OpenAI für höchste Qualität (Cloud-Kunden) - Ollama für On-Premise / DSGVO-konforme Deployments
LangChain wird NICHT empfohlen: - Zu viel Abstraktion, die mit eurem Port-System kollidiert - Eure PostProcessorPort ABC IST die Abstraktion — LangChain wäre eine redundante Zwischenschicht - Häufige Breaking Changes, komplexe Dependency-Chain - Direkte httpx/openai-Calls in Adaptern sind einfacher, testbarer, debugbarer
Port Interface¶
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class ProcessingContext:
"""Kontext für den Processor: Sprache, gewünschte Struktur, etc."""
language: str = "deu"
source_mime_type: str = "application/pdf"
extraction_confidence: float = 1.0
options: dict[str, Any] = field(default_factory=dict)
class PostProcessorPort(ABC):
"""Outbound Port für Text-Nachbearbeitung.
Wird in einer Pipeline sequenziell aufgerufen.
Jeder Processor transformiert den Text und gibt ihn weiter.
"""
@abstractmethod
async def process(self, text: str, context: ProcessingContext) -> str:
"""Verarbeitet den Text. Gibt den transformierten Text zurück."""
...
@property
@abstractmethod
def name(self) -> str:
"""Eindeutiger Name für Pipeline-Konfiguration."""
...
class LLMPort(ABC):
"""Spezialisierter Port für LLM-basierte Verarbeitung.
Erweitert PostProcessorPort um LLM-spezifische Capabilities.
Wird vom LLMPostProcessor-Adapter intern genutzt.
"""
@abstractmethod
async def complete(
self,
prompt: str,
*,
system_message: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.1,
) -> str:
"""Sendet Prompt an LLM und gibt die Antwort zurück."""
...
@abstractmethod
async def is_available(self) -> bool:
"""Health-Check: Ist das LLM erreichbar?"""
...
@property
@abstractmethod
def model_name(self) -> str:
"""Name des verwendeten Modells."""
...
Adapter-Skeleton: OpenAI¶
from openai import AsyncOpenAI
from docflow.domain.ports.post_processor import LLMPort
class OpenAILLMAdapter(LLMPort):
"""Adapter: OpenAI API (GPT-4o, GPT-4o-mini)."""
def __init__(
self,
api_key: str,
model: str = "gpt-4o",
base_url: str | None = None,
) -> None:
self._client = AsyncOpenAI(api_key=api_key, base_url=base_url)
self._model = model
async def complete(
self,
prompt: str,
*,
system_message: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.1,
) -> str:
messages: list[dict[str, str]] = []
if system_message:
messages.append({"role": "system", "content": system_message})
messages.append({"role": "user", "content": prompt})
response = await self._client.chat.completions.create(
model=self._model,
messages=messages,
max_tokens=max_tokens,
temperature=temperature,
)
return response.choices[0].message.content or ""
async def is_available(self) -> bool:
try:
await self._client.models.retrieve(self._model)
return True
except Exception:
return False
@property
def model_name(self) -> str:
return self._model
Adapter-Skeleton: Ollama¶
import httpx
from docflow.domain.ports.post_processor import LLMPort
class OllamaLLMAdapter(LLMPort):
"""Adapter: Ollama (lokale LLMs – llama3, mistral, etc.)."""
def __init__(
self,
base_url: str = "http://ollama:11434",
model: str = "llama3.1:8b",
) -> None:
self._client = httpx.AsyncClient(base_url=base_url, timeout=120.0)
self._model = model
async def complete(
self,
prompt: str,
*,
system_message: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.1,
) -> str:
payload: dict = {
"model": self._model,
"prompt": prompt,
"stream": False,
"options": {"temperature": temperature, "num_predict": max_tokens},
}
if system_message:
payload["system"] = system_message
response = await self._client.post("/api/generate", json=payload)
response.raise_for_status()
return response.json()["response"]
async def is_available(self) -> bool:
try:
resp = await self._client.get("/api/tags")
return resp.status_code == 200
except httpx.HTTPError:
return False
@property
def model_name(self) -> str:
return self._model
LLM-Processor, der den LLMPort nutzt¶
from docflow.domain.ports.post_processor import PostProcessorPort, ProcessingContext, LLMPort
class LLMPostProcessor(PostProcessorPort):
"""Nutzt ein LLM zur intelligenten Text-Strukturierung.
Kapselt den LLMPort — der LLMPostProcessor ist selbst
ein PostProcessorPort und fügt sich in die Pipeline ein.
"""
SYSTEM_PROMPT = (
"Du bist ein Dokumenten-Strukturierungs-Experte. "
"Transformiere den folgenden extrahierten Rohtext in sauberes, "
"strukturiertes Markdown. Behalte alle Informationen bei."
)
def __init__(self, llm: LLMPort) -> None:
self._llm = llm
async def process(self, text: str, context: ProcessingContext) -> str:
if not await self._llm.is_available():
# Graceful Degradation: Text unverändert durchreichen
return text
prompt = f"Sprache: {context.language}\n\nRohtext:\n{text}"
return await self._llm.complete(
prompt,
system_message=self.SYSTEM_PROMPT,
temperature=0.1,
)
@property
def name(self) -> str:
return "llm"
Packages¶
[project.optional-dependencies]
openai = ["openai>=1.55,<2.0"]
ollama = ["httpx>=0.27,<1.0"] # Direct HTTP, kein extra SDK nötig
anthropic = ["anthropic>=0.40,<1.0"]
Fallstricke¶
| Problem | Lösung |
|---|---|
| LLM-Halluzinationen verändern Dokumentinhalt | temperature=0.1, Prompt-Engineering: "Keine Informationen hinzufügen oder entfernen" |
| Token-Limits bei großen Dokumenten | Chunking-Strategie: Dokument in Blöcke aufteilen, einzeln verarbeiten |
| API-Kosten explodieren | Token-Tracking pro Request, Budget-Limits in Config, LLM als opt-in |
| Latenz (2-30s pro Call) | LLM-Processing nur als optionaler Pipeline-Schritt, nie im Sync-Pfad |
| DSGVO: Daten an Cloud-LLM | Ollama-Adapter für On-Premise, Kunden-Konfiguration welcher LLM-Provider |
| LangChain Overhead | Nicht verwenden — euer PostProcessorPort + LLMPort ist die sauberere Abstraktion |
4. Framework & Tools¶
4.1 Web Framework: FastAPI vs. Litestar¶
| Kriterium | FastAPI | Litestar |
|---|---|---|
| Maturity | ⭐⭐⭐⭐⭐ (seit 2018) | ⭐⭐⭐ (seit 2022) |
| Community | Riesig, 75k+ GitHub Stars | Klein, 5k+ Stars |
| Ecosystem | Umfangreich (Auth, CORS, etc.) | Wachsend |
| Performance | Sehr gut (Starlette) | Leicht besser (eigener Router) |
| DI System | Einfach (Depends) | Mächtiger (native DI) |
| Hiring | Einfach | Schwierig |
| OpenAPI | Automatisch | Automatisch |
Empfehlung: FastAPI - Größeres Ecosystem, einfacheres Hiring, mehr Battle-Tested - Litestar hat ein besseres DI-System, aber ihr habt bereits eure eigene AdapterRegistry - Der Performance-Unterschied ist bei I/O-bound Workloads (Extraktion) irrelevant
4.2 Validation: Pydantic v2¶
Empfehlung: Pydantic v2 — keine Alternative.
Pydantic v2 ist der Standard für: - API Request/Response Schemas (FastAPI-Integration) - Configuration (pydantic-settings für docflow.yaml + ENV) - DTOs zwischen Application- und Adapter-Layer
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings
class TikaConfig(BaseSettings):
"""Konfiguration für den Tika-Adapter. Liest aus ENV + YAML."""
url: str = Field(default="http://tika:9998", alias="TIKA_URL")
timeout_seconds: float = Field(default=120.0, gt=0)
model_config = {"env_prefix": "DOCFLOW_TIKA_"}
class ProcessDocumentRequest(BaseModel):
"""API Request Schema."""
output_format: str = Field(default="markdown", pattern=r"^(markdown|json|plaintext)$")
ocr_enabled: bool = True
language: str = Field(default="deu", min_length=2, max_length=3)
processors: list[str] = Field(default=["cleanup", "structure"])
4.3 Background Jobs: Celery vs. ARQ vs. asyncio TaskGroups¶
| Kriterium | Celery | ARQ | asyncio TaskGroups |
|---|---|---|---|
| Maturity | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ (stdlib) |
| Broker | Redis, RabbitMQ | Redis only | Keiner (in-process) |
| Horizontal Scale | ✅ Multi-Worker | ✅ Multi-Worker | ❌ Single-Process |
| Retry/DLQ | ✅ Built-in | ✅ Built-in | ❌ Manuell |
| Monitoring | Flower Dashboard | Minimal | ❌ |
| Async-native | ❌ (sync by default) | ✅ | ✅ |
| Komplexität | Hoch | Niedrig | Sehr niedrig |
Empfehlung: Gestaffelter Ansatz
| Phase | Lösung | Begründung |
|---|---|---|
| Phase 1 | asyncio TaskGroups + in-memory Queue | Kein Redis nötig, einfaches Setup, reicht für 100 Docs/min |
| Phase 4 | ARQ (oder Celery wenn RabbitMQ schon da) | Wenn horizontal skaliert werden muss |
Gegen Celery in Phase 1: - Celery ist sync-first — kollidiert mit eurer async-Pipeline - Braucht Redis/RabbitMQ → zusätzlicher Container - Overengineered für das MVP
# Phase 1: Simple async background processing
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
@dataclass
class JobQueue:
"""Einfache in-process Job Queue für Phase 1."""
_tasks: dict[str, asyncio.Task] = field(default_factory=dict)
async def enqueue(
self,
job_id: str,
coro: Awaitable,
) -> None:
task = asyncio.create_task(coro)
self._tasks[job_id] = task
def get_status(self, job_id: str) -> str:
if job_id not in self._tasks:
return "not_found"
task = self._tasks[job_id]
if task.done():
return "failed" if task.exception() else "completed"
return "processing"
4.4 Database: SQLAlchemy vs. SQLModel¶
| Kriterium | SQLAlchemy 2.0 | SQLModel |
|---|---|---|
| Maturity | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| Async Support | ✅ (asyncio extension) | ✅ (via SQLAlchemy) |
| Pydantic Integration | Manuell | ✅ Native |
| Migrations | Alembic | Alembic |
| Complex Queries | ✅ Vollständig | ⚠️ Limitiert |
| Documentation | Excellent | Dünn |
Empfehlung: SQLAlchemy 2.0 + Alembic - SQLModel ist ein dünner Wrapper um SQLAlchemy — bei Problemen fallt ihr durch auf SQLAlchemy - SQLAlchemy 2.0 hat native Type-Hints → die Pydantic-Convenience von SQLModel braucht ihr nicht - Eure Entities sind Domain-Objekte (Dataclasses), keine ORM-Modelle — das Mapping gehört in den Persistence-Adapter
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class DocumentRecord(Base):
"""Persistence Model — NICHT das Domain-Entity!"""
__tablename__ = "documents"
id: Mapped[str] = mapped_column(primary_key=True)
filename: Mapped[str]
mime_type: Mapped[str]
size_bytes: Mapped[int]
status: Mapped[str]
storage_reference: Mapped[str | None]
created_at: Mapped[datetime]
4.5 File Storage: MinIO / S3¶
Empfehlung: S3-kompatibles Interface (MinIO für Dev, AWS S3 für Prod)
from docflow.domain.ports.storage import StoragePort
class S3StorageAdapter(StoragePort):
"""Adapter: S3-kompatibler Object Storage (AWS S3, MinIO, etc.)."""
def __init__(
self,
bucket: str,
endpoint_url: str | None = None, # MinIO: "http://minio:9000"
region: str = "eu-central-1",
) -> None:
import aiobotocore.session
self._session = aiobotocore.session.get_session()
self._bucket = bucket
self._endpoint_url = endpoint_url
self._region = region
async def store(self, document_id: str, data: bytes, metadata: dict) -> str:
key = f"documents/{document_id}"
async with self._session.create_client(
"s3",
endpoint_url=self._endpoint_url,
region_name=self._region,
) as client:
await client.put_object(
Bucket=self._bucket,
Key=key,
Body=data,
Metadata={k: str(v) for k, v in metadata.items()},
)
return f"s3://{self._bucket}/{key}"
async def retrieve(self, reference: str) -> bytes:
_, _, bucket, key = reference.replace("s3://", "").split("/", 1)
# ... retrieve implementation ...
async def delete(self, reference: str) -> None:
# ... delete implementation ...
Packages¶
[project.dependencies]
fastapi = ">=0.115,<1.0"
uvicorn = {version = ">=0.32,<1.0", extras = ["standard"]}
pydantic = ">=2.10,<3.0"
pydantic-settings = ">=2.6,<3.0"
httpx = ">=0.27,<1.0"
structlog = ">=24.4,<26.0"
[project.optional-dependencies]
database = [
"sqlalchemy[asyncio]>=2.0,<3.0",
"alembic>=1.14,<2.0",
"asyncpg>=0.30,<1.0", # PostgreSQL async driver
"aiosqlite>=0.20,<1.0", # SQLite für Dev/Tests
]
s3 = ["aiobotocore>=2.15,<3.0"]
5. Python Packaging¶
Empfehlung: src-Layout + pyproject.toml + uv¶
| Tool | Empfehlung | Begründung |
|---|---|---|
| Layout | src/docflow/ | Bereits so in PROJECT_STRUCTURE.md. Verhindert accidental imports. |
| Package Manager | uv | 10-100x schneller als pip/poetry, native lockfile, stabiles CLI |
| Linting | ruff | Ersetzt flake8 + isort + Black + pyupgrade. Ein Tool, <100ms |
| Type Checking | mypy (strict) | Standard für Enterprise Python |
| Testing | pytest + pytest-asyncio | Standard, kein Grund für Alternativen |
uv vs. poetry vs. pip¶
| Kriterium | uv | poetry | pip + pip-tools |
|---|---|---|---|
| Speed | ⭐⭐⭐⭐⭐ (Rust) | ⭐⭐ | ⭐⭐⭐ |
| Lockfile | ✅ uv.lock | ✅ poetry.lock | ✅ requirements.txt |
| PEP 621 Compliance | ✅ pyproject.toml | ⚠️ Eigenes Format | ✅ |
| Workspace Support | ✅ | ❌ | ❌ |
| Venv Management | ✅ Built-in | ✅ Built-in | ❌ Manuell |
| Adoption Trend | 📈 Stark steigend | 📉 Stagnierend | Stabil |
Empfehlung: uv - Folgt PEP 621 (pyproject.toml) — kein Vendor-Lock-in - Extrem schnell (relevantier bei CI/CD mit vielen Dependencies) - Drop-in Ersatz: uv pip install, uv run, uv lock - Aktive Entwicklung (Astral, gleiche Firma wie ruff)
pyproject.toml (Vollständig)¶
[project]
name = "docflow"
version = "0.1.0"
description = "Enterprise Document Text Extraction Pipeline"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.12"
authors = [{ name = "DocFlow Team" }]
dependencies = [
"fastapi>=0.115,<1.0",
"uvicorn[standard]>=0.32,<1.0",
"pydantic>=2.10,<3.0",
"pydantic-settings>=2.6,<3.0",
"httpx>=0.27,<1.0",
"structlog>=24.4,<26.0",
"python-multipart>=0.0.18",
]
[project.optional-dependencies]
tika = ["httpx>=0.27,<1.0"]
pymupdf = ["PyMuPDF>=1.24,<2.0"]
office = [
"python-docx>=1.1,<2.0",
"openpyxl>=3.1,<4.0",
"python-pptx>=1.0,<2.0",
]
textract = ["boto3>=1.35,<2.0"]
tesseract = [
"pytesseract>=0.3.13,<1.0",
"Pillow>=11.0,<12.0",
]
google-vision = ["google-cloud-vision>=3.8,<4.0"]
azure-ocr = ["azure-ai-vision-imageanalysis>=1.0,<2.0"]
openai = ["openai>=1.55,<2.0"]
anthropic = ["anthropic>=0.40,<1.0"]
database = [
"sqlalchemy[asyncio]>=2.0,<3.0",
"alembic>=1.14,<2.0",
"asyncpg>=0.30,<1.0",
"aiosqlite>=0.20,<1.0",
]
s3 = ["aiobotocore>=2.15,<3.0"]
all = ["docflow[tika,pymupdf,tesseract,database,s3]"]
dev = [
"pytest>=8.3,<9.0",
"pytest-asyncio>=0.24,<1.0",
"pytest-cov>=6.0,<7.0",
"mypy>=1.13,<2.0",
"ruff>=0.8,<1.0",
"pre-commit>=4.0,<5.0",
"httpx>=0.27,<1.0", # Für TestClient
]
[build-system]
requires = ["hatchling>=1.25"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/docflow"]
[tool.ruff]
target-version = "py312"
src = ["src", "tests"]
line-length = 99
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"SIM", # flake8-simplify
"TCH", # flake8-type-checking
"RUF", # ruff-specific
"ASYNC",# flake8-async
]
ignore = ["E501"] # line length handled by formatter
[tool.ruff.lint.isort]
known-first-party = ["docflow"]
[tool.ruff.format]
quote-style = "double"
[tool.mypy]
python_version = "3.12"
strict = true
plugins = ["pydantic.mypy"]
warn_return_any = true
warn_unused_configs = true
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
markers = [
"integration: marks tests requiring external services (Tika, Tesseract)",
"slow: marks tests as slow (>5s)",
]
[tool.coverage.run]
source = ["src/docflow"]
omit = ["*/tests/*"]
Fallstricke Packaging¶
| Problem | Lösung |
|---|---|
import docflow funktioniert nicht im src-Layout | uv pip install -e . (editable install) oder uv run pytest |
| Optional Dependencies vergessen zu installieren | uv pip install -e ".[tika,tesseract,database]" für konkretes Deployment |
ruff und mypy widersprechen sich | ruff für Formatting/Linting, mypy nur für Types. Kein Overlap. |
| CI/CD langsam durch pip | uv cacht Dependencies aggressiv → 10x schneller |
6. Gesamtübersicht: Empfohlener Tech Stack¶
Phase 1 (MVP)¶
| Layer | Technologie | Begründung |
|---|---|---|
| Framework | FastAPI + Uvicorn | Standard, hervorragend dokumentiert |
| Validation | Pydantic v2 | Zero Alternative |
| Extraction | Tika (HTTP) + PyMuPDF | Breite Abdeckung + schnelles PDF |
| OCR | Tesseract 5 | Kostenlos, Self-Hosted |
| Post-Processing | Regex-basiert (kein LLM) | Deterministisch, schnell |
| Output | Markdown Formatter | Kernformat |
| Storage | Local Filesystem | Einfach, für Dev/Staging |
| Jobs | asyncio (in-process) | Kein Redis nötig |
| Database | SQLite (async) | Für Metadata, kein Server nötig |
| Packaging | uv + ruff + mypy | Modern, schnell |
| Container | Docker + Compose | Tika + App |
Phase 2 (Enterprise)¶
| Upgrade | Von → Nach |
|---|---|
| Storage | Local → MinIO / S3 |
| Database | SQLite → PostgreSQL (asyncpg) |
| OCR | + Google Vision API Adapter |
| Extraction | + AWS Textract Adapter |
| Auth | API-Key → OAuth2/OIDC |
Phase 3 (Intelligence)¶
| Upgrade | Von → Nach |
|---|---|
| LLM | Kein LLM → OpenAI + Ollama Adapter |
| Processing | Regex → LLM-basierte Strukturierung |
| Delivery | Sync → + Webhooks |
Phase 4 (Scale)¶
| Upgrade | Von → Nach |
|---|---|
| Jobs | asyncio → ARQ + Redis |
| Deployment | docker-compose → Kubernetes |
| API | REST → + gRPC |
Docker-Compose (Phase 1)¶
services:
docflow-api:
build:
context: .
dockerfile: docker/Dockerfile
ports:
- "8000:8000"
environment:
- DOCFLOW_TIKA_URL=http://tika:9998
- DOCFLOW_STORAGE_BACKEND=local
- DOCFLOW_LOG_LEVEL=INFO
volumes:
- docflow-data:/data/documents
depends_on:
tika:
condition: service_healthy
tika:
image: apache/tika:3.0.0
ports:
- "9998:9998"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9998/tika"]
interval: 10s
timeout: 5s
retries: 3
volumes:
docflow-data:
Dockerfile (Multi-Stage)¶
# === Build Stage ===
FROM python:3.12-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY src/ src/
RUN uv sync --frozen --no-dev
# === Runtime Stage ===
FROM python:3.12-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr \
tesseract-ocr-deu \
tesseract-ocr-eng \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/.venv .venv/
COPY --from=builder /app/src src/
COPY config/ config/
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "docflow.adapters.inbound.api.app:create_app", "--host", "0.0.0.0", "--port", "8000", "--factory"]
Consequences¶
Positive¶
- Inkrementell erweiterbar — Jede Phase baut auf der vorherigen auf, kein Rewrite
- Adapter-austauschbar — Jede Technologie-Entscheidung ist durch das Port-Interface revidierbar
- Enterprise-ready — Cloud-Adapter (Textract, Vision, OpenAI) als opt-in, nicht als Requirement
- DSGVO-konform — Self-Hosted-Defaults (Tika, Tesseract, Ollama), Cloud nur wenn konfiguriert
- Developer-freundlich —
uv run pytestläuft ohne Docker, ohne externe Services
Risiken¶
- Tika-Server ist JVM-basiert → Memory-Overhead (~512 MB), aber Container-isoliert
- Tesseract-Qualität reicht nicht für alle Scans → Cloud-OCR-Adapter für Phase 2 einplanen
- asyncio-JobQueue (Phase 1) geht bei Restart verloren → Phase 4 bringt persistente Queue