From d9c2157d02e7a567004a5ade5c34d2f1ea92de5d Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 6 Jun 2026 22:05:15 +0500 Subject: [PATCH] feat(site_finder): velocity coverage gap-fill via spatial+name fallback (#968 949-A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For competitors missing from objective_complex_mapping, bridge to objective velocity: nearest objective-bearing complex within 200m whose name tolerantly matches → complex_id → objective_lots.project_name → objective_corpus_room_month. Prod-measured: 148 → 329 mapped competitors (2.2×), validated by EXPLAIN+execute. - nearest_cx CTE UNION'd into mapped (gap-fill only; primary 148 byte-identical, no double-count — UNION dedups, DISTINCT ON = one complex per competitor) - candidates restricted to complexes WITH objective_lots.project_name (data-bearing): naive nearest-any gave +37; data-bearing nearest gives +181 (the real win) - empty-comm_name guard avoids LIKE '%%' spatial-only leak - velocity.py unchanged (its has_mapping coverage-gate is a separate concern); parcel.py unchanged (relevance_weight already satisfies DoD) Blast radius narrow: affects relevance_weight (via stage_at_horizon) for newly-mapped competitors; §22 market-pulse velocity (velocity.py) untouched. deep-code-reviewer ⚠️ minor (approved); review items addressed. Part of EPIC #949. --- .../app/services/site_finder/competitors.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/backend/app/services/site_finder/competitors.py b/backend/app/services/site_finder/competitors.py index fddfcac2..ba652668 100644 --- a/backend/app/services/site_finder/competitors.py +++ b/backend/app/services/site_finder/competitors.py @@ -41,6 +41,12 @@ _TIME_WINDOW_MONTHS: dict[str, float] = { # site_status значения, считающиеся «активными» _ACTIVE_STATUSES = frozenset({"sales", "construction"}) +# #968 (949-A): радиус спатиального матча domrf↔complexes для velocity gap-fill. +# 200м + tolerant-name + nearest-per-domrf → подтверждаем «тот же ЖК» И гео, И +# именем. Чистый name-match даёт ~40 cross-city false positives; чистый spatial +# рискует приписать velocity СОСЕДНЕГО ЖК. Подбор по проду: 148 → ~300-400 (#968). +_VELOCITY_MATCH_RADIUS_M: float = 200.0 + # ────────────────────────────────────────────────────────────────────────────── # #949 PR B (ТЗ §9.1, §16) — relevance_weight: детерминированная (без LLM) # взвешенная оценка релевантности конкурента 0..1 + explainability-breakdown. @@ -287,10 +293,64 @@ _COMPETITORS_SQL = text(""" AND longitude IS NOT NULL ORDER BY obj_id, snapshot_date DESC NULLS LAST ), + nearest_cx AS ( + -- #968 (949-A) velocity gap-fill: для domrf-объектов, КОТОРЫХ НЕТ + -- в явном objective_complex_mapping, ищем «тот же ЖК» в complexes по + -- ГЕОГРАФИИ (≤ :velocity_match_radius_m м) И ТОЛЕРАНТНОМУ имени. + -- DISTINCT ON (obj_id) ORDER BY distance → ровно ОДИН (ближайший) + -- complex на конкурента: имя+гео подтверждают «тот же ЖК» (precision + -- важна — velocity питает forecast). Спатиальный join дёшев, т.к. + -- complexes мал (~1.5k строк с geom) — НЕ потому что latest_obj + -- пре-фильтрован (радиус участка применяется ниже, в финальном SELECT). + SELECT DISTINCT ON (o.obj_id) + o.obj_id, + c.id AS complex_id + FROM latest_obj o + JOIN complexes c + ON c.latitude IS NOT NULL + AND c.longitude IS NOT NULL + AND c.canonical_name IS NOT NULL + -- #968: только complexes, у которых ЕСТЬ objective velocity-данные → + -- «ближайший» выбирается среди data-bearing. Иначе ближайший complex + -- без objective_lots съедал бы матч (prod: +37 наивно → +181 так). + AND EXISTS ( + SELECT 1 FROM objective_lots ol + WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL + ) + AND ST_DWithin( + ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, + ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography, + CAST(:velocity_match_radius_m AS float) + ) + AND ( + lower(btrim(o.comm_name)) = lower(btrim(c.canonical_name)) + OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%' + OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%' + ) + WHERE o.comm_name IS NOT NULL + AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё → spatial-only + AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping) + ORDER BY o.obj_id, + ST_Distance( + ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, + ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography + ) ASC + ), mapped AS ( + -- PRIMARY (unchanged): 148 явных маппингов — velocity для них byte-identical. SELECT cm.domrf_obj_id AS obj_id, cm.objective_complex_name FROM objective_complex_mapping cm + UNION + -- FALLBACK (gap-fill only): все project_name ОДНОГО ближайшего complex'а + -- (у комплекса может быть несколько корпус-project_name → velocity + -- легитимно суммируется по ним, но НЕ по нескольким комплексам). + SELECT nc.obj_id, + ol.project_name AS objective_complex_name + FROM nearest_cx nc + JOIN objective_lots ol + ON ol.complex_id = nc.complex_id + AND ol.project_name IS NOT NULL ), velocity AS ( SELECT @@ -433,6 +493,7 @@ def get_competitors( "time_window_months": time_window_months, "window_interval": window_interval, "obj_class_filter": request.obj_class_filter, + "velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M, }, ) .mappings()