- 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>
122 lines
4.6 KiB
Python
122 lines
4.6 KiB
Python
"""
|
|
Testlauf: PDFs aus data_test/ durch GROBID schicken und das Ergebnis begutachten.
|
|
|
|
Voraussetzung: GROBID-Container läuft (z. B. auf http://localhost:8070).
|
|
Schreibt pro PDF eine TEI-XML-Datei und eine lesbare Textfassung nach grobid_out/
|
|
und gibt eine kurze Übersicht aus, was GROBID als Fließtext, Fußnote und
|
|
Literaturangabe erkannt hat.
|
|
|
|
Nur Standardbibliothek, kein zusätzliches Paket nötig.
|
|
"""
|
|
|
|
import sys
|
|
import uuid
|
|
import urllib.request
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
|
|
GROBID_URL = "http://localhost:8070"
|
|
INPUT_DIR = Path("data_test")
|
|
OUTPUT_DIR = Path("grobid_out") / INPUT_DIR.name
|
|
|
|
TEI = {"tei": "http://www.tei-c.org/ns/1.0"}
|
|
|
|
|
|
def process_pdf(pdf_path: Path) -> bytes:
|
|
"""Schickt ein PDF an processFulltextDocument und liefert TEI-XML zurück."""
|
|
boundary = uuid.uuid4().hex
|
|
fields = [
|
|
("consolidateHeader", "0"), # keine Online-Abfrage (CrossRef) für Metadaten
|
|
("consolidateCitations", "0"),
|
|
("segmentSentences", "0"),
|
|
("includeRawCitations", "1"), # Literaturangaben zusätzlich im Originalwortlaut
|
|
]
|
|
# Seitenkoordinaten an diese Elemente hängen (Format "seite,x,y,b,h;..."),
|
|
# damit sich jedem Absatz eine Seitenzahl zuordnen lässt.
|
|
fields += [("teiCoordinates", el) for el in ("head", "p", "note", "figure", "biblStruct")]
|
|
body = b""
|
|
for name, value in fields:
|
|
body += (f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n"
|
|
f"{value}\r\n").encode()
|
|
body += (f"--{boundary}\r\nContent-Disposition: form-data; name=\"input\"; "
|
|
f"filename=\"{pdf_path.name}\"\r\nContent-Type: application/pdf\r\n\r\n").encode()
|
|
body += pdf_path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
|
|
|
|
req = urllib.request.Request(
|
|
f"{GROBID_URL}/api/processFulltextDocument",
|
|
data=body,
|
|
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
|
|
)
|
|
# ganze Monografien brauchen deutlich länger als Zeitschriftenartikel
|
|
with urllib.request.urlopen(req, timeout=1800) as resp:
|
|
return resp.read()
|
|
|
|
|
|
def text_of(el) -> str:
|
|
return " ".join("".join(el.itertext()).split())
|
|
|
|
|
|
def summarize(tei_bytes: bytes) -> tuple[dict, str]:
|
|
"""Zählt die erkannten Elemente und baut eine lesbare Textfassung."""
|
|
root = ET.fromstring(tei_bytes)
|
|
title = root.find(".//tei:titleStmt/tei:title", TEI)
|
|
authors = list(dict.fromkeys( # GROBID listet Autoren gelegentlich doppelt
|
|
" ".join(s.strip() for s in p.itertext() if s.strip())
|
|
for p in root.findall(".//tei:sourceDesc//tei:author/tei:persName", TEI)))
|
|
abstract = root.find(".//tei:profileDesc/tei:abstract", TEI)
|
|
|
|
body = root.find(".//tei:text/tei:body", TEI)
|
|
divs = body.findall("tei:div", TEI) if body is not None else []
|
|
paras = body.findall(".//tei:p", TEI) if body is not None else []
|
|
notes = root.findall(".//tei:note[@place='foot']", TEI)
|
|
refs = root.findall(".//tei:back//tei:listBibl/tei:biblStruct", TEI)
|
|
|
|
stats = {
|
|
"titel": text_of(title) if title is not None else "",
|
|
"autoren": ", ".join(authors),
|
|
"abschnitte": len(divs),
|
|
"absaetze": len(paras),
|
|
"zeichen_fliesstext": sum(len(text_of(p)) for p in paras),
|
|
"fussnoten": len(notes),
|
|
"literaturangaben": len(refs),
|
|
}
|
|
|
|
out = [f"# {stats['titel']}", f"Autoren: {stats['autoren']}", ""]
|
|
if abstract is not None and text_of(abstract):
|
|
out += ["## Abstract", text_of(abstract), ""]
|
|
for div in divs:
|
|
head = div.find("tei:head", TEI)
|
|
if head is not None:
|
|
out += [f"## {text_of(head)}"]
|
|
for p in div.findall("tei:p", TEI):
|
|
out += [text_of(p), ""]
|
|
out += ["", "=" * 70, f"## Fußnoten ({len(notes)})"]
|
|
for n in notes:
|
|
out += [f"[{n.get('n', '?')}] {text_of(n)}"]
|
|
out += ["", "=" * 70, f"## Literaturverzeichnis ({len(refs)})"]
|
|
for r in refs:
|
|
out += [f"- {text_of(r)}"]
|
|
return stats, "\n".join(out)
|
|
|
|
|
|
def main():
|
|
try:
|
|
urllib.request.urlopen(f"{GROBID_URL}/api/isalive", timeout=5)
|
|
except Exception as e:
|
|
sys.exit(f"GROBID unter {GROBID_URL} nicht erreichbar: {e}")
|
|
|
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
for pdf in sorted(INPUT_DIR.glob("*.pdf")):
|
|
print(f"\n{pdf.name} ...", flush=True)
|
|
tei = process_pdf(pdf)
|
|
(OUTPUT_DIR / f"{pdf.stem}.tei.xml").write_bytes(tei)
|
|
stats, text = summarize(tei)
|
|
(OUTPUT_DIR / f"{pdf.stem}.txt").write_text(text, encoding="utf-8")
|
|
for k, v in stats.items():
|
|
print(f" {k:<20} {v}")
|
|
print(f"\nErgebnisse in {OUTPUT_DIR}/")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|