Add Docling corpus pipeline, retrieval evaluation and hybrid search with reranking

- prepare_corpus.py: split double-page scans (split_spreads.py), re-OCR with
  OCRmyPDF/Tesseract only where the existing text layer is worse, Docling
  layout extraction with heading hierarchy, QA report per PDF
- docling_loader.py: cleanup (margin noise, split paragraphs, footnote marks,
  OCR comma/period repair), page mapping back to the original PDF, chunk
  headers with author, title and section
- local_rag.py: bge-m3 embeddings, configurable chunking and loader, index
  settings check (index_info.json), hybrid/union BM25 search with Reciprocal
  Rank Fusion and bge-reranker-v2-m3 cross-encoder; query instruction text
  removed after it lowered recall
- eval_retrieval.py: CLI options, same retrieval path as the chat, tolerant
  file name matching; goldsets and evaluation reports for the Kafka corpus
- grobid_test.py, grobid_qa.py, extract_docling.py, compare_extractors.py:
  extractor tests and quality checks
- .gitignore: corpus PDFs, vector stores and extractor output stay local
- server.py: uses ./vectorstore_docling_hdr

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 16:48:12 +02:00
co-authored by Claude Opus 5.5
parent 7d70169ee3
commit d54b3e5b8b
49 changed files with 5007 additions and 75766 deletions
+777
View File
@@ -0,0 +1,777 @@
"""
Qualitätsprüfung der GROBID-Ausgabe, ohne Goldstandard.
Vergleicht für jedes PDF den GROBID-Output (TEI-XML) mit dem Rohtext, den pypdf
aus derselben Datei liest, und sucht gezielt nach den Fehlern, die für RAG
schaden:
- Textverlust: Passagen, die im PDF stehen, im TEI aber nirgends auftauchen.
Verlorene Passagen werden sortiert: Vermerke/Metadaten, Tabellen,
Literaturangaben und pypdf-Lesefehler sind unkritisch und zählen
nicht in die Ampel — nur verlorener Fließtext.
- Verunreinigung: Lizenz-/Download-Vermerke, Kolumnentitel, Literaturangaben und
Fragmente im Fließtext
- Doppelte Absätze
- Fehlklassierung: Fließtext, der als Abbildung/Tabelle/Notiz einsortiert wurde
- Absatzerkennung: Fließtext in Riesenabsätzen (ganze Seiten oder Kapitel am Stück)
- Zerrissene Sätze, Trennstrich-Reste, Kodierungsfehler, OCR-Fehler,
Fußnotenzeichen im Text (am Wort klebend oder abgesetzt)
- Lesereihenfolge: stimmt die Absatzfolge mit der Reihenfolge im PDF überein?
- Fehlender Textlayer (Scans ohne OCR — dann kann GROBID nichts extrahieren)
Die Ampel-Schwellen unten sind Startwerte, kalibriert an drei englischsprachigen
Zeitschriftenartikeln und dem Kafka-Korpus (deutsche Monografien, OCR-Scans).
Wo pypdf selbst keinen brauchbaren Text liefert (Sperrsatz, verschluckte
Leerzeichen), werden die Seiten aus dem Abgleich ausgenommen; ist das bei zu
vielen Seiten der Fall, bleibt die Verlustmessung leer statt falsch.
Aufruf (in der Projektumgebung, weil pypdf gebraucht wird):
conda activate local_rag
python grobid_qa.py # PDFs aus data_test/
python grobid_qa.py data # PDFs aus einem anderen Ordner
Ausgabe: Übersicht im Terminal, grobid_out/<ordner>/qa/summary.csv (eine Zeile
pro PDF, für Vergleiche über die Zeit) und grobid_out/<ordner>/qa/<pdf>.md mit
Belegstellen. Bereits vorhandenes TEI wird wiederverwendet.
"""
import csv
import random
import re
import sys
import time
import unicodedata
import xml.etree.ElementTree as ET
from collections import Counter, defaultdict
from dataclasses import dataclass
from pathlib import Path
from pypdf import PdfReader
from grobid_test import GROBID_URL, process_pdf
# ---------- Einstellungen ----------
PDF_DIR = Path("data_test") # oder als Argument: python grobid_qa.py data
OUT_ROOT = Path("grobid_out") # TEI landet in grobid_out/<pdf-ordner>/, Berichte in .../qa/
REPROCESS = False # True: TEI immer neu von GROBID holen, auch wenn vorhanden
MAX_EXAMPLES = 8 # Belegstellen pro Kategorie im Bericht
SAMPLE_PARAGRAPHS = 5 # zufällige Fließtextabsätze zum Gegenlesen
SHINGLE = 5 # Wortfolgen dieser Länge dienen zum Abgleich PDF <-> TEI
MIN_LOST_RUN = 20 # ab so vielen Wörtern am Stück gilt eine Passage als verloren
GIANT_PARA = 3000 # Absätze über so vielen Zeichen gelten als nicht segmentiert
MAX_UNUSABLE_REF = 30 # ist pypdf auf mehr als so viel % der Seiten unlesbar, keine Verlustmessung
# Ampel: (gelb ab, rot ab). Bei "order" und "body_share" ist kleiner schlechter.
THRESHOLDS = {
"scan_pages_pct": (1, 10), # Seiten ohne nennenswerten Textlayer, %
"lost_pct": (2, 5), # Fließtext-Wörter des PDFs, die im TEI fehlen, %
"contam_per_100k": (2, 8), # Fremdtext-Funde je 100.000 Zeichen Fließtext
"duplicate_pct": (0.5, 2), # doppelt vorkommende Fließtextabsätze, %
"misfiled_pct": (1, 5), # Fließtext, der in Abbildungen/Notizen steckt, %
"giant_para_pct": (10, 30), # Fließtext in Riesenabsätzen (> GIANT_PARA Zeichen), %
"broken_per_100p": (2, 6), # zerrissene Sätze je 100 Absätze
"hyphen_per_10k": (2, 10), # Trennstrich-Reste je 10.000 Wörter
"encoding_per_10k": (0.2, 3), # Kodierungsfehler je 10.000 Zeichen
"fnmark_per_10k": (2, 10), # Fußnotenzeichen im Fließtext je 10.000 Wörter
"ocr_per_10k": (5, 20), # OCR-Fehlerbilder je 10.000 Wörter
"order": (0.97, 0.90), # Anteil korrekt geordneter Absatzpaare
}
LOWER_IS_WORSE = {"order"}
# Zwei getrennte Urteile: "GROBID" bewertet, was GROBID aus dem PDF macht;
# "PDF" bewertet Mängel der Vorlage, die kein Extraktor beheben kann (Scans ohne
# Textlayer, falsch kodierte Schriften, schlechte OCR). Kodierungs- und OCR-Fehler
# zählen nur dann zum PDF, wenn sie schon im pypdf-Rohtext stehen — sonst hat
# GROBID sie verursacht.
PDF_METRICS = {"scan_pages_pct"}
# ---------- TEI lesen ----------
NS = {"t": "http://www.tei-c.org/ns/1.0"}
T = "{http://www.tei-c.org/ns/1.0}"
@dataclass
class Segment:
kind: str # body, head, header, footnote, body_note, figure, table, back, reference
text: str
page: int | None
def text_of(el) -> str:
return " ".join("".join(el.itertext()).split())
def first_page(el) -> int | None:
coords = el.get("coords")
if not coords:
return None
try:
return int(coords.split(",")[0])
except ValueError:
return None
def read_tei(path: Path) -> tuple[list[Segment], dict]:
root = ET.parse(path).getroot()
segs: list[Segment] = []
info = {}
title = root.find(".//t:titleStmt/t:title", NS)
info["title"] = text_of(title) if title is not None else ""
info["authors"] = len(root.findall(".//t:sourceDesc//t:author/t:persName", NS))
for el in root.findall(".//t:teiHeader//t:title", NS):
segs.append(Segment("header", text_of(el), None))
for el in root.findall(".//t:profileDesc/t:abstract//t:p", NS):
segs.append(Segment("header", text_of(el), first_page(el)))
for el in root.findall(".//t:profileDesc/t:textClass//t:term", NS):
segs.append(Segment("header", text_of(el), None))
abstract = root.find(".//t:profileDesc/t:abstract", NS)
info["abstract_chars"] = len(text_of(abstract)) if abstract is not None else 0
body = root.find(".//t:text/t:body", NS)
info["footnote_refs"] = 0
if body is not None:
info["footnote_refs"] = len(body.findall(".//t:ref[@type='foot']", NS))
# Reihenfolge des Dokuments beibehalten
for el in body.iter():
tag = el.tag.replace(T, "")
if tag == "p" and not _inside(el, body, ("figure", "note")):
segs.append(Segment("body", text_of(el), first_page(el)))
elif tag == "head" and not _inside(el, body, ("figure",)):
segs.append(Segment("head", text_of(el), first_page(el)))
elif tag == "figure":
kind = "table" if el.get("type") == "table" else "figure"
segs.append(Segment(kind, text_of(el), first_page(el)))
elif tag == "note" and el.get("place") != "foot" and not _inside(el, body, ("figure",)):
segs.append(Segment("body_note", text_of(el), first_page(el)))
for el in root.findall(".//t:note[@place='foot']", NS):
segs.append(Segment("footnote", text_of(el), first_page(el)))
back = root.find(".//t:text/t:back", NS)
refs = []
if back is not None:
for div in back.findall("t:div", NS):
if div.get("type") == "references":
continue
for p in div.iter():
if p.tag in (T + "p", T + "head"):
segs.append(Segment("back", text_of(p), first_page(p)))
refs = back.findall(".//t:listBibl/t:biblStruct", NS)
for b in refs:
segs.append(Segment("reference", text_of(b), first_page(b)))
complete = 0
for b in refs:
has_title = any(text_of(t) for t in b.findall(".//t:title", NS))
has_author = b.find(".//t:author", NS) is not None
has_year = b.find(".//t:date", NS) is not None
complete += has_title and has_author and has_year
info["references"] = len(refs)
info["refs_complete_pct"] = round(100 * complete / len(refs), 1) if refs else None
return [s for s in segs if s.text], info
_parent_cache: dict = {}
def _inside(el, root, tags) -> bool:
"""Liegt el (innerhalb von root) unter einem der genannten Elemente?"""
parents = _parent_cache.get(id(root))
if parents is None:
parents = {c: p for p in root.iter() for c in p}
_parent_cache.clear()
_parent_cache[id(root)] = parents
p = parents.get(el)
while p is not None and p is not root:
if p.tag.replace(T, "") in tags:
return True
p = parents.get(p)
return False
# ---------- Docling-JSON und pypdf-Ausgangswert lesen ----------
# Docling-Labels -> Segmentarten der Prüfung
DOCLING_KIND = {
"title": "header", "section_header": "head", "text": "body", "paragraph": "body",
"list_item": "body", "footnote": "footnote", "caption": "figure", "formula": "figure",
"code": "figure", "reference": "reference", "document_index": "back",
"page_header": "furniture", "page_footer": "furniture",
}
def read_docling(path: Path) -> tuple[list[Segment], dict]:
"""Liest ein mit DoclingDocument.save_as_json gespeichertes Dokument in Lesereihenfolge."""
import json
doc = json.loads(path.read_text(encoding="utf-8"))
segs: list[Segment] = []
def resolve(ref: str) -> dict:
_, coll, idx = ref.split("/")
return doc[coll][int(idx)]
def page(item) -> int | None:
prov = item.get("prov") or []
return prov[0].get("page_no") if prov else None
def walk(node, layer):
for child in node.get("children", []):
item = resolve(child["$ref"])
coll = child["$ref"].split("/")[1]
if coll == "texts":
kind = DOCLING_KIND.get(item.get("label"), "body")
if layer == "furniture" or item.get("content_layer") == "furniture":
kind = "furniture"
segs.append(Segment(kind, " ".join(item.get("text", "").split()), page(item)))
elif coll == "tables":
cells = (item.get("data") or {}).get("table_cells", [])
segs.append(Segment("table", " ".join(c.get("text", "") for c in cells), page(item)))
walk(item, layer) # Gruppen, Bildunterschriften, verschachtelte Listen
walk(doc.get("body", {}), "body")
walk(doc.get("furniture", {}), "furniture")
title = next((s.text for s in segs if s.kind == "header"), "")
info = {"title": title, "authors": 0, "abstract_chars": 0, "footnote_refs": 0,
"references": sum(s.kind == "reference" for s in segs), "refs_complete_pct": None}
return [s for s in segs if s.text], info
def read_pypdf_baseline(pdf: Path) -> tuple[list[Segment], dict]:
"""Das, was der jetzige PyPDFLoader liefert: eine Einheit pro Seite, ohne Struktur."""
segs = [Segment("body", " ".join(p.split()), i + 1) for i, p in enumerate(read_pdf_pages(pdf))]
info = {"title": "", "authors": 0, "abstract_chars": 0, "footnote_refs": 0,
"references": 0, "refs_complete_pct": None}
return [s for s in segs if s.text], info
def read_docling_cleaned(path: Path) -> tuple[list[Segment], dict]:
"""Docling-JSON so, wie docling_loader.py es bereinigt an den Index gibt."""
from docling_loader import clean_blocks
kind = {"head": "head", "body": "body", "footnote": "footnote", "caption": "figure",
"table": "table", "reference": "reference"}
segs = [Segment(kind.get(b.kind, "body"), b.text, b.page) for b in clean_blocks(path)]
info = {"title": "", "authors": 0, "abstract_chars": 0, "footnote_refs": 0,
"references": sum(s.kind == "reference" for s in segs), "refs_complete_pct": None}
return segs, info
def read_segments(pdf: Path, out: Path | None, cleaned: bool = False) -> tuple[list[Segment], dict]:
"""Extraktor-Ausgabe einlesen: GROBID-TEI, Docling-JSON (roh oder wie vom Loader
bereinigt) oder (out=None) pypdf."""
if out is None:
return read_pypdf_baseline(pdf)
if out.name.endswith(".json"):
return read_docling_cleaned(out) if cleaned else read_docling(out)
return read_tei(out)
# ---------- PDF-Rohtext ----------
def read_pdf_pages(path: Path) -> list[str]:
# Getrennte Doppelseiten (split_spreads.py): pypdf ignoriert den Zuschnitt und
# läse auf jeder Hälfte die ganze Doppelseite, deshalb begrenzt über pdfium
path = Path(path)
if path.with_name(path.stem + ".pages.json").exists():
from split_spreads import bounded_page_texts
return bounded_page_texts(path)
reader = PdfReader(path)
pages = []
for page in reader.pages:
try:
pages.append(page.extract_text() or "")
except Exception:
pages.append("")
return pages
def boilerplate_lines(pages: list[str]) -> set[str]:
"""Zeilen, die oben/unten auf vielen Seiten wiederkehren (Kolumnentitel, Fußzeilen)."""
def norm(line):
return re.sub(r"\d+", "#", " ".join(line.split()).lower())
counts = Counter()
for text in pages:
lines = [l for l in text.splitlines() if l.strip()]
for line in set(lines[:3] + lines[-3:]):
counts[norm(line)] += 1
need = max(3, int(0.3 * len(pages)))
return {l for l, c in counts.items() if c >= need and len(l) > 3}
def strip_boilerplate(text: str, boiler: set[str]) -> str:
keep = []
for line in text.splitlines():
if re.sub(r"\d+", "#", " ".join(line.split()).lower()) not in boiler:
keep.append(line)
return "\n".join(keep)
# ---------- Normalisierung für den Abgleich ----------
def normalize(text: str, dehyphenate: bool = False) -> list[str]:
text = unicodedata.normalize("NFKC", text).replace("­", "")
if dehyphenate:
text = re.sub(r"(\w)-\s*\n\s*(?=[a-zäöüß])", r"\1", text)
return re.findall(r"[^\W_]+", text.lower())
def shingles(tokens: list[str]) -> list[tuple]:
return [tuple(tokens[i:i + SHINGLE]) for i in range(len(tokens) - SHINGLE + 1)]
# ---------- Prüfungen ----------
# URLs und DOIs stehen in wissenschaftlicher Prosa legitim, sie zählen nicht als Fremdtext.
BOILER_RE = re.compile(
r"downloaded (from|by)|heruntergeladen|ip address|©|\bÓ\s?\d{4}|"
r"all rights reserved|alle rechte vorbehalten|creative ?commons|distributed under|"
r"published with license|licensed under|\bissn\b|"
r"this article (is|was) published", re.I)
YEAR_RE = re.compile(r"\b(1[5-9]|20)\d{2}[a-z]?\b")
AUTHOR_RE = re.compile(r"[A-ZÄÖÜ][a-zäöüß]+,\s(?:[A-ZÄÖÜ]\.\s?)+")
TERMINAL_RE = re.compile(r"[.!?:;…\"'“”‘’»«)\]]$")
HYPHEN_RE = re.compile(r"\b([^\W\d_]{2,})- ([a-zäöüß]{2,})\b")
HYPHEN_OK = {"und", "oder", "bis", "sowie", "als", "wie", "noch", "and", "or", "to", "nor", "versus"}
FNMARK_RE = re.compile(r"\b[a-zäöüß]{3,}[.,;:]?\d{1,3}\b(?![.,]\d)")
# abgesetzte Fußnotenzeichen aus OCR: "an Felice 6 , die" oder "Schreibart 1 6 :"
FNMARK_SPACED_RE = re.compile(
r"\b([A-Za-zÄÖÜäöüß]{3,})[.,;:!?»«\"”)]? (\d{1,3})(?: \d)? (?=[,;:]|[A-ZÄÖÜ„»])")
FNMARK_SPACED_SKIP = {
"seite", "seiten", "band", "bd", "nr", "nummer", "kap", "kapitel", "abschnitt", "teil", "vers",
"zeile", "anm", "anmerkung", "heft", "abb", "tab", "tabelle", "punkt", "absatz", "satz", "page",
"vol", "chapter", "section", "figure", "fig", "table", "experiment", "study", "studie",
"hypothesis", "hypothese", "condition", "group", "gruppe", "item", "items", "grade", "grades",
"klasse", "wave", "phase", "step", "level", "model", "modell", "booklet", "segment", "aufl"}
# typische OCR-Fehlerbilder: Ziffer im Wort (erl10ben), Backslash (\Vort), Zeichen, die
# in Prosa nicht vorkommen (| ~ ^), Satzzeichensalat (-----,~.). Binnenmajuskeln (KaJkas)
# bleiben außen vor: zu viele Fehlalarme bei Namen und Werkzeugen (McNamara, CoMap).
OCR_RE = re.compile(
r"\b[a-zäöüß]+\d+[a-zäöüß]+\b|\\[A-Za-z]|[|~^]"
r"|(?![^\w\s]*(?:\.\.\.|…))(?![.…\-–—]{4,})[^\w\s]{4,}") # Auslassungspunkte zählen nicht
URL_RE = re.compile(r"https?://\S+|\b\S+\.(?:org|io|com|de|net)/\S*")
ENCODING_RE = re.compile(
"[�-ff-st\u0000-\u0008\u000b\u000c\u000e-\u001f]"
r"|Ã[\x80-\xbf]|â€|Â(?=\s|\w)|(?<=\s)[¼½¾](?=\s)|(?<=\w) ?Ó ?(?=\w)|(?<=[\s(])À(?=\s?[\d.])")
# Platzhalter, die pypdf für Glyphen ohne Unicode-Zuordnung ausgibt
PDF_GLYPH_RE = re.compile(r"/(?:C\d{1,3}|G\d{1,3}|g\d{1,3}|uni[0-9A-Fa-f]{4}|cid:?\d+)\b|\(cid:\d+\)")
def bibliography_like(t: str) -> bool:
"""Kurzer Absatz, der überwiegend aus Literaturangaben besteht.
Belegdichte Prosa ("vgl. Binder 1983, S. 170") soll nicht anschlagen."""
if len(t) > 800:
return False
years = len(YEAR_RE.findall(t))
authors = len(AUTHOR_RE.findall(t)) + len(re.findall(r"[A-ZÄÖÜ][a-zäöüß]+, [A-ZÄÖÜ][a-zäöüß]+[^:]{0,20}:", t))
return years >= 2 and authors >= 1 and years * 1000 / len(t) >= 5
def prose_like(t: str) -> bool:
"""Grob: sieht nach Fließtext aus (lang, Sätze, wenig Ziffern)."""
if len(t) < 300:
return False
digits = sum(c.isdigit() for c in t) / len(t)
sentences = len(re.findall(r"[a-zäöüß][.!?]\s+[A-ZÄÖÜ]", t))
return digits < 0.08 and sentences >= 2
META_RE = re.compile(
r"https?|orcid|doi|licen[cs]e|creative commons|copyright|correspondence|"
r"email|e mail|university|universität|institut|published|issn|isbn|"
r"received|accepted|keywords|supplement|journal|verlag|rights|contact|cite this|"
r"gmbh|research cent|department|faculty|fakultät|lehrstuhl|seminar für", re.I)
def classify_lost(tokens: list[str]) -> str:
"""Ordnet eine verlorene Passage ein; nur 'Fließtext' ist kritisch."""
n = len(tokens)
if sum(len(t) for t in tokens) / n < 2.5:
return "pypdf-Lesefehler (Sperrsatz)"
if sum(t.isdigit() or bool(re.fullmatch(r"[a-z]?\d+", t)) for t in tokens) / n > 0.25:
return "Zahlen/Tabelle"
text = " ".join(tokens)
if len(META_RE.findall(text)) >= 2:
return "Vermerk/Metadaten"
if len(YEAR_RE.findall(text)) >= 2 and re.search(r"\b(pp|ed|eds|hg|hrsg|vol|bd|s)\b", text):
return "Literaturangabe"
return "Fließtext"
def page_unusable(text: str) -> bool:
"""Liefert pypdf auf dieser Seite Unbrauchbares? (Sperrsatz, verschluckte Leerzeichen)"""
words = text.split()
if len(words) < 30:
return False
mean = sum(len(w) for w in words) / len(words)
long_share = sum(len(w) > 20 for w in words) / len(words)
return mean < 2.5 or mean > 9 or long_share > 0.08
def light(metric: str, value) -> str:
if value is None:
return "–"
yellow, red = THRESHOLDS[metric]
if metric in LOWER_IS_WORSE:
return "ROT" if value < red else "GELB" if value < yellow else "GRÜN"
return "ROT" if value >= red else "GELB" if value >= yellow else "GRÜN"
def check(pdf: Path, out: Path | None, cleaned: bool = False) -> tuple[dict, dict]:
"""Prüft die Ausgabe eines Extraktors (TEI, Docling-JSON oder None für pypdf) gegen das PDF.
cleaned=True prüft Docling-JSON nach der Bereinigung durch docling_loader.py."""
segs, info = read_segments(pdf, out, cleaned)
pages = read_pdf_pages(pdf)
boiler = boilerplate_lines(pages)
ex = defaultdict(list) # Belegstellen
m = {"datei": pdf.name, "titel": info["title"][:80], "seiten": len(pages)}
# 1. Textlayer
scan_pages = [i + 1 for i, p in enumerate(pages) if len(p.strip()) < 200]
m["scan_pages_pct"] = round(100 * len(scan_pages) / max(1, len(pages)), 1)
if scan_pages:
ex["Seiten ohne nennenswerten Textlayer (Scan? GROBID macht kein OCR)"] = [
f"Seite {', '.join(map(str, scan_pages[:40]))}"]
# 2. Abdeckung: welcher Teil des PDF-Texts findet sich wo im TEI wieder?
priority = ["body", "head", "header", "footnote", "body_note", "back", "table", "figure",
"reference", "furniture"]
tei_index: dict[tuple, str] = {}
for kind in reversed(priority): # höhere Priorität überschreibt
for s in segs:
if s.kind == kind:
for sh in shingles(normalize(s.text)):
tei_index[sh] = kind
unusable = [i + 1 for i, p in enumerate(pages) if page_unusable(p)]
m["ref_unusable_pct"] = round(100 * len(unusable) / max(1, len(pages)), 1)
if unusable:
ex["Seiten, auf denen pypdf keinen brauchbaren Vergleichstext liefert (nicht geprüft)"] = [
f"Seite {', '.join(map(str, unusable[:60]))}"]
pdf_tokens, token_page = [], []
for i, p in enumerate(pages):
if i + 1 in unusable:
continue
toks = normalize(strip_boilerplate(p, boiler), dehyphenate=True)
pdf_tokens += toks
token_page += [i + 1] * len(toks)
covered = [None] * len(pdf_tokens)
for i, sh in enumerate(shingles(pdf_tokens)):
kind = tei_index.get(sh)
if kind:
for j in range(i, i + SHINGLE):
if covered[j] is None or priority.index(kind) < priority.index(covered[j]):
covered[j] = kind
n = max(1, len(pdf_tokens))
share = Counter(c or "fehlt" for c in covered)
m["pdf_woerter"] = len(pdf_tokens)
m["missing_all_pct"] = round(100 * share["fehlt"] / n, 1)
m["body_share_pct"] = round(100 * (share["body"] + share["head"]) / n, 1)
m["verteilung"] = " ".join(f"{k}={100 * v / n:.0f}%" for k, v in share.most_common())
# verlorene Passagen am Stück
runs, start = [], None
for i, c in enumerate(covered + ["end"]):
if c is None and start is None:
start = i
elif c is not None and start is not None:
if i - start >= MIN_LOST_RUN:
runs.append((start, i))
start = None
runs.sort(key=lambda r: r[1] - r[0], reverse=True)
lost_by_class = Counter()
for a, b in runs:
cls = classify_lost(pdf_tokens[a:b])
lost_by_class[cls] += b - a
title = ("Verlorener FLIESSTEXT (kritisch)" if cls == "Fließtext"
else "Verlorene Passagen, unkritisch (Vermerke, Tabellen, Literatur, Lesefehler)")
if len(ex[title]) < MAX_EXAMPLES:
words = " ".join(pdf_tokens[a:b])
ex[title].append(f"[{cls}, S. {token_page[a]}, {b - a} Wörter] "
f"{words[:400]}{' …' if len(words) > 400 else ''}")
m["lost_pct"] = round(100 * lost_by_class["Fließtext"] / n, 2)
if m["ref_unusable_pct"] > MAX_UNUSABLE_REF or not pdf_tokens:
m["lost_pct"] = None # Referenz zu schlecht, lieber keine Aussage als eine falsche
m["lost_runs"] = len(runs)
m["verlust_nach_art"] = " ".join(f"{k}={100 * v / n:.1f}%" for k, v in lost_by_class.most_common())
# 3. Fließtext-Prüfungen
body = [s for s in segs if s.kind == "body"]
body_chars = sum(len(s.text) for s in body) or 1
body_words = sum(len(s.text.split()) for s in body) or 1
m["absaetze"] = len(body)
m["ueberschriften"] = sum(s.kind == "head" for s in segs)
m["fliesstext_zeichen"] = body_chars
# Gezählt werden Funde, nicht Absätze: bei schlechter Absatzerkennung (wenige
# Riesenabsätze) wäre ein Anteil der Absätze sonst bedeutungslos.
heads = [l for l in boiler if len(l) >= 15]
hits = []
for s in body:
norm_text = re.sub(r"\d+", "#", s.text.lower())
found = [("Vermerk", mt.start()) for mt in BOILER_RE.finditer(s.text)]
for h in heads:
pos = norm_text.find(h)
while pos >= 0:
found.append(("Kolumnentitel/Fußzeile", pos))
pos = norm_text.find(h, pos + 1)
last = -1000
for reason, pos in sorted(found, key=lambda x: x[1]):
if pos - last > 300: # ein Einschub trifft oft mehrere Muster
hits.append((reason, s, pos))
last = pos
if bibliography_like(s.text):
hits.append(("wie Literaturangabe", s, 0))
elif len(s.text) < 40:
hits.append(("Fragment", s, 0))
for reason, s, where in hits[:MAX_EXAMPLES * 3]:
a = max(0, where - 100)
ex["Fremdtext im Fließtext"].append(
f"[{reason}, S. {s.page}] {'…' if a else ''}{s.text[a:where + 200]}…")
m["contam_per_100k"] = round(100000 * len(hits) / body_chars, 1)
seen, dups = set(), 0
for s in body:
key = s.text.lower()
if len(key) > 60 and key in seen:
dups += 1
ex["Doppelte Absätze"].append(f"[S. {s.page}] {s.text[:200]}")
seen.add(key)
m["duplicate_pct"] = round(100 * dups / max(1, len(body)), 1)
# Fließtext, der als Abbildung, Tabelle oder Notiz einsortiert wurde
misfiled = 0
for s in segs:
if s.kind in ("figure", "body_note") and prose_like(s.text):
misfiled += len(s.text)
ex["Möglicher Fließtext in Abbildung/Notiz (fehlt dem Index)"].append(
f"[{s.kind}, S. {s.page}] {s.text[:300]}")
m["misfiled_chars"] = misfiled
m["misfiled_pct"] = round(100 * misfiled / (body_chars + misfiled), 1)
# Absatzerkennung: wie viel Fließtext steckt in Riesenabsätzen?
giant = [s for s in body if len(s.text) > GIANT_PARA]
m["giant_para_pct"] = round(100 * sum(len(s.text) for s in giant) / body_chars, 1)
m["laengster_absatz"] = max((len(s.text) for s in body), default=0)
for s in sorted(giant, key=lambda s: -len(s.text))[:MAX_EXAMPLES]:
ex[f"Riesenabsätze (> {GIANT_PARA} Zeichen, Absatzerkennung versagt)"].append(
f"[S. {s.page}, {len(s.text):,} Zeichen] {s.text[:200]} … {s.text[-120:]}".replace(",", "."))
# zerrissene Sätze: Absatz endet ohne Satzzeichen, nächster beginnt klein
broken = 0
for a, b in zip(body, body[1:]):
if not TERMINAL_RE.search(a.text) and b.text[:1].islower():
broken += 1
ex["Über Absatzgrenzen zerrissene Sätze"].append(
f"S. {a.page}→{b.page}: …{a.text[-120:]} ‖ {b.text[:120]}…")
m["broken_per_100p"] = round(100 * broken / max(1, len(body)), 1)
all_body = "\n".join(s.text for s in body)
hyph = [mt for mt in HYPHEN_RE.finditer(all_body) if mt.group(2) not in HYPHEN_OK]
m["hyphen_per_10k"] = round(10000 * len(hyph) / body_words, 1)
for mt in hyph[:MAX_EXAMPLES]:
ex["Trennstrich-Reste"].append(all_body[max(0, mt.start() - 40):mt.end() + 40])
enc = list(ENCODING_RE.finditer(all_body))
m["encoding_per_10k"] = round(10000 * len(enc) / body_chars, 1)
# Stecken dieselben Zeichen schon im PDF-Rohtext, liegt es an der Schriftkodierung
# des PDFs, nicht an GROBID — behebbar mit einer Ersetzungstabelle im Loader.
# pypdf zeigt dieselben Schriftmängel teils anders, als Glyphen-Platzhalter wie "/C22".
raw_text = "\n".join(pages)
raw_enc = len(ENCODING_RE.findall(raw_text)) + len(PDF_GLYPH_RE.findall(raw_text))
m["kodierung_im_pdf"] = "ja" if enc and raw_enc >= 0.5 * len(enc) else ("nein" if enc else "–")
for mt in enc[:MAX_EXAMPLES]:
ctx = all_body[max(0, mt.start() - 40):mt.end() + 40]
ex["Kodierungsfehler"].append(f"{mt.group()!r}: {ctx}")
fnm = list(FNMARK_RE.finditer(all_body))
fns = [mt for mt in FNMARK_SPACED_RE.finditer(all_body)
if mt.group(1).lower() not in FNMARK_SPACED_SKIP]
m["fnmark_per_10k"] = round(10000 * (len(fnm) + len(fns)) / body_words, 1)
for mt in fnm[:MAX_EXAMPLES // 2] + fns[:MAX_EXAMPLES // 2]:
ex["Fußnotenzeichen im Fließtext (verfälscht Suche, Fußnote nicht getrennt)"].append(
all_body[max(0, mt.start() - 40):mt.end() + 40])
ocr = list(OCR_RE.finditer(URL_RE.sub(" ", all_body)))
m["ocr_per_10k"] = round(10000 * len(ocr) / body_words, 1)
raw_ocr = len(OCR_RE.findall(URL_RE.sub(" ", raw_text)))
raw_words = len(raw_text.split()) or 1
# gleiche oder höhere Dichte im Rohtext -> die OCR-Schicht des PDFs ist schuld
m["ocr_im_pdf"] = ("ja" if ocr and raw_ocr / raw_words >= 0.5 * len(ocr) / body_words
else "nein" if ocr else "–")
for mt in ocr[:MAX_EXAMPLES]:
ctx = URL_RE.sub(" ", all_body)
ex["OCR-Fehlerbilder"].append(f"{mt.group()!r}: {ctx[max(0, mt.start() - 40):mt.end() + 40]}")
# 4. Lesereihenfolge: Position jedes Absatzes im PDF-Text
pdf_pos: dict[tuple, int] = {}
dup = set()
for i, sh in enumerate(shingles(pdf_tokens)):
if sh in pdf_pos:
dup.add(sh)
pdf_pos.setdefault(sh, i)
positions = []
for s in body:
for sh in shingles(normalize(s.text))[:30]:
if sh in pdf_pos and sh not in dup:
positions.append((pdf_pos[sh], s))
break
pairs = list(zip(positions, positions[1:]))
ordered = sum(a[0] < b[0] for a, b in pairs)
m["order"] = round(ordered / len(pairs), 3) if pairs else None
for a, b in pairs:
if a[0] > b[0] and len(ex["Absätze außerhalb der PDF-Reihenfolge"]) < MAX_EXAMPLES:
ex["Absätze außerhalb der PDF-Reihenfolge"].append(
f"S. {a[1].page} „{a[1].text[:80]}…“ steht vor S. {b[1].page} „{b[1].text[:80]}…“")
m["absaetze_ohne_pdf_fundstelle"] = len(body) - len(positions)
# 5. Struktur
m["fussnoten"] = sum(s.kind == "footnote" for s in segs)
m["fussnoten_verweise"] = info["footnote_refs"]
m["literaturangaben"] = info["references"]
m["literatur_vollstaendig_pct"] = info["refs_complete_pct"]
m["autoren"] = info["authors"]
m["abstract_zeichen"] = info["abstract_chars"]
if m["fussnoten_verweise"] and not m["fussnoten"]:
ex["Strukturhinweise"].append(
f"{m['fussnoten_verweise']} Fußnotenverweise im Text, aber keine Fußnote erkannt")
if not info["title"]:
ex["Strukturhinweise"].append("Kein Titel erkannt")
if m["ueberschriften"] == 0:
ex["Strukturhinweise"].append("Keine Zwischenüberschriften erkannt")
# Stichprobe zum Gegenlesen
rnd = random.Random(pdf.name)
for s in rnd.sample(body, min(SAMPLE_PARAGRAPHS, len(body))):
ex["Stichprobe: zufällige Fließtextabsätze zum Gegenlesen"].append(f"[S. {s.page}] {s.text}")
m["ampel"] = {k: light(k, m[k]) for k in THRESHOLDS if k in m}
m["ursache"] = {k: "PDF" if k in PDF_METRICS else "Extraktor" for k in m["ampel"]}
if m["kodierung_im_pdf"] == "ja":
m["ursache"]["encoding_per_10k"] = "PDF"
if m["ocr_im_pdf"] == "ja":
m["ursache"]["ocr_per_10k"] = "PDF"
m["grobid"] = worst(l for k, l in m["ampel"].items() if m["ursache"][k] == "Extraktor")
m["pdf"] = worst(l for k, l in m["ampel"].items() if m["ursache"][k] == "PDF")
return m, ex
def worst(lights) -> str:
lights = list(lights)
return "ROT" if "ROT" in lights else "GELB" if "GELB" in lights else "GRÜN"
# ---------- Berichte ----------
LABELS = {
"scan_pages_pct": "Seiten ohne Textlayer (%)",
"lost_pct": "Verlorener Fließtext ggü. PDF (%)",
"giant_para_pct": f"Fließtext in Riesenabsätzen > {GIANT_PARA} Zeichen (%)",
"contam_per_100k": "Fremdtext-Funde je 100k Zeichen",
"duplicate_pct": "Doppelte Absätze (%)",
"misfiled_pct": "Fließtext in Abbildung/Notiz (%)",
"broken_per_100p": "Zerrissene Sätze je 100 Absätze",
"hyphen_per_10k": "Trennstrich-Reste je 10k Wörter",
"encoding_per_10k": "Kodierungsfehler je 10k Zeichen",
"fnmark_per_10k": "Fußnotenzeichen im Fließtext je 10k Wörter",
"ocr_per_10k": "OCR-Fehlerbilder je 10k Wörter",
"order": "Lesereihenfolge (Anteil geordnet)",
}
def write_report(m: dict, ex: dict, path: Path):
out = [f"# QA: {m['datei']}", "", f"**{m['titel']}**", "",
f"Extraktor: **{m['grobid']}** · PDF-Mängel: **{m['pdf']}** · {m['seiten']} Seiten · {m['absaetze']} Absätze · "
f"{m['ueberschriften']} Überschriften · {m['fliesstext_zeichen']:,} Zeichen Fließtext".replace(",", "."),
"", "| Prüfung | Wert | Ampel | zählt zu | gelb ab | rot ab |", "|---|---|---|---|---|---|"]
for k, label in LABELS.items():
y, r = THRESHOLDS[k]
out.append(f"| {label} | {m[k]} | {m['ampel'][k]} | {m['ursache'][k]} | {y} | {r} |")
out += ["", f"Verteilung der PDF-Wörter auf TEI-Bereiche: {m['verteilung']}", "",
f"Nicht wiedergefunden insgesamt: {m['missing_all_pct']} %, davon in Passagen "
f"≥ {MIN_LOST_RUN} Wörter: {m['verlust_nach_art'] or '–'}", "",
f"Fußnoten: {m['fussnoten']} (Verweise im Text: {m['fussnoten_verweise']}) · "
f"Literaturangaben: {m['literaturangaben']} (vollständig: {m['literatur_vollstaendig_pct']} %) · "
f"Autoren: {m['autoren']} · Abstract: {m['abstract_zeichen']} Zeichen · "
f"Absätze ohne PDF-Fundstelle: {m['absaetze_ohne_pdf_fundstelle']} · "
f"Kodierungsfehler schon im PDF angelegt: {m['kodierung_im_pdf']} · "
f"OCR-Fehler schon im PDF angelegt: {m['ocr_im_pdf']} · "
f"Längster Absatz: {m['laengster_absatz']} Zeichen · "
f"Seiten ohne brauchbaren pypdf-Vergleichstext: {m['ref_unusable_pct']} %"
+ (" → Verlustmessung ausgesetzt" if m["lost_pct"] is None else ""), ""]
for title, items in ex.items():
out += [f"## {title}", ""]
out += [f"- {i}" for i in items[:MAX_EXAMPLES if 'Stichprobe' not in title else None]]
out.append("")
path.write_text("\n".join(out), encoding="utf-8")
def tei_is_current(tei: Path) -> bool:
"""TEI vorhanden und mit den Optionen aus grobid_test.py erzeugt (Koordinaten, Rohzitate)?"""
if not tei.exists():
return False
data = tei.read_bytes()
return re.search(rb"<p [^>]*coords=", data) is not None and (
b"raw_reference" in data or b"<listBibl/>" in data or b"listBibl" not in data)
def main():
pdf_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else PDF_DIR
tei_dir = OUT_ROOT / pdf_dir.name
report_dir = tei_dir / "qa"
report_dir.mkdir(parents=True, exist_ok=True)
pdfs = sorted(pdf_dir.glob("*.pdf"))
if not pdfs:
raise SystemExit(f"Keine PDFs in {pdf_dir}/")
rows, failed = [], []
for i, pdf in enumerate(pdfs, 1):
tei = tei_dir / f"{pdf.stem}.tei.xml"
t0 = time.time()
try:
if REPROCESS or not tei_is_current(tei):
print(f"[{i}/{len(pdfs)}] {pdf.name}: GROBID ...", end=" ", flush=True)
tei.write_bytes(process_pdf(pdf))
else:
print(f"[{i}/{len(pdfs)}] {pdf.name}: vorhandenes TEI ...", end=" ", flush=True)
m, ex = check(pdf, tei)
except Exception as e: # ein kaputtes PDF soll den Lauf nicht abbrechen
print(f"FEHLER: {e}", flush=True)
failed.append((pdf.name, str(e)))
continue
write_report(m, ex, report_dir / f"{pdf.stem}.md")
rows.append(m)
print(f"GROBID {m['grobid']}, PDF {m['pdf']} ({time.time() - t0:.0f} s)", flush=True)
if not rows:
raise SystemExit("Kein PDF erfolgreich geprüft.")
cols = ["datei", "grobid", "pdf", "seiten", *LABELS, "body_share_pct", "absaetze", "ueberschriften",
"fussnoten", "fussnoten_verweise", "literaturangaben", "literatur_vollstaendig_pct",
"absaetze_ohne_pdf_fundstelle", "kodierung_im_pdf", "ocr_im_pdf", "laengster_absatz",
"ref_unusable_pct", "misfiled_chars", "missing_all_pct", "verlust_nach_art", "lost_runs", "titel"]
with open(report_dir / "summary.csv", "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=cols, extrasaction="ignore", delimiter=";")
w.writeheader()
w.writerows(rows)
w.writerows({"datei": name, "grobid": "FEHLER", "titel": err[:200]} for name, err in failed)
short = {"scan_pages_pct": "Scan%", "lost_pct": "Verlust%", "giant_para_pct": "Riesen%",
"contam_per_100k": "Fremd", "misfiled_pct": "Fehlabl%", "duplicate_pct": "Doppelt",
"broken_per_100p": "Zerriss", "hyphen_per_10k": "Trenn", "encoding_per_10k": "Kodier",
"ocr_per_10k": "OCR", "fnmark_per_10k": "FN-Zch", "order": "Reihenf"}
name_w = min(40, max(len(r["datei"]) for r in rows))
print("\n" + "Datei".ljust(name_w), "GROBID".ljust(6), "PDF".ljust(5), *(v.rjust(8) for v in short.values()))
for r in rows:
cells = [f"{'P:' if r['ursache'][k] == 'PDF' and r['ampel'][k] != 'GRÜN' else ''}{r[k]}"
f"{'!' if r['ampel'][k] == 'ROT' else '?' if r['ampel'][k] == 'GELB' else ''}"
for k in short]
print(r["datei"][:name_w].ljust(name_w), r["grobid"].ljust(6), r["pdf"].ljust(5),
*(c.rjust(8) for c in cells))
print("\n? = gelb, ! = rot, P: = Mangel der PDF-Vorlage, nicht von GROBID.")
for name, err in failed:
print(f"FEHLER bei {name}: {err}")
print("Details und Belegstellen:", report_dir)
if __name__ == "__main__":
main()