textmachine/eval/build_editor_artifact.py

218 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Генератор страницы слепого сравнения редакторов (эксперимент 04).
⚠ ГИГИЕНА СЛЕПОТЫ (D13.5 / 07-review §4.5 / POLYGON handoff Task 2): предыдущая версия была
структурно НЕ-слепой при LLM-оценке — (1) ключ «метка→модель» и цена лежали В ТОМ ЖЕ артефакте
(за <details> — для LLM-фетча это НЕ скрытие, весь DOM виден), (2) маппинг A/B/C/D был
ЗАФИКСИРОВАН одинаково на всех фрагментах (узнав раз — знаешь везде). Исправлено:
- метки рандомизируются ПО ФРАГМЕНТУ (детерминированная перестановка на соли, разная у каждого
фрагмента) — узнать порядок на одном фрагменте не даёт его на других;
- ключ (метка→модель) + цена/латентность пишутся в ОТДЕЛЬНЫЙ файл (--key), КОТОРЫЙ НЕ
ПУБЛИКУЕТСЯ до вынесения оценки; в самом артефакте НЕТ ни имён моделей, ни цены;
- соль генерится случайно на сборку и хранится ТОЛЬКО в ключ-файле → даже код-осведомлённый
оценщик не инвертирует перестановку без ключа.
Читает eval/data/editor_bench/<fragment>/*.txt, эмитит слепой HTML (контент для Artifact-обёртки)
и рядом — приватный ключ-файл. Запуск:
eval/.venv/bin/python eval/build_editor_artifact.py <out.html> [--key <out.key.json>]
"""
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": "~6086 с, обязательный 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"<p>{html.escape(b.strip())}</p>" 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="приватный ключ-файл (по умолчанию <out>.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("<title>Сравнение редакторов ru-стиля · Эксперимент 04</title>")
S.append(f"<style>{CSS}</style>")
S.append('<div class="wrap">')
S.append('<header class="intro">')
S.append('<p class="eyebrow">TextMachine · полигон · эксперимент 04</p>')
S.append("<h1>Кто лучше правит русский стиль</h1>")
S.append('<p class="lede">Четыре модели-редактора получили один и тот же черновой перевод и '
'задание: превратить его в живую художественную прозу, ничего не переврав. '
'Версии обезличены — <strong>A, B, C, D</strong>. Оцени по двум осям:</p>')
S.append('<ul class="criteria">'
'<li><span class="k k-style">стиль</span> — какая русская проза читается живее, без канцелярита и калек;</li>'
'<li><span class="k k-fidel">верность</span> — кто не потерял смысл и не добавил отсебятины (сверяй со «смысловым якорем»).</li>'
'</ul>')
S.append('<p class="note">⚠ Метки AD перемешаны СВОИМ порядком в каждом фрагменте — узнав, кто есть кто '
'в одном, ты НЕ знаешь этого в другом. Имена моделей и цена в артефакте НЕ приводятся '
'(ключ — в отдельном файле, вскрывается только ПОСЛЕ оценки). Фрагмент Уэллса — англ.→рус.: '
'оригинал перед тобой, судишь и стиль, и верность напрямую.</p>')
S.append("</header>")
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('<section class="frag">')
S.append(f'<div class="frag-head"><span class="fnum">{i:02d}</span>'
f'<div><p class="eyebrow">{html.escape(direction)}</p>'
f'<h2>{html.escape(work)}</h2></div>'
+ ('<span class="badge">твой полный контроль</span>' if is_en else '') +
'</div>')
S.append('<details class="context"><summary>Смысловой якорь и черновик</summary>'
'<div class="context-grid">')
S.append(f'<div class="ctx"><p class="ctx-label">{html.escape(anchor_label)}</p>'
f'<div class="prose muted">{paras(anchor)}</div></div>')
S.append(f'<div class="ctx"><p class="ctx-label">Черновой перевод (вход редактора)</p>'
f'<div class="prose muted">{paras(draft)}</div></div>')
S.append("</div></details>")
S.append('<div class="versions">')
for letter, model in lm: # per-fragment shuffled order
txt = read(frag, model)
S.append(f'<article class="ver"><header class="ver-head"><span class="chip">{letter}</span></header>'
f'<div class="prose">{paras(txt)}</div></article>')
S.append("</div>")
S.append("</section>")
# NO reveal/key section in the artifact (the exp04 blindness break). Key → separate file.
S.append('<footer class="foot"><p>Выбор дефолтного редактора для Фазы 1. '
'Твоя оценка — золотой стандарт: LLM-судьи на художественном качестве расходятся с людьми. '
'Ключ «метка→модель» и цена — в приватном файле, вскрываются после оценки.</p></footer>')
S.append("</div>")
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()