From f9dd903f4d3393604cb5b530388fa31b8aa0eccd Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 14 Jun 2026 15:51:38 +0000 Subject: [PATCH] feat(sql): conservative trgm backfill objective_complex_mapping +23 (#307 OBJ-2) (#1331) --- .../155_objective_mapping_trgm_backfill.sql | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 data/sql/155_objective_mapping_trgm_backfill.sql diff --git a/data/sql/155_objective_mapping_trgm_backfill.sql b/data/sql/155_objective_mapping_trgm_backfill.sql new file mode 100644 index 00000000..4474b8a7 --- /dev/null +++ b/data/sql/155_objective_mapping_trgm_backfill.sql @@ -0,0 +1,259 @@ +-- 155_objective_mapping_trgm_backfill.sql +-- Issue #307 OBJ-2 — КОНСЕРВАТИВНЫЙ fuzzy (pg_trgm) backfill of +-- objective_complex_mapping. Третий проход (после 116/150): добавляет ТОЛЬКО +-- near-exact distinctive-name матчи, которые 150 (dev-corroboration-only gate) +-- НЕ взял из-за Latin/Cyrillic dev-noise (TEN↔ТЭН, FORTIS↔Фортис) и юр.префиксов. +-- +-- CONTEXT +-- objective_complex_mapping связывает Objective-ЖК (objective_complex_name) → +-- domrf_kn_objects.obj_id. До этой миграции — 263 строки (все objective_group= +-- 'Екатеринбург'; match_method fuzzy/auto_fuzzy_v2/multi_feature_v1/fuzzy_v2/manual, +-- все is_reviewed=false кроме 2 manual). +-- +-- mv_layout_velocity JOIN-ит objective_corpus_room_month.project_name = +-- objective_complex_mapping.objective_complex_name WHERE objective_group= +-- 'Екатеринбург' AND domrf_obj_id IS NOT NULL (verified pg_get_viewdef +-- 2026-06-14). То есть unique-ключ матчинга — project_name (НЕ project_id): +-- новые строки с этим именем + group='Екатеринбург' немедленно расширяют MV. +-- +-- ПОЧЕМУ ИМЕНА, А НЕ project_id +-- objective_lots: 302 distinct objective_project_id ещё не в mapping, НО лишь +-- 156 distinct project_name не в mapping под group='Екатеринбург' (многие +-- project_id делят одно имя; многие имена уже сматчены под другим id). MV и +-- UNIQUE-констрейнт работают по имени, 150 дедупил по имени — поэтому здесь +-- тоже дедуп по project_name. Матчить 302 id создало бы дубль-имя конфликты, +-- которые ON CONFLICT молча отбросит. +-- +-- НОРМАЛИЗАЦИЯ (идентична 150; калибровка prod read-only 2026-06-14, snapshot +-- 2026-06-10): strip ЖК-boilerplate ('Жилой комплекс/квартал/район/дом', +-- 'Клубный дом', 'Микрорайон', 'Квартал', 'ЖК', 'Дом' …), кавычки, +-- stage-суффиксы ('N этап/очередь') ДО similarity. Strip-токены по +-- word-boundary (\m…\M) чтобы не резать середину ('Домино'→'ино'). +-- Dev нормализуется (strip юр.префиксов СЗ/ГК/ООО/Девелопмент/Development/Group…) +-- и dsim = trigram ИЛИ substring-containment. +-- +-- ПРАВИЛО ПРИЁМКИ — PRECISION OVER RECALL (P0: ложный mapping напрямую портит +-- velocity/prices, поэтому держим precision≈1.0 на авто-наборе): +-- nsim = similarity(norm(project_name), norm(comm_name)) +-- dsim = trigram(norm dev) OR substring-containment +-- corroborated = (dsim >= 0.5) OR (district точно совпал) +-- generic_guarded = norm(project_name) ∈ стоплист generic single-token имён, +-- где межзастройщицкие коллизии РЕАЛЬНЫ (наблюдённые FP в band'е 1.0: +-- «Солнечный», «Прекрасный», «Невский», «Стрелки», «Парковый» (over-strip +-- «Жилой квартал Парковый»→'парковый' ≡ obj 'Парковый квартал' PRINZIP), +-- «Тихий центр», плюс risk-слова центральный/самоцветы/южные/теплые/ +-- московский/сириус/нескучный сад). +-- accept = +-- (nsim >= 0.85 AND NOT generic_guarded) -- A: near-exact + distinctive +-- OR (nsim >= 0.85 AND generic_guarded AND corroborated) -- A': generic, но dev/district подтверждён +-- OR (nsim >= 0.62 AND nsim < 0.85 AND corroborated) -- B: good name + dev/district +-- Калибровка по реальным band'ам (prod, BEGIN…ROLLBACK 2026-06-14): +-- • Band A нетронутый дал 29 near-exact (все nsim=1.000). 6 — generic FP с +-- чужим застройщиком → отсечены guard'ом. Остаток 23 — distinctive +-- (RedRock/Геометрия/Башня Времени/…), межзастройщицкая коллизия +-- практически невозможна → авто без dev. Precision на этих 23 ≈ 1.0. +-- • Band B (0.62-0.85): 10 кандидатов, единственный corroborated — «Рио-3» +-- (dsim=1.0 Стройтэк) — но его obj_id 31874 уже занят 150 ('Рио'); «Рио-3» +-- это фаза того же ЖК → исключён obj_id-guard'ом. Прочие 9 uncorroborated +-- (чужой dev / Kurgan-застройщик / уже-сматченный «Самоцветы Урала») → +-- отброшены. ИТОГ band B = 0 авто (намеренно: ни одного безопасного). +-- ИТОГ: 23 новых mapping (все nsim=1.000, decision=A_auto). 133 имени остаются +-- на manual (Anton): 19 без кандидата в DOM.РФ (города-спутники / нет ПД), +-- 6 generic-guarded near-exact, 108 низко-/средне-confident без подтверждения. +-- +-- COLLISION HANDLING +-- 1. Лучший domrf-obj на каждый objective project: DISTINCT ON (project_name) +-- по nsim DESC, dsim DESC. +-- 2. Один obj_id ≤ один objective project: ROW_NUMBER per obj_id +-- ORDER BY corroborated DESC, nsim DESC. +-- 3. obj_id, уже занятые существующими 263 mapping, исключаются (NOT IN). +-- +-- ПОЧЕМУ ТРЕТИЙ ПРОХОД, А НЕ ПЕРЕДЕЛКА 150 +-- 150 уже применён на prod (_schema_migrations) — переписывать его нельзя +-- (auto-apply once). 155 — аддитивный: только INSERT новых имён, НИКАКИХ +-- DROP/UPDATE существующих 263 строк. +-- +-- GIN-ИНДЕКС +-- Существующий idx_domrf_kn_objects_comm_name_trgm — GIST partial WHERE is_ekb=true, +-- а ВСЕ 1543 строки latest-snapshot region_cd=66 имеют is_ekb=false (verified) → +-- индекс покрывает 0 строк, для backfill бесполезен. Добавляем НЕпартиальный +-- GIN trgm индекс (IF NOT EXISTS) на comm_name: он используется %-оператором +-- (verified EXPLAIN: Bitmap Index Scan) для будущих/runtime ad-hoc match-запросов. +-- Честно: САМ backfill ниже использует similarity()>порог в CTE-join (не %), +-- что НЕ триггерит trgm-индекс — но это one-shot 156×1543 Nested Loop (~4 s, +-- измерено на 150), приемлемо. Индекс добавлен под требование задачи и runtime, +-- не под эту миграцию. Таблица 9 MB / 10699 строк → обычный CREATE INDEX +-- (не CONCURRENTLY — нельзя в транзакции) строится мгновенно, lock тривиален. +-- +-- DEPENDENCIES: objective_lots, objective_complex_mapping, domrf_kn_objects, +-- pg_trgm (1.6, установлен), mv_layout_velocity (UNIQUE pk mv_layout_velocity_pk +-- на (obj_id, room_bucket) → CONCURRENTLY валиден). +-- DEPLOY: auto-applied by deploy.yml через _schema_migrations (ровно один раз по NN). +-- Применяется ДО compose up -d. ORDER: schema/data first → backend deploy second. +-- IDEMPOTENT: ON CONFLICT (objective_complex_name, objective_group) DO NOTHING + +-- CREATE INDEX IF NOT EXISTS. Re-apply добавит 0 строк (verified BEGIN/INSERT +-- x2/ROLLBACK на prod: INSERT 0 23, затем INSERT 0 0). + +BEGIN; + +-- Non-partial GIN trgm index for %-operator / runtime fuzzy match (см. шапку). +CREATE INDEX IF NOT EXISTS idx_domrf_kn_objects_comm_name_gin_trgm + ON domrf_kn_objects USING gin (comm_name gin_trgm_ops); + +WITH +-- 1. Latest DOM.РФ snapshot, регион 66 (Свердловская обл / ЕКБ-агломерация). +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, + -- \m…\M = PG word boundaries → токены матчатся как ЦЕЛЫЕ слова, не подстроки. + btrim(regexp_replace( + regexp_replace( + regexp_replace(lower(coalesce(o.comm_name, '')), + '\m(жилой комплекс|жилой квартал|жилой район|жилой дом|жилые башни|жилые кварталы|клубный дом|концепт-проект|семейный квартал|микрорайон|квартал|паркинг|жк|жр|жд|дом)\M', + ' ', 'g'), + '\d+(\.\d+)?\s*(этап|очередь)', ' ', 'g'), + '[«»"''.,()\-]+', ' ', 'g')) AS name_norm, + 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), +-- исключая уже сматченные в 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, + 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.*, + 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, + -- normalized objective name, whitespace-collapsed (для generic-guard сравнения) + btrim(regexp_replace(u.name_norm, '\s+', ' ', 'g')) AS obj_nn, + 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, + 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, + (u.district IS NOT NULL AND d.district_name IS NOT NULL + AND lower(btrim(u.district)) = lower(btrim(d.district_name)))::int AS district_match + 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 +), +classified AS ( + SELECT + b.*, + (b.dsim >= 0.5 OR b.district_match = 1) AS corroborated, + (b.obj_nn = ANY (ARRAY[ + 'солнечный','прекрасный','невский','стрелки','парковый','центральный', + 'тихий центр','самоцветы','южные','теплые','московский','сириус','нескучный сад' + ])) AS generic_guarded + FROM best_per_proj b +), +accepted AS ( + SELECT * + FROM ( + SELECT + c.*, + ROW_NUMBER() OVER ( + PARTITION BY c.obj_id + ORDER BY c.corroborated DESC, c.nsim DESC, c.project_name + ) AS rn_obj + FROM classified c + WHERE ( + (c.nsim >= 0.85 AND NOT c.generic_guarded) + OR (c.nsim >= 0.85 AND c.generic_guarded AND c.corroborated) + OR (c.nsim >= 0.62 AND c.nsim < 0.85 AND c.corroborated) + ) + AND c.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_trgm_v3', + round(a.nsim::numeric, 3), + 'OBJ-2 conservative trgm backfill #307' + || ' | 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 ВНЕ транзакции (после COMMIT): CONCURRENTLY требует non-tx-блок и +-- UNIQUE index (mv_layout_velocity_pk на (obj_id, room_bucket) существует) и не +-- блокирует читателей. MV уже populated → CONCURRENTLY безопасен. deploy.yml +-- прогоняет файл через `psql < file` без --single-transaction, поэтому COMMIT +-- выше закрывает txn и этот стейтмент исполняется в собственной транзакции +-- (тот же приём, что в 150_objective_mapping_backfill.sql). +REFRESH MATERIALIZED VIEW CONCURRENTLY mv_layout_velocity;