---
name: oddiks-overview
description: "Оддикс — новый Telegram-бот @Oddiks_bot, выделен 2026-07-18 из спорт-раздела Делора; ~/Services/oddiks на mac mini"
metadata: 
  node_type: memory
  type: project
  originSessionId: e74d593d-e37f-40f0-a4af-e354c1c389a4
  modified: 2026-07-21T07:37:09.944Z
---

Оддикс (`~/Services/oddiks`, mac mini) — самостоятельный бот-аналитик
коэффициентов Fonbet, выделен из спорт-вертикали [[delor_overview]] (Делор)
по плану `/Users/cebers/.claude/plans/crystalline-exploring-donut.md`.
Хэндл — **`@Oddiks_bot`** (не `@OddiksCodoCeh_bot`, как планировалось —
владелец решил оставить выданный BotFather-токен как есть, 2026-07-18).

**Why:** владелец хочет выделить спорт из мультидоменного Делора в отдельный
однодоменный продукт, по паттерну [[cryptopilot]]/[[investpilot]].

**Статус на 2026-07-18:** Фазы 1-6 сделаны (скаффолд, перенос domain/sport,
bot/, analyst.py+launchd деплой, 518 тестов зелёные, живой tg-web-e2e,
ops_dashboard-карточки). Fonbet-cookies бутстрапнуты копией живой сессии
Делора, не ручным прогоном. Доступ — плоский allowlist (`/grant`/`/revoke`),
не тишина для чужих. Без Fernet-шифрования БД (v1-решение).

**How to apply:** Делор УЖЕ почищен от спорта (Фаза 8, 2026-07-18/19,
коммит `71572c9`). 2026-07-19: убран банк целиком, исправлен критичный
конфиг-баг OmniRoute (порт 20127→20128, без него анализ вообще не шёл),
закрыты системные дыры сеттлинга по всем 27 живым видам спорта (бокс/
дартс/бильярд/beach-volley — no-draw/no-score гейты; cricket тест-матчи —
отдельная проверка по лиге). Массовый workflow-аудит (10 суб-агентов +
adversarial verify) дополнительно нашёл и закрыл: четвертные азиатские
форы (±X.25/±X.75 — молчаливый полный win/loss вместо честной "половины"),
score_recovery слепоту к лиге/дате (риск подтянуть счёт чужого матча тех
же команд). Добавлен composite-парсер «Победа+тотал» (с денормализацией
Fonbet `category`, иначе половина таких пиков сеттлилась бы не той
стороной) и best-effort «крайний случай» угадывания — строго изолирован,
`risk_tier=high`+`is_low_confidence_guess`, отдельная статистика в UI,
обычный сеттлинг не тронут. Известный неисправленный риск: `darts`
может молчаливо неверно сеттлиться на уолковерах (задокументировано, не
починено — латентный, реальных пиков не было). Полные доки — Obsidian
`Projects/Оддикс/` (5 канон-файлов, Decisions.md D1-D17). Диагностика/
операции — см. Scenarios.md там же.

**2026-07-19 (2), D11:** живой баг — рынок «Тотал сетов = N?» (факторы
9284/9287/...) рендерился нечитаемым «2 / Да 2» вместо «Тотал сетов /
Да 2», потому что `fonbet_parser.build_factor_index` брал заголовок
таблицы ТОЛЬКО из `table.name`, а Fonbet для этой таблицы кладёт его в
угловую ячейку шапки (`table.name` пуст). Подтверждено сверкой с сырым
`factors_catalog_tables.json`, не угадыванием — деньги-значимый грейдинг
не терпит догадок. Фикс + новый регекс-грейдер `_settle_set_count_exact`
(коммит `a4be70f`), пик id=29 (Циципас-Коллиньон) пересчитан вручную →
`loss`, доставлен через штатный `sport_outcome_notify_loop`. Правило на
будущее при похожих нечитаемых лейблах — см. Decisions.md D11 (Obsidian).

