fix(tradein/estimator): парсер адресов — имя-перед-типом, glued-суффикс, микрорайоны (#2269) (#2290)
All checks were successful
Deploy Trade-In / test (push) Successful in 1m30s
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 56s
Deploy Trade-In / deploy (push) Successful in 45s

This commit is contained in:
bot-backend 2026-07-03 13:03:25 +00:00
parent d6d4a1c94a
commit 666962185c
2 changed files with 162 additions and 1 deletions

View file

@ -4623,11 +4623,43 @@ _DEDUP_STREET_TYPE_ALT = "|".join(
# («8 Марта», «40 лет Октября», «22 Партсъезда»); она разрешена ТОЛЬКО перед # («8 Марта», «40 лет Октября», «22 Партсъезда»); она разрешена ТОЛЬКО перед
# буквенным словом (?:\d+\s+)?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия, # буквенным словом (?:\d+\s+)?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия,
# не сопровождаемое буквой) в токен не попадает: «Белинского 86» → «белинского». # не сопровождаемое буквой) в токен не попадает: «Белинского 86» → «белинского».
# #2269 (б): тип-суффикс может быть приклеен запятой/точкой без пробела
# («ул.,6к1»): после `\.?` разрешаем ЛИБО пробел (обычный формат), ЛИБО запятую
# (→ следом номер дома). Ветка «тип ПЕРЕД именем» — только для формата с пробелом.
_DEDUP_STREET_NAME_RE = re.compile( _DEDUP_STREET_NAME_RE = re.compile(
rf"(?:^|[,\s])(?:{_DEDUP_STREET_TYPE_ALT})\.?\s+" rf"(?:^|[,\s])(?:{_DEDUP_STREET_TYPE_ALT})\.?\s+"
r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})", r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})",
flags=re.IGNORECASE, flags=re.IGNORECASE,
) )
# #2269 (а): «<название> <тип>» — тип-слово ПОСЛЕ имени (формат domklik:
# «Рассветная улица, 6 к1»; avito с глухим суффиксом: «Рассветная ул.,6к1»).
# Название — 1-3 буквенных слова (+ опц. ведущая цифра нумерованной улицы) на
# границе запятой/начала строки, СРАЗУ перед типом. Тип завершается концом
# слова / точкой / запятой / пробелом — так «Рассветная ул.,6к1» и «Рассветная
# улица, 6» дают тот же токен «рассветная», что ветка-перед-именем на «улица
# Рассветная». Тип НЕ входит в захват (group 1) — токен байт-в-байт совпадает.
_DEDUP_STREET_NAME_SUFFIX_RE = re.compile(
r"(?:^|[,\s])"
r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+"
rf"(?:{_DEDUP_STREET_TYPE_ALT})(?=\.|,|\s|$)",
flags=re.IGNORECASE,
)
# #2269 (в): fallback без уличного типа — «микрорайон/мкр/мкр-н/м-н <имя>» или
# «<имя> микрорайон». Имя = 1-3 буквенных слова. Токен получает префикс «mkr »,
# чтобы НЕ пересекаться с уличными токенами (микрорайон ≠ одноимённая улица).
_DEDUP_MKR_WORDS_ALT = "|".join(
re.escape(w) for w in sorted(("микрорайон", "мкр-н", "мкр", "м-н"), key=len, reverse=True)
)
_DEDUP_MKR_PREFIX_RE = re.compile(
rf"(?:^|[,\s])(?:{_DEDUP_MKR_WORDS_ALT})\.?\s+" r"([а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})",
flags=re.IGNORECASE,
)
_DEDUP_MKR_SUFFIX_RE = re.compile(
r"(?:^|[,\s])"
r"([а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+"
rf"(?:{_DEDUP_MKR_WORDS_ALT})(?=\.|,|\s|$)",
flags=re.IGNORECASE,
)
# Номер дома = первое число ПОСЛЕ названия улицы (опц. «д.»/«дом»), с литерой/ # Номер дома = первое число ПОСЛЕ названия улицы (опц. «д.»/«дом»), с литерой/
# корпусом («46», «8а», «12/3»). Ищется в остатке строки от конца street-матча — # корпусом («46», «8а», «12/3»). Ищется в остатке строки от конца street-матча —
# ведущая цифра нумерованной улицы («8 Марта») сюда не попадает. # ведущая цифра нумерованной улицы («8 Марта») сюда не попадает.
@ -4670,8 +4702,20 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]:
if not addr: if not addr:
return "", "" return "", ""
s = addr.strip().lower() s = addr.strip().lower()
# Ветка 1 (существующая, #2265): тип ПЕРЕД именем («улица Рассветная»).
# Пробуется ПЕРВОЙ — новые ветки только если она дала пусто. Логика не тронута.
m = _DEDUP_STREET_NAME_RE.search(s) m = _DEDUP_STREET_NAME_RE.search(s)
if not m: mkr = False
if m is None:
# Ветка 2 (#2269 а/б): имя ПЕРЕД типом, тип-суффикс с пробелом или
# приклеенный запятой/точкой («Рассветная улица», «Рассветная ул.,6к1»).
m = _DEDUP_STREET_NAME_SUFFIX_RE.search(s)
if m is None:
# Ветка 3 (#2269 в): без уличного типа — микрорайон-fallback. Токен
# получает префикс «mkr », чтобы не пересекаться с уличными токенами.
m = _DEDUP_MKR_PREFIX_RE.search(s) or _DEDUP_MKR_SUFFIX_RE.search(s)
mkr = m is not None
if m is None:
return "", "" return "", ""
words = [ words = [
w w
@ -4679,6 +4723,10 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]:
if w and w not in _DEDUP_HOUSE_MARKER_WORDS and w not in _DEDUP_ADDR_NOISE_WORDS if w and w not in _DEDUP_HOUSE_MARKER_WORDS and w not in _DEDUP_ADDR_NOISE_WORDS
] ]
street = " ".join(words).strip() street = " ".join(words).strip()
if not street:
return "", ""
if mkr:
street = f"mkr {street}"
house = "" house = ""
hm = _DEDUP_HOUSE_NO_RE.search(s, m.end()) hm = _DEDUP_HOUSE_NO_RE.search(s, m.end())
if hm: if hm:

