"""PDF-Ingest: liest hochgeladene PDFs ein und legt sie als Pool-Artikel ab. Quellen vom Typ `pdf_document` werden in der Verwaltung angelegt (`processed_at IS NULL`). Dieser Service pollt sie, extrahiert den Text, uebersetzt nach DE+EN und schreibt EINEN Artikel (incident_id=NULL) in `articles`. Idempotent ueber `processed_at`. """ from __future__ import annotations import asyncio import json import logging import os import re from typing import Optional import aiosqlite from config import DB_PATH, CLAUDE_MODEL_FAST from agents.claude_client import call_claude logger = logging.getLogger("osint.pdf_ingest") MAX_CHARS_PER_PDF = 200_000 # harte Obergrenze, schuetzt vor riesigen Dumps TRANSLATE_INPUT_MAX = 12_000 # was wir dem LLM zum Uebersetzen geben (Cost-Control) def _extract_text_pdfplumber(path: str) -> str: import pdfplumber parts: list[str] = [] with pdfplumber.open(path) as pdf: for page in pdf.pages: t = page.extract_text() or "" if t: parts.append(t) return "\n\n".join(parts).strip() def _extract_text_ocr(path: str) -> str: """Tesseract-Fallback ueber pdf2image -> Pillow -> pytesseract.""" from pdf2image import convert_from_path import pytesseract images = convert_from_path(path, dpi=200) parts = [] for img in images: # deu+eng zusammen, damit mehrsprachige PDFs gehen t = pytesseract.image_to_string(img, lang="deu+eng") if t and t.strip(): parts.append(t.strip()) return "\n\n".join(parts).strip() def _extract_text(path: str) -> tuple[str, str]: """Gibt (text, method) zurueck. method: 'pdfplumber' oder 'ocr'.""" try: text = _extract_text_pdfplumber(path) except Exception as e: logger.warning("pdfplumber-Extraktion fehlgeschlagen fuer %s: %s", path, e) text = "" if len(text) >= 50: return text[:MAX_CHARS_PER_PDF], "pdfplumber" logger.info("PDF hat keinen Text-Layer (oder <50 Zeichen), versuche OCR: %s", path) text = _extract_text_ocr(path) return text[:MAX_CHARS_PER_PDF], "ocr" def _derive_headline(text: str, fallback: str) -> str: """Erste sinnvolle Zeile als Headline; sonst Fallback (Dateiname).""" for raw in text.splitlines(): line = raw.strip() if 5 <= len(line) <= 200: return line return fallback.strip() or "Untitled PDF" async def _translate(text: str, headline: str, target_lang: str) -> tuple[str, str]: """Uebersetzt Headline + Content nach target_lang ('de' oder 'en'). Eigene mini-Funktion (statt agents.translator), weil wir je PDF nur EIN Item haben und Headline+Content getrennt brauchen. Returnt (headline_t, content_t). Bei Fehler oder leerem Text: ('', ''). """ if not text and not headline: return "", "" lang_label = {"de": "Deutsch", "en": "Englisch"}.get(target_lang, target_lang) content_in = (text or "")[:TRANSLATE_INPUT_MAX] prompt = f"""Du bist ein praeziser Uebersetzer fuer Sachtexte. Uebersetze Headline und Inhalt nach {lang_label}. WICHTIG: - Verwende IMMER echte UTF-8-Umlaute (ae->ä, oe->ö, ue->ü, ss->ß) bei Deutsch. - Behalte Eigennamen im Original. - Wenn der Text schon auf {lang_label} ist, gib ihn (nahezu) unveraendert zurueck. - Behalte die wichtigsten Inhalte; kuerze stark auf MAX 3000 Zeichen Content. Antworte AUSSCHLIESSLICH mit einem JSON-Objekt im Format: {{"headline": "...", "content": "..."}} Keine Markdown-Codefence, keine Einleitung. HEADLINE: {headline} INHALT: {content_in} """ try: result_text, _usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST) except Exception as e: logger.warning("PDF-Translator (%s) Claude-Call fehlgeschlagen: %s", target_lang, e) return "", "" raw = result_text.strip() if raw.startswith("```"): raw = re.sub(r"^```(?:json)?\s*", "", raw) raw = re.sub(r"\s*```\s*$", "", raw).strip() try: data = json.loads(raw) except json.JSONDecodeError: m = re.search(r"\{.*\}", raw, re.DOTALL) if not m: logger.warning("PDF-Translator (%s) JSON nicht parsbar: %r", target_lang, raw[:200]) return "", "" try: data = json.loads(m.group(0)) except json.JSONDecodeError: return "", "" if not isinstance(data, dict): return "", "" return (data.get("headline") or "").strip(), (data.get("content") or "").strip() async def _process_one(db: aiosqlite.Connection, src: dict) -> None: sid = src["id"] name = src["name"] or "PDF" rel_path = src["pdf_path"] if not rel_path: logger.warning("PDF-Source #%d ohne pdf_path, ueberspringe", sid) return abs_path = rel_path if os.path.isabs(rel_path) else os.path.join( os.path.dirname(DB_PATH), rel_path ) if not os.path.exists(abs_path): logger.error("PDF-Datei fehlt fuer Source #%d: %s", sid, abs_path) # auf processed_at setzen aber Notiz hinterlegen, damit kein Endlos-Retry await db.execute( "UPDATE sources SET processed_at = CURRENT_TIMESTAMP, " "notes = COALESCE(notes,'') || ' [PDF-Datei nicht gefunden]' WHERE id = ?", (sid,), ) await db.commit() return logger.info("PDF-Ingest start: source #%d (%s)", sid, abs_path) try: text, method = await asyncio.to_thread(_extract_text, abs_path) except Exception as e: logger.exception("PDF-Extraktion fehlgeschlagen fuer #%d: %s", sid, e) await db.execute( "UPDATE sources SET processed_at = CURRENT_TIMESTAMP, " "notes = COALESCE(notes,'') || ' [PDF-Extraktion fehlgeschlagen]' WHERE id = ?", (sid,), ) await db.commit() return if not text: logger.warning("PDF #%d ergab keinen Text (auch OCR leer)", sid) await db.execute( "UPDATE sources SET processed_at = CURRENT_TIMESTAMP, " "notes = COALESCE(notes,'') || ' [PDF leer/nicht lesbar]' WHERE id = ?", (sid,), ) await db.commit() return fallback_name = re.sub(r"\.pdf$", "", os.path.basename(abs_path), flags=re.I) headline = _derive_headline(text, fallback_name) # Hochgeladene PDFs sind meist deutsch oder englisch; LLM kann das im Prompt erkennen src_lang = (src.get("language") or "").lower() or "auto" # Wir senden parallel DE + EN (de_h, de_c), (en_h, en_c) = await asyncio.gather( _translate(text, headline, "de"), _translate(text, headline, "en"), ) # Originaltext kappen, damit articles-Tabelle handhabbar bleibt content_original = text[:5000] await db.execute( """INSERT INTO articles (incident_id, headline, headline_de, headline_en, source, source_url, content_original, content_de, content_en, language, published_at, tenant_id, verification_status) VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, 'unverified')""", ( headline, de_h or None, en_h or None, name, f"pdf://{src.get('pdf_sha256') or sid}", content_original, de_c or None, en_c or None, src_lang if src_lang != "auto" else None, src.get("tenant_id"), ), ) await db.execute( "UPDATE sources SET processed_at = CURRENT_TIMESTAMP, article_count = article_count + 1, " "last_seen_at = CURRENT_TIMESTAMP WHERE id = ?", (sid,), ) await db.commit() logger.info("PDF-Ingest fertig: source #%d (%s, %d Zeichen)", sid, method, len(text)) async def run_once() -> int: """Verarbeitet alle pdf_document-Sources ohne processed_at. Returnt Anzahl. Wird vom APScheduler als interval-Job aufgerufen. Pro Tick max 5 PDFs, damit ein hochgeladener Stapel nicht einen einzelnen Lauf monopolisiert. """ async with aiosqlite.connect(DB_PATH) as db: db.row_factory = aiosqlite.Row cursor = await db.execute( "SELECT id, name, pdf_path, pdf_sha256, language, tenant_id " "FROM sources WHERE source_type = 'pdf_document' AND processed_at IS NULL " "ORDER BY created_at ASC LIMIT 5" ) rows = [dict(r) for r in await cursor.fetchall()] for src in rows: try: await _process_one(db, src) except Exception: logger.exception("PDF-Ingest unerwarteter Fehler bei source #%d", src["id"]) return len(rows)