- 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>
161 lines
7.7 KiB
Python
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")
|