diff --git a/data/sql/150_objective_mapping_backfill.sql b/data/sql/150_objective_mapping_backfill.sql new file mode 100644 index 00000000..23c8320b --- /dev/null +++ b/data/sql/150_objective_mapping_backfill.sql @@ -0,0 +1,230 @@ +-- 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;