This commit is contained in:
2026-07-12 12:30:20 -05:00
parent 888c5c1543
commit 0989c06b8f
23 changed files with 3324 additions and 119 deletions

101
python/anonymize_buyers.py Normal file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""
anonymize_buyers.py - Anonymisiert PII-Felder in buyers_raw.jsonl.
Ersetzt folgende Felder durch realistische Faker-Dummydaten:
name_company, prospective_buyer, company, phone, cell, email, address
Leere Felder (null / "" / []) bleiben leer.
Konsistenz: Gleicher Originalwert -> gleicher Dummy (ueber alle Zeilen).
So bleiben Personen ueber mehrere Sheets hinweg zusammenfuehrbar, ohne
echte Namen preiszugeben.
Alle uebrigen Felder (types_of_business, dates, checkbox, background etc.)
bleiben UNVERAENDERT - die sind fuer die Analyse relevant und nicht sensibel.
Aufruf:
python3 anonymize_buyers.py ./poc_out/buyers_raw.jsonl ./poc_out/buyers_raw_anonym.jsonl
"""
import sys
import json
try:
from faker import Faker
except ImportError:
print("Faker fehlt. Installieren mit: pip3 install faker --break-system-packages")
sys.exit(1)
fake = Faker("en_US")
Faker.seed(1234) # reproduzierbar
# Zu anonymisierende Felder
PII_FIELDS = ["name_company", "prospective_buyer", "company",
"phone", "cell", "email", "address"]
def is_empty(v):
return v in (None, "", [], {})
# Konsistenz-Caches: gleicher Originalwert -> gleicher Dummy
_caches = {f: {} for f in PII_FIELDS}
def fake_value(field):
"""Erzeugt einen feldgerechten Dummy."""
if field in ("name_company", "prospective_buyer"):
return fake.name()
if field == "company":
return fake.company()
if field in ("phone", "cell"):
return fake.phone_number()
if field == "email":
return fake.email()
if field == "address":
# einzeilige Adresse
return fake.address().replace("\n", ", ")
return fake.word()
def anonymize_field(field, value):
if is_empty(value):
return value # leer bleibt leer
key = str(value).strip().lower()
cache = _caches[field]
if key not in cache:
cache[key] = fake_value(field)
return cache[key]
def main():
if len(sys.argv) < 3:
print("Aufruf: python3 anonymize_buyers.py <input.jsonl> <output.jsonl>")
sys.exit(1)
inp, outp = sys.argv[1], sys.argv[2]
n_lines = 0
n_fields_anonymized = 0
with open(inp, encoding="utf-8") as fin, open(outp, "w", encoding="utf-8") as fout:
for line in fin:
line = line.strip()
if not line:
continue
rec = json.loads(line)
for field in PII_FIELDS:
if field in rec and not is_empty(rec[field]):
rec[field] = anonymize_field(field, rec[field])
n_fields_anonymized += 1
fout.write(json.dumps(rec, ensure_ascii=False) + "\n")
n_lines += 1
print(f"Verarbeitet: {n_lines} Zeilen")
print(f"Anonymisierte Felder: {n_fields_anonymized}")
print(f"Distinkte Ersetzungen: " +
", ".join(f"{f}={len(_caches[f])}" for f in PII_FIELDS))
print(f"Ausgabe: {outp}")
if __name__ == "__main__":
main()

572
python/classify_pdfs.py Normal file
View File

@@ -0,0 +1,572 @@
#!/usr/bin/env python3
"""
extract_buyers_poc.py - POC: extrahiert Buyer-Information-Sheet-Felder aus PDFs.
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
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:
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
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)
base = re.sub(r"\b\d{6,8}\b.*$", "", base)
base = re.sub(r"\([^)]*\)", "", base)
return base.strip(" -_").lower()
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()
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...")
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")
# 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]
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}")
if __name__ == "__main__":
main()

96
python/debug_one_pdf.py Normal file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
debug_one_pdf.py - Verarbeitet EIN PDF und zeigt die ROHE Modell-Antwort,
egal ob sie als JSON parst oder nicht. Zum Debuggen einzelner Problemfaelle.
Aufruf:
python3 debug_one_pdf.py \
--api http://localhost:8000/v1 --model "gemma-4-12b" \
--pdf ~/data/S/"Scott, Dexter Notes 031226.pdf"
"""
import argparse, io, base64, json, re
from openai import OpenAI
from pypdf import PdfReader
from pdf2image import convert_from_path
# --- dieselbe Konfiguration wie im Hauptscript ---
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"]},
"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", "date_of_introduction"],
}
SYSTEM_PROMPT = """Du bist ein praezises Datenextraktions-System fuer Formulare der Firma "BizMatch Business Brokerage". Extrahiere die verlangten Felder als JSON. Fehlende Felder -> null. ERFINDE NICHTS."""
ap = argparse.ArgumentParser()
ap.add_argument("--api", default="http://localhost:8000/v1")
ap.add_argument("--model", default="gemma-4-12b")
ap.add_argument("--pdf", required=True)
ap.add_argument("--max-tokens", type=int, default=512)
args = ap.parse_args()
client = OpenAI(base_url=args.api, api_key="x", timeout=120, max_retries=0)
n_pages = len(PdfReader(args.pdf).pages)
print(f"PDF: {args.pdf}")
print(f"Seiten: {n_pages}")
# Vision-Pfad (2 Seiten, wie im Hauptscript)
imgs = convert_from_path(args.pdf, first_page=1, last_page=2, dpi=150)
payload = [{"type":"text","text":"Extrahiere die Felder aus diesem Buyer Information Sheet:"}]
for img in imgs:
w,h = img.size
scale = min(1.0, 1600/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=80)
b64 = base64.b64encode(buf.getvalue()).decode()
payload.append({"type":"image_url","image_url":{"url":f"data:image/jpeg;base64,{b64}"}})
print(f"\nSende {len(imgs)} Bild(er) an das Modell, max_tokens={args.max_tokens}...\n")
resp = client.chat.completions.create(
model=args.model,
messages=[{"role":"system","content":SYSTEM_PROMPT},
{"role":"user","content":payload}],
temperature=0.0,
max_tokens=args.max_tokens,
response_format={"type":"json_schema","json_schema":{"name":"buyer","schema":JSON_SCHEMA}},
extra_body={"chat_template_kwargs":{"enable_thinking":False}},
)
choice = resp.choices[0]
raw = choice.message.content
print("=== finish_reason ===")
print(choice.finish_reason)
print(f"\n=== ROHE ANTWORT ({len(raw or '')} Zeichen) ===")
print(repr(raw))
print("\n=== ANTWORT LESBAR ===")
print(raw)
print("\n=== PARSE-VERSUCH ===")
try:
parsed = json.loads(raw)
print("OK, parst sauber:")
print(json.dumps(parsed, indent=2, ensure_ascii=False))
except Exception as e:
print(f"FEHLER: {e}")

39
python/dump_pdfplumber.py Normal file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""
dump_pdfplumber.py - Zeigt, wie PDFPLUMBER (nicht pypdf) die Seiten ausgibt.
Wichtig: pdfplumber ordnet Text anders an als pypdf.
Aufruf:
python3 dump_pdfplumber.py --pdf ~/data/S/"Sturgill, Garett 040126.pdf"
"""
import argparse
import pdfplumber
ANCHOR_INFO = "BUYER INFORMATION SHEET"
ANCHOR_CA = "PROSPECTIVE BUYER AGREES TO KEEP AND HOLD CONFIDENTIAL"
ap = argparse.ArgumentParser()
ap.add_argument("--pdf", required=True)
args = ap.parse_args()
with pdfplumber.open(args.pdf) as pdf:
for i, page in enumerate(pdf.pages):
txt = page.extract_text() or ""
is_info = ANCHOR_INFO in txt
is_ca = ANCHOR_CA in txt.replace("\n", " ")
tag = "INFO" if is_info else ("CA" if is_ca else "andere")
if tag == "andere":
continue
print(f"\n{'='*70}")
print(f"=== SEITE {i+1} [{tag}] - extract_text() ===")
print('='*70)
for ln, line in enumerate(txt.splitlines(), 1):
print(f"{ln:3} | {line!r}")
# Zusaetzlich: Woerter mit Position, um den Werte-Block zu lokalisieren
if is_info:
print(f"\n--- Woerter unterhalb von y=550 (wo die Werte stehen sollten) ---")
words = page.extract_words()
for w in words:
if w["top"] > 550:
print(f" top={w['top']:.0f} x0={w['x0']:.0f} {w['text']!r}")

