formula_img2latex/formula_img2latex/review/server.py
dschlueter 388024a17b feat: 0.2.0 – Konsens Qwen = Gemma = GLM als Standard, Gehrke komplett verarbeitet
- Dritte Lesart (GLM-4.6V über OpenRouter, ohne Nachdenken) muss zustimmen;
  keine 2:1-Annahme; fehlender Schlusspunkt der dritten Lesart zählt nicht
- run in Paketen zu 50 mit Statuszeile, beide lokalen Modelle parallel,
  GLM nur für Einige; nur die übernommene Lesart wird gegen das Bild geprüft
- Bild-Veto: Ähnlichkeit >= 0,65, Schriftgrößen-Prüfung abgeschaltet
- Antworten auf 800 Tokens begrenzt; Prompt-Version und Nachdenk-Einstellung
  im Cache-Schlüssel; Verfahrensversion für Prüfergebnisse
- Behoben: Gleichungsnummern aus alten Lesarten ("(3.7)"), gleichzeitiges
  Speichern zweier Engines, Absturz bei Formeln ohne aktuelle Lesart
- apply: deutsches Dezimalkomma 3{,}6; Review-Galerie zeigt aktuelle Lesarten
- tools/vergleich.py auf Konsens-Ablauf umgestellt
- Doku: README, BEDIENUNGSANLEITUNG, neues CHANGELOG

Gehrke-Brückenkurs: 942 von 1505 Formelbildern automatisch ersetzt (63 %),
62 von 62 von Hand geprüften korrekt; Pandoc/MathML 945 von 945.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:58:16 +02:00

161 lines
7.7 KiB
Python

