feat(site_finder): velocity coverage gap-fill (spatial+name) — #968 949-A #1076

Merged
bot-backend merged 1 commit from feat/competitor-velocity-spatial-fallback-968 into main 2026-06-06 17:07:40 +00:00

View file

@ -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()