Some checks failed
CI / backend-tests (pull_request) Successful in 14m13s
CI Trade-In / changes (pull_request) Successful in 6s
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 / openapi-codegen-check (pull_request) Failing after 1m44s
CI / frontend-tests (pull_request) Has been skipped
671 lines
27 KiB
Python
671 lines
27 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.
|
||
'manual' — ручная корректура
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import logging
|
||
import re
|
||
import sys
|
||
from dataclasses import dataclass, field
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
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()
|
||
|
||
|
||
def _main(argv: list[str] | None = None) -> int:
|
||
parser = argparse.ArgumentParser(
|
||
description="objective_backfill core-pass (#2177 step2, auto_core_dev_v5)."
|
||
)
|
||
parser.add_argument(
|
||
"--core-pass",
|
||
action="store_true",
|
||
help="Запустить core-name + dev-confirm проход (иначе no-op).",
|
||
)
|
||
parser.add_argument(
|
||
"--apply",
|
||
action="store_true",
|
||
help="Реально писать tier_a в БД (по умолчанию — 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:
|
||
parser.print_help()
|
||
return 0
|
||
|
||
result = run_core_pass(apply=args.apply)
|
||
logger.info("core-pass result: %s", result)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(_main())
|