All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m45s
CI / backend-tests (pull_request) Successful in 14m3s
1165 lines
48 KiB
Python
1165 lines
48 KiB
Python
"""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 при отсутствии/лимите."""
|
||
if addr in geo_cache:
|
||
return geo_cache[addr]
|
||
if report.dadata_calls >= GEO_DADATA_CALL_LIMIT:
|
||
report.call_limit_hit = True
|
||
return None
|
||
report.dadata_calls += 1
|
||
coords = clean_address(addr)
|
||
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())
|