-- 150_objective_mapping_backfill.sql -- Issue #307 OBJ-2 (P0 objective goldmine) — massive fuzzy backfill of -- objective_complex_mapping so Site Finder velocity/layout analytics covers a -- much larger share of Objective prices. -- -- CONTEXT -- objective_complex_mapping связывает Objective-ЖК (objective_complex_name) -- c domrf_kn_objects.obj_id. До этой миграции — 148 строк (все is_reviewed=true, -- objective_group='Екатеринбург'). objective_lots содержит 419 distinct -- project_name → 271 ещё не сматчены. -- -- mv_layout_velocity JOIN-ит objective_corpus_room_month.project_name = -- objective_complex_mapping.objective_complex_name WHERE objective_group='Екатеринбург'. -- То есть новые mapping-строки с group='Екатеринбург' автоматически расширяют MV. -- -- ПОДХОД (калибровка проведена на prod read-only 2026-06-13, latest snapshot 2026-06-10): -- Сырой trigram similarity по comm_name занижается boilerplate-префиксами -- ('Жилой комплекс "..."', 'ЖК', 'Квартал', 'Дом', кавычки, '… N этап'), -- из-за чего идентичные core-имена дают similarity 0.40-0.44 и теряются. -- Поэтому нормализуем ОБА имени (strip boilerplate + кавычки + stage-суффиксы) -- ДО similarity. Strip-токены матчатся по word-boundary (\m…\M), чтобы не -- резать середину слова ('Домино'→'ино', 'Доминанта', 'Кварталы…'). -- -- Developer-строки шумные (Latin vs Cyrillic: 'FORTIS Development' vs -- 'Фортис Девелопмент'; юр.префиксы 'СЗ/ГК/Группа/ООО'), поэтому dev -- нормализуем (strip юр.префиксов) и считаем dsim = trigram ИЛИ -- substring-containment. -- -- ПРАВИЛО ПРИЁМКИ — PRECISION OVER RECALL (P0: ложные mapping напрямую портят -- velocity/prices, поэтому берём ТОЛЬКО dev-corroborated матчи): -- accept = (nsim >= 0.92 AND dsim >= 0.5) (near-exact name + dev agreement) -- OR (nsim >= 0.62 AND dsim >= 0.5) (good name + dev agreement) -- == эффективно (nsim >= 0.62 AND dsim >= 0.5): КАЖДЫЙ авто-матч обязан иметь -- согласие застройщика. Standalone nsim>=0.92 БЕЗ dev убран намеренно — -- он ловил generic-имена с РАЗНЫМ девелопером (Солнечный/Прекрасный/Стрелки/ -- Невский/Тихий центр → FP). Цена: ~28 distinctive, но dev-noisy true-матчей -- (Основа/Тактика/Самоцветы Урала) НЕ замапятся авто — Anton добавит вручную. -- Низкоконфидентный хвост (nsim<0.62) тоже НЕ вставляется. -- -- COLLISION HANDLING -- 1. Лучший domrf-obj на каждый objective-проект: DISTINCT ON (project_name) -- по similarity DESC. -- 2. Если два objective-проекта целятся в один obj_id, оставляем лучший -- (ROW_NUMBER per obj_id ORDER BY dsim DESC, nsim DESC). Пример obj 12789 -- = 'Жилой комплекс "Парковый квартал"' (dev PRINZIP): и 'Парковый квартал' -- (PRINZIP, dsim высокий), и 'Жилой квартал Парковый' (ГК Вавилон) после -- нормализации дают core 'парковый'. Побеждает PRINZIP по dsim; вариант -- Вавилона имеет dsim=0.0 (другой застройщик) → отбрасывается. С новым -- правилом dsim>=0.5 он и так не прошёл бы глобальный accept-gate. -- (Аналогично 'Рио'/'Рио-3' Стройтэк целятся в один obj 31874.) -- 3. obj_id, уже занятые существующими 148 mapping, исключаются (NOT IN). -- -- РЕЗУЛЬТАТ (verified prod, dry-run BEGIN…ROLLBACK 2026-06-13): ~114 новых mapping -- (107 confidence=high + 7 confidence=mid, все dev-corroborated). Все -- is_reviewed=FALSE — Anton ревьюит вручную; note содержит confidence-tier, -- matched comm_name и dev. Часть имён имеет строки в objective_corpus_room_month -- → немедленно расширяют mv_layout_velocity. -- Честно: не все 271 unmapped замапятся — Objective шире domrf (satellite-города, -- проекты без объявленной ПД в наш.дом.рф) + намеренно отброшены ~28 dev-noisy -- и низкоконфидентный хвост. Остаток — для ручной разметки Anton'ом. -- -- objective_group='Екатеринбург' для ВСЕХ кандидатов: это региональный label -- ЕКБ-агломерации (region_cd=66), не строго город — существующие 148 включают -- 1 проект не из самого ЕКБ под тем же label. Так новые mapping тоже кормят MV. -- -- EXPLAIN (prod): Nested Loop с similarity() Join Filter по 419×1392 парам, -- Execution ~4 s. GIN/GIST индекс НЕ нужен и НЕ используется: предикат -- similarity()>порог (а не оператор %) через CTE не триггерит trgm-индекс; -- существующий idx_domrf_kn_objects_comm_name_trgm partial WHERE is_ekb=true, -- а latest snapshot имеет is_ekb=false → бесполезен. 4 с для one-shot backfill -- приемлемо (старый timeout миграции 116 был 89M-cost из-за CROSS JOIN LATERAL). -- -- DEPENDENCIES: objective_lots, objective_complex_mapping, domrf_kn_objects, -- pg_trgm (1.6, установлен), mv_layout_velocity (matview, UNIQUE pk → CONCURRENTLY). -- DEPLOY: auto-applied by deploy.yml via _schema_migrations (ровно один раз по NN order). -- ORDER: schema/data first → backend deploy second. -- IDEMPOTENT: ON CONFLICT (objective_complex_name, objective_group) DO NOTHING. -- Повторный запуск ничего не добавит (имена уже в mapping). Никакого DROP/мутаций -- существующих 148 строк. BEGIN; WITH -- 1. Latest DOM.РФ snapshot, регион 66 (Свердловская обл / ЕКБ-агломерация). -- (obj_id, snapshot_date) UNIQUE → одна строка на obj_id; DISTINCT ON — страховка. latest AS ( SELECT MAX(snapshot_date) AS sd FROM domrf_kn_objects ), domrf AS ( SELECT DISTINCT ON (o.obj_id) o.obj_id, o.comm_name, o.dev_name, o.district_name, -- normalized commercial name: strip ЖК-boilerplate, quotes, stage suffixes. -- \m…\M = PG word boundaries → токены матчатся как ЦЕЛЫЕ слова, не подстроки -- (иначе over-strip: «Домино»→«ино», «Доминанта»→«инанта», «Кварталы…»→«ы…»). btrim(regexp_replace( regexp_replace( regexp_replace(lower(coalesce(o.comm_name, '')), '\m(жилой комплекс|жилой квартал|жилой район|жилой дом|жилые башни|жилые кварталы|клубный дом|концепт-проект|семейный квартал|микрорайон|квартал|паркинг|жк|жр|жд|дом)\M', ' ', 'g'), '\d+(\.\d+)?\s*(этап|очередь)', ' ', 'g'), '[«»"''.,()\-]+', ' ', 'g')) AS name_norm, -- normalized developer: strip legal/group prefixes btrim(regexp_replace( regexp_replace(lower(coalesce(o.dev_name, '')), '(специализированный застройщик|сз|ооо|оао|зао|пао|ао|гк|группа|холдинг|ск|жск|фонд|девелопмент|девелопер[а-я]*|development|group|developm[a-z]*)', ' ', 'g'), '[«»"''.,()\-]+', ' ', 'g')) AS dev_norm FROM domrf_kn_objects o WHERE o.snapshot_date = (SELECT sd FROM latest) AND o.region_cd = 66 AND o.comm_name IS NOT NULL ORDER BY o.obj_id ), -- 2. Distinct Objective projects (один ряд на project_name; dominant dev/district/city), -- исключая уже сматченные в group='Екатеринбург'. obj AS ( SELECT l.project_name, mode() WITHIN GROUP (ORDER BY l.developer) AS developer, mode() WITHIN GROUP (ORDER BY l.district) AS district, mode() WITHIN GROUP (ORDER BY l.city) AS city, max(l.objective_project_id) AS objective_project_id FROM objective_lots l WHERE l.project_name IS NOT NULL GROUP BY l.project_name ), obj_unmapped AS ( SELECT o.*, -- symmetric нормализация project_name (см. domrf.name_norm выше: \m…\M boundaries) btrim(regexp_replace( regexp_replace( regexp_replace(lower(coalesce(o.project_name, '')), '\m(жилой комплекс|жилой квартал|жилой район|жилой дом|жилые башни|жилые кварталы|клубный дом|концепт-проект|семейный квартал|микрорайон|квартал|паркинг|жк|жр|жд|дом)\M', ' ', 'g'), '\d+(\.\d+)?\s*(этап|очередь)', ' ', 'g'), '[«»"''.,()\-]+', ' ', 'g')) AS name_norm, btrim(regexp_replace( regexp_replace(lower(coalesce(o.developer, '')), '(специализированный застройщик|сз|ооо|оао|зао|пао|ао|гк|группа|холдинг|ск|жск|фонд|девелопмент|девелопер[а-я]*|development|group|developm[a-z]*)', ' ', 'g'), '[«»"''.,()\-]+', ' ', 'g')) AS dev_norm FROM obj o WHERE NOT EXISTS ( SELECT 1 FROM objective_complex_mapping m WHERE m.objective_complex_name = o.project_name AND m.objective_group = 'Екатеринбург' ) ), -- 3. Best DOM.РФ candidate per objective project (нормализованный trigram). pairs AS ( SELECT u.project_name, u.developer AS obj_dev, u.district AS obj_district, u.objective_project_id, d.obj_id, d.comm_name, d.dev_name AS domrf_dev, d.district_name AS domrf_district, similarity(u.name_norm, d.name_norm) AS nsim, -- dev agreement: trigram OR substring containment (после нормализации) GREATEST( similarity(u.dev_norm, d.dev_norm), (char_length(btrim(u.dev_norm)) > 2 AND char_length(btrim(d.dev_norm)) > 2 AND (u.dev_norm ILIKE '%' || d.dev_norm || '%' OR d.dev_norm ILIKE '%' || u.dev_norm || '%'))::int::double precision ) AS dsim FROM obj_unmapped u JOIN domrf d ON similarity(u.name_norm, d.name_norm) > 0.2 -- prefilter ), best_per_proj AS ( SELECT DISTINCT ON (project_name) * FROM pairs ORDER BY project_name, nsim DESC, dsim DESC, obj_id ), -- 4. Accept rule + resolve obj_id collisions (keep best objective per domrf obj). accepted AS ( SELECT * FROM ( SELECT b.*, ROW_NUMBER() OVER ( PARTITION BY b.obj_id ORDER BY b.dsim DESC, b.nsim DESC, b.project_name ) AS rn_obj FROM best_per_proj b WHERE ((b.nsim >= 0.92 AND b.dsim >= 0.5) OR (b.nsim >= 0.62 AND b.dsim >= 0.5)) AND b.obj_id NOT IN ( SELECT domrf_obj_id FROM objective_complex_mapping WHERE domrf_obj_id IS NOT NULL ) ) s WHERE s.rn_obj = 1 ) INSERT INTO objective_complex_mapping (objective_complex_name, objective_project_id, objective_group, domrf_obj_id, is_reviewed, match_method, match_score, note) SELECT a.project_name, a.objective_project_id, 'Екатеринбург', a.obj_id, FALSE, -- ВСЕ авто-матчи на ручной review (Anton) 'auto_fuzzy_v2', round(a.nsim::numeric, 3), 'OBJ-2 #307 auto_fuzzy_v2 2026-06-13 (norm-trgm). ' || CASE WHEN a.nsim >= 0.92 THEN 'confidence=high (near-exact name + dev)' ELSE 'confidence=mid (good name + dev)' END || ' | nsim=' || round(a.nsim::numeric, 3) || ' dsim=' || round(a.dsim::numeric, 3) || ' | domrf="' || coalesce(a.comm_name, '') || '"' || ' dev_obj="' || coalesce(a.obj_dev, '') || '"' || ' dev_domrf="' || coalesce(a.domrf_dev, '') || '"' FROM accepted a ON CONFLICT (objective_complex_name, objective_group) DO NOTHING; COMMIT; -- REFRESH вне транзакции: CONCURRENTLY требует non-tx и UNIQUE index -- (mv_layout_velocity_pk на (obj_id, room_bucket) существует). Не блокирует читателей. -- Initial MV уже populated (~554 строк) — CONCURRENTLY безопасен. REFRESH MATERIALIZED VIEW CONCURRENTLY mv_layout_velocity;