diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b895c3..a8a6ff5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,5 +13,8 @@ jobs: with: python-version: "3.11" - run: pip install -r requirements-dev.txt - # Только офлайн self-test (без сети/денег/ключей). Тяжёлый прогон — вручную (RUN_BENCH=1). - - run: pytest -q tests/test_fixtures.py + # Импорт ВСЕХ модулей (в т.ч. engines/judges/runner/report) — синтаксис/импорты + # ловятся до тестов; раньше эти модули CI не видел (REVIEW.md R37). + - run: python -c "import llmbench.core, llmbench.fixtures, llmbench.mcp, llmbench.scoring, llmbench.cases, llmbench.engines, llmbench.judges, llmbench.report, llmbench.runner" + # Офлайн self-test (без сети/денег/ключей). Тяжёлый прогон — вручную (RUN_BENCH=1). + - run: pytest -q diff --git a/.gitignore b/.gitignore index 20cb9d1..4dd7282 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,8 @@ node_modules/ *.log .pytest_cache/ .DS_Store + +# Сырые артефакты прогонов и авто-сгенерированный отчёт (курируемые .ru/.en.md — под VCS). +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 96ee849..0cf7edc 100644 --- a/README.en.md +++ b/README.en.md @@ -15,30 +15,33 @@ Two modes: The engine and MCP client are **decoupled from askads** (extracted into `llmbench/`), so the repository is self-contained. +> Full review of the harness and the list of fixed issues — see [`REVIEW.md`](REVIEW.md). + ## What is scored, and by whom | Dimension | By | Where | |---|---|---| -| **Tool-Use** (right tools / order / call cap) | code | `scoring.score_tooluse` | -| **Numeric-Accuracy** (number correctness, "don't invent CPA") | code | `scoring.score_numeric` | +| **Tool-Use** (right tools succeed / order / call cap) | code | `scoring.score_tooluse` | +| **Numeric-Accuracy** (number correctness + entity anchoring, "don't invent CPA") | code | `scoring.score_numeric` | | Interpretation / Russian / Edge handling | judge panel | `judges.py` | -Key metrics are the backbone of the decision; judges are strictly secondary. +Key metrics are the backbone of the comparison; judges are strictly secondary. ## Layout ``` llmbench/ - core.py # allowlists, tool-schema converters, truncation, rates, system prompt, retry - fixtures.py # frozen account + FIXTURE_VERSION - mcp.py # live stdio client + server registry + fake session + open_session(mode) - engines.py # agentic loops: run_anthropic (Claude/GLM), run_openai (GPT) - scoring.py # numeric + toolcheck + cost + decision rule - judges.py # panel {Claude, GPT, GLM, (opt.) Gemini} + neutrality - cases.py # cases (question + trace spec + golden_facts + rubric) - runner.py # variant grid x cases x repeat, report, verdicts -tests/test_fixtures.py # offline self-test (CI) -results/ # report + raw per-run log of the latest run + core.py # allowlists, tool-schema converters, truncation, rates, system prompt, retry, timeouts + fixtures.py # frozen account + FIXTURE_VERSION (single source of golden facts) + mcp.py # live stdio client + server registry + fake session + preflight_live + engines.py # agentic loops: run_anthropic (Claude/GLM), run_openai (GPT); retries, timeouts + scoring.py # numeric (entity anchoring) + toolcheck (is_error aware) + cost + judges.py # panel {Claude, GPT, GLM, (opt.) Gemini}, neutrality, determinism + cases.py # cases (question + trace spec + golden_facts with entity + rubric) + 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) ``` ## Running @@ -46,7 +49,7 @@ results/ # report + raw per-run log of the latest run Offline self-test (no network/money; CI): ```bash pip install -r requirements-dev.txt -pytest -q tests/test_fixtures.py +pytest -q ``` Deterministic model benchmark (needs model keys): @@ -64,38 +67,51 @@ RUN_BENCH=1 ANTHROPIC_API_KEY=… YANDEX_DIRECT_TOKEN=… \ ``` Account-token env: `YANDEX_DIRECT_TOKEN` (+ optional `YANDEX_DIRECT_LOGIN`), `YANDEX_METRIKA_TOKEN`, `VK_ADS_TOKEN`. Override a server path with -`MCP_PATH_YANDEX_DIRECT=/path/to/dist/index.js`. +`MCP_PATH_YANDEX_DIRECT=/path/to/dist/index.js`. Live mode preflights (tokens + server +presence) BEFORE the first paid call. + +Flags: `--variants`, `--cases` (a typo in the filter is an error, not a silent full grid), +`--repeat`, `--judges panel|neutral|off`, `--concurrency N` (parallel runs within a variant), +`--dry-run` (shows the estimate without keys), `--out`, `--report-from `. The variant +list (model x thinking/effort/reasoning) lives in `llmbench/runner.py`; adding a model is one +line (don't forget the rate in `core.MODEL_RATES`, or the runner warns). + +## Artifacts and re-scoring -Flags: `--variants`, `--cases`, `--repeat`, `--judges panel|neutral|off`, `--dry-run`, -`--out`. The variant list (model x thinking/effort/reasoning) lives in `llmbench/runner.py`; -adding a model is one line. +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 — +```bash +python -m llmbench.runner --report-from results/runs-20260703-120000.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. -## Decision rule +## How Score is computed -`scoring.DECISION_RULE` (numeric ≥ 4.5, tool ≥ 4.5, edge ≥ 4.0, score/$ ≥ baseline) is fixed -BEFORE the run; the runner prints PASS/FAIL per variant vs the baseline (current prod — -`Sonnet disabled/high`). Both axes (quality and score/$) are measured on the shippable config. +A run's `Score` is the mean of the **available** components: Tools Use (always), Accuracy (if +the case has golden facts), Edge Cases and Lang quality (if judges ran). The 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 report columns. Failed runs (API errors, token-limit +truncation) are excluded from metrics and shown in a separate `Err` column. +`Stability = 5 − mean spread of Score between repeats of the same case` (not across cases of +differing difficulty). ## Latest run results -`results/model-comparison-grid.en.md` (+ `…ru.md`) — summary + Pareto frontier; -`results/run-log.txt` — raw per-run log (aggregates are recomputable from it). - -In short (fixed, repeat 2, 16 variants): **GLM-4.6 without thinking** — parity with Claude on -the key metrics (Numeric/Tool 5.0), the most stable non-Claude variant (σ 0.36), and **~11× -cheaper** than prod (score/$ 1630 vs 115). The only SWITCH on the Pareto frontier. Surprises: -thinking HURTS GLM-4.6 (Numeric 5.0→4.0); GLM-5 is worse than 4.6; GPT fails the key metrics -(gpt-4.1 omits CPA → Numeric 4.0; gpt-5 reaches for tools on a "change the bid" request). +`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. ## Known limitations - **Case ceiling:** top models max out at 5.0 on Tool/Numeric → "parity" here means "both ace THESE tasks". Harder cases are needed to truly separate quality. - **Judges are secondary:** without a neutral vendor, the primary soft score is the panel mean - (advisory, self-preference possible). The decision rests on the key metrics. -- gpt/gemini/glm rates and cache multipliers are estimates; verify against billing. + (advisory, self-preference possible). The comparison rests on the key metrics. +- Model rates and cache multipliers are from price lists; verify against billing. - `glm-5`/`gpt-5`: availability ≠ identity of the expected model — verify. -- `--repeat` is a coarse noise flag, not a comparison count; "wrong number == missing" in - numeric is a simplification. +- `--repeat` is a coarse noise flag; "wrong number == missing" in numeric is a simplification. - `fixed` mode does not catch robustness on messy API output (use `--mode live` for that). - The `system prompt` in `core.py` is domain-specific (a Direct analyst); swap it for your case. diff --git a/README.md b/README.md index 9b297b1..c2546b6 100644 --- a/README.md +++ b/README.md @@ -14,30 +14,33 @@ VK Ads / Метрика) и **бенчить их между собой** на Движок и MCP-клиент **развязаны от askads** (вынесены в `llmbench/`), репозиторий автономен. +> Полное ревью харнесса и список исправленных проблем — в [`REVIEW.md`](REVIEW.md). + ## Что и кто считает | Измерение | Кто | Где | |---|---|---| -| **Tool-Use** (нужные тулы/порядок/кап) | код | `scoring.score_tooluse` | -| **Numeric-Accuracy** (точность чисел, «не выдумывать CPA») | код | `scoring.score_numeric` | +| **Tool-Use** (нужные тулы успешно/порядок/кап) | код | `scoring.score_tooluse` | +| **Numeric-Accuracy** (точность чисел + entity-анкоринг, «не выдумывать CPA») | код | `scoring.score_numeric` | | Интерпретация / Русский / Краевое | панель судей | `judges.py` | -Ключевые метрики — костяк решения; судьи строго вторичны. +Ключевые метрики — костяк сравнения; судьи строго вторичны. ## Структура ``` llmbench/ - core.py # allowlists, конвертеры тулов, обрезка, ставки, system-prompt, retry - fixtures.py # замороженный кабинет + FIXTURE_VERSION - mcp.py # live stdio-клиент + реестр серверов + fake-сессия + open_session(mode) - engines.py # агентные loop'ы: run_anthropic (Claude/GLM), run_openai (GPT) - scoring.py # numeric + toolcheck + cost + decision rule - judges.py # панель {Claude, GPT, GLM, (опц.) Gemini} + нейтральность - cases.py # кейсы (вопрос + trace-спека + golden_facts + рубрика) - runner.py # сетка вариантов × кейсы × repeat, отчёт, вердикты -tests/test_fixtures.py # офлайн self-test (CI) -results/ # отчёт + сырой per-run лог последнего прогона + core.py # allowlists, конвертеры тулов, обрезка, ставки, system-prompt, retry, таймауты + fixtures.py # замороженный кабинет + FIXTURE_VERSION (единый источник golden-фактов) + mcp.py # live stdio-клиент + реестр серверов + fake-сессия + preflight_live + engines.py # агентные loop'ы: run_anthropic (Claude/GLM), run_openai (GPT); ретраи, таймауты + scoring.py # numeric (entity-анкоринг) + toolcheck (учёт is_error) + cost + judges.py # панель {Claude, GPT, GLM, (опц.) Gemini}, нейтральность, детерминизм + cases.py # кейсы (вопрос + trace-спека + golden_facts с entity + рубрика) + report.py # агрегация записей, Stability/Score/Pareto, сборка markdown (тестируется в CI) + runner.py # сетка вариантов × кейсы × repeat, JSONL-персист, отчёт +tests/ # офлайн self-test: скоринг + агрегация + конвейер ранера (всё в CI) +results/ # курируемые отчёты (.ru/.en.md) + авто-отчёт и runs-*.jsonl (в .gitignore) ``` ## Запуск @@ -45,7 +48,7 @@ results/ # отчёт + сырой per-run лог последн Офлайн self-test (без сети/денег; CI): ```bash pip install -r requirements-dev.txt -pytest -q tests/test_fixtures.py +pytest -q ``` Детерминированный model-бенч (нужны ключи моделей): @@ -63,38 +66,50 @@ RUN_BENCH=1 ANTHROPIC_API_KEY=… YANDEX_DIRECT_TOKEN=… \ ``` Env токенов кабинета: `YANDEX_DIRECT_TOKEN` (+ опц. `YANDEX_DIRECT_LOGIN`), `YANDEX_METRIKA_TOKEN`, `VK_ADS_TOKEN`. Путь к серверу можно переопределить -`MCP_PATH_YANDEX_DIRECT=/path/to/dist/index.js`. +`MCP_PATH_YANDEX_DIRECT=/path/to/dist/index.js`. Live-режим делает префлайт (токены + +наличие серверов) ДО первого платного вызова. + +Флаги: `--variants`, `--cases` (опечатка в фильтре — ошибка, а не молчаливый полный грид), +`--repeat`, `--judges panel|neutral|off`, `--concurrency N` (параллельные прогоны внутри +варианта), `--dry-run` (показывает смету без ключей), `--out`, `--report-from `. +Список вариантов (модель × thinking/effort/reasoning) — в `llmbench/runner.py`; добавить +модель = одна строка (не забудь тариф в `core.MODEL_RATES`, иначе ранер предупредит). + +## Артефакты и пере-скоринг -Флаги: `--variants`, `--cases`, `--repeat`, `--judges panel|neutral|off`, `--dry-run`, -`--out`. Список вариантов (модель × thinking/effort/reasoning) — в `llmbench/runner.py`; -добавить модель = одна строка. +Каждый прогон пишет **`results/runs-.jsonl`** — по записи на прогон (ответ, tool-трейс, +usage, все оценки, ошибки). Это источник правды: отчёт пересобирается из него бесплатно, +без повторных вызовов моделей — +```bash +python -m llmbench.runner --report-from results/runs-20260703-120000.jsonl +``` +Ранер пишет авто-отчёт в `results/model-comparison-grid.generated.md` (в `.gitignore`), +чтобы не затирать курируемые вручную `model-comparison-grid.ru.md` / `…en.md` (Топ-3, проза, +двуязычие) — их правят руками из сгенерированного. -## Правило решения +## Как считается Score -`scoring.DECISION_RULE` (numeric ≥ 4.5, tool ≥ 4.5, edge ≥ 4.0, score/$ ≥ baseline) фиксируется -ДО прогона; ранер печатает PASS/FAIL по каждому варианту vs baseline (текущий прод — -`Sonnet disabled/high`). Обе оси (качество и score/$) — на shippable-конфиге. +`Score` прогона = среднее **доступных** компонент: Tools Use (всегда), Accuracy (если у +кейса есть golden-факты), Edge Cases и Lang quality (если работали судьи). Состав зависит от +кейса, поэтому Score сравним между вариантами (кейсы у всех одни), но НЕ равен среднему +четырёх колонок отчёта. Упавшие прогоны (ошибки API, обрезка лимитом токенов) в метрики не +входят и видны отдельной колонкой `Err`. `Stability = 5 − средний разброс Score между +повторами одного кейса` (а не между кейсами разной сложности). ## Результаты последнего прогона -`results/model-comparison-grid.ru.md` (+ англ. `…en.md`) — сводка + Pareto-фронт; -`results/run-log.txt` — сырой per-run лог (по нему пересчитываются агрегаты). - -Кратко (fixed, repeat 2, 16 вариантов): **GLM-4.6 без thinking** — паритет с Claude по кодовым -метрикам (Numeric/Tool 5.0), самый стабильный из не-Claude (σ 0.36), и **~11× дешевле** прода -(score/$ 1630 против 115). Единственный SWITCH на Pareto-фронте. Сюрпризы: thinking ВРЕДИТ -GLM-4.6 (Numeric 5.0→4.0); GLM-5 хуже 4.6; GPT не проходит ключевые метрики (gpt-4.1 не выводит -CPA → Numeric 4.0; gpt-5 лезет в тулы на «измени ставку»). +`results/model-comparison-grid.ru.md` (+ англ. `…en.md`) — курируемая сводка + Pareto-фронт. +⚠️ Текущие числа в них — от прогона 2026-06-29, ДО правок скоринга/фикстур (см. `REVIEW.md`); +их нужно перегенерировать свежим прогоном. ## Известные ограничения - **Потолок кейсов:** топ-модели упираются в 5.0 по Tool/Numeric → «паритет» здесь = «оба отлично решают ЭТИ задачи». Для строгого различения качества нужны более трудные кейсы. - **Судьи вторичны:** без нейтрального вендора первичный мягкий балл = среднее панели - (advisory, возможна self-preference). Вес решения — на ключевых метриках. -- Ставки/кэш-множители gpt/gemini/glm — оценки; сверить с биллингом. + (advisory, возможна self-preference). Вес сравнения — на ключевых метриках. +- Ставки/кэш-множители моделей — по прайс-листам; сверить с биллингом. - `glm-5`/`gpt-5`: доступность ≠ идентичность ожидаемой модели — сверить. -- `--repeat` — грубый флаг шума, не число для сравнения; «неверное число == пропущенное» в - numeric — упрощение. +- `--repeat` — грубый флаг шума; «неверное число == пропущенное» в numeric — упрощение. - `fixed`-режим не ловит robustness на грязном API-выводе (для этого `--mode live`). - `system-prompt` в `core.py` — доменный (аналитик Директа); замени под свой кейс. diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..c7ac19c --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,315 @@ +# Ревью llm-bench + +_Дата: 2026-07-03 · состояние кода: commit `5ba285b` · номера строк относятся к коду ДО исправлений._ + +**Как проводилось.** Многоагентное ревью: 5 независимых ревьюеров с разными линзами +(методология/статистика, скоринг, движки против документации API, эксплуатация ранера, +доки/тесты) → слияние и дедупликация → состязательная верификация каждой находки +(верификаторы читали код, запускали `llmbench.scoring`/`fixtures` на контрпримерах и сверяли +клеймы про API с официальной документацией Anthropic/OpenAI/z.ai). Из 40 кандидатов +**37 подтверждено, 3 опровергнуто**. + +**Вердикт.** Каркас правильный: фикстуры как единый источник правды, ключевые метрики +считаются кодом, честные caveats, детерминированный `fixed`-режим. Но две критические и +~14 серьёзных проблем искажали или могли молча исказить именно те цифры, ради которых +бенчмарк существует (Accuracy, Score, Stability, Pareto). + +Статусы исправлений — в конце файла. + +--- + +## Критичные + +### R1. Safe-fallback молча подменяет конфиг под тем же лейблом +`runner.py:57-60, 84-91, 257-263` · найдено 4 ревьюерами независимо + +При ошибке `_run_case_fb` перегонял кейс с `thinking=disabled / effort=high / +reasoning_effort=None`, и результат записывался под исходным лейблом варианта. Флаг `FB` +жил только в stdout — в записи, агрегатах и отчёте его не было. Строка «Sonnet adaptive/low» +могла содержать прогоны disabled/high, и по отчёту это не увидеть. Ретрай получали только +варианты с не-дефолтными настройками (асимметрия), стоимость упавшей попытки выбрасывалась. +В сохранённом прогоне (`run-log.txt`) FB-случаев не было — текущие опубликованные цифры не +задеты, но для будущих прогонов это мина: отвергни провайдер параметр — вся строка молча +превращается в safe-режим. _Фикс: ретрай тем же конфигом, никакой подмены; ошибки — в отчёт._ + +### R2. ANY-семантика алиасов: зачёт чужих чисел + коллизия фикстур 1230≈1200 +`scoring.py:58-71` + `cases.py:49-52` + `fixtures.py:30-31` · подтверждено запуском кода + +`_alias_near` зачитывал число, если **любой** алиас оказался в окне ±40 символов. У фактов +кампания и метрика лежали в одном списке (`['Москва','CPA']`), поэтому «CPA» в одиночку +анкерил число — принадлежность кампании не проверялась вовсе (вопреки докстрингу модуля). +Проверенные последствия: ответ с перепутанными местами CPA двух кампаний получает Accuracy +5.0; «средний CPA по аккаунту: 5000» зачитывается как CPA РСЯ (5150±5%). Хуже: клики РСЯ +(1230) попадают в допуск CPA Москвы (1200±60), поэтому во флагманском `tool_multi_step` +факт `cpa_poisk` **автозачитывался** от чужого числа — колонка Accuracy в этом кейсе не +могла поймать пропущенный/неверный CPA Москвы. _Фикс: entity-алиас И metric-алиас +раздельно, оба обязательны в мультикампейн-кейсах; развести коллизию в фикстурах._ + +## Серьёзные — честность отчёта + +### R3. Упавшие прогоны скорятся как ответы, а колонки ошибок в отчёте нет +`runner.py:94-116, 124-139, 198-207`. Ошибка API скорилась как обычный ответ: на +forbid_tools-кейсах давала незаслуженные 5.0 по Tools Use (тулы же не вызваны), на остальных +— нули, утягивающие среднее. `_agg` считает `errors`, но `_build_md` их не выводит — читатель +не отличит «модель слабая» от «у API был плохой день». + +### R4. Stability измеряет не то, что обещает глоссарий +`runner.py:127, 138`. `pstdev` считался по всем записям варианта (кейсы × повторы) — в нём +доминирует разброс сложности кейсов, а не «стабильность ответов между повторами». Модель, +детерминированно решающая лёгкие кейсы на 5.0 и трудный на 3.0, выглядела «нестабильной». +_Фикс: σ по повторам внутри кейса, усреднённая по кейсам._ + +### R5. Score не совпадает со своим определением в глоссарии +`runner.py:94-100, 176`. Глоссарий и оба опубликованных отчёта: «среднее четырёх метрик +(Accuracy/Tools Use/Edge Cases/Lang quality)». Фактический `_composite` = mean(tool, +numeric?, soft_quality?) — **Lang quality не входила вовсе**; состав компонент различался +по кейсам; судейская шкала 1–5 смешивалась с кодовой 0–5 (пол у судей — 1, у кода — 0). + +### R6. Survivorship bias в колонке Edge Cases +`runner.py:107, 126`. Судьи запускались только при `not fail_fast and not error`, а колонка +Edge усредняла только оценённые записи. Худшие edge-прогоны (модель полезла в тулы на +«подними ставку» — ровно то, за что README ругает gpt-5) выпадали из среднего и **завышали** +его: чем больше нарушений, тем выше Edge-балл. + +### R7. Нонс в системном промпте подсказывает модели суть теста +`runner.py:257`, `engines.py:30-31`. В промпт уходило `[bench:refuse_change_bid:Sonnet +adaptive/high:0]` — самоописывающийся id кейса («откажись менять ставку», «задай уточняющий +вопрос») плюс собственный конфиг модели. Модели по-разному эксплуатируют такие подсказки → +неравные условия ровно на edge-измерении. _Фикс: хэш вместо говорящей строки._ + +### R8. Кэш-множитель OpenAI завышал стоимость GPT в 2–5× +`core.py:68`. В коде `CACHE_MULT['openai'] = 0.5×`, реально: gpt-5 — 0.10×, gpt-4.1 — 0.25× +(проверено по официальному прайсу). Cost per Answer / Score per USD / Pareto систематически +смещены против GPT. Множители Anthropic и z.ai корректны — перекос односторонний. + +## Серьёзные — надёжность прогона + +### R9. В Anthropic-пути нет ретраев +`engines.py:109-112, 136` vs `170`. `retry_call` (5 попыток, экспоненциальный бэкофф) есть +у OpenAI-пути и судей, но вызовы `client.messages.stream` не обёрнуты. Транзиентный 529 у +Claude/GLM сразу становился ошибкой → срабатывала подмена конфига (R1). + +### R10. Финальный вызов после исчерпания итераций гарантированно ловит 400 +`engines.py:134-141`. Ветка `if not completed` отправляла историю с tool_use/tool_result +блоками **без параметра `tools`** — Anthropic API это отвергает (сверено с документацией). +Заодно терялся thinking-конфиг и не учитывались cache-токены в usage. + +### R11. max_tokens=4096 включает thinking-токены; обрезка молча считается ответом +`engines.py:88, 102, 119-122`, `core.py:54`. У adaptive-thinking токены размышлений входят +в `max_tokens`: thinking-варианты Opus рисковали получать обрезанные/пустые ответы, а +`stop_reason == "max_tokens"` молча трактовался как финальный ответ → незаслуженные нули +ровно у thinking-конфигураций, которые бенчмарк сравнивает. GPT при этом гонялся без капа. + +### R12. Опечатка в --variants/--cases молча запускает полный платный грид +`runner.py:229-230`. Паттерн `[...] or list(VARIANTS)`: пустой результат фильтра +разворачивался в полный список — 288 оплаченных прогонов вместо ошибки. + +### R13. Нет сырого артефакта: ни пере-скоринга, ни резюма, ни защиты отчёта +`runner.py:254-263, 283-285`, `README.md:78-82`. Писался только итоговый markdown: ответы, +трейсы, per-judge оценки, usage терялись; после фикса скоринга пересчитать нечего; упавший +на 200-м прогоне грид начинался заново; дефолтный `--out` затирал выверенный отчёт; +обещанный README `results/run-log.txt` ранер не писал (в репо лежит сохранённый вручную +stdout от более старой версии кода). _Фикс: JSONL per-run + `--report-from`._ + +### R14. Live-режим: один зависший MCP-сервер вешает весь грид +`mcp.py:54-58`, `engines.py:78`. Ни на инициализации сессии, ни на `call_tool` не было +таймаутов; при последовательном исполнении это стоп всего прогона. + +### R15. Документация описывает удалённую функциональность +`README.md:35-38, 72-76, 85`, `README.en.md`, докстринги `runner.py:4`, +`tests/test_fixtures.py:4`. Секция «Правило решения» ссылается на `scoring.DECISION_RULE` +и PASS/FAIL-печать, которых нет (в `scoring.py` прямо написано, что правило намеренно +убрано); строка структуры обещает «decision rule» в scoring.py; итоги упоминают «SWITCH». + +## Скоринг чисел (парсер и матчеры) + +### R16. NBSP и узкий NBSP не распознаются как разделители тысяч +`scoring.py:16-17`. `_SPACES` содержал три **обычных ASCII-пробела** (проверено hexdump). +Модели часто форматируют «58 800» через NBSP/узкий NBSP — такое число парсилось как два +(58 и 800) → ложные недосчёты Accuracy, неравномерные по моделям (зависят от форматирования). + +### R17. Absence-чекер зануляет корректные ответы (false positives) +`scoring.py:27-28, 91-103`. В `_DISTRACTORS` не было `cpc`; «CPA рассчитать нельзя +(0 конверсий)» ловил «0» как выдуманный CPA; даты и id кампаний в окне после алиаса тоже +зануляли полностью правильный ответ (violation → score 0 на весь кейс). + +### R18. Absence-чекер пропускает типовые формулировки выдуманного CPA (false negatives) +`scoring.py:25-26, 94-102`, `cases.py:92`. «Стоимость одной конверсии — 180 ₽» не матчился +алиасом «стоимость конверси»; часть формулировок выходила за окно в 25 символов. + +### R19. US-формат «58,800» парсится как 58.8 +`scoring.py:17-19, 47`. Запятая безусловно заменялась на точку — ложный промах для моделей, +пишущих в англо-числовом формате (GPT так делает регулярно). + +### R20. Алиас 'цел' матчит «в целом»; golden-значение 612 захардкожено дважды +`cases.py:102-108`, `fixtures.py`. Подстрочный матч `'цел'` зачитывал числа рядом с +«в целом»; 612 продублировано в кейсе и в фикстуре (нарушение single source of truth). + +### R21. score_tooluse засчитывает упавшие вызовы; max_calls по умолчанию 99 +`scoring.py:124-125, 141, 144`. `is_error` из трейса игнорировался — требуемый тул +считался вызванным, даже если все вызовы упали. Дефолт `max_calls=99` — фиктивный бюджет. + +## Судьи + +### R22. Оценки судей не валидируются +`judges.py:72-76, 84-85`. `_num` — голый `float()`: судья, вернувший 45 (или NaN), +безгранично раздувал composite. + +### R23. Подводные камни состава панели +`judges.py:98-108`, `runner.py:234-237`. Один нейтральный вендор схлопывает первичный балл +до единственного судьи; `--judges neutral` без нейтрального ключа **молча выключает всё +судейство**; GPT-судья сам является кандидатом; упавшие судьи молча сжимают панель. + +### R24. GPT-судья с max_tokens ломается на reasoning-моделях +`judges.py:61-62`. `BENCH_GPT_JUDGE_MODEL=gpt-5` → ошибка `max_tokens` не поддерживается → +судья молча даёт None. + +### R25. Судьи недетерминированы и на другой шкале +`judges.py:49-69`. Ни одного `temperature=0`, один сэмпл на судью, рубрика «1–5» против +кодовых 0–5. + +## Движки и стоимость + +### R26. Неизвестная модель молча тарифицируется по цене Sonnet +`core.py:67`, `scoring.py:154-155`. Добавил новый вариант с опечаткой в id — получил +дефолтные (3.0, 15.0) без предупреждения; Cost/Pareto для новых моделей тихо врут. + +### R27. Ошибочные tool_result не клампятся +`engines.py:79-82`, `core.py:213-215`. Огромный error-payload из live-MCP обходил оба слоя +обрезки и раздувал контекст/стоимость. + +### R28. Стоимость упавших попыток выбрасывается +`runner.py:73-79, 84-91`. Usage ошибочных тёрнов и отброшенных fallback-попыток не попадал +в Cost per Answer — флаки-конфиги выглядели дешевле, чем стоили. + +### R29. При ошибке в середине диалога скорится ответ предыдущего тёрна +`runner.py:73-81`. `break` до присваивания `answer` — на multi-turn кейсе оценка первого +ответа против golden-фактов второго вопроса. + +### R30. Falsy-нули в отчёте +`runner.py:129-133, 143-145, 205-207`. Легитимный composite 0.0 рисовался как «—» в Score +per USD; варианты с cost 0/None выпадали из Pareto; `cost=None` печатался как `$0.00000`. + +## Live-режим и эксплуатация + +### R31. Отказ Метрики молча проглатывается +`engines.py:41-51`. `except Exception: pass` — ошибка конфигурации харнесса скорится как +провал модели (metrika-кейс без metrika-тулов = гарантированный ноль). + +### R32. Нет префлайта live-режима +`mcp.py:35-40, 47-49`. Отсутствие `node_modules`/токенов выяснялось по-прогонно, с +перезаписью прошлого отчёта нулями. + +### R33. env для MCP-сервера обрезан до PATH+токен +`mcp.py:50-54`. Терялись HOME/прокси/CA-переменные, нужные реальным node-серверам. + +### R34. Строго последовательное исполнение 288+ прогонов +`runner.py:249-263`. Часы wall-time; в связке с отсутствием персиста и таймаутов — риск +потерять всё на середине. + +### R35. Нет метаданных воспроизводимости; смета врёт; exit 0 при пустом прогоне +`runner.py:186-191, 243-253, 268-282`. В отчёте нет git-коммита; смета в шапке включает +скипнутые варианты; при полном отсутствии ключей ранер завершался успешно с пустым отчётом. + +### R36. Зависимости не запинены +`requirements.txt`, `requirements-dev.txt`. `anthropic`/`openai`/`mcp` без версий; локально +pytest 9.1/py3.13 против CI pytest 8/py3.11. + +### R37. CI не видит engines/judges/runner +`.github/workflows/ci.yml:17`, `tests/test_fixtures.py:9-14`. Тесты импортируют только +cases/core/fixtures/mcp/scoring — синтаксическая ошибка в engines.py/runner.py проходит CI +зелёным (проверено инъекцией ошибки). Агрегация и обрезка, производящие публикуемые числа, +не покрыты ни одним тестом. + +--- + +## Проверено и опровергнуто + +- **«GLM thinking молча не работает на z.ai»** — по сохранённому прогону thinking-строки GLM + отличаются от disabled (стоимость, Accuracy), FB-маркеров нет: thinking реально включался. + Мелкая правда внутри: для GLM отправлялся `output_config.effort`, хотя отчёт показывает «—». +- **«Текстовая история между тёрнами расходится с продом»** — верификатор проверил исходники + прод-системы (askads): она тоже передаёт между тёрнами только текст. Бенчмарк меряет то + поведение. +- **«Кэш-префикс ниже минимума кэшируемости на части моделей»** — по актуальной документации + и замеру реального префикса (tools+system) проблема не подтверждается. + +--- + +## Статус исправлений + +Все 37 находок исправлены (проверено многоагентным верификационным прогоном по диффу: +каждая находка перепроверена запуском кода, покрытие — 37/37 fixed). Ключевые правки +разнесены по файлам; агрегация/отчёт вынесены в новый `report.py` и покрыты тестами. + +| # | Находка | Статус | Где | +|---|---|---|---| +| R1 | Подмена конфига в fallback | ✅ | `runner._run_case_retried` — повтор тем же конфигом | +| R2 | ANY-семантика алиасов + коллизия 1230/1200 | ✅ | entity/metric-анкоринг в `scoring`; фикстура RSYA clicks→1530 | +| R3 | Ошибки скорятся/невидимы | ✅ | `report.composite`/`agg` (error→None), колонка Err | +| R4 | Stability | ✅ | `report.agg` — σ по повторам внутри кейса | +| R5 | Score ≠ глоссарию | ✅ | `report.composite` (+Lang quality), глоссарий | +| R6 | Edge survivorship | ✅ | судьи и на fail_fast (`runner._score`) | +| R7 | Нонс-подсказка | ✅ | `runner._nonce` — sha1-хэш | +| R8 | Кэш-множители OpenAI | ✅ | `core.MODEL_CACHE_MULT` gpt-5=0.10/gpt-4.1=0.25 | +| R9 | Ретраи Anthropic | ✅ | `retry_call` вокруг stream (`engines`) | +| R10 | tools в финальном вызове | ✅ | `_params(False)` — tools + tool_choice none | +| R11 | max_tokens + thinking | ✅ | `MAX_OUTPUT_TOKENS_THINKING`, stop_reason=max_tokens→error | +| R12 | Фильтры CLI | ✅ | `runner._filter_or_die` | +| R13 | JSONL-персист | ✅ | `runs-*.jsonl` + `--report-from`, дефолт out — generated | +| R14 | Таймауты MCP | ✅ | `asyncio.wait_for` в `engines`, `core.MCP_*_TIMEOUT` | +| R15 | Доки про DECISION_RULE | ✅ | README ru/en переписаны | +| R16 | NBSP | ✅ | `scoring._SPACES` (NBSP/узкий/тонкий) | +| R17 | Absence false positives | ✅ | `_unit_after`/`_looks_like_date`, +cpc | +| R18 | Absence false negatives | ✅ | расширенные алиасы, `_GAP_AFTER`=30 | +| R19 | US-запятая | ✅ | `_US_THOUSANDS_RE` в `parse_numbers` | +| R20 | 'цел' + 612 | ✅ | алиасы цель/цели/целей; `METRIKA_GOAL_REACHES` | +| R21 | tooluse is_error/max_calls | ✅ | `_ok_names`, max_calls без дефолта 99 | +| R22 | Валидация оценок судей | ✅ | `judges._num` (0–5) | +| R23 | Состав панели | ✅ | `runner` предупреждает, судьи для edge на fail_fast | +| R24 | GPT-судья reasoning | ✅ | `max_completion_tokens`, без temperature | +| R25 | Детерминизм судей/шкала | ✅ | temperature=0 где можно, шкала 0–5 | +| R26 | Тариф неизвестной модели | ✅ | `scoring._rates` warn | +| R27 | Кламп error-результатов | ✅ | `engines._exec_tool` клампит и ошибки | +| R28 | Стоимость упавших попыток | ✅ | `cost_wasted`, `agg.cost_total` | +| R29 | Ответ прошлого тёрна | ✅ | `runner._run_case` (error→None метрики) | +| R30 | Falsy-нули | ✅ | `report` — явные `is None`/`>0` проверки | +| R31 | Metrika swallow | ✅ | `engines._assemble` warn | +| R32 | Live-префлайт | ✅ | `mcp.preflight_live` | +| R33 | env MCP | ✅ | `mcp._PASSTHROUGH_ENV` | +| R34 | Параллелизм | ✅ | `runner._run_variant` (asyncio.Semaphore) | +| R35 | Метаданные/exit-коды | ✅ | git-коммит в meta, `sys.exit` на пустом прогоне | +| R36 | Пиннинг зависимостей | ✅ | `requirements*.txt` (>=), CI закреплён | +| R37 | Покрытие CI | ✅ | CI импортирует все модули + `pytest -q`; `test_report`/`test_runner_e2e` | + +### Регрессии, найденные и исправленные на верификации + +Три раунда состязательной верификации (по диффу + два прохода по переписанному скорингу, +каждый клейм воспроизведён запуском кода) выявили регрессии, внесённые самими правками — +все исправлены и покрыты тестами. Скоринг чисел переписан на принципиальную многослойную +модель. + +**Раунд 1 (по диффу):** absence-перекоррекция (дистракторы `период/дата/неделя/кампания` +глушили выдуманный CPA), дефис-диапазон `1500-2000` как «дата», entity в сравнительных +ответах (nearest-left ломал «Москва vs РСЯ: 1200 vs 5150»), таблицы → Accuracy 0.0 (метрика +в шапке столбца), протечка метрик в прозе, `retried` over-count. + +**Раунды 2–3 (скоринг):** маркеры «чужих метрик» перетягивали настоящий CPA («8 конверсиям — +5150 ₽»); id кампании ловился как выдуманный CPA; хрупкий разбор таблиц (key-value, in-cell, +одиночная труба, шапка над разделителем); списки/эллипсис метрики («Топ по CPA: РСЯ 5150; +Москва 1200», «в 4 раза выше»); метка чужой метрики справа («CPA 1200 ₽ (расход …)»); +транспонированные таблицы (кампании в шапке столбцов). + +**Итоговая модель metric/entity-атрибуции (`scoring.py`):** +1. `_wrong_unit` — число относится к метрике по СВОЕЙ единице: денежная метрика отвергает + count-числа («1530 кликов», «12%»), count-метрика (Метрика) — денежные. +2. `_metric_ok` — метка своей метрики есть в тексте, и к числу не ближе метка ЧУЖОЙ метрики + (левая приоритетнее — метки стоят перед значением); в таблице — строка/шапка столбца. +3. позиционное сопоставление кампаний только среди чисел из пула целевых значений + (`sibling_values`) — посторонние числа («в 4 раза») не сдвигают выравнивание. +4. коллизий значений между метриками фикстур нет (`test_no_golden_value_collisions`). + +Покрытие: `test_attribution_comparative_and_table` (сравнения, все виды таблиц, +транспонированные, списки, вспомогательные числа), `test_absence_no_overcorrection`, +`test_retried_flag_only_on_recovery`, расширенный `test_anchoring_and_absence` + широкий +smoke-прогон реалистичных ответов моделей. diff --git a/llmbench/cases.py b/llmbench/cases.py index 0ea4e47..010df3d 100644 --- a/llmbench/cases.py +++ b/llmbench/cases.py @@ -3,6 +3,11 @@ golden_facts выводятся из fixtures.metrics() — единый источник правды с тем, что видит модель. Покрытие: tool-use (многошаг/мультитёрн/Метрика), numeric (точность + absence «не выдумывать CPA»), edge (пустой срез, отказ менять ставку, уточнение, clamp-robustness). + +Анкоринг фактов: `aliases` — metric-алиасы (обязательны всегда); `entity` — алиасы кампании, +обязательны в кейсах, где в ответе фигурирует несколько кампаний (иначе число одной кампании +зачтётся другой). В однокампейн-кейсах entity не требуем: вопрос уже фиксирует кампанию, а +модель не обязана повторять её название рядом с каждым числом. """ from __future__ import annotations @@ -30,14 +35,20 @@ def turn_type(self) -> str: return "multi" if len(self.turns) > 1 else "single" -def _fact(key, value, aliases, *, tol=0.05, required=True): - return {"key": key, "value": value, "tolerance": tol, "required": required, "aliases": aliases} +def _fact(key, value, aliases, *, entity=None, tol=0.05, required=True, value_kind="money"): + # value_kind: 'money' (₽-метрика: CPA/расход/CPC) или 'count' (счётная: достижения цели). + # Определяет, по какой единице числа отбираются в набор метрики (см. scoring._wrong_unit). + return {"key": key, "value": value, "tolerance": tol, "required": required, + "aliases": aliases, "entity": entity, "value_kind": value_kind} def _absent(key, aliases): return {"key": key, "kind": "absent", "aliases": aliases} +_RSYA = ["рся", "rsya"] +_MSK = ["москва", "поиск-москва"] + CASES: list[BenchCase] = [ BenchCase( id="tool_multi_step", dimension="tool", @@ -46,24 +57,29 @@ def _absent(key, aliases): turns=["Сравни мои кампании по эффективности за последнюю неделю: где сливается бюджет, " "а где отдача лучше? Возьми данные через инструменты."], trace={"tools": ["get_statistics"], "allow": ["list_campaigns", "get_account_info"], "max_calls": 4}, - golden_facts=[_fact("cpa_rsya", _M[12346]["cpa"], ["РСЯ", "CPA"], tol=0.05), - _fact("cpa_poisk", _M[12345]["cpa"], ["Москва", "CPA"], tol=0.05), - _fact("cost_rsya", _M[12346]["cost"], ["РСЯ", "расход", "потрач"], tol=0.02, required=False), - _fact("cost_poisk", _M[12345]["cost"], ["Москва", "расход", "потрач"], tol=0.02, required=False)]), + # В ответе несколько кампаний → у каждого факта обязателен entity-якорь. + golden_facts=[_fact("cpa_rsya", _M[12346]["cpa"], ["CPA", "стоимость конверси", "цена конверси"], + entity=_RSYA, tol=0.05), + _fact("cpa_poisk", _M[12345]["cpa"], ["CPA", "стоимость конверси", "цена конверси"], + entity=_MSK, tol=0.05), + _fact("cost_rsya", _M[12346]["cost"], ["расход", "потрат", "потрач"], + entity=_RSYA, tol=0.02, required=False), + _fact("cost_poisk", _M[12345]["cost"], ["расход", "потрат", "потрач"], + entity=_MSK, tol=0.02, required=False)]), BenchCase( id="multi_turn_rsya", dimension="tool", rubric="Во втором ответе назвать расход и CPA кампании РСЯ-Россия из данных; числа корректны.", turns=["Какие у меня есть кампании?", "Покажи по РСЯ-Россия за последнюю неделю: сколько потратили и какой CPA?"], trace={"tools": ["get_statistics"], "allow": ["list_campaigns"], "max_calls": 4}, - golden_facts=[_fact("cost_rsya", _M[12346]["cost"], ["РСЯ", "расход", "потрач"], tol=0.02), - _fact("cpa_rsya", _M[12346]["cpa"], ["РСЯ", "CPA"], tol=0.05)]), + golden_facts=[_fact("cost_rsya", _M[12346]["cost"], ["расход", "потрат", "потрач"], tol=0.02), + _fact("cpa_rsya", _M[12346]["cpa"], ["CPA", "стоимость конверси"], tol=0.05)]), BenchCase( id="numeric_cpc_poisk", dimension="numeric", rubric="Назвать расход и среднюю цену клика (CPC) Поиск-Москва из данных; числа корректны.", turns=["Сколько я потратил на Поиск-Москва за последнюю неделю и какая средняя цена клика?"], trace={"tools": ["get_statistics"], "allow": ["list_campaigns"], "max_calls": 3}, - golden_facts=[_fact("cost_poisk", _M[12345]["cost"], ["Москва", "расход", "потрач"], tol=0.02), + golden_facts=[_fact("cost_poisk", _M[12345]["cost"], ["расход", "потрат", "потрач"], tol=0.02), _fact("cpc_poisk", _M[12345]["cpc"], ["клик", "CPC", "цена клика"], tol=0.05)]), BenchCase( id="empty_period", dimension="edge", @@ -89,8 +105,11 @@ def _absent(key, aliases): "нельзя, предложить настроить цели. Может назвать расход и CPC. НЕ выдумывать CPA.", turns=["Какая стоимость конверсии (CPA) по кампании Бренд за последнюю неделю?"], trace={"tools": ["get_statistics"], "allow": ["list_campaigns"], "max_calls": 3}, - golden_facts=[_absent("cpa_brand", ["CPA", "стоимость конверси", "цена конверси", "за конверси"]), - _fact("cost_brand", _M[12348]["cost"], ["Бренд", "расход", "потрач"], tol=0.02, required=False), + golden_facts=[_absent("cpa_brand", ["CPA", "стоимость конверси", "цена конверси", + "стоимость одной конверси", "цена одной конверси", + "конверсия обходится", "обходится в", "за конверси"]), + _fact("cost_brand", _M[12348]["cost"], ["расход", "потрат", "потрач"], + tol=0.02, required=False), _fact("cpc_brand", _M[12348]["cpc"], ["клик", "CPC"], tol=0.05, required=False)]), BenchCase( id="clamp_robustness", dimension="edge", @@ -105,7 +124,11 @@ def _absent(key, aliases): turns=["Сколько конверсий «Оформление заказа» принёс сайт за последнюю неделю по Метрике?"], trace={"tools": ["metrika_list_counters", "metrika_list_goals", "metrika_get_statistics"], "ordered": True, "max_calls": 5}, - golden_facts=[_fact("goal_reaches", 612, ["Оформление заказа", "конверси", "достижени", "цел"], tol=0.02)]), + # 'цел' убран: подстрочно матчил «в целом»; берём формы, не встречающиеся в других словах. + # value_kind=count: 612 — число достижений цели, единица не денежная. + golden_facts=[_fact("goal_reaches", acc.METRIKA_GOAL_REACHES, + ["Оформление заказа", "конверси", "достижени", "цель", "цели", "целей"], + tol=0.02, value_kind="count")]), ] diff --git a/llmbench/core.py b/llmbench/core.py index 052e969..478a1d4 100644 --- a/llmbench/core.py +++ b/llmbench/core.py @@ -52,9 +52,15 @@ def to_openai_tools(mcp_tools, allow=READ_ONLY_TOOLS, prefix: str = "") -> list[ # --- Бюджеты/дефолты движка --- MAX_TOOL_ITERATIONS = 8 MAX_OUTPUT_TOKENS = 4096 +# У adaptive thinking токены размышлений входят в max_tokens — thinking-вариантам нужен +# запас, иначе ответ обрезается и вариант получает незаслуженный ноль (см. REVIEW.md R11). +MAX_OUTPUT_TOKENS_THINKING = 16_000 TOOL_RESULT_CHAR_BUDGET = 48_000 TURN_TOOL_RESULTS_CHAR_BUDGET = 80_000 TURN_TOOL_RESULT_FLOOR_CHARS = 8_000 +# Таймауты live-MCP: один зависший node-сервер не должен вешать весь грид (REVIEW.md R14). +MCP_INIT_TIMEOUT_S = 30 +MCP_TOOL_CALL_TIMEOUT_S = 120 # --- Ставки моделей (USD/1M вход/выход) + множители кэша по семейству (ОЦЕНКИ, сверить) --- MODEL_RATES: dict[str, tuple[float, float]] = { @@ -65,7 +71,13 @@ def to_openai_tools(mcp_tools, allow=READ_ONLY_TOOLS, prefix: str = "") -> list[ "gemini-2.5-flash": (0.3, 2.5), "gemini-2.5-pro": (1.25, 10.0), } _DEFAULT_RATES = (3.0, 15.0) -CACHE_MULT = {"claude": (0.1, 1.25), "glm": (0.2, 1.0), "openai": (0.5, 1.0), "gemini": (0.25, 1.0)} +# Множители кэша по семейству (чтение, запись). Один множитель на семейство OpenAI не +# описать: у gpt-5 кэш-чтение 0.10×, у gpt-4.1 — 0.25× (по официальному прайсу), поэтому +# точные значения — в MODEL_CACHE_MULT, семейный — фолбэк. +CACHE_MULT = {"claude": (0.1, 1.25), "glm": (0.2, 1.0), "openai": (0.25, 1.0), "gemini": (0.25, 1.0)} +MODEL_CACHE_MULT: dict[str, tuple[float, float]] = { + "gpt-5": (0.10, 1.0), "gpt-4.1": (0.25, 1.0), +} _DEFAULT_CACHE = (0.1, 1.25) @@ -80,35 +92,60 @@ def family(model: str) -> str: return "claude" -# --- Системный промпт (домен: аналитик Яндекс Директа). Конфигурируемо — замени под свой кейс. --- +# --- Системный промпт (домен: аналитик Яндекс Директа). СИНК с прод askads +# app/engine/prompt.py — бандл PLATFORM_YANDEX_DIRECT, prompt_version 97f78571 (2026-07-03). +# Блоки metrika_/wordstat_ гейтятся «только если есть такие тулы» → у бенча без них +# безвредны. При правке прод-промпта СИНКНУТЬ этот литерал и обновить версию выше. SYSTEM_PROMPT = ( - "Ты — аналитик контекстной рекламы Яндекс Директа. Помогаешь владельцу рекламного кабинета " - "разбираться в его кампаниях и принимать решения.\n\n" - "Правила:\n" - "- Отвечай по-русски, по делу, структурировано (списки, при необходимости таблицы).\n" - "- Опирайся ТОЛЬКО на данные, полученные через инструменты. Никогда не выдумывай цифры, " - "идентификаторы, названия кампаний или ставки. Если данных не хватает — вызови нужный инструмент.\n" - "- Инструменты доступны ТОЛЬКО на чтение. Ты не можешь менять кампании, ставки, бюджеты, " - "статусы или объявления. Если пользователь просит что-то изменить — объясни, что сервис сейчас " - "работает в режиме «только чтение», и предложи, что можно проанализировать.\n" - "- Метрики (CTR, CPC, CPA, расход, доля показов и т.п.) бери из get_statistics и показывай, " - "из каких чисел и как ты их посчитал.\n" - "- Falsification-first: формулируй гипотезы и проверяй их данными, а не подтверждай желаемое. " - "Рекомендации давай с оговорками и рисками — без оптимистичных обещаний и гарантий результата.\n" - "- Если вопрос неоднозначный, уточни период, кампанию или метрику, но сначала постарайся " - "получить разумный ответ из доступных данных.\n\n" - "Как экономно запрашивать данные:\n" - "- Предпочитай узкие периоды. Избегай `dateRangeType=ALL_TIME` — бери ограниченное окно " - "(`LAST_7_DAYS`, `LAST_30_DAYS` или `CUSTOM_DATE` с конкретными датами).\n" - "- Если инструмент вернул ошибку или пустой результат — прямо скажи, что данные получить не " - "удалось, и предложи перепроверить фильтр/период. НЕ отвечай догадками без данных.\n" - "- Пустой отчёт статистики (`rowsTotal: 0`, нулевые `totals`) означает «за этот срез данных нет», " - "а НЕ «отчёт недоступен» или «нет прав». Так и скажи и предложи проверить `campaignId` и даты. " - "НЕ выдумывай объяснений (тип кампании, автотаргетинг «скрывает» запросы, ограничения API).\n\n" + "Ты — аналитик контекстной рекламы Яндекс Директа. Помогаешь владельцу рекламного кабинета разбираться в его кампаниях и принимать решения.\n" + "\n" + "Данные и честность:\n" + "- Опирайся ТОЛЬКО на данные, полученные через инструменты. Никогда не выдумывай цифры, идентификаторы, названия кампаний или ставки. Если данных не хватает — вызови нужный инструмент.\n" + "- Существование объекта подтверждай ТОЛЬКО находкой в данных: спросили про конкретную кампанию, группу или объявление (по id или названию) — сначала найди её в результатах инструментов (в этом ходе или ранее в диалоге); нашёл — отвечай по данным, не нашёл — прямо скажи, что такого объекта в кабинете нет. Отвечать «да, такая кампания есть» без находки ЗАПРЕЩЕНО.\n" + "- Инструменты — ТОЛЬКО чтение: менять кампании, ставки, бюджеты, статусы или объявления нельзя. Просят изменить — объясни, что сервис в режиме «только чтение», и предложи, что проанализировать.\n" + "- Метрики (CTR, CPC, CPA, расход и т.п.) бери из get_statistics и показывай, из каких чисел и как ты их посчитал.\n" + "- Пометка усечения (`[truncated: …]`, `\"truncated\": true`, `_truncated`) = данные НЕПОЛНЫЕ: видимые строки не суммируй и частичную сумму за итог не выдавай. Готовый итог (totals) бери, только если он есть в ответе; иначе сузь запрос (фильтр, короче период) и повтори — или прямо скажи, что показана лишь часть данных.\n" + "- Цифры показывай, но выводов на малых выборках не делай: при менее ~100 кликов не суди о CTR/CPC, при менее ~10 конверсий — о CPA; оговори, что данных мало, советов «отключить/масштабировать» не давай, предложи период длиннее. Средние скрывают разброс — декомпозируй по кампаниям. Точных рыночных «норм» не называй — оценивай относительно ЭТОГО кабинета.\n" + "- Falsification-first: формулируй гипотезы и проверяй данными, а не подтверждай желаемое; на «почему» отвечай гипотезами — подтверждённое данными отделяй от предположений (аукцион, конкуренты, сезон), совпадение по времени — не доказательство. Рекомендации — с оговорками и рисками, без гарантий.\n" + "- Ты помогаешь ТОЛЬКО с этим кабинетом и его аналитикой. На короткие вопросы о рекламных терминах и метриках отвечай. Тексты и идеи объявлений для кампаний ПОЛЬЗОВАТЕЛЯ предлагать можно — опирайся на его кампании, объявления и ключевые фразы (напомни, что применить изменения он должен сам: сервис только читает). Просьбы вне темы (код, тексты и вопросы, не связанные с рекламой пользователя) — вежливо откажись в одну-две фразы и предложи вернуться к кампаниям.\n" + "\n" + "Вызовы инструментов:\n" + "- Вызовов на ответ мало (порядка восьми): планируй минимум (типовой вопрос — 1–3 вызова), объекты бери батчем (список id одним вызовом), не опрашивай по одному в цикле; уже полученное в диалоге переиспользуй — повторный вызов только при смене периода/фильтра или просьбе обновить.\n" + "- Ошибка инструмента — не перебирай наугад: прочитай текст ошибки, исправь конкретный параметр, повтори максимум один-два раза; не помогло — скажи прямо, что данные получить не удалось, и предложи перепроверить фильтр/период. Пустой результат БЕЗ ошибки — не сбой, а ответ: данных за этот срез нет. НЕ отвечай догадками «по логике».\n" + "- Период не указан — НЕ переспрашивай: общий обзор («как дела в целом») — последние 7 дней, остальное — последние 30 дней, «вчера» — вчерашний день; период назови в ответе. Уточняющий вопрос задавай, только когда запрос не построить без пользователя. Периоды считай от текущей даты из контекста: следи за ГОДОМ, будущее не запрашивай; пусто при сомнительных датах — сначала перепроверь сами даты, а не объясняй пустоту свойствами кампании.\n" + "\n" + "Формат ответа:\n" + "- Отвечай по-русски, по делу. Аналитический ответ: краткий вывод (1–3 фразы) → цифры (список, или таблица для сравнения 3+ объектов: топ-5–10 строк плюс итог, остальное — «ещё N, суммарно …») → рекомендации, если уместны; простой вопрос — коротко. Не начинай с таблицы и не пересказывай ход вызовов — только то, что отвечает на вопрос.\n" + "- Числа — человекочитаемо: разряды пробелом (12 345), деньги с валютой и двумя знаками (6 994,24 ₽), доли в процентах (CTR 4,3%), показы и клики целыми; длинные дроби не копируй.\n" + "- Сырой JSON/TSV и дампы инструментов не вставляй — переводи в текст, список или markdown-таблицу; жаргон API не показывай («за последние 7 дней», а не «LAST_7_DAYS»). Объекты называй именем и id: «Ремонт квартир (№ 123456)».\n" + "\n" + "Особенности Директа:\n" + "- Предпочитай узкие периоды, избегай `ALL_TIME`. Допустимые `dateRangeType`: `TODAY`, `YESTERDAY`, `LAST_7_DAYS`, `LAST_30_DAYS`, `THIS_MONTH`, `LAST_MONTH`, `ALL_TIME`, `CUSTOM_DATE`; `THIS_WEEK` не существует — недели задавай через `CUSTOM_DATE` (`dateFrom` и `dateTo`, обе `YYYY-MM-DD`).\n" + "- `get_statistics` возвращает итог за период (одна строка на объект); `\"Date\"` в `fieldNames` — только для дневной динамики, остальные поля — точечно под вопрос.\n" + "- Обзор кабинета («как дела», сводка): `list_campaigns` + ОДИН `get_statistics` (`reportType=CAMPAIGN_PERFORMANCE_REPORT`) — итог по всем кампаниям сразу; не запрашивай статистику по каждой кампании отдельно, углубляйся только в 1–2 заметные.\n" + "- Про конкретный объект — сразу фильтр (`ids`, `campaignIds`), не полный список. `list_ads`, `list_ad_groups`, `list_keywords` без критерия отбора (`campaignIds`, `adGroupIds`, `ids`) вернут ошибку; id бери из `list_campaigns`.\n" + "- Наборы полей `fieldNames` у кампаний/групп/объявлений РАЗНЫЕ (например, у групп нет `State`) — не переноси поля одного инструмента в другой и не изобретай имена; вложенные структуры и селекторы (`TextAd`, `TextCampaign`, `Statistics`, `Funds`, `MetrikaCounters`) роняют вызов. Упал вызов на поле — убери это поле, а не подбирай замену.\n" + "- `autoPaginate` для разведки не используй — может вытянуть тысячи строк; `_truncated`/`LimitedBy` в ответе списка = список неполный.\n" + "- Узкий запрос вернул пусто — перепроверь `campaignId` и период, не снимай фильтр вслепую. Пустой отчёт (`rowsTotal: 0`, нулевые `totals` либо явная пометка про пустой срез) = «данных за срез нет», НЕ «нет прав»/«отчёт недоступен» — так и скажи; объяснений не выдумывай (тип кампании, ЕПК, автотаргетинг «скрывает» запросы, права) — отчёт по запросам работает и для ЕПК.\n" + "- Расход по API не сходится с интерфейсом Директа — не называй причин, которые не можешь проверить по данным; две типовые: (1) отчёты по умолчанию С НДС, интерфейс — БЕЗ НДС: помечай «с НДС», предложи пересчёт с `includeVat=false`; (2) «Мастер кампаний» и смарт-баннеры API не отдаёт вовсе (их нет в `list_campaigns` и в отчётах) — сумма по API может быть меньше реального списания: предложи свериться с кабинетом, недостающие кампании не выдумывай. Если кампания ЕСТЬ в `list_campaigns`, её пустая статистика «Мастером кампаний» не объясняется.\n" + "- Поиск и сети — разные каналы (`AdNetworkType`: `SEARCH` / `AD_NETWORK`, добавляй в `fieldNames` при сравнении): низкий CTR в РСЯ — норма, на поиске в разы выше; не суди по среднему CTR/CPC за весь кабинет.\n" + "- «Почему не показывается» — сверху вниз: кампания (`State`: ON/OFF/SUSPENDED/ENDED/ARCHIVED) → группа (`ServingStatus`: RARELY_SERVED = мало показов) → объявление (`State`, OFF_BY_MONITORING = сайт недоступен; `Status`: DRAFT/MODERATION/PREACCEPTED/ACCEPTED/REJECTED, причина — `StatusClarification`) → баланс (`get_balance`). `State` — показы, `Status` — модерация; `ServingStatus` и `StatusClarification` проси в `fieldNames` явно.\n" + "- Данные за последние ~3 дня неполны (антифрод, отложенные конверсии): «провал» в свежих датах — не факт падения; тренды считай по завершённым периодам, свежие цифры — с оговоркой.\n" + "\n" "Веб-аналитика Метрики (только если в наборе есть инструменты с префиксом `metrika_`):\n" - "- Порядок: `metrika_list_counters` → `metrika_list_goals` → `metrika_get_statistics`. " - "`counterId` — счётчик Метрики, он НЕ равен `id` кампании Директа.\n" - "- Зови Метрику, когда вопрос про конверсии на сайте, источники трафика или поведение." + "- Метрика — веб-аналитика сайта (визиты, источники, конверсии по целям), отдельная от Директа; счётчик НЕ равен id кампании Директа. Нет инструментов `metrika_` — Метрика не подключена, не упоминай её. Зови её про конверсии на сайте, источники и поведение; вопросы про расход/клики/ставки/статусы закрывает Директ — Метрику не вызывай.\n" + "- Порядок: `metrika_list_counters` → `metrika_list_goals` → `metrika_get_statistics`; `counterId` обязателен — без него вызов упадёт. Счётчик бери с доменом рекламируемого сайта и НАЗОВИ его в ответе; уточняй, только если подходящих несколько. Больше 5–6 goal-метрик за вызов не проси.\n" + "- Пустой `metrika_list_goals` = цели не настроены, конверсии посчитать нельзя. Это нормальный ответ: так и скажи, предложи настроить цели в Метрике; не подменяй конверсии визитами или отказами.\n" + "- Трафик именно из Директа: `filters=\"ym:s:lastsignAdvEngine=='ya_direct'\"`; разбивка по кампаниям — `dimensions=[\"ym:s:lastsignDirectClickOrder\"]` (в значении название и номер кампании — сопоставляй с Id из `list_campaigns`); `ym:s:directAdId` НЕ существует. Атрибуция зашита в имя измерения — для рекламы бери `lastsign…` («последний значимый»). Строки в `filters` — в одинарных кавычках, связки `AND`/`OR`, «не пусто» — `!n`. Без фильтра считаешь весь сайт — явно подпиши это.\n" + "- CPA по Директу: расход — директовский `get_statistics`, конверсии — `ym:s:goalreaches` за тот же период и срез; CPA = расход ÷ достижения (покажи оба числа, оговори НДС и атрибуцию). `ym:ad:*` (расходы Директа в Метрике) НЕДОСТУПНЫ — не запрашивай.\n" + "- Клики Директа ≠ визиты Метрики (счётчик мог не загрузиться, даты привязаны по-разному): расхождение до ~10–15% — норма. «Конверсия %»: Метрика = целевые визиты/визиты, Директ = конверсии/клики — в лоб не сравнивай.\n" + "\n" + "Поисковый спрос Вордстата (только если в наборе есть инструменты с префиксом `wordstat_`):\n" + "- Вордстат — частоты запросов в поиске Яндекса (спрос рынка), НЕ статистика кабинета: показы, клики и расход бери из Директа; частота запроса ≠ показы твоих объявлений, не смешивай эти числа. Нет инструментов `wordstat_` — Вордстат не подключён, не упоминай его.\n" + "- Зови для подбора и оценки семантики (идеи ключевых фраз, запросы для текстов объявлений), сезонности спроса и интереса по регионам. Вопросы про кампании и их метрики закрывает Директ — Вордстат не вызывай.\n" + "- `wordstat_top_requests` — запросы, СОДЕРЖАЩИЕ фразу (`results`), и похожие по смыслу (`associations`); `numPhrases` ограничивай (10–50 обычно хватает). `wordstat_regions` — распределение по регионам (`affinityIndex` > 100 = интерес выше среднего). Оба тула — всегда за последние 30 дней, дат не принимают; диапазон дат есть только у `wordstat_dynamics` — динамика спроса (`period`: daily/weekly/monthly). Id регионов — та же геобаза Яндекса, что и в Директе (213 = Москва); дерево id → имена — `wordstat_list_regions`.\n" + "- Счётчики в ответах могут прийти строками (\"12345\") — трактуй как числа.\n" + "" ) @@ -241,9 +278,18 @@ def _content(r): # ============================ RETRY / JSON ============================ +_RETRYABLE_STATUS = {408, 409, 429, 500, 502, 503, 529} + + def is_overloaded(err) -> bool: + """Транзиентная ли ошибка (стоит ретраить). Сначала типизированный status_code SDK, + подстроки в тексте — только фолбэк (числа вроде '429' могут встретиться и в данных).""" + status = getattr(err, "status_code", None) + if isinstance(status, int): + return status in _RETRYABLE_STATUS s = str(err or "").lower() - return any(k in s for k in ("529", "overloaded", "503", "rate_limit", "rate limit", "429", "internalservererror")) + return any(k in s for k in ("overloaded", "rate_limit", "rate limit", "internalservererror", + "connection error", "timed out", "timeout")) async def retry_call(make_coro, attempts: int = 5, base: float = 2.0): diff --git a/llmbench/engines.py b/llmbench/engines.py index f04e610..3cc2075 100644 --- a/llmbench/engines.py +++ b/llmbench/engines.py @@ -1,7 +1,8 @@ """Самодостаточные агентные loop'ы (замена askads app.engine.claude.run_chat). - run_anthropic: Claude натив + GLM через Anthropic-совместимый base_url (z.ai). Стриминг, - thinking/effort, cache_control, обрезка, маршрутизация тулов (+ префикс metrika_). + thinking/effort, cache_control, обрезка, маршрутизация тулов (+ префикс metrika_), + ретраи на транзиентных ошибках (429/529/5xx). - run_openai: GPT через chat.completions + function-tools (reasoning_effort для gpt-5). Оба принимают режим сессии ('live' | 'fixed') и платформу — MCP-слой решает, спавнить @@ -10,18 +11,21 @@ """ from __future__ import annotations +import asyncio import json import os +import sys from contextlib import AsyncExitStack from anthropic import APIError, AsyncAnthropic, RateLimitError -from llmbench.core import (MAX_OUTPUT_TOKENS, MAX_TOOL_ITERATIONS, METRIKA_PREFIX, +from llmbench.core import (MAX_OUTPUT_TOKENS, MAX_OUTPUT_TOKENS_THINKING, MAX_TOOL_ITERATIONS, + MCP_INIT_TIMEOUT_S, MCP_TOOL_CALL_TIMEOUT_S, METRIKA_PREFIX, METRIKA_READ_ONLY_TOOLS, PLATFORM_YANDEX_DIRECT, PLATFORM_YANDEX_METRIKA, - READ_ONLY_TOOLS, SYSTEM_PROMPT, TOOL_RESULT_CHAR_BUDGET, - TURN_TOOL_RESULTS_CHAR_BUDGET, TURN_TOOL_RESULT_FLOOR_CHARS, - clamp_tool_result, clamp_turn_results, is_allowed, retry_call, - to_anthropic_tools, to_metrika_anthropic_tools, to_openai_tools) + SYSTEM_PROMPT, TOOL_RESULT_CHAR_BUDGET, TURN_TOOL_RESULTS_CHAR_BUDGET, + TURN_TOOL_RESULT_FLOOR_CHARS, clamp_tool_result, clamp_turn_results, + is_allowed, retry_call, to_anthropic_tools, to_metrika_anthropic_tools, + to_openai_tools) from llmbench.mcp import open_session _EMPTY_USAGE = {"input_tokens": 0, "cache_read_tokens": 0, "cache_write_tokens": 0, "tokens_out": 0} @@ -32,23 +36,28 @@ def _sys(system_prompt, nonce): async def _assemble(stack, mode, platform, metrika_enabled, conv): - """Открывает сессию(и), собирает tools + карту маршрутов {имя: (session, real, is_primary)}.""" - session = await stack.enter_async_context(open_session(mode, platform)) - direct = conv(await session.list_tools()) + """Открывает сессию(и), собирает tools + карту маршрутов {имя: (session, real, is_primary)}. + Инициализация — под таймаутом: зависший node-сервер не должен вешать грид.""" + session = await asyncio.wait_for( + stack.enter_async_context(open_session(mode, platform)), MCP_INIT_TIMEOUT_S) + direct = conv(await asyncio.wait_for(session.list_tools(), MCP_INIT_TIMEOUT_S)) tools = list(direct) routes = {t["name"] if isinstance(t, dict) and "name" in t else t["function"]["name"]: (session, _real(t), True) for t in direct} if metrika_enabled and platform == PLATFORM_YANDEX_DIRECT: try: - m = await stack.enter_async_context(open_session(mode, PLATFORM_YANDEX_METRIKA)) + m = await asyncio.wait_for( + stack.enter_async_context(open_session(mode, PLATFORM_YANDEX_METRIKA)), MCP_INIT_TIMEOUT_S) m_tools = (to_metrika_anthropic_tools if conv is _conv_anthropic else _conv_openai_metrika)( - (await m.list_tools()).tools) + (await asyncio.wait_for(m.list_tools(), MCP_INIT_TIMEOUT_S)).tools) tools += m_tools for t in m_tools: name = t["name"] if "name" in t else t["function"]["name"] routes[name] = (m, name[len(METRIKA_PREFIX):], False) - except Exception: # noqa: BLE001 — Метрика не обязательна - pass + except Exception as e: # noqa: BLE001 — Метрика не обязательна для не-metrika кейсов + print(f"[warn] Метрика недоступна ({type(e).__name__}: {e}) — кейс пойдёт без " + f"metrika_* тулов; metrika-кейсы провалятся из-за харнесса, не модели", + file=sys.stderr) return tools, routes @@ -74,50 +83,81 @@ async def _exec_tool(routes, name, args): if route is None or not is_allowed(route[1]): return f"Инструмент '{name}' недоступен: только чтение.", True session, real, _ = route - result = await session.call_tool(real, args or {}) + try: + result = await asyncio.wait_for(session.call_tool(real, args or {}), MCP_TOOL_CALL_TIMEOUT_S) + except asyncio.TimeoutError: + return f"Инструмент '{real}' не ответил за {MCP_TOOL_CALL_TIMEOUT_S}с.", True content = "\n".join(getattr(b, "text", "") or "" for b in result.content) is_err = bool(result.isError) - if not is_err: - content = clamp_tool_result(content, name=real, budget_chars=TOOL_RESULT_CHAR_BUDGET) + # Клампим и ошибочные результаты: огромный error-payload из live-MCP не должен + # раздувать контекст и стоимость. + content = clamp_tool_result(content, name=real, budget_chars=TOOL_RESULT_CHAR_BUDGET) return content, is_err # ============================ ANTHROPIC (Claude / GLM) ============================ +async def _stream_final(client, params): + async with client.messages.stream(**params) as stream: + async for _t in stream.text_stream: + pass + return await stream.get_final_message() + + +def _acc_anthropic(usage, u): + usage["input_tokens"] += u.input_tokens or 0 + usage["tokens_out"] += u.output_tokens or 0 + usage["cache_read_tokens"] += getattr(u, "cache_read_input_tokens", 0) or 0 + usage["cache_write_tokens"] += getattr(u, "cache_creation_input_tokens", 0) or 0 + + +def _text_of(final): + return "".join(b.text for b in final.content if b.type == "text") + + async def run_anthropic(history, *, model, base_url="", api_key, thinking=None, effort=None, mode="fixed", platform=PLATFORM_YANDEX_DIRECT, metrika_enabled=False, - system_prompt=SYSTEM_PROMPT, cache_nonce="", max_tokens=MAX_OUTPUT_TOKENS) -> dict: + system_prompt=SYSTEM_PROMPT, cache_nonce="", max_tokens=None) -> dict: kwargs = {"api_key": api_key} if base_url: kwargs["base_url"] = base_url client = AsyncAnthropic(**kwargs) + if max_tokens is None: + # Токены adaptive thinking входят в max_tokens — thinking-вариантам нужен запас, + # иначе ответ обрезается и вариант получает незаслуженный ноль. + max_tokens = MAX_OUTPUT_TOKENS_THINKING if thinking == "adaptive" else MAX_OUTPUT_TOKENS usage = dict(_EMPTY_USAGE) trace, answer = [], "" system = [{"type": "text", "text": _sys(system_prompt, cache_nonce), "cache_control": {"type": "ephemeral"}}] messages = list(history) + + def _params(allow_tools, tools): + params = {"model": model, "max_tokens": max_tokens, "system": system, "messages": messages} + if tools: + params["tools"] = tools # история содержит tool_use-блоки — tools обязателен + if not allow_tools: + params["tool_choice"] = {"type": "none"} + if thinking == "adaptive": + params["thinking"] = {"type": "adaptive"} + if effort and effort != "none": + params["output_config"] = {"effort": effort} + return params + + def _done(err=None): + return {"answer": answer, "tool_trace": trace, **usage, "error": err} + try: async with AsyncExitStack() as stack: tools, routes = await _assemble(stack, mode, platform, metrika_enabled, _conv_anthropic) completed = False for _ in range(MAX_TOOL_ITERATIONS): - params = {"model": model, "max_tokens": max_tokens, "system": system, "messages": messages} - if tools: - params["tools"] = tools - if thinking == "adaptive": - params["thinking"] = {"type": "adaptive"} - if effort and effort != "none": - params["output_config"] = {"effort": effort} - async with client.messages.stream(**params) as stream: - async for _t in stream.text_stream: - pass - final = await stream.get_final_message() - u = final.usage - usage["input_tokens"] += u.input_tokens or 0 - usage["tokens_out"] += u.output_tokens or 0 - usage["cache_read_tokens"] += getattr(u, "cache_read_input_tokens", 0) or 0 - usage["cache_write_tokens"] += getattr(u, "cache_creation_input_tokens", 0) or 0 + final = await retry_call(lambda: _stream_final(client, _params(True, tools))) + _acc_anthropic(usage, final.usage) messages.append({"role": "assistant", "content": final.content}) + if final.stop_reason == "max_tokens": + answer = _text_of(final) + return _done(f"max_tokens: ответ обрезан (лимит {max_tokens} токенов)") if final.stop_reason != "tool_use": - answer = "".join(b.text for b in final.content if b.type == "text") + answer = _text_of(final) completed = True break results = [] @@ -132,36 +172,43 @@ async def run_anthropic(history, *, model, base_url="", api_key, thinking=None, floor_chars=TURN_TOOL_RESULT_FLOOR_CHARS) messages.append({"role": "user", "content": results}) if not completed: - params = {"model": model, "max_tokens": max_tokens, "system": system, "messages": messages} - async with client.messages.stream(**params) as stream: - async for _t in stream.text_stream: - pass - final = await stream.get_final_message() - answer = "".join(b.text for b in final.content if b.type == "text") - usage["tokens_out"] += final.usage.output_tokens or 0 - usage["input_tokens"] += final.usage.input_tokens or 0 + # Бюджет итераций исчерпан — финальный вызов с теми же tools/thinking + # (история содержит tool_use/tool_result — без tools API вернёт 400), + # но с запретом новых вызовов через tool_choice none. + final = await retry_call(lambda: _stream_final(client, _params(False, tools))) + _acc_anthropic(usage, final.usage) + answer = _text_of(final) + if final.stop_reason == "max_tokens": + return _done(f"max_tokens: ответ обрезан (лимит {max_tokens} токенов)") except (RateLimitError, APIError) as e: - return {"answer": answer, "tool_trace": trace, **usage, "error": f"{type(e).__name__}: {e}"} + return _done(f"{type(e).__name__}: {e}") except Exception as e: # noqa: BLE001 - return {"answer": answer, "tool_trace": trace, **usage, "error": f"{type(e).__name__}: {e}"} - return {"answer": answer, "tool_trace": trace, **usage, "error": None} + return _done(f"{type(e).__name__}: {e}") + return _done(None) # ============================ OPENAI (GPT) ============================ async def run_openai(history, *, model, mode="fixed", platform=PLATFORM_YANDEX_DIRECT, metrika_enabled=False, reasoning_effort=None, system_prompt=SYSTEM_PROMPT, - cache_nonce="", **_ignore) -> dict: + cache_nonce="", max_tokens=None, **_ignore) -> dict: from openai import AsyncOpenAI client = AsyncOpenAI(api_key=os.environ["OPENAI_API_KEY"]) + if max_tokens is None: + # У reasoning-моделей внутренние токены тоже входят в лимит — запас как у thinking. + max_tokens = MAX_OUTPUT_TOKENS_THINKING if reasoning_effort else MAX_OUTPUT_TOKENS usage = dict(_EMPTY_USAGE) trace, answer = [], "" + + def _done(err=None): + return {"answer": answer, "tool_trace": trace, **usage, "error": err} + try: async with AsyncExitStack() as stack: tools, routes = await _assemble(stack, mode, platform, metrika_enabled, _conv_openai) messages = [{"role": "system", "content": _sys(system_prompt, cache_nonce)}] + list(history) async def _create(with_tools): - kw = {"model": model, "messages": messages} + kw = {"model": model, "messages": messages, "max_completion_tokens": max_tokens} if with_tools: kw["tools"] = tools kw["tool_choice"] = "auto" @@ -173,7 +220,11 @@ async def _create(with_tools): for _ in range(MAX_TOOL_ITERATIONS): resp = await _create(True) _acc_openai(usage, resp.usage) - msg = resp.choices[0].message + choice = resp.choices[0] + msg = choice.message + if choice.finish_reason == "length": + answer = msg.content or "" + return _done(f"length: ответ обрезан (лимит {max_tokens} токенов)") if not msg.tool_calls: answer = msg.content or "" completed = True @@ -198,10 +249,13 @@ async def _create(with_tools): if not completed: resp = await _create(False) _acc_openai(usage, resp.usage) - answer = resp.choices[0].message.content or "" + choice = resp.choices[0] + answer = choice.message.content or "" + if choice.finish_reason == "length": + return _done(f"length: ответ обрезан (лимит {max_tokens} токенов)") except Exception as e: # noqa: BLE001 - return {"answer": answer, "tool_trace": trace, **usage, "error": f"{type(e).__name__}: {e}"} - return {"answer": answer, "tool_trace": trace, **usage, "error": None} + return _done(f"{type(e).__name__}: {e}") + return _done(None) def _acc_openai(usage, u): diff --git a/llmbench/fixtures.py b/llmbench/fixtures.py index e943f2d..5003fe5 100644 --- a/llmbench/fixtures.py +++ b/llmbench/fixtures.py @@ -8,7 +8,7 @@ import json -FIXTURE_VERSION = "2026-06-29" +FIXTURE_VERSION = "2026-07-03" CURRENCY = "RUB" STANDARD_WINDOW = "LAST_7_DAYS" @@ -26,9 +26,11 @@ ] # conversions=None → цели не настроены (CPA называть нельзя); stats=None → пустой срез. +# ВАЖНО: значения метрик разных кампаний не должны попадать в допуски друг друга +# (см. test_no_golden_value_collisions) — иначе Accuracy зачтёт чужое число. STATS: dict[int, dict | None] = { 12345: {"Impressions": 40210, "Clicks": 980, "Cost": 58800.0, "Conversions": 49}, - 12346: {"Impressions": 512300, "Clicks": 1230, "Cost": 41200.0, "Conversions": 8}, + 12346: {"Impressions": 512300, "Clicks": 1530, "Cost": 41200.0, "Conversions": 8}, 12347: None, 12348: {"Impressions": 22000, "Clicks": 1500, "Cost": 9000.0, "Conversions": None}, 12349: {"Impressions": 88000, "Clicks": 2100, "Cost": 73500.0, "Conversions": 35}, @@ -112,8 +114,13 @@ def metrika_goals_result() -> str: return json.dumps({"goals": METRIKA_GOALS}, ensure_ascii=False) +# Единый источник правды для golden-факта metrika_conversions (см. cases.py). +METRIKA_GOAL_REACHES = 612 + + def metrika_stats_result() -> str: - return json.dumps({"totals": [18450, 14200, 612, 3.32], "total_rows": 1, "sampled": False, + totals = [18450, 14200, METRIKA_GOAL_REACHES, 3.32] + return json.dumps({"totals": totals, "total_rows": 1, "sampled": False, "sample_share": 1.0, - "data": [{"dimensions": [], "metrics": [18450, 14200, 612, 3.32]}]}, + "data": [{"dimensions": [], "metrics": totals}]}, ensure_ascii=False) diff --git a/llmbench/judges.py b/llmbench/judges.py index 494d2f5..8a738f3 100644 --- a/llmbench/judges.py +++ b/llmbench/judges.py @@ -3,7 +3,10 @@ Нейтральность: первичный субъективный балл — среднее судей, чей вендор ∉ кандидатам. Каждый судья — свой клиент из фиксированных кредов (изоляция). Доступность судьи — по наличию ключа. -""" + +Детерминизм: temperature=0 везде, где провайдер это позволяет (GPT non-reasoning, GLM, +Gemini). Claude-судья на Opus 4.8 сэмплинг-параметры не принимает (400) — не отправляем. +Шкала оценок 0–5 — та же, что у кодовых метрик (пол = 0, а не 1).""" from __future__ import annotations import asyncio @@ -20,6 +23,9 @@ JUDGE_GPT_MODEL = os.environ.get("BENCH_GPT_JUDGE_MODEL", "gpt-4.1") JUDGE_GEMINI_MODEL = os.environ.get("BENCH_GEMINI_JUDGE_MODEL", "gemini-2.5-flash") +# Reasoning-модели OpenAI не принимают temperature и требуют запас на внутренние токены. +_OPENAI_REASONING_PREFIXES = ("gpt-5", "o1", "o3", "o4") + def available_judges() -> list[dict]: out = [] @@ -50,30 +56,42 @@ def _prompt(question, rubric, answer): return ("Ты — строгий судья ответов ассистента-аналитика Яндекс Директа. Оцени ТОЛЬКО " "качество по рубрике и язык — НЕ проверяй арифметику.\n\n" f"Вопрос:\n{question}\n\nРубрика:\n{rubric}\n\nОтвет:\n{answer or '(пустой ответ)'}\n\n" - "Оцени 1–5 (5 — лучший): quality (соответствие рубрике: интерпретация, корректность " - "выводов, поведение в краевом случае, без выдуманных причин); russian (естественность и " + "Оцени 0–5 (5 — лучший, 0 — полностью мимо): quality (соответствие рубрике: " + "интерпретация, корректность выводов, поведение в краевом случае, без выдуманных " + "причин); russian (естественность и " 'ясность). Верни СТРОГО JSON без markdown: {"quality":N,"russian":N,"note":"кратко"}') async def _call(judge, prompt): client = _client_for(judge) if judge["kind"] == "openai": - resp = await retry_call(lambda: client.chat.completions.create( - model=judge["model"], max_tokens=300, messages=[{"role": "user", "content": prompt}])) + reasoning = judge["model"].startswith(_OPENAI_REASONING_PREFIXES) + kw = {"model": judge["model"], "messages": [{"role": "user", "content": prompt}], + # у reasoning-моделей внутренние токены входят в лимит — даём запас + "max_completion_tokens": 2000 if reasoning else 300} + if not reasoning: + kw["temperature"] = 0 + resp = await retry_call(lambda: client.chat.completions.create(**kw)) return resp.choices[0].message.content or "" if judge["kind"] == "gemini": - resp = await retry_call(lambda: client.aio.models.generate_content(model=judge["model"], contents=prompt)) + resp = await retry_call(lambda: client.aio.models.generate_content( + model=judge["model"], contents=prompt, config={"temperature": 0})) return resp.text or "" - resp = await retry_call(lambda: client.messages.create( - model=judge["model"], max_tokens=400, messages=[{"role": "user", "content": prompt}])) + kw = {"model": judge["model"], "max_tokens": 400, + "messages": [{"role": "user", "content": prompt}]} + if judge["kind"] == "anthropic-glm": + kw["temperature"] = 0 # Opus 4.8 сэмплинг-параметры отвергает — только для GLM + resp = await retry_call(lambda: client.messages.create(**kw)) return "".join(getattr(b, "text", "") or "" for b in resp.content if getattr(b, "type", "") == "text") def _num(v): + """float в допустимой шкале 0–5; всё остальное (в т.ч. NaN, 45, '—') → None.""" try: - return float(v) + f = float(v) except (TypeError, ValueError): return None + return f if 0.0 <= f <= 5.0 else None async def _judge_one(judge, question, rubric, answer): diff --git a/llmbench/mcp.py b/llmbench/mcp.py index 35f910d..8070008 100644 --- a/llmbench/mcp.py +++ b/llmbench/mcp.py @@ -40,6 +40,27 @@ def _server_path(platform: str) -> str: return os.path.join(_ROOT, "node_modules", pkg, "dist", "index.js") +# Переменные, без которых реальные node-серверы часто не работают (прокси, CA, домашняя +# директория) — пробрасываем как есть, если заданы. +_PASSTHROUGH_ENV = ("HOME", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", + "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "NODE_OPTIONS", "TMPDIR") + + +def preflight_live(platforms) -> list[str]: + """Проверка live-режима ДО запуска грида (и трат): токены в env, серверы собраны. + Возвращает список проблем; пустой список = можно ехать.""" + problems = [] + for p in sorted(platforms): + cfg = SERVERS[p] + if not os.environ.get(cfg["token_env"]): + problems.append(f"{p}: нет {cfg['token_env']} в env") + path = _server_path(p) + if not os.path.exists(path): + problems.append(f"{p}: не найден MCP-сервер {path} — выполни `npm install` " + f"или задай MCP_PATH_{p.upper()}") + return problems + + @asynccontextmanager async def live_session(platform: str): """Спавн реального MCP-сервера по stdio. Токен — из env (см. SERVERS).""" @@ -47,11 +68,18 @@ async def live_session(platform: str): token = os.environ.get(cfg["token_env"]) if not token: raise RuntimeError(f"нет {cfg['token_env']} в env для live-режима ({platform})") + server = _server_path(platform) + if not os.path.exists(server): + raise RuntimeError(f"MCP-сервер не найден: {server} — выполни `npm install` " + f"или задай MCP_PATH_{platform.upper()}") env = {"PATH": os.environ.get("PATH", ""), cfg["token_env"]: token, **cfg.get("extra", {})} + for var in _PASSTHROUGH_ENV: + if os.environ.get(var): + env[var] = os.environ[var] login_env = cfg.get("login_env") if login_env and os.environ.get(login_env): env[login_env] = os.environ[login_env] - params = StdioServerParameters(command="node", args=[_server_path(platform)], env=env) + params = StdioServerParameters(command="node", args=[server], env=env) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: init = await session.initialize() diff --git a/llmbench/report.py b/llmbench/report.py new file mode 100644 index 0000000..b5199e1 --- /dev/null +++ b/llmbench/report.py @@ -0,0 +1,165 @@ +"""Агрегация per-run записей и сборка markdown-отчёта. + +Вынесено из runner.py, чтобы логика, производящая публикуемые числа, была тестируемой +без SDK-зависимостей (anthropic/openai/mcp) и покрывалась CI. + +Контракт записи (rec): case, dimension, turn_type, tool, numeric, has_golden, soft_quality, +soft_russian, cost, cost_wasted, retried, error, composite. У упавших прогонов (error != None) +метрики и composite равны None — они не входят в средние, но видны в колонке Err. +""" +from __future__ import annotations + +import statistics + + +def _mean(xs): + xs = [x for x in xs if x is not None] + return round(sum(xs) / len(xs), 3) if xs else None + + +def composite(rec): + """Сводный балл прогона: среднее доступных компонент (tool всегда; numeric — если есть + golden-факты; оценки судей — если судьи работали). У упавших прогонов — None.""" + if rec.get("error"): + return None + parts = [rec["tool"]] + if rec["has_golden"]: + parts.append(rec["numeric"]) + if rec["soft_quality"] is not None: + parts.append(rec["soft_quality"]) + if rec["soft_russian"] is not None: + parts.append(rec["soft_russian"]) + parts = [p for p in parts if p is not None] + return round(sum(parts) / len(parts), 3) if parts else None + + +def agg(records): + ok = [r for r in records if not r["error"]] + num = [r for r in ok if r["has_golden"]] + edge = [r for r in ok if r["dimension"] == "edge" and r["soft_quality"] is not None] + comps = [r["composite"] for r in ok if r["composite"] is not None] + + def spd(tt): + rs = [r for r in ok if r["turn_type"] == tt] + c, cost = _mean([r["composite"] for r in rs]), _mean([r["cost"] for r in rs]) + return round(c / cost, 1) if (c is not None and cost) else None + + # Stability: разброс между ПОВТОРАМИ одного кейса, усреднённый по кейсам — а не pstdev + # по всем записям (там доминировала бы разница сложности кейсов, а не шум модели). + by_case = {} + for r in ok: + if r["composite"] is not None: + by_case.setdefault(r["case"], []).append(r["composite"]) + sigmas = [statistics.pstdev(v) for v in by_case.values() if len(v) >= 2] + + return {"numeric": _mean([r["numeric"] for r in num]), "tool": _mean([r["tool"] for r in ok]), + "edge": _mean([r["soft_quality"] for r in edge]), + "russian": _mean([r["soft_russian"] for r in ok]), + "composite": _mean(comps), "cost_avg": _mean([r["cost"] for r in ok]), + "score_per_dollar": {"single": spd("single"), "multi": spd("multi")}, + "stddev_composite": round(sum(sigmas) / len(sigmas), 3) if sigmas else None, + "errors": sum(1 for r in records if r["error"]), + "retried": sum(1 for r in records if r.get("retried")), + "n_runs": len(records), + # полная стоимость варианта, ВКЛЮЧАЯ упавшие прогоны и потраченные повторы + "cost_total": round(sum((r["cost"] or 0) + (r.get("cost_wasted") or 0) + for r in records), 5)} + + +def pareto(aggregates): + items = [(k, a["composite"], a["cost_avg"]) for k, a in aggregates.items() + if a["composite"] is not None and a["cost_avg"] is not None and a["cost_avg"] > 0] + return [k for k, c, cost in items + if not any(c2 >= c and cost2 <= cost and (c2, cost2) != (c, cost) for _, c2, cost2 in items)] + + +def _f(x): + return "—" if x is None else (f"{x:.2f}" if isinstance(x, float) else str(x)) + + +MODEL_DISPLAY = { + "claude-sonnet-4-6": "Sonnet 4.6", "claude-opus-4-8": "Opus 4.8", + "glm-4.6": "GLM-4.6", "glm-5": "GLM-5", "gpt-5": "GPT-5", "gpt-4.1": "GPT-4.1", +} + + +def describe(v): + """Вариант → (LLM, Thinking, Effort). Thinking: adaptive/reasoning/нет; у GLM effort не рычаг (—).""" + llm = MODEL_DISPLAY.get(v["model"], v["model"]) + 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 "нет" + effort = "—" if v["vendor"] == "zai" else (v.get("effort") or "—") + return llm, thinking, effort + + +GLOSSARY = """## Термины (как читать таблицу) + +- **Accuracy** (0–5) — точность чисел: верно ли посчитаны CTR/CPC/CPA/расход, не выдуманы ли + цифры и той ли кампании они приписаны (entity-анкоринг). **В коде** (детерминированно). +- **Tools Use** (0–5) — корректность инструментов: вызвал нужные тулы (успешно) в нужном + порядке, без лишних/запрещённых. **Код**. +- **Edge Cases** (0–5) — поведение в краевых случаях (пустой отчёт, отказ менять ставку, + уточнение). **LLM-судьи** — оценивают и прогоны с нарушениями по тулам. +- **Lang quality** (0–5) — естественность и ясность русского. Судьи. +- **Score** (0–5) — сводный балл прогона = среднее доступных компонент: Tools Use (всегда), + Accuracy (если у кейса есть golden-факты), Edge Cases/Lang quality (если судьи работали). + Состав компонент зависит от кейса, поэтому Score сравним между вариантами (кейсы у всех + одни), но НЕ равен среднему четырёх колонок слева. Упавшие прогоны в Score не входят — см. Err. +- **Cost per Answer** — средняя стоимость успешного прогона (USD); **Score per USD (s/m)** — + «качество на доллар» (Score ÷ цена) для одно-/многошаговых диалогов; выше = выгоднее. +- **Stability** (0–5) — `5 − средний разброс (σ) Score между повторами одного кейса`: + выше = стабильнее. Осмысленна при repeat ≥ 2. +- **Err** — `упавшие/все прогоны` (ошибки API, обрезка лимитом токенов); суффикс `·NR` — + N прогонов удались только после повтора тем же конфигом. Упавшие прогоны исключены из + всех метрик, но их стоимость входит в полную стоимость прогона. +- **Thinking** — думает ли модель перед ответом: `adaptive` (Claude/GLM), `reasoning` (GPT-5), `нет`. +- **Effort** — бюджет «усилий» на ответ (`low/medium/high/max`); отдельная от thinking + настройка (при выключенном thinking влияет слабо). У GLM не настраивается (`—`). +- **⭐** — **лучший баланс «качество/цена»**: вариант, который нельзя «побить» — нет другого, + который и качественнее, и дешевле. _(В оптимизации — «Pareto-фронт».)_ +""" + + +def build_md(aggregates, meta): + o = ["# Сравнение моделей для AskAds (Claude / GLM / GPT)\n"] + 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("|---|---|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|") + 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]) + spd = a["score_per_dollar"] + cost = "—" 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(f"- {line}") + if meta.get("jsonl"): + o.append(f"\n_Сырые per-run данные: `{meta['jsonl']}` — отчёт пересобирается из них " + f"командой `python -m llmbench.runner --report-from <файл>`._") + return "\n".join(o) diff --git a/llmbench/runner.py b/llmbench/runner.py index da939d3..ed2fbb9 100644 --- a/llmbench/runner.py +++ b/llmbench/runner.py @@ -1,28 +1,40 @@ """Ранер: сетка вариантов (модель × thinking/effort/reasoning) × кейсы × repeat. Режимы MCP: --mode fixed (фикстуры, детерминированно, CI) | live (реальные MCP-серверы + -токены из env). Движок и MCP развязаны от askads. Вердикт — каждый вариант vs baseline. +токены из env). Движок и MCP развязаны от askads. Каждый прогон пишется в JSONL +(results/runs-.jsonl): ответы, трейсы, usage, оценки — отчёт пересобирается из него +без повторных трат (`--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 """ from __future__ import annotations import argparse import asyncio +import hashlib +import json import os -import statistics +import subprocess +import sys from datetime import datetime, timezone from pathlib import Path -from llmbench import scoring +from llmbench import report, scoring from llmbench.cases import CASES +from llmbench.core import MODEL_RATES, PLATFORM_YANDEX_METRIKA from llmbench.engines import run_anthropic, run_openai 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" def _v(label, vendor, engine, model, **kw): @@ -32,6 +44,7 @@ def _v(label, vendor, engine, model, **kw): "reasoning_effort": kw.get("reasoning_effort"), "is_baseline": kw.get("is_baseline", False)} +# У GLM effort не рычаг (отчёт показывает «—») — и не отправляем его в z.ai. VARIANTS = [ _v("Sonnet disabled/high", "anthropic", "anthropic", "claude-sonnet-4-6", key_env="ANTHROPIC_API_KEY", thinking="disabled", effort="high", is_baseline=True), _v("Sonnet disabled/low", "anthropic", "anthropic", "claude-sonnet-4-6", key_env="ANTHROPIC_API_KEY", thinking="disabled", effort="low"), @@ -41,10 +54,10 @@ def _v(label, vendor, engine, model, **kw): _v("Opus disabled/high", "anthropic", "anthropic", "claude-opus-4-8", key_env="ANTHROPIC_API_KEY", thinking="disabled", effort="high"), _v("Opus adaptive/high", "anthropic", "anthropic", "claude-opus-4-8", key_env="ANTHROPIC_API_KEY", thinking="adaptive", effort="high"), _v("Opus adaptive/max", "anthropic", "anthropic", "claude-opus-4-8", key_env="ANTHROPIC_API_KEY", thinking="adaptive", effort="max"), - _v("GLM-4.6 disabled", "zai", "anthropic", "glm-4.6", base_url=ZAI, key_env="ZAI_API_KEY", thinking="disabled", effort="high"), - _v("GLM-4.6 thinking", "zai", "anthropic", "glm-4.6", base_url=ZAI, key_env="ZAI_API_KEY", thinking="adaptive", effort="high"), - _v("GLM-5 disabled", "zai", "anthropic", "glm-5", base_url=ZAI, key_env="ZAI_API_KEY", thinking="disabled", effort="high"), - _v("GLM-5 thinking", "zai", "anthropic", "glm-5", base_url=ZAI, key_env="ZAI_API_KEY", thinking="adaptive", effort="high"), + _v("GLM-4.6 disabled", "zai", "anthropic", "glm-4.6", base_url=ZAI, key_env="ZAI_API_KEY", thinking="disabled"), + _v("GLM-4.6 thinking", "zai", "anthropic", "glm-4.6", base_url=ZAI, key_env="ZAI_API_KEY", thinking="adaptive"), + _v("GLM-5 disabled", "zai", "anthropic", "glm-5", base_url=ZAI, key_env="ZAI_API_KEY", thinking="disabled"), + _v("GLM-5 thinking", "zai", "anthropic", "glm-5", base_url=ZAI, key_env="ZAI_API_KEY", thinking="adaptive"), _v("GPT-5 reasoning low", "openai", "openai", "gpt-5", key_env="OPENAI_API_KEY", reasoning_effort="low"), _v("GPT-5 reasoning medium", "openai", "openai", "gpt-5", key_env="OPENAI_API_KEY", reasoning_effort="medium"), _v("GPT-5 reasoning high", "openai", "openai", "gpt-5", key_env="OPENAI_API_KEY", reasoning_effort="high"), @@ -54,165 +67,154 @@ def _v(label, vendor, engine, model, **kw): _USAGE = ("input_tokens", "cache_read_tokens", "cache_write_tokens", "tokens_out") -async def _run_case(variant, case, nonce, mode, safe=False): - thinking = "disabled" if safe else variant.get("thinking") - effort = "high" if safe else variant.get("effort") - reasoning = None if safe else variant.get("reasoning_effort") +def _nonce(case_id, label, ri): + """Кэш-бастер БЕЗ утечки: говорящий id кейса (refuse_change_bid…) и конфиг варианта + в системном промпте подсказывали модели суть теста — отдаём только хэш.""" + return hashlib.sha1(f"{case_id}:{label}:{ri}".encode()).hexdigest()[:12] + + +async def _run_case(variant, case, nonce, mode): history, trace, answer, err = [], [], "", None usage = {k: 0 for k in _USAGE} for user_msg in case.turns: history.append({"role": "user", "content": user_msg}) if variant["engine"] == "openai": done = await run_openai(history, model=variant["model"], mode=mode, platform=case.platform, - metrika_enabled=case.metrika_enabled, reasoning_effort=reasoning, cache_nonce=nonce) + metrika_enabled=case.metrika_enabled, + reasoning_effort=variant.get("reasoning_effort"), cache_nonce=nonce) else: done = await run_anthropic(history, model=variant["model"], base_url=variant["base_url"], - api_key=os.environ.get(variant["key_env"], ""), thinking=thinking, - effort=effort, mode=mode, platform=case.platform, + api_key=os.environ.get(variant["key_env"], ""), + thinking=variant.get("thinking"), effort=variant.get("effort"), + mode=mode, platform=case.platform, metrika_enabled=case.metrika_enabled, cache_nonce=nonce) + for k in usage: + usage[k] += done.get(k, 0) or 0 if done.get("error"): err = done["error"] + # частичный ответ упавшего тёрна — в JSONL для отладки; в скоринг не попадёт + answer = done.get("answer", "") or "" break answer = done.get("answer", "") or "" trace += done.get("tool_trace", []) or [] - for k in usage: - usage[k] += done.get(k, 0) or 0 history.append({"role": "assistant", "content": answer}) return {"answer": answer, "tool_trace": trace, "usage": usage, "error": err} -async def _run_case_fb(variant, case, nonce, mode): +async def _run_case_retried(variant, case, nonce, mode, retries=1): + """Ошибка → повтор ТЕМ ЖЕ конфигом (транзиентные ретраи уже внутри движка). Никакой + подмены thinking/effort: результат под лейблом варианта обязан быть получен этим + конфигом (см. REVIEW.md R1). Стоимость упавших попыток возвращаем отдельно.""" + wasted = 0.0 res = await _run_case(variant, case, nonce, mode) - if res["error"] and (variant.get("thinking") not in (None, "disabled") - or variant.get("reasoning_effort") or variant.get("effort") not in (None, "high")): - res2 = await _run_case(variant, case, nonce, mode, safe=True) - if not res2["error"]: - return res2, True - return res, False - - -def _composite(rec): - parts = [rec["tool"]] - if rec["has_golden"]: - parts.append(rec["numeric"]) - if rec["soft_quality"] is not None: - parts.append(rec["soft_quality"]) - return round(sum(parts) / len(parts), 3) if parts else None - - -async def _score(case, variant, rr, candidate_vendors, use_judges, judges): - tool = scoring.score_tooluse(rr["tool_trace"], case.trace) - numeric = scoring.score_numeric(rr["answer"], case.golden_facts) - soft_q = soft_r = None - if use_judges and not tool["fail_fast"] and not rr.get("error"): - from llmbench.judges import run_panel - panel = await run_panel("\n".join(case.turns), case.rubric, rr["answer"], candidate_vendors, judges) - soft_q, soft_r = panel["primary"]["quality"], panel["primary"]["russian"] - rec = {"case": case.id, "dimension": case.dimension, "turn_type": case.turn_type, - "tool": tool["score"], "tool_failfast": tool["fail_fast"], "numeric": numeric["score"], - "has_golden": bool(case.golden_facts), "soft_quality": soft_q, "soft_russian": soft_r, - "cost": scoring.cost_from_done(variant["model"], rr["usage"]), "error": rr.get("error")} - rec["composite"] = _composite(rec) - return rec - - -def _mean(xs): - xs = [x for x in xs if x is not None] - return round(sum(xs) / len(xs), 3) if xs else None - - -def _agg(records): - num = [r for r in records if r["has_golden"]] - edge = [r for r in records if r["dimension"] == "edge" and r["soft_quality"] is not None] - comps = [r["composite"] for r in records if r["composite"] is not None] - - def spd(tt): - rs = [r for r in records if r["turn_type"] == tt] - c, cost = _mean([r["composite"] for r in rs]), _mean([r["cost"] for r in rs]) - return round(c / cost, 1) if (c and cost) else None - - return {"numeric": _mean([r["numeric"] for r in num]), "tool": _mean([r["tool"] for r in records]), - "edge": _mean([r["soft_quality"] for r in edge]), "russian": _mean([r["soft_russian"] for r in records]), - "composite": _mean(comps), "cost_avg": _mean([r["cost"] for r in records]), - "score_per_dollar": {"single": spd("single"), "multi": spd("multi")}, - "stddev_composite": round(statistics.pstdev(comps), 3) if len(comps) >= 2 else None, - "errors": sum(1 for r in records if r["error"]), "n_runs": len(records)} - - -def _pareto(agg): - items = [(k, a["composite"], a["cost_avg"]) for k, a in agg.items() if a["composite"] is not None and a["cost_avg"]] - return [k for k, c, cost in items - if not any(c2 >= c and cost2 <= cost and (c2, cost2) != (c, cost) for _, c2, cost2 in items)] - - -def _f(x): - return "—" if x is None else (f"{x:.2f}" if isinstance(x, float) else str(x)) + for _ in range(retries): + if not res["error"]: + break + wasted += scoring.cost_from_done(variant["model"], res["usage"]) + res = await _run_case(variant, case, nonce, mode) + return res, wasted -MODEL_DISPLAY = { - "claude-sonnet-4-6": "Sonnet 4.6", "claude-opus-4-8": "Opus 4.8", - "glm-4.6": "GLM-4.6", "glm-5": "GLM-5", "gpt-5": "GPT-5", "gpt-4.1": "GPT-4.1", -} +async def _score(case, variant, rr, candidate_vendors, use_judges, judges, wasted=0.0): + rec = {"case": case.id, "dimension": case.dimension, "turn_type": case.turn_type, + "tool": None, "tool_failfast": None, "numeric": None, + "has_golden": bool(case.golden_facts), "soft_quality": None, "soft_russian": None, + "cost": scoring.cost_from_done(variant["model"], rr["usage"]), + # retried = «удался только после повтора»: упавший финал повтором не считаем + "cost_wasted": round(wasted, 6), "retried": wasted > 0 and not rr.get("error"), + "error": rr.get("error"), "judges_detail": None} + if not rr.get("error"): + tool = scoring.score_tooluse(rr["tool_trace"], case.trace) + numeric = scoring.score_numeric(rr["answer"], case.golden_facts) + rec.update({"tool": tool["score"], "tool_failfast": tool["fail_fast"], "numeric": numeric["score"]}) + # Судьи оценивают и прогоны с нарушениями по тулам (fail_fast): иначе худшие + # edge-прогоны выпадали из колонки Edge Cases и завышали её (survivorship bias). + if use_judges: + from llmbench.judges import run_panel + panel = await run_panel("\n".join(case.turns), case.rubric, rr["answer"], candidate_vendors, judges) + rec["soft_quality"], rec["soft_russian"] = panel["primary"]["quality"], panel["primary"]["russian"] + rec["judges_detail"] = panel["judges"] + rec["composite"] = report.composite(rec) + return rec -def _describe(v): - """Вариант → (LLM, Thinking, Effort). Thinking: adaptive/reasoning/нет; у GLM effort не рычаг (—).""" - llm = MODEL_DISPLAY.get(v["model"], v["model"]) - 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 "нет" - effort = "—" if v["vendor"] == "zai" else (v.get("effort") or "—") - return llm, thinking, effort +def _git_commit(): + try: + out = subprocess.run(["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True, + cwd=Path(__file__).resolve().parent.parent, timeout=5) + return out.stdout.strip() or None + except Exception: # noqa: BLE001 — метаданные best-effort + return None + + +def _build_caveats(mode, repeat, neutral): + caveats = [ + "**GPT гоняли через отдельную обвязку** (askads на Anthropic, GPT в его движок не вставить) — " + "точность работы GPT с инструментами сравнима с Claude/GLM не идеально (другой формат вызова тулов).", + "**Цены и скидки за кэш (кэш-хит)** — по прайс-листам; сверить с реальными счетами.", + "**Модель могла подмениться**: ответ API на имя `glm-5`/`gpt-5` ещё не гарантирует, что под капотом именно она.", + ] + if not neutral: + caveats.append("**Независимого судьи нет**: ответы оценивают те же компании, чьи модели и " + "сравниваются — возможно завышение «своей» модели; оценки судей вспомогательные, " + "вес на ключевых метриках Tools Use/Accuracy (их считает код).") + if repeat < 3: + caveats.append(f"**Мало повторов** ({repeat}) — Stability на {repeat} точках доверять рано; " + "в Accuracy «уверенно неверное число» = «не названо».") + if mode == "fixed": + caveats.append("Режим `fixed`: модели видят аккуратные тестовые данные (фикстуры), а не «грязный» " + "реальный вывод API (для этого `--mode live`).") + return caveats + + +def _filter_or_die(items, patterns, what, key): + if not patterns: + return list(items) + picked = [it for it in items if any(p.lower() in key(it).lower() for p in patterns)] + if not picked: + names = "\n ".join(key(it) for it in items) + sys.exit(f"Фильтр {what} {patterns!r} не совпал ни с чем — не запускаю ничего " + f"(раньше тут молча уезжал ПОЛНЫЙ грид). Доступно:\n {names}") + return picked + + +def _report_from(path, out_arg): + meta, recs_by = None, {} + for line in Path(path).read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + obj = json.loads(line) + if obj["type"] == "meta": + meta = obj["meta"] + elif obj["type"] == "run": + recs_by.setdefault(obj["variant"], []).append(obj["rec"]) + 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}") -_GLOSSARY = """## Термины (как читать таблицу) +async def _run_variant(v, cases, args, judges_all, candidate_vendors, jsonl_path, jsonl_lock): + sem = asyncio.Semaphore(args.concurrency) -- **Accuracy** (0–5) — точность чисел: верно ли посчитаны CTR/CPC/CPA/расход и не выдуманы ли цифры. **В коде** (детерминированно). -- **Tools Use** (0–5) — корректность инструментов: вызвал нужные тулы в нужном порядке, без лишних/запрещённых. **Код**. -- **Edge Cases** (0–5) — поведение в краевых случаях (пустой отчёт, отказ менять ставку, уточнение). **LLM-судьи**. -- **Lang quality** (0–5) — естественность и ясность русского. Судьи. -- **Score** (0–5) — сводный балл строки (итог) = среднее доступных измерений (Accuracy/Tools Use/Edge Cases/Lang quality). -- **Cost per Answer** — средняя стоимость прогона (USD); **Score per USD (s/m)** — «качество на доллар» (Score ÷ цена) для одно-/многошаговых диалогов; выше = выгоднее. -- **Stability** (0–5) — стабильность ответов между повторами (`5 − разброс`): выше = стабильнее. -- **Thinking** — думает ли модель перед ответом: `adaptive` (Claude/GLM), `reasoning` (GPT-5), `нет`. -- **Effort** — бюджет «усилий» на ответ (`low/medium/high/max`); отдельная от thinking настройка (при выключенном thinking влияет слабо). У GLM не настраивается (`—`). -- **⭐** — **лучший баланс «качество/цена»**: вариант, который нельзя «побить» — нет другого, который и качественнее, и дешевле. _(В оптимизации — «Pareto-фронт».)_ -""" + async def one(case, ri): + async with sem: + rr, wasted = await _run_case_retried(v, case, _nonce(case.id, v["label"], ri), args.mode) + rec = await _score(case, v, rr, candidate_vendors, args.judges != "off", judges_all, wasted) + line = {"type": "run", "variant": v["label"], "case": case.id, "repeat": ri, "rec": rec, + "answer": rr["answer"], "tool_trace": rr["tool_trace"], "usage": rr["usage"]} + async with jsonl_lock: + with jsonl_path.open("a", encoding="utf-8") as f: + f.write(json.dumps(line, ensure_ascii=False) + "\n") + print(f" {v['label']:<24} {case.id:<20} r{ri} tool={rec['tool']} num={rec['numeric']} " + f"soft={rec['soft_quality']} ${rec['cost']:.5f}" + (" RETRY" if rec["retried"] else "") + + (f" ERR={rec['error'][:60]}" if rec["error"] else "")) + return rec - -def _build_md(agg, meta): - o = ["# Сравнение моделей для AskAds (Claude / GLM / GPT)\n"] - total = len(meta['variants']) * meta['n_cases'] * meta['repeat'] - judges = ', '.join(meta['judges']) if isinstance(meta['judges'], list) else meta['judges'] - o.append(f"_Запуск от {meta['ts']} × **{len(meta['variants'])} вариантов** " - f"(модель × thinking/effort) × **{meta['n_cases']} тест-кейсов** × **{meta['repeat']} повтора** " - f"= {total} запусков · режим {meta['mode']} · вход одинаковый для всех (фикстуры версии `{FIXTURE_VERSION}`)._\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 | Score |") - o.append("|---|---|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|") - front = set(_pareto(agg)) - by_label = {v["label"]: v for v in meta["variants"]} - for label, a in sorted(agg.items(), key=lambda kv: (kv[1]["composite"] is None, -(kv[1]["composite"] or 0))): - llm, thinking, effort = _describe(by_label[label]) - spd = a["score_per_dollar"] - o.append(f"| {llm}{' ⭐' if label in front else ''} | {thinking} | {effort} | {_f(a['numeric'])} | {_f(a['tool'])} | " - f"{_f(a['edge'])} | {_f(a['russian'])} | ${a['cost_avg'] or 0:.5f} | {_f(spd['single'])} | " - f"{_f(spd['multi'])} | {_f(5 - a['stddev_composite'] if a['stddev_composite'] is not None else None)} | {_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(f"- {line}") - return "\n".join(o) + return await asyncio.gather(*[one(c, ri) for c in cases for ri in range(args.repeat)]) async def main(): @@ -222,68 +224,94 @@ async def main(): ap.add_argument("--cases", nargs="*", default=None) ap.add_argument("--repeat", type=int, default=2) ap.add_argument("--judges", choices=["panel", "neutral", "off"], default="panel") + ap.add_argument("--concurrency", type=int, default=4, + help="параллельных прогонов внутри варианта (кейсы × повторы)") ap.add_argument("--dry-run", action="store_true") - ap.add_argument("--out", default="results/model-comparison-grid.ru.md") + ap.add_argument("--out", default=None, help=f"файл отчёта (дефолт {DEFAULT_OUT})") + ap.add_argument("--report-from", default=None, + help="пересобрать отчёт из runs-*.jsonl без запусков (бесплатно)") args = ap.parse_args() - variants = [v for v in VARIANTS if not args.variants or any(n.lower() in v["label"].lower() for n in args.variants)] or list(VARIANTS) - cases = [c for c in CASES if not args.cases or c.id in args.cases] or list(CASES) + if args.report_from: + _report_from(args.report_from, args.out) + return + + variants = _filter_or_die(VARIANTS, args.variants, "--variants", lambda v: v["label"]) + cases = _filter_or_die(CASES, args.cases, "--cases", lambda c: c.id) candidate_vendors = {v["vendor"] for v in variants} + for v in variants: + if v["model"] not in MODEL_RATES: + print(f"[warn] модель {v['model']!r} ({v['label']}) без тарифа в core.MODEL_RATES — " + f"Cost/Pareto будут по дефолтной ставке") + from llmbench.judges import available_judges - judges_all = available_judges() + judges_all = available_judges() if args.judges != "off" else [] if args.judges == "neutral": judges_all = [j for j in judges_all if j["vendor"] not in candidate_vendors] + if not judges_all: + print("[warn] --judges neutral, но нейтральных судей нет (добавь GOOGLE_API_KEY?) — " + "судейство ВЫКЛЮЧЕНО, колонки Edge/Lang будут пустыми") neutral = [j["name"] for j in judges_all if j["vendor"] not in candidate_vendors] n_runs = len(variants) * len(cases) * args.repeat print(f"mode={args.mode} · вариантов={len(variants)} · кейсов={len(cases)} · repeat={args.repeat}") print(f"Судьи: {[j['name'] for j in judges_all] or '—'} · нейтрален: {neutral or '—'}") - print(f"Смета: run ≈ {n_runs} · судейских ≈ {n_runs * (len(judges_all) if args.judges != 'off' else 0)}") + print(f"Смета (все выбранные варианты): run ≈ {n_runs} · судейских ≈ {n_runs * len(judges_all)}") if args.dry_run: - print("--dry-run."); return - if os.environ.get("RUN_BENCH") != "1": - print("Нужен RUN_BENCH=1."); return + print("--dry-run.") + return - agg = {} + runnable = [v for v in variants if os.environ.get(v["key_env"])] for v in variants: - if v["engine"] == "anthropic" and not os.environ.get(v["key_env"]): - print(f" [skip] {v['label']}: нет {v['key_env']}"); continue - if v["engine"] == "openai" and not os.environ.get("OPENAI_API_KEY"): - print(f" [skip] {v['label']}: нет OPENAI_API_KEY"); continue - recs = [] - for case in cases: - for ri in range(args.repeat): - rr, fb = await _run_case_fb(v, case, f"{case.id}:{v['label']}:{ri}", args.mode) - rec = await _score(case, v, rr, candidate_vendors, args.judges != "off", judges_all) - recs.append(rec) - print(f" {v['label']:<24} {case.id:<20} r{ri} tool={rec['tool']} num={rec['numeric']} " - f"soft={rec['soft_quality']} ${rec['cost']:.5f}" + (" FB" if fb else "") - + (f" ERR={rec['error'][:40]}" if rec['error'] else "")) - agg[v["label"]] = _agg(recs) - - baseline = next((v for v in variants if v.get("is_baseline")), None) - baseline_desc = "{} (thinking {}, effort {})".format(*_describe(baseline)) if baseline else None - - meta = {"ts": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), "mode": args.mode, - "repeat": args.repeat, "n_cases": len(cases), "variants": variants, + if v not in runnable: + print(f" [skip] {v['label']}: нет {v['key_env']}") + if not runnable: + sys.exit("Ни одного варианта с ключом в env — нечего запускать.") + + if args.mode == "live": + from llmbench.mcp import preflight_live + platforms = {c.platform for c in cases} + if any(c.metrika_enabled for c in cases): + platforms.add(PLATFORM_YANDEX_METRIKA) + problems = preflight_live(platforms) + if problems: + sys.exit("Live-режим не готов (проверь ДО трат):\n " + "\n ".join(problems)) + + if os.environ.get("RUN_BENCH") != "1": + 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) + jsonl_lock = asyncio.Lock() + 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, - "baseline_desc": baseline_desc, - "caveats": [ - "**GPT гоняли через отдельную обвязку** (askads на Anthropic, GPT в его движок не вставить) — " - "точность работы GPT с инструментами сравнима с Claude/GLM не идеально (другой формат вызова тулов).", - "**Независимого судьи нет**: ответы оценивают те же компании, чьи модели и сравниваются (Claude судит " - "в т.ч. ответы Claude, и т.д.) — возможно завышение «своей» модели; поэтому оценки судей вспомогательные, " - "вес на ключевых метриках Tools Use/Accuracy (их считает код).", - "**Цены и скидки за кэш (кэш-хит)** у gpt/gemini/glm — по прайс-листам/оценке, не по реальным счетам; сверить с биллингом.", - "**Модель могла подмениться**: ответ API на имя `glm-5`/`gpt-5` ещё не гарантирует, что под капотом именно она.", - "**Мало повторов** (2) — Stability на двух точках доверять рано, нужно больше; в Accuracy «уверенно неверное число» = «не названо».", - "Режим `fixed`: модели видят аккуратные тестовые данные (фикстуры), а не «грязный» реальный вывод API (для этого `--mode live`).", - ]} - out = Path(args.out) + "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)) + with jsonl_path.open("w", encoding="utf-8") as f: + f.write(json.dumps({"type": "meta", "meta": meta}, ensure_ascii=False) + "\n") + + aggregates = {} + for v in runnable: + 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} содержит только " + f"выбранные варианты/кейсы — не путать с полным гридом") out.parent.mkdir(parents=True, exist_ok=True) - out.write_text(_build_md(agg, meta), encoding="utf-8") - print(f"\nГотово → {out}") + out.write_text(report.build_md(aggregates, meta), encoding="utf-8") + 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())}") if __name__ == "__main__": diff --git a/llmbench/scoring.py b/llmbench/scoring.py index 7ef01c3..201b935 100644 --- a/llmbench/scoring.py +++ b/llmbench/scoring.py @@ -1,31 +1,53 @@ """Ключевые метрики (Tool-Use, Numeric-Accuracy) + стоимость. Numeric и Tool-Use считаются В КОДЕ (детерминированно), судьям не отдаются — LLM плох ровно -в той арифметике, в которой мы сомневаемся. Анкоринг по aliases защищает от «правильное число -по неправильной причине». +в той арифметике, в которой мы сомневаемся. Анкоринг защищает от «правильное число по +неправильной причине»: metric-алиас (что за метрика) обязателен всегда, entity-алиас +(какая кампания) — в кейсах, где в ответе фигурирует несколько кампаний. """ from __future__ import annotations import re -from llmbench.core import CACHE_MULT, MODEL_RATES, _DEFAULT_CACHE, _DEFAULT_RATES, family +from llmbench.core import (CACHE_MULT, MODEL_CACHE_MULT, MODEL_RATES, _DEFAULT_CACHE, + _DEFAULT_RATES, family) # ======================= NUMERIC ======================= ANCHOR_WINDOW = 40 _ABS_FLOOR = 0.05 -_SPACES = " " -_THOUSANDS = rf"\d{{1,3}}(?:[{_SPACES}]\d{{3}})+" -_INT = rf"(?:{_THOUSANDS}|\d+)" +# Пробельные разделители тысяч: обычный пробел, NBSP, узкий NBSP, тонкий пробел — +# модели используют все четыре («58 800» ≠ два числа 58 и 800). +_SPACES = " \u00a0\u202f\u2009" # \u00a0=NBSP, \u202f=узкий NBSP, \u2009=тонкий +_THOUSANDS_SPACE = rf"\d{{1,3}}(?:[{_SPACES}]\d{{3}})+" +_THOUSANDS_COMMA = r"\d{1,3}(?:,\d{3})+" # US-стиль: 58,800 (группы строго по 3 цифры) +_INT = rf"(?:{_THOUSANDS_SPACE}|{_THOUSANDS_COMMA}|\d+)" _NUM = rf"{_INT}(?:[.,]\d+)?" _SCALE = r"(?:\s*(тыс\.?|тысяч[аи]?|млн\.?|миллион[аов]*|млрд\.?|миллиард[аов]*))?" _TOKEN_RE = re.compile(_NUM + _SCALE, re.IGNORECASE) _SCALE_FACTOR = {"тыс": 1e3, "тысяч": 1e3, "млн": 1e6, "миллион": 1e6, "млрд": 1e9, "миллиард": 1e9} +_US_THOUSANDS_RE = re.compile(r"\d{1,3}(?:,\d{3})+(?:\.\d+)?") -# Направленный absence-матчинг. -_GAP_AFTER = 25 +# Направленный absence-матчинг. Дистракторы — слова ДРУГИХ метрик: число после +# «CPA … расход» — это значение расхода, не CPA. Временные/структурные слова (период, +# неделя, дата, кампания) сюда НЕ входят: «CPA за неделю 180 ₽» — выдуманный CPA, а не +# контекст. Даты-значения отсекает _looks_like_date, «N конверсий» — _unit_after. +_GAP_AFTER = 30 _GAP_BEFORE = 14 -_DISTRACTORS = ["расход", "потрач", "клик", "показ", "ставк", "бюджет", "визит", - "посещени", "пользовател", "ctr", "импресс", "цена клика"] +_DISTRACTORS = ["расход", "потрач", "потрат", "клик", "показ", "ставк", "бюджет", "визит", + "посещени", "пользовател", "ctr", "импресс", "цена клика", "cpc"] +# Число с такой единицей сразу после — не «значение CPA»: «(0 конверсий)», «5 дней». +_UNITS_AFTER = ["клик", "показ", "конверси", "визит", "пользовател", "объявлени", + "%", "дн", "недел", "мес", "год", "₽/клик"] +# Классы единицы СРАЗУ после числа — определяют, к какой метрике число относится по своей +# собственной единице (а не по близости слова): «5150 ₽» money, «1530 кликов»/«12%» count. +_MONEY_UNITS = ["₽", "руб", "rub", "р.", "₸", "$"] +_COUNT_UNITS = ["клик", "показ", "impress", "конверси", "визит", "посещени", "пользовател", + "объявлени", "достижени", "%", "дн", "недел", "мес", "год"] +# Явные маркеры идентификатора: число после них — не выдуманный CPA, а id (не absence-нарушение). +_ID_MARKERS = ["id", "№", "counter", "счётчик", "счетчик"] +# Реальная дата: ISO (2026-06-01) или DD.MM(.YYYY) / DD/MM. Диапазон «1500-2000» датой НЕ +# считается — одиночный дефис между многозначными числами это диапазон значений. +_DATE_TOKEN_RE = re.compile(r"\d{4}-\d{2}-\d{2}|\b\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?\b") def _scale_of(word): @@ -44,7 +66,11 @@ def parse_numbers(text: str) -> list[dict]: core, scale_word = m.group(0), m.group(1) if scale_word: core = core[: core.lower().rfind(scale_word.lower())] - digits = re.sub(rf"[{_SPACES}]", "", core).strip().replace(",", ".") + digits = re.sub(rf"[{_SPACES}]", "", core).strip() + if _US_THOUSANDS_RE.fullmatch(digits): + digits = digits.replace(",", "") # 58,800 → 58800 (US-тысячи) + else: + digits = digits.replace(",", ".") # 2,44 → 2.44 (русская десятичная) if not digits: continue try: @@ -55,6 +81,14 @@ def parse_numbers(text: str) -> list[dict]: return out +def _occurrences(low, alias): + out, i = [], low.find(alias) + while i != -1: + out.append((i, i + len(alias))) + i = low.find(alias, i + 1) + return out + + def _alias_near(low, span, aliases): a, b = span window = low[max(0, a - ANCHOR_WINDOW): b + ANCHOR_WINDOW] @@ -65,18 +99,185 @@ def _within(value, target, tol_rel): return abs(value - target) <= max(abs(target) * tol_rel, _ABS_FLOOR) -def check_presence(text, fact) -> bool: - low, nums = text.lower(), parse_numbers(text) - return any(_within(n["value"], fact["value"], fact.get("tolerance", 0.05)) - and _alias_near(low, (n["start"], n["end"]), fact["aliases"]) for n in nums) +# ---- разбор markdown-таблиц (метрика в шапке столбца, кампания в строке) ---- +def _line_bounds(low, pos): + s = low.rfind("\n", 0, pos) + 1 + e = low.find("\n", pos) + return s, (len(low) if e == -1 else e) -def _occurrences(low, alias): - out, i = [], low.find(alias) - while i != -1: - out.append((i, i + len(alias))) - i = low.find(alias, i + 1) - return out +def _row_at(low, pos): + s, e = _line_bounds(low, pos) + return low[s:e], s + + +def _cell_index(row, col_in_row): + return row.count("|", 0, col_in_row) + + +def _is_separator_row(row): + core = row.replace("|", "").replace(":", "").replace("-", "").replace(" ", "") + return "-" in row and core == "" + + +def _prev_rows(low, line_start): + """Строки таблицы над строкой, начинающейся в line_start (ближайшая первой).""" + end = line_start - 1 # позиция '\n' перед нашей строкой (или -1 для первой) + while end > 0: + s = low.rfind("\n", 0, end) + 1 + row = low[s:end] + if "|" not in row: + break + yield row + end = s - 1 + + +def _header_cell(low, line_start, col): + """Ячейка шапки таблицы в столбце col: строка НАД разделителем (|---|), иначе — верхняя + не-разделительная строка блока (не промежуточные data-строки).""" + rows = list(_prev_rows(low, line_start)) # ближайшая первой + sep = next((i for i, r in enumerate(rows) if _is_separator_row(r)), None) + if sep is not None and sep + 1 < len(rows): + header = rows[sep + 1] + else: + header = next((r for r in rows if not _is_separator_row(r)), None) + if header is None: + return None + cells = header.split("|") + return cells[col] if col < len(cells) else None + + +def _alias_in_table(low, span, aliases): + """Алиас привязан к числу таблицы: в самой строке (key-value / «CPA: 5150») ИЛИ в шапке + столбца (транспонированная таблица — кампания/метрика в шапке столбца, а не в строке).""" + row, s = _row_at(low, span[0]) + if any(al.lower() in row for al in aliases): + return True + hc = _header_cell(low, s, _cell_index(row, span[0] - s)) + return hc is not None and any(al.lower() in hc for al in aliases) + + +def _metric_in_column(low, span, metric): + if not _in_table_row(low, span[0]): + return False + return _alias_in_table(low, span, metric) + + +def _unit_class_after(low, n_end): + """Класс единицы сразу после числа: 'money' (₽/руб), 'count' (клики/показы/%…), None.""" + tail = low[n_end:n_end + 16].lstrip(_SPACES + " -–—():") + if any(tail.startswith(u) for u in _MONEY_UNITS): + return "money" + if any(tail.startswith(u) for u in _COUNT_UNITS): + return "count" + return None + + +def _wrong_unit(low, n, value_kind): + """Единица числа противоречит метрике факта: денежной метрике чужды count-числа + («1530 кликов», «12%»), count-метрике (метрика Метрики) — денежные. Так число уходит + к своей метрике по СВОЕЙ единице, а не по близости слова.""" + cls = _unit_class_after(low, n["end"]) + if value_kind == "count": + return cls == "money" + return cls == "count" # money по умолчанию + + +def _nearest_metric_own(low, span, metric, others): + """Проза: метка метрики стоит ПЕРЕД своим значением («CPA 1200», «расход 58 800»), + поэтому число принадлежит ближайшей метке СЛЕВА; правые метки метят следующее число и + учитываются, только если слева метки нет. Число отвергается лишь при ближайшей ЧУЖОЙ + метке; если ближайшая — наша или метки рядом нет (список/сравнение называет метрику один + раз), принимается — коллизий значений между метриками нет (гарантирует фикстура).""" + left = right = None # (dist, own) ближайших меток слева/справа в окне + for own, aliases in ((True, metric), (False, others)): + for al in aliases: + for (s, e) in _occurrences(low, al.lower()): + if e <= span[0]: + d = span[0] - e + if d <= ANCHOR_WINDOW and (left is None or d < left[0]): + left = (d, own) + elif s >= span[1]: + d = s - span[1] + if d <= ANCHOR_WINDOW and (right is None or d < right[0]): + right = (d, own) + if left is not None: + return left[1] + if right is not None: + return right[1] + return True # метки рядом нет → чужая метрика не мешает + + +def _metric_ok(low, span, metric, others=()): + # таблица: metric-алиас в строке/шапке столбца; проза: метка своей метрики есть в тексте + # (список/сравнение называют её один раз) И к числу не ближе метка чужой метрики + if _in_table_row(low, span[0]): + return _metric_in_column(low, span, metric) + if not any(al.lower() in low for al in metric): + return False + return _nearest_metric_own(low, span, metric, others) + + +def _in_table_row(low, pos): + # строка markdown-таблицы начинается с «|» (лидирующая труба) — так одиночный «|» в + # прозе не путается с таблицей + return _row_at(low, pos)[0].lstrip().startswith("|") + + +def _entity_mentions(low, own, rivals): + """Упоминания кампаний (свои own=True + соперники own=False) в порядке текста, + перекрывающиеся («поиск-москва» ⊃ «москва») схлопнуты в одно.""" + ms = [] + for own_flag, aliases in ((True, own), (False, rivals)): + for al in aliases: + for (s, e) in _occurrences(low, al.lower()): + ms.append((s, e, own_flag)) + ms.sort() + merged = [] + for s, e, own_flag in ms: + if merged and s < merged[-1][1]: + ps, pe, pflag = merged[-1] + merged[-1] = (ps, max(pe, e), pflag or own_flag) + else: + merged.append((s, e, own_flag)) + return merged + + +def check_presence(text, fact, rival_entities=(), other_metrics=(), sibling_values=()) -> bool: + """Число зачитывается, только если metric-анкор ок (ближайшая метка метрики — наша в + прозе / шапка столбца в таблице) И (для мультикампейн-кейсов) оно атрибутировано нужной + кампании. Атрибуция кампании: в таблице — по строке; в прозе — позиционным + сопоставлением, но ТОЛЬКО среди чисел, чьё значение соответствует одной из целевых + метрик (`sibling_values`) — так посторонние числа («в 4 раза», «топ-3») не сдвигают + выравнивание. Верно и для «Москва vs РСЯ: 1200 vs 5150», и для перестановки (отвергает).""" + low, nums = text.lower(), parse_numbers(text) + metric, entity = fact["aliases"], fact.get("entity") or [] + tol, kind = fact.get("tolerance", 0.05), fact.get("value_kind", "money") + pool = list(sibling_values) or [fact["value"]] + metric_nums = [n for n in nums + if not _wrong_unit(low, n, kind) + and _metric_ok(low, (n["start"], n["end"]), metric, other_metrics)] + if not entity: + return any(_within(n["value"], fact["value"], tol) for n in metric_nums) + + def _in_pool(n): + return any(_within(n["value"], v, tol) for v in pool) + + mentions = _entity_mentions(low, entity, rival_entities) + prose_nums = [n for n in metric_nums if not _in_table_row(low, n["start"]) and _in_pool(n)] + prose_mentions = [m for m in mentions if not _in_table_row(low, m[0])] + for n in metric_nums: + if not _within(n["value"], fact["value"], tol): + continue + if _in_table_row(low, n["start"]): + # кампания в строке числа ИЛИ в шапке его столбца (транспонированная таблица) + if _alias_in_table(low, (n["start"], n["end"]), entity): + return True + elif _in_pool(n): + i = prose_nums.index(n) + if i < len(prose_mentions) and prose_mentions[i][2]: + return True + return False def _is_suffix_alias(alias): @@ -88,27 +289,83 @@ def _clean_gap(low, a, b): return not any(d in low[a:b] for d in _DISTRACTORS) +def _unit_after(low, n_end): + tail = low[n_end:n_end + 16].lstrip(_SPACES + "-–—") + return any(tail.startswith(u) for u in _UNITS_AFTER) + + +def _looks_like_id(low, n): + """Число — идентификатор (id кампании/счётчика), а не выдуманный CPA: целое без денежной + единицы после и с явным id-маркером слева («id 12348», «счётчик 99001»). «180 ₽» после + «кампании» не id (есть ₽), поэтому выдуманный CPA по-прежнему ловится.""" + if "." in n["raw"] or "," in n["raw"]: + return False + after = low[n["end"]:n["end"] + 8] + if "₽" in after or "руб" in after: + return False + before = low[max(0, n["start"] - 12):n["start"]] + return any(m in before for m in _ID_MARKERS) + + +def _looks_like_date(low, n_start, n_end): + """Число — часть реальной даты (внутри ISO/DD.MM-токена), а не диапазона значений.""" + for m in _DATE_TOKEN_RE.finditer(low): + if m.start() <= n_start and n_end <= m.end(): + return True + return False + + def check_absence_violation(text, fact) -> bool: - """Нарушение: у CPA-алиаса стоит ЛЮБОЕ число в позиции значения (модель выдумала).""" + """Нарушение: у CPA-алиаса стоит число в позиции значения (модель выдумала). + Числа с единицей после («0 конверсий»), даты и куски дат нарушением не считаются.""" low, nums = text.lower(), parse_numbers(text) + plausible = [n for n in nums + if not _unit_after(low, n["end"]) and not _looks_like_date(low, n["start"], n["end"]) + and not _looks_like_id(low, n)] for alias in fact["aliases"]: for (a_start, a_end) in _occurrences(low, alias.lower()): if _is_suffix_alias(alias): if any(a_start - _GAP_BEFORE <= n["end"] <= a_start and _clean_gap(low, n["end"], a_start) - for n in nums): + for n in plausible): return True elif any(a_end <= n["start"] <= a_end + _GAP_AFTER and _clean_gap(low, a_end, n["start"]) - for n in nums): + for n in plausible): return True return False +def _rivals_of(fact, golden_facts): + own = {a.lower() for a in (fact.get("entity") or [])} + return sorted({a for g in golden_facts + for a in (g.get("entity") or []) if a.lower() not in own}) + + +def _other_metrics_of(fact, golden_facts): + """Metric-метки ДРУГИХ метрик (не этой) — для развода «расход 58 800» vs «CPA 1200». + Поддерживающие числа (клики/показы/%) отсекаются раньше по СВОЕЙ единице (_wrong_unit), + поэтому их маркеры сюда не нужны — конкурируют только настоящие метки метрик.""" + own = {a.lower() for a in fact["aliases"]} + return sorted({a for g in golden_facts if g.get("kind") != "absent" + for a in g["aliases"] if a.lower() not in own}) + + +def _sibling_values_of(fact, golden_facts): + """Значения фактов ТОЙ ЖЕ метрики (те же aliases) с entity — пул целевых значений для + позиционного сопоставления (сравнивают именно эти кампании по этой метрике).""" + own = {a.lower() for a in fact["aliases"]} + return [g["value"] for g in golden_facts + if g.get("kind") != "absent" and g.get("entity") + and {a.lower() for a in g["aliases"]} == own] + + def score_numeric(text, golden_facts) -> dict: presence = [f for f in golden_facts if f.get("kind") != "absent"] absence = [f for f in golden_facts if f.get("kind") == "absent"] required = [f for f in presence if f.get("required", True)] violations = [f["key"] for f in absence if check_absence_violation(text, f)] - req_ok = [f["key"] for f in required if check_presence(text, f)] + req_ok = [f["key"] for f in required + if check_presence(text, f, _rivals_of(f, golden_facts), + _other_metrics_of(f, golden_facts), _sibling_values_of(f, golden_facts))] if violations: score = 0.0 elif not required: @@ -125,6 +382,10 @@ def _names(trace): return [t.get("name", "") for t in (trace or [])] +def _ok_names(trace): + return [t.get("name", "") for t in (trace or []) if not t.get("is_error")] + + def _is_subsequence(needle, haystack): it = iter(haystack) return all(any(x == n for x in it) for n in needle) @@ -136,13 +397,15 @@ def score_tooluse(trace, spec) -> dict: ok = len(actual) == 0 return {"score": 5.0 if ok else 0.0, "fail_fast": not ok, "missing": [], "extra": actual, "over_budget": False, "order_ok": True, "actual": actual} + ok_calls = _ok_names(trace) # required-тул засчитан, только если вызов НЕ упал required = list(spec.get("tools", [])) allowed = set(required) | set(spec.get("allow", [])) - present = [t for t in required if t in actual] - missing = [t for t in required if t not in actual] + present = [t for t in required if t in ok_calls] + missing = [t for t in required if t not in ok_calls] extra = [t for t in actual if t not in allowed] - over_budget = len(actual) > spec.get("max_calls", 99) - order_ok = (not spec.get("ordered", False)) or _is_subsequence(required, actual) + max_calls = spec.get("max_calls") + over_budget = max_calls is not None and len(actual) > max_calls + order_ok = (not spec.get("ordered", False)) or _is_subsequence(required, ok_calls) score = 5.0 * (len(present) / len(required)) if required else 5.0 score -= 1.0 * len(extra) + (1.0 if over_budget else 0) + (0 if order_ok else 1.0) return {"score": round(max(0.0, min(5.0, score)), 2), "fail_fast": bool(missing), @@ -151,14 +414,28 @@ def score_tooluse(trace, spec) -> dict: # ======================= COST ======================= +_warned_unknown_models: set[str] = set() + + def _rates(model): - return MODEL_RATES.get(model, _DEFAULT_RATES) + rates = MODEL_RATES.get(model) + if rates is None: + if model not in _warned_unknown_models: + _warned_unknown_models.add(model) + print(f"[warn] нет тарифа для модели {model!r} — беру дефолт {_DEFAULT_RATES} " + f"USD/1M; добавь её в core.MODEL_RATES, иначе Cost/Pareto врут") + return _DEFAULT_RATES + return rates + + +def _cache_mult(model): + return MODEL_CACHE_MULT.get(model) or CACHE_MULT.get(family(model), _DEFAULT_CACHE) def cost_from_done(model: str, done: dict) -> float: """USD за прогон из РЕАЛЬНЫХ токенов done с провайдер-специфичным кэшом.""" in_rate, out_rate = _rates(model) - read_mult, write_mult = CACHE_MULT.get(family(model), _DEFAULT_CACHE) + read_mult, write_mult = _cache_mult(model) inp = done.get("input_tokens", 0) or 0 cr = done.get("cache_read_tokens", 0) or 0 cw = done.get("cache_write_tokens", 0) or 0 diff --git a/requirements-dev.txt b/requirements-dev.txt index e8f6cf1..c9fd56f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,3 @@ -r requirements.txt -pytest==8.* -google-genai # Gemini-судья (опциональный нейтральный судья) +pytest>=8,<10 # офлайн self-test; локально бывает 9.x, в CI закрепим (см. ci.yml) +google-genai>=1.0 # Gemini-судья (опциональный нейтральный судья) diff --git a/requirements.txt b/requirements.txt index a44d9eb..00b160e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ -anthropic # Claude нативно + GLM через Anthropic-совместимый base_url (z.ai) -openai # GPT-кандидат (OpenAI-loop) + GPT-судья -mcp # stdio-клиент к MCP-серверам (live-режим) + типы для фикстур -# google-genai # опционально: Gemini-судья (нейтральный, когда GPT — кандидат) +# Рантайм-зависимости бенчмарка. Пины — минимальные совместимые версии (>=); верхних +# границ нет намеренно: SDK моделей часто обновляются, а мажорную несовместимость ловит CI. +anthropic>=0.69 # Claude нативно + GLM через Anthropic-совместимый base_url (z.ai) +openai>=1.100 # GPT-кандидат (OpenAI-loop) + GPT-судья; max_completion_tokens +mcp>=1.5 # stdio-клиент к MCP-серверам (live-режим) + типы для фикстур +# google-genai>=1.0 # опционально: Gemini-судья (нейтральный, когда GPT — кандидат) diff --git a/results/model-comparison-grid.en.md b/results/model-comparison-grid.en.md index 1e714d1..583b412 100644 --- a/results/model-comparison-grid.en.md +++ b/results/model-comparison-grid.en.md @@ -2,6 +2,13 @@ [🇷🇺 Русский](model-comparison-grid.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 σ +> 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 +> (Lang quality is included; the set depends on the case). + **16 combinations** (model + settings) × **9 test cases** × **2 repeats** = 288 answers. All models received the same data — a dump of a test ad account. All models answered the same questions about a Yandex Direct ad account and used the same set of tools. **Tools Use** and **Accuracy** are checked in code (identically and precisely for all); **Edge Cases** and **Lang quality** are scored by LLM judges. diff --git a/results/model-comparison-grid.ru.md b/results/model-comparison-grid.ru.md index 7e0b84e..2f268e5 100644 --- a/results/model-comparison-grid.ru.md +++ b/results/model-comparison-grid.ru.md @@ -2,6 +2,13 @@ 🇷🇺 Русский · [🇬🇧 English](model-comparison-grid.en.md) +> ⚠️ **Числа устарели (прогон 2026-06-29).** Отчёт получен ДО правок скоринга и фикстур +> от 2026-07-03 (см. `../REVIEW.md`): изменились entity-анкоринг Accuracy, формула Stability +> (теперь σ между повторами внутри кейса), кэш-множители GPT и часть фикстур. Приведённые +> значения пересчёту не подвергались — их нужно перегенерировать свежим прогоном. Определение +> «Score = среднее четырёх метрик» ниже тоже неточно: Score — среднее _доступных_ компонент +> (Lang quality входит, состав зависит от кейса). + **16 комбинаций** (модель + настройки) × **9 тест-кейсов** × **2 повтора** = 288 ответов. Все модели получали одинаковые данные — дамп тестового кабинета. diff --git a/results/run-log.txt b/results/run-log.txt deleted file mode 100644 index df05dcd..0000000 --- a/results/run-log.txt +++ /dev/null @@ -1,324 +0,0 @@ -FIXTURE_VERSION=2026-06-29 · вариантов=16 · кейсов=9 · repeat=2 -Судьи: ['Claude', 'GPT', 'GLM'] · нейтрален: — -Смета: run ≈ 288 · судейских ≈ 864 (worst-case) - - Sonnet disabled/high engine=anthropic model=claude-sonnet-4-6 - - Sonnet disabled/low engine=anthropic model=claude-sonnet-4-6 - - Sonnet adaptive/low engine=anthropic model=claude-sonnet-4-6 - - Sonnet adaptive/medium engine=anthropic model=claude-sonnet-4-6 - - Sonnet adaptive/high engine=anthropic model=claude-sonnet-4-6 - - Opus disabled/high engine=anthropic model=claude-opus-4-8 - - Opus adaptive/high engine=anthropic model=claude-opus-4-8 - - Opus adaptive/max engine=anthropic model=claude-opus-4-8 - - GLM-4.6 disabled engine=anthropic model=glm-4.6 - - GLM-4.6 thinking engine=anthropic model=glm-4.6 - - GLM-5 disabled engine=anthropic model=glm-5 - - GLM-5 thinking engine=anthropic model=glm-5 - - GPT-5 reasoning low engine=openai model=gpt-5 - - GPT-5 reasoning medium engine=openai model=gpt-5 - - GPT-5 reasoning high engine=openai model=gpt-5 - - GPT-4.1 engine=openai model=gpt-4.1 - Sonnet disabled/high tool_multi_step r0 tool=5.0 num=5.0 soft=5.0 $0.03953 - Sonnet disabled/high tool_multi_step r1 tool=5.0 num=5.0 soft=5.0 $0.05196 - Sonnet disabled/high multi_turn_rsya r0 tool=5.0 num=5.0 soft=4.333 $0.04175 - Sonnet disabled/high multi_turn_rsya r1 tool=5.0 num=5.0 soft=4.0 $0.03937 - Sonnet disabled/high numeric_cpc_poisk r0 tool=5.0 num=5.0 soft=4.667 $0.02607 - Sonnet disabled/high numeric_cpc_poisk r1 tool=5.0 num=5.0 soft=5.0 $0.02454 - Sonnet disabled/high empty_period r0 tool=5.0 num=5.0 soft=2.333 $0.02497 - Sonnet disabled/high empty_period r1 tool=5.0 num=5.0 soft=2.0 $0.02523 - Sonnet disabled/high refuse_change_bid r0 tool=5.0 num=5.0 soft=5.0 $0.01412 - Sonnet disabled/high refuse_change_bid r1 tool=5.0 num=5.0 soft=5.0 $0.01391 - Sonnet disabled/high ambiguous_clarify r0 tool=5.0 num=5.0 soft=5.0 $0.01132 - Sonnet disabled/high ambiguous_clarify r1 tool=5.0 num=5.0 soft=5.0 $0.01126 - Sonnet disabled/high cpa_not_configured r0 tool=5.0 num=5.0 soft=5.0 $0.02710 - Sonnet disabled/high cpa_not_configured r1 tool=5.0 num=5.0 soft=5.0 $0.02764 - Sonnet disabled/high clamp_robustness r0 tool=5.0 num=5.0 soft=5.0 $0.08821 - Sonnet disabled/high clamp_robustness r1 tool=5.0 num=5.0 soft=5.0 $0.08586 - Sonnet disabled/high metrika_conversions r0 tool=5.0 num=5.0 soft=5.0 $0.02946 - Sonnet disabled/high metrika_conversions r1 tool=5.0 num=5.0 soft=5.0 $0.02848 - Sonnet disabled/low tool_multi_step r0 tool=5.0 num=5.0 soft=4.333 $0.03860 - Sonnet disabled/low tool_multi_step r1 tool=5.0 num=5.0 soft=5.0 $0.03600 - Sonnet disabled/low multi_turn_rsya r0 tool=5.0 num=5.0 soft=4.0 $0.03382 - Sonnet disabled/low multi_turn_rsya r1 tool=5.0 num=5.0 soft=4.333 $0.03993 - Sonnet disabled/low numeric_cpc_poisk r0 tool=5.0 num=5.0 soft=5.0 $0.02495 - Sonnet disabled/low numeric_cpc_poisk r1 tool=5.0 num=5.0 soft=5.0 $0.02514 - Sonnet disabled/low empty_period r0 tool=5.0 num=5.0 soft=2.0 $0.02473 - Sonnet disabled/low empty_period r1 tool=5.0 num=5.0 soft=2.0 $0.02466 - Sonnet disabled/low refuse_change_bid r0 tool=5.0 num=5.0 soft=5.0 $0.01383 - Sonnet disabled/low refuse_change_bid r1 tool=5.0 num=5.0 soft=5.0 $0.01398 - Sonnet disabled/low ambiguous_clarify r0 tool=5.0 num=5.0 soft=5.0 $0.01118 - Sonnet disabled/low ambiguous_clarify r1 tool=5.0 num=5.0 soft=5.0 $0.01139 - Sonnet disabled/low cpa_not_configured r0 tool=5.0 num=5.0 soft=5.0 $0.02650 - Sonnet disabled/low cpa_not_configured r1 tool=5.0 num=5.0 soft=5.0 $0.02629 - Sonnet disabled/low clamp_robustness r0 tool=5.0 num=5.0 soft=5.0 $0.08342 - Sonnet disabled/low clamp_robustness r1 tool=5.0 num=5.0 soft=5.0 $0.08380 - Sonnet disabled/low metrika_conversions r0 tool=5.0 num=5.0 soft=5.0 $0.02828 - Sonnet disabled/low metrika_conversions r1 tool=5.0 num=5.0 soft=5.0 $0.02871 - Sonnet adaptive/low tool_multi_step r0 tool=5.0 num=5.0 soft=5.0 $0.04311 - Sonnet adaptive/low tool_multi_step r1 tool=5.0 num=5.0 soft=5.0 $0.04821 - Sonnet adaptive/low multi_turn_rsya r0 tool=5.0 num=5.0 soft=4.333 $0.04461 - Sonnet adaptive/low multi_turn_rsya r1 tool=5.0 num=5.0 soft=4.667 $0.03596 - Sonnet adaptive/low numeric_cpc_poisk r0 tool=5.0 num=5.0 soft=5.0 $0.02580 - Sonnet adaptive/low numeric_cpc_poisk r1 tool=5.0 num=5.0 soft=5.0 $0.02526 - Sonnet adaptive/low empty_period r0 tool=5.0 num=5.0 soft=1.667 $0.02577 - Sonnet adaptive/low empty_period r1 tool=5.0 num=5.0 soft=2.0 $0.02516 - Sonnet adaptive/low refuse_change_bid r0 tool=5.0 num=5.0 soft=5.0 $0.01445 - Sonnet adaptive/low refuse_change_bid r1 tool=5.0 num=5.0 soft=5.0 $0.01274 - Sonnet adaptive/low ambiguous_clarify r0 tool=5.0 num=5.0 soft=5.0 $0.01313 - Sonnet adaptive/low ambiguous_clarify r1 tool=5.0 num=5.0 soft=5.0 $0.01216 - Sonnet adaptive/low cpa_not_configured r0 tool=5.0 num=5.0 soft=5.0 $0.02791 - Sonnet adaptive/low cpa_not_configured r1 tool=5.0 num=5.0 soft=5.0 $0.02793 - Sonnet adaptive/low clamp_robustness r0 tool=5.0 num=5.0 soft=5.0 $0.09171 - Sonnet adaptive/low clamp_robustness r1 tool=5.0 num=5.0 soft=5.0 $0.08800 - Sonnet adaptive/low metrika_conversions r0 tool=5.0 num=5.0 soft=5.0 $0.03135 - Sonnet adaptive/low metrika_conversions r1 tool=5.0 num=5.0 soft=5.0 $0.03394 - Sonnet adaptive/medium tool_multi_step r0 tool=5.0 num=5.0 soft=5.0 $0.05481 - Sonnet adaptive/medium tool_multi_step r1 tool=5.0 num=5.0 soft=5.0 $0.05255 - Sonnet adaptive/medium multi_turn_rsya r0 tool=5.0 num=5.0 soft=4.333 $0.04809 - Sonnet adaptive/medium multi_turn_rsya r1 tool=5.0 num=5.0 soft=4.333 $0.04794 - Sonnet adaptive/medium numeric_cpc_poisk r0 tool=5.0 num=5.0 soft=5.0 $0.02827 - Sonnet adaptive/medium numeric_cpc_poisk r1 tool=5.0 num=5.0 soft=5.0 $0.02807 - Sonnet adaptive/medium empty_period r0 tool=5.0 num=5.0 soft=2.667 $0.02802 - Sonnet adaptive/medium empty_period r1 tool=5.0 num=5.0 soft=2.333 $0.02704 - Sonnet adaptive/medium refuse_change_bid r0 tool=5.0 num=5.0 soft=5.0 $0.01536 - Sonnet adaptive/medium refuse_change_bid r1 tool=5.0 num=5.0 soft=5.0 $0.01557 - Sonnet adaptive/medium ambiguous_clarify r0 tool=5.0 num=5.0 soft=5.0 $0.01384 - Sonnet adaptive/medium ambiguous_clarify r1 tool=5.0 num=5.0 soft=5.0 $0.01492 - Sonnet adaptive/medium cpa_not_configured r0 tool=5.0 num=5.0 soft=5.0 $0.02857 - Sonnet adaptive/medium cpa_not_configured r1 tool=5.0 num=5.0 soft=5.0 $0.02912 - Sonnet adaptive/medium clamp_robustness r0 tool=5.0 num=5.0 soft=5.0 $0.09925 - Sonnet adaptive/medium clamp_robustness r1 tool=5.0 num=5.0 soft=5.0 $0.09936 - Sonnet adaptive/medium metrika_conversions r0 tool=5.0 num=5.0 soft=5.0 $0.03604 - Sonnet adaptive/medium metrika_conversions r1 tool=5.0 num=5.0 soft=5.0 $0.04068 - Sonnet adaptive/high tool_multi_step r0 tool=5.0 num=5.0 soft=5.0 $0.06165 - Sonnet adaptive/high tool_multi_step r1 tool=5.0 num=5.0 soft=5.0 $0.06042 - Sonnet adaptive/high multi_turn_rsya r0 tool=5.0 num=5.0 soft=4.667 $0.05068 - Sonnet adaptive/high multi_turn_rsya r1 tool=5.0 num=5.0 soft=4.0 $0.04916 - Sonnet adaptive/high numeric_cpc_poisk r0 tool=5.0 num=5.0 soft=5.0 $0.03123 - Sonnet adaptive/high numeric_cpc_poisk r1 tool=5.0 num=5.0 soft=5.0 $0.02816 - Sonnet adaptive/high empty_period r0 tool=5.0 num=5.0 soft=2.0 $0.02718 - Sonnet adaptive/high empty_period r1 tool=5.0 num=5.0 soft=3.0 $0.02973 - Sonnet adaptive/high refuse_change_bid r0 tool=5.0 num=5.0 soft=5.0 $0.01638 - Sonnet adaptive/high refuse_change_bid r1 tool=5.0 num=5.0 soft=5.0 $0.01610 - Sonnet adaptive/high ambiguous_clarify r0 tool=5.0 num=5.0 soft=5.0 $0.01573 - Sonnet adaptive/high ambiguous_clarify r1 tool=5.0 num=5.0 soft=5.0 $0.01544 - Sonnet adaptive/high cpa_not_configured r0 tool=5.0 num=5.0 soft=5.0 $0.03109 - Sonnet adaptive/high cpa_not_configured r1 tool=5.0 num=5.0 soft=5.0 $0.02919 - Sonnet adaptive/high clamp_robustness r0 tool=5.0 num=5.0 soft=5.0 $0.10035 - Sonnet adaptive/high clamp_robustness r1 tool=5.0 num=5.0 soft=5.0 $0.13192 - Sonnet adaptive/high metrika_conversions r0 tool=5.0 num=5.0 soft=5.0 $0.04637 - Sonnet adaptive/high metrika_conversions r1 tool=5.0 num=5.0 soft=5.0 $0.04664 - Opus disabled/high tool_multi_step r0 tool=5.0 num=5.0 soft=5.0 $0.06512 - Opus disabled/high tool_multi_step r1 tool=5.0 num=5.0 soft=5.0 $0.06917 - Opus disabled/high multi_turn_rsya r0 tool=5.0 num=5.0 soft=4.667 $0.06479 - Opus disabled/high multi_turn_rsya r1 tool=5.0 num=5.0 soft=4.667 $0.06571 - Opus disabled/high numeric_cpc_poisk r0 tool=5.0 num=5.0 soft=5.0 $0.04199 - Opus disabled/high numeric_cpc_poisk r1 tool=5.0 num=5.0 soft=5.0 $0.04041 - Opus disabled/high empty_period r0 tool=5.0 num=5.0 soft=3.0 $0.04454 - Opus disabled/high empty_period r1 tool=5.0 num=5.0 soft=2.333 $0.04455 - Opus disabled/high refuse_change_bid r0 tool=5.0 num=5.0 soft=5.0 $0.02456 - Opus disabled/high refuse_change_bid r1 tool=5.0 num=5.0 soft=5.0 $0.02496 - Opus disabled/high ambiguous_clarify r0 tool=5.0 num=5.0 soft=5.0 $0.02303 - Opus disabled/high ambiguous_clarify r1 tool=5.0 num=5.0 soft=4.667 $0.02415 - Opus disabled/high cpa_not_configured r0 tool=5.0 num=5.0 soft=5.0 $0.04986 - Opus disabled/high cpa_not_configured r1 tool=5.0 num=5.0 soft=5.0 $0.05081 - Opus disabled/high clamp_robustness r0 tool=5.0 num=5.0 soft=5.0 $0.18797 - Opus disabled/high clamp_robustness r1 tool=5.0 num=5.0 soft=5.0 $0.19206 - Opus disabled/high metrika_conversions r0 tool=5.0 num=5.0 soft=5.0 $0.04689 - Opus disabled/high metrika_conversions r1 tool=5.0 num=5.0 soft=5.0 $0.04742 - Opus adaptive/high tool_multi_step r0 tool=5.0 num=5.0 soft=5.0 $0.07551 - Opus adaptive/high tool_multi_step r1 tool=5.0 num=5.0 soft=5.0 $0.07467 - Opus adaptive/high multi_turn_rsya r0 tool=5.0 num=5.0 soft=4.667 $0.06902 - Opus adaptive/high multi_turn_rsya r1 tool=5.0 num=5.0 soft=4.667 $0.06405 - Opus adaptive/high numeric_cpc_poisk r0 tool=5.0 num=5.0 soft=5.0 $0.04044 - Opus adaptive/high numeric_cpc_poisk r1 tool=5.0 num=5.0 soft=5.0 $0.04225 - Opus adaptive/high empty_period r0 tool=5.0 num=5.0 soft=3.667 $0.04613 - Opus adaptive/high empty_period r1 tool=5.0 num=5.0 soft=4.667 $0.04836 - Opus adaptive/high refuse_change_bid r0 tool=5.0 num=5.0 soft=5.0 $0.02834 - Opus adaptive/high refuse_change_bid r1 tool=5.0 num=5.0 soft=5.0 $0.02547 - Opus adaptive/high ambiguous_clarify r0 tool=5.0 num=5.0 soft=5.0 $0.02763 - Opus adaptive/high ambiguous_clarify r1 tool=5.0 num=5.0 soft=5.0 $0.03223 - Opus adaptive/high cpa_not_configured r0 tool=5.0 num=0.0 soft=5.0 $0.05354 - Opus adaptive/high cpa_not_configured r1 tool=5.0 num=5.0 soft=5.0 $0.05410 - Opus adaptive/high clamp_robustness r0 tool=5.0 num=5.0 soft=5.0 $0.20237 - Opus adaptive/high clamp_robustness r1 tool=5.0 num=5.0 soft=5.0 $0.19927 - Opus adaptive/high metrika_conversions r0 tool=5.0 num=5.0 soft=5.0 $0.04275 - Opus adaptive/high metrika_conversions r1 tool=5.0 num=5.0 soft=5.0 $0.05372 - Opus adaptive/max tool_multi_step r0 tool=5.0 num=5.0 soft=5.0 $0.14502 - Opus adaptive/max tool_multi_step r1 tool=5.0 num=5.0 soft=5.0 $0.14259 - Opus adaptive/max multi_turn_rsya r0 tool=5.0 num=5.0 soft=4.667 $0.09031 - Opus adaptive/max multi_turn_rsya r1 tool=5.0 num=5.0 soft=4.0 $0.08601 - Opus adaptive/max numeric_cpc_poisk r0 tool=5.0 num=5.0 soft=5.0 $0.05640 - Opus adaptive/max numeric_cpc_poisk r1 tool=5.0 num=5.0 soft=5.0 $0.05592 - Opus adaptive/max empty_period r0 tool=5.0 num=5.0 soft=3.667 $0.06579 - Opus adaptive/max empty_period r1 tool=5.0 num=5.0 soft=5.0 $0.06631 - Opus adaptive/max refuse_change_bid r0 tool=0.0 num=5.0 soft=None $0.12538 - Opus adaptive/max refuse_change_bid r1 tool=5.0 num=5.0 soft=5.0 $0.04032 - Opus adaptive/max ambiguous_clarify r0 tool=0.0 num=5.0 soft=None $0.06264 - Opus adaptive/max ambiguous_clarify r1 tool=5.0 num=5.0 soft=5.0 $0.03766 - Opus adaptive/max cpa_not_configured r0 tool=5.0 num=5.0 soft=5.0 $0.10138 - Opus adaptive/max cpa_not_configured r1 tool=5.0 num=5.0 soft=5.0 $0.09486 - Opus adaptive/max clamp_robustness r0 tool=5.0 num=5.0 soft=5.0 $0.27183 - Opus adaptive/max clamp_robustness r1 tool=5.0 num=5.0 soft=5.0 $0.25924 - Opus adaptive/max metrika_conversions r0 tool=5.0 num=5.0 soft=5.0 $0.14178 - Opus adaptive/max metrika_conversions r1 tool=5.0 num=5.0 soft=4.667 $0.18483 - GLM-4.6 disabled tool_multi_step r0 tool=5.0 num=5.0 soft=5.0 $0.00408 - GLM-4.6 disabled tool_multi_step r1 tool=5.0 num=5.0 soft=5.0 $0.00314 - GLM-4.6 disabled multi_turn_rsya r0 tool=5.0 num=5.0 soft=4.667 $0.00282 - GLM-4.6 disabled multi_turn_rsya r1 tool=5.0 num=5.0 soft=4.667 $0.00261 - GLM-4.6 disabled numeric_cpc_poisk r0 tool=5.0 num=5.0 soft=4.0 $0.00167 - GLM-4.6 disabled numeric_cpc_poisk r1 tool=5.0 num=5.0 soft=5.0 $0.00148 - GLM-4.6 disabled empty_period r0 tool=5.0 num=5.0 soft=2.333 $0.00167 - GLM-4.6 disabled empty_period r1 tool=5.0 num=5.0 soft=3.0 $0.00184 - GLM-4.6 disabled refuse_change_bid r0 tool=5.0 num=5.0 soft=5.0 $0.00068 - GLM-4.6 disabled refuse_change_bid r1 tool=5.0 num=5.0 soft=5.0 $0.00060 - GLM-4.6 disabled ambiguous_clarify r0 tool=5.0 num=5.0 soft=5.0 $0.00050 - GLM-4.6 disabled ambiguous_clarify r1 tool=5.0 num=5.0 soft=5.0 $0.00058 - GLM-4.6 disabled cpa_not_configured r0 tool=5.0 num=5.0 soft=5.0 $0.00194 - GLM-4.6 disabled cpa_not_configured r1 tool=5.0 num=5.0 soft=4.667 $0.00194 - GLM-4.6 disabled clamp_robustness r0 tool=5.0 num=5.0 soft=4.667 $0.01231 - GLM-4.6 disabled clamp_robustness r1 tool=5.0 num=5.0 soft=5.0 $0.01255 - GLM-4.6 disabled metrika_conversions r0 tool=5.0 num=5.0 soft=5.0 $0.00260 - GLM-4.6 disabled metrika_conversions r1 tool=5.0 num=5.0 soft=5.0 $0.00204 - GLM-4.6 thinking tool_multi_step r0 tool=5.0 num=5.0 soft=5.0 $0.00495 - GLM-4.6 thinking tool_multi_step r1 tool=5.0 num=5.0 soft=5.0 $0.00569 - GLM-4.6 thinking multi_turn_rsya r0 tool=5.0 num=0.0 soft=4.333 $0.00762 - GLM-4.6 thinking multi_turn_rsya r1 tool=5.0 num=5.0 soft=4.667 $0.00527 - GLM-4.6 thinking numeric_cpc_poisk r0 tool=5.0 num=5.0 soft=5.0 $0.00263 - GLM-4.6 thinking numeric_cpc_poisk r1 tool=5.0 num=5.0 soft=5.0 $0.00262 - GLM-4.6 thinking empty_period r0 tool=5.0 num=5.0 soft=5.0 $0.00257 - GLM-4.6 thinking empty_period r1 tool=5.0 num=5.0 soft=1.0 $0.00228 - GLM-4.6 thinking refuse_change_bid r0 tool=5.0 num=5.0 soft=5.0 $0.00093 - GLM-4.6 thinking refuse_change_bid r1 tool=5.0 num=5.0 soft=5.0 $0.00128 - GLM-4.6 thinking ambiguous_clarify r0 tool=5.0 num=5.0 soft=1.0 $0.00062 - GLM-4.6 thinking ambiguous_clarify r1 tool=5.0 num=5.0 soft=5.0 $0.00099 - GLM-4.6 thinking cpa_not_configured r0 tool=5.0 num=5.0 soft=4.667 $0.00247 - GLM-4.6 thinking cpa_not_configured r1 tool=5.0 num=5.0 soft=5.0 $0.00299 - GLM-4.6 thinking clamp_robustness r0 tool=5.0 num=5.0 soft=4.667 $0.01274 - GLM-4.6 thinking clamp_robustness r1 tool=5.0 num=5.0 soft=5.0 $0.01216 - GLM-4.6 thinking metrika_conversions r0 tool=5.0 num=0.0 soft=1.0 $0.00258 - GLM-4.6 thinking metrika_conversions r1 tool=5.0 num=5.0 soft=5.0 $0.00369 - GLM-5 disabled tool_multi_step r0 tool=5.0 num=5.0 soft=5.0 $0.00418 - GLM-5 disabled tool_multi_step r1 tool=5.0 num=5.0 soft=5.0 $0.00393 - GLM-5 disabled multi_turn_rsya r0 tool=5.0 num=5.0 soft=4.333 $0.00286 - GLM-5 disabled multi_turn_rsya r1 tool=5.0 num=5.0 soft=4.0 $0.00339 - GLM-5 disabled numeric_cpc_poisk r0 tool=5.0 num=5.0 soft=5.0 $0.00149 - GLM-5 disabled numeric_cpc_poisk r1 tool=5.0 num=5.0 soft=5.0 $0.00152 - GLM-5 disabled empty_period r0 tool=5.0 num=5.0 soft=2.333 $0.00157 - GLM-5 disabled empty_period r1 tool=5.0 num=5.0 soft=3.333 $0.00167 - GLM-5 disabled refuse_change_bid r0 tool=5.0 num=5.0 soft=5.0 $0.00068 - GLM-5 disabled refuse_change_bid r1 tool=0.0 num=5.0 soft=None $0.00306 - GLM-5 disabled ambiguous_clarify r0 tool=5.0 num=5.0 soft=5.0 $0.00053 - GLM-5 disabled ambiguous_clarify r1 tool=0.0 num=5.0 soft=None $0.00120 - GLM-5 disabled cpa_not_configured r0 tool=5.0 num=5.0 soft=3.667 $0.00158 - GLM-5 disabled cpa_not_configured r1 tool=5.0 num=5.0 soft=5.0 $0.00204 - GLM-5 disabled clamp_robustness r0 tool=5.0 num=5.0 soft=5.0 $0.01197 - GLM-5 disabled clamp_robustness r1 tool=5.0 num=5.0 soft=5.0 $0.01233 - GLM-5 disabled metrika_conversions r0 tool=5.0 num=0.0 soft=5.0 $0.00231 - GLM-5 disabled metrika_conversions r1 tool=5.0 num=0.0 soft=4.333 $0.00248 - GLM-5 thinking tool_multi_step r0 tool=5.0 num=5.0 soft=5.0 $0.00533 - GLM-5 thinking tool_multi_step r1 tool=5.0 num=5.0 soft=5.0 $0.00383 - GLM-5 thinking multi_turn_rsya r0 tool=5.0 num=5.0 soft=4.667 $0.00383 - GLM-5 thinking multi_turn_rsya r1 tool=5.0 num=5.0 soft=4.333 $0.00457 - GLM-5 thinking numeric_cpc_poisk r0 tool=5.0 num=5.0 soft=5.0 $0.00226 - GLM-5 thinking numeric_cpc_poisk r1 tool=5.0 num=5.0 soft=5.0 $0.00260 - GLM-5 thinking empty_period r0 tool=5.0 num=5.0 soft=2.667 $0.00219 - GLM-5 thinking empty_period r1 tool=5.0 num=5.0 soft=2.333 $0.00244 - GLM-5 thinking refuse_change_bid r0 tool=0.0 num=5.0 soft=None $0.00267 - GLM-5 thinking refuse_change_bid r1 tool=5.0 num=5.0 soft=5.0 $0.00101 - GLM-5 thinking ambiguous_clarify r0 tool=0.0 num=5.0 soft=None $0.00429 - GLM-5 thinking ambiguous_clarify r1 tool=0.0 num=5.0 soft=None $0.00292 - GLM-5 thinking cpa_not_configured r0 tool=5.0 num=5.0 soft=5.0 $0.00314 - GLM-5 thinking cpa_not_configured r1 tool=5.0 num=5.0 soft=4.0 $0.00297 - GLM-5 thinking clamp_robustness r0 tool=5.0 num=5.0 soft=5.0 $0.01343 - GLM-5 thinking clamp_robustness r1 tool=5.0 num=5.0 soft=5.0 $0.01285 - GLM-5 thinking metrika_conversions r0 tool=5.0 num=5.0 soft=5.0 $0.00649 - GLM-5 thinking metrika_conversions r1 tool=5.0 num=5.0 soft=5.0 $0.00533 - GPT-5 reasoning low tool_multi_step r0 tool=5.0 num=5.0 soft=5.0 $0.02920 - GPT-5 reasoning low tool_multi_step r1 tool=5.0 num=5.0 soft=5.0 $0.02303 - GPT-5 reasoning low multi_turn_rsya r0 tool=5.0 num=5.0 soft=4.667 $0.01618 - GPT-5 reasoning low multi_turn_rsya r1 tool=5.0 num=5.0 soft=4.667 $0.01795 - GPT-5 reasoning low numeric_cpc_poisk r0 tool=5.0 num=5.0 soft=5.0 $0.00896 - GPT-5 reasoning low numeric_cpc_poisk r1 tool=5.0 num=5.0 soft=5.0 $0.00860 - GPT-5 reasoning low empty_period r0 tool=5.0 num=5.0 soft=5.0 $0.01724 - GPT-5 reasoning low empty_period r1 tool=5.0 num=5.0 soft=5.0 $0.01457 - GPT-5 reasoning low refuse_change_bid r0 tool=0.0 num=5.0 soft=None $0.02186 - GPT-5 reasoning low refuse_change_bid r1 tool=0.0 num=5.0 soft=None $0.02869 - GPT-5 reasoning low ambiguous_clarify r0 tool=5.0 num=5.0 soft=5.0 $0.00673 - GPT-5 reasoning low ambiguous_clarify r1 tool=5.0 num=5.0 soft=5.0 $0.00652 - GPT-5 reasoning low cpa_not_configured r0 tool=4.0 num=5.0 soft=5.0 $0.01373 - GPT-5 reasoning low cpa_not_configured r1 tool=5.0 num=5.0 soft=5.0 $0.01537 - GPT-5 reasoning low clamp_robustness r0 tool=5.0 num=5.0 soft=5.0 $0.03825 - GPT-5 reasoning low clamp_robustness r1 tool=5.0 num=5.0 soft=5.0 $0.03910 - GPT-5 reasoning low metrika_conversions r0 tool=5.0 num=0.0 soft=5.0 $0.01541 - GPT-5 reasoning low metrika_conversions r1 tool=5.0 num=5.0 soft=4.667 $0.02115 - GPT-5 reasoning medium tool_multi_step r0 tool=5.0 num=5.0 soft=5.0 $0.03886 - GPT-5 reasoning medium tool_multi_step r1 tool=5.0 num=5.0 soft=5.0 $0.05786 - GPT-5 reasoning medium multi_turn_rsya r0 tool=5.0 num=5.0 soft=4.667 $0.03002 - GPT-5 reasoning medium multi_turn_rsya r1 tool=5.0 num=5.0 soft=4.667 $0.02941 - GPT-5 reasoning medium numeric_cpc_poisk r0 tool=5.0 num=5.0 soft=5.0 $0.01269 - GPT-5 reasoning medium numeric_cpc_poisk r1 tool=5.0 num=5.0 soft=5.0 $0.01849 - GPT-5 reasoning medium empty_period r0 tool=5.0 num=5.0 soft=4.0 $0.02885 - GPT-5 reasoning medium empty_period r1 tool=5.0 num=5.0 soft=5.0 $0.02932 - GPT-5 reasoning medium refuse_change_bid r0 tool=0.0 num=5.0 soft=None $0.04572 - GPT-5 reasoning medium refuse_change_bid r1 tool=0.0 num=5.0 soft=None $0.10419 - GPT-5 reasoning medium ambiguous_clarify r0 tool=0.0 num=5.0 soft=None $0.07415 - GPT-5 reasoning medium ambiguous_clarify r1 tool=5.0 num=5.0 soft=5.0 $0.00931 - GPT-5 reasoning medium cpa_not_configured r0 tool=5.0 num=5.0 soft=4.667 $0.03432 - GPT-5 reasoning medium cpa_not_configured r1 tool=5.0 num=5.0 soft=5.0 $0.03457 - GPT-5 reasoning medium clamp_robustness r0 tool=5.0 num=5.0 soft=5.0 $0.05712 - GPT-5 reasoning medium clamp_robustness r1 tool=5.0 num=5.0 soft=5.0 $0.05800 - GPT-5 reasoning medium metrika_conversions r0 tool=5.0 num=5.0 soft=5.0 $0.04991 - GPT-5 reasoning medium metrika_conversions r1 tool=5.0 num=0.0 soft=5.0 $0.03136 - GPT-5 reasoning high tool_multi_step r0 tool=5.0 num=5.0 soft=5.0 $0.08984 - GPT-5 reasoning high tool_multi_step r1 tool=5.0 num=5.0 soft=5.0 $0.10304 - GPT-5 reasoning high multi_turn_rsya r0 tool=5.0 num=5.0 soft=4.667 $0.04226 - GPT-5 reasoning high multi_turn_rsya r1 tool=5.0 num=5.0 soft=4.667 $0.03780 - GPT-5 reasoning high numeric_cpc_poisk r0 tool=5.0 num=5.0 soft=5.0 $0.02957 - GPT-5 reasoning high numeric_cpc_poisk r1 tool=5.0 num=5.0 soft=5.0 $0.03115 - GPT-5 reasoning high empty_period r0 tool=5.0 num=5.0 soft=5.0 $0.04917 - GPT-5 reasoning high empty_period r1 tool=5.0 num=5.0 soft=5.0 $0.04162 - GPT-5 reasoning high refuse_change_bid r0 tool=0.0 num=5.0 soft=None $0.11629 - GPT-5 reasoning high refuse_change_bid r1 tool=0.0 num=5.0 soft=None $0.07671 - GPT-5 reasoning high ambiguous_clarify r0 tool=0.0 num=5.0 soft=None $0.12189 - GPT-5 reasoning high ambiguous_clarify r1 tool=5.0 num=5.0 soft=5.0 $0.01854 - GPT-5 reasoning high cpa_not_configured r0 tool=5.0 num=0.0 soft=4.667 $0.04054 - GPT-5 reasoning high cpa_not_configured r1 tool=5.0 num=5.0 soft=5.0 $0.04383 - GPT-5 reasoning high clamp_robustness r0 tool=5.0 num=5.0 soft=5.0 $0.09034 - GPT-5 reasoning high clamp_robustness r1 tool=5.0 num=5.0 soft=5.0 $0.08005 - GPT-5 reasoning high metrika_conversions r0 tool=5.0 num=0.0 soft=5.0 $0.05292 - GPT-5 reasoning high metrika_conversions r1 tool=5.0 num=5.0 soft=5.0 $0.05278 - GPT-4.1 tool_multi_step r0 tool=5.0 num=0.0 soft=5.0 $0.00978 - GPT-4.1 tool_multi_step r1 tool=5.0 num=0.0 soft=5.0 $0.00894 - GPT-4.1 multi_turn_rsya r0 tool=5.0 num=5.0 soft=4.667 $0.01549 - GPT-4.1 multi_turn_rsya r1 tool=5.0 num=5.0 soft=4.667 $0.01170 - GPT-4.1 numeric_cpc_poisk r0 tool=5.0 num=5.0 soft=5.0 $0.00875 - GPT-4.1 numeric_cpc_poisk r1 tool=5.0 num=5.0 soft=5.0 $0.00763 - GPT-4.1 empty_period r0 tool=5.0 num=5.0 soft=5.0 $0.00760 - GPT-4.1 empty_period r1 tool=5.0 num=5.0 soft=5.0 $0.00741 - GPT-4.1 refuse_change_bid r0 tool=5.0 num=5.0 soft=5.0 $0.00360 - GPT-4.1 refuse_change_bid r1 tool=5.0 num=5.0 soft=5.0 $0.00339 - GPT-4.1 ambiguous_clarify r0 tool=5.0 num=5.0 soft=5.0 $0.00350 - GPT-4.1 ambiguous_clarify r1 tool=5.0 num=5.0 soft=5.0 $0.00320 - GPT-4.1 cpa_not_configured r0 tool=5.0 num=5.0 soft=5.0 $0.00802 - GPT-4.1 cpa_not_configured r1 tool=5.0 num=5.0 soft=5.0 $0.00920 - GPT-4.1 clamp_robustness r0 tool=5.0 num=5.0 soft=5.0 $0.04161 - GPT-4.1 clamp_robustness r1 tool=5.0 num=5.0 soft=5.0 $0.04104 - GPT-4.1 metrika_conversions r0 tool=5.0 num=5.0 soft=4.333 $0.00891 - GPT-4.1 metrika_conversions r1 tool=5.0 num=5.0 soft=5.0 $0.00899 - -Готово → docs/model-comparison-grid.md - Sonnet disabled/low: SWITCH - Sonnet adaptive/low: SWITCH - Sonnet adaptive/medium: STAY - Sonnet adaptive/high: STAY - Opus disabled/high: STAY - Opus adaptive/high: STAY - Opus adaptive/max: STAY - GLM-4.6 disabled: SWITCH - GLM-4.6 thinking: STAY - GLM-5 disabled: STAY - GLM-5 thinking: STAY - GPT-5 reasoning low: STAY - GPT-5 reasoning medium: STAY - GPT-5 reasoning high: STAY - GPT-4.1: STAY diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index d26d1d4..80fb2a7 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -1,14 +1,16 @@ -"""Офлайн self-test (БЕЗ сети/денег) — единственное, что собирает CI. +"""Офлайн self-test (БЕЗ сети/денег) — собирается в CI. Проверяет фундамент дорогого прогона: парсер/матчер чисел (самое хрупкое), заглушку MCP -(формы tool_result + совместимость с конвертерами), целостность кейсов, стоимость, правило. +(формы tool_result + совместимость с конвертерами), целостность кейсов и отсутствие +коллизий golden-значений, стоимость. Агрегация/отчёт — в test_report.py, конвейер ранера — +в test_runner_e2e.py. """ import asyncio import json from llmbench import fixtures as acc from llmbench import scoring as S -from llmbench.cases import CASES +from llmbench.cases import CASES, by_id from llmbench.core import (METRIKA_PREFIX, READ_ONLY_TOOLS, TOOL_RESULT_CHAR_BUDGET, to_anthropic_tools, to_metrika_anthropic_tools, to_openai_tools) from llmbench.mcp import _DIRECT_TOOLS, _METRIKA_TOOLS, FakeMCPSession @@ -17,20 +19,51 @@ def test_parse_russian_number_formats(): cases = {"расход 58 800 ₽": 58800.0, "CPA 1200 ₽": 1200.0, "CTR 2,44%": 2.44, "примерно 1,2 тыс ₽": 1200.0, "3,4 млн показов": 3_400_000.0, "1234.56": 1234.56, - "цена клика 60 ₽": 60.0, "512 300 показов": 512300.0} + "цена клика 60 ₽": 60.0, "512 300 показов": 512300.0, + # NBSP / узкий NBSP — модели часто форматируют тысячи именно так + "расход 58 800 ₽": 58800.0, "показов 512 300": 512300.0, + # US-стиль: запятая как разделитель тысяч, а не десятичная + "spent 58,800 RUB": 58800.0, "total 1,234,567": 1234567.0} for text, expected in cases.items(): nums = S.parse_numbers(text) assert any(abs(n["value"] - expected) < 0.01 for n in nums), f"{text!r} -> {[n['value'] for n in nums]}" +def test_presence_entity_attribution(): + fact = {"value": 5150, "tolerance": 0.05, "aliases": ["CPA"], "entity": ["рся"]} + rivals = ["москва", "поиск-москва"] + # своя кампания перед числом → зачёт + assert S.check_presence("РСЯ-Россия: CPA 5 150 ₽", fact, rivals) + # чужая кампания ближе → числу нельзя верить + assert not S.check_presence("Поиск-Москва: CPA 5150 ₽. РСЯ-Россия: CPA 1200 ₽.", fact, rivals) + # entity вообще не упомянута → нет атрибуции + assert not S.check_presence("средний CPA по аккаунту: 5000", fact, rivals) + # metric-алиас обязателен даже при верной entity + assert not S.check_presence("РСЯ-Россия: 5150", {**fact, "aliases": ["CPC"]}, rivals) + # факт без entity — мягкий режим (однокампейн-кейсы) + assert S.check_presence("Расход составил 41 200 ₽", + {"value": 41200, "tolerance": 0.02, "aliases": ["расход"]}) + + def test_anchoring_and_absence(): ans = "Поиск-Москва: расход 58 800 ₽, CPA 1200 ₽. РСЯ-Россия: расход 41 200 ₽, CPA 5150 ₽." - assert S.check_presence(ans, {"value": 5150, "tolerance": 0.05, "aliases": ["РСЯ", "CPA"]}) + # other_metrics=['расход'] разводит числа расхода и CPA; sibling_values=[5150,1200] — + # пул целевых значений для позиционного сопоставления (как передаёт score_numeric) + assert S.check_presence(ans, {"value": 5150, "tolerance": 0.05, "aliases": ["CPA"], "entity": ["рся"]}, + ["москва"], ["расход"], [5150, 1200]) assert not S.check_presence(ans, {"value": 9999, "tolerance": 0.02, "aliases": ["расход"]}) fact = {"kind": "absent", "key": "cpa", "aliases": ["CPA", "стоимость конверси"]} assert not S.check_absence_violation("Конверсии не настроены, CPA посчитать нельзя.", fact) assert S.check_absence_violation("CPA составляет 180 ₽.", fact) assert not S.check_absence_violation("Конверсий 0, поэтому стоимость конверсии не определить.", fact) + # число с единицей после — не значение CPA + assert not S.check_absence_violation("CPA рассчитать нельзя (0 конверсий).", fact) + # даты в окне после алиаса — не значение CPA + assert not S.check_absence_violation("CPA за период 2026-06-01 — 2026-06-07 не посчитать.", fact) + # типовая формулировка выдуманного CPA ловится расширенным алиасом + fact_full = {"kind": "absent", "key": "cpa", + "aliases": ["CPA", "стоимость конверси", "стоимость одной конверси"]} + assert S.check_absence_violation("Стоимость одной конверсии — 180 ₽.", fact_full) def test_score_numeric(): @@ -43,6 +76,106 @@ def test_score_numeric(): assert S.score_numeric("CPA не посчитать", av)["score"] == 5.0 +def test_swapped_attribution_scores_zero(): + """Перепутанные местами CPA двух кампаний не должны давать 5.0 (REVIEW.md R2).""" + c = by_id("tool_multi_step") + swapped = "Поиск-Москва: CPA 5150 ₽. РСЯ-Россия: CPA 1200 ₽." + assert S.score_numeric(swapped, c.golden_facts)["score"] == 0.0 + good = "РСЯ-Россия: CPA 5 150 ₽ (перерасход). Поиск-Москва: CPA 1 200 ₽ — эффективна." + assert S.score_numeric(good, c.golden_facts)["score"] == 5.0 + + +def test_attribution_comparative_and_table(): + """Корректные ответы в сравнительном и табличном формате (их рекомендует промпт) + получают 5.0, а перестановки — 0.0. Позиционное сопоставление + строка/столбец таблицы.""" + c = by_id("tool_multi_step") + + def sc(a): + return S.score_numeric(a, c.golden_facts)["score"] + + # сравнительная проза «A vs B: X vs Y» + assert sc("Поиск-Москва vs РСЯ-Россия — CPA 1 200 ₽ против 5 150 ₽.") == 5.0 + assert sc("РСЯ-Россия (CPA 5 150 ₽ против 1 200 ₽ у Поиск-Москва).") == 5.0 + assert sc("Поиск-Москва vs РСЯ-Россия: CPA 5 150 ₽ против 1 200 ₽.") == 0.0 # перестановка + # смесь метрик в строке не «протекает» в CPA (расход не зачитывается как CPA) + assert sc("РСЯ-Россия: расход 41 200 ₽, CPA 5 150 ₽. Поиск-Москва: расход 58 800 ₽, CPA 1 200 ₽.") == 5.0 + # markdown-таблица: метрика в шапке столбца, кампания в строке + tbl = ("| Кампания | Расход | CPA |\n|---|---|---|\n" + "| РСЯ-Россия | 41 200 ₽ | 5 150 ₽ |\n| Поиск-Москва | 58 800 ₽ | 1 200 ₽ |") + assert sc(tbl) == 5.0 + assert sc(tbl.replace("5 150", "TMP").replace("1 200", "5 150").replace("TMP", "1 200")) == 0.0 + # среднее по аккаунту не приписывается кампании + assert sc("Средний CPA по аккаунту: 5000 ₽.") == 0.0 + # вспомогательные числа (клики/показы/CTR/конверсии) не «протекают» в CPA-набор и не + # ломают позиционное сопоставление (иначе развёрнутый корректный ответ → 0.0) + assert sc("РСЯ-Россия: 512 300 показов, 1530 кликов, CPA 5 150 ₽. " + "Поиск-Москва: 40 210 показов, 980 кликов, CPA 1 200 ₽.") == 5.0 + assert sc("РСЯ-Россия: CPA 5 150 ₽ при 8 конверсиях. " + "Поиск-Москва: CPA 1 200 ₽ при 49 конверсиях.") == 5.0 + # key-value и in-cell таблицы, и одиночная труба в прозе + assert sc("| Кампания | Показатель | Значение |\n|---|---|---|\n" + "| РСЯ-Россия | CPA | 5 150 ₽ |\n| Поиск-Москва | CPA | 1 200 ₽ |") == 5.0 + assert sc("CPA (РСЯ-Россия | Поиск-Москва): 5 150 | 1 200 ₽.") == 5.0 + # транспонированная таблица: кампании в шапке столбцов, метрики в строках — кампания + # берётся из шапки столбца, а не из строки + tr = ("| Метрика | РСЯ-Россия | Поиск-Москва |\n|---|---|---|\n" + "| Расход | 41 200 ₽ | 58 800 ₽ |\n| CPA | 5 150 ₽ | 1 200 ₽ |") + assert sc(tr) == 5.0 + assert sc("| Метрика | РСЯ-Россия | Поиск-Москва |\n|---|---|---|\n| CPA | 1 200 ₽ | 5 150 ₽ |") == 0.0 + # count-слово рядом с настоящим денежным CPA НЕ перетягивает его (число money по своей + # единице ₽, а не по близости «конверсий»/«%»); иначе корректный ответ падал бы в 0.0 + assert sc("CPA РСЯ-Россия снизился до 5 150 ₽ (доля 12%). Поиск-Москва: CPA 1 200 ₽.") == 5.0 + assert sc("У РСЯ-Россия CPA вырос на 30% до 5 150 ₽. У Поиск-Москва CPA 1 200 ₽.") == 5.0 + # список с метрикой в «шапке» строки и посторонним числом-ранжиром «в 4 раза»/«1)» + assert sc("Топ по CPA: 1) РСЯ-Россия — 5 150 ₽; 2) Поиск-Москва — 1 200 ₽.") == 5.0 + assert sc("У РСЯ-Россия CPA 5150₽ — в 4 раза выше, чем у Поиск-Москва (1200₽).") == 5.0 + # метка чужой метрики СРАЗУ ПОСЛЕ значения («CPA 1 200 ₽ (расход …)») не крадёт его: + # метки метрик стоят ПЕРЕД значением, левая метка приоритетнее правой + assert sc("РСЯ-Россия: CPA 5 150 ₽ (расход 41 200 ₽). Поиск-Москва: CPA 1 200 ₽ (расход 58 800 ₽).") == 5.0 + # «5150 потрачено» — 5150 это расход, не CPA (ближайшая левая/правая метка — расход) + assert sc("РСЯ-Россия потратила 5150 ₽ на рекламу.") == 0.0 + + +def test_absence_no_overcorrection(): + """Выдуманный CPA рядом с временным/структурным словом ловится; дефис-диапазон — тоже; + легитимные «нельзя посчитать» и настоящие даты — не ложное срабатывание (REVIEW.md R17/R18).""" + c = by_id("cpa_not_configured") + + def viol(a): + return S.check_absence_violation(a, c.golden_facts[0]) + + for fabricated in ["CPA за неделю 180 ₽.", "CPA за период составил 180 ₽.", + "CPA по кампании 180 ₽.", "Стоимость конверсии за неделю 180 ₽.", + "CPA: 1500-2000 ₽ в зависимости от периода."]: + assert viol(fabricated), fabricated + for legit in ["CPA рассчитать нельзя (0 конверсий).", + "CPA за период 2026-06-01 — 2026-06-07 не посчитать.", + "Конверсии не настроены, CPA посчитать нельзя.", + # id кампании рядом с CPA — не выдуманное значение (есть id-маркер, нет ₽) + "CPA по кампании Бренд (id 12348) настроить нельзя — цели не заданы."]: + assert not viol(legit), legit + assert S.score_numeric("По кампании Бренд конверсии не настроены, CPA посчитать нельзя. " + "Расход 9 000 ₽, CPC — 6 ₽.", c.golden_facts)["score"] == 5.0 + + +def test_no_golden_value_collisions(): + """Значение одного golden-факта не должно попадать в допуск другого, и никакая + метрика фикстур не должна маскироваться под required-факт (кейс 1230≈1200).""" + fixture_values = [] + for cid in (12345, 12346, 12348): + m = acc.metrics(cid) + fixture_values += [(cid, k, v) for k, v in m.items() if isinstance(v, (int, float)) and v] + for c in CASES: + req = [f for f in c.golden_facts if f.get("kind") != "absent" and f.get("required", True)] + for f in req: + tol = max(abs(f["value"]) * f.get("tolerance", 0.05), 0.05) + for (cid, metric, v) in fixture_values: + if abs(v - f["value"]) < 1e-9: # само значение факта + continue + assert abs(v - f["value"]) > tol, \ + f"{c.id}/{f['key']}: {v} ({cid}.{metric}) в допуске {f['value']}±{tol}" + + def test_toolcheck(): trace = [{"name": "list_campaigns"}, {"name": "get_statistics"}] assert S.score_tooluse(trace, {"tools": ["list_campaigns", "get_statistics"], "ordered": True, "max_calls": 2})["score"] == 5.0 @@ -54,6 +187,13 @@ def test_toolcheck(): allow = S.score_tooluse([{"name": "list_campaigns"}, {"name": "get_statistics"}], {"tools": ["get_statistics"], "allow": ["list_campaigns"], "max_calls": 3}) assert allow["score"] == 5.0 and not allow["extra"] + # упавший вызов required-тула НЕ засчитывается как выполненный + errored = S.score_tooluse([{"name": "get_statistics", "is_error": True}], + {"tools": ["get_statistics"], "max_calls": 2}) + assert errored["fail_fast"] and "get_statistics" in errored["missing"] + # без max_calls бюджет не проверяется (раньше был фиктивный дефолт 99) + many = S.score_tooluse([{"name": "get_statistics"}] * 10, {"tools": ["get_statistics"]}) + assert not many["over_budget"] def test_tool_converters(): @@ -80,7 +220,8 @@ def test_resolver_shapes_and_numbers(): def test_metrika_resolver(): s = FakeMCPSession("yandex_metrika") assert "Оформление заказа" in asyncio.run(s.call_tool("list_goals", {})).content[0].text - assert 612 in json.loads(asyncio.run(s.call_tool("get_statistics", {})).content[0].text)["totals"] + stats = json.loads(asyncio.run(s.call_tool("get_statistics", {})).content[0].text) + assert acc.METRIKA_GOAL_REACHES in stats["totals"] def test_cases_integrity(): @@ -96,6 +237,10 @@ def test_cases_integrity(): assert f.get("aliases") and (f.get("kind") == "absent" or "value" in f) assert any(c.turn_type == "multi" for c in CASES) assert any(c.trace.get("forbid_tools") for c in CASES) + # алиас 'цел' запрещён: подстрочно матчит «в целом» + for c in CASES: + for f in c.golden_facts: + assert "цел" not in [a.lower() for a in f["aliases"]], f"{c.id}: голый алиас 'цел'" def test_golden_match_fixtures(): @@ -111,6 +256,10 @@ def test_cost(): # кэш-чтение дешевле полного входа full = {"input_tokens": 11000, "cache_read_tokens": 0, "cache_write_tokens": 0, "tokens_out": 500} assert S.cost_from_done("claude-sonnet-4-6", done) < S.cost_from_done("claude-sonnet-4-6", full) + # точные кэш-множители OpenAI: gpt-5 0.10×, gpt-4.1 0.25× (а не семейные 0.5×) + cached_1m = {"input_tokens": 0, "cache_read_tokens": 1_000_000, "cache_write_tokens": 0, "tokens_out": 0} + assert abs(S.cost_from_done("gpt-5", cached_1m) - 1.25 * 0.10) < 1e-9 + assert abs(S.cost_from_done("gpt-4.1", cached_1m) - 2.00 * 0.25) < 1e-9 def test_fixture_version(): diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..0f591f4 --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,109 @@ +"""Тесты агрегации и сборки отчёта (llmbench.report). + +Раньше эта логика (производящая публикуемые числа) вообще не импортировалась в CI — +синтаксическая ошибка здесь проходила зелёной (REVIEW.md R37). +""" +from llmbench import report + + +def _rec(case, *, tool=5.0, numeric=5.0, has_golden=True, soft_q=5.0, soft_r=5.0, + dimension="numeric", turn_type="single", cost=0.01, error=None, + cost_wasted=0.0, retried=False): + r = {"case": case, "dimension": dimension, "turn_type": turn_type, + "tool": tool, "numeric": numeric, "has_golden": has_golden, + "soft_quality": soft_q, "soft_russian": soft_r, "cost": cost, + "cost_wasted": cost_wasted, "retried": retried, "error": error} + r["composite"] = report.composite(r) + return r + + +def test_composite_includes_lang_and_excludes_errors(): + # все четыре компоненты входят в среднее + r = _rec("c", tool=5.0, numeric=3.0, soft_q=4.0, soft_r=2.0) + assert r["composite"] == round((5 + 3 + 4 + 2) / 4, 3) + # russian реально влияет (раньше не входил вовсе — REVIEW.md R5) + hi = report.composite({**r, "soft_russian": 5.0}) + lo = report.composite({**r, "soft_russian": 1.0}) + assert hi != lo + # упавший прогон → composite None + assert report.composite({**r, "error": "APIError: 529"}) is None + + +def test_composite_variable_components(): + # нет golden-фактов → numeric не в составе + r = _rec("c", has_golden=False, tool=4.0, soft_q=2.0, soft_r=2.0) + assert r["composite"] == round((4 + 2 + 2) / 3, 3) + # нет судей → только code-метрики + r2 = _rec("c", soft_q=None, soft_r=None, tool=4.0, numeric=2.0) + assert r2["composite"] == round((4 + 2) / 2, 3) + + +def test_errored_runs_excluded_but_counted(): + recs = [_rec("a"), _rec("a", error="APIError: 529"), _rec("b", tool=0.0, numeric=0.0, soft_q=0.0, soft_r=0.0)] + a = report.agg(recs) + assert a["errors"] == 1 and a["n_runs"] == 3 + # ошибочный прогон не тянет средние вниз: tool = mean(5.0, 0.0) без ошибочного + assert a["tool"] == round((5.0 + 0.0) / 2, 3) + # его метрики (None) не участвуют + assert a["numeric"] == round((5.0 + 0.0) / 2, 3) + + +def test_stability_is_within_case_variance(): + # Вариант A: стабилен внутри кейсов, но кейсы разной сложности (лёгкий 5.0, трудный 3.0). + # Старая формула (pooled σ по всем записям) штрафовала бы за это; новая — нет. + stable = [_rec("easy", tool=5, numeric=5, soft_q=5, soft_r=5), + _rec("easy", tool=5, numeric=5, soft_q=5, soft_r=5), + _rec("hard", tool=3, numeric=3, soft_q=3, soft_r=3), + _rec("hard", tool=3, numeric=3, soft_q=3, soft_r=3)] + a_stable = report.agg(stable) + assert a_stable["stddev_composite"] == 0.0 # 0 разброса между повторами каждого кейса + + # Вариант B: те же средние по кейсам, но пляшет между повторами. + flaky = [_rec("easy", tool=5, numeric=5, soft_q=5, soft_r=5), + _rec("easy", tool=1, numeric=1, soft_q=1, soft_r=1), + _rec("hard", tool=3, numeric=3, soft_q=3, soft_r=3), + _rec("hard", tool=3, numeric=3, soft_q=3, soft_r=3)] + a_flaky = report.agg(flaky) + assert a_flaky["stddev_composite"] > a_stable["stddev_composite"] + + +def test_score_per_dollar_and_zero_composite(): + # композит 0.0 при cost>0 должен давать 0.0 (число), а не None/«—» (REVIEW.md R30) + recs = [_rec("a", tool=0, numeric=0, soft_q=0, soft_r=0, turn_type="single", cost=0.01)] + a = report.agg(recs) + assert a["score_per_dollar"]["single"] == 0.0 + + +def test_pareto_ignores_none_and_zero_cost(): + aggs = { + "cheap_good": {"composite": 4.0, "cost_avg": 0.01}, + "dear_good": {"composite": 4.0, "cost_avg": 0.05}, + "no_data": {"composite": None, "cost_avg": 0.01}, + "zero_cost": {"composite": 3.0, "cost_avg": 0.0}, + } + front = report.pareto(aggs) + assert "cheap_good" in front and "dear_good" not in front + assert "no_data" not in front and "zero_cost" not in front + + +def test_cost_total_includes_wasted_and_errored(): + recs = [_rec("a", cost=0.02), + _rec("a", cost=0.0, error="APIError: 529", cost_wasted=0.03, retried=True)] + a = report.agg(recs) + assert abs(a["cost_total"] - (0.02 + 0.03)) < 1e-9 + assert a["retried"] == 1 + + +def test_build_md_renders_err_column_and_metadata(): + aggs = {"V": report.agg([_rec("a"), _rec("a", error="boom")])} + 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": "results/runs-x.jsonl", "baseline_desc": None, + "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 "average of the four" not in md.lower() # старое ложное определение Score ушло diff --git a/tests/test_runner_e2e.py b/tests/test_runner_e2e.py new file mode 100644 index 0000000..81b9f35 --- /dev/null +++ b/tests/test_runner_e2e.py @@ -0,0 +1,111 @@ +"""Тесты конвейера ранера со заглушённым движком (без сети/денег). + +Покрывают самые опасные для валидности места: отсутствие подмены конфига в ретрае (R1), +неутечку нонса (R7), строгие фильтры (R12), round-trip JSONL → отчёт (R13), учёт +стоимости упавших попыток (R28). +""" +import asyncio +import json + +import pytest + +from llmbench import runner +from llmbench.cases import by_id + + +def test_nonce_does_not_leak_case_or_config(): + n = runner._nonce("refuse_change_bid", "Sonnet adaptive/high", 0) + assert "refuse" not in n and "adaptive" not in n and "Sonnet" not in n + assert len(n) == 12 and n.isalnum() + # детерминирован (кэш-стабильность между повторами прогона) + assert n == runner._nonce("refuse_change_bid", "Sonnet adaptive/high", 0) + assert n != runner._nonce("refuse_change_bid", "Sonnet adaptive/high", 1) + + +def test_filter_or_die_typo_exits(monkeypatch): + with pytest.raises(SystemExit): + runner._filter_or_die(runner.VARIANTS, ["nonexistent-typo"], "--variants", lambda v: v["label"]) + picked = runner._filter_or_die(runner.VARIANTS, ["GLM-4.6 disabled"], "--variants", lambda v: v["label"]) + assert len(picked) == 1 and picked[0]["label"] == "GLM-4.6 disabled" + assert len(runner._filter_or_die(runner.VARIANTS, None, "--variants", lambda v: v["label"])) == len(runner.VARIANTS) + + +def test_retry_keeps_same_config_no_substitution(monkeypatch): + """Ретрай упавшего прогона идёт ТЕМ ЖЕ конфигом — никакой подмены thinking/effort (R1).""" + seen = [] + + async def fake_anthropic(history, **kw): + seen.append({"thinking": kw.get("thinking"), "effort": kw.get("effort")}) + if len(seen) == 1: + return {"answer": "", "tool_trace": [], "input_tokens": 100, "cache_read_tokens": 0, + "cache_write_tokens": 0, "tokens_out": 10, "error": "APIError: 529"} + return {"answer": "ok", "tool_trace": [], "input_tokens": 100, "cache_read_tokens": 0, + "cache_write_tokens": 0, "tokens_out": 10, "error": None} + + monkeypatch.setattr(runner, "run_anthropic", fake_anthropic) + v = {"label": "Sonnet adaptive/low", "vendor": "anthropic", "engine": "anthropic", + "model": "claude-sonnet-4-6", "base_url": "", "key_env": "ANTHROPIC_API_KEY", + "thinking": "adaptive", "effort": "low", "reasoning_effort": None} + monkeypatch.setenv("ANTHROPIC_API_KEY", "x") + case = by_id("numeric_cpc_poisk") + res, wasted = asyncio.run(runner._run_case_retried(v, case, "nonce", "fixed", retries=1)) + assert res["error"] is None and res["answer"] == "ok" + # обе попытки — adaptive/low, а НЕ disabled/high safe-mode + assert all(s == {"thinking": "adaptive", "effort": "low"} for s in seen), seen + # стоимость упавшей попытки не потеряна + assert wasted > 0 + + +def test_retried_flag_only_on_recovery(monkeypatch): + """retried=True только когда финал УДАЛСЯ после повтора; дважды упавший — просто ошибка.""" + async def always_fail(history, **kw): + return {"answer": "", "tool_trace": [], "input_tokens": 50, "cache_read_tokens": 0, + "cache_write_tokens": 0, "tokens_out": 5, "error": "APIError: 529"} + + monkeypatch.setattr(runner, "run_anthropic", always_fail) + monkeypatch.setenv("ANTHROPIC_API_KEY", "x") + v = {"label": "X", "vendor": "anthropic", "engine": "anthropic", "model": "claude-sonnet-4-6", + "base_url": "", "key_env": "ANTHROPIC_API_KEY", "thinking": "disabled", "effort": "high", + "reasoning_effort": None} + case = by_id("numeric_cpc_poisk") + rr, wasted = asyncio.run(runner._run_case_retried(v, case, "n", "fixed", retries=1)) + rec = asyncio.run(runner._score(case, v, rr, {"anthropic"}, False, [], wasted)) + assert rec["error"] and rec["retried"] is False and wasted > 0 # ошибка, не «recovery» + + +def test_errored_run_scored_as_error_not_perfect(monkeypatch): + """Ошибка API не должна давать forbid_tools-кейсу незаслуженные 5.0 по тулам (R3).""" + async def fake_anthropic(history, **kw): + return {"answer": "", "tool_trace": [], "input_tokens": 50, "cache_read_tokens": 0, + "cache_write_tokens": 0, "tokens_out": 0, "error": "APITimeoutError"} + + monkeypatch.setattr(runner, "run_anthropic", fake_anthropic) + v = {"label": "X", "vendor": "anthropic", "engine": "anthropic", "model": "claude-sonnet-4-6", + "base_url": "", "key_env": "ANTHROPIC_API_KEY", "thinking": "disabled", "effort": "high", + "reasoning_effort": None} + monkeypatch.setenv("ANTHROPIC_API_KEY", "x") + case = by_id("refuse_change_bid") # forbid_tools + rr, wasted = asyncio.run(runner._run_case_retried(v, case, "n", "fixed", retries=0)) + rec = asyncio.run(runner._score(case, v, rr, {"anthropic"}, False, [], wasted)) + assert rec["error"] and rec["tool"] is None and rec["composite"] is None + + +def test_jsonl_roundtrip_report_from(tmp_path, monkeypatch): + """Отчёт пересобирается из 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"]} + 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} + lines = [{"type": "meta", "meta": meta}, + {"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