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:
parent
bc9d277309
commit
29222585d0
2 changed files with 132 additions and 11 deletions
|
|
@ -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} — точные координаты "
|
||||
|
|
|
|||
|
|
@ -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 "")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue