diff --git a/.gitignore b/.gitignore index 4dd7282..ce69a55 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ node_modules/ .pytest_cache/ .DS_Store -# Сырые артефакты прогонов и авто-сгенерированный отчёт (курируемые .ru/.en.md — под VCS). +# Сырые per-run данные прогонов (отчёты results.ru/en.md в дата-папках — под VCS). +results/**/runs.jsonl results/runs-*.jsonl -results/model-comparison-grid.generated.md -results/model-comparison-grid.partial.md diff --git a/README.en.md b/README.en.md index 0cf7edc..747e2b6 100644 --- a/README.en.md +++ b/README.en.md @@ -41,7 +41,7 @@ llmbench/ report.py # record aggregation, Stability/Score/Pareto, markdown build (CI-tested) runner.py # variant grid x cases x repeat, JSONL persistence, report tests/ # offline self-test: scoring + aggregation + runner pipeline (all in CI) -results/ # curated reports (.ru/.en.md) + generated report and runs-*.jsonl (gitignored) +results/ # per-date folders: results.ru.md + results.en.md (in VCS) + raw runs.jsonl (gitignored) ``` ## Running @@ -78,15 +78,16 @@ line (don't forget the rate in `core.MODEL_RATES`, or the runner warns). ## Artifacts and re-scoring -Every run writes **`results/runs-.jsonl`** — one record per run (answer, tool trace, -usage, all scores, errors). This is the source of truth: the report is rebuilt from it for -free, with no repeat model calls — +Every run lands in a dated folder **`results//`**: the raw `runs.jsonl` (one record per +run — answer, tool trace, usage, all scores, errors) and the bilingual generated report +`results.ru.md` + `results.en.md`. `runs.jsonl` is the source of truth: both report versions +are rebuilt from it for free, with no repeat model calls — ```bash -python -m llmbench.runner --report-from results/runs-20260703-120000.jsonl +python -m llmbench.runner --report-from results/2026-07-03/runs.jsonl ``` -The runner writes the generated report to `results/model-comparison-grid.generated.md` -(gitignored) so it never clobbers the hand-curated `model-comparison-grid.ru.md` / `…en.md` -(top-3, prose, bilingual) — those are edited by hand from the generated one. +`runs.jsonl` is gitignored (raw data), the `.md` reports are in VCS. Top-3, prose and takeaways +are added by hand on top of the generated grid in the same dated folder. A second run on the +same day doesn't clobber the first (a time suffix is added on a date collision). ## How Score is computed @@ -100,9 +101,9 @@ differing difficulty). ## Latest run results -`results/model-comparison-grid.ru.md` (+ English `…en.md`) — curated summary + Pareto -frontier. ⚠️ Their current numbers are from the 2026-06-29 run, BEFORE the scoring/fixture -fixes (see `REVIEW.md`); regenerate with a fresh run. +The latest run is in the newest dated folder `results//` (`results.ru.md` + English +`results.en.md`) — curated summary + Pareto frontier. ⚠️ The numbers in `results/2026-06-29/` +predate the scoring/fixture fixes (see `REVIEW.md`); regenerate with a fresh run. ## Known limitations diff --git a/README.md b/README.md index c2546b6..ce97534 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ llmbench/ report.py # агрегация записей, Stability/Score/Pareto, сборка markdown (тестируется в CI) runner.py # сетка вариантов × кейсы × repeat, JSONL-персист, отчёт tests/ # офлайн self-test: скоринг + агрегация + конвейер ранера (всё в CI) -results/ # курируемые отчёты (.ru/.en.md) + авто-отчёт и runs-*.jsonl (в .gitignore) +results/ # per-date папки: results.ru.md + results.en.md (под VCS) + сырой runs.jsonl (.gitignore) ``` ## Запуск @@ -77,15 +77,16 @@ Env токенов кабинета: `YANDEX_DIRECT_TOKEN` (+ опц. `YANDEX_DI ## Артефакты и пере-скоринг -Каждый прогон пишет **`results/runs-.jsonl`** — по записи на прогон (ответ, tool-трейс, -usage, все оценки, ошибки). Это источник правды: отчёт пересобирается из него бесплатно, -без повторных вызовов моделей — +Каждый прогон складывается в дата-папку **`results//`**: сырой `runs.jsonl` (по записи +на прогон — ответ, tool-трейс, usage, все оценки, ошибки) и двуязычный авто-отчёт +`results.ru.md` + `results.en.md`. `runs.jsonl` — источник правды: отчёт (обе версии) +пересобирается из него бесплатно, без повторных вызовов моделей — ```bash -python -m llmbench.runner --report-from results/runs-20260703-120000.jsonl +python -m llmbench.runner --report-from results/2026-07-03/runs.jsonl ``` -Ранер пишет авто-отчёт в `results/model-comparison-grid.generated.md` (в `.gitignore`), -чтобы не затирать курируемые вручную `model-comparison-grid.ru.md` / `…en.md` (Топ-3, проза, -двуязычие) — их правят руками из сгенерированного. +`runs.jsonl` — в `.gitignore` (сырые данные), сами отчёты `.md` — под VCS. Топ-3, прозу и +выводы дописывают руками поверх сгенерированного грида в той же дата-папке. Второй прогон за +день не затирает первый (при коллизии по дате к папке добавляется время). ## Как считается Score @@ -98,9 +99,9 @@ python -m llmbench.runner --report-from results/runs-20260703-120000.jsonl ## Результаты последнего прогона -`results/model-comparison-grid.ru.md` (+ англ. `…en.md`) — курируемая сводка + Pareto-фронт. -⚠️ Текущие числа в них — от прогона 2026-06-29, ДО правок скоринга/фикстур (см. `REVIEW.md`); -их нужно перегенерировать свежим прогоном. +Последний прогон — в самой свежей дата-папке `results//` (`results.ru.md` + англ. +`results.en.md`), курируемая сводка + Pareto-фронт. ⚠️ Числа в `results/2026-06-29/` — ДО +правок скоринга/фикстур (см. `REVIEW.md`); их нужно перегенерировать свежим прогоном. ## Известные ограничения diff --git a/llmbench/report.py b/llmbench/report.py index b5199e1..75fa560 100644 --- a/llmbench/report.py +++ b/llmbench/report.py @@ -83,19 +83,20 @@ def _f(x): } -def describe(v): - """Вариант → (LLM, Thinking, Effort). Thinking: adaptive/reasoning/нет; у GLM effort не рычаг (—).""" +def describe(v, lang="ru"): + """Вариант → (LLM, Thinking, Effort). Thinking: adaptive/reasoning/нет(en: no); у GLM effort не рычаг (—).""" llm = MODEL_DISPLAY.get(v["model"], v["model"]) + no = "no" if lang == "en" else "нет" if v.get("reasoning_effort"): return llm, "reasoning", v["reasoning_effort"] if v["engine"] == "openai": - return llm, "нет", "—" - thinking = "adaptive" if v.get("thinking") == "adaptive" else "нет" + return llm, no, "—" + thinking = "adaptive" if v.get("thinking") == "adaptive" else no effort = "—" if v["vendor"] == "zai" else (v.get("effort") or "—") return llm, thinking, effort -GLOSSARY = """## Термины (как читать таблицу) +GLOSSARY_RU = """## Термины (как читать таблицу) - **Accuracy** (0–5) — точность чисел: верно ли посчитаны CTR/CPC/CPA/расход, не выдуманы ли цифры и той ли кампании они приписаны (entity-анкоринг). **В коде** (детерминированно). @@ -123,43 +124,129 @@ def describe(v): """ -def build_md(aggregates, meta): - o = ["# Сравнение моделей для AskAds (Claude / GLM / GPT)\n"] +GLOSSARY_EN = """## Terms (how to read the table) + +- **Accuracy** (0–5) — numeric correctness: are CTR/CPC/CPA/spend computed right, nothing made + up, and are the numbers attributed to the right campaign (entity anchoring). **In code** + (deterministic). +- **Tools Use** (0–5) — tool correctness: called the right tools (successfully) in the right + order, nothing extra/forbidden. **Code**. +- **Edge Cases** (0–5) — behavior in edge cases (empty report, refusing to change a bid, + clarifying). **LLM judges** — they also score runs with tool violations. +- **Lang quality** (0–5) — naturalness and clarity of the Russian. Judges. +- **Score** (0–5) — a run's overall score = mean of the available components: Tools Use + (always), Accuracy (if the case has golden facts), Edge Cases/Lang quality (if judges ran). + The component set depends on the case, so Score is comparable across variants (everyone runs + the same cases) but is NOT equal to the mean of the four left columns. Failed runs are + excluded from Score — see Err. +- **Cost per Answer** — mean cost of a successful run (USD); **Score per USD (s/m)** — "quality + per dollar" (Score ÷ cost) for single-/multi-step dialogs; higher = better value. +- **Stability** (0–5) — `5 − mean spread (σ) of Score between repeats of the same case`: + higher = more stable. Meaningful at repeat ≥ 2. +- **Err** — `failed/all runs` (API errors, token-limit truncation); suffix `·NR` — N runs + succeeded only after a retry with the same config. Failed runs are excluded from all metrics, + but their cost is included in the total run cost. +- **Thinking** — whether the model thinks before answering: `adaptive` (Claude/GLM), + `reasoning` (GPT-5), `no`. +- **Effort** — the "effort" budget per answer (`low/medium/high/max`); separate from thinking + (weak effect when thinking is off). Not configurable for GLM (`—`). +- **⭐** — **best quality/price balance**: a variant that can't be beaten — no other is both + better and cheaper. _(In optimization — the "Pareto frontier".)_ +""" + + +# Локализация проз-строк отчёта. Колонки таблицы (Accuracy/Tools Use/…) — англ. в обеих +# версиях, поэтому переводим только заголовки/пояснения/оговорки; числа считает agg(). +_LANG = { + "ru": { + "title": "# Сравнение моделей для AskAds (Claude / GLM / GPT)\n", + "cross": "🇷🇺 Русский · [🇬🇧 English](results.en.md)\n", + "runline": ("_Запуск от {ts} × **{nv} вариантов** (модель × thinking/effort) × " + "**{nc} тест-кейсов** × **{rep} повтора** = {total} запусков · режим {mode} · " + "вход одинаковый для всех (фикстуры версии `{fx}`){commit}._\n"), + "commit": " · код `{c}`", + "how": ("**Как считалось.** Claude/GLM — наш агентный движок; GPT — отдельный OpenAI-цикл " + "(askads на Anthropic, GPT в тот же движок не встроить) → его tool-use сопоставим не " + "на 100%. **Tools Use/Accuracy** считает код; **Edge Cases/Lang quality** — LLM-судьи " + "({judges}; нейтрален: **{neutral}**). Судьи вторичны — вес на ключевых метриках.\n"), + "glossary": GLOSSARY_RU, + "variants_h": "## Все варианты (сорт. по Score)\n", + "star": ("\n⭐ — **лучший баланс «качество/цена»** (нельзя стать и качественнее, и дешевле " + "одновременно): **{front}**.\n"), + "baseline": "_Для ориентира: текущий прод askads — {desc}._\n", + "limits_h": "\n## Известные ограничения\n", + "jsonl": ("\n_Сырые per-run данные: `{jsonl}` — отчёт пересобирается из них командой " + "`python -m llmbench.runner --report-from <файл>`._"), + "none": "—", + }, + "en": { + "title": "# Model comparison for AskAds (Claude / GLM / GPT)\n", + "cross": "[🇷🇺 Русский](results.ru.md) · 🇬🇧 English\n", + "runline": ("_Run from {ts} × **{nv} variants** (model × thinking/effort) × " + "**{nc} test cases** × **{rep} repeats** = {total} runs · mode {mode} · " + "identical input for all (fixtures version `{fx}`){commit}._\n"), + "commit": " · code `{c}`", + "how": ("**How it was measured.** Claude/GLM — our agentic engine; GPT — a separate OpenAI " + "loop (askads is on Anthropic, GPT can't be plugged into the same engine) → its " + "tool-use isn't 100% comparable. **Tools Use/Accuracy** are computed in code; **Edge " + "Cases/Lang quality** — LLM judges ({judges}; neutral: **{neutral}**). Judges are " + "secondary — weight is on the key metrics.\n"), + "glossary": GLOSSARY_EN, + "variants_h": "## All variants (sorted by Score)\n", + "star": ("\n⭐ — **best quality/price balance** (can't become both better and cheaper at " + "once): **{front}**.\n"), + "baseline": "_For reference: current askads production — {desc}._\n", + "limits_h": "\n## Known limitations\n", + "jsonl": ("\n_Raw per-run data: `{jsonl}` — the report is rebuilt from it with " + "`python -m llmbench.runner --report-from `._"), + "none": "—", + }, +} + +_TABLE_HEADER = ("| LLM | Thinking | Effort | Accuracy | Tools
Use | Edge
Cases | " + "Lang
quality | Cost
per Answer | Score
per USD (s) | " + "Score
per USD (m) | Stability | Err | Score |") +_TABLE_SEP = "|---|---|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|" + + +def build_md(aggregates, meta, lang="ru"): + t = _LANG.get(lang, _LANG["ru"]) total = sum(a["n_runs"] for a in aggregates.values()) judges = ', '.join(meta['judges']) if isinstance(meta['judges'], list) else meta['judges'] - commit = f" · код `{meta['git_commit']}`" if meta.get("git_commit") else "" - o.append(f"_Запуск от {meta['ts']} × **{len(meta['variants'])} вариантов** " - f"(модель × thinking/effort) × **{meta['n_cases']} тест-кейсов** × **{meta['repeat']} повтора** " - f"= {total} запусков · режим {meta['mode']} · вход одинаковый для всех " - f"(фикстуры версии `{meta['fixture_version']}`){commit}._\n") - o.append("**Как считалось.** Claude/GLM — наш агентный движок; GPT — отдельный OpenAI-цикл " - "(askads на Anthropic, GPT в тот же движок не встроить) → его tool-use сопоставим не на 100%. " - f"**Tools Use/Accuracy** считает код; **Edge Cases/Lang quality** — LLM-судьи ({judges}; " - f"нейтрален: **{meta['neutral'] or '—'}**). Судьи вторичны — вес на ключевых метриках.\n") - o.append(GLOSSARY) - o.append("## Все варианты (сорт. по Score)\n") - o.append("| LLM | Thinking | Effort | Accuracy | Tools
Use | Edge
Cases | Lang
quality | " - "Cost
per Answer | Score
per USD (s) | Score
per USD (m) | Stability | Err | Score |") - o.append("|---|---|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|") + nv = meta.get('neutral') + neutral = (', '.join(nv) if isinstance(nv, list) else (str(nv) if nv else "")) or t["none"] + commit = t["commit"].format(c=meta['git_commit']) if meta.get("git_commit") else "" + o = [t["title"], t["cross"]] + o.append(t["runline"].format(ts=meta['ts'], nv=len(meta['variants']), nc=meta['n_cases'], + rep=meta['repeat'], total=total, mode=meta['mode'], + fx=meta['fixture_version'], commit=commit)) + o.append(t["how"].format(judges=judges, neutral=neutral)) + o.append(t["glossary"]) + o.append(t["variants_h"]) + o.append(_TABLE_HEADER) + o.append(_TABLE_SEP) front = set(pareto(aggregates)) by_label = {v["label"]: v for v in meta["variants"]} for label, a in sorted(aggregates.items(), key=lambda kv: (kv[1]["composite"] is None, -(kv[1]["composite"] or 0))): - llm, thinking, effort = describe(by_label[label]) + llm, thinking, effort = describe(by_label[label], lang) spd = a["score_per_dollar"] - cost = "—" if a["cost_avg"] is None else f"${a['cost_avg']:.5f}" + cost = t["none"] if a["cost_avg"] is None else f"${a['cost_avg']:.5f}" err = f"{a['errors']}/{a['n_runs']}" + (f" ·{a['retried']}R" if a.get("retried") else "") stability = _f(round(5 - a["stddev_composite"], 3) if a["stddev_composite"] is not None else None) o.append(f"| {llm}{' ⭐' if label in front else ''} | {thinking} | {effort} | {_f(a['numeric'])} | " f"{_f(a['tool'])} | {_f(a['edge'])} | {_f(a['russian'])} | {cost} | {_f(spd['single'])} | " f"{_f(spd['multi'])} | {stability} | {err} | {_f(a['composite'])} |") - o.append(f"\n⭐ — **лучший баланс «качество/цена»** (нельзя стать и качественнее, и дешевле одновременно): " - f"**{', '.join(front) or '—'}**.\n") - if meta.get("baseline_desc"): - o.append(f"_Для ориентира: текущий прод askads — {meta['baseline_desc']}._\n") - o.append("\n## Известные ограничения\n") - for line in meta["caveats"]: + o.append(t["star"].format(front=', '.join(front) or t["none"])) + baseline = next((v for v in meta["variants"] if v.get("is_baseline")), None) + if baseline: + bl_llm, bl_th, bl_ef = describe(baseline, lang) + o.append(t["baseline"].format(desc=f"{bl_llm} (thinking {bl_th}, effort {bl_ef})")) + o.append(t["limits_h"]) + caveats = meta["caveats"] + if isinstance(caveats, dict): + caveats = caveats.get(lang) or caveats.get("ru") or [] + for line in caveats: o.append(f"- {line}") if meta.get("jsonl"): - o.append(f"\n_Сырые per-run данные: `{meta['jsonl']}` — отчёт пересобирается из них " - f"командой `python -m llmbench.runner --report-from <файл>`._") + o.append(t["jsonl"].format(jsonl=meta['jsonl'])) return "\n".join(o) diff --git a/llmbench/runner.py b/llmbench/runner.py index ed2fbb9..bc601e8 100644 --- a/llmbench/runner.py +++ b/llmbench/runner.py @@ -1,16 +1,17 @@ """Ранер: сетка вариантов (модель × thinking/effort/reasoning) × кейсы × repeat. Режимы MCP: --mode fixed (фикстуры, детерминированно, CI) | live (реальные MCP-серверы + -токены из env). Движок и MCP развязаны от askads. Каждый прогон пишется в JSONL -(results/runs-.jsonl): ответы, трейсы, usage, оценки — отчёт пересобирается из него -без повторных трат (`--report-from`). +токены из env). Движок и MCP развязаны от askads. Каждый прогон складывается в дата-папку +results//: сырой runs.jsonl (ответы, трейсы, usage, оценки) + двуязычный отчёт +results.ru.md и results.en.md. Отчёт пересобирается из runs.jsonl без повторных трат +(`--report-from`). # детерминированный model-бенч (нужны ключи моделей): python -m llmbench.runner --mode fixed --repeat 2 # против РЕАЛЬНЫХ тулов (нужны npm-серверы + токены кабинета): python -m llmbench.runner --mode live --variants "GLM-4.6 disabled" --judges off - # пересобрать отчёт из сырых данных (бесплатно): - python -m llmbench.runner --report-from results/runs-20260703-120000.jsonl + # пересобрать отчёт (ru+en) из сырых данных (бесплатно): + python -m llmbench.runner --report-from results/2026-07-03/runs.jsonl """ from __future__ import annotations @@ -31,10 +32,12 @@ from llmbench.fixtures import FIXTURE_VERSION ZAI = "https://api.z.ai/api/anthropic" -# Ранер пишет СГЕНЕРИРОВАННЫЙ отчёт, а не курируемые model-comparison-grid.ru/en.md — -# чтобы прогон не затирал ручную сборку (Топ-3, прозу, двуязычие). Курируемые отчёты -# правятся из этого файла вручную. -DEFAULT_OUT = "results/model-comparison-grid.generated.md" +# Каждый прогон — своя дата-папка results// с сырым runs.jsonl и двуязычным отчётом +# (results.ru.md + results.en.md). Проза/Топ-3 дописываются руками поверх сгенерированного +# грида — прогон не затирает чужую дата-папку (при коллизии по дате добавляет время). +RESULTS_ROOT = "results" +REPORT_STEM = "results" # results.ru.md / results.en.md +JSONL_NAME = "runs.jsonl" def _v(label, vendor, engine, model, **kw): @@ -148,23 +151,40 @@ def _git_commit(): def _build_caveats(mode, repeat, neutral): - caveats = [ - "**GPT гоняли через отдельную обвязку** (askads на Anthropic, GPT в его движок не вставить) — " + """Оговорки на обоих языках → {'ru': [...], 'en': [...]}; build_md берёт нужный по lang.""" + ru, en = [], [] + + def add(r, e): + ru.append(r) + en.append(e) + + add("**GPT гоняли через отдельную обвязку** (askads на Anthropic, GPT в его движок не вставить) — " "точность работы GPT с инструментами сравнима с Claude/GLM не идеально (другой формат вызова тулов).", - "**Цены и скидки за кэш (кэш-хит)** — по прайс-листам; сверить с реальными счетами.", - "**Модель могла подмениться**: ответ API на имя `glm-5`/`gpt-5` ещё не гарантирует, что под капотом именно она.", - ] + "**GPT was run via a separate wrapper** (askads is on Anthropic, GPT can't be plugged into its " + "engine) — GPT's tool-use accuracy isn't perfectly comparable to Claude/GLM (a different tool-call format).") + add("**Цены и скидки за кэш (кэш-хит)** — по прайс-листам; сверить с реальными счетами.", + "**Prices and cache-read discounts** — from price lists; verify against real bills.") + add("**Модель могла подмениться**: ответ API на имя `glm-5`/`gpt-5` ещё не гарантирует, что под капотом именно она.", + "**The model may have been substituted**: the API answering to `glm-5`/`gpt-5` doesn't guarantee " + "that's the model under the hood.") if not neutral: - caveats.append("**Независимого судьи нет**: ответы оценивают те же компании, чьи модели и " - "сравниваются — возможно завышение «своей» модели; оценки судей вспомогательные, " - "вес на ключевых метриках Tools Use/Accuracy (их считает код).") + add("**Независимого судьи нет**: ответы оценивают те же компании, чьи модели и сравниваются — " + "возможно завышение «своей» модели; оценки судей вспомогательные, вес на ключевых метриках " + "Tools Use/Accuracy (их считает код).", + "**No independent judge**: answers are scored by the same companies whose models are compared — " + "possible self-model inflation; judge scores are auxiliary, weight is on the key metrics " + "Tools Use/Accuracy (computed in code).") if repeat < 3: - caveats.append(f"**Мало повторов** ({repeat}) — Stability на {repeat} точках доверять рано; " - "в Accuracy «уверенно неверное число» = «не названо».") + add(f"**Мало повторов** ({repeat}) — Stability на {repeat} точках доверять рано; " + "в Accuracy «уверенно неверное число» = «не названо».", + f"**Few repeats** ({repeat}) — Stability on {repeat} points is premature to trust; " + "in Accuracy a \"confidently wrong number\" = \"not stated\".") if mode == "fixed": - caveats.append("Режим `fixed`: модели видят аккуратные тестовые данные (фикстуры), а не «грязный» " - "реальный вывод API (для этого `--mode live`).") - return caveats + add("Режим `fixed`: модели видят аккуратные тестовые данные (фикстуры), а не «грязный» " + "реальный вывод API (для этого `--mode live`).", + "Mode `fixed`: models see clean test data (fixtures), not the \"messy\" real API output " + "(use `--mode live` for that).") + return {"ru": ru, "en": en} def _filter_or_die(items, patterns, what, key): @@ -178,6 +198,29 @@ def _filter_or_die(items, patterns, what, key): return picked +def _run_dir(ts, out_arg=None): + """Каталог прогона: --out override или results// (при коллизии по дате — +время, + чтобы второй прогон за день не затирал первый).""" + if out_arg: + return Path(out_arg) + base = Path(RESULTS_ROOT) / ts.strftime("%Y-%m-%d") + if base.exists(): + base = Path(RESULTS_ROOT) / ts.strftime("%Y-%m-%d_%H%M%S") + return base + + +def _write_reports(aggregates, meta, run_dir): + """Двуязычный отчёт results.ru.md + results.en.md в run_dir → dict lang→Path.""" + run_dir = Path(run_dir) + run_dir.mkdir(parents=True, exist_ok=True) + paths = {} + for lang in ("ru", "en"): + p = run_dir / f"{REPORT_STEM}.{lang}.md" + p.write_text(report.build_md(aggregates, meta, lang=lang), encoding="utf-8") + paths[lang] = p + return paths + + def _report_from(path, out_arg): meta, recs_by = None, {} for line in Path(path).read_text(encoding="utf-8").splitlines(): @@ -191,10 +234,9 @@ def _report_from(path, out_arg): if not meta or not recs_by: sys.exit(f"{path}: нет meta/run записей — это не лог ранера") aggregates = {label: report.agg(rs) for label, rs in recs_by.items()} - out = Path(out_arg or DEFAULT_OUT) - out.parent.mkdir(parents=True, exist_ok=True) - out.write_text(report.build_md(aggregates, meta), encoding="utf-8") - print(f"Отчёт пересобран из {path} → {out}") + # По умолчанию — рядом с исходным JSONL (в его дата-папке); --out переопределяет каталог. + paths = _write_reports(aggregates, meta, out_arg or Path(path).parent) + print(f"Отчёт пересобран из {path} → {paths['ru']} + {paths['en']}") async def _run_variant(v, cases, args, judges_all, candidate_vendors, jsonl_path, jsonl_lock): @@ -227,9 +269,10 @@ async def main(): ap.add_argument("--concurrency", type=int, default=4, help="параллельных прогонов внутри варианта (кейсы × повторы)") ap.add_argument("--dry-run", action="store_true") - ap.add_argument("--out", default=None, help=f"файл отчёта (дефолт {DEFAULT_OUT})") + ap.add_argument("--out", default=None, + help="каталог отчёта (дефолт results//); пишет results.ru.md + results.en.md") ap.add_argument("--report-from", default=None, - help="пересобрать отчёт из runs-*.jsonl без запусков (бесплатно)") + help="пересобрать отчёт (ru+en) из results//runs.jsonl без запусков (бесплатно)") args = ap.parse_args() if args.report_from: @@ -282,18 +325,17 @@ async def main(): sys.exit("Нужен RUN_BENCH=1 (защита от случайного платного запуска).") ts = datetime.now(timezone.utc) - jsonl_path = Path(f"results/runs-{ts.strftime('%Y%m%d-%H%M%S')}.jsonl") - jsonl_path.parent.mkdir(parents=True, exist_ok=True) + run_dir = _run_dir(ts, args.out) + run_dir.mkdir(parents=True, exist_ok=True) + jsonl_path = run_dir / JSONL_NAME jsonl_lock = asyncio.Lock() + # baseline_desc в meta не пишем — build_md выводит его из variants (is_baseline) на нужном языке. meta = {"ts": ts.strftime("%Y-%m-%d %H:%M UTC"), "mode": args.mode, "repeat": args.repeat, "n_cases": len(cases), "variants": runnable, "judges": [j["name"] for j in judges_all] or "—", "neutral": neutral, "fixture_version": FIXTURE_VERSION, "git_commit": _git_commit(), "jsonl": str(jsonl_path), - "baseline_desc": None, "caveats": _build_caveats(args.mode, args.repeat, neutral)} - baseline = next((v for v in runnable if v.get("is_baseline")), None) - if baseline: - meta["baseline_desc"] = "{} (thinking {}, effort {})".format(*report.describe(baseline)) + "caveats": _build_caveats(args.mode, args.repeat, neutral)} with jsonl_path.open("w", encoding="utf-8") as f: f.write(json.dumps({"type": "meta", "meta": meta}, ensure_ascii=False) + "\n") @@ -302,16 +344,15 @@ async def main(): recs = await _run_variant(v, cases, args, judges_all, candidate_vendors, jsonl_path, jsonl_lock) aggregates[v["label"]] = report.agg(recs) - out = Path(args.out) if args.out else Path(DEFAULT_OUT) - if not args.out and (args.variants or args.cases): - print(f"[note] частичный прогон (--variants/--cases): отчёт {out} содержит только " + if args.variants or args.cases: + print(f"[note] частичный прогон (--variants/--cases): отчёт в {run_dir} содержит только " f"выбранные варианты/кейсы — не путать с полным гридом") - out.parent.mkdir(parents=True, exist_ok=True) - out.write_text(report.build_md(aggregates, meta), encoding="utf-8") + paths = _write_reports(aggregates, meta, run_dir) total_cost = sum(a["cost_total"] for a in aggregates.values()) total_errors = sum(a["errors"] for a in aggregates.values()) - print(f"\nГотово → {out} · сырые данные: {jsonl_path} · потрачено ≈ ${total_cost:.2f} " - f"· ошибок {total_errors}/{sum(a['n_runs'] for a in aggregates.values())}") + print(f"\nГотово → {paths['ru']} + {paths['en']} · сырые данные: {jsonl_path} · " + f"потрачено ≈ ${total_cost:.2f} · ошибок {total_errors}/" + f"{sum(a['n_runs'] for a in aggregates.values())}") if __name__ == "__main__": diff --git a/results/model-comparison-grid.en.md b/results/2026-06-29/results.en.md similarity index 97% rename from results/model-comparison-grid.en.md rename to results/2026-06-29/results.en.md index 583b412..46f45da 100644 --- a/results/model-comparison-grid.en.md +++ b/results/2026-06-29/results.en.md @@ -1,9 +1,9 @@ # Model comparison for AskAds (Claude / GLM / GPT) — run from 2026-06-29 UTC -[🇷🇺 Русский](model-comparison-grid.ru.md) · 🇬🇧 English +[🇷🇺 Русский](results.ru.md) · 🇬🇧 English > ⚠️ **Numbers are stale (2026-06-29 run).** Produced BEFORE the scoring/fixture fixes of -> 2026-07-03 (see `../REVIEW.md`): Accuracy entity-anchoring, the Stability formula (now σ +> 2026-07-03 (see `../../REVIEW.md`): Accuracy entity-anchoring, the Stability formula (now σ > between repeats within a case), GPT cache multipliers, and some fixtures all changed. These > values were NOT recomputed — regenerate with a fresh run. The "Score = average of the four > metrics" definition below is also imprecise: Score is the mean of the _available_ components diff --git a/results/model-comparison-grid.ru.md b/results/2026-06-29/results.ru.md similarity index 97% rename from results/model-comparison-grid.ru.md rename to results/2026-06-29/results.ru.md index 2f268e5..8eee22b 100644 --- a/results/model-comparison-grid.ru.md +++ b/results/2026-06-29/results.ru.md @@ -1,9 +1,9 @@ # Сравнение моделей для AskAds (Claude / GLM / GPT) - запуск от 2026-06-29 UTC -🇷🇺 Русский · [🇬🇧 English](model-comparison-grid.en.md) +🇷🇺 Русский · [🇬🇧 English](results.en.md) > ⚠️ **Числа устарели (прогон 2026-06-29).** Отчёт получен ДО правок скоринга и фикстур -> от 2026-07-03 (см. `../REVIEW.md`): изменились entity-анкоринг Accuracy, формула Stability +> от 2026-07-03 (см. `../../REVIEW.md`): изменились entity-анкоринг Accuracy, формула Stability > (теперь σ между повторами внутри кейса), кэш-множители GPT и часть фикстур. Приведённые > значения пересчёту не подвергались — их нужно перегенерировать свежим прогоном. Определение > «Score = среднее четырёх метрик» ниже тоже неточно: Score — среднее _доступных_ компонент diff --git a/tests/test_report.py b/tests/test_report.py index 0f591f4..3a92fce 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -100,10 +100,33 @@ def test_build_md_renders_err_column_and_metadata(): "variants": [{"label": "V", "model": "claude-opus-4-8", "engine": "anthropic", "vendor": "anthropic", "thinking": "adaptive", "effort": "high"}], "judges": ["Claude"], "neutral": [], "fixture_version": "2026-07-03", - "git_commit": "abc1234", "jsonl": "results/runs-x.jsonl", "baseline_desc": None, + "git_commit": "abc1234", "jsonl": "results/2026-07-03/runs.jsonl", "caveats": ["тест"]} md = report.build_md(aggs, meta) assert "Err" in md and "1/2" in md # колонка ошибок assert "abc1234" in md # git-коммит в метаданных - assert "runs-x.jsonl" in md # ссылка на сырые данные + assert "results/2026-07-03/runs.jsonl" in md # ссылка на сырые данные assert "average of the four" not in md.lower() # старое ложное определение Score ушло + + +def test_build_md_bilingual_and_caveats_by_lang(): + """build_md(lang=...) даёт ru/en с кросс-ссылками, baseline из variants, оговорками своего языка.""" + aggs = {"V": report.agg([_rec("a")])} + meta = {"ts": "2026-07-03 12:00 UTC", "mode": "fixed", "repeat": 3, "n_cases": 1, + "variants": [{"label": "V", "model": "claude-opus-4-8", "engine": "anthropic", + "vendor": "anthropic", "thinking": "adaptive", "effort": "high", + "is_baseline": True}], + "judges": ["Claude"], "neutral": [], "fixture_version": "2026-07-03", + "git_commit": "abc1234", "jsonl": "results/2026-07-03/runs.jsonl", + "caveats": {"ru": ["оговорка-ру"], "en": ["caveat-en"]}} + ru = report.build_md(aggs, meta, lang="ru") + en = report.build_md(aggs, meta, lang="en") + assert "Сравнение моделей" in ru and "Все варианты" in ru + assert "[🇬🇧 English](results.en.md)" in ru # кросс-ссылка на соседний файл + assert "прод askads — Opus 4.8 (thinking adaptive" in ru # baseline выведен из variants + assert "Model comparison" in en and "All variants" in en + assert "[🇷🇺 Русский](results.ru.md)" in en + assert "production — Opus 4.8 (thinking adaptive" in en + # оговорки — строго своего языка, чужие не протекают + assert "оговорка-ру" in ru and "caveat-en" not in ru + assert "caveat-en" in en and "оговорка-ру" not in en diff --git a/tests/test_runner_e2e.py b/tests/test_runner_e2e.py index 81b9f35..5ca8b7c 100644 --- a/tests/test_runner_e2e.py +++ b/tests/test_runner_e2e.py @@ -91,13 +91,14 @@ async def fake_anthropic(history, **kw): def test_jsonl_roundtrip_report_from(tmp_path, monkeypatch): - """Отчёт пересобирается из JSONL без запусков (R13).""" + """Отчёт (ru+en) пересобирается из JSONL без запусков (R13).""" jsonl = tmp_path / "runs.jsonl" meta = {"ts": "2026-07-03 12:00 UTC", "mode": "fixed", "repeat": 2, "n_cases": 1, "variants": [{"label": "V", "model": "claude-opus-4-8", "engine": "anthropic", "vendor": "anthropic", "thinking": "adaptive", "effort": "high"}], "judges": ["Claude"], "neutral": [], "fixture_version": "2026-07-03", - "git_commit": "abc1234", "jsonl": str(jsonl), "baseline_desc": None, "caveats": ["c"]} + "git_commit": "abc1234", "jsonl": str(jsonl), + "caveats": {"ru": ["оговорка"], "en": ["caveat"]}} rec = {"case": "a", "dimension": "numeric", "turn_type": "single", "tool": 5.0, "numeric": 5.0, "has_golden": True, "soft_quality": 5.0, "soft_russian": 5.0, "cost": 0.01, "cost_wasted": 0.0, "retried": False, "error": None, "composite": 5.0} @@ -105,7 +106,10 @@ def test_jsonl_roundtrip_report_from(tmp_path, monkeypatch): {"type": "run", "variant": "V", "case": "a", "repeat": 0, "rec": rec, "answer": "...", "tool_trace": [], "usage": {}}] jsonl.write_text("\n".join(json.dumps(x, ensure_ascii=False) for x in lines), encoding="utf-8") - out = tmp_path / "report.md" - runner._report_from(str(jsonl), str(out)) - md = out.read_text(encoding="utf-8") - assert "Opus 4.8" in md and "abc1234" in md + # out=None → отчёт ложится рядом с JSONL, в его дата-папке (обе языковые версии) + runner._report_from(str(jsonl), None) + ru = (tmp_path / "results.ru.md").read_text(encoding="utf-8") + en = (tmp_path / "results.en.md").read_text(encoding="utf-8") + assert "Opus 4.8" in ru and "abc1234" in ru and "Все варианты" in ru and "оговорка" in ru + assert "Opus 4.8" in en and "All variants" in en and "caveat" in en + assert "average of the four" not in en.lower() # старое ложное определение Score ушло