Оценка: дедуп склеивает улицы вида «40-летия Комсомола», пороги Tier H — в настройки для бэктеста #3552
5 changed files with 268 additions and 41 deletions
|
|
@ -711,6 +711,16 @@ class Settings(BaseSettings):
|
|||
# ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED.
|
||||
estimate_unknown_attr_penalty_enabled: bool = False
|
||||
estimate_unknown_attr_penalty_min_n: int = 5
|
||||
# #3234: Tier H в _fetch_analogs («тот же класс дома»). Порог отдачи и окно
|
||||
# этажности были литералами; вынесены, чтобы бэктест мог сравнить варианты
|
||||
# через env без правки кода. Дефолты = прежнее поведение байт-в-байт.
|
||||
# Прод, Loki 30 сут (12.09): 61 из 86 попыток Tier H уходят в Tier W, в 31 из
|
||||
# них в своей полосе 0 комплов. Менять дефолт — только по MAPE бэктеста.
|
||||
# Ручки временные: после решения по бэктесту (#3234, шаг 4) вернуть их в
|
||||
# константы движка, как #1970 сделал с остальными неменявшимися estimate_*.
|
||||
# ENV: ESTIMATE_TIER_H_MIN_COMPS, ESTIMATE_TIER_H_FLOORS_TOL.
|
||||
estimate_tier_h_min_comps: int = 5
|
||||
estimate_tier_h_floors_tol: float = 0.30 # total_floors × (1 ± tol)
|
||||
#
|
||||
# is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая
|
||||
# корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard
|
||||
|
|
|
|||
|
|
@ -6551,8 +6551,9 @@ def _fetch_analogs(
|
|||
_normalize_building_key + _house_boundary_regex, тот же принцип что и anchor
|
||||
Tier A) + ST_DWithin(radius_m). Если ≥3 результатов → tier='S'.
|
||||
|
||||
**Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors ±30%.
|
||||
Если ≥5 результатов → возвращаем; tier='H'.
|
||||
**Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors
|
||||
±estimate_tier_h_floors_tol (0.30). Если ≥ estimate_tier_h_min_comps (5)
|
||||
результатов → возвращаем; tier='H'.
|
||||
Пропускается если year_built или total_floors неизвестны.
|
||||
|
||||
**Tier W (wide / current):** текущая логика без year/floors WHERE фильтра.
|
||||
|
|
@ -6747,12 +6748,12 @@ def _fetch_analogs(
|
|||
)
|
||||
return _stratify_candidates(tier_s), radius_m > DEFAULT_RADIUS_M, "S"
|
||||
|
||||
# ── Tier H: same class (year ±15, total_floors ±30%) ─────────────────────
|
||||
# ── Tier H: same class (year ±15, total_floors ±tol, #3234: tol/порог в settings) ─
|
||||
if year_built is not None and total_floors is not None:
|
||||
year_min = year_built - 15
|
||||
year_max = year_built + 15
|
||||
tf_min = math.floor(total_floors * 0.7)
|
||||
tf_max = math.ceil(total_floors * 1.3)
|
||||
tf_min = math.floor(total_floors * (1 - settings.estimate_tier_h_floors_tol))
|
||||
tf_max = math.ceil(total_floors * (1 + settings.estimate_tier_h_floors_tol))
|
||||
|
||||
tier_h_rows = (
|
||||
db.execute(
|
||||
|
|
@ -6860,7 +6861,7 @@ def _fetch_analogs(
|
|||
min_n=settings.estimate_unknown_attr_penalty_min_n,
|
||||
)
|
||||
tier_h.sort(key=lambda r: r.get("relevance_score") or 0.0)
|
||||
if len(tier_h) >= 5:
|
||||
if len(tier_h) >= settings.estimate_tier_h_min_comps:
|
||||
logger.info(
|
||||
"analogs tier=H year=%d±15 tf=%d-%d → %d results",
|
||||
year_built,
|
||||
|
|
@ -7328,15 +7329,18 @@ _DEDUP_STREET_TYPE_ALT = "|".join(
|
|||
)
|
||||
# «<тип>[.] <название>»: название = опциональная ведущая цифро-группа + 1-3
|
||||
# буквенных слова, до запятой/цифры. Ведущая цифра нужна для нумерованных улиц
|
||||
# («8 Марта», «40 лет Октября», «22 Партсъезда»); она разрешена ТОЛЬКО перед
|
||||
# буквенным словом (?:\d+\s+)?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия,
|
||||
# («8 Марта», «40 лет Октября», «22 Партсъезда»; #2291 класс 4 — и через дефис:
|
||||
# «40-летия Комсомола», «22-го Партсъезда»; однобуквенный порядковый «2-я
|
||||
# Новосибирская», «3-й Конный», в т.ч. перед нумерованным именем «2-й переулок
|
||||
# 1 Мая»); она разрешена ТОЛЬКО перед буквенным словом
|
||||
# (?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия,
|
||||
# не сопровождаемое буквой) в токен не попадает: «Белинского 86» → «белинского».
|
||||
# #2269 (б): тип-суффикс может быть приклеен запятой/точкой без пробела
|
||||
# («ул.,6к1»): после `\.?` разрешаем ЛИБО пробел (обычный формат), ЛИБО запятую
|
||||
# (→ следом номер дома). Ветка «тип ПЕРЕД именем» — только для формата с пробелом.
|
||||
_DEDUP_STREET_NAME_RE = re.compile(
|
||||
rf"(?:^|[,\s])(?:{_DEDUP_STREET_TYPE_ALT})\.?\s+"
|
||||
r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})",
|
||||
r"((?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
# #2269 (а): «<название> <тип>» — тип-слово ПОСЛЕ имени (формат domklik:
|
||||
|
|
@ -7348,10 +7352,19 @@ _DEDUP_STREET_NAME_RE = re.compile(
|
|||
# Рассветная». Тип НЕ входит в захват (group 1) — токен байт-в-байт совпадает.
|
||||
_DEDUP_STREET_NAME_SUFFIX_RE = re.compile(
|
||||
r"(?:^|[,\s])"
|
||||
r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+"
|
||||
r"((?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+"
|
||||
rf"(?:{_DEDUP_STREET_TYPE_ALT})(?=\.|,|\s|$)",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
# #2291 класс 4: третий формат порядковой улицы — номер ПЕРЕД типом («8-я улица
|
||||
# Текстильщиков», «2-я ул. Синичкина», «12-й пр. Марьиной Рощи»). Переставляем в
|
||||
# «улица 8-я текстильщиков», чтобы он дал тот же токен, что «улица 8-я Текстильщиков»
|
||||
# и «8-я Текстильщиков улица». Без перестановки ветка 1 взяла бы «текстильщиков»
|
||||
# без номера, а номер нельзя терять: «9-я Парковая, 5» и «12-я Парковая, 5» — разные дома.
|
||||
_DEDUP_ORDINAL_BEFORE_TYPE_RE = re.compile(
|
||||
rf"(^|[,\s])(\d+-[а-яё]{{1,2}})\s+((?:{_DEDUP_STREET_TYPE_ALT})\.?)\s+",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
# #2269 (в): fallback без уличного типа — «микрорайон/мкр/мкр-н/м-н <имя>» или
|
||||
# «<имя> микрорайон». Имя = 1-3 буквенных слова. Токен получает префикс «mkr »,
|
||||
# чтобы НЕ пересекаться с уличными токенами (микрорайон ≠ одноимённая улица).
|
||||
|
|
@ -7537,6 +7550,7 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]:
|
|||
# #2291 (класс 2): «ё» → «е» — «Шевелёва»/«Шевелева» и «Миномётчиков»/
|
||||
# «Минометчиков» должны схлопываться в один канонический токен.
|
||||
s = s.replace("ё", "е")
|
||||
s = _DEDUP_ORDINAL_BEFORE_TYPE_RE.sub(r"\1\3 \2 ", s)
|
||||
# Ветка 1 (существующая, #2265): тип ПЕРЕД именем («улица Рассветная»).
|
||||
# Пробуется ПЕРВОЙ — новые ветки только если она дала пусто. Логика не тронута.
|
||||
m = _DEDUP_STREET_NAME_RE.search(s)
|
||||
|
|
|
|||
|
|
@ -2,19 +2,19 @@
|
|||
"calibration": {
|
||||
"high": {
|
||||
"coverage_pct": 75.0,
|
||||
"mape_pct": 19.6,
|
||||
"mape_pct": 19.97,
|
||||
"n": 8,
|
||||
"n_covered": 6
|
||||
},
|
||||
"low": {
|
||||
"coverage_pct": 83.59,
|
||||
"coverage_pct": 83.67,
|
||||
"mape_pct": 15.64,
|
||||
"n": 1562,
|
||||
"n_covered": 1029
|
||||
"n_covered": 1030
|
||||
},
|
||||
"medium": {
|
||||
"coverage_pct": 70.0,
|
||||
"mape_pct": 27.59,
|
||||
"mape_pct": 26.53,
|
||||
"n": 30,
|
||||
"n_covered": 21
|
||||
}
|
||||
|
|
@ -26,17 +26,17 @@
|
|||
],
|
||||
"expected_sold": {
|
||||
"overall": {
|
||||
"mape_pct": 15.88,
|
||||
"mape_pct": 15.92,
|
||||
"median_bias_pct": 2.87,
|
||||
"n": 1269,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -10.88,
|
||||
"p75_pct": 22.98
|
||||
"p25_pct": -10.94,
|
||||
"p75_pct": 22.81
|
||||
},
|
||||
"per_area_bucket": {
|
||||
"0 <30": {
|
||||
"mape_pct": 19.76,
|
||||
"median_bias_pct": -1.49,
|
||||
"median_bias_pct": -1.02,
|
||||
"n": 161,
|
||||
"p25_pct": -16.45,
|
||||
"p75_pct": 34.7
|
||||
|
|
@ -45,21 +45,21 @@
|
|||
"mape_pct": 15.64,
|
||||
"median_bias_pct": 4.37,
|
||||
"n": 503,
|
||||
"p25_pct": -10.58,
|
||||
"p25_pct": -10.56,
|
||||
"p75_pct": 22.86
|
||||
},
|
||||
"2 44-62": {
|
||||
"mape_pct": 13.86,
|
||||
"median_bias_pct": 4.15,
|
||||
"mape_pct": 13.93,
|
||||
"median_bias_pct": 4.13,
|
||||
"n": 379,
|
||||
"p25_pct": -7.92,
|
||||
"p75_pct": 23.85
|
||||
"p25_pct": -8.12,
|
||||
"p75_pct": 23.5
|
||||
},
|
||||
"3 62-85": {
|
||||
"mape_pct": 15.97,
|
||||
"median_bias_pct": -2.33,
|
||||
"n": 179,
|
||||
"p25_pct": -12.1,
|
||||
"p25_pct": -11.99,
|
||||
"p75_pct": 18.18
|
||||
},
|
||||
"4 >=85": {
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
"0": {
|
||||
"label": "студия",
|
||||
"mape_pct": 19.76,
|
||||
"median_bias_pct": -1.49,
|
||||
"median_bias_pct": -1.02,
|
||||
"n": 161,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -16.45,
|
||||
|
|
@ -86,17 +86,17 @@
|
|||
"median_bias_pct": 4.37,
|
||||
"n": 503,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -10.58,
|
||||
"p25_pct": -10.56,
|
||||
"p75_pct": 22.86
|
||||
},
|
||||
"2": {
|
||||
"label": "2к",
|
||||
"mape_pct": 13.86,
|
||||
"median_bias_pct": 4.15,
|
||||
"mape_pct": 13.93,
|
||||
"median_bias_pct": 4.13,
|
||||
"n": 379,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -7.92,
|
||||
"p75_pct": 23.85
|
||||
"p25_pct": -8.12,
|
||||
"p75_pct": 23.5
|
||||
},
|
||||
"3": {
|
||||
"label": "3к",
|
||||
|
|
@ -104,7 +104,7 @@
|
|||
"median_bias_pct": -2.33,
|
||||
"n": 179,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -12.1,
|
||||
"p25_pct": -11.99,
|
||||
"p75_pct": 18.18
|
||||
},
|
||||
"4": {
|
||||
|
|
@ -126,11 +126,11 @@
|
|||
"p75_pct": -12.79
|
||||
},
|
||||
"комфорт": {
|
||||
"mape_pct": 10.68,
|
||||
"mape_pct": 10.69,
|
||||
"median_bias_pct": -5.78,
|
||||
"n": 425,
|
||||
"p25_pct": -15.88,
|
||||
"p75_pct": 4.49
|
||||
"p25_pct": -15.98,
|
||||
"p75_pct": 4.54
|
||||
},
|
||||
"премиум": {
|
||||
"mape_pct": null,
|
||||
|
|
@ -140,11 +140,11 @@
|
|||
"p75_pct": null
|
||||
},
|
||||
"эконом": {
|
||||
"mape_pct": 20.17,
|
||||
"mape_pct": 20.22,
|
||||
"median_bias_pct": 18.98,
|
||||
"n": 697,
|
||||
"p25_pct": 1.25,
|
||||
"p75_pct": 41.97
|
||||
"p25_pct": 0.96,
|
||||
"p75_pct": 42.31
|
||||
},
|
||||
"элит": {
|
||||
"mape_pct": 36.11,
|
||||
|
|
@ -162,9 +162,9 @@
|
|||
},
|
||||
"range_coverage": {
|
||||
"overall": {
|
||||
"coverage_pct": 83.22,
|
||||
"coverage_pct": 83.29,
|
||||
"n": 1269,
|
||||
"n_covered": 1056
|
||||
"n_covered": 1057
|
||||
},
|
||||
"per_confidence": {
|
||||
"high": {
|
||||
|
|
@ -173,9 +173,9 @@
|
|||
"n_covered": 6
|
||||
},
|
||||
"low": {
|
||||
"coverage_pct": 83.59,
|
||||
"coverage_pct": 83.67,
|
||||
"n": 1231,
|
||||
"n_covered": 1029
|
||||
"n_covered": 1030
|
||||
},
|
||||
"medium": {
|
||||
"coverage_pct": 70.0,
|
||||
|
|
|
|||
78
tradein-mvp/backend/tests/test_3234_tier_h_settings.py
Normal file
78
tradein-mvp/backend/tests/test_3234_tier_h_settings.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""#3234: порог и окно этажности Tier H берутся из settings, дефолты = прежние литералы.
|
||||
|
||||
Tier H в _fetch_analogs отдаёт «тот же класс дома» только при ≥ N комплов, иначе
|
||||
уходит в Tier W без фильтра по классу. N и окно total_floors были литералами (5 и
|
||||
×0.7…×1.3); вынесены, чтобы бэктест сравнил варианты через env. Проверяем значения,
|
||||
которые реально уходят в SQL, и тир, который реально возвращается.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import pytest
|
||||
|
||||
import app.services.estimator as est
|
||||
|
||||
|
||||
def _run(n_tier_h: int, total_floors: int) -> tuple[str, dict]:
|
||||
"""_fetch_analogs на mock-БД: Tier H отдаёт n_tier_h строк, Tier W — одну."""
|
||||
tier_h_params: dict = {}
|
||||
|
||||
def side_effect(stmt, params): # type: ignore[no-untyped-def]
|
||||
result = MagicMock()
|
||||
if "tf_min" in params:
|
||||
tier_h_params.update(params)
|
||||
rows = [{"source": "avito", "relevance_score": float(i)} for i in range(n_tier_h)]
|
||||
else:
|
||||
rows = [{"source": "cian", "relevance_score": 0.0}]
|
||||
result.mappings.return_value.all.return_value = rows
|
||||
return result
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = side_effect
|
||||
_, _, tier = est._fetch_analogs(
|
||||
db,
|
||||
lat=56.83,
|
||||
lon=60.6,
|
||||
rooms=2,
|
||||
area=50.0,
|
||||
radius_m=1000,
|
||||
year_built=1975,
|
||||
total_floors=total_floors,
|
||||
)
|
||||
return tier, tier_h_params
|
||||
|
||||
|
||||
def test_defaults_keep_old_literals() -> None:
|
||||
tier, params = _run(n_tier_h=5, total_floors=9)
|
||||
assert (params["tf_min"], params["tf_max"]) == (6, 12) # floor(6.3), ceil(11.7)
|
||||
assert tier == "H"
|
||||
tier, params = _run(n_tier_h=4, total_floors=16)
|
||||
assert (params["tf_min"], params["tf_max"]) == (11, 21) # floor(11.2), ceil(20.8)
|
||||
assert tier == "W"
|
||||
|
||||
|
||||
def test_min_comps_from_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(est.settings, "estimate_tier_h_min_comps", 4)
|
||||
assert _run(n_tier_h=4, total_floors=9)[0] == "H"
|
||||
assert _run(n_tier_h=3, total_floors=9)[0] == "W"
|
||||
|
||||
|
||||
def test_floors_tol_from_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(est.settings, "estimate_tier_h_floors_tol", 0.5)
|
||||
_, params = _run(n_tier_h=5, total_floors=9)
|
||||
assert (params["tf_min"], params["tf_max"]) == (4, 14) # floor(4.5), ceil(13.5)
|
||||
|
||||
|
||||
def test_env_reaches_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Бэктест вариантов идёт через `docker exec -e ESTIMATE_TIER_H_…` — env обязан доехать.
|
||||
from app.core.config import Settings
|
||||
|
||||
monkeypatch.setenv("ESTIMATE_TIER_H_MIN_COMPS", "4")
|
||||
monkeypatch.setenv("ESTIMATE_TIER_H_FLOORS_TOL", "0.5")
|
||||
s = Settings()
|
||||
assert (s.estimate_tier_h_min_comps, s.estimate_tier_h_floors_tol) == (4, 0.5)
|
||||
|
|
@ -977,3 +977,128 @@ def test_parse_street_house_dangling_corpus_letter_no_digit_is_liter() -> None:
|
|||
# сразу после буквы ⇒ литер, см. design §3), остаётся «65к» — НЕ
|
||||
# схлопывается ни во что и не отбрасывается.
|
||||
assert estimator._parse_street_house("ул. Ленина, 65к") == ("ленина", "65к")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# #2291 класс 4 — нумерованные улицы с дефисом («40-летия Комсомола»)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("addr", "expected"),
|
||||
[
|
||||
# Живые форматы источников с прода (tradein-postgres, 2026-09-17).
|
||||
("ул. 40-летия Комсомола,32Г", ("40-летия комсомола", "32г")), # avito
|
||||
(
|
||||
"Россия, Свердловская область, Екатеринбург, улица 40-летия Комсомола, 32Г",
|
||||
("40-летия комсомола", "32г"),
|
||||
), # yandex
|
||||
("р-н Кировский, мкр. ЖБИ, улица 40-летия Комсомола, 32Г", ("40-летия комсомола", "32г")),
|
||||
("Екатеринбург, ЖБИ м-н, 40-летия Комсомола, 32Г", ("40-летия комсомола", "32г")),
|
||||
("Москва, 60-летия Октября проспект, 18 к3", ("60-летия октября", "18/3")),
|
||||
("улица 22-го Партсъезда, 10", ("22-го партсъезда", "10")),
|
||||
# Регрессии: пробельная нумерованная, мкр-шум при уличном типе, bare-street, mkr.
|
||||
("улица 8 Марта, 12", ("8 марта", "12")),
|
||||
("мкр. ЖБИ, улица Сыромолотова, 5", ("сыромолотова", "5")),
|
||||
("Заречный м-н, Готвальда, 24", ("готвальда", "24")),
|
||||
("мкр Пионерский, 5", ("mkr пионерский", "5")),
|
||||
],
|
||||
)
|
||||
def test_parse_street_house_hyphen_numbered_street(addr: str, expected: tuple[str, str]) -> None:
|
||||
assert estimator._parse_street_house(addr) == expected
|
||||
|
||||
|
||||
def test_dedup_collapses_hyphen_numbered_street_four_sources() -> None:
|
||||
# Один физлот в четырёх форматах: раньше avito/yandex давали ('', ''), cian —
|
||||
# ('mkr жби', '40'), и слиться не мог никто. Флаг дедупа не трогаем: на main он
|
||||
# ON по умолчанию (test_dedup_default_is_on), а #3556 удаляет его из Settings —
|
||||
# setattr по несуществующему полю уронил бы тест при любом порядке мержа.
|
||||
addrs = [
|
||||
("avito", "ул. 40-летия Комсомола,32Г"),
|
||||
("yandex", "Россия, Свердловская область, Екатеринбург, улица 40-летия Комсомола, 32Г"),
|
||||
("cian", "р-н Кировский, мкр. ЖБИ, улица 40-летия Комсомола, 32Г"),
|
||||
("domklik", "Екатеринбург, ЖБИ м-н, 40-летия Комсомола, 32Г"),
|
||||
]
|
||||
lots = [
|
||||
_lot(source=s, address=a, area=44.0, price=5_900_000, floor=7, cad=None) for s, a in addrs
|
||||
]
|
||||
assert len(estimator._dedup_cross_source(lots)) == 1
|
||||
|
||||
|
||||
def test_dedup_hyphen_numbered_street_different_houses_stay_distinct() -> None:
|
||||
# Раньше cian ключевал все дома улицы как ('mkr жби', '40'): дома 8А и 35 с
|
||||
# одинаковыми этажом/площадью/ценой сливались в один лот.
|
||||
lots = [
|
||||
_lot(
|
||||
source="cian",
|
||||
address=f"р-н Кировский, мкр. ЖБИ, улица 40-летия Комсомола, {h}",
|
||||
area=44.0,
|
||||
price=5_900_000,
|
||||
floor=7,
|
||||
cad=None,
|
||||
)
|
||||
for h in ("8А", "35")
|
||||
]
|
||||
assert len(estimator._dedup_cross_source(lots)) == 2
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# #2291 класс 4 — однобуквенные порядковые («2-я Новосибирская», «3-й Конный»)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("addr", "expected"),
|
||||
[
|
||||
# Три формата одной улицы (живые адреса с прода, tradein-postgres, 17.09):
|
||||
# cian «тип номер имя» раньше давал ('', ''), avito/yandex «номер тип имя» и
|
||||
# domklik «номер имя тип» теряли номер ('синичкина').
|
||||
("2-я ул. Синичкина,22", ("2-я синичкина", "22")), # avito
|
||||
("Москва, 2-я Синичкина улица, 22", ("2-я синичкина", "22")), # domklik
|
||||
("Россия, Москва, 2-я улица Синичкина, 22", ("2-я синичкина", "22")), # yandex
|
||||
("улица 2-я Новосибирская, 5", ("2-я новосибирская", "5")),
|
||||
("пер. 3-й Конный, 7", ("3-й конный", "7")),
|
||||
("ул. 1-й Пятилетки, 3", ("1-й пятилетки", "3")),
|
||||
("12-й пр. Марьиной Рощи,8с2", ("12-й марьиной рощи", "8")),
|
||||
# Раньше мкр-ветка: ('mkr перловка', '1') — номер улицы вместо дома.
|
||||
("мкр. Перловка, улица 1-я Крестьянская, 33к2", ("1-я крестьянская", "33/2")),
|
||||
# Порядковый перед нумерованным именем: без него было бы ('', '').
|
||||
("2-й пер. 1 Мая,13", ("2-й 1 мая", "13")),
|
||||
(
|
||||
"Россия, Московская область, Павловский Посад, 2-й переулок 1 Мая, 11",
|
||||
("2-й 1 мая", "11"),
|
||||
),
|
||||
# Регрессии: номер дома с дефисом остаётся номером дома.
|
||||
("ул. Ленина, 12-а", ("ленина", "12")),
|
||||
("ул. Щорса, 7-б", ("щорса", "7")),
|
||||
],
|
||||
)
|
||||
def test_parse_street_house_single_letter_ordinal(addr: str, expected: tuple[str, str]) -> None:
|
||||
assert estimator._parse_street_house(addr) == expected
|
||||
|
||||
|
||||
def test_dedup_collapses_single_letter_ordinal_three_formats() -> None:
|
||||
addrs = [
|
||||
("avito", "2-я ул. Синичкина,22"),
|
||||
("domklik", "Москва, 2-я Синичкина улица, 22"),
|
||||
("yandex", "Россия, Москва, 2-я улица Синичкина, 22"),
|
||||
("cian", "ЮВАО, р-н Лефортово, улица 2-я Синичкина, 22"),
|
||||
]
|
||||
lots = [
|
||||
_lot(source=s, address=a, area=44.0, price=9_900_000, floor=3, cad=None) for s, a in addrs
|
||||
]
|
||||
assert len(estimator._dedup_cross_source(lots)) == 1
|
||||
|
||||
|
||||
def test_dedup_different_ordinal_streets_stay_distinct() -> None:
|
||||
# Раньше domklik терял номер: «9-я Парковая» и «12-я Парковая» обе давали
|
||||
# ('парковая', '5'), и квартиры с одинаковыми этажом/площадью/ценой в двух
|
||||
# разных домах Измайлова сливались в одну.
|
||||
lots = [
|
||||
_lot(source=s, address=a, area=44.0, price=9_900_000, floor=3, cad=None)
|
||||
for s, a in (
|
||||
("domklik", "Москва, 9-я Парковая улица, 5"),
|
||||
("domklik", "Москва, 12-я Парковая улица, 5"),
|
||||
)
|
||||
]
|
||||
assert len(estimator._dedup_cross_source(lots)) == 2
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue