#!/usr/bin/env python3 """Генератор страницы слепого сравнения редакторов (эксперимент 04). ⚠ ГИГИЕНА СЛЕПОТЫ (D13.5 / 07-review §4.5 / POLYGON handoff Task 2): предыдущая версия была структурно НЕ-слепой при LLM-оценке — (1) ключ «метка→модель» и цена лежали В ТОМ ЖЕ артефакте (за
— для LLM-фетча это НЕ скрытие, весь DOM виден), (2) маппинг A/B/C/D был ЗАФИКСИРОВАН одинаково на всех фрагментах (узнав раз — знаешь везде). Исправлено: - метки рандомизируются ПО ФРАГМЕНТУ (детерминированная перестановка на соли, разная у каждого фрагмента) — узнать порядок на одном фрагменте не даёт его на других; - ключ (метка→модель) + цена/латентность пишутся в ОТДЕЛЬНЫЙ файл (--key), КОТОРЫЙ НЕ ПУБЛИКУЕТСЯ до вынесения оценки; в самом артефакте НЕТ ни имён моделей, ни цены; - соль генерится случайно на сборку и хранится ТОЛЬКО в ключ-файле → даже код-осведомлённый оценщик не инвертирует перестановку без ключа. Читает eval/data/editor_bench//*.txt, эмитит слепой HTML (контент для Artifact-обёртки) и рядом — приватный ключ-файл. Запуск: eval/.venv/bin/python eval/build_editor_artifact.py [--key ] """ import argparse import hashlib import html import json import re import secrets from pathlib import Path ROOT = Path(__file__).resolve().parent BENCH = ROOT / "data" / "editor_bench" MODELS = ["grok-4.3", "glm-4.6", "gemini-3.1-pro", "kimi-k2.6"] LABELS = ["A", "B", "C", "D"] # стоимость/латентность — В КЛЮЧ-ФАЙЛ, НЕ в артефакт (цена коррелирует с моделью → утечка слепоты) COST = {"grok-4.3": "~13 с, без «размышлений» — самый быстрый", "glm-4.6": "~105 с (thinking off)", "gemini-3.1-pro": "~60–86 с, обязательный thinking", "kimi-k2.6": "~150 с, ~11 000 токенов «размышлений» на абзац — самый дорогой"} FRAGMENTS = [ ("rashomon", "японский → русский", "Акутагава Рюноскэ, «Расёмон»", False), ("ahq", "китайский → русский", "Лу Синь, «Подлинная история А-Кью»", False), ("merosu", "японский → русский", "Дадзай Осаму, «Беги, Мелос!»", False), ("wells", "английский → русский", "Г. Уэллс, «Машина времени»", True), ] def label_map(salt: str, frag: str) -> list[tuple[str, str]]: """Детерминированная перестановка меток ПО ФРАГМЕНТУ на секретной соли: сортируем модели по sha256(salt|frag|model) и присваиваем A,B,C,D. Разная у каждого фрагмента; без соли (в ключе) не инвертируется. Возвращает [(label, model), …] в порядке меток.""" order = sorted(MODELS, key=lambda m: hashlib.sha256(f"{salt}|{frag}|{m}".encode()).hexdigest()) return list(zip(LABELS, order)) def paras(text: str) -> str: text = text.strip() blocks = re.split(r"\n\s*\n", text) or [text] if len(blocks) == 1: blocks = [b for b in text.split("\n") if b.strip()] return "".join(f"

{html.escape(b.strip())}

" for b in blocks if b.strip()) def read(frag: str, name: str) -> str: f = BENCH / frag / f"{name}.txt" return f.read_text() if f.exists() else "" def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("out") ap.add_argument("--key", default="", help="приватный ключ-файл (по умолчанию .key.json)") ap.add_argument("--salt", default="", help="фиксировать соль (для воспроизводимости; иначе случайная)") args = ap.parse_args() out = Path(args.out) key_path = Path(args.key) if args.key else out.with_suffix(".key.json") salt = args.salt or secrets.token_hex(8) S = [] S.append("Сравнение редакторов ru-стиля · Эксперимент 04") S.append(f"") S.append('
') S.append('
') S.append('

