v1.17+v1.18: Beleg-Auto-Zuordnung (Zaehlernummer/Adresse) + eigener OCR-Container (Tesseract)

This commit is contained in:
2026-10-04 13:05:55 +02:00
parent 4eab719550
commit c0cadd7451
10 changed files with 357 additions and 10 deletions
+103
View File
@@ -0,0 +1,103 @@
"""mp-ocr – OCR-Dienst für das Mieterportal (Beleg-Analyse).
Nimmt eine Datei-URL (oder Upload) entgegen, lädt den Beleg (PDF/Bild),
extrahiert den Text mit Tesseract (Deutsch) und liefert ihn zurück.
Nur intern im Docker-Netz erreichbar (mp-api ruft ihn auf).
"""
import base64
import binascii
import io
import urllib.parse
import fitz # PyMuPDF
import httpx
import pytesseract
from fastapi import FastAPI, HTTPException, UploadFile
from PIL import Image
from pydantic import BaseModel
MAX_DATEI_BYTES = 20 * 1024 * 1024 # 20 MB
DOWNLOAD_TIMEOUT = 20.0
app = FastAPI(title="mp-ocr", docs_url=None, redoc_url=None)
class Anfrage(BaseModel):
url: str
@app.get("/healthz")
def healthz():
return {"ok": True, "dienst": "mp-ocr"}
def bild_text(img: Image.Image) -> str:
return pytesseract.image_to_string(img, lang="deu").strip()
def pdf_text(daten: bytes) -> str:
teile = []
with fitz.open(stream=daten, filetype="pdf") as doc:
for seite in doc:
pix = seite.get_pixmap(dpi=200)
img = Image.open(io.BytesIO(pix.tobytes("png")))
teile.append(bild_text(img))
return "\n".join(t for t in teile if t).strip()
def datei_text(daten: bytes, dateiname: str) -> str:
if dateiname.lower().endswith(".pdf") or daten[:5] == b"%PDF-":
return pdf_text(daten)
try:
with Image.open(io.BytesIO(daten)) as img:
return bild_text(img.convert("RGB"))
except Exception:
raise HTTPException(400, "Dateiformat nicht unterstützt (PDF/JPG/PNG erwartet).")
def datenVonDataUrl(url: str) -> tuple[bytes, str]:
"""data:application/pdf;base64,…. → (bytes, name)."""
kopf, _, payload = url.partition(",")
if not payload:
raise HTTPException(400, "Leere data-URL.")
typ = kopf[5:].split(";")[0] or "application/octet-stream"
if ";base64" in kopf:
try:
return base64.b64decode(urllib.parse.unquote(payload)), "beleg-data"
except (binascii.Error, ValueError):
raise HTTPException(400, "Ungültige base64-data-URL.")
return urllib.parse.unquote_to_bytes(payload), "beleg-data"
@app.post("/ocr")
async def ocr_url(anfrage: Anfrage):
"""Beleg von einer URL herunterladen und per OCR auswerten."""
url = anfrage.url
if url.startswith("data:"):
daten, name = datenVonDataUrl(url)
if len(daten) > MAX_DATEI_BYTES:
raise HTTPException(413, "Datei größer als 20 MB.")
return {"text": datei_text(daten, name)}
if not url.startswith(("http://", "https://")):
raise HTTPException(400, "Nur http(s)- oder data-URLs erlaubt.")
try:
async with httpx.AsyncClient(timeout=DOWNLOAD_TIMEOUT, follow_redirects=True) as client:
resp = await client.get(url)
except Exception as e:
raise HTTPException(502, f"Download fehlgeschlagen: {e}")
if resp.status_code != 200:
raise HTTPException(502, f"Download: HTTP {resp.status_code}")
if len(resp.content) > MAX_DATEI_BYTES:
raise HTTPException(413, "Datei größer als 20 MB.")
name = url.split("?")[0].rsplit("/", 1)[-1] or "beleg"
return {"text": datei_text(resp.content, name)}
@app.post("/ocr/upload")
async def ocr_upload(datei: UploadFile):
"""Beleg direkt als Upload (multipart) auswerten."""
daten = await datei.read(MAX_DATEI_BYTES + 1)
if len(daten) > MAX_DATEI_BYTES:
raise HTTPException(413, "Datei größer als 20 MB.")
return {"text": datei_text(daten, datei.filename or "beleg")}