diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index cd477ac6..825c5f0f 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -7328,15 +7328,16 @@ _DEDUP_STREET_TYPE_ALT = "|".join( ) # «<тип>[.] <название>»: название = опциональная ведущая цифро-группа + 1-3 # буквенных слова, до запятой/цифры. Ведущая цифра нужна для нумерованных улиц -# («8 Марта», «40 лет Октября», «22 Партсъезда»); она разрешена ТОЛЬКО перед -# буквенным словом (?:\d+\s+)?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия, +# («8 Марта», «40 лет Октября», «22 Партсъезда»; #2291 класс 4 — и через дефис: +# «40-летия Комсомола», «22-го Партсъезда»); она разрешена ТОЛЬКО перед +# буквенным словом (?:\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+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})", flags=re.IGNORECASE, ) # #2269 (а): «<название> <тип>» — тип-слово ПОСЛЕ имени (формат domklik: @@ -7348,7 +7349,7 @@ _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+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+" rf"(?:{_DEDUP_STREET_TYPE_ALT})(?=\.|,|\s|$)", flags=re.IGNORECASE, ) diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json index 12017ca0..6b445063 100644 --- a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json +++ b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json @@ -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, diff --git a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py index 4564c412..3b91ea57 100644 --- a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py +++ b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py @@ -977,3 +977,70 @@ 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( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Один физлот в четырёх форматах: раньше avito/yandex давали ('', ''), cian — + # ('mkr жби', '40'), и слиться не мог никто. + monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) + 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( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Раньше cian ключевал все дома улицы как ('mkr жби', '40'): дома 8А и 35 с + # одинаковыми этажом/площадью/ценой сливались в один лот. + monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) + 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