gendesign/backend/app/services/site_finder/district_resolver.py
Light1YT 681a922d99
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-worker (push) Successful in 2m32s
Deploy / deploy (push) Successful in 1m7s
feat(forecast): resolve admin district -> micro set in §9.x market/supply/sales filters
/analyze passes the official ЕКБ admin district (ekb_districts polygon, e.g.
'Кировский'), but objective_lots/corpus_room_month store informal micro-districts
('Втузгородок','ЖБИ') -> admin name matched 0 rows -> silent empty forecast.

Add resolve_objective_districts() (site_finder/district_resolver.py) mapping an
admin name to its clean micros via ekb_district_alias (note IS NULL), with
None -> EKB-wide fallback and raw-micro pass-through. Wire into the objective_lots
district filters of market_metrics (§9.2 stock+sales), supply_layers L1 (§9.3),
and sales_series Sources A+B (crm shares the micro vocab, prod-verified),
switching the scalar filter to psycopg3-safe = ANY(CAST(:districts AS text[])).
supply_layers L2/L3 keep the admin name (domrf_kn_objects.district_name is admin vocab).

Prod: Кировский/Ленинский/Орджоникидзевский obj_count 0 -> 32/64/31.
Tests mutation-verified non-vacuous. 192 module tests pass; ruff clean. Refs #969 #949.
2026-06-05 07:03:37 +05:00

144 lines
8 KiB
Python
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.

