gendesign/data/sql/150_objective_mapping_backfill.sql
Light1YT d1bc59a5dd
All checks were successful
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m31s
CI / backend-tests (pull_request) Successful in 6m29s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m16s
Deploy / build-worker (push) Successful in 3m34s
Deploy / deploy (push) Successful in 1m28s
chore(objective): fuzzy backfill objective_complex_mapping +115 dev-corroborated (#307 OBJ-2)
P0 goldmine: objective_lots (1.12M, 81% цен) связывается с domrf для velocity/
layout-аналитики. Mapping было 148 → +115 (107 high + 8 mid) = 263.

Fuzzy-match objective(project_name) ↔ domrf_kn_objects(comm_name) LATEST snapshot
region_cd=66 через pg_trgm на НОРМАЛИЗОВАННЫХ именах (strip ЖК-boilerplate с
\m\M word-boundary). Правило приёмки dev-corroborated: nsim≥0.62 AND dsim≥0.5 —
precision over recall для P0-цен (generic-имена с разным застройщиком, напр.
Солнечный/Прекрасный/Стрелки dsim=0.0, отклонены). match_method='auto_fuzzy_v2',
is_reviewed=false (для human-review топ-кандидатов), confidence-tier в note.
Idempotent ON CONFLICT (objective_complex_name, objective_group). REFRESH
CONCURRENTLY mv_layout_velocity после COMMIT.

Refs #307
2026-06-13 18:50:57 +05:00

230 lines
14 KiB
PL/PgSQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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