fix(tradein/matching): не сваливать листинги в чужой дом (P1-P3)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

Три гарда в match_or_create_house (+ P2 в match_house_readonly):
- P1: не матчить/алиасить голую улицу (normalized_address без номера дома)
- P2: отклонять Tier-3 гео-кандидата, если номера домов различаются
- P3: не цементировать слабый гео-матч (0.7) как alias-ключ здания

Хелперы has_house_number/house_number_token (по хвостовому токену —
«улица 8 марта» это улица, не дом). Корень mis-bucketing: house 131237
стянул 8 разных Мамина-номеров + голую улицу. Юнит + поведенческие тесты,
603 matching/normalize/house зелёных. Аффектит только НОВЫЕ матчи;
чистка cemented-данных — отдельной миграцией (P4).
This commit is contained in:
bot-backend 2026-06-29 00:06:13 +03:00
parent bd3562b6bd
commit 384c4e7ac3
4 changed files with 372 additions and 72 deletions

View file

@ -14,7 +14,12 @@ import logging
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.matching.normalize import address_fingerprint, normalize_address
from app.services.matching.normalize import (
address_fingerprint,
has_house_number,
house_number_token,
normalize_address,
)
logger = logging.getLogger(__name__)
@ -66,6 +71,14 @@ def match_or_create_house(
{"fp": fp},
)
# Normalize address once and detect whether it carries a house number.
# `has_num` gates the bare-street guards (P1): a numberless normalized address
# (e.g. 'екатеринбург улица мамина сибиряка' — common in Yandex SERP) is too
# ambiguous to use as a building key, so it must NOT match or register a
# normalized_address alias (mass mis-bucketing — house 131237 collapsed 8 numbers).
norm_addr = normalize_address(address)
has_num = has_house_number(norm_addr)
# Tier 0: cadastral number exact match
if cad:
row = (
@ -119,7 +132,6 @@ def match_or_create_house(
# Without 2b, two scrapers for the same house with slightly different lat/lon (beyond
# the 4-decimal rounding tolerance) would produce distinct fingerprints, miss Tier 2a,
# and each potentially create a duplicate house row.
norm_addr = normalize_address(address)
row = (
db.execute(
text("SELECT house_id FROM house_address_aliases " "WHERE fingerprint = :fp LIMIT 1"),
@ -128,8 +140,11 @@ def match_or_create_house(
.mappings()
.first()
)
if row is None and norm_addr:
# Tier 2b: same normalized address, possibly different coords fingerprint
if row is None and norm_addr and has_num:
# Tier 2b: same normalized address, possibly different coords fingerprint.
# Gated on has_num (P1): never match a bare-street normalized_address — any
# numberless listing would otherwise collapse into whichever house first
# registered that street.
row = (
db.execute(
text(
@ -162,7 +177,8 @@ def match_or_create_house(
db.execute(
text("""
SELECT id,
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS dist
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS dist,
COALESCE(short_address, full_address, address) AS h_addr
FROM houses
WHERE geom IS NOT NULL
AND ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, 30)
@ -175,24 +191,41 @@ def match_or_create_house(
.first()
)
if row:
house_id = int(row["id"])
_upsert_house_source(
db,
house_id=house_id,
ext_source=ext_source,
ext_id=ext_id,
method="geo_proximity",
confidence=0.7,
)
# Register fingerprint alias so future calls skip geo lookup
_insert_alias(db, house_id=house_id, address=address, fp=fp, source=ext_source)
logger.info(
"house match geo_proximity house_id=%s dist=%.1f src=%s",
house_id,
float(row["dist"]),
ext_source,
)
return (house_id, 0.7, "geo_proximity")
# P2: address-consistency guard. Coarse street-centroid geocodes can place
# DIFFERENT house numbers within 30 m of one bad point (52/64/126 all matched
# one centroid → collapsed into house 131237). Reject the geo candidate when
# both sides carry a house number and the numbers differ; accept only when the
# tokens agree OR at least one side has no number (geo is the only signal then).
cand_token = house_number_token(normalize_address(row.get("h_addr")))
lst_token = house_number_token(norm_addr)
if cand_token is not None and lst_token is not None and cand_token != lst_token:
logger.info(
"house geo reject: listing %s != house %s (dist=%.0f)",
lst_token,
cand_token,
float(row["dist"]),
)
# Fall through to the New-house INSERT below — do NOT match this house.
else:
house_id = int(row["id"])
_upsert_house_source(
db,
house_id=house_id,
ext_source=ext_source,
ext_id=ext_id,
method="geo_proximity",
confidence=0.7,
)
# P3: do NOT register an alias for a geo match. A 0.7-confidence
# proximity hit must not be cemented as a building-key alias — that is
# what compounded the mis-bucketing (each loose match spawned a new alias).
logger.info(
"house match geo_proximity house_id=%s dist=%.1f src=%s",
house_id,
float(row["dist"]),
ext_source,
)
return (house_id, 0.7, "geo_proximity")
# New house — INSERT canonical record.
# geom column is auto-populated by houses_set_geom_trg BEFORE INSERT trigger from lat/lon.
@ -308,7 +341,8 @@ def match_house_readonly(
db.execute(
text("""
SELECT id,
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS dist
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS dist,
COALESCE(short_address, full_address, address) AS h_addr
FROM houses
WHERE geom IS NOT NULL
AND ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, 50)
@ -321,13 +355,26 @@ def match_house_readonly(
.first()
)
if row:
house_id = int(row["id"])
logger.info(
"house readonly match geo_proximity house_id=%s dist=%.1f",
house_id,
float(row["dist"]),
)
return (house_id, 0.7, "geo_proximity")
# P2: same house-number consistency guard as match_or_create_house. Reject a
# geo candidate whose house number differs from the target's; accept when the
# numbers agree or at least one side has no number.
cand_token = house_number_token(normalize_address(row.get("h_addr")))
lst_token = house_number_token(normalize_address(address))
if cand_token is not None and lst_token is not None and cand_token != lst_token:
logger.info(
"house readonly geo reject: target %s != house %s (dist=%.0f)",
lst_token,
cand_token,
float(row["dist"]),
)
else:
house_id = int(row["id"])
logger.info(
"house readonly match geo_proximity house_id=%s dist=%.1f",
house_id,
float(row["dist"]),
)
return (house_id, 0.7, "geo_proximity")
return None
@ -386,7 +433,15 @@ def _insert_alias(
4-decimal rounding boundary) do NOT spawn duplicate alias rows they converge to one
row with the latest fingerprint, which is then found by Tier 2a on the next scrape.
house_id is not updated on conflict: the first writer wins canonical ownership.
P1: a bare-street normalized_address (no house number) is NOT registered as an
alias it is too ambiguous to serve as a building key. Any later numberless
listing would otherwise hit that alias via Tier 2b and be mass-dumped into the
wrong house (house 131237 collapsed 8 distinct numbers via a bare-street alias).
"""
na = normalize_address(address)
if not has_house_number(na):
return
db.execute(
text("""
INSERT INTO house_address_aliases (house_id, normalized_address, fingerprint, source)
@ -397,7 +452,7 @@ def _insert_alias(
"""),
{
"hid": house_id,
"na": normalize_address(address),
"na": na,
"fp": fp,
"src": source,
},

View file

@ -10,8 +10,8 @@ import unicodedata
# Strip punctuation EXCEPT hyphens — keep hyphens so abbreviation rules like
# 'пр-кт', 'б-р', 'пр-д' can match before they are collapsed to spaces.
_PUNCT = re.compile(r'[^\w\s\-]', flags=re.UNICODE)
_WS = re.compile(r'\s+')
_PUNCT = re.compile(r"[^\w\s\-]", flags=re.UNICODE)
_WS = re.compile(r"\s+")
# House/building markers ('дом'/'д'/'корпус'/'корп'/'к') are dropped to a single
# canonical form: only the number is kept. This makes the output source-independent —
@ -20,29 +20,29 @@ _WS = re.compile(r'\s+')
# The marker is removed ONLY when immediately followed by a number, so street-name
# initials like 'К. Либкнехта' / 'Д. Зверева' (marker followed by a word) are left
# untouched and not mis-expanded into 'корпус'/'дом' (bug #1535).
_HOUSE_MARKER = re.compile(r'\b(?:дом|корпус|корп|д|к)\s+(?=\d)', flags=re.UNICODE)
_HOUSE_MARKER = re.compile(r"\b(?:дом|корпус|корп|д|к)\s+(?=\d)", flags=re.UNICODE)
# Abbreviation expansion — applied after lowercasing, bounded by spaces.
# Order matters: longer/more-specific patterns first to avoid partial overlap.
# Hyphenated forms ('пр-кт', 'б-р', 'пр-д') come first so they match before
# the shorter variants ('пр', 'б') would erroneously consume the prefix.
_ABBREV = [
(' пр-кт ', ' проспект '),
(' пр-кт. ', ' проспект '),
(' б-р ', ' бульвар '),
(' пр-д ', ' проезд '),
(' ш ', ' шоссе '),
(' ул ', ' улица '),
(' ул. ', ' улица '),
(' пр ', ' проспект '),
(' пр. ', ' проспект '),
(' пер ', ' переулок '),
(' пл ', ' площадь '),
(' наб ', ' набережная '),
(' бул ', ' бульвар '),
(' туп ', ' тупик '),
(' стр ', ' строение '),
(' корп ', ' корпус '),
(" пр-кт ", " проспект "),
(" пр-кт. ", " проспект "),
(" б-р ", " бульвар "),
(" пр-д ", " проезд "),
(" ш ", " шоссе "),
(" ул ", " улица "),
(" ул. ", " улица "),
(" пр ", " проспект "),
(" пр. ", " проспект "),
(" пер ", " переулок "),
(" пл ", " площадь "),
(" наб ", " набережная "),
(" бул ", " бульвар "),
(" туп ", " тупик "),
(" стр ", " строение "),
(" корп ", " корпус "),
# NOTE: house/building markers ('д'/'дом'/'к'/'корп'/'корпус') are NOT expanded here.
# They are handled separately by _HOUSE_MARKER below — stripped to a single canonical
# form (number only) and only when followed by a number — to avoid asymmetric keys
@ -65,20 +65,51 @@ def normalize_address(text: str | None) -> str:
"""
if not text:
return ""
s = unicodedata.normalize('NFC', text).lower()
s = _PUNCT.sub(' ', s) # strip punctuation EXCEPT hyphens
s = _WS.sub(' ', s).strip()
s = unicodedata.normalize("NFC", text).lower()
s = _PUNCT.sub(" ", s) # strip punctuation EXCEPT hyphens
s = _WS.sub(" ", s).strip()
# Wrap with spaces for clean boundary matching
s = f' {s} '
s = f" {s} "
for short, full in _ABBREV:
s = s.replace(short, full)
# Collapse remaining hyphens (e.g. standalone '-' separators or numeric ranges)
s = s.replace('-', ' ')
s = _WS.sub(' ', s).strip()
s = s.replace("-", " ")
s = _WS.sub(" ", s).strip()
# Drop house/building markers that precede a number ('д 5'/'дом 5'/'корпус 5' -> '5'),
# leaving street-name initials ('к либкнехта') untouched. Re-collapse whitespace after.
s = _HOUSE_MARKER.sub('', s)
return _WS.sub(' ', s).strip()
s = _HOUSE_MARKER.sub("", s)
return _WS.sub(" ", s).strip()
def has_house_number(normalized: str | None) -> bool:
"""True if the normalized address ends with a house-number token.
The trailing token must start with a digit:
'улица мамина сибиряка 126а' -> True
'улица 8 марта' -> False (street-name digit, no house no.)
'улица 8 марта 5' -> True ('5' is the house number)
'улица мамина сибиряка' -> False (bare street, no number)
''/None -> False
Detection uses the TRAILING token only a digit inside the street name
('8 марта', '50 лет октября') is NOT a house number.
"""
if not normalized:
return False
return re.search(r"(?:^|\s)\d\S*$", normalized) is not None
def house_number_token(normalized: str | None) -> str | None:
"""The trailing house-number token (e.g. '126а', '32', '108и'), or None.
Returns None when the normalized address has no trailing house number.
Used to compare whether two addresses refer to the same building number
(geo-proximity consistency guard in match_or_create_house).
"""
if not normalized:
return None
m = re.search(r"(?:^|\s)(\d\S*)$", normalized)
return m.group(1) if m else None
def address_fingerprint(address: str | None, lat: float | None, lon: float | None) -> str:
@ -86,8 +117,8 @@ def address_fingerprint(address: str | None, lat: float | None, lon: float | Non
Returns first 32 hex chars of the digest (128 bits collision-safe for millions of rows).
"""
norm_addr = normalize_address(address or '')
lat_r = f'{lat:.4f}' if lat is not None else ''
lon_r = f'{lon:.4f}' if lon is not None else ''
key = f'{norm_addr}|{lat_r}|{lon_r}'
return hashlib.sha256(key.encode('utf-8')).hexdigest()[:32]
norm_addr = normalize_address(address or "")
lat_r = f"{lat:.4f}" if lat is not None else ""
lon_r = f"{lon:.4f}" if lon is not None else ""
key = f"{norm_addr}|{lat_r}|{lon_r}"
return hashlib.sha256(key.encode("utf-8")).hexdigest()[:32]

View file

@ -15,7 +15,12 @@ from app.services.matching.conflict_resolution import (
LISTING_FIELD_PRIORITY,
update_canonical_fields,
)
from app.services.matching.normalize import address_fingerprint, normalize_address
from app.services.matching.normalize import (
address_fingerprint,
has_house_number,
house_number_token,
normalize_address,
)
# ---------------------------------------------------------------------------
# normalize_address
@ -73,6 +78,50 @@ def test_normalize_unicode_nfc():
assert s1 == s2
# ---------------------------------------------------------------------------
# has_house_number / house_number_token (mis-bucketing hardening)
# ---------------------------------------------------------------------------
def test_has_house_number_true_when_trailing_number():
assert has_house_number("улица мамина сибиряка 126а") is True
def test_has_house_number_false_for_street_name_digit_only():
# 'улица 8 марта' — the digit is part of the street NAME, not a house number
assert has_house_number("улица 8 марта") is False
def test_has_house_number_true_for_street_name_digit_plus_house():
# 'улица 8 марта 5' — '5' is the house number, '8' is in the name
assert has_house_number("улица 8 марта 5") is True
def test_has_house_number_false_for_bare_street():
assert has_house_number("улица мамина сибиряка") is False
def test_has_house_number_false_for_none_and_empty():
assert has_house_number(None) is False
assert has_house_number("") is False
def test_house_number_token_extracts_trailing_token():
assert house_number_token("улица мамина сибиряка 126а") == "126а"
def test_house_number_token_for_street_name_digit_plus_house():
assert house_number_token("улица 8 марта 5") == "5"
def test_house_number_token_none_for_bare_street():
assert house_number_token("мамина сибиряка") is None
def test_house_number_token_none_for_none():
assert house_number_token(None) is None
# ---------------------------------------------------------------------------
# address_fingerprint
# ---------------------------------------------------------------------------
@ -324,6 +373,136 @@ def test_match_house_advisory_lock_called_first():
), f"fp must be 32-char sha256 hex, got: {bind.get('fp')!r}"
# ---------------------------------------------------------------------------
# Mis-bucketing hardening — bare-street + geo address-consistency guards
# ---------------------------------------------------------------------------
def _executed_sqls(db: MagicMock) -> list[str]:
"""Collapsed SQL text of every db.execute() call (whitespace-normalized)."""
out = []
for call in db.execute.call_args_list:
if call.args:
out.append(" ".join(str(call.args[0]).split()))
return out
def test_geo_match_does_not_register_alias():
"""P3: a geo-proximity match must NOT INSERT a house_address_aliases row.
A 0.7-confidence proximity hit is too weak to cement as a building key.
Execute order: lock, source miss, fp miss, norm_addr miss, geo hit, upsert.
"""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss
None, # fingerprint miss (Tier 2a)
None, # normalized_address miss (Tier 2b)
{"id": 22, "dist": 12.0, "h_addr": "улица новая 3"}, # geo hit, same number
None, # _upsert_house_source
]
)
house_id, conf, method = match_or_create_house(
db, "n1", "ext-geo", address="улица Новая 3", lat=56.83, lon=60.59
)
assert (house_id, conf, method) == (22, 0.7, "geo_proximity")
assert not any(
"INSERT INTO house_address_aliases" in s for s in _executed_sqls(db)
), "geo match must not write an alias (P3)"
def test_geo_match_rejected_when_house_number_differs():
"""P2: geo candidate with a DIFFERENT house number is rejected → New house.
Listing 'Мамина Сибиряка 126' must not bucket into nearby house '... 52'.
On rejection the flow falls through to the New-house INSERT.
"""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss
None, # fingerprint miss (Tier 2a)
None, # normalized_address miss (Tier 2b)
{"id": 99, "dist": 5.0, "h_addr": "улица мамина сибиряка 52"}, # geo hit, WRONG number
{"id": 500}, # INSERT RETURNING id (New house)
None, # _upsert_house_source
None, # _insert_alias (address has number → executes)
]
)
house_id, _conf, method = match_or_create_house(
db, "yandex", "ext-rej", address="улица Мамина-Сибиряка 126", lat=56.84, lon=60.61
)
assert method == "new", "geo candidate with different house number must be rejected"
assert house_id == 500
def test_bare_street_listing_skips_tier2b_and_does_not_alias():
"""P1: a numberless ('bare street') address must not match or register an alias.
'екатеринбург улица мамина сибиряка' has no house number Tier 2b is skipped
and _insert_alias is a no-op, so it cannot mass-collapse into a wrong house.
Execute order: lock, source miss, fp miss, INSERT, upsert (no Tier 2b, no alias).
"""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss
None, # fingerprint miss (Tier 2a)
{"id": 700}, # INSERT RETURNING id (New house — Tier 2b skipped, no lat/lon)
None, # _upsert_house_source
]
)
house_id, _conf, method = match_or_create_house(
db, "yandex", "ext-bare", address="екатеринбург улица мамина сибиряка"
)
assert method == "new"
assert house_id == 700
sqls = _executed_sqls(db)
assert not any(
"normalized_address = :na" in s for s in sqls
), "bare-street address must not run Tier 2b normalized_address lookup (P1)"
assert not any(
"INSERT INTO house_address_aliases" in s for s in sqls
), "bare-street address must not register an alias (P1)"
def test_insert_alias_noop_for_bare_street():
"""_insert_alias is a no-op when the normalized address has no house number."""
from app.services.matching.houses import _insert_alias
db = MagicMock()
_insert_alias(
db,
house_id=1,
address="екатеринбург улица мамина сибиряка",
fp="a" * 32,
source="yandex",
)
db.execute.assert_not_called()
def test_insert_alias_executes_when_house_number_present():
"""_insert_alias still writes when the normalized address carries a house number."""
from app.services.matching.houses import _insert_alias
db = MagicMock()
_insert_alias(
db,
house_id=1,
address="улица Мамина-Сибиряка 126",
fp="b" * 32,
source="yandex",
)
db.execute.assert_called_once()
# ---------------------------------------------------------------------------
# match_or_create_listing — mock DB tier routing
# ---------------------------------------------------------------------------

View file

@ -6,6 +6,7 @@ Two pieces:
2. estimator._fetch_analogs canonical Tier S when target_house_id задан,
матчит listings по house_id_fk; иначе fallback на address-prefix Tier S.
"""
import os
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
@ -77,12 +78,28 @@ def test_readonly_geo_proximity_hit():
db = MagicMock()
# fingerprint miss → geo hit
db.execute.return_value.mappings.return_value.first.side_effect = [
None, {"id": 9, "dist": 12.3}
None,
{"id": 9, "dist": 12.3},
]
result = match_house_readonly(db, address="неизвестный адрес", lat=56.84, lon=60.65)
assert result == (9, 0.7, "geo_proximity")
def test_readonly_geo_rejected_when_house_number_differs():
"""P2: geo candidate within 50 m but with a DIFFERENT house number is rejected.
Target 'Малышева 125' must not resolve to a nearby '... 200' on a coarse geocode.
fingerprint miss geo hit (wrong number) reject None.
"""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.side_effect = [
None,
{"id": 9, "dist": 12.3, "h_addr": "улица малышева 200"},
]
result = match_house_readonly(db, address="ул Малышева 125", lat=56.84, lon=60.65)
assert result is None
def test_readonly_no_match_returns_none():
db = MagicMock()
db.execute.return_value.mappings.return_value.first.side_effect = [None, None]
@ -115,8 +132,14 @@ def test_canonical_tier_s_fires_with_house_id():
rows = [_listing(source=s) for s in ("cian", "avito", "yandex", "n1")]
db = _db_returning(rows)
listings, _fallback, tier = _fetch_analogs(
db, lat=56.84, lon=60.65, rooms=2, area=50.0, radius_m=1000,
full_address="Екатеринбург, ул Малышева, д 125", target_house_id=555,
db,
lat=56.84,
lon=60.65,
rooms=2,
area=50.0,
radius_m=1000,
full_address="Екатеринбург, ул Малышева, д 125",
target_house_id=555,
)
assert tier == "S"
assert len(listings) >= 3
@ -128,8 +151,14 @@ def test_falls_back_to_address_tier_s_without_house_id():
rows = [_listing(source=s) for s in ("cian", "avito", "yandex", "n1")]
db = _db_returning(rows)
_listings, _fallback, tier = _fetch_analogs(
db, lat=56.84, lon=60.65, rooms=2, area=50.0, radius_m=1000,
full_address="Екатеринбург, ул Малышева, д 125", target_house_id=None,
db,
lat=56.84,
lon=60.65,
rooms=2,
area=50.0,
radius_m=1000,
full_address="Екатеринбург, ул Малышева, д 125",
target_house_id=None,
)
assert tier == "S"
# Без target_house_id канонический tier пропущен — первый запрос по address ILIKE
@ -145,8 +174,14 @@ def test_canonical_tier_s_too_few_falls_through():
# full_address без распознаваемого short_addr И year/floors None →
# address-Tier S и Tier H пропустятся, упадём в Tier W (тоже вернёт rows mock).
_listings, _fallback, tier = _fetch_analogs(
db, lat=56.84, lon=60.65, rooms=2, area=50.0, radius_m=1000,
full_address=None, target_house_id=555,
db,
lat=56.84,
lon=60.65,
rooms=2,
area=50.0,
radius_m=1000,
full_address=None,
target_house_id=555,
)
# canonical Tier S не сработал (1 < 3); tier — не S(canonical) early-return.
# Mock возвращает те же rows для Tier W, так что итог не пустой, tier='W'.