gfdfg
This commit is contained in:
101
python/anonymize_buyers.py
Normal file
101
python/anonymize_buyers.py
Normal 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
572
python/classify_pdfs.py
Normal 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
96
python/debug_one_pdf.py
Normal 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
39
python/dump_pdfplumber.py
Normal 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
27
python/dump_text.py
Normal 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
36
python/dump_values.py
Normal 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}")
|
||||
476
python/extract_buyers_llamacpp.py
Normal file
476
python/extract_buyers_llamacpp.py
Normal 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()
|
||||
441
python/extract_buyers_poc.py
Normal file
441
python/extract_buyers_poc.py
Normal 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
103
python/inspect_poc.py
Normal 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
189
python/merge_buyers.py
Normal 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
184
python/parse_text_pdf.py
Normal 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
81
python/probe_checkbox.py
Normal 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
64
python/probe_coords.py
Normal 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
82
python/test_connection.py
Normal 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.")
|
||||
82
python/test_connection_llamacpp.py
Normal file
82
python/test_connection_llamacpp.py
Normal 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.")
|
||||
Reference in New Issue
Block a user