TextMachine · полигон · эксперимент 04

') S.append("

Кто лучше правит русский стиль

") S.append('

Четыре модели-редактора получили один и тот же черновой перевод и ' 'задание: превратить его в живую художественную прозу, ничего не переврав. ' 'Версии обезличены — A, B, C, D. Оцени по двум осям:

') S.append('
    ' '
  • стиль — какая русская проза читается живее, без канцелярита и калек;
  • ' '
  • верность — кто не потерял смысл и не добавил отсебятины (сверяй со «смысловым якорем»).
  • ' '
') S.append('

⚠ Метки A–D перемешаны СВОИМ порядком в каждом фрагменте — узнав, кто есть кто ' 'в одном, ты НЕ знаешь этого в другом. Имена моделей и цена в артефакте НЕ приводятся ' '(ключ — в отдельном файле, вскрывается только ПОСЛЕ оценки). Фрагмент Уэллса — англ.→рус.: ' 'оригинал перед тобой, судишь и стиль, и верность напрямую.

') S.append("
") key = {"salt": salt, "note": "ПРИВАТНЫЙ КЛЮЧ эксп.04 — НЕ публиковать вместе с артефактом; " "вскрывать только ПОСЛЕ вынесения слепой оценки (урок 07-review §4.5). " "Метки рандомизированы по фрагменту.", "cost": COST, "fragments": {}} for i, (frag, direction, work, is_en) in enumerate(FRAGMENTS, 1): lm = label_map(salt, frag) key["fragments"][frag] = {lab: model for lab, model in lm} anchor = read(frag, "source") if is_en else read(frag, "english_ref") anchor_label = "Оригинал (английский)" if is_en else "Смысловой якорь — дословный английский перевод" draft = read(frag, "draft") S.append('
') S.append(f'
{i:02d}' f'

{html.escape(direction)}

' f'

{html.escape(work)}

' + ('твой полный контроль' if is_en else '') + '
') S.append('
Смысловой якорь и черновик' '
') S.append(f'

{html.escape(anchor_label)}

' f'
{paras(anchor)}
') S.append(f'

Черновой перевод (вход редактора)

