gendesign/tradein-mvp/backend/app/services/matching/listings.py
bot-backend 65b9247369
All checks were successful
Deploy Trade-In / test (push) Successful in 30s
Deploy Trade-In / deploy (push) Successful in 46s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 40s
fix(matching): Tier 2b alias sync + collapse double SAVEPOINT (#849) (#870)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-05-31 08:05:48 +00:00

290 lines
10 KiB
Python

"""Listing cross-source matching — 3-tier algorithm.
Tier 0 (confidence 1.0): cadastral_number exact match on listings table.
Tier 1 (confidence 1.0): ext_source + ext_id already in listing_sources.
Tier 2 (confidence 0.85): description_minhash exact match within same house.
Tier 3 (confidence 0.75): composite match — house + floor + area ±2% + rooms.
New (sentinel 0): caller is responsible for INSERT + call _upsert_listing_source.
Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 4
"""
import json
import logging
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def match_or_create_listing(
db: Session,
ext_source: str,
ext_id: str,
house_id: int,
floor: int | None = None,
rooms_count: int | None = None,
area_m2: float | None = None,
price_rub: float | None = None,
*,
cadastral_number: str | None = None,
description_minhash: str | None = None,
source_url: str | None = None,
source_data: dict | None = None,
) -> tuple[int, float, str]:
"""Match existing listing or signal that caller should create a new one.
Returns:
(listing_id, confidence in [0.0, 1.0], method name)
Method values:
'cadastr_exact' — matched by cadastral number (confidence 1.0)
'source_exact' — already in listing_sources for this source+ext_id (confidence 1.0)
'minhash' — matched by description minhash within house (confidence 0.85)
'composite' — matched by house+floor+area±2%+rooms (confidence 0.75)
'new' — no match; caller must INSERT listing then call upsert_listing_source()
When method == 'new', listing_id is 0 (sentinel). Caller flow:
listing_id, conf, method = match_or_create_listing(db, ...)
if method == 'new':
listing_id = <INSERT canonical listing row>
upsert_listing_source(db, listing_id=listing_id, ...)
"""
# Tier 0: cadastral number exact match
if cadastral_number:
row = (
db.execute(
text(
"SELECT id FROM listings WHERE cadastral_number = :cad ORDER BY id ASC LIMIT 1"
),
{"cad": cadastral_number},
)
.mappings()
.first()
)
if row:
listing_id = int(row["id"])
_upsert_listing_source(
db,
listing_id=listing_id,
ext_source=ext_source,
ext_id=ext_id,
method="cadastr_exact",
confidence=1.0,
price_rub=price_rub,
area_m2=area_m2,
floor=floor,
rooms_count=rooms_count,
source_url=source_url,
source_data=source_data,
)
logger.info("listing match cadastr_exact id=%s cad=%s", listing_id, cadastral_number)
return (listing_id, 1.0, "cadastr_exact")
# Tier 1: source+ext_id already registered
row = (
db.execute(
text(
"SELECT listing_id FROM listing_sources "
"WHERE ext_source = :s AND ext_id = :e LIMIT 1"
),
{"s": ext_source, "e": str(ext_id)},
)
.mappings()
.first()
)
if row:
listing_id = int(row["listing_id"])
logger.info(
"listing match source_exact id=%s src=%s ext_id=%s", listing_id, ext_source, ext_id
)
return (listing_id, 1.0, "source_exact")
# Tier 2: description minhash match within same house
# Stage 8 v1: exact match on pre-computed minhash string.
# Future (Stage 8.x): replace with datasketch MinHash LSH for approximate Jaccard.
if description_minhash:
row = (
db.execute(
text("""
SELECT id FROM listings
WHERE house_id = CAST(:hid AS bigint)
AND description_minhash = :hash
LIMIT 1
"""),
{"hid": house_id, "hash": description_minhash},
)
.mappings()
.first()
)
if row:
listing_id = int(row["id"])
_upsert_listing_source(
db,
listing_id=listing_id,
ext_source=ext_source,
ext_id=ext_id,
method="minhash",
confidence=0.85,
price_rub=price_rub,
area_m2=area_m2,
floor=floor,
rooms_count=rooms_count,
source_url=source_url,
source_data=source_data,
)
logger.info("listing match minhash id=%s hash=%s", listing_id, description_minhash)
return (listing_id, 0.85, "minhash")
# Tier 3: composite match — house + floor + area ±2% + rooms
if house_id and floor is not None and area_m2 is not None and rooms_count is not None:
row = (
db.execute(
text("""
SELECT id,
ABS(area_m2 - CAST(:area AS numeric)) AS diff
FROM listings
WHERE house_id = CAST(:hid AS bigint)
AND floor = :fl
AND rooms = :rc
AND area_m2 BETWEEN CAST(:area AS numeric) * 0.98
AND CAST(:area AS numeric) * 1.02
ORDER BY diff ASC
LIMIT 1
"""),
{"hid": house_id, "fl": floor, "rc": rooms_count, "area": area_m2},
)
.mappings()
.first()
)
if row:
listing_id = int(row["id"])
_upsert_listing_source(
db,
listing_id=listing_id,
ext_source=ext_source,
ext_id=ext_id,
method="composite",
confidence=0.75,
price_rub=price_rub,
area_m2=area_m2,
floor=floor,
rooms_count=rooms_count,
source_url=source_url,
source_data=source_data,
)
logger.info(
"listing match composite id=%s house=%s floor=%s area=%.1f rooms=%s",
listing_id,
house_id,
floor,
area_m2,
rooms_count,
)
return (listing_id, 0.75, "composite")
# No match — signal caller to create new listing
# NOTE (Part 4 / #849): match_or_create_listing returns (listing_id, confidence, method)
# but the main scraper ingestion path (_link_listing_to_house in scrapers/base.py) does NOT
# call this function — it uses match_or_create_house + upsert_listing_source('source_link')
# directly. Wiring the matcher confidence/method from this function into listing_sources on
# the source_link path would require restructuring the ingestion flow non-trivially.
# listing_sources already has confidence + matched_method columns (028_matching_tables.sql),
# but they are written as confidence=1.0 / method='source_link' by the scraper path.
# Accurate per-tier confidence wiring is deferred to #774-3.
return (0, 1.0, "new")
def upsert_listing_source(
db: Session,
*,
listing_id: int,
ext_source: str,
ext_id: str,
method: str,
confidence: float,
price_rub: float | None = None,
area_m2: float | None = None,
floor: int | None = None,
rooms_count: int | None = None,
source_url: str | None = None,
source_data: dict | None = None,
) -> None:
"""Public helper — register or refresh a listing_sources row.
Called by external code after creating a new canonical listing.
"""
_upsert_listing_source(
db,
listing_id=listing_id,
ext_source=ext_source,
ext_id=ext_id,
method=method,
confidence=confidence,
price_rub=price_rub,
area_m2=area_m2,
floor=floor,
rooms_count=rooms_count,
source_url=source_url,
source_data=source_data,
)
def _upsert_listing_source(
db: Session,
*,
listing_id: int,
ext_source: str,
ext_id: str,
method: str,
confidence: float,
price_rub: float | None,
area_m2: float | None,
floor: int | None,
rooms_count: int | None,
source_url: str | None,
source_data: dict | None,
) -> None:
"""Insert or refresh listing_sources row for this source+ext_id."""
raw = json.dumps(source_data) if source_data is not None else None
db.execute(
text("""
INSERT INTO listing_sources (
listing_id, ext_source, ext_id,
confidence, matched_method, matched_at, last_seen_at,
source_url, last_scraped_at,
price_rub, area_m2, floor, rooms_count, raw_payload
) VALUES (
CAST(:lid AS bigint), :s, :e,
CAST(:c AS real), :m, NOW(), NOW(),
:url, NOW(),
CAST(:p AS bigint), CAST(:a AS numeric), :fl, :rc,
CAST(:raw AS jsonb)
)
ON CONFLICT (ext_source, ext_id) DO UPDATE SET
confidence = GREATEST(EXCLUDED.confidence, listing_sources.confidence),
last_seen_at = NOW(),
last_scraped_at = NOW(),
price_rub = COALESCE(EXCLUDED.price_rub, listing_sources.price_rub),
area_m2 = COALESCE(EXCLUDED.area_m2, listing_sources.area_m2),
floor = COALESCE(EXCLUDED.floor, listing_sources.floor),
rooms_count = COALESCE(EXCLUDED.rooms_count, listing_sources.rooms_count),
raw_payload = COALESCE(EXCLUDED.raw_payload, listing_sources.raw_payload)
"""),
{
"lid": listing_id,
"s": ext_source,
"e": str(ext_id),
"c": confidence,
"m": method,
"url": source_url,
# Migration 029 declares listing_sources.price_rub bigint — whole rubles only,
# kopecks truncated. Document expectation in caller.
"p": int(price_rub) if price_rub is not None else None,
"a": area_m2,
"fl": floor,
"rc": rooms_count,
"raw": raw,
},
)