"""Local review gallery: http://127.0.0.1:PORT, decisions are written straight into the workspace.
Only binds to localhost. Every POST must carry the per-session token embedded in the page, so
other web pages open in the same browser cannot send decisions.
"""
from __future__ import annotations
import base64
import json
import secrets
import threading
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlparse
from ..checks import pandoc_mathml_ok
from ..imageio import load_ink
from ..pipeline import _ref_for, comparison_pngs, readings_of
from ..sources.base import Source
from ..store import Formula, Workspace, now_iso
from ..tex import strip_delimiters, unsafe_reason
from ..verify import verify
REVIEW_STATUSES = ("needs_review", "rejected_keep_image", "render_failed", "no_candidate")
PAGE = (Path(__file__).parent / "page.html").read_text(encoding="utf-8")
_LOCK = threading.Lock()
def _card(f: Formula) -> dict[str, Any]:
best = (f.verification or {}).get("best") or {}
cands = [r.get("tex") for r in readings_of(f) if r.get("tex")] or [c.get("tex") for c in f.candidates if c.get("tex")]
return {
"id": f.id, "status": f.status, "reason": f.reason, "display": f.display, "asset": f.asset,
"source_name": f.source_name, "occurrences": f.occurrences[:3], "n_occ": len(f.occurrences),
"tex": f.final_tex or best.get("tex") or (cands[0] if cands else ""),
"candidates": cands, "metrics": best.get("metrics", {}), "min_contrast": best.get("min_contrast"),
"worst_variant": best.get("worst_variant"), "climbed_from": best.get("climbed_from", []),
"classify": f.classify,
}
def serve(source: Source, ws: Workspace, port: int = 8765, show_all: bool = False, open_browser: bool = True) -> None:
token = secrets.token_urlsafe(16)
preset = ws.settings.get("preset") or "cm"
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args: Any) -> None: # keep the terminal quiet
return
def _send(self, code: int, body: bytes, ctype: str) -> None:
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _json(self, data: Any, code: int = 200) -> None:
self._send(code, json.dumps(data, ensure_ascii=False).encode(), "application/json; charset=utf-8")
def _file(self, base: Path, rel: str) -> None:
path = (base / unquote(rel)).resolve()
if base.resolve() not in path.parents or not path.is_file():
self._send(404, b"not found", "text/plain")
return
ctype = {".png": "image/png", ".gif": "image/gif", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".svg": "image/svg+xml"}.get(path.suffix.lower(), "application/octet-stream")
self._send(200, path.read_bytes(), ctype)
def do_GET(self) -> None:
url = urlparse(self.path)
if url.path == "/":
formulas = [f for f in ws.all() if show_all or f.status in REVIEW_STATUSES]
formulas.sort(key=lambda f: (f.status != "needs_review", f.occurrences[0]["file"] if f.occurrences else "",
f.occurrences[0]["line"] if f.occurrences else 0))
data = {"token": token, "cards": [_card(f) for f in formulas], "stats": ws.stats(),
"source": ws.meta.get("source", {})}
html = PAGE.replace("/*DATA*/null", json.dumps(data, ensure_ascii=False).replace("</", "<\\/"))
self._send(200, html.encode(), "text/html; charset=utf-8")
elif url.path.startswith("/asset/"):
self._file(source.root, url.path[len("/asset/"):])
elif url.path.startswith("/review/"):
self._file(ws.review_dir, url.path[len("/review/"):])
else:
self._send(404, b"not found", "text/plain")
def do_POST(self) -> None:
if self.headers.get("X-Token") != token:
self._json({"error": "ungültiges Token"}, 403)
return
length = int(self.headers.get("Content-Length") or 0)
try:
req = json.loads(self.rfile.read(min(length, 1_000_000)) or b"{}")
except ValueError:
self._json({"error": "ungültige Anfrage"}, 400)
return
with _LOCK:
f = ws.load(str(req.get("id", "")))
if f is None:
self._json({"error": "Formel unbekannt"}, 404)
return
if self.path == "/check":
self._json(self._check(f, str(req.get("tex", ""))))
elif self.path == "/decide":
self._json(self._decide(f, str(req.get("action", "")), str(req.get("tex", ""))))
else:
self._json({"error": "unbekannter Pfad"}, 404)
def _check(self, f: Formula, tex: str) -> dict[str, Any]:
tex = strip_delimiters(tex)
reason = unsafe_reason(tex)
if reason:
return {"ok": False, "error": reason}
orig = load_ink(source.original_bytes(_ref_for(source, f)), f.asset)
v = verify(orig, tex, f.display, preset, max_climb=0)
pngs = comparison_pngs(v.comparison)
pandoc = pandoc_mathml_ok([(tex, f.display)])[0]
return {"ok": v.ok, "error": v.reason, "metrics": {k: round(x, 3) for k, x in v.metrics.items()},
"min_contrast": v.min_contrast, "worst_variant": v.worst_variant, "pandoc": pandoc,
"images": {k: "data:image/png;base64," + base64.b64encode(b).decode() for k, b in pngs.items()}}
def _decide(self, f: Formula, action: str, tex: str) -> dict[str, Any]:
tex = strip_delimiters(tex)
if action in ("accept", "correct"):
reason = unsafe_reason(tex)
if reason:
return {"error": reason}
if not pandoc_mathml_ok([(tex, f.display)])[0]:
return {"error": "Pandoc kann diese Formel nicht nach MathML umsetzen"}
best = ((f.verification or {}).get("best") or {}).get("tex", "")
f.status = "accepted_by_human" if tex == best else "corrected_by_human"
f.final_tex = tex
elif action == "keep":
f.status, f.final_tex = "kept_by_human", None
elif action == "figure":
f.status, f.final_tex = "not_formula", None
elif action == "reset":
f.status, f.final_tex = "needs_review", None
else:
return {"error": "unbekannte Aktion"}
f.reviewer, f.decided_at = "review", now_iso()
f.reason = {"accept": "im Review bestätigt", "correct": "im Review korrigiert", "keep": "im Review: Bild behalten",
"figure": "im Review: keine Formel", "reset": "zurückgesetzt"}[action]
ws.save(f)
return {"ok": True, "status": f.status, "stats": ws.stats()}
httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
url = f"http://127.0.0.1:{port}/"
print(f"Review-Galerie: {url} (Beenden mit Strg+C)")
if open_browser:
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
finally:
httpd.server_close()
print("Review beendet. Nächster Schritt: apply")