changes
This commit is contained in:
@@ -67,7 +67,12 @@ JSON_SCHEMA = {
|
||||
"background_experience": {"type": ["string", "null"]},
|
||||
"date_of_introduction": {"type": ["string", "null"]},
|
||||
},
|
||||
"required": ["is_buyer_sheet", "types_of_business"],
|
||||
"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".
|
||||
@@ -198,10 +203,8 @@ def pdf_to_base64_images(pdf_path, max_pages=2, max_dim=2200, quality=85, dpi=20
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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}
|
||||
# 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=[
|
||||
@@ -210,7 +213,10 @@ def call_model(client, model, content_payload, vision_budget=None):
|
||||
],
|
||||
temperature=0.0,
|
||||
max_tokens=1200,
|
||||
extra_body=extra_body,
|
||||
response_format={
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "buyer_sheet", "schema": JSON_SCHEMA},
|
||||
},
|
||||
)
|
||||
return resp.choices[0].message.content
|
||||
|
||||
@@ -233,45 +239,105 @@ def parse_json_loose(txt):
|
||||
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 process_pdf(client, model, pdf_path, vision_budget):
|
||||
|
||||
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)
|
||||
is_scan = len(text.strip()) < TEXT_SCAN_THRESHOLD
|
||||
has_text = 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)
|
||||
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}, mode
|
||||
return {"_error": f"API: {type(e).__name__}: {e}", "n_pages": n_pages}, "?"
|
||||
|
||||
data = parse_json_loose(raw)
|
||||
if data is None:
|
||||
return {"_error": "JSON-Parse fehlgeschlagen", "_raw": (raw or "")[:400],
|
||||
"n_pages": n_pages}, mode
|
||||
return {"_error": "JSON-Parse fehlgeschlagen", "n_pages": n_pages}, mode
|
||||
data["n_pages"] = n_pages
|
||||
if truncated_note:
|
||||
data["_note"] = truncated_note
|
||||
@@ -287,7 +353,7 @@ def main():
|
||||
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("--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)")
|
||||
@@ -318,7 +384,7 @@ def main():
|
||||
f"{'rekursiv' if args.recursive else 'nur oberste Ebene'}).\n")
|
||||
|
||||
all_categories = {}
|
||||
n_ok = n_err = n_text = n_vision = n_nosheet = 0
|
||||
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):
|
||||
@@ -335,6 +401,7 @@ def main():
|
||||
|
||||
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}
|
||||
@@ -363,6 +430,7 @@ def main():
|
||||
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}")
|
||||
@@ -370,4 +438,4 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
Reference in New Issue
Block a user