**2026-07-19 (3), D12:** владелец просил ускорить обход матчей раз «есть
фолбэки», без потери качества. Параллелить fallback внутри матча не стали
(вызывается только при отказе primary — 0 выигрыша в норме, 2x нагрузка
в проблемных случаях). Реальная причина, почему семафор сканирования уже
урезали 6→4 (2026-07-18) — `sport_analysis_loop`/`lesson_chain`/
`score_recovery_llm` делят один DashScope-пул OmniRoute (~5 соединений).
Фикс: у lesson_chain/score_recovery_llm groq (уже существовавший
fallback2) переставлен ПЕРВЫМ вместо dashscope (`analyst.py`, коммит
`5f30489`) — модель самого сканирования не тронута, семафор сканирования
вернули 4→6. **ОТКАЧЕНО в тот же день** (см. (5) ниже) — groq оказался
ТЕМ ЖЕ провайдером, что 3-й фолбэк самой цепочки сканирования, живые
429/413 через ~25 минут, тики раздулись до 977с. Урок: «свободная
мощность» нужно проверять по факту (кто ещё её использует), не считать
свободной по факту редкой роли в СВОЁМ коде.

**2026-07-19 (4), D13:** экран per-sport списка (кнопка «⚽ Футбол · N»)
раньше показывал ВСЕ запланированные матчи с детерминированным
фаворитом по коэффициентам как фолбэк — владелец хотел видеть только
реально проанализированные матчи с рекомендациями. Запрос переписан на
тот же критерий, что уже использовал экран «🏆 Топ-рекомендации»
(свежий непустой `sport_analysis_cache.verdict_json`), логика вынесена
в общий `_fetch_analyzed_matches()` вместо копии; рендер переиспользует
`render_top_picks_overview`/`build_top_picks_kb` (коммит `202c63b`).
620 тестов (было 618, +2 новых), живой e2e подтвердил: у футбола 297
матчей в расписании, список показал ровно 2 реально проанализированных
с настоящими пиками (маркет/коэф/риск), а не расписание.

**2026-07-19 (5), D14:** главная цель владельца — все матчи/ставки
анализируются, без упора в квоту, без потери скорости/качества. Реальная
цифра: 617 из 1422 матчей в 3-дневном горизонте ни разу не
проанализированы. groq-фикс (3) откачен (`d22329f`) — см. выше. Разбор
провайдеров: mistral занят под zemlya (владелец исключил явно), cerebras
несёт Делора L2-чат, nvidia уже используется САМИМ Оддиксом (verify/
audit) на низкой частоте — владелец разрешил «модельки Делора можно
брать». Добавлен `overflow_llm`/`overflow_concurrency` в `analyze_top_n`
(коммит `158a8a9`) — второй независимый движок (nvidia primary →
notion-web2api fallback) со своим семафором, батч делится
пропорционально конкуррентности (6 dashscope : 2 nvidia, канареечный
старт). 622 теста (+2), живой прогон: тики 30.9с/62.1с (было 50-350с),
backlog 582→479→465 за ~25 минут, 0 429/502 от DashScope/nvidia.

**2026-07-19 (6), D15:** живой баг — «Фора / Ф2 +3.5» (Вукич-Хольмгрен)
навсегда `НЕ СВЕРЕНО»`. Корень: subscores (счёт геймов по партиям)
никогда не денормализуется в sport_pick_results — геймовые Фора/Тотал
(теннис и т.п.) структурно НЕ МОГЛИ пересмотреться, сколько ни жди.
Лично проверил гипотезу «нужно время»: 5 ручных веб-проверок подряд дали
4 разных счёта одного матча (~2ч после финиша, источники ещё не
устаканились) — подтверждает и саму гипотезу, и необходимость ≥2-
источникового протокола. Добавлена `score_recovery.recover_set_scores()`
(тот же fail-closed протокол, что и recover_final_score/recover_mma_
outcome) + `_is_set_unit_market()` в `review_unsettled_pending` (коммит
`236b1b8`). 637 тестов (+15). Живая проверка (не мок) против РЕАЛЬНОГО
pick id=52: честно нашла 0 источников (слишком рано) и корректно НЕ
засеттлила — механизм работает, само срабатывание ждёт естественного
3-дневного цикла. Побочный эффект теста задел все 4 unsettled-пика в
системе — счётчики пересмотра откачены обратно на 0.

