"""House cross-source matching — 3-tier algorithm. Tier 0 (confidence 1.0): cadastral_number exact match on houses table. Tier 1 (confidence 1.0): ext_source + ext_id already in house_sources. Tier 2 (confidence 0.9): address_fingerprint match in house_address_aliases. Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin). New (confidence 1.0): INSERT new canonical house. Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 3 """ import logging from sqlalchemy import text from sqlalchemy.orm import Session from app.services.matching.normalize import address_fingerprint, normalize_address logger = logging.getLogger(__name__) def match_or_create_house( db: Session, ext_source: str, ext_id: str, address: str | None = None, lat: float | None = None, lon: float | None = None, *, year_built: int | None = None, building_cadastral_number: str | None = None, cadastral_number: str | None = None, ) -> tuple[int, float, str]: """Match existing house or create new canonical record. Returns: (house_id, confidence in [0.0, 1.0], method name) Method values: 'cadastr_exact' — matched by cadastral number (confidence 1.0) 'source_exact' — already in house_sources for this source+ext_id (confidence 1.0) 'fingerprint' — matched by address fingerprint (confidence 0.9) 'geo_proximity' — matched by geo within 30 m (confidence 0.7) 'new' — new house created (confidence 1.0) """ cad = building_cadastral_number or cadastral_number # Tier 0: cadastral number exact match if cad: row = db.execute( text('SELECT id FROM houses WHERE cadastral_number = :cad LIMIT 1'), {'cad': cad}, ).mappings().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='cadastr_exact', confidence=1.0, ) logger.info('house match cadastr_exact house_id=%s cad=%s', house_id, cad) return (house_id, 1.0, 'cadastr_exact') # Tier 1: source+ext_id already registered in house_sources row = db.execute( text( 'SELECT house_id FROM house_sources ' 'WHERE ext_source = :s AND ext_id = :e LIMIT 1' ), {'s': ext_source, 'e': str(ext_id)}, ).mappings().first() if row: house_id = int(row['house_id']) logger.info('house match source_exact house_id=%s src=%s ext_id=%s', house_id, ext_source, ext_id) return (house_id, 1.0, 'source_exact') # Compute fingerprint once for Tier 2 and reuse for new house registration fp = address_fingerprint(address, lat, lon) # Tier 2: address fingerprint lookup row = db.execute( text( 'SELECT house_id FROM house_address_aliases ' 'WHERE fingerprint = :fp LIMIT 1' ), {'fp': fp}, ).mappings().first() if row: house_id = int(row['house_id']) _upsert_house_source( db, house_id=house_id, ext_source=ext_source, ext_id=ext_id, method='fingerprint', confidence=0.9, ) logger.info('house match fingerprint house_id=%s fp=%s', house_id, fp) return (house_id, 0.9, 'fingerprint') # Tier 3: geo-proximity — within 30 m (PostGIS geography) if lat is not None and lon is not None: row = db.execute( text(""" SELECT id, ST_Distance(geom, ST_MakePoint(:lon, :lat)::geography) AS dist FROM houses WHERE geom IS NOT NULL AND ST_DWithin(geom, ST_MakePoint(:lon, :lat)::geography, 30) ORDER BY dist ASC LIMIT 1 """), {'lat': lat, 'lon': lon}, ).mappings().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') # New house — INSERT canonical record geom_expr = ( 'ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography' if lat is not None and lon is not None else 'NULL' ) row = db.execute( text(f""" INSERT INTO houses (address, lat, lon, geom, year_built, cadastral_number) VALUES ( :addr, CAST(:lat AS double precision), CAST(:lon AS double precision), {geom_expr}, CAST(:yb AS integer), :cad ) RETURNING id """), { 'addr': address, 'lat': lat, 'lon': lon, 'yb': year_built, 'cad': cad, }, ).mappings().one() house_id = int(row['id']) _upsert_house_source( db, house_id=house_id, ext_source=ext_source, ext_id=ext_id, method='new', confidence=1.0, ) _insert_alias(db, house_id=house_id, address=address, fp=fp, source=ext_source) logger.info('house new house_id=%s addr=%r src=%s', house_id, address, ext_source) return (house_id, 1.0, 'new') def _upsert_house_source( db: Session, *, house_id: int, ext_source: str, ext_id: str, method: str, confidence: float, ) -> None: """Insert or refresh house_sources row for this source+ext_id.""" db.execute( text(""" INSERT INTO house_sources ( house_id, ext_source, ext_id, confidence, matched_method, matched_at, last_seen_at ) VALUES ( CAST(:hid AS bigint), :s, :e, CAST(:c AS real), :m, NOW(), NOW() ) ON CONFLICT (ext_source, ext_id) DO UPDATE SET confidence = GREATEST(EXCLUDED.confidence, house_sources.confidence), last_seen_at = NOW() """), {'hid': house_id, 's': ext_source, 'e': str(ext_id), 'c': confidence, 'm': method}, ) def _insert_alias( db: Session, *, house_id: int, address: str | None, fp: str, source: str, ) -> None: """Register normalized_address alias for this house (idempotent on normalized_address).""" db.execute( text(""" INSERT INTO house_address_aliases (house_id, normalized_address, fingerprint, source) VALUES (CAST(:hid AS bigint), :na, :fp, :src) ON CONFLICT (normalized_address) DO NOTHING """), { 'hid': house_id, 'na': normalize_address(address), 'fp': fp, 'src': source, }, )