gendesign/backend/app/api/v1/admin_etl.py
bot-backend dfc1ee8be0
Some checks failed
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m47s
Deploy / build-worker (push) Successful in 5m42s
Deploy / build-frontend (push) Successful in 5m55s
Deploy / deploy (push) Has been cancelled
feat: ревью-инструмент авто-маппингов Объектив↔ДОМ.РФ (/admin/mapping-review) (#2278)
2026-07-03 07:59:13 +00:00

236 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Admin endpoints для ETL operations (#203, #44).
POST /api/v1/admin/etl/objective-backfill
Запустить fuzzy-match backfill objective_complex_mapping.
Поддерживает два режима:
- v1 (default): threshold=0.85, match_method='fuzzy_trgm'
- v2: threshold=0.80, match_method='fuzzy_v2' — для coverage 8.5%→17%
POST /api/v1/admin/etl/nspd-denorm-backfill
Запустить backfill nspd_parcels/nspd_buildings из всех nspd_quarter_dumps.
GET /api/v1/admin/etl/mapping-review
Список авто-маппингов objective_complex_mapping + обогащение для ревью.
POST /api/v1/admin/etl/mapping-review/{id}/approve
Подтвердить маппинг (is_reviewed=true + note append).
POST /api/v1/admin/etl/mapping-review/{id}/reject
Отклонить маппинг — DELETE строки (иначе травит mv_layout_velocity §4.2).
Auth: gendsgn.ru-wide Caddy basic_auth gate (PR #426). App-level X-Admin-Token
header removed 2026-05-23 — двойная auth избыточна для pilot.
"""
from __future__ import annotations
import logging
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.services.etl.mapping_review import (
approve_mapping,
list_mapping_review,
reject_mapping,
)
from app.services.etl.objective_backfill import (
AUTO_ACCEPT_THRESHOLD,
AUTO_ACCEPT_THRESHOLD_V2,
REVIEW_THRESHOLD,
apply_core_matches,
auto_apply_matches,
find_core_matches,
find_match_candidates,
trigger_mv_refresh,
)
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/objective-backfill")
def run_objective_backfill(
db: Annotated[Session, Depends(get_db)],
dry_run: Annotated[bool, Query(description="Preview без insertions")] = False,
refresh_mv: Annotated[
bool, Query(description="REFRESH mv_layout_velocity после backfill")
] = True,
v2: Annotated[
bool,
Query(
description=(
"v2 mode: threshold=0.80, match_method='fuzzy_v2'. "
"Запускать после v1 run — только для unmapped объектов."
)
),
] = False,
) -> dict[str, object]:
"""Запустить backfill objective_complex_mapping + опционально REFRESH MV.
Ищет DOM.РФ комплексы (is_ekb=true) без mapping и применяет fuzzy match
к project_name из objective_corpus_room_month через pg_trgm similarity.
Режим v1 (default, ?v2=false):
- score >= 0.85 (AUTO_ACCEPT_THRESHOLD): auto-insert, match_method='fuzzy_trgm'
- score >= 0.6 (REVIEW_THRESHOLD) и < 0.85: только счётчик review_queue
Режим v2 (?v2=true, task #44 coverage expansion):
- score >= 0.80 (AUTO_ACCEPT_THRESHOLD_V2): auto-insert, match_method='fuzzy_v2'
- is_reviewed=false — требует ручной проверки (false positives вероятны)
- Целевой прирост: +~47-80 строк, coverage 8.5% → ~17%
Returns dict:
auto_accepted: сколько строк вставлено
review_queue: сколько кандидатов ниже порога auto-accept
skipped: ON CONFLICT + ошибки
mv_rows_after_refresh: строк в MV после REFRESH (0 если refresh_mv=False)
threshold_used: фактический порог (float)
match_method_used: match_method в БД (str)
"""
threshold = AUTO_ACCEPT_THRESHOLD_V2 if v2 else AUTO_ACCEPT_THRESHOLD
method = "fuzzy_v2" if v2 else "fuzzy_trgm"
# v2 ищет кандидатов начиная с threshold (не от REVIEW_THRESHOLD)
search_min = threshold if v2 else REVIEW_THRESHOLD
candidates = find_match_candidates(db, only_unmapped=True, min_threshold=search_min)
logger.info(
"Backfill candidates found: %d (score >= %.2f, method=%s)",
len(candidates),
search_min,
method,
)
result: dict[str, object] = dict(
auto_apply_matches(
db, candidates, threshold=threshold, match_method=method, dry_run=dry_run
)
)
mv_rows = 0
if refresh_mv and not dry_run and result.get("auto_accepted", 0):
mv_rows = trigger_mv_refresh(db)
logger.info("mv_layout_velocity refreshed after backfill: %d rows", mv_rows)
result["mv_rows_after_refresh"] = mv_rows
result["threshold_used"] = threshold
result["match_method_used"] = method
return result
@router.post("/objective-core-backfill")
def run_objective_core_backfill(
db: Annotated[Session, Depends(get_db)],
dry_run: Annotated[bool, Query(description="Preview без insertions (default True)")] = True,
refresh_mv: Annotated[bool, Query(description="REFRESH mv_layout_velocity после apply")] = True,
) -> dict[str, object]:
"""Core-name + dev-confirm проход (#2177 шаг 2, match_method='auto_core_dev_v5').
Нормализует ЯДРО имени обеих сторон (normalize_complex_name, #2198) и мапит
несопоставленные domrf ЕКБ-объекты на objective-проекты. Пишутся ТОЛЬКО tier_a
(ровно 1 кандидат + подтверждение застройщика). tier_b/ambiguous — в counts.
Дефолт dry_run=True (безопасно). Реальный insert — ?dry_run=false.
Returns dict:
inserted / conflict_skipped / error_skipped / tier_a_total — из apply;
tier_b / ambiguous / skipped_taken — счётчики отчёта;
mv_rows_after_refresh — строк в MV после REFRESH (0 если dry-run / нет вставок).
"""
report = find_core_matches(db)
result: dict[str, object] = dict(apply_core_matches(db, report, dry_run=dry_run))
result.update(report.counts())
mv_rows = 0
if refresh_mv and not dry_run and result.get("inserted", 0):
mv_rows = trigger_mv_refresh(db)
logger.info("mv_layout_velocity refreshed after core-backfill: %d rows", mv_rows)
result["mv_rows_after_refresh"] = mv_rows
return result
@router.post("/nspd-denorm-backfill")
def run_nspd_denorm_backfill(
limit: Annotated[
int | None,
Query(description="Максимум кварталов для обработки (None = все)"),
] = None,
) -> dict[str, object]:
"""Запустить Celery backfill: denormalize nspd_quarter_dumps → nspd_parcels/nspd_buildings.
Задача идемпотентна (ON CONFLICT DO UPDATE). Безопасно запускать повторно.
Возвращает Celery task_id — статус через /flower или celery inspect.
Args:
limit: если задан — обработать только первые N кварталов ORDER BY quarter_cad.
"""
from app.workers.tasks.nspd_denorm_backfill import backfill_all_dumps
task = backfill_all_dumps.apply_async(kwargs={"limit": limit})
logger.info("nspd-denorm-backfill enqueued: task_id=%s limit=%s", task.id, limit)
return {"task_id": task.id, "status": "enqueued", "limit": limit}
# ════════════════════════════════════════════════════════════════════════════
# Mapping review — глазами подтвердить/отклонить авто-маппинги (feat/…-review)
# ════════════════════════════════════════════════════════════════════════════
@router.get("/mapping-review")
def get_mapping_review(
db: Annotated[Session, Depends(get_db)],
only_unreviewed: Annotated[
bool, Query(description="Только is_reviewed=false (авто-матчи без ревью)")
] = True,
limit: Annotated[int, Query(ge=1, le=500, description="Строк на странице")] = 100,
offset: Annotated[int, Query(ge=0, description="Смещение страницы")] = 0,
) -> dict[str, Any]:
"""Список строк objective_complex_mapping + обогащение для ревьюера.
Обогащение каждой строки:
- domrf_comm_name / domrf_dev_name — latest snapshot по domrf_obj_id;
- objective_developers — застройщики objective-проекта (агрегат
objective_lots.developer, scoped по project_name).
Сорт: is_reviewed asc, match_score asc NULLS FIRST (сомнительные сверху).
Returns dict:
rows: list обогащённых строк (id, objective_complex_name,
objective_project_id, objective_group, domrf_obj_id, match_method,
match_score, is_reviewed, note, created_at, domrf_comm_name,
domrf_dev_name, objective_developers);
total: всего строк (с учётом only_unreviewed, БЕЗ limit/offset);
limit / offset / only_unreviewed: эхо параметров.
"""
return list_mapping_review(db, only_unreviewed=only_unreviewed, limit=limit, offset=offset)
@router.post("/mapping-review/{mapping_id}/approve")
def approve_mapping_review(
mapping_id: int,
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
"""Подтвердить маппинг: is_reviewed=true + append «approved <date>» в note.
404 если строки с таким id нет.
"""
result = approve_mapping(db, mapping_id)
if result is None:
raise HTTPException(status_code=404, detail=f"mapping {mapping_id} не найден")
return result
@router.post("/mapping-review/{mapping_id}/reject")
def reject_mapping_review(
mapping_id: int,
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
"""Отклонить маппинг — DELETE строки (иначе травит mv_layout_velocity §4.2).
Удаляемая строка целиком логируется (logger.info) перед commit — в note
писать некуда, строки не будет. 404 если id не найден.
"""
deleted = reject_mapping(db, mapping_id)
if deleted is None:
raise HTTPException(status_code=404, detail=f"mapping {mapping_id} не найден")
return {"status": "deleted", "deleted": deleted}