From ce71a1a7523be7d266dc038ee254f99ce8a6e77a Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Sat, 4 Jul 2026 19:26:36 +0300 Subject: [PATCH] Add polygon eval scripts for local-model, editor and MoE benchmarks plus ollama launcher and provider config --- eval/build_editor_artifact.py | 202 ++++++++++++++++++++++++++++++++++ eval/editor_bench.py | 164 +++++++++++++++++++++++++++ eval/llama_moe_bench.sh | 43 ++++++++ eval/local_bench.py | 33 +++++- eval/ollama-up.sh | 16 +++ eval/providers.json | 8 ++ 6 files changed, 460 insertions(+), 6 deletions(-) create mode 100644 eval/build_editor_artifact.py create mode 100644 eval/editor_bench.py create mode 100644 eval/llama_moe_bench.sh create mode 100644 eval/ollama-up.sh diff --git a/eval/build_editor_artifact.py b/eval/build_editor_artifact.py new file mode 100644 index 0000000..ff4be85 --- /dev/null +++ b/eval/build_editor_artifact.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Генератор страницы слепого сравнения редакторов (эксперимент 04). +Читает eval/data/editor_bench//*.txt, применяет слепую разметку A/B/C/D, +эмитит самодостаточный HTML (контент для Artifact-обёртки). Запуск: + eval/.venv/bin/python eval/build_editor_artifact.py +""" +import html +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +BENCH = ROOT / "data" / "editor_bench" + +# Слепая разметка: фиксированный перемешанный порядок (одинаков на всех фрагментах) +BLIND = [("A", "grok-4.3"), ("B", "glm-4.6"), ("C", "gemini-3.1-pro"), ("D", "kimi-k2.6")] + +FRAGMENTS = [ + ("rashomon", "японский → русский", "Акутагава Рюноскэ, «Расёмон»", False), + ("ahq", "китайский → русский", "Лу Синь, «Подлинная история А-Кью»", False), + ("merosu", "японский → русский", "Дадзай Осаму, «Беги, Мелос!»", False), + ("wells", "английский → русский", "Г. Уэллс, «Машина времени»", True), +] + +# стоимость/латентность из results.json (последний прогон) — для секции-расшифровки +COST = {"grok-4.3": "~13 с, без «размышлений» — самый быстрый", + "glm-4.6": "~105 с (thinking off)", + "gemini-3.1-pro": "~60–86 с, обязательный thinking", + "kimi-k2.6": "~150 с, ~11 000 токенов «размышлений» на абзац — самый дорогой"} + + +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: + out = Path(sys.argv[1]) + 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('

Названия моделей и цена — внизу, под ключом-расшифровкой, чтобы не влияли на оценку. ' + 'Фрагмент Уэллса — англ.→рус.: оригинал перед тобой, судишь и стиль, и верность напрямую.

') + S.append("
") + + for i, (frag, direction, work, is_en) in enumerate(FRAGMENTS, 1): + 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 BLIND: + txt = read(frag, model) + S.append(f'
{letter}
' + f'
{paras(txt)}
') + S.append("
") + S.append("
") + + # ключ-расшифровка + стоимость (свёрнуто) + S.append('
Показать расшифровку и стоимость') + S.append('') + for letter, model in BLIND: + S.append(f'' + f'') + S.append("
МеткаМодельЛатентность / цена редактуры
{letter}{html.escape(model)}{html.escape(COST.get(model, ""))}
") + S.append('

Черновик — DeepSeek (на китайском А-Кью DeepSeek «эхал» оригинал, ' + 'поэтому там черновик от GLM — деталь для бэкенда, на сравнение редакторов не влияет). ' + 'Латентность — на коротком фрагменте; в продакшене всё идёт батчем.

') + S.append("
") + + S.append('

Выбор дефолтного редактора для Фазы 1. ' + 'Твоя оценка — золотой стандарт: LLM-судьи на художественном качестве расходятся с людьми.

