chore(objective): fuzzy backfill objective_complex_mapping +115 (#307 OBJ-2) #1310

Merged
bot-backend merged 1 commit from chore/objective-mapping-backfill-307-obj2 into main 2026-06-13 13:58:31 +00:00

View file

@ -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;