"""Backfill objective_complex_mapping (#203). Auto-match DOM.РФ комплексов к Objective dataset через fuzzy name matching. Goal: coverage 3% → 40%+ для mv_layout_velocity. Schema facts (confirmed via pg MCP): - domrf_kn_objects: фильтр is_ekb = true (~1285 ЕКБ объектов). district_name ILIKE '%екатеринбург%' возвращает 0 строк — не использовать. - objective_corpus_room_month: group_name = 'Екатеринбург' (единственное значение), 263 distinct project_name. - objective_complex_mapping: UNIQUE (objective_complex_name, objective_group), колонки match_method + match_score + is_reviewed поддерживают audit trail. match_method history: 'fuzzy' — legacy Anton SQLite import (avg score 0.98, 127 rows) 'fuzzy_trgm' — pg_trgm backfill, auto-accept threshold=0.85 (первый запуск) 'fuzzy_v2' — pg_trgm backfill, pruned threshold=0.80 (второй запуск, #44) 'auto_core_dev_v5' — core-name normalize (#2198 normalize_complex_name) обеих сторон + dev-confirm. trgm по СЫРЫМ именам давал ~300 маппингов, 1253 obj_id остались (низкая similarity: «Жилой комплекс "Малевич" (ЖК "Малевич")» vs «Малевич»). Ядро-нормализация схлопывает обёртки → exact-core match; dev-подтверждение (пересечение токенов / подстрока) отсеивает одноимённые чужие проекты (Объектив = только ЕКБ). #2177 шаг 2. 'auto_core_geo_v6' — гео-разрешение остатка core-pass (tier_b / ambiguous) через DaData-геокод. Адрес objective-проекта (mode objective_lots. address per project) → clean_address → (lat, lon); haversine до domrf-точки (domrf_kn_objects.latitude/longitude, latest snapshot). tier_b: дистанция ≤ порога → confirm (гео вместо dev). ambiguous: геокодим ВСЕХ кандидатов ядра, confirm только если РОВНО ОДИН в радиусе. score 0.80, is_reviewed=false. #2177. 'manual' — ручная корректура """ from __future__ import annotations import argparse import logging import math import re import sys from dataclasses import dataclass, field from sqlalchemy import text from sqlalchemy.orm import Session from app.services.dadata_client import clean_address from app.services.site_finder.best_layouts import normalize_complex_name logger = logging.getLogger(__name__) # Порог для auto-insert (высокая уверенность) AUTO_ACCEPT_THRESHOLD = 0.85 # Pruned threshold для v2 run (ниже уверенность, is_reviewed=false → review queue) AUTO_ACCEPT_THRESHOLD_V2 = 0.80 # Порог для review queue (средняя уверенность — Phase 2) REVIEW_THRESHOLD = 0.6 @dataclass class MatchCandidate: """Один candidate match DOM.РФ ↔ Objective.""" domrf_obj_id: int domrf_comm_name: str domrf_dev_name: str | None objective_project_name: str similarity_score: float # 0.0..1.0 def find_match_candidates( db: Session, *, only_unmapped: bool = True, min_threshold: float | None = None, limit: int | None = None, ) -> list[MatchCandidate]: """Поиск candidates через pg_trgm similarity. Использует CROSS JOIN LATERAL + similarity() для fuzzy match comm_name (DOM.РФ) ↔ project_name (Objective). Args: db: SQLAlchemy sync Session. only_unmapped: Если True — пропускает уже-mapped obj_id. min_threshold: Нижняя граница similarity для фильтрации кандидатов. По умолчанию REVIEW_THRESHOLD (0.6). limit: Максимальное число строк результата (для тестирования). Returns: Список MatchCandidate, отсортированных по убыванию similarity_score. """ effective_min = min_threshold if min_threshold is not None else REVIEW_THRESHOLD # Формируем SQL. LIMIT добавляем через int() — SQL injection safe (только число). limit_clause = f"LIMIT {int(limit)}" if limit is not None else "" sql = text( f""" WITH domrf_unmapped AS ( SELECT o.obj_id, o.comm_name, o.dev_name FROM domrf_kn_objects o WHERE o.is_ekb = true AND o.comm_name IS NOT NULL AND ( CAST(:only_unmapped AS boolean) = FALSE OR NOT EXISTS ( SELECT 1 FROM objective_complex_mapping cm WHERE cm.domrf_obj_id = o.obj_id ) ) ), objective_distinct AS ( SELECT DISTINCT project_name FROM objective_corpus_room_month WHERE group_name = 'Екатеринбург' ) SELECT d.obj_id, d.comm_name, d.dev_name, obj.project_name, similarity(d.comm_name, obj.project_name) AS sim_score FROM domrf_unmapped d CROSS JOIN LATERAL ( SELECT project_name FROM objective_distinct WHERE similarity(d.comm_name, project_name) > 0.4 ORDER BY similarity(d.comm_name, project_name) DESC LIMIT 1 ) obj WHERE similarity(d.comm_name, obj.project_name) >= CAST(:min_threshold AS float) ORDER BY sim_score DESC {limit_clause} """ ) rows = db.execute( sql, {"only_unmapped": only_unmapped, "min_threshold": effective_min}, ).all() return [ MatchCandidate( domrf_obj_id=int(r[0]), domrf_comm_name=str(r[1]), domrf_dev_name=str(r[2]) if r[2] is not None else None, objective_project_name=str(r[3]), similarity_score=float(r[4]), ) for r in rows ] def auto_apply_matches( db: Session, candidates: list[MatchCandidate], *, threshold: float = AUTO_ACCEPT_THRESHOLD, match_method: str = "fuzzy_trgm", dry_run: bool = False, ) -> dict[str, int]: """Apply candidates с score >= threshold в objective_complex_mapping. Кандидаты ниже threshold, но >= REVIEW_THRESHOLD попадают в review_queue (Phase 2 — UI для ручного review, в этом PR только считаются). ON CONFLICT DO NOTHING — если пара (objective_complex_name, objective_group) уже существует, строка пропускается без ошибки. Args: db: SQLAlchemy sync Session. candidates: Список из find_match_candidates(). threshold: Минимальный score для auto-insert (default 0.85). match_method: Значение колонки match_method в БД. По умолчанию 'fuzzy_trgm'. Для re-run с пониженным порогом передавать 'fuzzy_v2'. dry_run: Если True — только логирует, не пишет в БД. Returns: dict с ключами auto_accepted, review_queue, skipped. """ auto = [c for c in candidates if c.similarity_score >= threshold] review = [c for c in candidates if REVIEW_THRESHOLD <= c.similarity_score < threshold] if dry_run: logger.info( "DRY RUN: would auto-accept %d, review queue %d", len(auto), len(review), ) return {"auto_accepted": len(auto), "review_queue": len(review), "skipped": 0} inserted = 0 skipped = 0 for c in auto: try: with db.begin_nested(): result = db.execute( text( """ INSERT INTO objective_complex_mapping (objective_complex_name, domrf_obj_id, objective_group, match_method, match_score, is_reviewed) VALUES ( CAST(:name AS text), CAST(:obj_id AS bigint), CAST(:group AS text), CAST(:method AS text), CAST(:score AS numeric), CAST(:reviewed AS boolean) ) ON CONFLICT (objective_complex_name, objective_group) DO NOTHING """ ), { "name": c.objective_project_name, "obj_id": c.domrf_obj_id, "group": "Екатеринбург", "method": match_method, "score": c.similarity_score, "reviewed": False, }, ) if result.rowcount > 0: inserted += 1 else: skipped += 1 except Exception as e: logger.warning( "Insert failed для %s ↔ %s: %s", c.domrf_comm_name, c.objective_project_name, e, ) skipped += 1 db.commit() logger.info( "Backfill complete: auto_accepted=%d skipped=%d review_queue=%d", inserted, skipped, len(review), ) return {"auto_accepted": inserted, "review_queue": len(review), "skipped": skipped} def trigger_mv_refresh(db: Session) -> int: """REFRESH mv_layout_velocity после backfill. Вызывает существующий helper из layout_velocity_refresh. Передаём concurrently=True (MV уже заполнен). """ from app.services.site_finder.layout_velocity_refresh import refresh_layout_velocity return refresh_layout_velocity(db, concurrently=True) # ════════════════════════════════════════════════════════════════════════════ # Core-name pass (#2177 шаг 2) — auto_core_dev_v5 # ════════════════════════════════════════════════════════════════════════════ # objective_group у всех существующих строк mapping = 'Екатеринбург' # (Объектив покрывает только ЕКБ). Новые строки пишем с тем же значением. OBJECTIVE_GROUP = "Екатеринбург" CORE_MATCH_METHOD = "auto_core_dev_v5" CORE_MATCH_SCORE = 0.90 CORE_MATCH_NOTE = "#2177 step2: exact-core + dev-confirm" # Префиксы/обёртки орг-формы застройщика, срезаемые перед сравнением. # Порядок применения не важен (все режутся из целого норм-имени). _DEV_JUNK_RE = re.compile( r""" \b(?: ооо | оао | пао | зао | ао | ип | сз # СЗ = специализированный застройщик (аббрев.) | специализированн\w* # «специализированный застройщик» | застройщик\w* | девелопмент\w* | групп\w* # групп / группа | компани\w* | инвест\w* # инвест / инвестиции (частый шум) | строительн\w* )\b """, re.VERBOSE, ) def _norm_dev(raw: str | None) -> str: """Нормализовать имя застройщика до сравниваемого ядра. lower → срез кавычек/скобок → срез орг-формы (ООО/АО/ПАО/ЗАО/«СЗ»/ «специализированный застройщик»/«девелопмент»/«групп(а)») → схлопывание пробелов. Пустой / None → ''. Namespaced на _NAME_-уровне: возвращаем очищенную строку, дальнейшая классификация (пересечение токенов / подстрока) — в find_core_matches. """ if not raw: return "" name = raw.strip().lower() # убрать кавычки-ёлочки/лапки/скобки — оставить только содержательные символы name = re.sub(r"[«»\"'`()\[\]]", " ", name) name = _DEV_JUNK_RE.sub(" ", name) # мусорные разделители → пробел (точки в «Астон.» и т.п. не трогаем на dev-стороне: # dev-имена обычно без них, но на всякий нормализуем распространённые) name = re.sub(r"[.,;/\\]", " ", name) name = re.sub(r"\s+", " ", name).strip() return name def _dev_confirms(domrf_dev: str, objective_devs: set[str]) -> bool: """True если domrf-застройщик подтверждает связь с objective-проектом. У одного objective project_name бывает НЕСКОЛЬКО developers (SPV / очереди) — подтверждение по ЛЮБОМУ из них. Критерий (любой): • пересечение токенов норм-имён (общий содержательный токен), ИЛИ • одно норм-имя — подстрока другого (в любую сторону), НО только когда короткая сторона ≥ 4 символов: «дом»/«юг»/«ак» как подстрока чужого длинного имени («Домострой», «Югстройинвест») — ложное подтверждение, а именно dev-confirm — последняя защита от generic-core коллизий. Пустые имена не подтверждают (нет сигнала). """ d = _norm_dev(domrf_dev) if not d: return False d_tokens = {t for t in d.split() if len(t) >= 3} for raw_odev in objective_devs: o = _norm_dev(raw_odev) if not o: continue if min(len(d), len(o)) >= 4 and (d in o or o in d): return True o_tokens = {t for t in o.split() if len(t) >= 3} if d_tokens & o_tokens: return True return False @dataclass class CoreMatch: """Один core-match domrf-объекта к objective-проекту.""" domrf_obj_id: int domrf_comm_name: str domrf_dev_name: str | None core: str objective_project_name: str objective_project_id: int | None objective_developers: list[str] dev_confirmed: bool @dataclass class CoreMatchReport: """Итог классификации core-pass. tier_a — ровно 1 objective-кандидат И dev-подтверждён → пишем в БД. tier_b — ровно 1 кандидат, dev НЕ подтверждён → кандидат на DaData-гео проход, в БД НЕ пишем (только отчёт). ambiguous — >1 objective-кандидатов по core → в отчёт, разрешение вручную/гео. skipped_taken — objective_complex_name уже занят в mapping (UNIQUE-констрейнт; его domrf-группа уже покрыта — дубли не нужны). """ tier_a: list[CoreMatch] = field(default_factory=list) tier_b: list[CoreMatch] = field(default_factory=list) ambiguous: list[CoreMatch] = field(default_factory=list) skipped_taken: list[CoreMatch] = field(default_factory=list) def counts(self) -> dict[str, int]: return { "tier_a": len(self.tier_a), "tier_b": len(self.tier_b), "ambiguous": len(self.ambiguous), "skipped_taken": len(self.skipped_taken), } # ── SQL: несопоставленные domrf ЕКБ-объекты, latest snapshot per obj_id ─────── # ЛОВУШКА is_ekb (прод-проверка 2026-07-03): флаг проставляется per-snapshot и на # свежих снапшотах ПУСТ (все latest-строки is_ekb=false — см. также комментарий # в 155_objective_mapping_trgm_backfill.sql). `WHERE is_ekb = true` внутри # DISTINCT ON отдал бы устаревшие имена со старых снапшотов (или 0 строк после # DISTINCT). Поэтому: ЕКБ-принадлежность = «obj_id имел is_ekb=true хоть на одном # снапшоте» (1285 живых объектов), а строку берём с LATEST снапшота без флага. _DOMRF_UNMAPPED_SQL = text(""" SELECT DISTINCT ON (o.obj_id) o.obj_id, o.comm_name, o.dev_name FROM domrf_kn_objects o WHERE o.comm_name IS NOT NULL AND o.obj_id IN ( SELECT DISTINCT e.obj_id FROM domrf_kn_objects e WHERE e.is_ekb = true ) AND NOT EXISTS ( SELECT 1 FROM objective_complex_mapping cm WHERE cm.domrf_obj_id = o.obj_id ) ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST """) # ── SQL: objective-проекты — distinct project_name + агрегаты developer/id ──── # objective_project_id: в objective_lots есть колонка objective_project_id # (68_schema_objective.sql). Берём MAX (стабилен per project_name; NULL если нет). _OBJECTIVE_PROJECTS_SQL = text(""" SELECT ol.project_name, ARRAY_REMOVE(ARRAY_AGG(DISTINCT ol.developer), NULL) AS developers, MAX(ol.objective_project_id) AS objective_project_id FROM objective_lots ol WHERE ol.project_name IS NOT NULL AND ol.project_name <> '' GROUP BY ol.project_name """) # ── SQL: уже занятые objective_complex_name в текущей группе ────────────────── _TAKEN_NAMES_SQL = text(""" SELECT objective_complex_name FROM objective_complex_mapping WHERE objective_group = CAST(:group AS text) """) def find_core_matches(db: Session) -> CoreMatchReport: """Сопоставить несопоставленные domrf ЕКБ-объекты с objective-проектами по ядру. Обе стороны нормализуются `normalize_complex_name` (#2198): обёртки вроде «Жилой комплекс "Малевич" (ЖК "Малевич")» схлопываются до ядра «малевич», что даёт exact-core match там, где trgm по сырым именам не дотягивал. Классификация (см. CoreMatchReport): tier_a — 1 objective-кандидат + dev-подтверждён (пишем); tier_b — 1 кандидат, dev НЕ подтверждён (DaData-гео проход, не пишем); ambiguous — >1 кандидата (ручное/гео разрешение); skipped — objective_complex_name уже в mapping (UNIQUE-констрейнт). Реального обращения к БД в тестах нет — db.execute мокается по паттерну файла. """ report = CoreMatchReport() # objective-сторона: core → [(project_name, {devs}, project_id), ...] objective_by_core: dict[str, list[tuple[str, set[str], int | None]]] = {} for row in db.execute(_OBJECTIVE_PROJECTS_SQL).all(): project_name = str(row[0]) developers = {str(d) for d in (row[1] or []) if d} project_id = int(row[2]) if row[2] is not None else None core = normalize_complex_name(project_name) if not core: continue objective_by_core.setdefault(core, []).append((project_name, developers, project_id)) # уже занятые objective_complex_name (skip — их группа уже покрыта) taken_names: set[str] = { str(r[0]) for r in db.execute(_TAKEN_NAMES_SQL, {"group": OBJECTIVE_GROUP}).all() } # domrf-сторона: несопоставленные ЕКБ, latest snapshot per obj_id for row in db.execute(_DOMRF_UNMAPPED_SQL).all(): obj_id = int(row[0]) comm_name = str(row[1]) dev_name = str(row[2]) if row[2] is not None else None core = normalize_complex_name(comm_name) if not core: continue candidates = objective_by_core.get(core) if not candidates: continue # нет objective-проекта с таким ядром — не наш случай if len(candidates) > 1: # >1 objective project_name делят одно ядро → ambiguous (не выбираем вслепую) proj_name, devs, proj_id = candidates[0] report.ambiguous.append( _build_core_match(obj_id, comm_name, dev_name, core, proj_name, proj_id, devs) ) continue proj_name, devs, proj_id = candidates[0] if proj_name in taken_names: report.skipped_taken.append( _build_core_match(obj_id, comm_name, dev_name, core, proj_name, proj_id, devs) ) continue match = _build_core_match(obj_id, comm_name, dev_name, core, proj_name, proj_id, devs) if match.dev_confirmed: report.tier_a.append(match) else: report.tier_b.append(match) logger.info("find_core_matches: %s", report.counts()) return report def _build_core_match( obj_id: int, comm_name: str, dev_name: str | None, core: str, proj_name: str, proj_id: int | None, devs: set[str], ) -> CoreMatch: """Сконструировать CoreMatch + посчитать dev_confirmed.""" return CoreMatch( domrf_obj_id=obj_id, domrf_comm_name=comm_name, domrf_dev_name=dev_name, core=core, objective_project_name=proj_name, objective_project_id=proj_id, objective_developers=sorted(devs), dev_confirmed=_dev_confirms(dev_name or "", devs), ) def apply_core_matches( db: Session, report: CoreMatchReport, *, dry_run: bool = True, ) -> dict[str, int]: """Вставить ТОЛЬКО tier_a в objective_complex_mapping (auto_core_dev_v5). tier_b / ambiguous / skipped НЕ пишутся (см. CoreMatchReport). Каждая вставка в per-row SAVEPOINT (backend.md) с ON CONFLICT (objective_complex_name, objective_group) DO NOTHING — гонка/дубль не роняет весь проход. Args: db: SQLAlchemy sync Session. report: результат find_core_matches(). dry_run: True (default) — только логирует counts + первые 30 пар, БЕЗ insert. Returns: dict: inserted, conflict_skipped, error_skipped, tier_a_total. """ counts = report.counts() logger.info( "apply_core_matches (dry_run=%s): tier_a=%d tier_b=%d ambiguous=%d skipped_taken=%d", dry_run, counts["tier_a"], counts["tier_b"], counts["ambiguous"], counts["skipped_taken"], ) for m in report.tier_a[:30]: logger.info( " tier_a: obj_id=%s core=%r domrf=%r → objective=%r (dev %r ~ %r)", m.domrf_obj_id, m.core, m.domrf_comm_name, m.objective_project_name, m.domrf_dev_name, m.objective_developers, ) if dry_run: return { "inserted": 0, "conflict_skipped": 0, "error_skipped": 0, "tier_a_total": counts["tier_a"], } inserted = 0 conflict_skipped = 0 error_skipped = 0 for m in report.tier_a: try: with db.begin_nested(): result = db.execute( text( """ INSERT INTO objective_complex_mapping (objective_complex_name, objective_project_id, domrf_obj_id, objective_group, match_method, match_score, is_reviewed, note) VALUES ( CAST(:name AS text), CAST(:project_id AS bigint), CAST(:obj_id AS bigint), CAST(:group AS text), CAST(:method AS text), CAST(:score AS numeric), CAST(:reviewed AS boolean), CAST(:note AS text) ) ON CONFLICT (objective_complex_name, objective_group) DO NOTHING """ ), { "name": m.objective_project_name, "project_id": m.objective_project_id, "obj_id": m.domrf_obj_id, "group": OBJECTIVE_GROUP, "method": CORE_MATCH_METHOD, "score": CORE_MATCH_SCORE, "reviewed": False, "note": CORE_MATCH_NOTE, }, ) if result.rowcount > 0: inserted += 1 else: conflict_skipped += 1 except Exception as e: logger.warning( "core insert failed для %s ↔ %s: %s", m.domrf_comm_name, m.objective_project_name, e, ) error_skipped += 1 db.commit() logger.info( "apply_core_matches done: inserted=%d conflict_skipped=%d error_skipped=%d", inserted, conflict_skipped, error_skipped, ) return { "inserted": inserted, "conflict_skipped": conflict_skipped, "error_skipped": error_skipped, "tier_a_total": counts["tier_a"], } def run_core_pass(*, apply: bool = False) -> dict[str, int]: """CLI-обёртка core-pass: открыть Session, найти матчи, (опц.) применить. Дефолт — dry-run. Реальный insert только при apply=True. """ from app.core.db import SessionLocal db = SessionLocal() try: report = find_core_matches(db) return apply_core_matches(db, report, dry_run=not apply) finally: db.close() # ════════════════════════════════════════════════════════════════════════════ # Geo-pass (#2177) — auto_core_geo_v6 # ════════════════════════════════════════════════════════════════════════════ # # Разрешает ОСТАТОК core-pass (tier_b + ambiguous) географией: адрес objective- # проекта → DaData-геокод → haversine до domrf-точки. tier_b подтверждается # близостью (гео вместо dev-confirm); ambiguous — только если РОВНО ОДИН из # кандидатов ядра в радиусе (иначе остаётся ambiguous). GEO_MATCH_METHOD = "auto_core_geo_v6" GEO_MATCH_SCORE = 0.80 GEO_MAX_DISTANCE_M = 500.0 # Бережный потолок вызовов DaData за один прогон (МАКС-тариф, но не жжём квоту # на прогон-цикле). Превышение → стоп с warning, недобранные кандидаты reject'ятся # как «без геокода». GEO_DADATA_CALL_LIMIT = 200 _EARTH_RADIUS_M = 6_371_000.0 def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: """Расстояние (метры) между двумя точками по формуле гаверсинуса.""" p1 = math.radians(lat1) p2 = math.radians(lat2) dphi = math.radians(lat2 - lat1) dlambda = math.radians(lon2 - lon1) a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlambda / 2) ** 2 return 2 * _EARTH_RADIUS_M * math.asin(min(1.0, math.sqrt(a))) @dataclass class GeoMatch: """Один подтверждённый гео-матч domrf-объекта к objective-проекту. origin — 'tier_b' | 'ambiguous' (из какого класса core-pass пришёл). distance_m — гаверсинус адрес-проекта ↔ domrf-точки (метры). """ domrf_obj_id: int domrf_comm_name: str core: str objective_project_name: str objective_project_id: int | None address: str distance_m: float origin: str @dataclass class GeoReject: """Отклонённый гео-кандидат (для отчёта). reason: 'no_address' | 'no_geocode' | 'too_far' | 'ambiguous_multi' | 'call_limit'. distance_m None когда дистанцию посчитать не удалось (нет адреса/геокода/ координат domrf). """ domrf_obj_id: int domrf_comm_name: str objective_project_name: str origin: str reason: str distance_m: float | None = None @dataclass class GeoMatchReport: """Итог geo-pass. confirmed — подтверждённые гео-матчи (пишутся в apply_geo_matches). rejected — кандидаты без адреса/геокода/слишком далёкие/оставшиеся ambiguous. dadata_calls — сколько раз реально дёрнули DaData (для контроля квоты). call_limit_hit — True если упёрлись в GEO_DADATA_CALL_LIMIT (часть кандидатов не обработана — см. rejected c reason='call_limit'). """ confirmed: list[GeoMatch] = field(default_factory=list) rejected: list[GeoReject] = field(default_factory=list) dadata_calls: int = 0 call_limit_hit: bool = False def counts(self) -> dict[str, int]: return { "confirmed": len(self.confirmed), "rejected": len(self.rejected), "dadata_calls": self.dadata_calls, } # ── SQL: mode (самый частый непустой) адрес objective_lots per project_name ──── # Адресов на проект много (по корпусам) — берём mode: GROUP BY (project_name, # address) → COUNT DESC, DISTINCT ON (project_name) отбирает верхнюю строку. # NULLIF(trim,'') отсекает пустые адреса до агрегации. _OBJECTIVE_ADDRESS_SQL = text(""" SELECT DISTINCT ON (project_name) project_name, address FROM ( SELECT ol.project_name AS project_name, NULLIF(trim(ol.address), '') AS address, COUNT(*) AS cnt FROM objective_lots ol WHERE ol.project_name IS NOT NULL AND ol.project_name <> '' AND NULLIF(trim(ol.address), '') IS NOT NULL GROUP BY ol.project_name, NULLIF(trim(ol.address), '') ) agg ORDER BY project_name, cnt DESC, address """) # ── SQL: latest-snapshot координаты domrf per obj_id (для набора obj_id) ─────── # DISTINCT ON (obj_id) + snapshot_date DESC — берём свежую точку. Фильтр по # конкретным obj_id (кандидаты geo-pass), NULL-координаты отбрасываются. _DOMRF_COORDS_SQL = text(""" SELECT DISTINCT ON (o.obj_id) o.obj_id, o.latitude, o.longitude FROM domrf_kn_objects o WHERE o.obj_id = ANY(:obj_ids) AND o.latitude IS NOT NULL AND o.longitude IS NOT NULL ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST """) def _load_project_addresses(db: Session) -> dict[str, str]: """project_name → mode-адрес (самый частый непустой) из objective_lots.""" out: dict[str, str] = {} for row in db.execute(_OBJECTIVE_ADDRESS_SQL).all(): name = str(row[0]) addr = row[1] if addr: out[name] = str(addr) return out def _load_domrf_coords(db: Session, obj_ids: list[int]) -> dict[int, tuple[float, float]]: """obj_id → (lat, lon) latest snapshot для заданного набора obj_id.""" if not obj_ids: return {} out: dict[int, tuple[float, float]] = {} for row in db.execute(_DOMRF_COORDS_SQL, {"obj_ids": obj_ids}).all(): lat = float(row[1]) lon = float(row[2]) out[int(row[0])] = (lat, lon) return out def find_geo_matches(db: Session, *, max_distance_m: float = GEO_MAX_DISTANCE_M) -> GeoMatchReport: """Разрешить остаток core-pass (tier_b + ambiguous) через DaData-геокод. Алгоритм: 1. find_core_matches → берём report.tier_b и report.ambiguous. 2. Для каждого кандидата: адрес проекта (mode objective_lots.address) → clean_address → (lat, lon); haversine до domrf-точки. • tier_b: дистанция ≤ max_distance_m → confirm (гео вместо dev); • ambiguous: геокодим ВСЕХ кандидатов ядра; confirm только если РОВНО ОДИН в радиусе (иначе остаётся ambiguous); • нет адреса / DaData null / дистанция больше → reject c дистанцией. 3. Кэш геокода в памяти прогона (адреса повторяются). Лимит вызовов DaData ≤ GEO_DADATA_CALL_LIMIT — при превышении стоп с warning, остаток reject. Args: db: SQLAlchemy sync Session. max_distance_m: радиус подтверждения (метры), default 500. Returns: GeoMatchReport (confirmed / rejected / dadata_calls / call_limit_hit). Реального обращения к БД / DaData в тестах нет — оба мокаются по паттерну файла. """ core_report = find_core_matches(db) report = GeoMatchReport() tier_b = core_report.tier_b ambiguous = core_report.ambiguous if not tier_b and not ambiguous: logger.info("find_geo_matches: нет tier_b/ambiguous кандидатов — nothing to do") return report addresses = _load_project_addresses(db) # ── ambiguous: нужны ВСЕ objective-кандидаты ядра, не только первый ──────── # core-report.ambiguous хранит по одному CoreMatch на domrf-obj_id (первый # кандидат). Восстанавливаем полный список project_name'ов ядра из objective- # стороны, чтобы геокодить всех и выбрать единственного в радиусе. objective_by_core = _load_objective_by_core(db) # obj_id'ы, для которых нужны координаты domrf (tier_b + ambiguous) needed_obj_ids = [m.domrf_obj_id for m in tier_b] + [m.domrf_obj_id for m in ambiguous] domrf_coords = _load_domrf_coords(db, needed_obj_ids) # Кэш геокода в памяти прогона: address → (lat, lon) | None (None = «пробовали, # DaData не дал»; кэшируем и его, чтобы не жечь квоту на повтор одного адреса). geo_cache: dict[str, tuple[float, float] | None] = {} def _geocode(addr: str) -> tuple[float, float] | None: """Геокод с кэшем + лимитом вызовов. None при отсутствии/лимите. Адреса objective_lots идут БЕЗ города («ул. Гидрострой, д. 2а») — DaData на таком отдаёт qc_geo=5 (нет координат): прод-прогон 2026-07-03 дал confirmed=0/45 именно из-за этого. Объектив покрывает только ЕКБ → безопасно префиксуем городом, если его нет в строке. """ if addr in geo_cache: return geo_cache[addr] if report.dadata_calls >= GEO_DADATA_CALL_LIMIT: report.call_limit_hit = True return None query = addr if "екатеринбург" in addr.lower() else f"г Екатеринбург, {addr}" report.dadata_calls += 1 coords = clean_address(query) geo_cache[addr] = coords return coords # ── tier_b: 1 кандидат, подтверждаем близостью ──────────────────────────── for m in tier_b: domrf_pt = domrf_coords.get(m.domrf_obj_id) addr = addresses.get(m.objective_project_name) if not addr: report.rejected.append(_geo_reject(m, "tier_b", "no_address")) continue if domrf_pt is None: # у domrf нет координат — дистанцию не посчитать; не жжём квоту на геокод report.rejected.append(_geo_reject(m, "tier_b", "no_geocode")) continue if report.dadata_calls >= GEO_DADATA_CALL_LIMIT and addr not in geo_cache: report.call_limit_hit = True report.rejected.append(_geo_reject(m, "tier_b", "call_limit")) continue coords = _geocode(addr) if coords is None: report.rejected.append(_geo_reject(m, "tier_b", "no_geocode")) continue dist = _haversine_m(coords[0], coords[1], domrf_pt[0], domrf_pt[1]) if dist <= max_distance_m: report.confirmed.append(_geo_confirm(m, addr, dist, "tier_b")) else: report.rejected.append(_geo_reject(m, "tier_b", "too_far", dist)) # ── ambiguous: геокодим всех кандидатов ядра, confirm если РОВНО ОДИН близко ─ for m in ambiguous: domrf_pt = domrf_coords.get(m.domrf_obj_id) if domrf_pt is None: report.rejected.append(_geo_reject(m, "ambiguous", "no_geocode")) continue candidates = objective_by_core.get(m.core, []) in_radius: list[tuple[str, int | None, str, float]] = [] any_geocoded = False geocoded_count = 0 for proj_name, _devs, proj_id in candidates: addr = addresses.get(proj_name) if not addr: continue if report.dadata_calls >= GEO_DADATA_CALL_LIMIT and addr not in geo_cache: report.call_limit_hit = True continue coords = _geocode(addr) if coords is None: continue any_geocoded = True geocoded_count += 1 dist = _haversine_m(coords[0], coords[1], domrf_pt[0], domrf_pt[1]) if dist <= max_distance_m: in_radius.append((proj_name, proj_id, addr, dist)) # Ревью 2026-07-03: «ровно один в радиусе» доверяем ТОЛЬКО когда # геокодились ВСЕ кандидаты ядра. Иначе «один» — артефакт отсутствия # данных (сосед без адреса/геокода мог быть ближе) → слепой confirm # запрещён, остаётся честный reject partial_geocode. if len(in_radius) == 1 and geocoded_count < len(candidates): report.rejected.append(_geo_reject(m, "ambiguous", "partial_geocode", in_radius[0][3])) elif len(in_radius) == 1: proj_name, proj_id, addr, dist = in_radius[0] report.confirmed.append( GeoMatch( domrf_obj_id=m.domrf_obj_id, domrf_comm_name=m.domrf_comm_name, core=m.core, objective_project_name=proj_name, objective_project_id=proj_id, address=addr, distance_m=dist, origin="ambiguous", ) ) elif not any_geocoded: reason = "call_limit" if report.call_limit_hit else "no_geocode" report.rejected.append(_geo_reject(m, "ambiguous", reason)) else: # 0 в радиусе, или >1 в радиусе → остаётся ambiguous nearest = min((d for *_, d in in_radius), default=None) report.rejected.append(_geo_reject(m, "ambiguous", "ambiguous_multi", nearest)) logger.info( "find_geo_matches: %s call_limit_hit=%s", report.counts(), report.call_limit_hit, ) return report def _load_objective_by_core(db: Session) -> dict[str, list[tuple[str, set[str], int | None]]]: """core → [(project_name, {devs}, project_id), ...] (как в find_core_matches).""" out: dict[str, list[tuple[str, set[str], int | None]]] = {} for row in db.execute(_OBJECTIVE_PROJECTS_SQL).all(): project_name = str(row[0]) developers = {str(d) for d in (row[1] or []) if d} project_id = int(row[2]) if row[2] is not None else None core = normalize_complex_name(project_name) if not core: continue out.setdefault(core, []).append((project_name, developers, project_id)) return out def _geo_confirm(m: CoreMatch, address: str, distance_m: float, origin: str) -> GeoMatch: return GeoMatch( domrf_obj_id=m.domrf_obj_id, domrf_comm_name=m.domrf_comm_name, core=m.core, objective_project_name=m.objective_project_name, objective_project_id=m.objective_project_id, address=address, distance_m=distance_m, origin=origin, ) def _geo_reject( m: CoreMatch, origin: str, reason: str, distance_m: float | None = None ) -> GeoReject: return GeoReject( domrf_obj_id=m.domrf_obj_id, domrf_comm_name=m.domrf_comm_name, objective_project_name=m.objective_project_name, origin=origin, reason=reason, distance_m=distance_m, ) def apply_geo_matches( db: Session, report: GeoMatchReport, *, dry_run: bool = True, ) -> dict[str, int]: """Вставить report.confirmed в objective_complex_mapping (auto_core_geo_v6). Та же per-row SAVEPOINT + ON CONFLICT (objective_complex_name, objective_group) DO NOTHING механика, что в apply_core_matches. Каждая строка: match_method='auto_core_geo_v6', match_score=0.80, is_reviewed=false, note с дистанцией в метрах и origin (tier_b/ambiguous). Args: db: SQLAlchemy sync Session. report: результат find_geo_matches(). dry_run: True (default) — только counts + первые 30 пар с дистанциями, БЕЗ insert. Returns: dict: inserted, conflict_skipped, error_skipped, confirmed_total. """ counts = report.counts() logger.info( "apply_geo_matches (dry_run=%s): confirmed=%d rejected=%d dadata_calls=%d", dry_run, counts["confirmed"], counts["rejected"], counts["dadata_calls"], ) for gm in report.confirmed[:30]: logger.info( " confirm[%s]: obj_id=%s core=%r domrf=%r → objective=%r (%.0f м, addr=%r)", gm.origin, gm.domrf_obj_id, gm.core, gm.domrf_comm_name, gm.objective_project_name, gm.distance_m, gm.address[:60], ) if dry_run: return { "inserted": 0, "conflict_skipped": 0, "error_skipped": 0, "confirmed_total": counts["confirmed"], } inserted = 0 conflict_skipped = 0 error_skipped = 0 for gm in report.confirmed: note = f"#2177 geo-pass ({gm.origin}): {gm.distance_m:.0f} м to domrf point" try: with db.begin_nested(): result = db.execute( text( """ INSERT INTO objective_complex_mapping (objective_complex_name, objective_project_id, domrf_obj_id, objective_group, match_method, match_score, is_reviewed, note) VALUES ( CAST(:name AS text), CAST(:project_id AS bigint), CAST(:obj_id AS bigint), CAST(:group AS text), CAST(:method AS text), CAST(:score AS numeric), CAST(:reviewed AS boolean), CAST(:note AS text) ) ON CONFLICT (objective_complex_name, objective_group) DO NOTHING """ ), { "name": gm.objective_project_name, "project_id": gm.objective_project_id, "obj_id": gm.domrf_obj_id, "group": OBJECTIVE_GROUP, "method": GEO_MATCH_METHOD, "score": GEO_MATCH_SCORE, "reviewed": False, "note": note, }, ) if result.rowcount > 0: inserted += 1 else: conflict_skipped += 1 except Exception as e: logger.warning( "geo insert failed для %s ↔ %s: %s", gm.domrf_comm_name, gm.objective_project_name, e, ) error_skipped += 1 db.commit() logger.info( "apply_geo_matches done: inserted=%d conflict_skipped=%d error_skipped=%d", inserted, conflict_skipped, error_skipped, ) return { "inserted": inserted, "conflict_skipped": conflict_skipped, "error_skipped": error_skipped, "confirmed_total": counts["confirmed"], } def run_geo_pass( *, apply: bool = False, max_distance_m: float = GEO_MAX_DISTANCE_M ) -> dict[str, int]: """CLI-обёртка geo-pass: открыть Session, найти гео-матчи, (опц.) применить. Дефолт — dry-run. Реальный insert только при apply=True. """ from app.core.db import SessionLocal db = SessionLocal() try: report = find_geo_matches(db, max_distance_m=max_distance_m) return apply_geo_matches(db, report, dry_run=not apply) finally: db.close() def _main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="objective_backfill passes (#2177): core-pass (auto_core_dev_v5), " "geo-pass (auto_core_geo_v6)." ) parser.add_argument( "--core-pass", action="store_true", help="Запустить core-name + dev-confirm проход (auto_core_dev_v5).", ) parser.add_argument( "--geo-pass", action="store_true", help="Запустить DaData-гео проход по tier_b/ambiguous остатку core-pass " "(auto_core_geo_v6).", ) parser.add_argument( "--max-distance-m", type=float, default=GEO_MAX_DISTANCE_M, help="Радиус подтверждения гео-матча в метрах (geo-pass; " f"default {GEO_MAX_DISTANCE_M:.0f}).", ) parser.add_argument( "--apply", action="store_true", help="Реально писать матчи в БД (по умолчанию — dry-run).", ) args = parser.parse_args(argv) logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) if not args.core_pass and not args.geo_pass: parser.print_help() return 0 if args.core_pass: result = run_core_pass(apply=args.apply) logger.info("core-pass result: %s", result) if args.geo_pass: geo_result = run_geo_pass(apply=args.apply, max_distance_m=args.max_distance_m) logger.info("geo-pass result: %s", geo_result) return 0 if __name__ == "__main__": sys.exit(_main())