') + S.append("
") + out.write_text("\n".join(S), encoding="utf-8") + print("written", out, out.stat().st_size, "bytes") + + +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%;} +.chip.sm{width:1.6rem;height:1.6rem;font-size:.85rem;} + +.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;} + +.reveal{margin:2rem 0 0;border:1px solid var(--hair);border-radius:10px;background:var(--surface);} +.reveal>summary{cursor:pointer;padding:.9rem 1.1rem;font-weight:600;color:var(--accent);list-style:none;} +.reveal>summary::-webkit-details-marker{display:none;} +.reveal>summary::before{content:"🔑 ";} +.reveal table{width:100%;border-collapse:collapse;margin:0 0 .5rem;} +.reveal th,.reveal td{text-align:left;padding:.55rem 1.1rem;border-top:1px solid var(--hair);font-size:.92rem;} +.reveal th{font-size:.72rem;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);font-weight:600;} +.chip-cell{width:3rem;} .mono{font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:.88rem;} +.reveal .note{padding:0 1.1rem 1rem;} + +.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() diff --git a/eval/editor_bench.py b/eval/editor_bench.py new file mode 100644 index 0000000..b8abfb9 --- /dev/null +++ b/eval/editor_bench.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Эксперимент 04: бейк-офф редакторов ru-стиля (задача из PROGRESS, вопрос бэкенда №2). + +Пайплайн: DeepSeek делает ЧЕРНОВИК перевода → каждый редактор-кандидат полирует его +в живой литературный русский (чек-лист Норы Галь: убрать канцелярит, кальки, оживить +синтаксис), НЕ меняя смысл и не теряя предложений. Выходы сохраняются для сравнения +(человеческая оценка владельца — золотой стандарт для худ. качества, LAIT). + +Запуск: eval/.venv/bin/python eval/editor_bench.py +Выход: eval/data/editor_bench//{source,draft,}.txt + results.json +""" +from __future__ import annotations + +import json +import re +import sys +import time +from pathlib import Path + +CJK_RE = re.compile("[一-鿿぀-ヿ]") + + +def cjk_share(text: str) -> float: + return len(CJK_RE.findall(text)) / max(1, len(text)) + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from refusal_bench import call_provider, load_env_file # noqa: E402 + +load_env_file() +ROOT = Path(__file__).resolve().parent + +# Черновик — реальный черновой контур пайплайна +DRAFT = {"name": "deepseek", "base_url": "https://api.deepseek.com/v1", + "model": "deepseek-chat", "api_key_env": "DEEPSEEK_API_KEY", + "temperature": 0.3, "max_tokens": 3000} +# Фолбэк-черновик, если основной «эхнул» оригинал (deepseek-chat так делает на плотной zh-прозе) +DRAFT_FALLBACK = {"name": "glm-draft", "base_url": "https://api.z.ai/api/paas/v4", + "model": "glm-4.6", "api_key_env": "ZAI_API_KEY", "temperature": 0.3, + "max_tokens": 4000, "extra_body": {"thinking": {"type": "disabled"}}} + +# Редакторы-кандидаты (дорогой тир; имена моделей сверены с /models 2026-07-04) +EDITORS = [ + {"name": "glm-4.6", "base_url": "https://api.z.ai/api/paas/v4", "model": "glm-4.6", + "api_key_env": "ZAI_API_KEY", "extra_body": {"thinking": {"type": "disabled"}}}, + {"name": "kimi-k2.6", "base_url": "https://api.moonshot.ai/v1", "model": "kimi-k2.6", + "api_key_env": "KIMI_API_KEY", "temperature": 1, "max_tokens": 16000}, + # Kimi K2.6: только temp=1; ОЧЕНЬ многословная думалка (~11k reasoning-токенов на абзац!) → + # бюджет ≥16000, иначе content пуст (finish=length). Дорогой редактор: reasoning биллится как выход + {"name": "gemini-3.1-pro", "base_url": "https://generativelanguage.googleapis.com/v1beta/openai", + "model": "gemini-3.1-pro-preview", "api_key_env": "GEMINI_API_KEY", + "max_tokens": 16000}, # обязательно-думающая: thinking не отключить; на длинном черновике 8k обрезал ответ + {"name": "grok-4.3", "base_url": "https://api.x.ai/v1", "model": "grok-4.3", + "api_key_env": "XAI_API_KEY"}, +] +for e in EDITORS: + e.setdefault("temperature", 0.4) + e.setdefault("max_tokens", 4000) + +# Фрагменты: (id, lang, файл, срез) — разные исходники и стили. +# wells — en→ru: владелец читает английский, поэтому оценит и стиль, И верность на 100%. +FRAGMENTS = [ + ("rashomon", "японского", "ja/akutagawa-rashomon.txt", 1400), + ("ahq", "китайского", "zh/luxun-ah-q-ch1-4.txt", 1300), # нарративнее zhufu (тот deepseek эхает) + ("merosu", "японского", "ja/dazai-hashire-merosu.txt", 1400), + ("wells", "английского", "en/wells-time-machine-ch1-2.txt", 1400), +] + +# Английский эталон-подстрочник для ja/zh — читаемый владельцем якорь смысла +# (сверить верность русских версий). Для en-исходника не нужен (сам оригинал английский). +ENREF_SYS = ("Translate this fiction fragment from {src} into faithful, literal English. " + "Preserve every sentence and all proper nouns/cultural terms. Output only the translation.") + +DRAFT_SYS = ("Ты переводчик. Переведи фрагмент художественной прозы с {src} на русский " + "буквально и точно, сохрани все предложения. Выведи только перевод.") + +EDITOR_SYS = ( + "Ты — литературный редактор русского художественного перевода уровня издательства. " + "Тебе дают ОРИГИНАЛ (на {src}) и его ЧЕРНОВОЙ подстрочный перевод. Отредактируй " + "русский текст в живую художественную прозу: убери канцелярит и кальки с оригинала, " + "разнообразь синтаксис, добавь естественную русскую идиоматику и ритм — по духу школы " + "Норы Галь. СТРОГО сохрани смысл, все предложения, реалии и имена собственные из черновика. " + "Не добавляй отсебятины. Выведи ТОЛЬКО отредактированный русский текст, без комментариев." +) + + +def read_frag(rel: str, n: int) -> str: + return (ROOT / "data" / "samples" / rel).read_text()[:n] + + +def main() -> None: + out_dir = ROOT / "data" / "editor_bench" + out_dir.mkdir(parents=True, exist_ok=True) + results = [] + + for fid, src_name, rel, n in FRAGMENTS: + fdir = out_dir / fid + fdir.mkdir(exist_ok=True) + source = read_frag(rel, n) + (fdir / "source.txt").write_text(source) + print(f"\n=== {fid} ({src_name}) ===") + + # 0. английский эталон смысла (для ja/zh; владелец сверяет верность по нему) + enref_path = fdir / "english_ref.txt" + if src_name != "английского" and not (enref_path.exists() and enref_path.read_text().strip()): + en_src = {"src": src_name.replace("японского", "Japanese").replace("китайского", "Chinese")} + text, err, _ = call_provider(DRAFT, ENREF_SYS.format(**en_src), source, timeout=180) + if text: + enref_path.write_text(text.strip()) + print(f" english_ref: {len(text)} симв") + + # 1. черновик DeepSeek + draft_path = fdir / "draft.txt" + if draft_path.exists() and draft_path.read_text().strip(): + draft = draft_path.read_text() + print(" draft: из кэша") + else: + draft = None + for attempt in range(3): # ретрай на «эхо» (модель вернула оригинал вместо перевода) + t0 = time.time() + cand, err, usage = call_provider( + DRAFT, DRAFT_SYS.format(src=src_name), source, timeout=180) + if not cand: + print(f" draft ОШИБКА: {err[:120]}"); break + if cjk_share(cand) > 0.15: + print(f" draft эхо ({cjk_share(cand):.0%} CJK), ретрай {attempt+1}") + continue + draft = cand + draft_path.write_text(draft) + print(f" draft ({DRAFT['name']}): {time.time()-t0:.0f}с, {len(draft)} симв") + break + if not draft: # основной эхает стабильно → фолбэк-черновик (GLM) + cand, err, _ = call_provider( + DRAFT_FALLBACK, DRAFT_SYS.format(src=src_name), source, timeout=180) + if cand and cjk_share(cand) <= 0.15: + draft = cand + draft_path.write_text(draft) + print(f" draft (фолбэк {DRAFT_FALLBACK['name']}): {len(draft)} симв") + else: + print(" draft не удался даже фолбэком — фрагмент пропущен"); continue + + # 2. каждый редактор полирует черновик + user = f"ОРИГИНАЛ ({src_name}):\n{source}\n\nЧЕРНОВОЙ ПЕРЕВОД:\n{draft}" + for ed in EDITORS: + ep = fdir / f"{ed['name']}.txt" + if ep.exists() and ep.read_text().strip(): + print(f" {ed['name']}: из кэша"); continue + t0 = time.time() + text, err, usage = call_provider(ed, EDITOR_SYS.format(src=src_name), user, timeout=300) + if not text: + print(f" {ed['name']}: ОШИБКА/пусто — {err or 'empty'}") + results.append({"fragment": fid, "editor": ed["name"], "error": err or "empty"}) + continue + ep.write_text(text.strip()) + el = time.time() - t0 + results.append({"fragment": fid, "editor": ed["name"], "elapsed_s": round(el, 1), + "chars": len(text), "usage": usage}) + print(f" {ed['name']}: {el:.0f}с, {len(text)} симв") + + (out_dir / "results.json").write_text(json.dumps(results, ensure_ascii=False, indent=1)) + print(f"\nГотово. Выходы: {out_dir}//*.txt") + + +if __name__ == "__main__": + main() diff --git a/eval/llama_moe_bench.sh b/eval/llama_moe_bench.sh new file mode 100644 index 0000000..b965ba7 --- /dev/null +++ b/eval/llama_moe_bench.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Замер 30b-a3b MoE через llama-server с офлоадом экспертов на CPU (--n-cpu-moe / --cpu-moe). +# Сравнение с ollama (дал ~10 tok/s). Использование: bash eval/llama_moe_bench.sh [NCMOE] +# NCMOE пусто → --cpu-moe (все эксперты на CPU); число → --n-cpu-moe N (первые N слоёв). +set -u +BIN=/home/ubuntu/llama.cpp/build/bin/llama-server +GGUF=/home/ubuntu/.ollama/models/blobs/sha256-11b3b371aedb6c43d438ec010e66f9dfaa92127d64c22499891d9013ab7a6ee6 +PORT=8080 +LOG=/home/ubuntu/llama-moe.log +export NO_PROXY="localhost,127.0.0.1" no_proxy="localhost,127.0.0.1" +NCMOE_ARG="--cpu-moe" +[ $# -ge 1 ] && NCMOE_ARG="--n-cpu-moe $1" + +pkill -f "llama-server" 2>/dev/null || true; sleep 2 +echo "== старт llama-server: -ngl 99 $NCMOE_ARG (лог $LOG) ==" +setsid nohup "$BIN" -m "$GGUF" -ngl 99 $NCMOE_ARG -c 8192 -fa on \ + --host 127.0.0.1 --port $PORT --no-webui >"$LOG" 2>&1 < /dev/null & + +# ждём готовности (загрузка 18GB весов — до ~90с) +for _ in $(seq 1 180); do + curl -s --noproxy '*' --max-time 1 "http://127.0.0.1:$PORT/health" 2>/dev/null | grep -q '"ok"' && break + sleep 1 +done +if ! curl -s --noproxy '*' --max-time 2 "http://127.0.0.1:$PORT/health" | grep -q '"ok"'; then + echo "СЕРВЕР НЕ ПОДНЯЛСЯ:"; tail -15 "$LOG"; exit 1 +fi +echo "== VRAM после загрузки =="; nvidia-smi --query-gpu=memory.used --format=csv,noheader + +# Нарезка ПОСИМВОЛЬНО (не head -c — тот режет по байтам и рубит иероглиф → битый JSON) +BODY=$(/home/ubuntu/projects/textmachine/eval/.venv/bin/python -c ' +import json +sys_p = "Ты профессиональный литературный переводчик с китайского на русский. Переведи фрагмент, сохрани все предложения. Выведи только перевод." +frag = open("/home/ubuntu/projects/textmachine/eval/data/samples/zh/luxun-zhufu.txt", encoding="utf-8").read()[:1500] +print(json.dumps({"model":"local","temperature":0.3,"max_tokens":2000, + "messages":[{"role":"system","content":sys_p},{"role":"user","content":frag}]}))') + +echo "== генерация (zh→ru, 1500 знаков) ==" +curl -s --noproxy '*' --max-time 600 "http://127.0.0.1:$PORT/v1/chat/completions" \ + -H "Content-Type: application/json" -d "$BODY" > /home/ubuntu/llama-moe-resp.json +echo "-- tok/s из лога llama-server --"; grep -iE "prompt eval time|eval time|tokens per second" "$LOG" | tail -6 +echo "-- первые 400 симв. перевода --" +/home/ubuntu/projects/textmachine/eval/.venv/bin/python -c "import json; print(json.load(open('/home/ubuntu/llama-moe-resp.json'))['choices'][0]['message']['content'][:400])" +pkill -f "llama-server" 2>/dev/null || true diff --git a/eval/local_bench.py b/eval/local_bench.py index 40717d4..47c3dd1 100644 --- a/eval/local_bench.py +++ b/eval/local_bench.py @@ -19,6 +19,7 @@ import re import subprocess import threading import time +import urllib.error import urllib.request from pathlib import Path @@ -95,16 +96,34 @@ class PeakSampler(threading.Thread): time.sleep(1) -def ollama_generate(model: str, prompt: str, num_ctx: int, timeout: int = 1800) -> dict: +def ollama_generate(model: str, prompt: str, num_ctx: int, timeout: int = 1800, + think: bool = False) -> dict: + # think=False (дефолт): у моделей 3.5/GLM thinking включён по умолчанию и съедает + # бюджет вывода на рассуждения (перевод обрезается/пустой) — для роли переводчика + # отключаем. think=True: явно включаем + большой num_predict, чтобы модель успела + # и порассуждать, и выдать перевод (замер влияния reasoning на качество). + # num_predict — потолок вывода, чтобы модель не убегала. req = urllib.request.Request( f"{OLLAMA}/api/generate", data=json.dumps({ - "model": model, "prompt": prompt, "stream": False, - "options": {"num_ctx": num_ctx, "temperature": 0.3}, + "model": model, "prompt": prompt, "stream": False, "think": think, + "options": {"num_ctx": num_ctx, "temperature": 0.3, + "num_predict": 6000 if think else 2048}, }).encode(), headers={"Content-Type": "application/json"}) - with _NO_PROXY_OPENER.open(req, timeout=timeout) as resp: - return json.loads(resp.read()) + try: + with _NO_PROXY_OPENER.open(req, timeout=timeout) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + # не все модели принимают think=False (нет thinking-способности) — повтор без него + if e.code == 400: + body = json.loads(req.data) + body.pop("think", None) + req2 = urllib.request.Request(f"{OLLAMA}/api/generate", data=json.dumps(body).encode(), + headers={"Content-Type": "application/json"}) + with _NO_PROXY_OPENER.open(req2, timeout=timeout) as resp: + return json.loads(resp.read()) + raise def strip_think(text: str) -> str: @@ -138,6 +157,8 @@ def main() -> None: ap.add_argument("--models", help="через запятую; по умолчанию все установленные") ap.add_argument("--num-ctx", type=int, default=8192) ap.add_argument("--out", default=str(ROOT / "data" / "local_bench")) + ap.add_argument("--think", action="store_true", + help="включить thinking-режим (reasoning) + большой num_predict — замер влияния на качество") args = ap.parse_args() models = args.models.split(",") if args.models else installed_models() @@ -160,7 +181,7 @@ def main() -> None: sampler.start() t0 = time.time() try: - r = ollama_generate(model, prompt, args.num_ctx) + r = ollama_generate(model, prompt, args.num_ctx, think=args.think) except Exception as e: sampler.stop_flag = True print(f" {fname}: ОШИБКА {type(e).__name__}: {str(e)[:200]}") diff --git a/eval/ollama-up.sh b/eval/ollama-up.sh new file mode 100644 index 0000000..5e74d6a --- /dev/null +++ b/eval/ollama-up.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Запуск ollama в боевом конфиге стенда (как в ~/vojo-local-up.sh, без туннеля/релея). +# Флаги: держать модель в VRAM, flash attention, q8 KV-кэш. Прокси для localhost — в обход. +# Использование: bash eval/ollama-up.sh +set -u +LOG="${OLLAMA_LOG:-$HOME/ollama.log}" +pkill -f "ollama serve" 2>/dev/null || true +sleep 2 +export OLLAMA_HOST=0.0.0.0:11434 OLLAMA_KEEP_ALIVE=-1 OLLAMA_FLASH_ATTENTION=1 OLLAMA_KV_CACHE_TYPE=q8_0 +export NO_PROXY="localhost,127.0.0.1,0.0.0.0,::1" no_proxy="localhost,127.0.0.1,0.0.0.0,::1" +setsid nohup ollama serve >"$LOG" 2>&1 < /dev/null & +for _ in $(seq 1 40); do + curl -s --noproxy '*' --max-time 1 http://127.0.0.1:11434/api/version >/dev/null 2>&1 && break + sleep 0.5 +done +curl -s --noproxy '*' http://127.0.0.1:11434/api/version && echo " <- ollama up (FA=1, KV=q8_0, keep_alive=-1); log: $LOG" diff --git a/eval/providers.json b/eval/providers.json index 805ece6..ddafe5e 100644 --- a/eval/providers.json +++ b/eval/providers.json @@ -33,6 +33,14 @@ "extra_body": { "thinking": { "type": "disabled" } }, "_comment": "thinking у GLM-4.6 включён по умолчанию и не влезал в 180с — для перевода отключаем" }, + { + "name": "kimi", + "base_url": "https://api.moonshot.ai/v1", + "model": "kimi-k2.6", + "api_key_env": "KIMI_API_KEY", + "rps_delay": 0.5, + "_comment": "Moonshot Kimi K2.6 — кандидат в редакторы zh-исходников (Р4); интернациональный endpoint api.moonshot.ai (не .cn)" + }, { "name": "gemini", "base_url": "https://generativelanguage.googleapis.com/v1beta/openai",