From 8434c09a5559782d42231e13c6ed4532d22c4100 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Thu, 9 Jul 2026 18:05:30 -0500 Subject: [PATCH] first scripts --- .gitignore | 1 + extract_buyers_llamacpp.py | 374 ++++++++++++++++++++++++++++++++++++ extract_buyers_poc.py | 373 +++++++++++++++++++++++++++++++++++ test_connection.py | 82 ++++++++ test_connection_llamacpp.py | 82 ++++++++ 5 files changed, 912 insertions(+) create mode 100644 .gitignore create mode 100644 extract_buyers_llamacpp.py create mode 100644 extract_buyers_poc.py create mode 100644 test_connection.py create mode 100644 test_connection_llamacpp.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5797656 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +poc_out \ No newline at end of file diff --git a/extract_buyers_llamacpp.py b/extract_buyers_llamacpp.py new file mode 100644 index 0000000..733dcec --- /dev/null +++ b/extract_buyers_llamacpp.py @@ -0,0 +1,374 @@ +#!/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", "types_of_business"], +} + +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"(? 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" + + +def process_pdf(client, model, pdf_path, vision_budget): + n_pages = pdf_page_count(pdf_path) + text = extract_text(pdf_path) + is_scan = len(text.strip()) < TEXT_SCAN_THRESHOLD + + # Riesige, voll gescannte Stapel: nicht komplett verarbeiten. + # Wir schauen nur auf die erste Seite, ob ueberhaupt ein Sheet vorliegt. + truncated_note = None + if is_scan and n_pages > MAX_SCAN_PAGES_TOTAL: + truncated_note = f"grosser Scan ({n_pages} Seiten), nur erste Seite geprueft" + imgs = pdf_to_base64_images(pdf_path, max_pages=1) + elif is_scan: + imgs = pdf_to_base64_images(pdf_path, max_pages=2) + else: + imgs = None + + if is_scan: + if not imgs: + return {"_error": "Scan, aber Bildkonvertierung fehlgeschlagen", + "n_pages": n_pages}, "vision" + 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}"}}) + mode = "vision" + else: + payload = USER_TEXT_INTRO + text[:12000] + mode = "text" + + try: + raw = call_model(client, model, payload, vision_budget if mode == "vision" else None) + except Exception as e: + return {"_error": f"API: {type(e).__name__}: {e}", "n_pages": n_pages}, mode + + data = parse_json_loose(raw) + if data is None: + return {"_error": "JSON-Parse fehlgeschlagen", "_raw": (raw or "")[:400], + "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_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") + 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" 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() diff --git a/extract_buyers_poc.py b/extract_buyers_poc.py new file mode 100644 index 0000000..1bffdd9 --- /dev/null +++ b/extract_buyers_poc.py @@ -0,0 +1,373 @@ +#!/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", "types_of_business"], +} + +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"(? 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): + extra_body = {"guided_json": JSON_SCHEMA} + # Gemma-4 Vision-Budget hochsetzen fuer feine Checkbox-Erkennung + if vision_budget: + extra_body["mm_processor_kwargs"] = {"max_soft_tokens": vision_budget} + resp = client.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": content_payload}, + ], + temperature=0.0, + max_tokens=1200, + extra_body=extra_body, + ) + 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" + + +def process_pdf(client, model, pdf_path, vision_budget): + n_pages = pdf_page_count(pdf_path) + text = extract_text(pdf_path) + is_scan = len(text.strip()) < TEXT_SCAN_THRESHOLD + + # Riesige, voll gescannte Stapel: nicht komplett verarbeiten. + # Wir schauen nur auf die erste Seite, ob ueberhaupt ein Sheet vorliegt. + truncated_note = None + if is_scan and n_pages > MAX_SCAN_PAGES_TOTAL: + truncated_note = f"grosser Scan ({n_pages} Seiten), nur erste Seite geprueft" + imgs = pdf_to_base64_images(pdf_path, max_pages=1) + elif is_scan: + imgs = pdf_to_base64_images(pdf_path, max_pages=2) + else: + imgs = None + + if is_scan: + if not imgs: + return {"_error": "Scan, aber Bildkonvertierung fehlgeschlagen", + "n_pages": n_pages}, "vision" + 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}"}}) + mode = "vision" + else: + payload = USER_TEXT_INTRO + text[:12000] + mode = "text" + + try: + raw = call_model(client, model, payload, vision_budget if mode == "vision" else None) + except Exception as e: + return {"_error": f"API: {type(e).__name__}: {e}", "n_pages": n_pages}, mode + + data = parse_json_loose(raw) + if data is None: + return {"_error": "JSON-Parse fehlgeschlagen", "_raw": (raw or "")[:400], + "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="google/gemma-4-12b-it") + 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_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") + 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" 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() diff --git a/test_connection.py b/test_connection.py new file mode 100644 index 0000000..70f32ea --- /dev/null +++ b/test_connection.py @@ -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.") diff --git a/test_connection_llamacpp.py b/test_connection_llamacpp.py new file mode 100644 index 0000000..d93f477 --- /dev/null +++ b/test_connection_llamacpp.py @@ -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.")