SPB Git

Repository Public

server.py 46.9 KB 760 lines python

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
Files main
Menu

Theme