feat(site_finder): velocity coverage gap-fill via spatial+name fallback (#968 949-A)
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 5s
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m34s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m57s
CI / backend-tests (push) Successful in 5m43s
Deploy / deploy (push) Successful in 1m9s
CI / backend-tests (pull_request) Successful in 5m56s
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 5s
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m34s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m57s
CI / backend-tests (push) Successful in 5m43s
Deploy / deploy (push) Successful in 1m9s
CI / backend-tests (pull_request) Successful in 5m56s
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.
This commit is contained in:
parent
b77e856d89
commit
d9c2157d02
1 changed files with 61 additions and 0 deletions
|
|
@ -41,6 +41,12 @@ _TIME_WINDOW_MONTHS: dict[str, float] = {
|
||||||
# site_status значения, считающиеся «активными»
|
# site_status значения, считающиеся «активными»
|
||||||
_ACTIVE_STATUSES = frozenset({"sales", "construction"})
|
_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)
|
# #949 PR B (ТЗ §9.1, §16) — relevance_weight: детерминированная (без LLM)
|
||||||
# взвешенная оценка релевантности конкурента 0..1 + explainability-breakdown.
|
# взвешенная оценка релевантности конкурента 0..1 + explainability-breakdown.
|
||||||
|
|
@ -287,10 +293,64 @@ _COMPETITORS_SQL = text("""
|
||||||
AND longitude IS NOT NULL
|
AND longitude IS NOT NULL
|
||||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
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 (
|
mapped AS (
|
||||||
|
-- PRIMARY (unchanged): 148 явных маппингов — velocity для них byte-identical.
|
||||||
SELECT cm.domrf_obj_id AS obj_id,
|
SELECT cm.domrf_obj_id AS obj_id,
|
||||||
cm.objective_complex_name
|
cm.objective_complex_name
|
||||||
FROM objective_complex_mapping cm
|
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 (
|
velocity AS (
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -433,6 +493,7 @@ def get_competitors(
|
||||||
"time_window_months": time_window_months,
|
"time_window_months": time_window_months,
|
||||||
"window_interval": window_interval,
|
"window_interval": window_interval,
|
||||||
"obj_class_filter": request.obj_class_filter,
|
"obj_class_filter": request.obj_class_filter,
|
||||||
|
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue