PR фото-бот — поиск по медиабиблиотеке Yango (Telegram-бот + форма разметки людей + MCP-сервер)
  • Python 96.8%
  • Shell 3.2%
Find a file
niko cd1e79190d Merge: живой wfolio — реальная разметка, честное обогащение
Ветка родилась из живого прогона по ссылке фотографа: путь wfolio не работал
вообще (0 файлов из 214, молча), потому что фикстуру восстановили по памяти.
Плюс два дефекта конвейера, которые тот же прогон и вскрыл: 400 по ключу или
региону навсегда выбрасывал файлы из очереди обогащения, а отчёт считал
пометку [skip: …] за описание.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:57:58 +03:00
deploy docs: верифицировать импорт съёмок end-to-end 2026-08-26 12:04:28 +03:00
docs docs: отчёт о живом прогоне wfolio и найденных дефектах 2026-08-26 16:50:26 +03:00
eval PR фото-бот: поиск по медиабиблиотеке Yango 2026-08-17 19:30:03 +03:00
ops feat(import): нацелить импорт на выданную папку и починить запуск конвейера 2026-08-26 11:58:08 +03:00
src/photobot fix(enrich): не списывать файлы в брак из-за ключа или региона 2026-08-26 16:50:26 +03:00
tests fix(enrich): не списывать файлы в брак из-за ключа или региона 2026-08-26 16:50:26 +03:00
.env.example docs(deploy): принять секреты и эксплуатацию photo import 2026-08-26 00:20:30 +03:00
.gitignore PR фото-бот: поиск по медиабиблиотеке Yango 2026-08-17 19:30:03 +03:00
docker-compose.yml PR фото-бот: поиск по медиабиблиотеке Yango 2026-08-17 19:30:03 +03:00
pyproject.toml PR фото-бот: поиск по медиабиблиотеке Yango 2026-08-17 19:30:03 +03:00
README.md docs(deploy): принять секреты и эксплуатацию photo import 2026-08-26 00:20:30 +03:00
requirements.txt fix: свежий клон заводится по README, а не падает на первом шаге 2026-08-19 09:55:05 +03:00
schema.sql PR фото-бот: поиск по медиабиблиотеке Yango 2026-08-17 19:30:03 +03:00

PR фото-бот — поиск по медиабиблиотеке Yango

Telegram-бот, который находит кадр в медиабиблиотеке по человеческому запросу: «Рома Карлаш на встрече с министром в Омане», «команда в офисе», «фото из Анголы». Сделан для Group Brand Team, чтобы PR не искал руками по папкам Я.Диска.

В индексе около 21 800 ассетов из нескольких публичных шар Я.Диска и одной папки Google Drive; примерно 2100 из них намеренно скрыты из выдачи — см. «Эксплуатация». Поиск гибридный: смысловой по векторам + лексический по путям и именам файлов + жёсткие фильтры (человек, страна, год, тип медиа).

Что здесь есть, кроме бота

Это не один сервис, а три. Раньше об этом не было написано нигде — начните отсюда.

                      ┌──────────────────────────┐
   Telegram  ───────► │  бот (photobot-bot)      │
                      │  поиск, выдача, доступ   │
                      └────────────┬─────────────┘
                                   │
   Claude / любой ИИ ──► ┌─────────▼─────────┐    ┌──────────────────────┐
   photos.nercy.org      │  Postgres         │    │  Я.Диск (шары)       │
   (MCP, photobot-mcp)   │  + pgvector       │◄───┤  Google Drive        │
                         │  база photobot    │    │  ← краулеры          │
                         └─────────▲─────────┘    └──────────────────────┘
                                   │
   people.nercy.org  ───► ┌────────┴──────────┐
   (форма, photobot-form) │  база n8n_db      │
   разметка людей         │  form_people      │
                          └───────────────────┘
  • Бот (src/photobot/bot.py) — Telegram, поиск и выдача. Доступ по allowlist: живой список читается из whitelist.txt, незнакомый человек может отправить заявку, владелец подтверждает кнопкой.
  • Форма разметки людей (src/photobot/verify_server.py, verify_page.py) — веб-страница, где коллеги подписывают лица: кто это, как ещё пишется имя, должность. Живёт на people.nercy.org. Её данные лежат в ДРУГОЙ базе (n8n_db, таблица form_people), а не в базе бота — это самая неочевидная вещь в проекте.
  • MCP-сервер (src/photobot/mcp_server.py) — та же библиотека как инструмент для внешнего ИИ: search_photos и get_photos. Используется, чтобы собирать презентации с реальными фото. Read-only, видео-оригиналы не отдаёт.