View file

@ -399,3 +399,116 @@ def test_dedup_empty_cadnum_and_street_stay_separate(monkeypatch: pytest.MonkeyP
] ]
out = estimator._dedup_cross_source(lots) out = estimator._dedup_cross_source(lots)
assert len(out) == 2 assert len(out) == 2
# --------------------------------------------------------------------------- #
# #2269 — расширенный парсер адреса (три source-формата, ронявшие ('',''))
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
("addr", "expected"),
[
# --- контрольные (существующая ветка «тип ПЕРЕД именем») — БАЙТ-В-БАЙТ
# тот же токен, что дают новые ветки, иначе клейка развалится.
("улица Рассветная, 6к1", ("рассветная", "")), # cian-стиль, house не извлекается
("ул. Белинского 86", ("белинского", "86")),
("улица 8 Марта, 46", ("8 марта", "46")), # нумерованная, ведущая цифра в токене
# --- (а) имя ПЕРЕД типом (формат domklik)
("Рассветная улица, 6 к1", ("рассветная", "6")),
("Россия, Екатеринбург, Рассветная улица, 6", ("рассветная", "6")),
("Красных Командиров улица, 10", ("красных командиров", "10")),
("8 Марта улица, 46", ("8 марта", "46")), # нумерованная, имя-перед-типом
# --- (б) тип-суффикс приклеен запятой/точкой без пробела (avito)
("Рассветная ул.,6к1", ("рассветная", "")),
("Белинского ул., 86", ("белинского", "86")),
# --- (в) fallback без уличного типа — микрорайон, префикс «mkr »
("Екатеринбург, Светлый микрорайон, 10", ("mkr светлый", "10")),
("микрорайон Светлый, 10", ("mkr светлый", "10")),
("мкр Пионерский, 5", ("mkr пионерский", "5")),
("м-н ЖБИ, 3", ("mkr жби", "3")),
# --- всё ещё пусто, если ни улицы, ни микрорайона
("Екатеринбург, ЖК Дружба", ("", "")),
("улица, 5", ("", "")), # тип без имени
],
)
def test_parse_street_house_extended_formats(addr: str, expected: tuple[str, str]) -> None:
assert estimator._parse_street_house(addr) == expected
def test_parse_street_house_branch1_wins_over_mkr_noise() -> None:
# #2265 регрессия: когда в адресе ЕСТЬ уличный тип, ветка-перед-именем берёт
# улицу, а «мкр/м-н» остаётся шумом (НЕ префикс mkr) — токен = «сыромолотова».
assert estimator._parse_street_house("р-н Кировский, мкр. ЖБИ, улица Сыромолотова, 11") == (
"сыромолотова",
"11",
)
assert estimator._parse_street_house("Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова") == (
"сыромолотова",
"",
)
def test_extract_street_token_extended_same_token_across_formats() -> None:
# Все три source-формата дома «Рассветная» → один и тот же токен → склейка.
domklik = estimator._extract_street_token("Рассветная улица, 6 к1")
avito = estimator._extract_street_token("Рассветная ул.,6к1")
cian = estimator._extract_street_token("улица Рассветная, 6к1")
assert domklik == avito == cian == "рассветная"
def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch) -> None:
# Живой пример несклейки (оценка 23b8ada1): domklik «Рассветная улица, 6 к1»
# + cian «улица Рассветная, 6к1» — тот же физлот (эт.13, ~38.5 м², 6.599М),
# cadnum пуст (domklik всегда NULL). Расширенный парсер даёт обоим токен
# «рассветная» → union-find по street-композиту схлопывает в один.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="domklik",
address="Рассветная улица, 6 к1",
area=38.5,
price=6_599_000,
floor=13,
cad=None,
scraped_at=datetime(2026, 7, 1, tzinfo=UTC),
),
_lot(
source="cian",
address="улица Рассветная, 6к1",
area=38.5,
price=6_599_000,
floor=13,
cad=None,
scraped_at=datetime(2026, 7, 3, tzinfo=UTC),
),
]
out = estimator._dedup_cross_source(lots)
assert len(out) == 1 # две записи → одна
assert out[0]["source"] == "cian" # представитель — свежайший scraped_at
def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) -> None:
# (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная,
# 6к1» → тот же токен «рассветная», house не извлекается ни у одного (guard
# equal-or-missing) → склейка.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(source="avito", address="Рассветная ул.,6к1", area=38.5, price=6_599_000, floor=13),
_lot(source="cian", address="улица Рассветная, 6к1", area=38.5, price=6_599_000, floor=13),
]
out = estimator._dedup_cross_source(lots)
assert len(out) == 1
def test_dedup_mkr_fallback_does_not_cross_with_street(monkeypatch: pytest.MonkeyPatch) -> None:
# Префикс «mkr » изолирует микрорайонный токен от одноимённого уличного:
# «Светлый микрорайон» (mkr) и «улица Светлая» (street) — РАЗНЫЕ ключи, не
# сливаются даже при совпадении метрик.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(source="cian", address="Светлый микрорайон, 10", area=40.0, price=5_000_000, floor=6),
_lot(source="avito", address="улица Светлая, 10", area=40.0, price=5_000_000, floor=6),
]
out = estimator._dedup_cross_source(lots)
assert len(out) == 2