Repository Public
1#!/usr/bin/env python32"""htr.spboucher.ai — Human Text Reconstruction platform (research preview).34Standard-library HTTP server exposing several reconstruction models through one JSON API: a single-page5workbench, POST /api/reconstruct (with a `model` field), GET /api/models (cards + loaded flag), /api/info,6/api/stats (per-model counts and latency quantiles), the rendered model cards (/model-card, /model-card/<id>),7an SVG OpenGraph image (/og.svg) and /health.89Models live in models/<id>/ (one model_card.json each) and are served by htr.inference.backbones.ModelRegistry:10the default model is loaded at start-up, the others lazily on first request, with a bounded number of resident11models (LRU eviction, HTR-Tiny pinned). One inference lock per model; inputs are capped and rate-limited per12client address (lower limit for the pretrained backbones). Every output carries the synthetic-reconstruction13notice. No third-party web dependencies: everything (CSS, JS, icons, OG image) is self-hosted under static/.14"""15from __future__ import annotations1617import argparse18import difflib19import gzip20import hashlib21import html22import json23import logging24import os25import re26import sys27import threading28import time29from collections import deque30from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer31from pathlib import Path32from urllib.parse import urlparse3334ROOT = Path(__file__).resolve().parent35sys.path.insert(0, str(ROOT))36os.environ.setdefault("HF_HUB_OFFLINE", "1")37os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")38os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")3940from htr.inference.backbones import NOTICE, ModelRegistry # noqa: E4024142VERSION = "0.4.0"43MAX_CHARS = 800044RATE_PER_MIN = 30 # all models, per client address45RATE_BACKBONE_PER_MIN = 10 # pretrained backbones (hf-seq2seq, hf-causal), per client address46MAX_RESIDENT = 2 # non-pinned models kept in memory (LRU)47PINNED = ("htr-tiny-0.3.0",)48MAX_GEN_S = float(os.environ.get("HTR_MAX_GEN_S", "55")) # hard cap on backbone generation time per request49STATIC = ROOT / "static"50MODELS_DIR = Path(os.environ.get("HTR_MODELS_DIR", ROOT / "models"))51PUBLIC_URL = os.environ.get("HTR_PUBLIC_URL", "https://htr.spboucher.ai")52LOG = logging.getLogger("htr")53CSP = ("default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; "54 "base-uri 'none'; form-action 'self'; frame-ancestors 'none'")55CTYPES = {56 "css": "text/css; charset=utf-8", "js": "text/javascript; charset=utf-8", "svg": "image/svg+xml",57 "png": "image/png", "ico": "image/x-icon", "json": "application/json; charset=utf-8",58 "webmanifest": "application/manifest+json", "txt": "text/plain; charset=utf-8", "html": "text/html; charset=utf-8",59}60COMPRESSIBLE = ("text/", "application/json", "application/manifest+json", "image/svg+xml")61FAMILY_LABEL = {"htr": "HTR Transformer (trained from scratch)", "hf-seq2seq": "pretrained encoder–decoder backbone",62 "hf-causal": "pretrained decoder-only backbone"}63BACKBONE_CAVEAT = ("Pretrained backbone: its pretraining corpus may contain pre-2018 scientific text; memorisation "64 "controls cover only the HTR training pairs (see the paper's threats-to-validity section).")65SYNTHETIC_SENTENCE = "Outputs are synthetic reconstructions; they are not the original text and not evidence of human authorship or of AI use."66PANGRAM_SENTENCE = ("This measures one commercial detector's behaviour on a 54-passage sample. A low AI-likelihood is not evidence of "67 "human authorship, and HTR must not be used to evade AI-text detection or for integrity decisions.")68# Detector-sensitivity reference conditions (artifacts/metrics/pangram_test*/pangram_summary.md, Pangram 4.1, n = 54 test passages).69PANGRAM_REFERENCE = {"detector": "Pangram 4.1", "date": "2026-11-10", "n": 54, "note": PANGRAM_SENTENCE,70 "originals": {"label": "human originals", "human_share": 1.0, "flagged_share": 0.0, "mean_ai_likelihood": 0.0},71 "rewrites": {"label": "LLM rewrites (model input)", "human_share": round(29 / 54, 4), "flagged_share": round(25 / 54, 4), "mean_ai_likelihood": 0.1875}}72PAPER_TITLE = "Human Text Reconstruction: Learning the Inverse of LLM-Induced Transformations in Scientific Writing"7374EXAMPLES = [75 {76 "label": "Biomedicine · methods",77 "discipline": "biomedicine", "pub_year": 2014, "section_role": "methods",78 "text": "To assess whether PTE2 differed significantly across the experimental conditions NAA, AA, AA+ and NAA+, a one-way repeated measures ANOVA was carried out. Because the PTE2 values deviated from a normal distribution (Shapiro-Wilk's test, p < 0.05 for all conditions), the data were log-transformed to achieve normality. Mauchly's test indicated that the assumption of sphericity was satisfied, X2(5) = 8.62, p = 0.125.",79 },80 {81 "label": "Economics · results",82 "discipline": "economics", "pub_year": 2015, "section_role": "results",83 "text": "Across all specifications, the estimated elasticity of labour supply with respect to the net wage remains positive and statistically significant, ranging from 0.18 to 0.24. Introducing region-by-year fixed effects reduces the point estimate only marginally, which suggests that unobserved local shocks are unlikely to drive the result. The effect is concentrated among secondary earners, consistent with the predictions of the collective household model [12].",84 },85 {86 "label": "Finance · introduction",87 "discipline": "finance", "pub_year": 2012, "section_role": "introduction",88 "text": "A large body of work documents that momentum strategies earn abnormal returns in equity markets, yet the sources of these returns remain contested. In this paper we revisit the question using daily data for 1,842 firms between 1995 and 2010 and show that roughly 40% of the momentum premium is concentrated in the three days surrounding earnings announcements.",89 },90]919293class Limiter:94 def __init__(self, per_min: int):95 self.per_min = per_min96 self.hits: dict[str, list[float]] = {}97 self.lock = threading.Lock()9899 def allow(self, key: str) -> tuple[bool, int]:100 """Return (allowed, remaining) for this client in the current 60 s window."""101 now = time.time()102 with self.lock:103 if len(self.hits) > 5000: # drop idle clients so the table cannot grow without bound104 self.hits = {k: v for k, v in self.hits.items() if v and now - v[-1] < 60}105 h = [t for t in self.hits.get(key, []) if now - t < 60]106 if len(h) >= self.per_min:107 self.hits[key] = h108 return False, 0109 h.append(now)110 self.hits[key] = h111 return True, self.per_min - len(h)112113114def word_diff(a: str, b: str) -> str:115 aw, bw = a.split(), b.split()116 sm = difflib.SequenceMatcher(None, aw, bw, autojunk=False)117 out = []118 for op, i1, i2, j1, j2 in sm.get_opcodes():119 if op == "equal":120 out.append(html.escape(" ".join(aw[i1:i2])))121 else:122 if i2 > i1:123 out.append("<del>" + html.escape(" ".join(aw[i1:i2])) + "</del>")124 if j2 > j1:125 out.append("<ins>" + html.escape(" ".join(bw[j1:j2])) + "</ins>")126 return " ".join(out)127128129def diff_stats(a: str, b: str) -> dict:130 aw, bw = a.split(), b.split()131 sm = difflib.SequenceMatcher(None, aw, bw, autojunk=False)132 added = removed = 0133 for op, i1, i2, j1, j2 in sm.get_opcodes():134 if op != "equal":135 removed += i2 - i1136 added += j2 - j1137 return {"words_in": len(aw), "words_out": len(bw), "words_added": added, "words_removed": removed,138 "word_similarity": round(sm.ratio(), 4)}139140141def quantiles(lat) -> dict:142 lat = sorted(lat)143144 def q(p: float):145 return round(lat[min(len(lat) - 1, int(p * len(lat)))], 3) if lat else None146 return {"p50": q(0.5), "p95": q(0.95), "max": lat[-1] if lat else None, "window": len(lat)}147148149# ---------------------------------------------------------------- minimal Markdown → HTML (model card)150def _inline(s: str) -> str:151 s = html.escape(s, quote=False)152 s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)153 s = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", s)154 s = re.sub(r"(?<![*\w])\*([^*\n]+)\*(?!\w)", r"<em>\1</em>", s)155 s = re.sub(r"\[([^\]]+)\]\((https?://[^)\s]+|/[^)\s]*)\)", r'<a href="\2">\1</a>', s)156 return s157158159def _metrics_table(obj) -> str | None:160 """Render a {metric: {mean, ci}} evaluation block (with optional _baseline_* dict) as an HTML table."""161 if not isinstance(obj, dict):162 return None163 rows = {k: v for k, v in obj.items() if isinstance(v, dict) and "mean" in v}164 if not rows:165 return None166 base = next((v for k, v in obj.items() if k.startswith("_baseline") and isinstance(v, dict)), {})167 fmt = lambda x: f"{x:.2f}" if abs(x) >= 10 else f"{x:.4f}" # noqa: E731168 body = ""169 for k, v in rows.items():170 ci = v.get("ci") or [None, None]171 b = base.get(k)172 body += (f"<tr><td><code>{html.escape(k)}</code></td><td class=\"num\">{fmt(v['mean'])}</td>"173 f"<td class=\"num\">[{fmt(ci[0])}, {fmt(ci[1])}]</td><td class=\"num\">{fmt(b) if isinstance(b, (int, float)) else '—'}</td></tr>")174 return ('<div class="table-wrap"><table><thead><tr><th>Metric</th><th class="num">Mean</th><th class="num">95 % CI</th>'175 f'<th class="num">Identity baseline</th></tr></thead><tbody>{body}</tbody></table></div>')176177178def render_markdown(md: str) -> str:179 md = re.sub(r"\A---\n.*?\n---\n", "", md, flags=re.S) # YAML front matter180 lines, out, i = md.splitlines(), [], 0181 while i < len(lines):182 ln = lines[i]183 if ln.startswith("```"):184 lang, buf = ln[3:].strip(), []185 i += 1186 while i < len(lines) and not lines[i].startswith("```"):187 buf.append(lines[i])188 i += 1189 i += 1190 code = "\n".join(buf)191 if lang == "json":192 try:193 obj = json.loads(code)194 table = _metrics_table(obj)195 if table:196 out.append(table)197 continue198 code = json.dumps(obj, indent=2)199 except ValueError:200 pass201 out.append(f'<pre><code data-lang="{html.escape(lang)}">{html.escape(code)}</code></pre>')202 continue203 m = re.match(r"(#{1,4})\s+(.*)", ln)204 if m:205 lvl = min(len(m.group(1)) + 1, 4)206 slug = re.sub(r"[^a-z0-9]+", "-", m.group(2).lower()).strip("-")207 out.append(f'<h{lvl} id="{slug}">{_inline(m.group(2))}</h{lvl}>')208 i += 1209 continue210 if ln.startswith("|"):211 rows = []212 while i < len(lines) and lines[i].startswith("|"):213 cells = [c.strip() for c in lines[i].strip().strip("|").split("|")]214 if not all(re.fullmatch(r":?-{2,}:?", c) for c in cells):215 rows.append(cells)216 i += 1217 if rows:218 head = "".join(f"<th>{_inline(c)}</th>" for c in rows[0])219 body = "".join("<tr>" + "".join(f"<td>{_inline(c)}</td>" for c in r) + "</tr>" for r in rows[1:])220 out.append(f'<div class="table-wrap"><table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table></div>')221 continue222 if re.match(r"\s*[-*]\s+", ln):223 items = []224 while i < len(lines) and re.match(r"\s*[-*]\s+", lines[i]):225 items.append(re.sub(r"\s*[-*]\s+", "", lines[i], count=1))226 i += 1227 while i < len(lines) and lines[i].strip() and not re.match(r"(#{1,4}\s|```|\||\s*[-*]\s)", lines[i]):228 items[-1] += " " + lines[i].strip() # wrapped continuation line229 i += 1230 out.append("<ul>" + "".join(f"<li>{_inline(x)}</li>" for x in items) + "</ul>")231 continue232 if not ln.strip():233 i += 1234 continue235 buf = []236 while i < len(lines) and lines[i].strip() and not re.match(r"(#{1,4}\s|```|\||\s*[-*]\s)", lines[i]):237 buf.append(lines[i])238 i += 1239 out.append(f"<p>{_inline(' '.join(buf))}</p>")240 return "\n".join(out)241242243def _fmt_params(n) -> str:244 if not n:245 return "—"246 return f"{n / 1e9:.2f} B" if n >= 1e9 else f"{n / 1e6:.1f} M"247248249def _fmt_delta(v, pm=None, ci=None) -> str:250 if v is None:251 return "—"252 s = f"{v:+.2f}"253 if ci:254 s += f" [{ci[0]:+.2f}, {ci[1]:+.2f}]"255 elif pm is not None:256 s += f" ± {pm:.2f}"257 return s258259260def card_markdown(card: dict) -> str:261 """Model card in Markdown built from model_card.json (used when a model ships no README.md)."""262 m = card.get("metrics", {})263 fam = card.get("family", "")264 lines = [f"# {card.get('name', card['id'])}", "",265 "**Human Text Reconstruction** — a research model that maps an LLM-rewritten scientific passage back towards "266 "the register of the pre-2018 human-authored original. Outputs are **synthetic reconstructions**: they are not "267 "the original text, and they are not evidence of human authorship or of AI use. Part of UQO working paper WP-AI-0002.", "",268 "| Field | Value |", "|---|---|",269 f"| Model id | `{card['id']}` |",270 f"| Family | `{fam}` — {FAMILY_LABEL.get(fam, fam)} |",271 f"| Parameters | {_fmt_params(card.get('n_params'))} |",272 f"| Base model | {card.get('base_model') or 'none (trained from scratch)'} |",273 f"| Licence | {card.get('licence', '—')} |",274 f"| Default on this server | {'yes' if card.get('default') else 'no'} |",275 f"| Speed | {card.get('speed_hint', '—')} |",276 f"| Decoding | {'greedy or beam 3' if fam == 'htr' else 'greedy (beam search not offered)'} |",277 f"| Context window | {card.get('max_len') or card.get('max_new_tokens') or '—'} tokens |",278 f"| Exported | {card.get('exported_at', '—')} |", ""]279 if card.get("trained_on"):280 lines += ["## Training data", "", card["trained_on"], ""]281 if m:282 lines += ["## Evaluation (Δ chrF++ vs the identity baseline B0, Medium Haiku pairs)", "",283 "| Metric | Value |", "|---|---|",284 f"| Validation Δ chrF++ | {_fmt_delta(m.get('val_chrf_delta'), m.get('val_chrf_pm'))} |",285 f"| Test Δ chrF++ {'(confirmatory)' if m.get('test_confirmatory') else '(exploratory)'} | {_fmt_delta(m.get('test_chrf_delta'), m.get('test_chrf_pm'), m.get('test_chrf_ci'))} |",286 f"| Held-out domain Δ chrF++ | {_fmt_delta(m.get('heldout_domain_chrf_delta'), m.get('heldout_domain_pm'))} |",287 f"| Held-out period Δ chrF++ | {_fmt_delta(m.get('heldout_period_chrf_delta'), m.get('heldout_period_pm'))} |",288 f"| Number recall | {m.get('number_recall', '—')} |",289 f"| Placeholder exactness | {m.get('placeholders_exact', '—')} |",290 f"| Pangram share labelled Human | {m.get('pangram_human', '—')}/{m.get('pangram_n', '—')}"291 + (f" ({m['pangram_mixed']} Mixed)" if m.get("pangram_mixed") else "") + " |", "",292 m.get("reference", ""), ""]293 pg = m.get("pangram")294 if pg:295 lines += [f"## Detector sensitivity — {pg.get('detector', 'Pangram')} (n = {pg.get('n')})", "",296 f"- Outputs labelled Human: {pg['labels']['human']}/{pg['n']} ({pg['human_share'] * 100:.1f} %); flagged (Mixed or AI): {pg['flagged_share'] * 100:.1f} %",297 f"- Mean AI-likelihood score: {pg['mean_ai_likelihood']:.4f}" + (f" [95 % CI {pg['mean_ai_likelihood_ci'][0]:.4f}, {pg['mean_ai_likelihood_ci'][1]:.4f}]" if pg.get("mean_ai_likelihood_ci") else ""),298 f"- Reference: LLM rewrites (model input) {PANGRAM_REFERENCE['rewrites']['flagged_share'] * 100:.1f} % flagged, mean {PANGRAM_REFERENCE['rewrites']['mean_ai_likelihood']:.4f}; human originals 0 % flagged, mean 0.0000",299 "", PANGRAM_SENTENCE, ""]300 lines += ["## Limitations", "",301 "- Reconstruction from a paraphrase is non-identifiable: many originals map to the same rewrite; the model returns one plausible estimate.",302 "- Factual integrity is enforced heuristically (protected spans, number checks); residual errors are flagged in `warnings`, not guaranteed absent. Always verify numbers, citations and negations.",303 "- Not intended for AI-text detection, authorship attribution, academic-integrity decisions, or production of text presented as human-written."]304 for n in card.get("notes", []) if isinstance(card.get("notes"), list) else [card.get("notes")] if card.get("notes") else []:305 lines.append(f"- {n}")306 lines += ["", "## Citation", "", "Boucher, S.-P. (2026). *Human Text Reconstruction: Learning the Inverse of LLM-Induced "307 "Transformations in Scientific Writing.* UQO Working Paper AI-0002."]308 return "\n".join(lines)309310311def _h(x) -> str:312 return html.escape(str(x if x is not None else "—"), quote=True)313314315def _fmt_pct(x, digits: int = 1) -> str:316 return "—" if x is None else f"{x * 100:.{digits}f} %"317318319def card_html(card: dict, cards: list[dict]) -> str:320 """Structured, server-rendered model card (header band, metric tiles, Pangram block, bar comparison, sections)."""321 mid, m, fam = card["id"], card.get("metrics", {}), card.get("family", "")322 bb = fam != "htr"323 pg = m.get("pangram") or {}324 ref = PANGRAM_REFERENCE325 fam_label = FAMILY_LABEL.get(fam, fam)326 decoding = "greedy or beam search (3 beams); seeded, deterministic" if not bb else "greedy decoding only (beam requests are mapped to greedy and the response says so); seeded, deterministic"327 window = f"{card.get('max_len')} tokens read" if card.get("max_len") else (f"up to {card.get('max_new_tokens')} new tokens" if card.get("max_new_tokens") else "—")328 # ---- header band329 badges = [f'<span class="badge badge-fam">{_h(fam_label)}</span>']330 if card.get("default"):331 badges.append('<span class="badge badge-accent">Default on this server</span>')332 badges.append('<span class="badge">Research preview</span>')333 if pg and pg.get("flagged_share") == 0:334 badges.append(f'<span class="badge badge-ok">0 % flagged — {pg["labels"]["human"]}/{pg["n"]} labelled Human, mean AI score {pg["mean_ai_likelihood"]:.3f}</span>')335 facts = [("Parameters", _fmt_params(card.get("n_params"))), ("Base model", card.get("base_model") or "none — trained from scratch"),336 ("Licence", card.get("licence", "—")), ("Exported", str(card.get("exported_at", "—"))[:10]), ("Model id", f"<code>{_h(mid)}</code>"), ("Context", window)]337 facts_html = "".join(f'<div class="fact{" fact-wide" if k == "Licence" else ""}"><dt>{_h(k)}</dt><dd>{v if k == "Model id" else _h(v)}</dd></div>' for k, v in facts)338 nav = "".join(f'<a class="chip{" is-current" if c["id"] == mid else ""}" href="/model-card/{_h(c["id"])}">{_h(c.get("name", c["id"]))}</a>' for c in cards)339 head = (f'<p class="kicker">Model card</p><h1 class="mch-title">{_h(card.get("name", mid))}</h1>'340 f'<div class="mch-badges">{"".join(badges)}</div><dl class="mch-facts">{facts_html}</dl>'341 f'<nav class="mch-nav" aria-label="Model cards"><span class="muted small">All models:</span>{nav}</nav>')342 # ---- metric tiles343 def tile(label, value, sub="", cls=""):344 return f'<div class="mt{(" " + cls) if cls else ""}"><dt>{_h(label)}</dt><dd>{value}</dd>{f"<p>{sub}</p>" if sub else ""}</div>'345 tiles = [346 tile("Test Δ chrF++ vs identity", _h(_fmt_delta(m.get("test_chrf_delta"), m.get("test_chrf_pm"), m.get("test_chrf_ci"))),347 "confirmatory, document-safe test split" if m.get("test_confirmatory") else "exploratory, document-safe test split", "mt-accent"),348 tile("Held-out domain Δ", _h(_fmt_delta(m.get("heldout_domain_chrf_delta"), m.get("heldout_domain_pm"))), "chrF++ vs identity, unseen discipline"),349 tile("Held-out period Δ", _h(_fmt_delta(m.get("heldout_period_chrf_delta"), m.get("heldout_period_pm"))), "chrF++ vs identity, unseen era"),350 tile("Number recall", _h(_fmt_pct(m.get("number_recall"))), "numbers of the input kept"),351 tile("Placeholder exactness", _h(_fmt_pct(m.get("placeholders_exact"))), "citations, equations, URLs restored"),352 tile("Pangram: labelled Human", f'{pg["labels"]["human"]}/{pg["n"]}' if pg else "—", f'{_h(pg.get("detector", ""))}, n = {pg.get("n", "—")} test passages' if pg else ""),353 ]354 tiles_html = f'<dl class="mtiles">{"".join(tiles)}</dl>'355 # ---- Pangram block356 if pg:357 flagged = pg["flagged_share"]358 zero = flagged == 0359 pg_badge = (f'<span class="pg-badge ok">0 % flagged — {pg["labels"]["human"]}/{pg["n"]} labelled Human, mean AI score {pg["mean_ai_likelihood"]:.3f}</span>' if zero360 else f'<span class="pg-badge">{_fmt_pct(flagged)} flagged — {pg["labels"]["human"]}/{pg["n"]} labelled Human ({pg["labels"]["mixed"]} Mixed), mean AI score {pg["mean_ai_likelihood"]:.3f}</span>')361 ci = pg.get("mean_ai_likelihood_ci")362 rows = [("Human originals", ref["originals"]), ("LLM rewrites (model input)", ref["rewrites"]), (card.get("name", mid) + " output", pg)]363 flow = "".join(f'<div class="pg-row{" is-this" if i == 2 else ""}"><span class="pg-lbl">{_h(lbl)}</span><span class="pg-bar"><i style="width:{min(100, r["flagged_share"] * 100):.1f}%"></i></span>'364 f'<span class="pg-val">{_fmt_pct(r["flagged_share"])} flagged · mean {r["mean_ai_likelihood"]:.3f}</span></div>' for i, (lbl, r) in enumerate(rows))365 pangram_html = (f'<section class="cs pg" aria-labelledby="pg-t"><h2 id="pg-t">Detector sensitivity · {_h(pg.get("detector", "Pangram"))} (n = {pg["n"]} test passages)</h2>'366 f'<div class="pg-head">{pg_badge}</div>'367 f'<div class="pg-nums"><div><dt>Share labelled Human</dt><dd>{_fmt_pct(pg["human_share"])}</dd><p>{pg["labels"]["human"]} Human · {pg["labels"]["mixed"]} Mixed · {pg["labels"]["ai"]} AI</p></div>'368 f'<div><dt>Mean AI-likelihood score</dt><dd>{pg["mean_ai_likelihood"]:.4f}</dd><p>{("95 % CI [" + f"{ci[0]:.4f}, {ci[1]:.4f}" + "]") if ci else "word-weighted window score, 0 = human … 1 = AI"}</p></div>'369 f'<div><dt>Input → output</dt><dd>{_fmt_pct(ref["rewrites"]["flagged_share"], 0)} → {_fmt_pct(flagged, 0)}</dd><p>share of passages flagged (Mixed or AI), rewrites vs this model</p></div></div>'370 f'<div class="pg-flow">{flow}</div>'371 f'<p class="pg-note"><svg aria-hidden="true"><use href="#i-info"/></svg><span>{_h(PANGRAM_SENTENCE)}</span></p>'372 f'<p class="muted small">Source: {_h(pg.get("source", ""))}; measured {_h(pg.get("date", ""))}.</p></section>')373 else:374 pangram_html = ""375 # ---- bar comparison (test Δ chrF++ vs identity, this model vs identity vs the others)376 entries = [("Identity baseline (input unchanged)", 0.0, None, "base")] + [(c.get("name", c["id"]), (c.get("metrics") or {}).get("test_chrf_delta"), c["id"], "this" if c["id"] == mid else "other") for c in cards]377 entries = [e for e in entries if e[1] is not None]378 vmax = max(0.5, max(abs(e[1]) for e in entries)) * 1.12379 bars = ""380 for name, v, cid, kind in sorted(entries, key=lambda e: -e[1] if e[1] is not None else 0):381 w = abs(v) / vmax * 100382 lbl = f'<a href="/model-card/{_h(cid)}">{_h(name)}</a>' if cid and kind == "other" else _h(name)383 bars += (f'<div class="bar-row {kind}"><span class="bar-lbl">{lbl}</span><span class="bar-track"><i style="width:{w:.1f}%"></i>'384 f'<b>{"±0.00" if v == 0 else f"{v:+.2f}"}</b></span></div>')385 bars_html = (f'<section class="cs" aria-labelledby="cmp-t"><h2 id="cmp-t">Test Δ chrF++ versus the identity baseline</h2>'386 f'<p class="muted">Differences in chrF++ against returning the input unchanged (B0), Medium Haiku pairs. Exploratory except HTR-Tiny (confirmatory). Larger is better; the identity baseline is 0 by construction.</p>'387 f'<div class="bars">{bars}</div></section>')388 # ---- text sections389 limits = ["Reconstruction from a paraphrase is non-identifiable: many originals map to the same rewrite; the model returns one plausible estimate, and lexical gains over the input are small by construction.",390 "Factual integrity is enforced heuristically (protected spans, number checks); residual errors are flagged in <code>warnings</code>, not guaranteed absent. Verify every number, citation and negation against your source.",391 SYNTHETIC_SENTENCE + " Do not use it for AI-text detection, authorship attribution, academic-integrity decisions, or to produce text presented as human-written."]392 if bb:393 limits.insert(0, BACKBONE_CAVEAT)394 if card.get("max_len"):395 limits.append(f"Reads at most {card['max_len']} tokens (about 2 000 characters): longer inputs are truncated and a warning is returned.")396 notes = card.get("notes") if isinstance(card.get("notes"), list) else ([card["notes"]] if card.get("notes") else [])397 notes = [n for n in notes if n not in (BACKBONE_CAVEAT,)]398 curl = (f"curl -s {PUBLIC_URL}/api/reconstruct \\\n -H 'Content-Type: application/json' \\\n -d '{{\"text\": \"…LLM-rewritten passage…\", \"model\": \"{mid}\",\n \"discipline\": \"economics\", \"pub_year\": 2012"399 + (", \"beam\": 3" if not bb else "") + "}'")400 py = (f"import json, urllib.request\nreq = urllib.request.Request(\"{PUBLIC_URL}/api/reconstruct\",\n data=json.dumps({{\"text\": \"…LLM-rewritten passage…\", \"model\": \"{mid}\"}}).encode(),\n"401 f" headers={{\"Content-Type\": \"application/json\"}})\nr = json.load(urllib.request.urlopen(req))\nprint(r[\"model\"], r[\"latency_s\"], \"s\")\nprint(r[\"reconstruction\"]); print(r[\"warnings\"])")402 others = "".join(f'<li><a href="/model-card/{_h(c["id"])}"><strong>{_h(c.get("name", c["id"]))}</strong></a> <span class="muted">— {_h(FAMILY_LABEL.get(c.get("family", ""), c.get("family", "")))}, {_h(_fmt_params(c.get("n_params")))}, test Δ {_h(_fmt_delta((c.get("metrics") or {}).get("test_chrf_delta")))}</span></li>'403 for c in cards if c["id"] != mid)404 body = f"""{tiles_html}405{pangram_html}406{bars_html}407<section class="cs" aria-labelledby="tr-t"><h2 id="tr-t">What it was trained on</h2><p>{_h(card.get("trained_on", "—"))}</p>{"".join(f"<p>{_h(n)}</p>" for n in notes)}</section>408<section class="cs" aria-labelledby="dec-t"><h2 id="dec-t">How it decodes</h2><p>Citations, equations and URLs are protected behind placeholders{" spelled <code>[[C1]]</code> for the pretrained tokenizer" if bb else ""}; the context (discipline, era, section) is {"given as a text prefix" if bb else ("encoded as conditioning tokens" if card.get("arch") in ("m2", "m3") else "recorded with the run but not used by this architecture (m1)")}. Decoding: {decoding}. {window.capitalize()}. Placeholders are restored, then numbers, citations, negations, hedges and length are compared with the input and every deviation is returned as a warning.</p><p class="muted small">Speed on this server: {_h(card.get("speed_hint", "—"))}. Rate limit: {RATE_BACKBONE_PER_MIN if bb else RATE_PER_MIN} requests per minute per client.</p></section>409<section class="cs" aria-labelledby="lim-t"><h2 id="lim-t">Known limits</h2><ul class="limits-ul">{"".join(f"<li>{x}</li>" for x in limits)}</ul></section>410<section class="cs" aria-labelledby="call-t"><h2 id="call-t">Call it</h2><div class="code-card static"><div class="code-head"><span>curl</span></div><pre><code>{html.escape(curl)}</code></pre><div class="code-head"><span>Python</span></div><pre><code>{html.escape(py)}</code></pre></div><p class="muted small">Model ids: <a href="/api/models">/api/models</a>. <a href="/#api">Full API reference</a>.</p></section>411<section class="cs" aria-labelledby="oth-t"><h2 id="oth-t">Other models and the paper</h2><ul class="others">{others}</ul><p>Paper: <em>{_h(PAPER_TITLE)}</em>, UQO Working Paper WP-AI-0002 (Simon-Pierre Boucher, Université du Québec en Outaouais, 2026). <a href="/#research">Citation</a>.</p></section>"""412 return head, body413414415class ModelStats:416 def __init__(self):417 self.n = 0418 self.errors = 0419 self.lat: deque[float] = deque(maxlen=300)420421422class App:423 def __init__(self, models_dir: Path, device: str):424 self.device = device425 self.registry = ModelRegistry(models_dir, device=device, max_resident=MAX_RESIDENT, pinned=PINNED)426 if not self.registry.ids():427 raise SystemExit(f"no model found under {models_dir} (expected models/<id>/model_card.json)")428 self.default_id = self.registry.default_id()429 self.locks = {mid: threading.Lock() for mid in self.registry.ids()}430 self.mstats = {mid: ModelStats() for mid in self.registry.ids()}431 self.tuned: set[str] = set()432 self.limiter = Limiter(RATE_PER_MIN)433 self.limiter_hf = Limiter(RATE_BACKBONE_PER_MIN)434 self.started = time.time()435 LOG.info("registry: %d models %s, default %s, device %s", len(self.registry.ids()), self.registry.ids(), self.default_id, device)436 demo_id = self.default_id437 try:438 self._get(self.default_id)439 except Exception: # noqa: BLE001440 LOG.exception("default model %s failed to load", self.default_id)441 demo_id = PINNED[0] if PINNED[0] in self.registry.cards else next(iter(self.registry.cards))442 if demo_id != self.default_id:443 self._get(demo_id)444 # Live demo pair for the hero: a real model output computed once at start-up (not counted in stats).445 try:446 ex = EXAMPLES[0]447 t1 = time.time()448 r = self._get(demo_id).reconstruct(ex["text"], {k: ex[k] for k in ("discipline", "pub_year", "section_role")}, beam=1)449 self.demo = {"label": ex["label"], "input": ex["text"], "output": r.reconstruction,450 "diff_html": word_diff(ex["text"], r.reconstruction), "latency_s": round(time.time() - t1, 2),451 "warnings": r.warnings, "model": self.name(demo_id), "model_id": demo_id}452 LOG.info("demo pair computed with %s in %.2fs", demo_id, self.demo["latency_s"])453 except Exception: # noqa: BLE001454 LOG.exception("demo generation failed")455 self.demo = None456457 # ---- registry helpers458 def _get(self, mid: str):459 """Resident reconstructor for `mid` (loads it on first use; logs the load time; tunes HF generation once)."""460 was_loaded = self.registry.is_loaded(mid)461 rec = self.registry.get(mid)462 if not was_loaded:463 LOG.info("model %s loaded in %.1fs on %s (%s)", mid, self.registry.load_s.get(mid, 0.0), self.device, type(rec).__name__)464 if mid not in self.tuned:465 self.tuned.add(mid)466 gc = getattr(getattr(rec, "model", None), "generation_config", None)467 if gc is not None and self.family(mid) != "htr":468 gc.max_time = MAX_GEN_S # hard wall-clock cap per request (MaxTimeCriteria)469 for k in ("temperature", "top_p", "top_k"): # sampling knobs inherited from the base model's config470 if hasattr(gc, k):471 setattr(gc, k, None)472 gc.do_sample = False473 return rec474475 def family(self, mid: str) -> str:476 return str(self.registry.cards[mid].get("family", "htr"))477478 def name(self, mid: str) -> str:479 return str(self.registry.cards[mid].get("name") or mid)480481 def is_backbone(self, mid: str) -> bool:482 return self.family(mid) != "htr"483484 def card(self, mid: str) -> dict:485 c = dict(self.registry.cards[mid])486 st = self.mstats[mid]487 c.update({"id": mid, "default": mid == self.default_id, "loaded": self.registry.is_loaded(mid),488 "load_s": self.registry.load_s.get(mid), "beam_supported": not self.is_backbone(mid),489 "rate_limit_per_min": RATE_BACKBONE_PER_MIN if self.is_backbone(mid) else RATE_PER_MIN,490 "requests_served": st.n, "latency_p50_s": quantiles(st.lat)["p50"]})491 if self.is_backbone(mid):492 c["caveat"] = BACKBONE_CAVEAT493 return c494495 def models(self) -> dict:496 return {"default": self.default_id, "device": self.device, "max_resident": MAX_RESIDENT, "pinned": list(PINNED),497 "pangram_reference": PANGRAM_REFERENCE, "synthetic_notice": SYNTHETIC_SENTENCE,498 "models": [self.card(mid) for mid in self.registry.ids()]}499500 def conditioning(self, mid: str) -> bool:501 if self.is_backbone(mid):502 return True # context is given to the backbone as a text prefix503 c = self.registry.cards[mid]504 return c.get("arch") in ("m2", "m3") or bool(c.get("text_meta_prefix"))505506 def info(self) -> dict:507 c = self.registry.cards[self.default_id]508 return {509 "name": c.get("name"), "model_id": self.default_id, "family": self.family(self.default_id), "version": c.get("version"),510 "arch": c.get("arch"), "n_params": c.get("n_params"), "base_model": c.get("base_model"), "max_len": c.get("max_len"),511 "exported_at": c.get("exported_at"), "metrics": c.get("metrics", {}), "trained_on": c.get("trained_on"),512 "notice": NOTICE, "max_chars": MAX_CHARS, "requests_served": sum(s.n for s in self.mstats.values()),513 "uptime_s": round(time.time() - self.started),514 "status": f"research preview — {len(self.registry.ids())} models, default {c.get('name')}",515 "conditioning": self.conditioning(self.default_id), "device": self.device,516 "rate_limit_per_min": RATE_PER_MIN, "rate_limit_backbone_per_min": RATE_BACKBONE_PER_MIN,517 "models": self.registry.ids(), "default": self.default_id, "loaded": [m for m in self.registry.ids() if self.registry.is_loaded(m)],518 "platform_version": VERSION,519 }520521 def stats(self) -> dict:522 all_lat = [x for s in self.mstats.values() for x in s.lat]523 return {"model": self.name(self.default_id), "default": self.default_id, "platform_version": VERSION,524 "requests_served": sum(s.n for s in self.mstats.values()), "errors": sum(s.errors for s in self.mstats.values()),525 "uptime_s": round(time.time() - self.started), "device": self.device,526 "latency_s": quantiles(all_lat),527 "models": {mid: {"name": self.name(mid), "family": self.family(mid), "loaded": self.registry.is_loaded(mid),528 "load_s": self.registry.load_s.get(mid), "requests": st.n, "errors": st.errors,529 "latency_s": quantiles(st.lat)} for mid, st in self.mstats.items()},530 "limits": {"max_chars": MAX_CHARS, "rate_per_min": RATE_PER_MIN, "rate_backbone_per_min": RATE_BACKBONE_PER_MIN,531 "max_body_bytes": 64_000, "concurrency": "1 per model", "max_generation_s": MAX_GEN_S}}532533 def reconstruct(self, mid: str, text: str, meta: dict, beam: int) -> dict:534 fam = self.family(mid)535 eff_beam = beam if fam == "htr" else 1536 t0 = time.time()537 st = self.mstats[mid]538 with self.locks[mid]:539 try:540 rec = self._get(mid)541 loaded_s = round(time.time() - t0, 2)542 res = rec.reconstruct(text, meta, beam=eff_beam)543 except Exception:544 st.errors += 1545 raise546 st.n += 1547 d = res.to_dict()548 if fam == "hf-seq2seq" and getattr(rec, "tok", None) is not None:549 try: # the encoder window is short: say so when the tail of the input was not read550 n_tok = len(rec.tok(text, add_special_tokens=False).input_ids)551 if n_tok + 32 > getattr(rec, "max_len", 512):552 d["warnings"].append(f"input longer than this model's {getattr(rec, 'max_len', 512)}-token window — the tail was not read (truncated)")553 except Exception: # noqa: BLE001554 pass555 d["diff_html"] = word_diff(text, res.reconstruction)556 d["diff_stats"] = diff_stats(text, res.reconstruction)557 d["latency_s"] = round(time.time() - t0, 2)558 d["model"] = self.name(mid)559 d["model_id"] = mid560 d["family"] = fam561 d["device"] = self.device562 d["beam"] = eff_beam563 d["beam_requested"] = beam564 d["decoding"] = "beam 3" if eff_beam == 3 else "greedy"565 if beam != eff_beam:566 d["decoding_note"] = "beam search is only offered for HTR models; this backbone used greedy decoding"567 if loaded_s > 1.0:568 d["load_s"] = loaded_s569 st.lat.append(d["latency_s"])570 return d571572573def _asset_versions() -> dict[str, str]:574 v = {}575 for name in ("app.css", "app.js"):576 f = STATIC / name577 v[name] = hashlib.sha256(f.read_bytes()).hexdigest()[:10] if f.exists() else VERSION578 return v579580581def _json_for_html(obj) -> str:582 return json.dumps(obj, ensure_ascii=False).replace("</", "<\\/").replace("<!--", "<\\!--")583584585class Handler(BaseHTTPRequestHandler):586 app: App = None # type: ignore[assignment]587 server_version = f"HTR/{VERSION}"588 head_only = False589590 def _send(self, code: int, body: bytes, ctype: str, cache: str = "no-store", extra: dict | None = None) -> None:591 enc = None592 if len(body) > 1024 and ctype.startswith(COMPRESSIBLE) and "gzip" in self.headers.get("Accept-Encoding", ""):593 body, enc = gzip.compress(body, 6), "gzip"594 self.send_response(code)595 self.send_header("Content-Type", ctype)596 self.send_header("Content-Length", str(len(body)))597 if enc:598 self.send_header("Content-Encoding", enc)599 self.send_header("Vary", "Accept-Encoding")600 self.send_header("Cache-Control", cache)601 self.send_header("X-Content-Type-Options", "nosniff")602 self.send_header("Referrer-Policy", "same-origin")603 self.send_header("Permissions-Policy", "interest-cohort=(), camera=(), microphone=(), geolocation=()")604 self.send_header("Content-Security-Policy", CSP)605 for k, v in (extra or {}).items():606 self.send_header(k, v)607 self.end_headers()608 if not self.head_only:609 self.wfile.write(body)610611 def _json(self, code: int, obj: dict, extra: dict | None = None) -> None:612 self._send(code, json.dumps(obj, ensure_ascii=False).encode(), "application/json; charset=utf-8", extra=extra)613614 def _client(self) -> str:615 return self.headers.get("X-Forwarded-For", self.client_address[0]).split(",")[0].strip()616617 def _page(self, name: str, subs: dict[str, str]) -> bytes:618 page = (STATIC / name).read_text()619 v = _asset_versions()620 subs = {"{{CSS_V}}": v["app.css"], "{{JS_V}}": v["app.js"], "{{URL}}": PUBLIC_URL, "{{VERSION}}": VERSION,621 "{{MODEL}}": html.escape(self.app.name(self.app.default_id)), "{{N_MODELS}}": str(len(self.app.registry.ids())), **subs}622 for k, val in subs.items():623 page = page.replace(k, val)624 return page.encode()625626 def _model_card_page(self, mid: str, raw: bool) -> None:627 d = self.app.registry.root / mid628 card = self.app.card(mid)629 readme = (d / "README.md").read_text() if (d / "README.md").exists() else ""630 if raw:631 return self._send(200, (readme or card_markdown(card)).encode(), "text/markdown; charset=utf-8", cache="no-cache")632 cards = sorted((self.app.card(m) for m in self.app.registry.ids()), key=lambda c: c.get("n_params") or 0)633 head, body = card_html(card, cards)634 if readme:635 body += ('<section class="cs card-doc" aria-labelledby="readme-t"><h2 id="readme-t">Release notes (README of the model directory)</h2>'636 f'<p class="muted small"><a href="/model-card/{html.escape(mid)}?raw">Markdown source</a></p>{render_markdown(readme)}</section>')637 subs = {"{{CARD_HEAD}}": head, "{{CARD_BODY}}": body, "{{CARD_NAME}}": html.escape(card.get("name", mid)), "{{CARD_ID}}": html.escape(mid),638 "{{CARD_DESC}}": html.escape(f"{card.get('name', mid)} — {FAMILY_LABEL.get(card.get('family', ''), '')}, {_fmt_params(card.get('n_params'))} parameters; test Δ chrF++ {_fmt_delta((card.get('metrics') or {}).get('test_chrf_delta'))} vs identity. Synthetic reconstructions, not evidence of authorship.")}639 return self._send(200, self._page("card.html", subs), "text/html; charset=utf-8", cache="no-cache")640641 def do_HEAD(self): # noqa: N802642 self.head_only = True643 self.do_GET()644645 def do_GET(self): # noqa: N802646 u = urlparse(self.path)647 p = u.path648 if p in ("/", "/index.html"):649 boot = {"examples": EXAMPLES, "demo": self.app.demo, "info": self.app.info(), "models": self.app.models(),650 "limits": {"max_chars": MAX_CHARS, "rate_per_min": RATE_PER_MIN, "rate_backbone_per_min": RATE_BACKBONE_PER_MIN}}651 return self._send(200, self._page("index.html", {"{{BOOT}}": _json_for_html(boot)}), "text/html; charset=utf-8", cache="no-cache")652 if p == "/health":653 return self._json(200, {"ok": True, "model": self.app.name(self.app.default_id), "default": self.app.default_id,654 "loaded": [m for m in self.app.registry.ids() if self.app.registry.is_loaded(m)],655 "requests": sum(s.n for s in self.app.mstats.values()), "version": VERSION})656 if p == "/api/info":657 return self._json(200, self.app.info())658 if p == "/api/models":659 return self._json(200, self.app.models())660 if p == "/api/stats":661 return self._json(200, self.app.stats())662 if p == "/api/examples":663 return self._json(200, {"examples": EXAMPLES})664 m = re.fullmatch(r"/model-card(?:/([A-Za-z0-9._-]+))?/?", p)665 if m:666 mid = m.group(1) or self.app.default_id667 if mid not in self.app.registry.cards:668 return self._send(404, self._page("404.html", {}), "text/html; charset=utf-8")669 return self._model_card_page(mid, "raw" in u.query)670 if p in ("/og.svg", "/favicon.svg", "/site.webmanifest", "/favicon.ico"):671 p = "/static" + ("/favicon.svg" if p == "/favicon.ico" else p)672 if p.startswith("/static/"):673 f = (STATIC / p[len("/static/"):]).resolve()674 if STATIC.resolve() in f.parents and f.is_file():675 ct = CTYPES.get(f.suffix.lstrip("."), "application/octet-stream")676 cache = "public, max-age=31536000, immutable" if "v=" in u.query else "public, max-age=3600"677 return self._send(200, f.read_bytes(), ct, cache=cache)678 if p == "/robots.txt":679 return self._send(200, "User-agent: *\nAllow: /\nDisallow: /api/\n".encode(), "text/plain; charset=utf-8")680 return self._send(404, self._page("404.html", {}), "text/html; charset=utf-8")681682 def do_OPTIONS(self): # noqa: N802683 self._send(204, b"", "text/plain", extra={"Allow": "GET, HEAD, POST, OPTIONS"})684685 def do_POST(self): # noqa: N802686 p = urlparse(self.path).path687 if p != "/api/reconstruct":688 return self._json(404, {"error": "not found"})689 try:690 n = int(self.headers.get("Content-Length", "0"))691 if n > 64_000:692 return self._json(413, {"error": "payload too large"})693 req = json.loads(self.rfile.read(n) or b"{}")694 if not isinstance(req, dict):695 raise ValueError696 except Exception: # noqa: BLE001697 return self._json(400, {"error": "invalid JSON"})698 mid = str(req.get("model") or self.app.default_id)699 if mid not in self.app.registry.cards:700 return self._json(400, {"error": f"unknown model {mid!r}", "models": self.app.registry.ids(), "default": self.app.default_id})701 client = self._client()702 backbone = self.app.is_backbone(mid)703 if backbone:704 ok_hf, rem_hf = self.app.limiter_hf.allow(client)705 if not ok_hf:706 return self._json(429, {"error": f"rate limit: {RATE_BACKBONE_PER_MIN} requests per minute for pretrained backbones (HTR models: {RATE_PER_MIN})"},707 extra={"X-RateLimit-Limit": str(RATE_BACKBONE_PER_MIN), "X-RateLimit-Remaining": "0", "Retry-After": "60"})708 ok, remaining = self.app.limiter.allow(client)709 limit = RATE_BACKBONE_PER_MIN if backbone else RATE_PER_MIN710 rl = {"X-RateLimit-Limit": str(limit), "X-RateLimit-Remaining": str(min(remaining, rem_hf) if backbone else remaining)}711 if not ok:712 return self._json(429, {"error": f"rate limit: {RATE_PER_MIN} requests per minute"}, extra={**rl, "Retry-After": "60"})713 text = (req.get("text") or "").strip() if isinstance(req.get("text"), str) else ""714 if not text:715 return self._json(400, {"error": "empty text"}, extra=rl)716 if len(text) > MAX_CHARS:717 return self._json(413, {"error": f"text longer than {MAX_CHARS} characters"}, extra=rl)718 meta = {}719 for k in ("discipline", "section_role"):720 if req.get(k):721 meta[k] = str(req[k])[:40]722 if req.get("pub_year"):723 try:724 meta["pub_year"] = max(1500, min(2017, int(req["pub_year"])))725 except (TypeError, ValueError):726 pass727 beam = 3 if str(req.get("beam", "1")) == "3" else 1728 try:729 out = self.app.reconstruct(mid, text, meta, beam)730 except Exception as e: # noqa: BLE001731 LOG.exception("reconstruct failed (%s)", mid)732 return self._json(500, {"error": f"inference failed: {type(e).__name__}", "model_id": mid}, extra=rl)733 LOG.info("%s model=%s chars=%d beam=%d %.2fs warnings=%d", client, mid, len(text), out["beam"], out["latency_s"], len(out["warnings"]))734 return self._json(200, out, extra=rl)735736 def log_message(self, *a): # quiet default access log737 pass738739740def main() -> None:741 import torch742743 ap = argparse.ArgumentParser()744 ap.add_argument("--host", default="0.0.0.0")745 ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", "8540")))746 ap.add_argument("--device", default=os.environ.get("HTR_DEVICE", "mps" if torch.backends.mps.is_available() else "cpu"))747 ap.add_argument("--models", default=str(MODELS_DIR), help="directory with one sub-directory (model_card.json) per model")748 a = ap.parse_args()749 (ROOT / "logs").mkdir(exist_ok=True)750 logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", handlers=[logging.StreamHandler(), logging.FileHandler(ROOT / "logs" / "server.log")], force=True) # a dependency already installs a root handler751 Handler.app = App(Path(a.models), a.device)752 srv = ThreadingHTTPServer((a.host, a.port), Handler)753 srv.daemon_threads = True754 LOG.info("htr platform %s listening on %s:%d (default model %s, device %s)", VERSION, a.host, a.port, Handler.app.default_id, a.device)755 srv.serve_forever()756757758if __name__ == "__main__":759 main()760