**2026-07-19 (7), D16:** проверка ops_dashboard («всё зелёное?») вскрыла
живой баг доставки. Часть 1: `_clean_label()` (единая точка показа
market_label) экранировала HTML только на ОДНОЙ из двух веток — частый
случай (без `%`-плейсхолдера) отдавал сырой текст; LLM-`reasoning_ru`/
`outcome_note` вставлялись напрямую без экранирования вообще — голый
`<` в LLM-прозе («кэф был < 1.60») ронял ВСЁ сообщение целиком у
Telegram. Добавлен `_esc()`, применён по всему `formatting.py` (коммит
`520652c`, 12 тестов). Часть 2 (найдено при верификации): пик id=57
имел `notified_ts`, хотя отправка упала — `sport_outcome_notify_loop`
ставил отметку «доставлено» БЕЗУСЛОВНО, не после успеха (в отличие от
`notify_push_loop`). Пофикшено — mark только при ≥1 успехе или полном
отсутствии получателей (коммит `0d3913c`, 2 теста). Живой сквозной
прогон: сброшен notified_ts пику 57, рестарт бота, тик `outcome_notify`
доставил его реально — подтверждено логом+БД. 651 тест (было 637).

**2026-07-20, D17:** пачка «НЕ СВЕРЕНО» с другим текстом (не D15/D16) —
6 нижних ITF/challenger теннис-матчей, `score_recovery_attempts=6`
исчерпан. Ручная проверка (Фельдбауш-Копп через WebFetch) нашла точный
счёт на tennisexplorer.com — данные есть, обычный snippet-поиск их не
находит. Добавлена ветка `site:tennisexplorer.com OR site:itftennis.com`
для тенниса (коммит `825f468`, 4 теста), первой в списке запросов.
**Честно:** после фикса домены реально попадают в выдачу, но сниппеты —
общие страницы, не результат матча — LLM корректно не угадал. Частичный,
измеримый прирост, не полное решение (нужен был бы full-page fetch, а
не snippet-поиск) — владелец решил остановиться на этом уровне.
Процессный урок: ручная проверка `review_unsettled_pending(min_age_s=0)`
случайно исчерпала бюджет пересмотра у всех 13 unsettled-пиков сразу —
откачено; впредь точечная проверка звать `recover_final_score`/
`recover_set_scores` напрямую (не трогает БД). 654 теста (было 651).

**2026-07-20 (2), D18:** владелец попросил (1) источники результатов
на весь спорт, не только теннис, (2) топ-матчи анализировать раньше
остальных. `analyze_top_n`'s активный в проде drainer
(`sport_analysis_once=True`) сортировал кандидатов чисто по start_ts,
без учёта лиги — добавлена сортировка: срочные (старт < 2ч, любая лига)
первыми, затем топ-лиги, затем по времени. Попутно найден баг: SQLite
`LOWER()` не трогает кириллицу — топ-лига CASE WHEN никогда не срабатывал
для КХЛ/НБА/НХЛ/Лиги Чемпионов/Лиги Европы/Лиги Наций/Ла Лиги/Серии А/
АПЛ/МЛС (только латинские atp/wta/mlb работали), баг жил и в
пред-существующей round-robin ветке. Фикс — `py_lower` через
`conn.create_function` (Python `str.lower()`, Unicode-aware). Живая
проверка: топ-бакет 98→120 матчей (+22%). D17-паттерн (site:-таргетинг)
обобщён на весь спорт: `site:flashscore.com OR site:sofascore.com` для
любого sport, `site:liquipedia.net` доп. для esports (104 матча/3д —
3-я по объёму категория). Коммит `68db1b6`, 7 тестов (654→661). Живая
проверка: 0 новых ошибок в analyst.log; порядок выборки сверен
НАПРЯМУЮ SQL-запросом (не через `analyzed_ts` — при семафоре=6 порядок
завершения конкурентных вызовов не отражает порядок выборки, едва не
завело проверку по ложному следу). Известное неисправленное
пред-существующее огрубление: `%atp%`/`%wta%` матчит ЛЮБОЙ турнир с
этими буквами (Challenger/125K тоже) — top-флаг для тенниса шире, чем
«только основной тур».

