gendesign/data/sql/155_objective_mapping_trgm_backfill.sql
bot-backend f9dd903f4d
All checks were successful
Deploy / build-backend (push) Successful in 29s
Deploy / build-worker (push) Successful in 29s
Deploy / deploy (push) Successful in 1m1s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
feat(sql): conservative trgm backfill objective_complex_mapping +23 (#307 OBJ-2) (#1331)
2026-06-14 15:51:38 +00:00

259 lines
16 KiB
PL/PgSQL
Raw 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.

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