Работа из формы доезжает до бота не сама: перенос делает ops/apply_form_people.py (по умолчанию сухой прогон, применяет с --commit). Это единственный путь, и о нём легко не узнать.

Быстрый старт

Честно про предварительные условия: свою копию поднять можно, но полноценный индекс стоит денег и времени. Обогащение одного кадра — это вызов Gemini (описание + вектор), на 19 800 кадрах это часы работы и платный ключ. Для разработки берите маленькую шару или подпапку на несколько сотен файлов — весь код к этому готов.

Что понадобится своё:

Что Где взять Замечание
Telegram-токен BotFather Нужен свой аккаунт: у аккаунта автора лимит в 20 ботов уже выбран
Ключ Gemini Google AI Studio Платный, тратится на обогащении
Публичная шара Я.Диска своя папка Ключ библиотеки Yango в репозитории намеренно заменён на <КЛЮЧ_ШАРЫ>
Postgres + pgvector docker compose up -d db Поднимается на :5433 из этого репозитория
python3 -m venv .venv && ./.venv/bin/pip install -r requirements.txt
docker compose up -d db                 # Postgres 16 + pgvector на :5433
cp .env.example .env                    # вписать свои ключи, .env в git не попадает
./.venv/bin/pytest                      # см. раздел «Тесты» — должно быть зелено
# пилотный краул небольшой подпапки:
./.venv/bin/python -m photobot.crawl --path "/05_EVENTS" --limit 50

Схема создаётся кодом при старте (db.ensure_*-функции), отдельного шага миграции нет. schema.sql — не источник истины: он отстал от реальности, в нём есть мёртвые таблицы и нет части живых. Смотреть надо ensure_* в src/photobot/db.py.

Тесты

./.venv/bin/pytest                      # 301 проходит, 228 пропускается — это норма
./.venv/bin/pytest -m "not integration" # без обращений к живому API Яндекса

Всего 529 тестов. Тем, что работают с базой (18 модулей), нужна отдельная тестовая база; без неё tests/conftest.py их пропускает с внятной причиной, а не заваливает прогон сотней OperationalError. Чтобы прогнать всё:

docker compose up -d db
./.venv/bin/python ops/init_test_db.py   # создаст базу и соберёт схему
./.venv/bin/pytest                       # 515 проходит, 14 пропускается (нужен ключ живой шары)

Пустого createdb мало, и это не очевидно. Схема собирается в два шага: schema.sql (таблицы + расширение vector) и ensure_*-миграции в коде (path_text с индексами, missing_since, источники, кэш выдач). Пустая база при этом достижима, поэтому раньше она проходила проверку conftest как годная и давала ~90 UndefinedTable. Теперь conftest проверяет собранность схемы и в причине пропуска называет команду выше; ops/init_test_db.py идемпотентен, зовёт ту же функцию миграций, что бот при старте, и отказывается работать с базой, в которой есть данные.

Отдельный клиент Postgres ставить не нужно — скрипту хватает psycopg из requirements.txt. Переопределить адрес: PHOTOBOT_TEST_URL=postgresql://....

Тестовую базу держите отдельно от рабочей: тесты миграций дропают и создают колонки. Если прогон начал флакать без изменений в коде — проверьте накопившиеся тумбстоуны:

SELECT count(*) FILTER (WHERE attisdropped) FROM pg_attribute WHERE attrelid='assets'::regclass;

Лимит Postgres — 1600; однажды суита падала примерно каждый третий прогон именно из-за этого, лечится пересозданием тестовой базы.

Мины

Каждая из этих команд необратимо портит данные при неверном вызове. Ни одна не защищена подтверждением.

python -m photobot.cluster без --incremental пересоздаёт кластеры лиц целиком: DELETE FROM face_clusters + INSERT, а id — bigserial, последовательность не сбрасывается. Все кластеры получают новые id, form_people ключуется голым cluster_id — вся разметка коллег становится неадресуемым мусором, форма открывается пустой. Правильный вызов — только --incremental (он же в ops/faces_pass.sh). Если встретите в docstring db.write_clusters утверждение, что это безопасно — оно неверно, проверено на живых данных.

WITH_LOCAL_DATA=1 в deploy/deploy_form.sh заливает локальную копию данных формы поверх серверной. Авторитетная копия — серверная, её собирает ops/update_form.sh из живой базы. Подписанные коллегами карточки при этом откатятся.

deploy/deploy_bot.sh пересобирает .env.bot с нуля из переменных окружения. Если на сервере в нём была переменная, которую вы не передали, она молча исчезнет вместе с функцией, которая на ней держалась.

Образы собираются из копии кода на сервере, а не из git. Проверять надо не «закоммичено ли», а что реально лежит в образе:

ssh yango-server "grep -c <маркер> /home/claude/photo-bot-bot/src/photobot/<файл>.py"

Эксплуатация

  • Ночной догон — photobot-catchup.timer (systemd, ежедневно 04:30, скрипт ops/photobot-catchup.sh): докраул новых файлов + обогащение. Пропускает тик, если прогон уже идёт. У enrich --limit дефолт 20 — для полного прогона нужен --limit 20000.
  • Ловушка часовых поясов при разборе логов: сервер в +10, Postgres в контейнере в +03. Прогон 04:30 по серверу выглядит как 21:30 предыдущего дня в crawled_at. Из-за этого легко решить, что таймер не отработал.
  • Удаления в шаре отслеживаются: assets.missing_since выставляется после полного обхода (частичный --path/--limit разметку не делает), исчезнувшие выпадают из поиска, вернувшийся файл снимает пометку сам. Предохранитель: если «пропало» больше 10 % библиотеки, пометка не делается — обрезанный листинг Я.Диска неотличим от массового удаления.
  • Часть библиотеки намеренно скрыта из выдачи — src/photobot/exclusions.py — один источник истины для трёх мест: краул в такие папки не спускается, поиск фильтрует их по пути, а mark_missing их не помечает (краул их не обходит, иначе они выглядели бы пропавшими). Скрыто около 2100 из 21 800: монтажные исходники source files, стикеры Yango Play, видео-стиллы Still\d. Это фильтр, а не удаление: строки в базе целы, откат — убрать паттерн и пересобрать образ.
  • Бэкап базы делает yango-backup.timer на сервере (pg_dumpall всех баз контейнера → restic → B2, retention 7d/4w/3m). База photobot в него попадает. Это важно: в ней лежат оплаченные описания, векторы и разметка лиц.

Ночной прогон идёт под локом конвейера (photobot.pipeline_lock): догон и импорт из бота не могут работать одновременно — очередь обогащения не арендует строки, и два прогона взяли бы один набор, оплатив Gemini дважды. Держатель печатает READY/BUSY и отпускает лок, когда закрывается его stdin, поэтому упавшая стадия не запирает конвейер до утра.

Скрипты в ops/

Скрипт Зачем
apply_form_people.py перенос разметки из формы в базу бота (единственный путь)
update_form.sh пересборка снапшота формы и кропов из живой базы
faces_pass.sh детекция лиц, инкрементальная кластеризация и простановка имён
rebuild_crops.py пересоздание кропов, показываемых в форме
dimensions_pass.sh бэкфилл размеров кадров (см. ниже)
photobot-catchup.sh ночной догон под локом: краул → обогащение → лица; systemd-таймер
init_test_db.py тестовая база для pytest: создание + схема, идемпотентно
add_shares.example.sh регистрация публичных шар в индексе (заполните своими ссылками)
gdrive_auth.py разовый вход в Google, кладёт refresh token в ~/.config/photobot
pipeline_lock.py (модуль) держатель лока конвейера для shell: READY/BUSY, держит до EOF
imagesize_probe.py отладка парсера размеров на живых файлах

Предпосылки, которых нет в скриптах: faces_pass.sh требует собранного образа photobot-faces (deploy/Dockerfile.faces, ~1.5 ГБ, onnxruntime + insightface) и моделей buffalo_l в /home/claude/insightface-models.

Прод

Три контейнера на yango-server, каждый из своего каталога, и ни один из них не является git-репозиторием — код доставляется rsync/tar, образ собирается на месте:

Сервис Контейнер Каталог на сервере Адрес
бот photobot-bot /home/claude/photo-bot-bot Telegram
MCP photobot-mcp /home/claude/photo-bot-mcp photos.nercy.org
форма photobot-form /home/claude/photo-bot-form people.nercy.org
TELEGRAM_BOT_TOKEN=... GEMINI_API_KEY=... PGPASSWORD=... bash deploy/deploy_bot.sh
MCP_BEARER=... MCP_SIGN_SECRET=... PGPASSWORD=... bash deploy/deploy_mcp.sh

Секреты уезжают через stdin в .env.* на сервере (chmod 600, вне git) и нигде не печатаются. Живые значения лежат на сервере и в ~/.config/photobot на машине автора — в репозитории их нет и быть не должно.

Осторожно с FORM_TOKEN: ссылка на форму, разосланная коллегам, содержит токен. Новый токен при деплое тихо убивает уже разосланную ссылку.

Размеры кадра (бэкфилл)

Ширина/высота нужны, чтобы подбирать кадр под слот презентации. Я.Диск размеров не отдаёт, поэтому читаются заголовки по HTTP Range (photobot.imagesize + photobot.dimensions) — оригиналы не качаются, на кадр уходит ~64 КБ.

bash ops/dimensions_pass.sh              # на сервере: вся очередь, 8 воркеров
bash ops/dimensions_pass.sh 500 4        # осторожный первый заход

Прогон резюмируемый и идемпотентный: очередь — только dims_at IS NULL, повторный запуск не перемеряет измеренное. Фиксированного префикса не существует: офсет SOF в JPEG медиана ~45 КБ, встречался кадр с SOF на 378 КБ — поэтому читается лесенкой с добором. EXIF Orientation обязателен: около 9.5 % кадров требуют перестановки W/H.

Google Drive как второй тип источника

Кроме шар Я.Диска индекс умеет папки Google Drive. Что важно знать:

  • Что ищется. Только имя файла и имена папок — содержимое видео модель не смотрит ни при крауле, ни в боте. Больше обещать нельзя.
  • Как помечен источник. assets.public_key = 'gdrive:<id папки>', source_type = 'gdrive', external_id = id файла. Согласие между ними держит CHECK, иначе файл Drive однажды уедет в API Я.Диска.
  • Дедуп. sha256 Drive не отдаёт, ключ — детерминированный суррогат от md5 (gdrive.dedup_key). Межоблачный дедуп «тот же файл и там, и там» невозможен by design.
PYTHONPATH=src ./.venv/bin/python ops/gdrive_auth.py          # разовый вход
PYTHONPATH=src ./.venv/bin/python -m photobot.crawl_gdrive \
    --folder-id <ID> --dry-run                               # посмотреть, не записывая