27
python/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()

36
python/dump_values.py Normal file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""
dump_values.py - Zeigt (mit pypdf) den WERTE-BLOCK der Info- und CA-Seite.
Aus der Analyse: pypdf gibt zuerst das leere Template (Labels) aus, dann
einen Block mit den eingegebenen Werten. Dieses Script isoliert genau diesen
Werte-Block, damit wir die Feld-Zuordnung bauen koennen.
Aufruf:
python3 dump_values.py --pdf ~/data/S/"Sturgill, Garett 040126.pdf"
"""
import argparse
from pypdf import PdfReader
ANCHOR_INFO = "BUYER INFORMATION SHEET"
ANCHOR_CA = "PROSPECTIVE BUYER AGREES TO KEEP AND HOLD CONFIDENTIAL"
ap = argparse.ArgumentParser()
ap.add_argument("--pdf", required=True)
args = ap.parse_args()
reader = PdfReader(args.pdf)
for i, page in enumerate(reader.pages):
txt = page.extract_text() or ""
flat = txt.replace("\n", " ")
is_info = ANCHOR_INFO in txt
is_ca = ANCHOR_CA in flat
if not (is_info or is_ca):
continue
tag = "INFO" if is_info else "CA"
print(f"\n{'='*70}")
print(f"=== SEITE {i+1} [{tag}] ===")
print('='*70)
lines = txt.splitlines()
for ln, line in enumerate(lines, 1):
print(f"{ln:3} | {line!r}")

View File

@@ -0,0 +1,476 @@
#!/usr/bin/env python3
"""
extract_buyers_poc.py - POC: extrahiert Buyer-Information-Sheet-Felder aus PDFs.
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
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:
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
import io
import sys
import json
import base64
import argparse
import glob
import re
import datetime
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"]},
"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", "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".
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.
- 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):
try:
reader = PdfReader(pdf_path)
pages = reader.pages[:max_pages]
return "\n".join(p.extract_text() or "" for p in pages)
except Exception:
return ""
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)
_PREFER_VISION = {
"prospective_buyer", "name_company", "company", "phone", "cell", "email",
"address", "state", "how_did_you_hear", "interested_in_updates",
"date_of_introduction",
}
# Felder, bei denen Text meist die sauberere (getippte) Quelle ist
_PREFER_TEXT = {"background_experience", "types_of_business", "types_of_business_raw"}
def _empty(v):
return v in (None, "", [], {})
def merge_records(text_rec, vision_rec):
"""
Fuehrt Text- und Vision-Extraktion desselben PDF zusammen.
Grundregel: nicht-leerer Wert schlaegt leeren; bei Konflikt entscheidet
die bevorzugte Quelle je Feld (Vision fuer Handschrift, Text fuer Getipptes).
"""
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
elif k in _PREFER_TEXT:
merged[k] = tv
else:
merged[k] = vv # Default: Vision
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 = extract_text(pdf_path)
has_text = len(text.strip()) >= TEXT_SCAN_THRESHOLD
# "Notes" im Dateinamen -> fuehrende Notiz-Seite ueberspringen.
# Aber nur, wenn genug Seiten da sind (sonst normal ab Seite 1).
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:
# grosser reiner Scan: nur die erste relevante Seite pruefen
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:
# reiner Scan
data = _extract_via_vision(client, model, pdf_path, max_pages=2, first_page=vpage)
mode = "vision"
else:
# Textebene vorhanden -> HYBRID: beide Pfade, dann mergen
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
data["n_pages"] = n_pages
if is_notes:
data["_notes_file"] = True
if truncated_note:
data["_note"] = truncated_note
return data, mode
# ---------------------------------------------------------------------------
# Hauptlauf
# ---------------------------------------------------------------------------
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") # --alias des llama-server
ap.add_argument("--limit", type=int, default=150)
ap.add_argument("--recursive", action="store_true",
help="auch Unterordner durchsuchen (Default: nur oberste Ebene)")
ap.add_argument("--timeout", type=float, default=60,
help="HTTP-Timeout je Anfrage in Sekunden")
ap.add_argument("--vision-budget", type=int, default=0,
help="Gemma Vision-Token-Budget (0=aus/Default 280; sonst 70/140/280/560/1120). "
"ACHTUNG: hohe Werte koennen auf manchen ROCm-Builds das Bild zerstoeren.")
args = ap.parse_args()
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")
# Timeout am Client verhindert unendliches Haengen
client = OpenAI(base_url=args.api, api_key="test-key", timeout=args.timeout, max_retries=1)
if args.recursive:
pdfs = glob.glob(os.path.join(args.src, "**", "*.pdf"), recursive=True)
else:
pdfs = glob.glob(os.path.join(args.src, "*.pdf"))
# nach (Dateiname-)Datum sortieren
pdfs = sorted(pdfs, key=sort_key)
if args.limit:
pdfs = pdfs[:args.limit]
print(f"{len(pdfs)} PDFs im POC-Umfang (sortiert nach Datum, "
f"{'rekursiv' if args.recursive else 'nur oberste Ebene'}).\n")
all_categories = {}
n_ok = n_err = n_text = n_vision = n_hybrid = n_nosheet = 0
with open(jsonl_path, "w", encoding="utf-8") as jf:
for i, pdf in enumerate(pdfs, 1):
rel = os.path.relpath(pdf, args.src)
fdate = date_from_filename(pdf)
print(f"[{i}/{len(pdfs)}] {fdate or '????-??-??'} {rel}")
try:
data, mode = process_pdf(client, args.model, pdf, args.vision_budget)
# Bei Fehler EINMAL erneut versuchen (faengt seltene
# nicht-deterministische Parse-Ausrutscher ab).
if "_error" in data:
print(f" (Fehler, ein Wiederholversuch...)")
data, mode = process_pdf(client, args.model, pdf, args.vision_budget)
except KeyboardInterrupt:
print("\nAbbruch 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,
"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
print(f" FEHLER: {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
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=== POC-Zusammenfassung ===")
print(f" verarbeitet: {n_ok + n_err}")
print(f" erfolgreich: {n_ok}")
print(f" Fehler: {n_err}")
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 (roh): {len(all_categories)}")
print(f"\n JSONL: {jsonl_path}")
print(f" Kategorien: {cats_path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,441 @@
#!/usr/bin/env python3
"""
extract_buyers_poc.py - POC: extrahiert Buyer-Information-Sheet-Felder aus PDFs.
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
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:
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
import io
import sys
import json
import base64
import argparse
import glob
import re
import datetime
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"]},
"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", "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".
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.
- 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):
try:
reader = PdfReader(pdf_path)
pages = reader.pages[:max_pages]
return "\n".join(p.extract_text() or "" for p in pages)
except Exception:
return ""
def pdf_to_base64_images(pdf_path, max_pages=2, max_dim=2200, quality=85, dpi=200):
"""
Erste max_pages Seiten -> Base64-JPEG.
Hoehere Aufloesung als zuvor (dpi=200, max_dim=2200), damit die kleinen
YES/NO-Kaestchen erkennbar bleiben. max_pages=2 reicht: Buyer-Felder
stehen auf den ersten Seiten, und es haelt die Vision-Token im Rahmen.
"""
try:
images = convert_from_path(pdf_path, first_page=1, last_page=max_pages, 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):
# llama.cpp erzwingt JSON ueber response_format mit json_schema.
# (vLLM-spezifisches guided_json / mm_processor_kwargs gibt es hier nicht.)
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": content_payload},
],
temperature=0.0,
max_tokens=1200,
response_format={
"type": "json_schema",
"json_schema": {"name": "buyer_sheet", "schema": JSON_SCHEMA},
},
)
return resp.choices[0].message.content
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)
_PREFER_VISION = {
"prospective_buyer", "name_company", "company", "phone", "cell", "email",
"address", "state", "how_did_you_hear", "interested_in_updates",
"date_of_introduction",
}
# Felder, bei denen Text meist die sauberere (getippte) Quelle ist
_PREFER_TEXT = {"background_experience", "types_of_business", "types_of_business_raw"}
def _empty(v):
return v in (None, "", [], {})
def merge_records(text_rec, vision_rec):
"""
Fuehrt Text- und Vision-Extraktion desselben PDF zusammen.
Grundregel: nicht-leerer Wert schlaegt leeren; bei Konflikt entscheidet
die bevorzugte Quelle je Feld (Vision fuer Handschrift, Text fuer Getipptes).
"""
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
elif k in _PREFER_TEXT:
merged[k] = tv
else:
merged[k] = vv # Default: Vision
return merged
def _extract_via_text(client, model, text):
payload = USER_TEXT_INTRO + text[:12000]
raw = call_model(client, model, payload)
return parse_json_loose(raw)
def _extract_via_vision(client, model, pdf_path, max_pages):
imgs = pdf_to_base64_images(pdf_path, 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 = call_model(client, model, payload)
return parse_json_loose(raw)
def process_pdf(client, model, pdf_path, vision_budget=None):
"""
Hybrid-Strategie:
- reiner Scan (keine Textebene): nur Vision
- grosser Scan (> MAX_SCAN_PAGES_TOTAL): nur erste Seite Vision
- getipptes PDF mit Textebene: Text UND Vision, dann mergen
(Text bringt getippten Background, Vision die handschriftlichen Felder)
"""
n_pages = pdf_page_count(pdf_path)
text = extract_text(pdf_path)
has_text = len(text.strip()) >= TEXT_SCAN_THRESHOLD
truncated_note = None
try:
if not has_text and n_pages > MAX_SCAN_PAGES_TOTAL:
# grosser reiner Scan: nur erste Seite pruefen
truncated_note = f"grosser Scan ({n_pages} Seiten), nur erste Seite geprueft"
data = _extract_via_vision(client, model, pdf_path, max_pages=1)
mode = "vision"
elif not has_text:
# reiner Scan
data = _extract_via_vision(client, model, pdf_path, max_pages=2)
mode = "vision"
else:
# Textebene vorhanden -> HYBRID: beide Pfade, dann mergen
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)
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
data["n_pages"] = n_pages
if truncated_note:
data["_note"] = truncated_note
return data, mode
# ---------------------------------------------------------------------------
# Hauptlauf
# ---------------------------------------------------------------------------
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") # --alias des llama-server
ap.add_argument("--limit", type=int, default=150)
ap.add_argument("--recursive", action="store_true",
help="auch Unterordner durchsuchen (Default: nur oberste Ebene)")
ap.add_argument("--timeout", type=float, default=120,
help="HTTP-Timeout je Anfrage in Sekunden")
ap.add_argument("--vision-budget", type=int, default=0,
help="Gemma Vision-Token-Budget (0=aus/Default 280; sonst 70/140/280/560/1120). "
"ACHTUNG: hohe Werte koennen auf manchen ROCm-Builds das Bild zerstoeren.")
args = ap.parse_args()
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")
# Timeout am Client verhindert unendliches Haengen
client = OpenAI(base_url=args.api, api_key="test-key", timeout=args.timeout, max_retries=1)
if args.recursive:
pdfs = glob.glob(os.path.join(args.src, "**", "*.pdf"), recursive=True)
else:
pdfs = glob.glob(os.path.join(args.src, "*.pdf"))
# nach (Dateiname-)Datum sortieren
pdfs = sorted(pdfs, key=sort_key)
if args.limit:
pdfs = pdfs[:args.limit]
print(f"{len(pdfs)} PDFs im POC-Umfang (sortiert nach Datum, "
f"{'rekursiv' if args.recursive else 'nur oberste Ebene'}).\n")
all_categories = {}
n_ok = n_err = n_text = n_vision = n_hybrid = n_nosheet = 0
with open(jsonl_path, "w", encoding="utf-8") as jf:
for i, pdf in enumerate(pdfs, 1):
rel = os.path.relpath(pdf, args.src)
fdate = date_from_filename(pdf)
print(f"[{i}/{len(pdfs)}] {fdate or '????-??-??'} {rel}")
try:
data, mode = process_pdf(client, args.model, pdf, args.vision_budget)
except KeyboardInterrupt:
print("\nAbbruch 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,
"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
print(f" FEHLER: {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
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=== POC-Zusammenfassung ===")
print(f" verarbeitet: {n_ok + n_err}")
print(f" erfolgreich: {n_ok}")
print(f" Fehler: {n_err}")
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 (roh): {len(all_categories)}")
print(f"\n JSONL: {jsonl_path}")
print(f" Kategorien: {cats_path}")
if __name__ == "__main__":
main()

103
python/inspect_poc.py Normal file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
inspect_poc.py - Wertet buyers_raw.jsonl nach dem POC-Lauf aus.
Zeigt:
- Feld-Fuellquoten (wie oft ist welches Feld nicht null?)
- Text- vs Vision-Qualitaet im Vergleich
- die Multi-Sheet-Gruppierung: welche Person hat mehrere Sheets?
- Datums-Parsing-Quote (fuer die "letzte 5 Jahre"-Abfragen kritisch)
Aufruf: python inspect_poc.py ./poc_out/buyers_raw.jsonl
"""
import sys
import json
import re
from collections import defaultdict, Counter
def person_key(rec):
"""Gruppierungsschluessel: bevorzugt echten Namen, sonst name_company."""
name = (rec.get("prospective_buyer") or rec.get("name_company") or "").strip().lower()
# "Nachname, Vorname" und "Vorname Nachname" grob angleichen
name = re.sub(r"\s+", " ", name)
return name or "(unbekannt)"
def main():
path = sys.argv[1] if len(sys.argv) > 1 else "./poc_out/buyers_raw.jsonl"
records = []
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
records.append(json.loads(line))
ok = [r for r in records if "_error" not in r]
err = [r for r in records if "_error" in r]
print(f"Datensaetze: {len(records)} (ok: {len(ok)}, Fehler: {len(err)})\n")
# Feld-Fuellquoten
fields = ["name_company", "prospective_buyer", "company", "phone", "cell",
"email", "address", "state", "how_did_you_hear",
"interested_in_updates", "types_of_business", "background_experience",
"date_of_introduction"]
print("=== Feld-Fuellquoten (nicht-null / erfolgreiche) ===")
for fld in fields:
filled = 0
for r in ok:
v = r.get(fld)
if v not in (None, "", [], {}):
filled += 1
pct = 100 * filled / len(ok) if ok else 0
print(f" {fld:<24} {filled:>4}/{len(ok)} ({pct:4.0f}%)")
# Text vs Vision
print("\n=== Text- vs Vision-Pfad ===")
for mode in ("text", "vision"):
sub = [r for r in ok if r.get("extraction_mode") == mode]
if not sub:
continue
# als grobe Qualitaetsmetrik: durchschnittliche Zahl gefuellter Felder
avg_filled = sum(
sum(1 for f in fields if r.get(f) not in (None, "", [], {})) for r in sub
) / len(sub)
print(f" {mode:<7} {len(sub):>4} Docs, im Schnitt {avg_filled:.1f}/{len(fields)} Felder gefuellt")
# Datums-Parsing
print("\n=== Date of Introduction ===")
iso = sum(1 for r in ok if isinstance(r.get("date_of_introduction"), str)
and re.match(r"\d{4}-\d{2}-\d{2}", r["date_of_introduction"]))
nonnull = sum(1 for r in ok if r.get("date_of_introduction"))
print(f" vorhanden: {nonnull}/{len(ok)} davon sauber ISO (YYYY-MM-DD): {iso}")
# Multi-Sheet-Gruppierung
print("\n=== Personen mit mehreren Sheets ===")
groups = defaultdict(list)
for r in ok:
groups[person_key(r)].append(r)
multi = {k: v for k, v in groups.items() if len(v) > 1}
print(f" distinkte Personen: {len(groups)}, davon mit >1 Sheet: {len(multi)}")
for name, recs in sorted(multi.items(), key=lambda x: -len(x[1]))[:10]:
cats = set()
dates = []
for r in recs:
cats.update(r.get("types_of_business") or [])
if r.get("date_of_introduction"):
dates.append(r["date_of_introduction"])
print(f" {name!r}: {len(recs)} Sheets | Kategorien: {sorted(cats)} | Daten: {sorted(dates)}")
# Haeufigste Kategorien
print("\n=== Top 20 Kategorien (roh) ===")
cats = Counter()
for r in ok:
for c in (r.get("types_of_business") or []):
cats[c.strip().lower()] += 1
for c, n in cats.most_common(20):
print(f" {n:>4} {c}")
if __name__ == "__main__":
main()

189
python/merge_buyers.py Normal file
View File

@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
merge_buyers.py - Fuehrt Buyer-Datensaetze auf PERSONENEBENE zusammen.
Problem: Eine Person hat oft mehrere Buyer Information Sheets (verschiedene
Daten, Text- und Notes-Varianten). Beispiel Sudduth: eine Text-Datei + eine
Notes-Scan-Datei = dieselbe Person. Oder Sahota mit 6 Dateien.
Dieses Script liest die rohe buyers_raw.jsonl (ein Datensatz je DATEI) und
erzeugt buyers_merged.jsonl (ein Datensatz je PERSON), mit:
- allen Business-Kategorien ueber alle Sheets vereinigt
- fruehestem und spaetestem Date-of-Introduction
- je Kontaktfeld dem besten (nicht-leeren) Wert
- Liste der Quelldateien zur Nachvollziehbarkeit
Die rohe Extraktion bleibt unangetastet (nachvollziehbar). Merge ist ein
separater, pruefbarer Schritt.
Aufruf:
python merge_buyers.py ./poc_out/buyers_raw.jsonl ./poc_out/buyers_merged.jsonl
"""
import sys
import json
import re
from collections import defaultdict
def _empty(v):
return v in (None, "", [], {})
def name_from_filename(source_file):
"""
Extrahiert 'Nachname, Vorname' aus dem Dateinamen als robusten Fallback.
Die Dateien folgen konsistent dem Schema 'Nachname, Vorname <datum> [Notes].pdf'.
"""
if not source_file:
return ""
base = source_file.rsplit("/", 1)[-1] # nur Dateiname
base = re.sub(r"\.pdf$", "", base, flags=re.I)
# Datum, 'Notes', Zahlen und Zusaetze abschneiden
base = re.sub(r"\b\d{6,8}\b.*$", "", base) # ab erstem Datum abschneiden
base = re.sub(r"(?i)\bnotes\b.*$", "", base)
base = base.strip(" -_")
return base
def normalize_name(rec):
"""
Personen-Schluessel. Bevorzugt den echten Namen (prospective_buyer),
faellt auf name_company zurueck, dann auf den DATEINAMEN (robust, da
konsistentes Schema). Normalisiert "Nachname, Vorname" und
"Vorname Nachname" auf eine vergleichbare Form.
"""
name = rec.get("prospective_buyer") or rec.get("name_company") or ""
if not name.strip():
# Fallback: aus Dateiname (gerade beim Text-Pfad oft noetig)
name = name_from_filename(rec.get("source_file", ""))
name = name.strip().lower()
name = re.sub(r"\s+", " ", name)
# "sudduth, henry" -> "henry sudduth" (Komma-Form angleichen)
if "," in name:
parts = [p.strip() for p in name.split(",", 1)]
if len(parts) == 2 and parts[1]:
name = f"{parts[1]} {parts[0]}"
# Satzzeichen weg
name = re.sub(r"[^\w\s]", "", name)
return name.strip()
def pick_best(values):
"""Ersten nicht-leeren Wert aus einer Liste waehlen."""
for v in values:
if not _empty(v):
return v
return None
def merge_person(records):
"""Fuehrt alle Sheets EINER Person zu einem Datensatz zusammen."""
# Kontaktfelder: bester nicht-leerer Wert (spaetere Sheets zuerst,
# da meist aktueller - wir sortieren unten nach Datum absteigend)
single_fields = ["name_company", "prospective_buyer", "company", "phone",
"cell", "email", "address", "state", "how_did_you_hear",
"background_experience"]
out = {}
for f in single_fields:
out[f] = pick_best([r.get(f) for r in records])
# interested_in_updates: wenn IRGENDEIN Sheet true/false sagt, nimm das
# (bevorzugt das neueste eindeutige)
upd = pick_best([r.get("interested_in_updates") for r in records
if r.get("interested_in_updates") is not None])
out["interested_in_updates"] = upd
# types_of_business: Vereinigung ueber alle Sheets
cats = []
for r in records:
for c in (r.get("types_of_business") or []):
c = c.strip()
if c and c not in cats:
cats.append(c)
out["types_of_business"] = cats
# Daten: alle gueltigen ISO-Daten sammeln
dates = sorted(d for d in (r.get("date_of_introduction") for r in records)
if isinstance(d, str) and re.match(r"\d{4}-\d{2}-\d{2}", d))
out["date_first_introduction"] = dates[0] if dates else None
out["date_last_introduction"] = dates[-1] if dates else None
out["all_introduction_dates"] = dates
# Nachvollziehbarkeit
out["source_files"] = [r.get("source_file") for r in records]
out["n_sheets"] = len(records)
return out
def main():
if len(sys.argv) < 3:
print("Aufruf: python merge_buyers.py <input.jsonl> <output.jsonl>")
sys.exit(1)
inp, outp = sys.argv[1], sys.argv[2]
records = []
with open(inp, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
r = json.loads(line)
# Fehler und Nicht-Sheets ueberspringen
if "_error" in r:
continue
if r.get("is_buyer_sheet") is False:
continue
records.append(r)
# nach Person gruppieren
groups = defaultdict(list)
unkeyed = []
for r in records:
key = normalize_name(r)
if key:
groups[key].append(r)
else:
unkeyed.append(r) # ohne erkennbaren Namen: einzeln behalten
merged = []
for key, recs in groups.items():
# innerhalb der Person nach Datum absteigend (neuestes zuerst)
recs_sorted = sorted(
recs,
key=lambda r: (r.get("date_of_introduction") or ""),
reverse=True,
)
m = merge_person(recs_sorted)
m["person_key"] = key
merged.append(m)
# namenlose einzeln anhaengen
for r in unkeyed:
m = merge_person([r])
m["person_key"] = "(kein Name erkannt)"
merged.append(m)
# nach neuestem Datum sortieren
merged.sort(key=lambda m: (m.get("date_last_introduction") or ""), reverse=True)
with open(outp, "w", encoding="utf-8") as f:
for m in merged:
f.write(json.dumps(m, ensure_ascii=False) + "\n")
# Statistik
multi = [m for m in merged if m["n_sheets"] > 1]
print(f"Eingelesen: {len(records)} Datensaetze (Sheets)")
print(f"Distinkte Personen: {len(merged)}")
print(f"davon mit >1 Sheet: {len(multi)}")
print(f"Ausgabe: {outp}")
if multi:
print("\nBeispiele (Personen mit mehreren Sheets):")
for m in sorted(multi, key=lambda x: -x["n_sheets"])[:8]:
print(f" {m['person_key']!r}: {m['n_sheets']} Sheets, "
f"Kategorien={m['types_of_business']}, "
f"Daten {m['date_first_introduction']}..{m['date_last_introduction']}")
if __name__ == "__main__":
main()

184
python/parse_text_pdf.py Normal file
View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""
parse_text_pdf.py - DETERMINISTISCHER Parser fuer die Text-PDFs (kein LLM).
Nutzt die feste Struktur digital ausgefuellter BizMatch-Formulare (pypdf):
- Info-Seite und CA-Seite werden ueber ANKER-Texte gefunden (positionsunabh.).
- Auf der Info-Seite folgt nach dem Template ein WERTE-BLOCK in fester
Feld-Reihenfolge.
- Zuordnung von VORNE (Name..Types, einzeilig, fix) und von HINTEN
(letzte 6 Felder: Price, DownPay, Income, Accountant, Attorney, Bank).
Dazwischen = mehrzeiliger Background.
Checkbox 'interested_in_updates' steht NICHT im Text (eingebettetes Bild)
-> null, Marker _checkbox_pending=true fuer spaeteres Crop-Vision.
Aufruf:
python3 parse_text_pdf.py --pdf ~/data/S/"Sturgill, Garett 040126.pdf" [--debug]
"""
import argparse, re, json, os
from pypdf import PdfReader
ANCHOR_INFO = "BUYER INFORMATION SHEET"
ANCHOR_CA = "PROSPECTIVE BUYER AGREES TO KEEP AND HOLD CONFIDENTIAL"
# Leer-Marker: n, na, n/a (case-insensitiv), leer
_EMPTY_RE = re.compile(r"^(n|na|n/a|n\.a\.?)$", re.IGNORECASE)
def is_empty(s):
if s is None:
return True
t = s.strip()
return (not t) or bool(_EMPTY_RE.match(t))
def clean(s):
return None if is_empty(s) else s.strip()
def find_pages(reader):
info_txt = ca_txt = None
info_pageno = ca_pageno = None
for i, page in enumerate(reader.pages):
t = page.extract_text() or ""
flat = t.replace("\n", " ")
if info_txt is None and ANCHOR_INFO in t:
info_txt, info_pageno = t, i + 1
if ca_txt is None and ANCHOR_CA in flat:
ca_txt, ca_pageno = t, i + 1
return info_txt, ca_txt, info_pageno, ca_pageno
def value_block(info_txt):
"""Zeilen des Werte-Blocks: nach 'BANK:' bis vor 'Doc ID'."""
lines = [l.rstrip() for l in info_txt.splitlines()]
bank_idx = None
for i, l in enumerate(lines):
if l.strip().upper().startswith("BANK:"):
bank_idx = i
if bank_idx is None:
return None
block = []
for l in lines[bank_idx + 1:]:
if l.strip().lower().startswith("doc id"):
break
if l.strip():
block.append(l.strip())
return block
def parse_down_payment(raw):
"""
Gibt (zahl_oder_none, rohwert) zurueck.
Eindeutige Zahl: '350000' -> '350000'; '$350,000' -> '350000';
'1.5M'/'1.5 mil' -> '1500000'; '500k' -> '500000'.
Spanne ('50-200k'), Text ('Depends on deal') -> None, Rohwert behalten.
"""
if is_empty(raw):
return None, None
s = raw.strip()
low = s.lower().replace(",", "").replace("$", "").replace(" ", "")
# Spanne (enthaelt Bindestrich zwischen Zahlen) -> nicht eindeutig
if re.search(r"\d\s*[-]\s*\d", low):
return None, s
m = re.fullmatch(r"(\d+(?:\.\d+)?)(k|m|mil|million)?", low)
if m:
num = float(m.group(1))
suffix = m.group(2)
if suffix == "k":
num *= 1_000
elif suffix in ("m", "mil", "million"):
num *= 1_000_000
return str(int(num)), s
return None, s # Text -> null, Rohwert behalten
def parse_info(info_txt, debug=False):
block = value_block(info_txt)
out = {}
if not block:
return out, None
if debug:
print(" --- Werte-Block ---")
for j, l in enumerate(block):
print(f" [{j}] {l!r}")
n = len(block)
# Von HINTEN: letzte 6 Felder
# Reihenfolge: Price, DownPay, Income, Accountant, Attorney, Bank
if n >= 6:
last6 = block[-6:]
out["total_purchase_price"] = clean(last6[0])
dp_num, dp_raw = parse_down_payment(last6[1])
out["down_payment"] = dp_num
out["down_payment_raw"] = dp_raw
# income/accountant/attorney/bank interessieren uns nicht als Zielfelder
front_end = n - 6
else:
front_end = n
# Von VORNE: feste einzeilige Felder
def g(i):
return clean(block[i]) if i < front_end else None
out["name_company"] = g(0)
# Phone-Zeile: "{PHONE} FAX CELL" bzw. mit Leer-Markern dazwischen
if 1 < front_end:
phone_line = block[1]
# grob: erstes Token=Phone, evtl. weitere; wir nehmen die Zeile roh und
# bereinigen Leer-Marker. Feinsplit spaeter falls noetig.
toks = phone_line.split()
toks = [t for t in toks if not is_empty(t)]
out["phone"] = toks[0] if toks else None
out["cell"] = toks[-1] if len(toks) > 1 else None
out["address"] = g(2)
out["email"] = g(3)
out["how_did_you_hear"] = g(4)
out["types_of_business_raw"] = g(5)
# Background = alles zwischen Index 6 und front_end (mehrzeilig)
if front_end > 6:
bg = " ".join(block[6:front_end]).strip()
out["background_experience"] = bg or None
else:
out["background_experience"] = None
return out, block
def parse_ca(ca_txt):
out = {}
lines = [l.strip() for l in ca_txt.splitlines() if l.strip()]
# Datum
for l in lines:
m = re.search(r"(\d{1,2})\s*/\s*(\d{1,2})\s*/\s*(\d{4})", l)
if m:
mm, dd, yy = m.groups()
out["date_of_introduction"] = f"{yy}-{int(mm):02d}-{int(dd):02d}"
break
# Prospective Buyer: erste Zeile nach dem Anker-Block (Zeile 1 ist der
# lange Vertragstext; Zeile 2 ist der Name)
if len(lines) >= 2 and ANCHOR_CA in lines[0].replace(" ", " "):
out["prospective_buyer"] = clean(lines[1])
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--pdf", required=True)
ap.add_argument("--debug", action="store_true")
args = ap.parse_args()
reader = PdfReader(args.pdf)
info_txt, ca_txt, info_no, ca_no = find_pages(reader)
result = {
"is_buyer_sheet": info_txt is not None,
"name_company": None, "prospective_buyer": None, "company": None,
"phone": None, "cell": None, "email": None, "address": None,
"state": None, "how_did_you_hear": None, "interested_in_updates": None,
"types_of_business_raw": None, "background_experience": None,
"total_purchase_price": None, "down_payment": None, "down_payment_raw": None,
"date_of_introduction": None,
"_checkbox_pending": True, "_parser": "deterministic",
"_info_page": info_no, "_ca_page": ca_no,
}
if info_txt:
info_fields, _ = parse_info(info_txt, debug=args.debug)
result.update(info_fields)
if ca_txt:
result.update(parse_ca(ca_txt))
print(json.dumps(result, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()

81
python/probe_checkbox.py Normal file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""
probe_checkbox.py - Untersucht, was pdfplumber an Grafik/Annotationen auf der
Info-Seite sieht, speziell rund um die YES/NO-Checkbox.
Aufruf:
python3 probe_checkbox.py --pdf ~/data/S/"Sturgill, Garett 040126.pdf"
"""
import argparse
import pdfplumber
ANCHOR_INFO = "BUYER INFORMATION SHEET"
ap = argparse.ArgumentParser()
ap.add_argument("--pdf", required=True)
args = ap.parse_args()
with pdfplumber.open(args.pdf) as pdf:
# Info-Seite per Anker finden
info_page = None
for i, page in enumerate(pdf.pages):
txt = page.extract_text() or ""
if ANCHOR_INFO in txt:
info_page = page
print(f"Info-Seite gefunden: Seite {i+1}\n")
break
if info_page is None:
print("Keine Info-Seite gefunden!")
raise SystemExit(1)
# Finde die Y-Position der Checkbox-Zeile ("INTEREST?" ... "YES" ... "NO")
words = info_page.extract_words()
yes_word = no_word = interest_word = None
for w in words:
t = w["text"].upper().strip(".:?")
if t == "YES" and yes_word is None:
yes_word = w
elif t == "NO" and no_word is None:
no_word = w
elif "INTEREST" in t and interest_word is None:
interest_word = w
print("=== Position der Schluesselwoerter ===")
for label, w in [("INTEREST", interest_word), ("YES", yes_word), ("NO", no_word)]:
if w:
print(f" {label:10} x0={w['x0']:.0f} x1={w['x1']:.0f} top={w['top']:.0f} bottom={w['bottom']:.0f}")
else:
print(f" {label:10} NICHT GEFUNDEN")
if not (yes_word and no_word):
print("\nYES/NO nicht beide gefunden - Analyse eingeschraenkt.")
# Grafik-Elemente in der Naehe der YES/NO-Zeile untersuchen
print(f"\n=== Grafik-Elemente (rects/lines/curves) auf der Info-Seite ===")
print(f" rects: {len(info_page.rects)}")
print(f" lines: {len(info_page.lines)}")
print(f" curves: {len(info_page.curves)}")
# Die Checkbox-Zeile hat ein bestimmtes 'top'. Zeige alle Rects/Lines in
# diesem Y-Bereich (kleine Quadrate = Checkboxen, Haekchen = curves/lines).
if yes_word:
y_lo = yes_word["top"] - 5
y_hi = yes_word["bottom"] + 5
print(f"\n=== Elemente im Y-Bereich der YES/NO-Zeile (top {y_lo:.0f}..{y_hi:.0f}) ===")
print(" --- Rechtecke (moegliche Checkboxen) ---")
for r in info_page.rects:
if y_lo <= r["top"] <= y_hi or y_lo <= r["bottom"] <= y_hi:
w_ = r["x1"]-r["x0"]; h_ = r["bottom"]-r["top"]
print(f" x0={r['x0']:.0f} top={r['top']:.0f} groesse={w_:.0f}x{h_:.0f}")
print(" --- Linien/Kurven (moegliche Haekchen) ---")
for el in info_page.lines + info_page.curves:
if y_lo <= el["top"] <= y_hi or y_lo <= el["bottom"] <= y_hi:
print(f" typ x0={el['x0']:.0f} x1={el['x1']:.0f} top={el['top']:.0f} bottom={el['bottom']:.0f}")
# Annotationen (Formularfelder / Widgets)?
print(f"\n=== Annotationen (Formular-Widgets) ===")
annots = info_page.annots or []
print(f" Anzahl: {len(annots)}")
for a in annots[:15]:
print(f" {a.get('data',{}).get('Subtype','?')} @ top={a.get('top',0):.0f} "
f"x0={a.get('x0',0):.0f} {str(a.get('data',{}).get('AS',''))[:30]}")

64
python/probe_coords.py Normal file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""
probe_coords.py - Zeigt die Y-Koordinaten von Labels und Werten auf der
Info-Seite, um koordinatenbasiertes Parsen zu ermoeglichen.
Idee (vom Nutzer): Jeder Wert gehoert zu dem Label, UNTER dem er steht und
UEBER dem naechsten Label. Types-Werte liegen zwischen 'TYPES OF BUSINESSES'
und 'BACKGROUND'; Background-Werte zwischen 'BACKGROUND' und 'TOTAL PURCHASE'.
Aufruf:
python3 probe_coords.py --pdf ~/data/S/"Schultz, Gunnar 022526.pdf"
"""
import argparse
import pdfplumber
ANCHOR_INFO = "BUYER INFORMATION SHEET"
# Label-Texte, deren Y-Position wir als Grenzen brauchen
LABELS = [
"NAME / COMPANY", "PHONE", "ADDRESS", "EMAIL ADDRESS",
"HOW DID YOU HEAR", "ARE YOU INTERESTED", "TYPES OF BUSINESSES",
"BACKGROUND", "TOTAL PURCHASE PRICE", "DOWN PAYMENT",
"INCOME REQUIREMENTS", "ACCOUNTANT", "ATTORNEY", "BANK",
]
ap = argparse.ArgumentParser()
ap.add_argument("--pdf", required=True)
args = ap.parse_args()
with pdfplumber.open(args.pdf) as pdf:
info_page = None
for page in pdf.pages:
if ANCHOR_INFO in (page.extract_text() or ""):
info_page = page
break
if not info_page:
print("Keine Info-Seite"); raise SystemExit(1)
# Zeilen rekonstruieren: Woerter nach 'top' gruppieren (gleiche Zeile = aehnliches top)
words = info_page.extract_words(x_tolerance=5)
# nach top sortieren
words.sort(key=lambda w: (round(w["top"]), w["x0"]))
# Zeilen bilden (Toleranz 3px)
lines = []
cur = []
cur_top = None
for w in words:
if cur_top is None or abs(w["top"] - cur_top) <= 3:
cur.append(w); cur_top = w["top"] if cur_top is None else cur_top
else:
lines.append((cur_top, cur)); cur = [w]; cur_top = w["top"]
if cur:
lines.append((cur_top, cur))
print(f"{'TOP':>6} | {'X0':>5} | TEXT")
print("-"*70)
for top, ws in lines:
text = " ".join(w["text"] for w in ws)
x0 = min(w["x0"] for w in ws)
# Markiere Label-Zeilen
is_label = any(lbl in text.upper() for lbl in LABELS)
mark = " <== LABEL" if is_label else ""
print(f"{top:6.0f} | {x0:5.0f} | {text[:60]}{mark}")

82
python/test_connection.py Normal file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""
test_connection.py - Isoliert testen, ob der vLLM-Server antwortet.
Schrittweise, damit wir sehen, WO es haengt:
1. reiner Text-Call (kein Bild, kein guided_json)
2. Text-Call MIT guided_json
3. Bild-Call ohne Extras
4. Bild-Call MIT vision-budget
Aufruf:
python test_connection.py \
--api http://192.168.100.160:8000/v1 \
--model "google/gemma-4-12b-it" \
--pdf "/mnt/bizmatch-nas/AA Buyers NDA's/Buyers NDA's A-Z/S/Snody, Troy Notes 101706.pdf"
"""
import argparse, io, base64, json, time, sys
from openai import OpenAI
from pdf2image import convert_from_path
ap = argparse.ArgumentParser()
ap.add_argument("--api", default="http://192.168.100.160:8000/v1")
ap.add_argument("--model", default="google/gemma-4-12b-it")
ap.add_argument("--pdf", required=True)
args = ap.parse_args()
client = OpenAI(base_url=args.api, api_key="test-key", timeout=60, max_retries=0)
SCHEMA = {"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"]}
def run(label, **kwargs):
print(f"\n--- {label} ---")
t0 = time.time()
try:
r = client.chat.completions.create(**kwargs)
dt = time.time() - t0
print(f"OK ({dt:.1f}s): {r.choices[0].message.content[:200]}")
return True
except Exception as e:
dt = time.time() - t0
print(f"FEHLER nach {dt:.1f}s: {type(e).__name__}: {e}")
return False
# 1. Reiner Text
run("1. Text, keine Extras",
model=args.model, max_tokens=50, temperature=0.0,
messages=[{"role":"user","content":"Antworte mit genau einem Wort: Hallo"}])
# 2. Text + guided_json
run("2. Text + guided_json",
model=args.model, max_tokens=50, temperature=0.0,
messages=[{"role":"user","content":"Gib JSON {\"ok\": true} zurueck"}],
extra_body={"guided_json": SCHEMA})
# Bild vorbereiten (nur 1. Seite, moderate Groesse)
print("\n(bereite Bild vor: 1. Seite, dpi=150)")
try:
imgs = convert_from_path(args.pdf, first_page=1, last_page=1, dpi=150)
buf = io.BytesIO(); imgs[0].convert("RGB").save(buf, format="JPEG", quality=80)
b64 = base64.b64encode(buf.getvalue()).decode()
print(f" Bildgroesse base64: {len(b64):,} Zeichen")
except Exception as e:
print(f" Bildkonvertierung fehlgeschlagen: {e}")
sys.exit(1)
img_content = [
{"type":"text","text":"Was steht oben auf dieser Seite? Ein Satz."},
{"type":"image_url","image_url":{"url":f"data:image/jpeg;base64,{b64}"}},
]
# 3. Bild ohne Extras
run("3. Bild, keine Extras",
model=args.model, max_tokens=100, temperature=0.0,
messages=[{"role":"user","content":img_content}])
# 4. Bild + vision budget
run("4. Bild + max_soft_tokens=1120",
model=args.model, max_tokens=100, temperature=0.0,
messages=[{"role":"user","content":img_content}],
extra_body={"mm_processor_kwargs":{"max_soft_tokens":1120}})
print("\nFertig. Welche Schritte OK/FEHLER waren, sagt uns die Ursache.")

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""
test_connection.py - Isoliert testen, ob der vLLM-Server antwortet.
Schrittweise, damit wir sehen, WO es haengt:
1. reiner Text-Call (kein Bild, kein guided_json)
2. Text-Call MIT guided_json
3. Bild-Call ohne Extras
4. Bild-Call MIT vision-budget
Aufruf:
python test_connection.py \
--api http://192.168.100.160:8000/v1 \
--model "google/gemma-4-12b-it" \
--pdf "/mnt/bizmatch-nas/AA Buyers NDA's/Buyers NDA's A-Z/S/Snody, Troy Notes 101706.pdf"
"""
import argparse, io, base64, json, time, sys
from openai import OpenAI
from pdf2image import convert_from_path
ap = argparse.ArgumentParser()
ap.add_argument("--api", default="http://192.168.100.160:8000/v1")
ap.add_argument("--model", default="gemma-4-12b")
ap.add_argument("--pdf", required=True)
args = ap.parse_args()
client = OpenAI(base_url=args.api, api_key="test-key", timeout=60, max_retries=0)
SCHEMA = {"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"]}
def run(label, **kwargs):
print(f"\n--- {label} ---")
t0 = time.time()
try:
r = client.chat.completions.create(**kwargs)
dt = time.time() - t0
print(f"OK ({dt:.1f}s): {r.choices[0].message.content[:200]}")
return True
except Exception as e:
dt = time.time() - t0
print(f"FEHLER nach {dt:.1f}s: {type(e).__name__}: {e}")
return False
# 1. Reiner Text
run("1. Text, keine Extras",
model=args.model, max_tokens=50, temperature=0.0,
messages=[{"role":"user","content":"Antworte mit genau einem Wort: Hallo"}])
# 2. Text + guided_json
run("2. Text + JSON-Schema (response_format)",
model=args.model, max_tokens=50, temperature=0.0,
messages=[{"role":"user","content":"Gib JSON {\"ok\": true} zurueck"}],
response_format={"type":"json_schema","json_schema":{"name":"t","schema":SCHEMA}})
# Bild vorbereiten (nur 1. Seite, moderate Groesse)
print("\n(bereite Bild vor: 1. Seite, dpi=150)")
try:
imgs = convert_from_path(args.pdf, first_page=1, last_page=1, dpi=150)
buf = io.BytesIO(); imgs[0].convert("RGB").save(buf, format="JPEG", quality=80)
b64 = base64.b64encode(buf.getvalue()).decode()
print(f" Bildgroesse base64: {len(b64):,} Zeichen")
except Exception as e:
print(f" Bildkonvertierung fehlgeschlagen: {e}")
sys.exit(1)
img_content = [
{"type":"text","text":"Was steht oben auf dieser Seite? Ein Satz."},
{"type":"image_url","image_url":{"url":f"data:image/jpeg;base64,{b64}"}},
]
# 3. Bild ohne Extras
run("3. Bild, keine Extras",
model=args.model, max_tokens=100, temperature=0.0,
messages=[{"role":"user","content":img_content}])
# 4. Bild + JSON-Schema (so wie im echten Lauf)
run("4. Bild + JSON-Schema",
model=args.model, max_tokens=200, temperature=0.0,
messages=[{"role":"user","content":img_content}],
response_format={"type":"json_schema","json_schema":{"name":"t","schema":SCHEMA}})
print("\nFertig. Welche Schritte OK/FEHLER waren, sagt uns die Ursache.")