**2026-07-20 (3), D19:** владелец прислал скриншот 2 «НЕ СВЕРЕНО»
(Уругвай) с гипотезой «слишком рано пытаемся определить результат».
Прямая проверка опровергла: прошло вдвое больше времени, чем система
сама даёт, результат всё равно `None`. Сырые сниппеты показали ту же
проблему, что D17 — snippet-поиск видит только generic-анонс страницы,
не итог (появляется там ПОСЛЕ матча). Реализован полный fetch
sofascore.com через `curl_cffi` (httpx получает 403 без TLS-отпечатка
браузера) с парсингом структурированного `__NEXT_DATA__` JSON.
Неожиданно вскрылось: оба матча оказались Postponed/Interrupted —
результата для сверки нет вообще, это не задача "искать счёт", а
"закрыть без результата". Владелец подтвердил (`AskUserQuestion`):
для void достаточно ОДНОГО sofascore-статуса (структурированное
редакционное поле, не число, не нужен ≥2-источниковый протокол как
для счёта). `check_match_void_status()` встроена в оба тира сверки.
673 теста (было 661, +12), коммит `cf4bf88`. Живая проверка: 0 ошибок
в логе, пики 61/62 закрыты `void` прямо сейчас (не ждали 3 дня) —
доставка владельцу подтверждена по БД (12:17:45).

**2026-07-20 (3b), D19 follow-up:** владелец попросил проверить
остальные 13 unsettled-пиков той же причиной. Вскрылось два реальных
бага в только что написанном коде: (1) `site:A OR site:B` в ОДНОМ
запросе стабильно 502-ит у DDGS-бэкенда research-gateway (проверено
даже на "Novak Djokovic" — 502; каждый `site:X` отдельно работает) —
значит ВЕСЬ site-таргетинг D17/D18 не работал НИ РАЗУ с момента
внедрения, "победа" на 61/62 была совпадением через обычный запрос.
Фикс — раздельные запросы (коммит `78536f1`, 674 теста). (2) После
фикса — generic `site:sofascore.com` иногда цепляет СОВСЕМ ДРУГОЙ матч
(сумо зацепило баскетбол, волейбол — футбол); в этих 13 случаях чужой
статус был "finished" (без вреда), но риск воида ПРАВИЛЬНОГО пика по
статусу ЧУЖОГО реален. Добавлена проверка `startTimestamp` sofascore
против нашего `start_ts` (>24ч = другой матч) — team-name сверка не
тянется (RU/EN без транслитерации). Коммит `0635fdf`, 677 тестов.
Итог: все 13 оставшихся unsettled — честный `no void signal` (в
основном потому что sofascore не находит нижние теннисные турниры
вообще, тот же пробел покрытия, что D17, не "все подтверждены ОК").
Урок: тестировать НОВЫЙ механизм на РАЗНООБРАЗНЫХ реальных данных
сразу после написания, не только на исходном кейсе, который его
мотивировал — исходный кейс (61/62) случайно "прошёл" по другой
причине, маскируя оба бага.