' f'
{paras(draft)}
') S.append("
") S.append('
') for letter, model in lm: # per-fragment shuffled order txt = read(frag, model) S.append(f'
{letter}
' f'
{paras(txt)}
') S.append("
") S.append("
") # NO reveal/key section in the artifact (the exp04 blindness break). Key → separate file. S.append('') S.append("
") out.write_text("\n".join(S), encoding="utf-8") key_path.write_text(json.dumps(key, ensure_ascii=False, indent=2), encoding="utf-8") print("written", out, out.stat().st_size, "bytes") print("KEY (do NOT publish until scored):", key_path) CSS = r""" :root{ --paper:#f7f6f3; --surface:#fffffe; --ink:#1c1b19; --muted:#6f6b64; --hair:#e4e0d8; --accent:#9e2b25; --accent-soft:#f0e2df; --style:#3f6f52; --fidel:#9e2b25; --serif:Georgia,'Iowan Old Style','Times New Roman',serif; --sans:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif; --measure:64ch; } @media (prefers-color-scheme:dark){ :root{--paper:#17181a; --surface:#1f2124; --ink:#e9e7e2; --muted:#9a978f; --hair:#31343a; --accent:#d67a72; --accent-soft:#33231f; --style:#7db38f; --fidel:#d67a72;} } :root[data-theme="light"]{--paper:#f7f6f3; --surface:#fffffe; --ink:#1c1b19; --muted:#6f6b64; --hair:#e4e0d8; --accent:#9e2b25; --accent-soft:#f0e2df; --style:#3f6f52; --fidel:#9e2b25;} :root[data-theme="dark"]{--paper:#17181a; --surface:#1f2124; --ink:#e9e7e2; --muted:#9a978f; --hair:#31343a; --accent:#d67a72; --accent-soft:#33231f; --style:#7db38f; --fidel:#d67a72;} *{box-sizing:border-box;} body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--sans); line-height:1.5;-webkit-font-smoothing:antialiased;} .wrap{max-width:1080px;margin:0 auto;padding:clamp(1.2rem,4vw,3.5rem) clamp(1rem,4vw,2rem) 4rem;} .eyebrow{font-size:.72rem;letter-spacing:.14em;text-transform:uppercase;color:var(--accent); font-weight:600;margin:0 0 .5rem;} h1{font-family:var(--serif);font-weight:600;font-size:clamp(1.9rem,5vw,3rem);line-height:1.08; margin:.1rem 0 .8rem;text-wrap:balance;letter-spacing:-.01em;} .lede{font-size:1.08rem;max-width:var(--measure);color:var(--ink);margin:0 0 1rem;} .criteria{list-style:none;padding:0;margin:0 0 1.2rem;max-width:var(--measure); display:flex;flex-direction:column;gap:.4rem;} .criteria li{padding-left:.2rem;color:var(--muted);} .k{display:inline-block;font-family:var(--sans);font-size:.72rem;font-weight:700;text-transform:uppercase; letter-spacing:.08em;padding:.12em .5em;border-radius:3px;margin-right:.4em;color:#fff;} .k-style{background:var(--style);} .k-fidel{background:var(--fidel);} .note{font-size:.9rem;color:var(--muted);max-width:var(--measure);} .intro{border-bottom:1px solid var(--hair);padding-bottom:2rem;margin-bottom:2.5rem;} .frag{margin:0 0 3.5rem;} .frag-head{display:flex;align-items:baseline;gap:1rem;margin-bottom:1.1rem;flex-wrap:wrap;} .fnum{font-family:var(--serif);font-size:1.5rem;color:var(--accent);font-weight:600; font-variant-numeric:tabular-nums;line-height:1;} .frag-head h2{font-family:var(--serif);font-weight:600;font-size:1.4rem;margin:.1rem 0 0;letter-spacing:-.01em;} .badge{margin-left:auto;align-self:center;font-size:.7rem;font-weight:600;text-transform:uppercase; letter-spacing:.08em;color:var(--accent);background:var(--accent-soft); padding:.28em .7em;border-radius:999px;} .context{margin:0 0 1.3rem;border:1px solid var(--hair);border-radius:10px;background:var(--surface);} .context>summary{cursor:pointer;padding:.75rem 1rem;font-size:.82rem;font-weight:600; text-transform:uppercase;letter-spacing:.06em;color:var(--muted);list-style:none;} .context>summary::-webkit-details-marker{display:none;} .context>summary::before{content:"▸ ";color:var(--accent);} .context[open]>summary::before{content:"▾ ";} .context-grid{display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;padding:0 1rem 1.1rem;} .ctx-label{font-size:.7rem;text-transform:uppercase;letter-spacing:.07em;color:var(--accent); font-weight:600;margin:0 0 .4rem;} @media(max-width:680px){.context-grid{grid-template-columns:1fr;}} .versions{display:grid;grid-template-columns:1fr 1fr;gap:1.1rem;} @media(max-width:760px){.versions{grid-template-columns:1fr;}} .ver{background:var(--surface);border:1px solid var(--hair);border-radius:12px; padding:1.1rem 1.3rem 1.3rem;position:relative;} .ver-head{margin:0 0 .5rem;} .chip{display:inline-flex;align-items:center;justify-content:center;width:2rem;height:2rem; font-family:var(--serif);font-weight:700;font-size:1.05rem;color:#fff;background:var(--accent); border-radius:50%;} .prose{font-family:var(--serif);font-size:1.02rem;line-height:1.62;max-width:var(--measure);} .prose p{margin:0 0 .75em;} .prose p:last-child{margin-bottom:0;} .prose.muted{color:var(--muted);font-size:.94rem;line-height:1.55;} .foot{margin-top:2.5rem;padding-top:1.5rem;border-top:1px solid var(--hair);color:var(--muted);font-size:.9rem;} a:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px;border-radius:4px;} @media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important;}} """ if __name__ == "__main__": main()