This commit is contained in:
2026-07-11 13:09:21 -05:00
parent 55e031c23f
commit 3b49970702
2 changed files with 567 additions and 74 deletions

View File

@@ -1,106 +1,572 @@
#!/usr/bin/env python3
"""
classify_pdfs.py - Analysiert einen Ordner nach deinem neuen Workflow:
extract_buyers_poc.py - POC: extrahiert Buyer-Information-Sheet-Felder aus PDFs.
1. Gruppiert Dateien nach Person (aus Dateiname 'Nachname, Vorname')
2. Trennt Notes- von Nicht-Notes-Dateien
3. Klassifiziert jede Nicht-Notes-Datei als TEXT-PDF oder IMAGE-PDF
(hat die pypdf-Textebene genug Inhalt?)
4. Prueft bei TEXT-PDFs, ob Datum + Name tatsaechlich im Text stehen
5. Findet Personen, die NUR als Image-PDF vorliegen (Sonderbehandlung spaeter)
Verbesserte Fassung mit:
- HTTP-Timeout (kein unendliches Haengen mehr bei Problem-PDFs)
- Seitenlimit gegen riesige Scans (z.B. SALADINO mit 59 Seiten)
- hoehere Vision-Aufloesung + Gemma-Vision-Budget fuer Checkbox-Erkennung
- Guided Decoding (JSON-Schema erzwungen) mit Loose-Parser als Fallback
- Wahl zwischen nur-oberste-Ebene und rekursiv (--recursive)
- Sortierung nach (aus Dateiname geparstem) Datum fuer Nachvollziehbarkeit
Kein LLM, keine Netzwerk. Reine lokale Analyse zum Planen.
Ablauf pro PDF:
1. Text-Extraktion (pypdf). Genug Text -> Text-Pfad.
2. Zu wenig Text (Scan) -> erste Seiten als Bilder -> Vision-Pfad.
3. VLM/LLM extrahiert Zielfelder als JSON (Schema-guided).
4. Eine Zeile pro PDF -> buyers_raw.jsonl
5. Am Ende: Business-Kategorien -> kategorien_roh.txt
Zielfelder verteilen sich ueber ZWEI Seiten (Buyer Information Sheet +
Confidentiality Agreement). Fehlt eine Seite -> ihre Felder bleiben null.
Aufruf:
python3 classify_pdfs.py --src ~/data/S
python extract_buyers_poc.py \
--src "/mnt/bizmatch-nas/AA Buyers NDA's/Buyers NDA's A-Z/S" \
--out ./poc_out \
--api http://192.168.100.160:8000/v1 \
--model "google/gemma-4-12b-it" \
--limit 150
"""
import os, re, glob, argparse, sys
from collections import defaultdict
from pypdf import PdfReader
TEXT_THRESHOLD = 120 # Zeichen in der Textebene -> gilt als Text-PDF
import os
import io
import sys
import json
import base64
import argparse
import glob
import re
import datetime
from collections import defaultdict
from openai import OpenAI
from pypdf import PdfReader
from pdf2image import convert_from_path
# ---------------------------------------------------------------------------
# Zielschema (fuer Guided Decoding) + Prompt
# ---------------------------------------------------------------------------
JSON_SCHEMA = {
"type": "object",
"properties": {
"is_buyer_sheet": {"type": "boolean"},
"name_company": {"type": ["string", "null"]},
"prospective_buyer": {"type": ["string", "null"]},
"company": {"type": ["string", "null"]},
"phone": {"type": ["string", "null"]},
"cell": {"type": ["string", "null"]},
"email": {"type": ["string", "null"]},
"address": {"type": ["string", "null"]},
"state": {"type": ["string", "null"]},
"how_did_you_hear": {"type": ["string", "null"]},
"interested_in_updates": {"type": ["boolean", "null"]},
"types_of_business_raw": {"type": ["string", "null"]},
"types_of_business": {"type": "array", "items": {"type": "string"}},
"background_experience": {"type": ["string", "null"]},
"total_purchase_price": {"type": ["string", "null"]},
"down_payment": {"type": ["string", "null"]},
"date_of_introduction": {"type": ["string", "null"]},
},
"required": [
"is_buyer_sheet", "name_company", "prospective_buyer", "company",
"phone", "cell", "email", "address", "state", "how_did_you_hear",
"interested_in_updates", "types_of_business_raw", "types_of_business",
"background_experience", "total_purchase_price", "down_payment",
"date_of_introduction"
],
}
SYSTEM_PROMPT = """Du bist ein praezises Datenextraktions-System fuer Formulare der Firma "BizMatch Business Brokerage".
Es gibt zwei relevante Seiten:
1. "BUYER INFORMATION SHEET" - enthaelt: NAME/COMPANY, PHONE/FAX/CELL, ADDRESS, EMAIL, "HOW DID YOU HEAR ABOUT US", die Frage "ARE YOU INTERESTED IN UPDATES ON NEW LISTINGS" mit ZWEI KAESTCHEN direkt vor den Woertern YES und NO, "TYPES OF BUSINESSES INTERESTED IN", "BACKGROUND / BUSINESS EXPERIENCE", "TOTAL PURCHASE PRICE", "DOWN PAYMENT AVAILABLE".
2. "CONFIDENTIALITY AGREEMENT" - enthaelt: "Prospective Buyer / Broker / Contact" (der ECHTE Name der Person), "Company (if applicable)", "Date of Introduction".
Ignoriere juristischen Vertragstext, Brokerage Services, Dropbox Sign, Unterschriften und alles, was unten nicht verlangt wird. Wenn KEINE dieser beiden BizMatch-Seiten erkennbar ist, setze is_buyer_sheet=false und alle anderen Felder null bzw. [].
CHECKBOX-ANWEISUNG (sehr wichtig): Bei "ARE YOU INTERESTED IN UPDATES" stehen zwei kleine Quadrate, je eines direkt VOR "YES" und vor "NO". Untersuche genau, welches Quadrat eine Markierung enthaelt (Haken, X, Kreuz, ausgefuellt, handschriftlich). Nur YES markiert -> true. Nur NO markiert -> false. Keines klar markiert -> null.
REGELN fuer "types_of_business":
- Extrahiere NUR die Geschaefts-KATEGORIE, nicht Umsatz/Groesse/Zusatz.
- "High Volume Restaurants sales 5Mil" -> ["Restaurant"]
- "Oil field service company, trucking" -> ["Oil Field", "Trucking"]
- "any profitable business" -> ["Any"]
- Singular, knapp, englisch. Leer/unklar -> [].
REGELN allgemein:
- Fehlendes/leeres Feld -> null (bzw. [] fuer Listen).
- ERFINDE NICHTS. Nur was tatsaechlich im Dokument steht.
- Bei "date_of_introduction": nach Moeglichkeit Format YYYY-MM-DD, sonst Originaltext, sonst null.
- Bei "total_purchase_price" und "down_payment": Gib nur den reinen Zahlenwert als String zurueck, wenn eine EINDEUTIGE Zahl dasteht (z.B. "$200,000" -> "200000", "1.5M" -> "1500000", "500k" -> "500000"). Bei unklaren Angaben wie "negotiable", "TBD", "flexible" oder leer -> null. KEINE Waehrungssymbole, KEINE Kommas im Ergebnis.
- Antworte NUR mit dem JSON-Objekt.
"""
USER_TEXT_INTRO = "Hier ist das Dokument als extrahierter Text:\n\n"
USER_IMG_INTRO = ("Hier sind die ersten Seiten des gescannten Dokuments. Achte "
"besonders auf die angekreuzte YES/NO-Checkbox. Extrahiere die Felder:")
# ---------------------------------------------------------------------------
# Datum aus Dateinamen parsen (fuer Sortierung + als Datums-Fallback)
# ---------------------------------------------------------------------------
def date_from_filename(path):
"""
Sucht ein Datum im Dateinamen. Typische Muster: 120623, 030924, 071923
(MMDDYY) sowie 2024-01-01 o.ae. Gibt (datetime.date | None).
"""
name = os.path.basename(path)
# ISO zuerst
m = re.search(r"(20\d{2})[-_](\d{2})[-_](\d{2})", name)
if m:
try:
return datetime.date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
except ValueError:
pass
# 6-stellig MMDDYY (haeufigstes Muster hier)
for m in re.finditer(r"(?<!\d)(\d{2})(\d{2})(\d{2})(?!\d)", name):
mm, dd, yy = int(m.group(1)), int(m.group(2)), int(m.group(3))
year = 2000 + yy
try:
if 1 <= mm <= 12 and 1 <= dd <= 31:
return datetime.date(year, mm, dd)
except ValueError:
continue
return None
def _invert_date(iso):
"""Fuer absteigende Sortierung: Datum in einen Schluessel umkehren,
der bei aufsteigender Sortierung neueste zuerst liefert."""
# Ordinalzahl negieren -> groesseres Datum = kleinerer Schluessel
y, m, d = (int(x) for x in iso.split("-"))
return -datetime.date(y, m, d).toordinal()
def sort_key(path):
d = date_from_filename(path)
if d:
# Gruppe 0 (mit Datum), neueste zuerst
return (0, _invert_date(d.isoformat()), path)
# ohne erkanntes Datum: nach mtime (neueste zuerst), ans Ende
try:
mt = -int(os.path.getmtime(path))
except OSError:
mt = 0
return (1, mt, path)
# ---------------------------------------------------------------------------
# PDF-Handling
# ---------------------------------------------------------------------------
def pdf_page_count(path):
try:
return len(PdfReader(path).pages)
except Exception:
return -1
def extract_text(pdf_path, max_pages=4):
"""Gibt (text, error) zurueck. error != None bei beschaedigtem PDF."""
try:
reader = PdfReader(pdf_path)
pages = reader.pages[:max_pages]
txt = "\n".join(p.extract_text() or "" for p in pages)
return txt, None
except Exception as e:
return "", f"{type(e).__name__}: {e}"
def pdf_to_base64_images(pdf_path, first_page=1, max_pages=2, max_dim=1600, quality=80, dpi=150):
"""
Seiten [first_page .. first_page+max_pages-1] -> Base64-JPEG.
Moderate Aufloesung (dpi=150, max_dim=1600): reicht fuer Checkbox/Handschrift.
first_page erlaubt das Ueberspringen einer fuehrenden Seite (z.B. die
ueberfluessige handschriftliche Notiz-Seite bei "Notes"-Dateien).
"""
last_page = first_page + max_pages - 1
try:
images = convert_from_path(pdf_path, first_page=first_page, last_page=last_page, dpi=dpi)
except Exception as e:
print(f" [Bildkonvertierung-Fehler] {e}")
return []
out = []
for img in images:
w, h = img.size
scale = min(1.0, max_dim / max(w, h))
if scale < 1.0:
img = img.resize((int(w * scale), int(h * scale)))
buf = io.BytesIO()
img.convert("RGB").save(buf, format="JPEG", quality=quality)
out.append(base64.b64encode(buf.getvalue()).decode("utf-8"))
return out
# ---------------------------------------------------------------------------
# LLM-Call (mit Guided Decoding + Vision-Budget)
# ---------------------------------------------------------------------------
def call_model(client, model, content_payload, vision_budget=None, max_tokens=768):
# llama.cpp erzwingt JSON ueber response_format mit json_schema.
# WICHTIG gegen Gemma-4 "<unused49>-Flood"/Runaway:
# - enable_thinking:false per Request (zuverlaessiger als nur Server-Flag)
# - max_tokens moderat (768: genug fuer volle Datensaetze mit langem
# Background/vielen Kategorien, aber begrenzt genug gegen Runaway)
# - repeat_penalty gegen Wiederholschleifen
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": content_payload},
],
temperature=0.0,
max_tokens=max_tokens,
response_format={
"type": "json_schema",
"json_schema": {"name": "buyer_sheet", "schema": JSON_SCHEMA},
},
extra_body={
"chat_template_kwargs": {"enable_thinking": False},
"repeat_penalty": 1.05,
},
)
choice = resp.choices[0]
content = choice.message.content
# finish_reason "length" = Runaway (max_tokens erreicht) -> als Warnung markieren
if getattr(choice, "finish_reason", None) == "length":
return content, "length"
return content, "stop"
def parse_json_loose(txt):
if not txt:
return None
txt = txt.strip()
txt = re.sub(r"^```(?:json)?|```$", "", txt, flags=re.MULTILINE).strip()
start, end = txt.find("{"), txt.rfind("}")
if start == -1 or end == -1 or end < start:
return None
try:
return json.loads(txt[start:end + 1])
except json.JSONDecodeError:
return None
# Seitenlimits
TEXT_SCAN_THRESHOLD = 120 # < so viele Textzeichen -> als Scan behandeln
MAX_SCAN_PAGES_TOTAL = 12 # PDFs mit mehr Seiten gelten als "kein reines Sheet"
# Felder, bei denen wir Vision bevorzugen (handschriftlich/visuell auf dem Formular)
# TEXT-FIRST-Strategie: Bei getippten PDFs ist der Text die verlaessliche
# Quelle (Datum, Name, alles GETIPPT - bestaetigt: 96% haben Datum im Text).
# Vision verliest v.a. handschriftliche Jahreszahlen (2016 statt 2026), darum
# gewinnt bei Konflikten IMMER Text. Vision fuellt nur, was im Text leer ist.
# Ausnahme: die Checkbox interested_in_updates ist visuell -> hier gewinnt Vision.
_PREFER_VISION = {"interested_in_updates"}
_PREFER_TEXT = {
"prospective_buyer", "name_company", "company", "phone", "cell", "email",
"address", "state", "how_did_you_hear", "date_of_introduction",
"background_experience", "types_of_business", "types_of_business_raw",
"total_purchase_price", "down_payment",
}
def _empty(v):
return v in (None, "", [], {})
def merge_records(text_rec, vision_rec):
"""
Fuehrt Text- und Vision-Extraktion desselben PDF zusammen (TEXT-FIRST).
Grundregel: nicht-leerer Wert schlaegt leeren; bei Konflikt gewinnt Text,
ausser bei der visuellen Checkbox (interested_in_updates -> Vision).
"""
if text_rec is None:
return vision_rec
if vision_rec is None:
return text_rec
merged = {}
keys = set(text_rec) | set(vision_rec)
for k in keys:
tv, vv = text_rec.get(k), vision_rec.get(k)
if _empty(tv) and _empty(vv):
merged[k] = tv if k in text_rec else vv
elif _empty(tv):
merged[k] = vv
elif _empty(vv):
merged[k] = tv
else:
# beide gefuellt -> bevorzugte Quelle
if k in _PREFER_VISION:
merged[k] = vv
else:
merged[k] = tv # Default jetzt: TEXT
return merged
def _extract_via_text(client, model, text):
payload = USER_TEXT_INTRO + text[:12000]
raw, finish = call_model(client, model, payload)
data = parse_json_loose(raw)
if data is not None and finish == "length":
data["_runaway"] = True # Antwort war abgeschnitten -> unvollstaendig moeglich
return data
def _extract_via_vision(client, model, pdf_path, max_pages, first_page=1):
imgs = pdf_to_base64_images(pdf_path, first_page=first_page, max_pages=max_pages)
if not imgs:
return None
payload = [{"type": "text", "text": USER_IMG_INTRO}]
for b64 in imgs:
payload.append({"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{b64}"}})
raw, finish = call_model(client, model, payload)
data = parse_json_loose(raw)
if data is not None and finish == "length":
data["_runaway"] = True
return data
def process_pdf(client, model, pdf_path, vision_budget=None):
"""
Hybrid-Strategie mit Notes-Seitenlogik:
- "Notes"-Dateien: Seite 1 ist eine ueberfluessige handschriftliche Notiz.
Die Buyer-Felder stehen auf Seite 2 (Info Sheet) + 3 (CA mit Datum/Name).
Darum bei Notes-Dateien first_page=2.
- Normale Dateien: Info Sheet = Seite 1, CA = Seite 2. first_page=1.
- reiner Scan (keine Textebene): nur Vision
- grosser Scan (> MAX_SCAN_PAGES_TOTAL): nur erste relevante Seite pruefen
- getipptes PDF mit Textebene: Text UND Vision, dann mergen
"""
n_pages = pdf_page_count(pdf_path)
text, text_err = extract_text(pdf_path)
has_text = len(text.strip()) >= TEXT_SCAN_THRESHOLD
# Beschaedigtes PDF (EOF-Fehler o.ae.) UND kein Text UND keine Seiten:
# sauber als Fehler melden statt still zu ueberspringen.
if text_err and not has_text and n_pages <= 0:
return {"_error": f"beschaedigtes PDF: {text_err}", "n_pages": n_pages}, "error"
# "Notes" im Dateinamen -> fuehrende Notiz-Seite ueberspringen.
is_notes = "notes" in os.path.basename(pdf_path).lower()
vpage = 2 if (is_notes and n_pages >= 3) else 1
truncated_note = None
try:
if not has_text and n_pages > MAX_SCAN_PAGES_TOTAL:
truncated_note = f"grosser Scan ({n_pages} Seiten), begrenzt geprueft"
data = _extract_via_vision(client, model, pdf_path, max_pages=2, first_page=vpage)
mode = "vision"
elif not has_text:
data = _extract_via_vision(client, model, pdf_path, max_pages=2, first_page=vpage)
mode = "vision"
else:
# Textebene vorhanden -> TEXT-FIRST: Text ist die Hauptquelle.
# Vision nur ergaenzend fuer die visuelle Checkbox und leere Felder.
text_rec = _extract_via_text(client, model, text)
vision_rec = None
if n_pages <= MAX_SCAN_PAGES_TOTAL:
vision_rec = _extract_via_vision(client, model, pdf_path, max_pages=2, first_page=vpage)
data = merge_records(text_rec, vision_rec)
mode = "hybrid" if vision_rec is not None else "text"
except Exception as e:
return {"_error": f"API: {type(e).__name__}: {e}", "n_pages": n_pages}, "?"
if data is None:
return {"_error": "JSON-Parse fehlgeschlagen", "n_pages": n_pages}, mode
# State normalisieren (Texas/tx/Tx -> TX)
if "state" in data:
data["state"] = normalize_state(data.get("state"))
data["n_pages"] = n_pages
if is_notes:
data["_notes_file"] = True
if truncated_note:
data["_note"] = truncated_note
return data, mode
# US-Bundesstaaten: Voller Name -> 2-Buchstaben-Code. Fuer die Normalisierung
# von "Texas"/"Tx"/"tx" -> "TX", damit "aus welchen Staaten"-Abfragen sauber sind.
_US_STATES = {
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR",
"california": "CA", "colorado": "CO", "connecticut": "CT", "delaware": "DE",
"florida": "FL", "georgia": "GA", "hawaii": "HI", "idaho": "ID",
"illinois": "IL", "indiana": "IN", "iowa": "IA", "kansas": "KS",
"kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS",
"missouri": "MO", "montana": "MT", "nebraska": "NE", "nevada": "NV",
"new hampshire": "NH", "new jersey": "NJ", "new mexico": "NM", "new york": "NY",
"north carolina": "NC", "north dakota": "ND", "ohio": "OH", "oklahoma": "OK",
"oregon": "OR", "pennsylvania": "PA", "rhode island": "RI",
"south carolina": "SC", "south dakota": "SD", "tennessee": "TN", "texas": "TX",
"utah": "UT", "vermont": "VT", "virginia": "VA", "washington": "WA",
"west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
"district of columbia": "DC", "puerto rico": "PR",
}
_VALID_CODES = set(_US_STATES.values())
def normalize_state(value):
"""'Texas'/'tx'/'Tx' -> 'TX'. Unbekanntes bleibt unveraendert (getrimmt)."""
if not value or not isinstance(value, str):
return value
s = value.strip()
if not s:
return value
up = s.upper()
if up in _VALID_CODES: # schon ein gueltiger Code (evtl. Kleinschreibung)
return up
low = s.lower()
if low in _US_STATES: # ausgeschriebener Name
return _US_STATES[low]
return s # unbekannt -> unveraendert
def person_from_filename(path):
"""Personen-Schluessel aus 'Nachname, Vorname <datum> [Notes].pdf'."""
base = os.path.basename(path)
base = re.sub(r"\.pdf$", "", base, flags=re.I)
base = re.sub(r"(?i)\bnotes\b.*$", "", base) # ab 'Notes' abschneiden
base = re.sub(r"\b\d{6,8}\b.*$", "", base) # ab Datum abschneiden
base = re.sub(r"\([^)]*\)", "", base) # (Talis) etc. weg
base = re.sub(r"(?i)\bnotes\b.*$", "", base)
base = re.sub(r"\b\d{6,8}\b.*$", "", base)
base = re.sub(r"\([^)]*\)", "", base)
return base.strip(" -_").lower()
def get_text(path):
try:
r = PdfReader(path)
return "\n".join(p.extract_text() or "" for p in r.pages[:4])
except Exception:
return ""
def has_date(text):
# sucht MM/DD/YYYY, MM-DD-YY, etc.
return bool(re.search(r"\b\d{1,2}\s*[/.-]\s*\d{1,2}\s*[/.-]\s*\d{2,4}\b", text))
def classify_priority(files):
"""
Ordnet die Dateien einer Person nach Prioritaet:
1 = hat mindestens eine Text-PDF (Nicht-Notes mit Textebene) -> zuerst
2 = nur Image/Notes -> ans Ende
Gibt (prio, bevorzugte_datei) zurueck.
"""
non_notes = [f for f in files if "notes" not in os.path.basename(f).lower()]
# bevorzugt eine Nicht-Notes-Datei mit echter Textebene
for f in non_notes:
txt, err = extract_text(f)
if len(txt.strip()) >= TEXT_SCAN_THRESHOLD:
return 1, f
# sonst: irgendeine Nicht-Notes-Datei (Image), sonst eine Notes-Datei
if non_notes:
return 2, non_notes[0]
return 2, files[0]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--src", required=True)
ap.add_argument("--out", default="./poc_out")
ap.add_argument("--api", default="http://192.168.100.160:8000/v1")
ap.add_argument("--model", default="gemma-4-12b")
ap.add_argument("--limit", type=int, default=0, help="0 = alle")
ap.add_argument("--timeout", type=float, default=60)
ap.add_argument("--vision-budget", type=int, default=0)
ap.add_argument("--only-priority", type=int, default=0,
help="nur Prioritaet 1 (Text) oder 2 (Image) verarbeiten; 0=beide")
args = ap.parse_args()
pdfs = sorted(glob.glob(os.path.join(args.src, "*.pdf")))
print(f"{len(pdfs)} PDFs (nur oberste Ebene) gefunden. Analysiere...\n")
os.makedirs(args.out, exist_ok=True)
jsonl_path = os.path.join(args.out, "buyers_raw.jsonl")
cats_path = os.path.join(args.out, "kategorien_roh.txt")
client = OpenAI(base_url=args.api, api_key="test-key", timeout=args.timeout, max_retries=1)
# --- Planung: Dateien nach Person gruppieren, priorisieren ---
pdfs = glob.glob(os.path.join(args.src, "*.pdf")) # nur oberste Ebene
print(f"{len(pdfs)} PDFs (nur oberste Ebene). Gruppiere nach Person...")
persons = defaultdict(list)
for p in pdfs:
persons[person_from_filename(p)].append(p)
print(f"{len(persons)} distinkte Personen. Klassifiziere Prioritaet...")
total_persons = len(persons)
n_text = n_image = n_notes = 0
text_with_date = 0
only_image_persons = []
person_has_text = {}
processed = 0
total_files = len(pdfs)
for person, files in persons.items():
non_notes = [f for f in files if "notes" not in os.path.basename(f).lower()]
notes = [f for f in files if "notes" in os.path.basename(f).lower()]
n_notes += len(notes)
has_any_text = False
for f in non_notes:
txt = get_text(f)
if len(txt.strip()) >= TEXT_THRESHOLD:
n_text += 1
has_any_text = True
if has_date(txt):
text_with_date += 1
else:
n_image += 1
# Fortschritt in EINER Zeile (mit \r ueberschreibend)
processed += len(files)
name_short = person[:40]
sys.stdout.write(f"\r {processed}/{total_files} bearbeitet - {name_short:<42}")
plan = [] # (prio, person, datei)
for i, (person, files) in enumerate(persons.items(), 1):
prio, chosen = classify_priority(files)
plan.append((prio, person, chosen))
sys.stdout.write(f"\r klassifiziert {i}/{len(persons)}")
sys.stdout.flush()
sys.stdout.write("\r" + " " * 40 + "\r")
if not has_any_text:
only_image_persons.append(person)
person_has_text[person] = has_any_text
# Prioritaet 1 (Text) zuerst, dann 2 (Image). Innerhalb: alphabetisch.
plan.sort(key=lambda x: (x[0], x[1]))
if args.only_priority:
plan = [t for t in plan if t[0] == args.only_priority]
if args.limit:
plan = plan[:args.limit]
sys.stdout.write("\r" + " " * 70 + "\r") # Zeile loeschen
print("Analyse fertig.\n")
n_prio1 = sum(1 for t in plan if t[0] == 1)
n_prio2 = sum(1 for t in plan if t[0] == 2)
print(f"Verarbeite {len(plan)} Personen: {n_prio1} Text (Prio 1), {n_prio2} Image (Prio 2).\n")
# --- Verarbeitung ---
all_categories = {}
n_ok = n_err = n_text = n_vision = n_hybrid = n_nosheet = n_corrupt = 0
total = len(plan)
with open(jsonl_path, "w", encoding="utf-8") as jf:
for i, (prio, person, pdf) in enumerate(plan, 1):
rel = os.path.relpath(pdf, args.src)
# Fortschritt in EINER Zeile
sys.stdout.write(f"\r [{i}/{total}] Prio{prio} - {person[:38]:<40}")
sys.stdout.flush()
fdate = date_from_filename(pdf)
try:
data, mode = process_pdf(client, args.model, pdf, args.vision_budget)
if "_error" in data and "beschaedigt" not in data["_error"]:
data, mode = process_pdf(client, args.model, pdf, args.vision_budget)
except KeyboardInterrupt:
sys.stdout.write("\n")
print("Abbruch durch Nutzer. Bisheriges ist gesichert.")
break
except Exception as e:
data, mode = {"_error": f"unerwartet: {type(e).__name__}: {e}"}, "?"
n_text += (mode == "text"); n_vision += (mode == "vision"); n_hybrid += (mode == "hybrid")
record = {"source_file": rel, "person_key": person, "priority": prio,
"file_date": fdate.isoformat() if fdate else None,
"extraction_mode": mode, **data}
jf.write(json.dumps(record, ensure_ascii=False) + "\n")
jf.flush()
if "_error" in data:
n_err += 1
if "beschaedigt" in data["_error"]:
n_corrupt += 1
sys.stdout.write("\n")
print(f" FEHLER ({person}): {data['_error']}")
continue
n_ok += 1
if data.get("is_buyer_sheet") is False:
n_nosheet += 1
for cat in (data.get("types_of_business") or []):
key = cat.strip().lower()
if key:
all_categories[key] = all_categories.get(key, 0) + 1
sys.stdout.write("\r" + " " * 60 + "\r")
with open(cats_path, "w", encoding="utf-8") as cf:
for cat, cnt in sorted(all_categories.items(), key=lambda x: -x[1]):
cf.write(f"{cnt:4d} {cat}\n")
print("\n=== Zusammenfassung ===")
print(f" verarbeitet: {n_ok + n_err}")
print(f" erfolgreich: {n_ok}")
print(f" Fehler: {n_err}")
print(f" davon beschaedigt: {n_corrupt}")
print(f" Text-Pfad: {n_text}")
print(f" Vision-Pfad: {n_vision}")
print(f" Hybrid-Pfad: {n_hybrid}")
print(f" kein Buyer-Sheet: {n_nosheet}")
print(f" distinkte Kategorien: {len(all_categories)}")
print(f"\n JSONL: {jsonl_path}")
print(f" Kategorien: {cats_path}")
print(f"=== Analyse: {args.src} ===")
print(f"PDFs gesamt: {len(pdfs)}")
print(f"Distinkte Personen: {total_persons}")
print(f"Notes-Dateien: {n_notes}")
print()
print(f"Nicht-Notes TEXT-PDFs: {n_text}")
print(f" davon mit Datum im Text: {text_with_date} ({100*text_with_date//max(n_text,1)}%)")
print(f"Nicht-Notes IMAGE-PDFs: {n_image}")
print()
print(f"Personen NUR mit Image/Notes (Sonderbehandlung spaeter): {len(only_image_persons)}")
for p in only_image_persons[:20]:
print(f" {p}")
if len(only_image_persons) > 20:
print(f" ... und {len(only_image_persons)-20} weitere")
if __name__ == "__main__":
main()

27
dump_text.py Normal file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""
dump_text.py - Zeigt den ROH-Text eines PDFs, wie pypdf ihn extrahiert.
Zum Verstehen der Feldstruktur fuer deterministisches Parsen.
Aufruf:
python3 dump_text.py --pdf ~/data/S/"Sidhu, Manny 040126.pdf"
"""
import argparse
from pypdf import PdfReader
ap = argparse.ArgumentParser()
ap.add_argument("--pdf", required=True)
ap.add_argument("--max-pages", type=int, default=3)
args = ap.parse_args()
reader = PdfReader(args.pdf)
print(f"Seiten gesamt: {len(reader.pages)}\n")
for i, page in enumerate(reader.pages[:args.max_pages], 1):
print(f"{'='*70}")
print(f"=== SEITE {i} ===")
print('='*70)
txt = page.extract_text() or "(kein Text)"
# mit sichtbaren Zeilennummern, damit wir Struktur sehen
for ln, line in enumerate(txt.splitlines(), 1):
print(f"{ln:3} | {line}")
print()