From 9f11b8ed69b83061383d2e46a4739da8ab2184c1 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:25:50 +0500 Subject: [PATCH 1/5] =?UTF-8?q?fix(mera/estimator):=20=D0=B4=D0=B5=D0=B4?= =?UTF-8?q?=D1=83=D0=BF=20=D1=81=D0=BA=D0=BB=D0=B5=D0=B8=D0=B2=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D0=BD=D1=83=D0=BC=D0=B5=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D1=83=D0=BB=D0=B8=D1=86=D1=8B=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20=D0=B4=D0=B5=D1=84=D0=B8=D1=81=20(#2?= =?UTF-8?q?291,=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Регексы названия улицы в _parse_street_house пускали ведущую цифру только через пробел («8 Марта»), а через дефис нет. «40-летия Комсомола», «22-го Партсъезда», «3-го Интернационала» разбирались так: - avito «ул. 40-летия Комсомола,32Г» и yandex — ('', ''), дедупа нет вовсе; - cian «мкр. ЖБИ, улица 40-летия Комсомола, 8А» — ('mkr жби', '40'): все дома улицы получали один ключ и дом «40», разные квартиры с одинаковыми этажом/площадью/ценой сливались в одну; - domklik «60-летия Октября проспект» — ('октября', …). Фикс: (?:\d+\s+)? → (?:\d+(?:\s+|-))? в _DEDUP_STREET_NAME_RE и _DEDUP_STREET_NAME_SUFFIX_RE. На 5310 живых адресах прода с «цифра-буква» меняется разбор у 730 объявлений, все новые токены — реальные нумерованные улицы (14 штук, от «40-летия комсомола» до «2-ая новосибирская»). Бэктест-гейт: baseline перегенерирован. В реплее дедуп включён (как на захвате), поэтому правка разбора сдвигает пул: изменились 99 из 1600 сделок, у 48 ошибка меньше, у 51 больше. expected_sold MAPE 15.88→15.92, покрытие вилки 1056→1057, headline не сдвинулся. Refs #2291 Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/services/estimator.py | 9 +-- .../tests/fixtures/backtest_baseline.json | 62 ++++++++--------- .../test_estimator_dedup_cross_source_2087.py | 67 +++++++++++++++++++ 3 files changed, 103 insertions(+), 35 deletions(-) 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 -- 2.45.3 From 08216e4c435ebb446fcdd2b5ca503f8ec8212201 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:28:55 +0500 Subject: [PATCH 2/5] =?UTF-8?q?feat(mera/estimator):=20=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=20=D0=B8=20=D0=BE=D0=BA=D0=BD=D0=BE=20=D1=8D=D1=82?= =?UTF-8?q?=D0=B0=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20Tier=20H=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D0=BA=D0=B8,=20=D0=B4=D0=BB=D1=8F=20=D0=B1=D1=8D=D0=BA=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B0=20(#3234)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier H («тот же класс дома») отдаётся только при ≥5 комплах, иначе молча уходит в Tier W без фильтра по классу. Прод, Loki 30 сут (комментарий 12.09): 61 из 86 попыток не доживает, в 31 из них в своей полосе 0 комплов. Какой вариант лучше — порог 5→4, окно этажей ±30%→±50% или оба — решает только MAPE бэктеста на сделках, а оба числа были литералами: сравнить варианты без правки кода было нельзя. estimate_tier_h_min_comps (5) и estimate_tier_h_floors_tol (0.30) в Settings, _fetch_analogs берёт их оттуда. Дефолты дают те же tf_min/tf_max, что литералы (сверено для total_floors 1..199), поведение прода не меняется. Бэктест варианта: docker exec -e ESTIMATE_TIER_H_MIN_COMPS=4 -e ESTIMATE_TIER_H_FLOORS_TOL=0.5 … Дефолт не меняю: бэктест вариантов не прогнан (нужен деплой этих ручек). Refs #3234 Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/core/config.py | 8 ++ tradein-mvp/backend/app/services/estimator.py | 13 ++-- .../tests/test_3234_tier_h_settings.py | 78 +++++++++++++++++++ 3 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3234_tier_h_settings.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index d291f723..cbef4320 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -711,6 +711,14 @@ 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 бэктеста. + # 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 diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 825c5f0f..3f07528a 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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, diff --git a/tradein-mvp/backend/tests/test_3234_tier_h_settings.py b/tradein-mvp/backend/tests/test_3234_tier_h_settings.py new file mode 100644 index 00000000..b7480b7a --- /dev/null +++ b/tradein-mvp/backend/tests/test_3234_tier_h_settings.py @@ -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) -- 2.45.3 From 1a9a612a823c02db8ff1545ae05cb82c097b04e7 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 13:44:45 +0500 Subject: [PATCH 3/5] =?UTF-8?q?test(mera/estimator):=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B4=D0=B5=D0=B4=D1=83=D0=BF=D0=B0=20#2291=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=82=D1=80=D0=BE=D0=B3=D0=B0=D1=8E=D1=82=20?= =?UTF-8?q?=D1=84=D0=BB=D0=B0=D0=B3,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D1=8B=D0=B9=20#3556=20=D1=83=D0=B4=D0=B0=D0=BB=D1=8F=D0=B5?= =?UTF-8?q?=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два новых теста класса 4 ставили monkeypatch.setattr(settings, "estimate_dedup_analogs_enabled", True). PR #3556 (#2378) удаляет это поле из Settings, и setattr по несуществующему атрибуту падает AttributeError. CI гоняет голову ветки, а не merge-ref, поэтому второй смерженный PR пришёл бы зелёным и уронил main. На main флаг и так ON по умолчанию (test_dedup_default_is_on), так что строки лишние при любом порядке мержа. Проверено: дифф PR, наложенный на голову #3556 (31c31f8f), до правки 2 failed / 144 passed (AttributeError), после 146 passed. Тесты не стали пустыми: откат префиксного регекса без флага даёт assert 4 == 1 и 1 == 2. Refs #2291 Co-Authored-By: Claude Opus 5 --- .../test_estimator_dedup_cross_source_2087.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) 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 3b91ea57..6370d80c 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 @@ -1008,12 +1008,11 @@ def test_parse_street_house_hyphen_numbered_street(addr: str, expected: tuple[st assert estimator._parse_street_house(addr) == expected -def test_dedup_collapses_hyphen_numbered_street_four_sources( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_dedup_collapses_hyphen_numbered_street_four_sources() -> None: # Один физлот в четырёх форматах: раньше avito/yandex давали ('', ''), cian — - # ('mkr жби', '40'), и слиться не мог никто. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) + # ('mkr жби', '40'), и слиться не мог никто. Флаг дедупа не трогаем: на main он + # ON по умолчанию (test_dedup_default_is_on), а #3556 удаляет его из Settings — + # setattr по несуществующему полю уронил бы тест при любом порядке мержа. addrs = [ ("avito", "ул. 40-летия Комсомола,32Г"), ("yandex", "Россия, Свердловская область, Екатеринбург, улица 40-летия Комсомола, 32Г"), @@ -1026,12 +1025,9 @@ def test_dedup_collapses_hyphen_numbered_street_four_sources( assert len(estimator._dedup_cross_source(lots)) == 1 -def test_dedup_hyphen_numbered_street_different_houses_stay_distinct( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_dedup_hyphen_numbered_street_different_houses_stay_distinct() -> None: # Раньше cian ключевал все дома улицы как ('mkr жби', '40'): дома 8А и 35 с # одинаковыми этажом/площадью/ценой сливались в один лот. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="cian", -- 2.45.3 From 45017d2535949de054697a8193480418dec76807 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 13:50:31 +0500 Subject: [PATCH 4/5] =?UTF-8?q?fix(mera/estimator):=20=D0=B4=D0=B5=D0=B4?= =?UTF-8?q?=D1=83=D0=BF=20=D1=80=D0=B0=D0=B7=D0=BB=D0=B8=D1=87=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D0=BF=D0=BE=D1=80=D1=8F=D0=B4=D0=BA=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=83=D0=BB=D0=B8=D1=86=D1=8B=20=C2=AB2-=D1=8F=20?= =?UTF-8?q?=D0=A1=D0=B8=D0=BD=D0=B8=D1=87=D0=BA=D0=B8=D0=BD=D0=B0=C2=BB=20?= =?UTF-8?q?=D0=B2=D0=BE=20=D0=B2=D1=81=D0=B5=D1=85=20=D1=82=D1=80=D1=91?= =?UTF-8?q?=D1=85=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B0=D1=85=20(#2?= =?UTF-8?q?291,=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что было. После «40-летия Комсомола» остались однобуквенные порядковые: регексы требовали после «N-» минимум две буквы. Одна улица в трёх форматах разбиралась по-разному: cian «улица 2-я Синичкина» давал ('', ''), avito/yandex «2-я ул. Синичкина» и domklik «2-я Синичкина улица» теряли номер ('синичкина'). Потеря номера — это ложная склейка: «9-я Парковая, 5» и «12-я Парковая, 5» получали один ключ ('парковая', '5'). Что сделано. В обоих регексах названия улицы добавлен необязательный порядковый «N-x » перед (нумерованным) именем; формат «номер тип имя» переставляется в «тип номер имя» до разбора. Номер дома с дефисом не тронут. Замер на проде (tradein-postgres, SELECT, 17.09): 12 848 разных адресов с шаблоном «цифра-дефис/пробел-буква», 29 959 объявлений. Разбор изменился у 8 713 объявлений; ни один адрес не потерял токен. Межисточниковых групп (улица, дом) 1305 -> 1473. Из 81 прежней группы, которая распалась, 60 — разные порядковые улицы, 3 — ложный mkr-ключ, 18 — голая и порядковая улица в разных городах (одна — источник сам потерял номер). Гейт бэктеста без изменений baseline. Refs #2291 Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/services/estimator.py | 20 ++++-- .../test_estimator_dedup_cross_source_2087.py | 62 +++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 3f07528a..394fc3a8 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -7330,15 +7330,17 @@ _DEDUP_STREET_TYPE_ALT = "|".join( # «<тип>[.] <название>»: название = опциональная ведущая цифро-группа + 1-3 # буквенных слова, до запятой/цифры. Ведущая цифра нужна для нумерованных улиц # («8 Марта», «40 лет Октября», «22 Партсъезда»; #2291 класс 4 — и через дефис: -# «40-летия Комсомола», «22-го Партсъезда»); она разрешена ТОЛЬКО перед -# буквенным словом (?:\d+(?:\s+|-))?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия, +# «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: @@ -7350,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 », # чтобы НЕ пересекаться с уличными токенами (микрорайон ≠ одноимённая улица). @@ -7539,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) 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 6370d80c..33eb220a 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 @@ -1040,3 +1040,65 @@ def test_dedup_hyphen_numbered_street_different_houses_stay_distinct() -> 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 -- 2.45.3 From cbf0f6dec62e84a35ed05b28628b6d5904e32033 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 13:50:49 +0500 Subject: [PATCH 5/5] =?UTF-8?q?docs(mera/estimator):=20=D1=80=D1=83=D1=87?= =?UTF-8?q?=D0=BA=D0=B8=20Tier=20H=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=E2=80=94=20=D0=B2=D0=B5=D1=80=D0=BD=D1=83?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BD=D1=82=D1=8B=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B1?= =?UTF-8?q?=D1=8D=D0=BA=D1=82=D0=B5=D1=81=D1=82=D0=B0=20(#3234)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью #3552: направление расходится с #3556/#1970, где неменявшиеся estimate_*-настройки переводят в константы движка. Две новые ручки нужны только для бэктеста вариантов; условие возврата записано рядом с полями, чтобы его увидел тот, кто будет делать шаг 4 #3234. Refs #3234 Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/core/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index cbef4320..9d05c22a 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -716,6 +716,8 @@ class Settings(BaseSettings): # через 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) -- 2.45.3