- Python 96.8%
- Shell 3.2%
Ветка родилась из живого прогона по ссылке фотографа: путь wfolio не работал вообще (0 файлов из 214, молча), потому что фикстуру восстановили по памяти. Плюс два дефекта конвейера, которые тот же прогон и вскрыл: 400 по ключу или региону навсегда выбрасывал файлы из очереди обогащения, а отчёт считал пометку [skip: …] за описание. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| deploy | ||
| docs | ||
| eval | ||
| ops | ||
| src/photobot | ||
| tests | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| pyproject.toml | ||
| README.md | ||
| requirements.txt | ||
| schema.sql | ||
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). Прод и секреты на нём; эта копия
кода рассчитана на то, что вы поднимете свою и будете развивать её.