fix(tradein): coarse-geo downgrade works without DaData on prod (#693)

PR #707's geo-quality gate was conditional on dadata.qc_geo>=2, but on prod
DaData is off (token unset) so dadata is always None — the gate never fired,
even for region/city centroids (QA smoke FAIL: 'фывапролд 999' → confident
medium). Add a DaData-independent coarse signal: a real house address always
ends in a house number; a city/region centroid full_address does not. OR it
into the existing gate (anchor_tier-None + median>0 guards unchanged).

Cache-agnostic (reads geo.full_address regardless of provider/cache) and
zero false-downgrades on real addresses (every real house has a number).
Postcode (6d) / year (4d) excluded by digit-boundary regex.

Refs #693
This commit is contained in:
bot-backend 2026-05-30 15:04:16 +03:00
parent bc9d277309
commit 29222585d0
2 changed files with 132 additions and 11 deletions

View file

@ -1288,6 +1288,38 @@ def _compute_same_building_anchor(
}
# ── #693 prod-fix: coarse-geocode detector (DaData-independent) ─────────────
# Дом всегда оканчивается номером (1-3 цифры, опц. литера корпуса). Centroid
# города/региона его НЕ содержит. Прод-сигнал грубости геокода, работающий БЕЗ
# DaData: на проде DaData может быть off (token не сконфигурирован) → dadata.qc_geo
# всегда None → старый гейт #707 (условный на qc_geo>=2) НЕ срабатывал НИКОГДА,
# даже для региона/города. Кэш-агностичен — смотрит на geo.full_address, каким бы
# провайдером/кэшем он ни был получен.
#
# Граница (?<!\d)\d{1,3}(?!\d) исключает почтовый индекс (6 цифр) и год постройки
# (4 цифры) — они не матчат «дом». Числовые улицы («8 Марта») матчат свой номер →
# НЕ даунгрейдятся (консервативно: реальная улица, аналоги рядом есть; принцип
# #707 — никаких ложных downgrade важнее, чем отлов всех coarse-кейсов).
_HOUSE_NUMBER_RE = re.compile(r"(?<!\d)\d{1,3}[а-яёa-z]?(?!\d)", flags=re.IGNORECASE)
def _geocode_is_coarse(geo: GeocodeResult) -> bool:
"""True если геокод разрешился лишь до centroid'а НП/города/региона (без дома).
Два сигнала (OR):
1. provider confidence == 'locality' явный centroid-маркер (на будущее:
текущие провайдеры его не эмитят, но enum/кэш-колонка допускают, и так
геокодер можно улучшить позже без правки гейта).
2. в geo.full_address нет house-number токена (1-3 цифры) геокодер не дошёл
до дома, вернул центр НП/города/региона.
Гарантирует ZERO ложных downgrade на реальных адресах: у любого реального дома
есть номер токен матчится не coarse.
"""
if geo.confidence == "locality":
return True
return _HOUSE_NUMBER_RE.search(geo.full_address or "") is None
# ── Time-budget guard (#654) ────────────────────────────────────────────────
async def _with_budget(coro: Any, budget_s: float, *, label: str) -> Any:
"""Await `coro` under an asyncio.wait_for() time budget.
@ -1930,16 +1962,19 @@ async def estimate_quality(
# 2) якорь «тот же дом» НЕ сработал (anchor_tier is None) — иначе оценка
# стоит на реальных комплах дома (#691 path) и геоцентроид не важен;
# 3) есть радиусное число (median_price > 0) — что квалифицировать.
if (
dadata is not None
and dadata.qc_geo is not None
and dadata.qc_geo >= 2
and anchor_tier is None
and median_price > 0
):
_coarse_label = {2: "населённого пункта", 3: "города", 4: "региона"}.get(
dadata.qc_geo, "населённого пункта"
)
# ИСТОЧНИКИ сигнала грубости (OR): dadata.qc_geo>=2 (если DaData активна) ИЛИ
# _geocode_is_coarse(geo) — прод-фолбэк, работает БЕЗ DaData (#693 QA-fail:
# на проде DaData off → qc_geo всегда None → старый гейт не срабатывал).
dadata_coarse = dadata is not None and dadata.qc_geo is not None and dadata.qc_geo >= 2
geo_coarse = _geocode_is_coarse(geo)
if (dadata_coarse or geo_coarse) and anchor_tier is None and median_price > 0:
# Уровень грубости берём из DaData (точнее), иначе generic «населённого пункта».
if dadata_coarse:
_coarse_label = {2: "населённого пункта", 3: "города", 4: "региона"}.get(
dadata.qc_geo, "населённого пункта"
)
else:
_coarse_label = "населённого пункта"
confidence = "low"
explanation = (explanation or "") + (
f" Адрес определён лишь до уровня {_coarse_label} — точные координаты "

View file

@ -23,6 +23,7 @@ import anyio
from app.services.estimator import (
_compute_same_building_anchor,
_geocode_is_coarse,
_normalize_building_key,
)
@ -379,6 +380,7 @@ def _run_estimate(
payload=None,
radius_analogs: list[dict[str, Any]] | None = None,
dadata_result: Any | None = None,
geo_result: Any | None = None,
):
"""estimate_quality со всеми I/O застабленными; _fetch_anchor_comps форсирован.
@ -396,7 +398,10 @@ def _run_estimate(
async def _run():
with (
patch.object(settings, "estimate_same_building_anchor_enabled", flag_enabled),
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())),
patch(
"app.services.estimator.geocode",
new=AsyncMock(return_value=geo_result or _make_fake_geo()),
),
patch(
"app.services.estimator.dadata_clean_address",
new=AsyncMock(return_value=dadata_result),
@ -808,3 +813,84 @@ def test_estimate_no_dadata_no_false_downgrade() -> None:
dadata_result=None,
)
assert _APPROX_FLAG not in (est.confidence_explanation or "")
# ── #693 prod-fix: coarse geocode detected WITHOUT DaData (QA-fail #693) ──────
def _make_fake_geo_coarse(
full_address: str = "Россия, Свердловская область, Екатеринбург",
):
"""Геокод-centroid без дома (город/регион) — прод-сценарий, когда DaData off.
full_address не содержит house-number токена _geocode_is_coarse True.
"""
from app.services.geocoder import GeocodeResult
return GeocodeResult(
lat=56.838,
lon=60.605,
full_address=full_address,
provider="yandex",
)
def test_geocode_is_coarse_signal() -> None:
"""Юнит: detector ловит centroid (нет дома) и пропускает реальные адреса."""
from app.services.geocoder import GeocodeResult
def _geo(addr: str, conf: str = "approximate"):
return GeocodeResult(
lat=56.8, lon=60.6, full_address=addr, provider="yandex", confidence=conf
) # type: ignore[arg-type]
# Coarse — нет house-number токена.
assert _geocode_is_coarse(_geo("Россия, Свердловская область, Екатеринбург")) is True
assert _geocode_is_coarse(_geo("Свердловская область")) is True
# Почтовый индекс (6 цифр) НЕ считается домом → всё ещё coarse.
assert _geocode_is_coarse(_geo("Екатеринбург, 620000")) is True
# provider confidence == 'locality' → coarse даже при наличии цифр.
assert _geocode_is_coarse(_geo("улица 8 Марта, 12", conf="locality")) is True
# Precise — есть номер дома → НЕ coarse (zero false downgrade).
assert _geocode_is_coarse(_geo("Екатеринбург, ул. Малышева, 30")) is False
assert _geocode_is_coarse(_geo("ЕКБ, ул. Хохрякова, 48")) is False
assert _geocode_is_coarse(_geo("Екатеринбург, ул.白, 16а")) is False
def test_estimate_coarse_geo_no_dadata_downgrades_to_low() -> None:
"""#693 QA-fail: DaData off (dadata=None), геокод свалился к city-centroid
(нет дома в full_address), якоря нет, но радиусные аналоги есть (median>0)
confidence='low' + флаг «ориентировочной» оценки. Это прод-кейс «фывапролд 999»."""
est = _run_estimate(
anchor_comps=[],
anchor_tier=None,
dadata_result=None,
geo_result=_make_fake_geo_coarse(),
)
assert est.median_price_rub > 0
assert est.confidence == "low"
assert _APPROX_FLAG in (est.confidence_explanation or "")
def test_estimate_coarse_geo_region_no_dadata_downgrades() -> None:
"""#693: регион-centroid («Свердловская область») без DaData → low + флаг."""
est = _run_estimate(
anchor_comps=[],
anchor_tier=None,
dadata_result=None,
geo_result=_make_fake_geo_coarse(full_address="Свердловская область"),
)
assert est.confidence == "low"
assert _APPROX_FLAG in (est.confidence_explanation or "")
def test_estimate_coarse_geo_with_anchor_no_dadata_not_downgraded() -> None:
"""#693 protect #691: coarse-геокод без DaData, НО якорь дома (tier=A) сработал →
НЕ даунгрейдим (оценка стоит на реальных комплах дома, центроид не важен)."""
est = _run_estimate(
anchor_comps=_SB_COMPS_PREMIUM,
anchor_tier="A",
dadata_result=None,
geo_result=_make_fake_geo_coarse(),
)
assert _APPROX_FLAG not in (est.confidence_explanation or "")