Зашёл тут на карту и вижу странную картину. На Чистых прудах висят три пина ровно друг на друге. Тыкаю, а там один и тот же «Вишнёвый сад» в Ленкоме. Совпадает всё, вплоть до времени и зала. Просто данные прилетели из трёх разных мест. Где-то площадка записана просто как Ленком, где-то полностью с именем Марка Захарова, а в третьем случае вообще пусто. Для пользователя это три разных события на карте, хотя спектакль на самом деле один.
У меня сейчас Окрест тянет афиши по шестнадцати городам из Яндекс Афиши, Afisha.ru, Timepad, KudaGo и телеграм-каналов самих площадок. Сейчас в базе 23 097 активных событий, и пересечений между источниками много. 8260 событий приходят из двух источников, 533 из трёх, десять встречаются сразу в четырёх. На карте всё это должно превращаться в одну точку, а не в гирлянду пинов.
Вот как выглядит путь события от источника до карты:

Почему одного названия недостаточно
Казалось бы, возьми да сравни названия. Но точное сравнение ломается сразу. Одна афиша пишет Polnalyubvi латиницей, другая — «Полналюбви» кириллицей. Для кода это совершенно разные строки, совпадений ноль.
Если включить нечёткое сравнение, начинается обратная беда. Какой-нибудь «Квиз, плиз» идёт пятничным вечером в двадцати барах. Названия идентичные, но это двадцать разных игр. Склеишь их по имени, и девятнадцать штук просто испарятся с карты.
Поэтому название нельзя рассматривать отдельно от места и времени. Сначала отбираются кандидаты: та же площадка и тот же календарный день. День я считаю в одном фиксированном поясе — по Москве, — и это не про «день Москвы», а про общую систему отсчёта: date_start у меня хранится абсолютным моментом, и два источника про одно новосибирское событие переведутся в один и тот же московский день независимо от местного времени. Важно, чтобы пояс был один для обоих, а какой именно — уже неважно. SQL при этом берёт с запасом плюс-минус сутки, чтобы не потерять событие на границе суток, а решающее сравнение уже в питоне требует точного совпадения дня. И только внутри этого окна я смотрю на текст.
Нормализация: один ключ на все алфавиты
Сначала оба названия транслитерируются: кириллица переводится в латиницу, регистр опускается. Из этой транслитерации я строю два представления — они пригодятся дальше по-разному. Первое, компактный ключ, оставляет только [0-9a-z] и склеивает всё встык, без пробелов и пунктуации. Второе сохраняет границы слов. Таблица транслитерации общая:
_CYR2LAT = str.maketrans({ "а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh", "з": "z", "и": "i", "й": "y", "к": "k", "л": "l", "м": "m", # ... "щ": "shch", "ъ": "", "ы": "y", "ь": "", "э": "e", "ю": "yu", "я": "ya",})
В компактном ключе пунктуация и пробелы отваливаются сами, «ё» и «е» схлопываются, мягкий и твёрдый знаки выбрасываются. Побочный эффект — «Полна любви» и «Полналюбви» дают один и тот же ключ. На нём же держится точное сравнение: если ключи двух названий совпали, это сильнейший сигнал, что событие одно.
Транслитерация тут заведомо с потерями, и об этом стоит сказать честно: «э» тоже даёт e, поэтому «Мэр» и «Мер» на уровне ключа совпадают. А фильтр [0-9a-z] режет не только пунктуацию, но и не-ASCII латиницу — Ömankö Day превращается в mankday. Пока это не выстрелило, но это мина.
Чего компактный ключ не делает — не нормализует порядок слов. «Иван Петров» и «Петров Иван» дают разные ключи. Порядок разбирается дальше, на втором представлении, где пробелы сохранены.

Три уровня строгости
Поначалу думал обойтись одним порогом похожести, скажем, 90. Не вышло. Ставишь высоко — лезут дубли. Опускаешь ниже — разные мероприятия схлопываются в одно. А ложная склейка это самое паршивое. Дубль человек хотя бы увидит, а ошибочно склеенное событие просто пропадает из афиши без следа. Я исхожу из того, что лучше лишний пин на карте, чем кто-то пропустит свой концерт.
Поэтому решение принимает не число, а функция с уровнями строгости:
def same_event( a: str, b: str, level: str = "auto", strict_numbers: bool = True,) -> bool: ...
Совпал компактный ключ — склеиваю. Да, ключ с потерями, и «Мэр» с «Мер» на нём неразличимы — но к этому моменту пара уже прошла отбор по площадке и дню, так что для ложной склейки два разных события должны совпасть ещё и залом, и датой. Не невозможно, но настолько редко, что я это принял. «Без разговоров» — это не про то, что ключ безопасен сам по себе, а про то, что связка «ключ + площадка + день» достаточно надёжна.
Замечу сразу про сам контракт: функция возвращает bool, но True на уровне auto означает «сливай», а на fuzzy — «это только кандидат». Один тип на два разных смысла — так себе решение, по-хорошему тут просился бы enum. Не переписал; просто честно, что это шов.
Дальше идёт разбор подмножеств, и вот тут важная деталь, которая обычно теряется в пересказах. Формально и «Света» ⊂ «Света. Большой сольный концерт», и «Женитьба» ⊂ «Женитьба Фигаро» — одна строка входит в другую. Различает их зашитый список служебных слов:
extra_is_filler = all( t in _FILLER or len(t) <= 2 or _YEAR_RE.fullmatch(t) for t in extra)if level == "auto": if extra_is_filler: return Trueelse: # fuzzy: любое отличительное лишнее слово — кандидат на разбор return True
_FILLER — 51 слово: форматы подачи («концерт», «шоу», «вечер», «программа», «премьера», show, live), дескрипторы («большой», «сольный», «юбилейный», «новогодний», «гала») и предлоги. Плюс прощаются токены короче трёх букв и год. «Большой сольный концерт» — три филлера подряд, поэтому «Света» сливается автоматически. «Фигаро» ни в каком списке нет, поэтому «Женитьба» на уровне auto не проходит.
Отдельно лежит _ACTIVITY — жанры: «спектакль», «выставка», «экскурсия», «фестиваль». Это не филлеры, и вдобавок они не могут быть якорем подмножества: «Спектакль» и «Спектакль для детей» не склеятся, потому что общее слово у них слишком общее.
Если подмножество не сработало, считается похожесть — token_sort_ratio, с порогами 92 для автоматического объединения и 85 для спорных кандидатов. И вот здесь работает как раз второе представление, со словами, а не компактный ключ: token_sort_ratio разбивает строку на токены, сортирует их и сравнивает — на слитном mezhdunarodnyyfestival сортировать было бы нечего. Поэтому для точного сравнения у меня ключ без пробелов, а для fuzzy — отдельная транслит-строка, где границы слов сохранены. Важно было взять именно эту метрику. Есть ещё token_set_ratio, но она почти не штрафует строку за лишние токены, если короткая полностью входит в длинную. Для неё «Концерт» и «Концерт Баха» — почти одно и то же. Sort-ratio учитывает длину и ведёт себя осторожнее.
Тут же стоит признать, что порог 92 — это дыра в стройной картине со списком филлеров. Новое смысловое слово уводит пару в спорные только пока оно заметно меняет строку. «Международный фестиваль современного искусства» и «Международный фестиваль современного искусства Арт» дают 95.8 и сливаются автоматически, хотя «Арт» ни в каком списке нет.
Ещё одна защита — числа. Берём «Лето 2025» и «Лето 2026». Строки почти близнецы, но события разные:
ya, yb = _years(a), _years(b)if ya and yb and ya != yb: return False
Если год указан в обеих строках и он разный, склейка блокируется. То же самое с «Часть 1» и «Часть 2». При этом если год есть только в одном названии, шанс на объединение остаётся — источники часто пишут по-разному.

Что получается на реальных парах:
|
Название A |
Название B |
Ключ |
auto |
fuzzy |
|---|---|---|---|---|
|
|
|
совпал |
склеить |
склеить |
|
|
|
совпал |
склеить |
склеить |
|
|
|
— |
склеить |
склеить |
|
|
|
— |
нет |
кандидат |
|
|
|
— |
склеить |
склеить |
|
|
|
— |
нет |
нет |
|
|
|
— |
нет |
нет |
|
|
|
— |
нет |
нет |
Почему точное время позволяет ослабить правила
Одна оговорка, без которой таблица врёт. Есть отдельная функция same_slot_title, которая срабатывает, когда два события делят площадку (тот же venue_id) и точный момент начала — не день, а посекундное совпадение timestamp. Там достаточно, чтобы одно название было подмножеством другого по токенам, без фильтра филлеров. Логика: одна сцена не играет два спектакля в одну и ту же секунду, поэтому сам слот и есть якорь. В этих условиях «Женитьба» и «Женитьба Фигаро» как раз сливаются автоматически. То есть точное время не сужает окно, а наоборот — покупает право сравнивать названия мягче.
Тут честно стоит назвать допущение по имени. venue_id — это не всегда один зал: у мультиплекса под одним id несколько экранов, и в 19:00 там реально идут «Дюна» и «Дюна 2». Поэтому номера-сиквелы защищены отдельно и не сливаются даже в один слот. Но если два разных события совпадут и площадкой, и секундой старта, и при этом одно название окажется подмножеством другого — они склеятся ошибочно. Требование подмножества делает это редким (произвольные разные названия так не сойдутся), но дыра тут есть, и держится всё на том, что в один зал два показа в одну секунду не ставят.
Почему сначала пришлось склеивать площадки
Дальше выяснилось, что пока не наведёшь порядок в площадках, события нормально не склеишь. Мумий Тролль Music Bar в разных источниках записан по-разному. Для базы это три отдельные площадки, поэтому связанные с ними события даже не попадают в одно окно сравнения.
Площадки клеить проще, там смотрю на название, адрес, город и расстояние. Но и тут есть засада. Большой и Малый залы консерватории. Одно здание, похожие названия, координаты рядом. Склеишь их в одно, и расписания залов превратятся в кашу. Пришлось собрать словарь антонимов: большой-малый, новый-старый, верхний-нижний. Если в одном названии есть «Большой зал», а в другом «Малый», объединение сразу блокируется, каким бы высоким ни был общий score.

Где правила не справились и понадобилась LLM
Некоторые пары уже неудобно покрывать отдельными эвристиками. Например, «Концерт ансамбля имени В. С. Локтева» и «Ансамбль им. Локтева» — склонения, инициалы, слова-обёртки.
Такие пары отправляю в LLM. Чтобы не разориться на запросах, модель зовётся в двух режимах с разными якорями. Первый, строгий: та же площадка, совпадение момента начала до точного значения timestamp, и общее слово длиной от четырёх букв после транслитерации — тут решение принимается от уверенности 0.7. Второй, послабее: та же площадка и тот же день, но без совпадения времени, — за более слабый якорь плачу порогом 0.95. Оба гейта отсекают почти всё: до модели доходят единицы пар в час.
В промпт при этом уходят только два названия, больше ничего:
f"A: {title_a}\nB: {title_b}"
Ни площадки, ни времени, ни категории модель не видит — они работают как условие вызова, а не как часть вопроса. Модели остаётся ровно та задача, где она сильна: два человекочитаемых заголовка и вопрос «это один и тот же показ?». Отвечает она не свободным текстом, а JSON:
{"same": true, "confidence": 0.94}
Confidence — рабочая ручка, а не украшение: это не калиброванная вероятность, а скор, который вернула модель, и пороги 0.7 и 0.95 из абзаца выше подобраны на глаз. Любая ошибка сети или парсинга трактуется как «не совпало»: на плохом вызове объединения не происходит никогда.
Вердикты лежат в Redis тридцать дней, ключ — хеш от пары названий, отсортированной, так что порядок аргументов не важен, и (A, B) с (B, A) — один и тот же кэш-хит. Отрицательные ответы кэшируются наравне с положительными, поэтому в устоявшемся режиме проход почти ничего не стоит. Тут есть свой шов: в ключ входят только названия, а версия модели и промпта — нет. Меняю промпт — старые вердикты живут в кэше ещё тридцать дней. Пока обхожусь ручным префиксом версии в ключе, но это заплатка.
Как одна миграция сломала загрузку
Из-за этой всей борьбы за уникальность я недавно умудрился сломать загрузку. Заметил старый баг, когда пермские театры почему-то показывались в Москве. Оказалось, уникальность площадки проверялась по имени и адресу, без учёта города. Пермский планетарий радостно клеился к московскому с тем же названием улицы. Я пошёл и расширил ключ, добавил город в проверку уникальности. Всё встало на свои места.
А через день смотрю, обновления по части событий встали колом. В логах появляется конкретная ошибка:
there is no unique or exclusion constraintmatching the ON CONFLICT specification
В другом куске кода висел вот такой инсерт:
insert into events.venues (name, address, city, ...)values (:n, :a, :c, ...)on conflict (name, address) do nothing
PostgreSQL требует, чтобы для колонок из ON CONFLICT существовало подходящее уникальное или exclusion-ограничение. Я снёс старую уникальность по (name, address), и запрос стал падать целиком. Упал как раз тот флоу, который переносил события с площадки-заглушки Unknown venue на найденную реальную площадку. Получилось, что починка городов сломала починку залов.
Лечится дописыванием города:
on conflict (name, address, city) do nothing
Две строки в двух файлах. Но сам факт. Меняешь ограничение в базе, и надо идти искать все его отражения по проекту, даже в тех скриптах, про которые ты давно забыл.
Как я ищу ложные объединения
Ещё фоном крутится проверка на лишние склейки. В моей модели одно событие не должно одновременно оказаться на двух несвязанных площадках. Первый фильтр грубый:
select event_idfrom events.event_occurrenceswhere venue_id is not nullgroup by event_idhaving count(distinct venue_id) > 1;
Дальше площадки события группируются в «физические места»: две попадают в одну группу, если они ближе двухсот метров по гаверсинусу или если их названия матчатся фаззи-матчером. Тот же гард на антонимы работает и здесь — «Большой зал» и «Малый зал» не объединятся никогда, даже если пины стоят в двадцати метрах друг от друга. Иначе два разных зала одного здания слиплись бы в одно место, и разбиение не сработало бы.
Отдельного исключения для фестивалей и гастрольных серий тут нет, и это осознанно. Логика прямо обратная: событие, висящее на нескольких физических местах, считается артефактом слияния и разбирается на несколько событий, по одному на место. Гастрольный спектакль на четырёх сценах становится четырьмя событиями, а не одним с четырьмя пинами. Это закреплено как инвариант здоровья: число событий, охватывающих больше одного физического места, должно быть равно нулю.
Раз в пятнадцать минут скрипт находит такие аномалии и разбирает их обратно.
Проблема, которую я пока не решил
Сейчас из 23 тысяч событий почти все дубли разбираются сами. Модель дёргается пару раз в час на сложных случаях.
Напрягает меня другое. У меня нет очереди на ручную проверку — вообще. Спорная пара либо уходит в LLM, либо просто не сливается, и нигде не оседает. Человека в цикле нет ни на одном шаге.
И это было бы полбеды, если бы слияние можно было откатить. Но оно необратимо: дубль перецеливается на каноническое событие и удаляется. Отменить это нечем — исходных строк уже нет. То есть единственная защита от неверного объединения — та самая фоновая проверка, которая ловит только один класс ошибок: когда событие расползлось по двум местам. Если алгоритм склеил два разных спектакля в одном и том же зале, ничто этого не заметит, и второй просто исчезнет.
Меня это устраивало ровно до того момента, пока я не сел это описывать.

Во что вся эта склейка собирается на карте — можно посмотреть в боте: Окрест. Весь разбор выше — про его кишки.
Интересно, где вы проводите границу между лишним дублем и потерянным событием. Поднимаете порог, храните исходные строки ради возможности откатить, обучаете отдельную модель — или тоже миритесь с тем, что часть ошибок необратима, а инвариант ловит только те, что видно на карте?
ссылка на оригинал статьи https://habr.com/ru/articles/1061956/