**2026-07-20 (4):** владелец прислал JSON-конфиг стороннего LLM-релея
(`free.lyclaude.site`, формат New API `newapi_channel_conn`, ключ
`sk-...`) с просьбой добавить в подмогу dashscope. Отказано на первом
шаге ([[harvested_free_key_repos_refuse]]) — паттерн раздаваемого
ключа. Владелец уточнил: это его СВОЙ оплаченный аккаунт ($200
собственных денег, скриншот личного кабинета с email). Это меняет
категорию — не кража чужого, но остаётся риск (неофициальный реселлер,
0 использования, домен намекает на серый Claude-прокси). Предложен
переносимый паттерн изоляции ([[risky_llm_provider_isolation]],
уже применённый в Делоре для atessa/aerolink): off-bus (не в
OmniRoute — общая шина на все продукты), отдельный клиент прямо в
`analyst.py`, лид-звено перед dashscope-бэкстопом, короткий таймаут.
Жду от владельца список моделей с «Площадки моделей» перед тем как
писать клиент — реализация НЕ начата.

**2026-07-20 (5):** второй ключ (`anymodel.org`, без исходного
подтверждения владения) — отказался тестировать до доказательства.
Владелец прислал скриншот кабинета (32.5М токенов баланс, замаскированный
ключ совпадает) — протестировал: 402 "balance has run out" несмотря на
отображаемый баланс (похоже на декоративный/несвязанный счётчик). Ни
один из двух ключей (lyclaude, anymodel) не подключён к проду.

**2026-07-20 (6), D20:** владелец прислал 2 новых «НЕ СВЕРЕНО» (MMA Fury
FC, table-tennis Setka Cup). Для ЭТИХ пиков — честное отсутствие
покрытия (нишевые лиги, sofascore/flashscore/tennisexplorer их не
знают), не баг. Но разбор нашёл 2 структурных пробела: (1) MMA/бокс
(`_NO_SCORE_SPORTS`) никогда не void-чекались — ни в `_try_mma_recovery`,
ни в MMA-ветке `review_unsettled_pending` — D19 дал void-чек только
счётным видам спорта. (2) `_research_v1_search_score_sync` резала
результат до `max_results` ПОСЛЕ склейки всех запросов — 4 site-запроса
(первые в списке) могли занять весь бюджет нерелевантными хитами,
оставляя 0 слотов исходным generic-запросам (живое подтверждение: Setka
Cup — 4 site-запроса дали ровно 14/14). Владелец подтвердил оба фикса
явно (AskUserQuestion). Void-чек добавлен в оба MMA-пути (зеркалит
D19); раздельный бюджет site/generic (site ≤ половины). Коммит
`41f728c`, 677→680 тестов. **Урок:** ad-hoc live-проверка ОБЯЗАНА
прокинуть `OMNIROUTE_API_KEY` (`source ~/.codex/omniroute.env`) —
без ключа `_v1_search_raw_sync` тихо возвращает `[]` БЕЗ warning в
логах (до сетевого запроса), и «0 сниппетов везде» неотличимо от
«источников реально нет», хотя причина — отсутствие креда в тестовом
shell, не в проде. Дал ложный первый результат в этой же сессии.

**2026-07-20 (7), D21:** владелец зарегистрировался на tokenrouter.com
сам (тот же протокол подтверждения — совпадающий аккаунт/раскрытый
ключ), попросил подключить как фолбэк «на всякий случай». Живая
проверка ДО подключения: `z-ai/glm-5.2-free` реально работает
(`glm-5.2-fp8`, ~10-14с), второй заявленный `nvidia/...:free` на деле
требует баланс (мисномер, не подключён). Добавлен `TokenRouterLLM`
(`llm_client.py`) — off-bus по [[risky_llm_provider_isolation]] (прямой
HTTP, НЕ в OmniRoute — общая шина всех продуктов семьи), звено ПОСЛЕ
notion-web2api (самое глубокое) в `lesson_chain`+`score_recovery_llm`.
Слой-3 паттерна (contextvar scope-гард) сознательно пропущен — Оддикс
однодоменный, нет другой чувствительной задачи в процессе для защиты.
Живой найденный нюанс: glm-5.2-free — reasoning-модель, тратит ~200+
скрытых токенов на размышления ДО ответа — `score_recovery.py` зовёт
LLM с `max_tokens=300/800` (рассчитано на быстрые dashscope-модели),
клиент сам поднимает бюджет (floor 2000), подтверждено живым тестом.
Ключ в plist `com.cebers.oddiks-analyst`
(`ODDIKS_SPORT_TOKENROUTER_API_KEY`), деплой через `bootout`+
`bootstrap` (`kickstart -k` не перечитывает env — известная грабля).
Коммит `210df11`, 679/680 (1 pre-existing fail не связан, git stash
подтвердил, заведена отдельная задача). Не проверено вживую: реальный
score-recovery промпт (не тривиальный тест) — сколько токенов уйдёт на
структурированный JSON-ответ, неизвестно, может понадобиться больше
floor'а.

**2026-07-21, D22:** живой кейс от владельца — пик 130 (Тазлер—Зузанек,
table-tennis, "Чехия. Лига Про А17") — тот же пробел покрытия, что D20
(Setka Cup): sofascore/flashscore/tennisexplorer лигу не знают, поиск
цепляет ЧУЖУЮ дату той же пары игроков (играют друг с другом по
несколько раз в неделю). Нашёл `tiu.ru` — специализированный агрегатор
именно этого класса лиг, с предсказуемым URL
(`slug1-slug2-DD-MM-YYYY`, порядок slug не важен, проверено живьём) —
вместо доверия найденной дате, берём только slug-префикс и подставляем
СВОЮ дату из `start_ts`. Владелец подтвердил (тот же протокол, что D19
для void): одной страницы tiu.ru с точным datetime + "Завершён"
достаточно для авто-счёта — отход от общего `min_sources=2`.
`recover_score_via_tiu()` в `score_recovery.py`, scope только
table-tennis, подключено в оба тира ПЕРЕД void-чеком. Пик 130
пересверен вручную (win, 2:3) до автоматизации. Коммит `8f4e1c6`,
680→695 тестов. Живой e2e воспроизвёл тот же счёт, что ручная проверка.
Не проверено: первое САМОСТОЯТЕЛЬНОЕ автоматическое срабатывание (без
участия человека) — искать в логе "recovered %s from tiu.ru".

**2026-07-21 (2), D23:** владелец не увидел результат HIGH-риск пика —
разбор: уведомление реально доставлено (лог-подтверждено, 02:17 ночи,
просто пропущено), но сам пик (79, Кальехон-Эрнандо—Кумасака, ATP
Challenger Сеговия квал.) реально исчерпал попытки без счёта. Ручная
проверка нашла 2 независимых структурированных источника —
checklive.net (Next.js `__NEXT_DATA__`, тот же паттерн, что sofascore
D19) и betcity.ru (H2H-таблица) — оба сошлись на 0:2, пересверено
вручную. В отличие от tiu.ru (D22), НИ ОДИН URL здесь не
date-предсказуем (checklive.net url без даты, betcity.ru — непредск.
числовые ID) — `recover_score_via_structured_pages` сверяет дату НА
странице (checklive: `startAt`; betcity: построчный поиск в
H2H-таблице), а не подставляет её в URL. Оба источника генуинно
независимы → остался на обычном `min_sources=2` БЕЗ owner-исключения
(в отличие от tiu.ru) — результат только если оба домена найдены и
согласны. Подключено в оба тира, scope только `tennis` (table-tennis
уже закрыт tiu.ru). Пик 79 пересверен вручную (win, 0:2) до
автоматизации. Коммит `0e023ff`, 695→712 тестов. Живой e2e воспроизвёл
точное совпадение (0:2, оба домена). Не проверено: как часто ОБА
источника реально находятся одновременно на случайном матче — эффект
на практике не измерен, только один живой прогон.
