formula_img2latex/tools/vergleich_bericht.py
dschlueter 2478bc5cb9 feat: 0.4.0 – mehrzeilige Normalform, erstes ganzes PDF-Buch
Normalform (Göllmann, 400 Formeln: +17 automatisch, weiterhin 0 falsch):
- mehrzeilige Formeln gleich bei gleichen Zeilen, egal in welchem Rahmen
  (aligned/&/\\, cases = \left\{array\right. = \left\{aligned\right.),
  leere letzte Zeile zählt nicht; Matrizen behalten & als Spaltentrenner
- Argumente nach Argumentzahl des Befehls (t^{2}\,{\sqrt{2}} = t^2\sqrt{2})
- \cdot\cdot\cdot = \dots, \root3\of{x} = \sqrt[3]{x}, \left./\right. entfallen,
  \cfrac = \frac; wörtliche \n aus Modellantworten werden zu Leerraum

Gemessen und abgeschaltet bzw. verworfen:
- Strukturvergleich mit sympy (structure.py, optional, FORMULA_STRUCTURAL=1):
  nach der neuen Normalform kein Gewinn mehr
- Bild-Schiedsrichter (verify.image_arbiter, arbiter=0): der gemeinsame
  Fehler \mathcal{E}_r bekam Beleg 7,2 – Abstand zu knapp
- Prompt-Stilvorgabe: kein Gewinn, Prompt-Version bleibt 2

Ganzes PDF-Buch (Corral, Elementary Calculus): 429 von 1123 automatisch.
- Bilder für die Modelle höchstens 1600 Pixel (Qwen lehnte große ab),
  Bildvergleich nur bis 4 MP (ganzseitige Abbildung brachte ihn zum Absturz)
- fehlgeschlagene Modellanfragen werden wiederholt
- pix2text: \cfrac -> \dfrac (Pandoc kennt \cfrac nicht)

Werkzeuge: tools/buch_bericht.py (Ergebnisseite eines Buchs),
tools/vergleich_bericht.py (Vorher/Nachher), regeltest --refresh/--arbiter,
calibrate misst Schiedsrichter und strukturelle Treffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:30:15 +02:00

205 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Visual before/after report of two runs on the same readings (--reuse), e.g. a rule change.
python3 tools/vergleich_bericht.py --epub1 BAND1.epub --epub2 BAND2.epub \\
--regeltest regeltest1-ohne:regeltest1-struktur --regeltest regeltest2-ohne:regeltest2-struktur \\
--kalibrierung VORHER.json:NACHHER.json [--name struktur] [--titel "…"]
Each pair is VORHER:NACHHER (regeltest: names under vergleich/, calibration: report files). Writes
vergleich/<name>/index.html (not versioned): summary table, every changed decision or truth verdict,
and every match found only by the structural comparison, with original image and rendered readings.
"""
from __future__ import annotations
import argparse
import html
import json
import sys
import zipfile
from pathlib import Path
import numpy as np
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from konsens_bericht import orig_img, png_uri # noqa: E402
from formula_img2latex.calibrate import _dvipng_jpeg, _rule_outcome # noqa: E402
from formula_img2latex.decide import consensus_thresholds, decide_pair_tiebreak # noqa: E402
from formula_img2latex.render import render_many # noqa: E402
from formula_img2latex.tex import strip_delimiters # noqa: E402
ROOT = Path(__file__).resolve().parents[1] / "vergleich"
LABEL = {"local": "Qwen", "p2t": "pix2text", "local2": "Gemma", "cloud": "GLM"}
class Page:
"""Collects cards; renders all TeX in one pdflatex batch at the end."""
def __init__(self) -> None:
self.parts: list[str] = []
self.texes: list[tuple[str, bool, str]] = [] # (tex, display, preset)
def img(self, tex: str | None, display: bool, preset: str) -> str:
self.texes.append((strip_delimiters(tex or "") or "\\text{–}", display, preset))
return f"@@IMG{len(self.texes) - 1}@@"
def html(self) -> str:
out = "".join(self.parts)
for preset in sorted({p for _t, _d, p in self.texes}):
idx = [k for k, (_t, _d, p) in enumerate(self.texes) if p == preset]
for k, rr in zip(idx, render_many([self.texes[k][:2] for k in idx], preset)):
if rr.ok:
g = (rr.rasterize(2.0) * 255).astype(np.uint8)
tag = f"<img src='{png_uri(Image.fromarray(255 - g))}'>"
else:
tag = "<i>nicht setzbar</i>"
out = out.replace(f"@@IMG{k}@@", tag)
return out
def original(z: zipfile.ZipFile | None, name: str, truth: str, display: bool) -> str:
try:
if name.endswith("#synth.jpg"):
data = _dvipng_jpeg(strip_delimiters(truth), display, 102) or b""
else:
data = z.read(name) if z else b""
return f"<img src='{png_uri(orig_img(data, 2))}'>"
except Exception: # noqa: BLE001
return "<i>–</i>"
def card(page: Page, cls: str, head: str, orig: str, truth: str, display: bool, preset: str,
rows: list[tuple[str, str | None]]) -> None:
trs = [f"<tr><th>Original</th><td>{orig}</td><td></td></tr>",
f"<tr><th>richtig</th><td>{page.img(truth, display, preset)}</td><td><code>{html.escape(truth)}</code></td></tr>"]
for label, tex in rows:
trs.append(f"<tr><th>{label}</th><td>{page.img(tex, display, preset) if tex else '<i>–</i>'}</td>"
f"<td><code>{html.escape(tex or '–')}</code></td></tr>")
page.parts.append(f"<section class={cls}><div class=v>{head}</div><table>{''.join(trs)}</table></section>")
def mark(ok: bool) -> str:
return "<b class=ok>✓</b>" if ok else "<b class=no>✗</b>"
def regeltest(page: Page, summary: list[str], pair: str, z: zipfile.ZipFile | None) -> None:
a, b = pair.split(":")
ra, rb = (json.loads((ROOT / n / "ergebnis.json").read_text(encoding="utf-8")) for n in (a, b))
sa, sb = ra["summary"], rb["summary"]
st = sb.get("structural", {})
summary.append(f"<tr><td><a href='../{b}/index.html'>{html.escape(b)}</a> "
f"<small>(<a href='../{a}/index.html'>vorher</a>)</small></td><td>{sa['formulas']}</td>"
f"<td>{sa['accepted']} / {sa['wrong']}</td><td><b>{sb['accepted']} / {sb['wrong']}</b></td>"
f"<td>{sa['review']} → {sb['review']}</td><td>{st.get('agree', 0)} / {st.get('truth', 0)}</td></tr>")
old = {c["name"]: c for c in ra["cases"]}
preset = rb.get("preset", "cm")
tools = ["local", "p2t", "local2"]
for c in rb["cases"]:
o = old[c["name"]]
changed = c["status"] != o["status"]
struct = [i for i, j, how in c["agree"] if how == "strukturell"] or [
k for k, h in enumerate(c.get("ok_how", [])) if h == "strukturell"]
verdict = [p for p in range(3) if c["readings"][p] and o["readings"][p] and c["ok"][p] != o["ok"][p]]
if not (changed or struct or verdict):
continue
right = c["status"] == "auto_accepted" and c["right"]
cls = "bad" if c["status"] == "auto_accepted" and not c["right"] else "good" if right else "rev"
head = (f"{html.escape(b)} · {'Entscheidung geändert: ' + o['status'] + ' → ' if changed else ''}"
f"{c['status']} · {html.escape(c['reason'])}"
+ (" · Urteil gegen Wahrheit geändert: " + ", ".join(LABEL[tools[p]] for p in verdict) if verdict else ""))
rows = [(f"{mark(c['ok'][p])} {LABEL[t]}" + (" (" + c["ok_how"][p] + ")" if c.get("ok_how") and c["ok_how"][p] else ""),
(c["readings"][p] or {}).get("tex")) for p, t in enumerate(tools) if c["readings"][p]]
card(page, cls, head, original(z, c["name"], c["truth"], c["display"]), c["truth"], c["display"], preset, rows)
def kalibrierung(page: Page, summary: list[str], pair: str, z: zipfile.ZipFile | None) -> None:
a, b = pair.split(":")
ka, kb = (json.loads(Path(p).read_text(encoding="utf-8")) for p in (a, b))
th = consensus_thresholds()
for sa, sb in zip(ka["sets"], kb["sets"]):
names = sb["engines"]
idx = [names.index(e) for e in ("local", "p2t", "local2")]
old = {r["name"]: r for r in sa["results"]}
acc = {"ohne": [0, 0], "mit": [0, 0]}
synth = sb["label"].endswith("Gehrke-Stil]")
preset = "cm" if synth else "txfonts"
for r in sb["results"]:
o = old[r["name"]]
use_o = _rule_outcome(o, idx, th, decide_pair_tiebreak, True)
use_n = _rule_outcome(r, idx, th, decide_pair_tiebreak, True)
for key, rr, u in (("ohne", o, use_o), ("mit", r, use_n)):
if u is not None:
acc[key][0] += 1
acc[key][1] += not rr["ok"][u]
struct = [(i, j) for i, j, how in r["agree_all"] if how == "strukturell"]
struct_t = [k for k, h in enumerate(r.get("ok_how", [])) if h == "strukturell"]
verdict_ch = [k for k in range(len(names)) if r["ok"][k] != o["ok"][k]]
if (use_o is None) == (use_n is None) and not struct and not struct_t and not verdict_ch:
continue
verdict = ("übernommen" if use_n is not None else "nicht übernommen")
if (use_o is None) != (use_n is None):
verdict = f"Entscheidung geändert: {'übernommen' if use_o is not None else 'nicht übernommen'} → {verdict}"
wrong = use_n is not None and not r["ok"][use_n]
cls = "bad" if wrong else "good" if use_n is not None else "rev"
how = "; ".join([f"{LABEL.get(names[i], names[i])} = {LABEL.get(names[j], names[j])} strukturell" for i, j in struct]
+ [f"{LABEL.get(names[k], names[k])} = Wahrheit strukturell" for k in struct_t]
+ ["Urteil gegen Wahrheit geändert: " + ", ".join(LABEL.get(names[k], names[k]) for k in verdict_ch)]
* bool(verdict_ch))
rows = [(f"{mark(r['ok'][k])} {LABEL.get(e, e)}" + (" ← verwendet" if k == use_n else ""),
r["readings"][k].get("tex")) for k, e in enumerate(names)]
card(page, cls, f"Kalibrierung {html.escape(sb['label'])} · {verdict} · {html.escape(how)}",
original(z, r["name"], r["truth"], r["display"]), r["truth"], r["display"], preset, rows)
n = sb["formulas"]
summary.append(f"<tr><td>Kalibrierung Band 1 {html.escape(sb['label'].split('[')[-1].rstrip(']'))}</td><td>{n}</td>"
f"<td>{acc['ohne'][0]} / {acc['ohne'][1]}</td><td><b>{acc['mit'][0]} / {acc['mit'][1]}</b></td>"
f"<td>–</td><td>{len(sb.get('structural_examples', []))}</td></tr>")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--epub1", type=Path, default=None, help="Göllmann Band 1 (Kalibrierung)")
ap.add_argument("--epub2", type=Path, default=None, help="Göllmann Band 2 (Regeltests)")
ap.add_argument("--regeltest", action="append", default=[], help="OHNE:MIT (Namen unter vergleich/)")
ap.add_argument("--kalibrierung", action="append", default=[], help="OHNE.json:MIT.json")
ap.add_argument("--name", default="struktur")
ap.add_argument("--titel", default="Vergleich vorher / nachher")
args = ap.parse_args()
z1 = zipfile.ZipFile(args.epub1) if args.epub1 else None
z2 = zipfile.ZipFile(args.epub2) if args.epub2 else None
page = Page()
summary: list[str] = []
for pair in args.regeltest:
regeltest(page, summary, pair, z2)
for pair in args.kalibrierung:
kalibrierung(page, summary, pair, z1)
head = (f"<h1>{html.escape(args.titel)}</h1>"
"<p>Dieselben gespeicherten Lesarten, zweimal entschieden: vorher und nachher. "
"Regel <code>p2t</code>, mit Pandoc und Bild-Veto. ✓/✗ = gleiche Formel wie die Wahrheit.</p>"
"<table class=sum><tr><th>Messung</th><th>Formeln</th><th>vorher: automatisch / falsch</th>"
"<th>nachher: automatisch / falsch</th><th>Review</th><th>nur strukturell gleich (Paare / Wahrheit)</th></tr>"
+ "".join(summary) + "</table>"
"<p>Unten: jede geänderte Entscheidung, jedes geänderte Urteil gegen die Wahrheit und jede "
"Übereinstimmung, die <b>nur</b> die Strukturstufe gefunden hat. Grün = übernommen und richtig, <b style='color:#a32020'>Rot = übernommen, aber falsch</b>, "
"Gelb = Review. Bitte prüfen: Sind die als gleich gewerteten Lesarten wirklich dieselbe Formel?</p>")
page.parts.insert(0, head)
out = ROOT / args.name
out.mkdir(parents=True, exist_ok=True)
(out / "index.html").write_text(
"<!doctype html><meta charset=utf-8><title>Vergleich</title><style>"
"body{font:14px system-ui,sans-serif;margin:16px;background:#f6f6f3}section{background:#fff;border:1px solid #ddd;"
"border-left:6px solid #999;border-radius:6px;padding:6px 10px;margin:10px 0}section.good{border-left-color:#1f7a3a}"
"section.bad{border-left-color:#a32020;background:#fff5f5}section.rev{border-left-color:#c88a00}"
".v{font-weight:600;font-size:13px}table.sum{border-collapse:collapse;background:#fff}"
"table.sum td,table.sum th{border:1px solid #ccc;padding:4px 10px;width:auto}"
"th{text-align:left;font-weight:400;color:#555;width:190px;vertical-align:middle}td{padding:3px 8px;vertical-align:middle}"
".ok{color:#1f7a3a}.no{color:#a32020}code{font-size:12px}img{max-width:640px}</style>" + page.html(),
encoding="utf-8")
print(out / "index.html")
return 0
if __name__ == "__main__":
sys.exit(main())