From 384c4e7ac3f3ba5d4817f17484ab10562db9fedd Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 29 Jun 2026 00:06:13 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/matching):=20=D0=BD=D0=B5=20=D1=81?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=D0=B8=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BB=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D0=BD=D0=B3=D0=B8=20=D0=B2=20=D1=87=D1=83?= =?UTF-8?q?=D0=B6=D0=BE=D0=B9=20=D0=B4=D0=BE=D0=BC=20(P1-P3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три гарда в 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). --- .../backend/app/services/matching/houses.py | 119 ++++++++---- .../app/services/matching/normalize.py | 95 +++++---- tradein-mvp/backend/tests/test_matching.py | 181 +++++++++++++++++- .../backend/tests/test_same_building_match.py | 49 ++++- 4 files changed, 372 insertions(+), 72 deletions(-) diff --git a/tradein-mvp/backend/app/services/matching/houses.py b/tradein-mvp/backend/app/services/matching/houses.py index d2ff0373..e3ff94b5 100644 --- a/tradein-mvp/backend/app/services/matching/houses.py +++ b/tradein-mvp/backend/app/services/matching/houses.py @@ -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, }, diff --git a/tradein-mvp/backend/app/services/matching/normalize.py b/tradein-mvp/backend/app/services/matching/normalize.py index 9d4d0503..166b0266 100644 --- a/tradein-mvp/backend/app/services/matching/normalize.py +++ b/tradein-mvp/backend/app/services/matching/normalize.py @@ -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] diff --git a/tradein-mvp/backend/tests/test_matching.py b/tradein-mvp/backend/tests/test_matching.py index d3d5e322..1fccf83d 100644 --- a/tradein-mvp/backend/tests/test_matching.py +++ b/tradein-mvp/backend/tests/test_matching.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/test_same_building_match.py b/tradein-mvp/backend/tests/test_same_building_match.py index 368c22ec..fcc0b3f3 100644 --- a/tradein-mvp/backend/tests/test_same_building_match.py +++ b/tradein-mvp/backend/tests/test_same_building_match.py @@ -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'.