Права запрошены drive.readonly — изменить или удалить что-то в Drive бот не может.

Импорт съёмок от фотографов

Фотограф отдаёт съёмку ссылкой, ссылка через месяц протухает, а кадры нужны годами. Команда /add_new_photos принимает публичную папку Я.Диска или галерею *.wfolio.pro, перекладывает оригиналы в библиотеку и доводит их до поиска.

Дизайн: docs/superpowers/specs/2026-08-25-photo-import-design.md.

Как устроено. Мастер в боте: ссылка → название → страна. Дальше importer пофайлово переносит оригиналы (save-to-disk + move для Я.Диска, серверный upload?url= для wfolio — байты оригиналов через наш сервер не идут), затем адресно краулит и обогащает только эту папку (enrich --path-prefix). Состояние мастера и пофайловый журнал живут в Postgres, поэтому импорт переживает рестарт бота и продолжается с места обрыва по той же ссылке.

Куда кладём. 05_EVENTS/<год>/<Страна> — <название>. Страна стоит ПЕРВОЙ не для красоты: location.infer_country берёт первый гео-токен пути, и при стране в конце «Dubai Expo — Kazakhstan» превратился бы в UAE. Кнопки стран фильтруются тем же построителем пути, который применит импортёр, — бот не предлагает выбор, который сам же отвергнет.

Секреты. YADISK_OAUTH_TOKEN (право записи) и IMPORT_DISK_ROOT — см. .env.example; оба перечислены поимённо в deploy/docker-compose.bot.yml. Сейчас используется статический токен, RefreshingTokenProvider — отдельный follow-up, публичные интерфейсы при этом не меняются.

Диагностика.

Что видит человек Что случилось
«у бота истёк доступ к Диску» 401: токен протух; владельцу уходит отдельный алерт
«на Диске закончилось место» 507: чинит владелец диска; повтор той же ссылки продолжит с места обрыва
«источник не отдаёт оригиналы» галерея под паролем или в ней выключено скачивание оригиналов — вопрос к фотографу
«сейчас идёт другая обработка» занят лок конвейера: ночной догон или другой импорт

Откат. Фича аддитивна: новые модули, новые таблицы, новый флаг у enrich, ветка в on_query. Откат = вернуть предыдущий образ бота; уже залитые и проиндексированные кадры остаются валидными.

MCP-сервер

Инструменты search_photos (поиск, опционально с inline-миниатюрами) и get_photos (короткоживущие подписанные ссылки preview/original). Два способа авторизации: статический bearer (для Claude Code) либо OAuth 2.1 — сервер выступает Resource Server, Authorization Server — Authentik.

MCP_BASE_URL задаёт и внешний URL для ссылок на файлы, и разрешённый Host: за обратным прокси с другим значением запросы получат 421. В проде это всегда прод-хост. Пин mcp==1.28.1 не косметический — путь к миниатюрам version-specific.

Подключение из Claude Code:

claude mcp add --transport http media https://photos.nercy.org/mcp \
  --header "Authorization: Bearer $MCP_BEARER"

Как читать проект дальше

  • docs/design/01-design-v2.md — основной проектный документ: почему поиск устроен именно так, почему одних эмбеддингов мало.
  • docs/design/00-feasibility.md — что проверяли до старта (доступ, объём, GPS).
  • docs/design/02-original-brief.md — исходная постановка от коллег.
  • docs/superpowers/specs/ — спеки отдельных доработок (поиск по имени, MCP).

Доступ и владение

Репозиторий приватный, живёт в организации yango на git.nercy.org. Доступ выдаётся самообслуживанием: страница git.nercy.org/join, вход через Telegram, дальше нужен код приглашения — тот же механизм, что для маркетплейса скиллов Yango.

Владелец и человек с контекстом — Николай (niko). Прод и секреты на нём; эта копия кода рассчитана на то, что вы поднимете свою и будете развивать её.