"""District-resolver — мост между ДВУМЯ районными вокабулярами ЕКБ (Step 2).
Контекст бага (ground-truthed на prod, см. `data/sql/131_district_vocab_map.sql`):
• §9.x market/supply/sales метрики фильтруют `objective_lots.district` (и
`objective_corpus_room_month.district`) — это НЕФОРМАЛЬНЫЕ МИКРО-районы ЕКБ
(«Втузгородок», «ЖБИ», «Уралмаш» … — 35 distinct значений).
Но `/analyze` резолвит у участка ОФИЦИАЛЬНЫЙ АДМИН-район из `ekb_districts`
(8 полигонов: «Кировский», «Ленинский», «Орджоникидзевский» …).
• Админ-имя → 0 строк в objective_lots → молча пустой прогноз. Напр.
market_metrics('Кировский') → obj_count=0 (а 'Академический' случайно работает,
т.к. он есть в ОБОИХ вокабулярах).
Step 1 (PR #1053, УЖЕ НА ПРОДЕ) добавил таблицу-словарь `ekb_district_alias`
(admin_district → micro_district, note). Этот модуль — Step 2: резолвер, который
разворачивает админ-имя в список микро-районов, чтобы фильтры §9.x перестали
возвращать пустоту.
Семантика `resolve_objective_districts(db, district)` — см. docstring функции.
psycopg v3: все binds через `CAST(:x AS type)`, НИКОГДА `:x::type`. Read-only SELECT.
"""
from __future__ import annotations
import logging
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Сентинел «район не определён» — приходит из /analyze, когда участок не попал ни в
# один из 8 полигонов ekb_districts (вне ЕКБ / без координат). Трактуем как None →
# EKB-wide fallback (без district-фильтра), а не как буквальное имя района.
_UNDETERMINED_DISTRICT: str = "не определён"
# Список 8 официальных АДМИН-районов ЕКБ (district_name из ekb_districts с непустым
# geom). Кешируем per-process: он фактически статичен (8 полигонов, меняется только
# при пере-импорте границ), а резолвер бьётся per-analyze — лишний round-trip ни к
# чему. Lazy-init на первом вызове; сбрасывается через `_reset_admin_cache()` в тестах.
_ADMIN_NAMES_CACHE: frozenset[str] | None = None
# Имена 8 админ-районов: те, у кого в ekb_districts есть непустой geom (= ekb_districts_geom
# view). 'не определён' (9-я строка-dimension без geom) сюда НЕ попадает — и не должен.
_ADMIN_NAMES_SQL = text(
"""
SELECT district_name
FROM ekb_districts
WHERE geom IS NOT NULL
"""
)
# Чистые ЕКБ-микро для одного админ-района. note IS NULL отсекает cross-city
# (Н.Тагил/Каменск, собранные в bucket «Чкаловский») и ambiguous-пометки —
# нам нужны только однозначные ЕКБ-микро (см. 131_district_vocab_map.sql).
_MICROS_FOR_ADMIN_SQL = text(
"""
SELECT micro_district
FROM ekb_district_alias
WHERE admin_district = CAST(:d AS text)
AND note IS NULL
ORDER BY micro_district
"""
)
def _admin_names(db: Session) -> frozenset[str]:
"""Множество 8 официальных админ-имён ЕКБ (с непустым geom), кеш per-process.
На ошибке БД возвращает пустой set (logged) — резолвер тогда трактует ЛЮБОЙ
вход как raw-микро (legacy pass-through), что безопаснее, чем уронить /analyze.
"""
global _ADMIN_NAMES_CACHE
if _ADMIN_NAMES_CACHE is not None:
return _ADMIN_NAMES_CACHE
try:
rows = db.execute(_ADMIN_NAMES_SQL).scalars().all()
_ADMIN_NAMES_CACHE = frozenset(str(r) for r in rows if r)
except Exception:
# Не кешируем пустой результат-от-сбоя: следующий вызов попробует снова.
logger.exception("district_resolver: admin-names lookup failed")
return frozenset()
return _ADMIN_NAMES_CACHE
def _reset_admin_cache() -> None:
"""Сбросить per-process кеш админ-имён (для тестов / форс-рефреша границ)."""
global _ADMIN_NAMES_CACHE
_ADMIN_NAMES_CACHE = None
def resolve_objective_districts(db: Session, district: str | None) -> list[str] | None:
"""Развернуть district-вход в набор МИКРО-районов для фильтра objective_lots.
Решает рассинхрон вокабуляров ЕКБ (admin polygons vs informal micro-districts).
Возвращаемое значение — контракт для вызывающего фильтра:
• ``None`` → фильтровать EKB-wide (БЕЗ district-фильтра). Это происходит
когда:
- вход ``None`` или ``'не определён'`` (участок вне 8
полигонов — нечего сужать), ЛИБО
- вход — валидное админ-имя, но у него НЕТ чистых алиасов
(на практике не случается: все 8 покрыты сидом; страховка).
• ``[micro, …]`` → фильтровать ``ol.district = ANY(...)``:
- вход — одно из 8 АДМИН-имён → его чистые микро
(note IS NULL: cross-city/ambiguous исключены), ЛИБО
- вход — НЕ админ-имя (raw МИКРО, переданный внутренним
legacy-вызовом) → ``[district]`` как есть (сохраняем
прежнее поведение фильтра-по-микро).
Никогда не возвращает пустой список — вместо «0 строк гарантированно» отдаёт
``None`` (EKB-wide), чтобы прогноз не схлопывался молча в пустоту (это и есть
суть фикса).
psycopg v3: bind через CAST(:d AS text). Read-only.
"""
if district is None or district == _UNDETERMINED_DISTRICT:
return None
admin_names = _admin_names(db)
if district not in admin_names:
# Raw микро-район (legacy внутренний вызов) — фильтруем по нему как есть.
return [district]
# district — официальное админ-имя: разворачиваем в чистые ЕКБ-микро.
try:
rows = db.execute(_MICROS_FOR_ADMIN_SQL, {"d": district}).scalars().all()
micros = [str(m) for m in rows]
except Exception:
logger.exception(
"district_resolver: micro-lookup failed (admin=%s) — falling back to EKB-wide",
district,
)
return None
if not micros:
# Админ без чистых алиасов (дыра в словаре) → EKB-wide fallback, НЕ пустой набор.
logger.warning(
"district_resolver: admin '%s' has no clean micro aliases — EKB-wide fallback",
district,
)
return None
logger.debug("district_resolver: admin '%s' → micros %s", district, micros)
return micros