fix(tradein/matching): не сваливать листинги в чужой дом (P1-P3 хардненинг матчера) #2082
4 changed files with 372 additions and 72 deletions
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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'.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue