merge: resolve conflicts with main (#1660) into week-review-finish
Some checks failed
CI / backend-tests (pull_request) Failing after 9m11s
CI / backend-tests (push) Failing after 9m22s
CI / openapi-codegen-check (push) Successful in 2m14s
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 8s
CI / frontend-tests (pull_request) Successful in 1m13s
CI / frontend-tests (push) Successful in 1m2s
CI / openapi-codegen-check (pull_request) Successful in 1m47s
Some checks failed
CI / backend-tests (pull_request) Failing after 9m11s
CI / backend-tests (push) Failing after 9m22s
CI / openapi-codegen-check (push) Successful in 2m14s
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 8s
CI / frontend-tests (pull_request) Successful in 1m13s
CI / frontend-tests (push) Successful in 1m2s
CI / openapi-codegen-check (pull_request) Successful in 1m47s
5 conflicts resolved: - redaction.py: merged INN checksum + _PHONE_LOCAL_RE + _Repl alias from main - recommendation.py: kept commercial_sell_through_pct rename (#1635) - report_pdf.py: kept _scenario_deficit_cell + int|None return type (#1590) - parcel.py: kept main's detailed docstring (functionally identical) - alembic/env.py: kept HEAD (app.models import in __init__ already covers all)
This commit is contained in:
commit
20efc3bf64
70 changed files with 3769 additions and 2241 deletions
|
|
@ -47,6 +47,7 @@ jobs:
|
|||
- '.forgejo/workflows/deploy-tradein.yml'
|
||||
scraper:
|
||||
- 'tradein-mvp/backend/app/services/scrapers/**'
|
||||
- 'tradein-mvp/backend/app/services/scrape_pipeline.py'
|
||||
- 'tradein-mvp/backend/app/services/scheduler.py'
|
||||
- 'tradein-mvp/backend/app/scheduler_main.py'
|
||||
- 'tradein-mvp/backend/app/tasks/**'
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import logging
|
|||
from datetime import UTC, datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -268,7 +268,7 @@ def revoke_task(
|
|||
def list_failures(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
run_id: int | None = None,
|
||||
limit: int = 50,
|
||||
limit: int = Query(default=50, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Per-request failure log for manual browser verification."""
|
||||
where = ""
|
||||
|
|
@ -314,7 +314,7 @@ def list_logs(
|
|||
db: Annotated[Session, Depends(get_db)],
|
||||
run_id: int | None = None,
|
||||
since_id: int | None = None,
|
||||
limit: int = 200,
|
||||
limit: int = Query(default=200, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Per-run progress events. Use since_id to poll incrementally:
|
||||
pass the highest log_id seen → returns only newer rows."""
|
||||
|
|
@ -483,7 +483,7 @@ def trigger_objective_etl(
|
|||
@router.get("/objective/runs")
|
||||
def list_objective_runs(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
limit: int = 20,
|
||||
limit: int = Query(default=20, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = (
|
||||
db.execute(
|
||||
|
|
@ -889,14 +889,20 @@ def bulk_enqueue_geo(
|
|||
|
||||
geo_queue = get_setting_value("nspd_geo", "queue_name", "geo")
|
||||
|
||||
# Валидируем ВСЕ thematic_ids ДО любых сайд-эффектов (создание jobs / apply_async),
|
||||
# иначе невалидный id в середине списка приводит к partial execution: для предыдущих
|
||||
# валидных id строки в nspd_geo_jobs уже созданы и задачи улетели в очередь geo,
|
||||
# а клиент получает 400 без идемпотентного отката (#1562).
|
||||
invalid_ids = [tid for tid in payload.thematic_ids if tid not in _THEMATIC_META]
|
||||
if invalid_ids:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"thematic_id={invalid_ids} не поддерживается (допустимы: 1, 2, 5)",
|
||||
)
|
||||
|
||||
jobs_summary: list[dict[str, Any]] = []
|
||||
|
||||
for thematic_id in payload.thematic_ids:
|
||||
if thematic_id not in _THEMATIC_META:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"thematic_id={thematic_id} не поддерживается (допустимы: 1, 2, 5)",
|
||||
)
|
||||
meta = _THEMATIC_META[thematic_id]
|
||||
|
||||
# 1) Собрать cad-номера
|
||||
|
|
@ -967,7 +973,7 @@ def bulk_enqueue_geo(
|
|||
@router.get("/geo/jobs")
|
||||
def list_geo_jobs(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
limit: int = 30,
|
||||
limit: int = Query(default=30, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Список последних geo-jobs (для UI dashboard)."""
|
||||
rows = (
|
||||
|
|
@ -1087,7 +1093,7 @@ def trigger_newbuilding_crossload() -> dict[str, Any]:
|
|||
def list_all_runs(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
scraper_type: str | None = None,
|
||||
limit: int = 30,
|
||||
limit: int = Query(default=30, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Унифицированный список прогонов (kn + nspd + objective).
|
||||
|
||||
|
|
@ -1145,7 +1151,7 @@ def list_all_logs(
|
|||
db: Annotated[Session, Depends(get_db)],
|
||||
scraper_type: str | None = None,
|
||||
run_id: int | None = None,
|
||||
limit: int = 200,
|
||||
limit: int = Query(default=200, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Унифицированный список логов (kn + nspd). Objective пока не пишет log."""
|
||||
where: list[str] = []
|
||||
|
|
@ -1416,7 +1422,7 @@ def scrape_freshness(
|
|||
@router.get("/runs")
|
||||
def list_runs(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
limit: int = 20,
|
||||
limit: int = Query(default=20, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = (
|
||||
db.execute(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from typing import Annotated, Any, Literal
|
|||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from shapely import wkt as _shp_wkt
|
||||
from shapely.geometry import Polygon
|
||||
from sqlalchemy import text
|
||||
|
|
@ -814,11 +815,20 @@ def _compute_confidence(
|
|||
market_trend: dict[str, Any] | None,
|
||||
zoning: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""X2 (#48) — composite confidence score 0..1 + caveats.
|
||||
"""X2 (#48) — composite confidence score 0..1 + caveats для site-finder analyze.
|
||||
|
||||
Stub-версия (до реализации G1/G2/D1/D2): использует сигналы которые уже
|
||||
доступны на main. Композитный балл = avg of subscore'ов; caveats — list
|
||||
конкретных проблем для UI ("Нет данных N, score K ненадёжен").
|
||||
Это НАДЁЖНОСТЬ САМОГО SITE-FINDER СКОРИНГА (coverage/свежесть входных
|
||||
сигналов: POI, район, рынок, конкуренты, источник геометрии) — отдельная
|
||||
by-design метрика, НЕ форсайтный §15 `forecasting.confidence_engine`
|
||||
(`compute_report_confidence`). §15 оценивает надёжность ПРОГНОЗА
|
||||
спроса/цены и работает над форсайт-входами (coverage/confounded/
|
||||
special_indices/sales_series), которых в analyze hot-path нет — поэтому
|
||||
analyze намеренно держит свою лёгкую coverage-метрику, а не зовёт §15.
|
||||
|
||||
Композитный балл = avg of subscore'ов; caveats — list конкретных проблем
|
||||
для UI ("Нет данных N, score K ненадёжен"). См. #1668 (решение: оставить
|
||||
раздельно by-design; «stub до G1/G2/D1/D2» — устаревшая формулировка,
|
||||
те эшелоны уже отгружены, их confidence сюда не вплетается намеренно).
|
||||
"""
|
||||
caveats: list[str] = []
|
||||
subscores: dict[str, float] = {}
|
||||
|
|
@ -2127,8 +2137,44 @@ def analyze_parcel(
|
|||
except Exception as e:
|
||||
logger.warning("red_lines_block query failed for %s: %s", cad_num, e)
|
||||
|
||||
# B5-4) Metro placeholder — заполнится после merge 22h metro scraper
|
||||
metro_block: dict[str, Any] = {"nearest_top3": None}
|
||||
# B5-4) Metro — ближайшие 3 станции метро к участку.
|
||||
# Данные уже в osm_poi_ekb (category='metro_stop', грузятся poi_loader.py:44),
|
||||
# скрапер не нужен — прямой KNN-запрос по geom <-> centroid участка.
|
||||
# nearest_top3=[] (НЕ None) когда метро не найдено: отличаем "посчитано, рядом
|
||||
# нет" от "не реализовано". None только при ошибке запроса.
|
||||
metro_block: dict[str, Any] = {"nearest_top3": []}
|
||||
try:
|
||||
with db.begin_nested():
|
||||
metro_rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT name,
|
||||
ST_Distance(
|
||||
m.geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||
) AS dist_m
|
||||
FROM osm_poi_ekb m
|
||||
WHERE m.category = 'metro_stop'
|
||||
ORDER BY m.geom <-> ST_Centroid(ST_GeomFromText(:wkt, 4326))
|
||||
LIMIT 3
|
||||
"""),
|
||||
{"wkt": geom_wkt},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
metro_block = {
|
||||
"nearest_top3": [
|
||||
{
|
||||
"name": mr["name"],
|
||||
"distance_m": round(float(mr["dist_m"])) if mr["dist_m"] is not None else None,
|
||||
}
|
||||
for mr in metro_rows
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("metro_block query failed for %s: %s", cad_num, e)
|
||||
metro_block = {"nearest_top3": None}
|
||||
|
||||
# B5-5) District price ranges из objective_lots (SF-B5)
|
||||
district_price_block: dict[str, Any] = {
|
||||
|
|
@ -2855,58 +2901,20 @@ def analyze_parcel(
|
|||
"risks": risks_block,
|
||||
}
|
||||
|
||||
# #994 (961-C3, ТЗ §22): persist завершённого рана в analysis_runs.
|
||||
# Best-effort — repository обёрнут в SAVEPOINT + try/except, провал НЕ меняет
|
||||
# форму/успех ответа (frontend зависит от него) и не отравляет outer-сессию.
|
||||
# district денормализуем из result["district"]["district_name"] (для фильтрации
|
||||
# без JSON-разбора); confidence — отчётный уровень high/medium/low из
|
||||
# confidence_label (нормализуется под CHECK в repository). schema_version —
|
||||
# ANALYZE_SCHEMA_VERSION: результат здесь — inline-dict analyze, НЕ
|
||||
# SiteFinderReport.as_dict() (у того свой _SCHEMA_VERSION "1.0").
|
||||
_district_name = (
|
||||
result_payload["district"].get("district_name")
|
||||
if isinstance(result_payload.get("district"), dict)
|
||||
else None
|
||||
)
|
||||
persist_analysis_run(
|
||||
db,
|
||||
cad_num=cad_num,
|
||||
result=result_payload,
|
||||
params={
|
||||
"profile_id": profile_id,
|
||||
"profile_user_id": profile_user_id,
|
||||
"inline_weights": _inline_weights,
|
||||
"weights_source": _weights_source,
|
||||
"x_session_id": _session_id,
|
||||
},
|
||||
district=_district_name,
|
||||
confidence=confidence_info["label"],
|
||||
status="complete",
|
||||
schema_version=ANALYZE_SCHEMA_VERSION,
|
||||
created_by=x_authenticated_user,
|
||||
)
|
||||
# #1561: forecast/ird/developer_attribution дописываются в result_payload ДО persist —
|
||||
# иначе persist_analysis_run сериализует jsonb-снимок (repository.py:124) и коммитит
|
||||
# (repository.py:140) ДО этих мутаций, и сохранённый ран расходится с live-ответом
|
||||
# (GET /runs/{run_id} вернул бы отчёт без этих блоков при re-open).
|
||||
|
||||
# §22-форсайт (3b-ii, #995): best-effort fire-and-forget enqueue после persist.
|
||||
# Таска `forecast_site_finder_report` читает только что сохранённый analyze-1.0
|
||||
# ран и в фоне (~30-180s) считает §22 SiteFinderReport ('1.0'). analyze НЕ ждёт
|
||||
# её — возвращаемся сразу. Celery/Redis down НЕ должен валить ответ (он уже успешен:
|
||||
# frontend зависит от формы). Зеркалит best-effort стиль find_or_enqueue_fetch.
|
||||
# Lazy import — избегаем import-цикла api ↔ workers.tasks на старте.
|
||||
try:
|
||||
from app.workers.tasks.forecast import forecast_site_finder_report
|
||||
|
||||
forecast_site_finder_report.delay(cad_num, horizon, x_authenticated_user)
|
||||
result_payload["forecast"] = {"status": "pending", "horizon": horizon}
|
||||
except Exception:
|
||||
# Enqueue не удался (broker недоступен и т.п.) — §9.x форсайт advisory,
|
||||
# клиент узнаёт по status="unavailable" и не будет зря поллить /forecast.
|
||||
logger.warning(
|
||||
"forecast enqueue failed for cad=%s horizon=%s — analyze response unaffected",
|
||||
cad_num,
|
||||
horizon,
|
||||
exc_info=True,
|
||||
)
|
||||
result_payload["forecast"] = {"status": "unavailable", "horizon": horizon}
|
||||
# §22-форсайт (3b-ii, #995): снимок статуса в result_payload ДО persist, чтобы
|
||||
# jsonb-снимок совпадал с live-ответом. Оптимистично ставим "pending" — фактический
|
||||
# enqueue делаем ПОСЛЕ persist_analysis_run (ниже): иначе Celery-воркер может стартануть
|
||||
# ДО коммита analyze-рана и latest_run_for вернёт None/старый ран → форсайт молча не
|
||||
# посчитается, а ретраев у таски нет (regression #1561-followup). result_payload
|
||||
# передаётся в persist by-reference; если enqueue провалится после persist —
|
||||
# перепишем снимок на "unavailable" уже только в возвращаемом ответе (persisted
|
||||
# снимок останется "pending", но это безвредно: poll-ручка читает live-статус рана).
|
||||
result_payload["forecast"] = {"status": "pending", "horizon": horizon}
|
||||
|
||||
# ИРД-слой (#1067 D9b «GG-форсайт»): parcel_ird_overlaps (м.132, incl opportunity) +
|
||||
# функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент зоны (C8b). Flag-gated (default off):
|
||||
|
|
@ -2942,6 +2950,62 @@ def analyze_parcel(
|
|||
exc_info=True,
|
||||
)
|
||||
|
||||
# #994 (961-C3, ТЗ §22): persist завершённого рана в analysis_runs — ПОСЛЕ дописывания
|
||||
# forecast/ird/developer_attribution, чтобы jsonb-снимок совпадал с live-ответом (#1561).
|
||||
# Best-effort — repository обёрнут в SAVEPOINT + try/except, провал НЕ меняет
|
||||
# форму/успех ответа (frontend зависит от него) и не отравляет outer-сессию.
|
||||
# district денормализуем из result["district"]["district_name"] (для фильтрации
|
||||
# без JSON-разбора); confidence — отчётный уровень high/medium/low из
|
||||
# confidence_label (нормализуется под CHECK в repository). schema_version —
|
||||
# ANALYZE_SCHEMA_VERSION: результат здесь — inline-dict analyze, НЕ
|
||||
# SiteFinderReport.as_dict() (у того свой _SCHEMA_VERSION "1.0").
|
||||
_district_name = (
|
||||
result_payload["district"].get("district_name")
|
||||
if isinstance(result_payload.get("district"), dict)
|
||||
else None
|
||||
)
|
||||
persist_analysis_run(
|
||||
db,
|
||||
cad_num=cad_num,
|
||||
result=result_payload,
|
||||
params={
|
||||
"profile_id": profile_id,
|
||||
"profile_user_id": profile_user_id,
|
||||
"inline_weights": _inline_weights,
|
||||
"weights_source": _weights_source,
|
||||
"x_session_id": _session_id,
|
||||
},
|
||||
district=_district_name,
|
||||
confidence=confidence_info["label"],
|
||||
status="complete",
|
||||
schema_version=ANALYZE_SCHEMA_VERSION,
|
||||
created_by=x_authenticated_user,
|
||||
)
|
||||
|
||||
# §22-форсайт enqueue — СТРОГО ПОСЛЕ persist_analysis_run. persist_analysis_run —
|
||||
# единственный commit analyze-рана (get_db() на success не коммитит), поэтому enqueue
|
||||
# должен случиться только после того, как ран закоммичен: иначе фоновая таска
|
||||
# `forecast_site_finder_report` (~30-180s) прочтёт latest_run_for и не найдёт свежий
|
||||
# ран (None/старый) → форсайт молча не посчитается, ретраев нет (#1561-followup).
|
||||
# Best-effort fire-and-forget: Celery/Redis down НЕ валит ответ (он уже успешен,
|
||||
# frontend зависит от формы). Зеркалит best-effort стиль find_or_enqueue_fetch.
|
||||
# Lazy import — избегаем import-цикла api ↔ workers.tasks на старте.
|
||||
try:
|
||||
from app.workers.tasks.forecast import forecast_site_finder_report
|
||||
|
||||
forecast_site_finder_report.delay(cad_num, horizon, x_authenticated_user)
|
||||
except Exception:
|
||||
# Enqueue не удался (broker недоступен и т.п.) — §9.x форсайт advisory,
|
||||
# клиент узнаёт по status="unavailable" и не будет зря поллить /forecast.
|
||||
# persisted снимок остаётся "pending" (безвреден — poll читает live-статус рана).
|
||||
logger.warning(
|
||||
"forecast enqueue failed for cad=%s horizon=%s — analyze response unaffected",
|
||||
cad_num,
|
||||
horizon,
|
||||
exc_info=True,
|
||||
)
|
||||
result_payload["forecast"] = {"status": "unavailable", "horizon": horizon}
|
||||
|
||||
return result_payload
|
||||
|
||||
|
||||
|
|
@ -3183,8 +3247,10 @@ async def get_parcel_competitors(
|
|||
Возвращает список ЖК из domrf_kn_objects в радиусе radius_km от центроида
|
||||
участка с рассчитанным velocity_per_month за указанный time_window.
|
||||
"""
|
||||
# sync get_competitors (несколько db.execute, competitors.py:518) мостится через
|
||||
# run_in_threadpool — иначе sync DB-IO блокирует event loop (тот же приём, что в chat.py).
|
||||
try:
|
||||
return get_competitors(db=db, cad_num=cad_num, request=body)
|
||||
return await run_in_threadpool(get_competitors, db=db, cad_num=cad_num, request=body)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
|
|
@ -3237,8 +3303,10 @@ async def get_parcel_best_layouts(
|
|||
Reads from mv_layout_velocity (auto-populated via objective_corpus_room_month
|
||||
× objective_complex_mapping).
|
||||
"""
|
||||
# sync get_best_layouts (db.execute, best_layouts.py:377) мостится через
|
||||
# run_in_threadpool — иначе sync DB-IO блокирует event loop.
|
||||
try:
|
||||
return get_best_layouts(db=db, cad_num=cad_num, request=body)
|
||||
return await run_in_threadpool(get_best_layouts, db=db, cad_num=cad_num, request=body)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
|
|
@ -3256,9 +3324,12 @@ async def get_parcel_best_layouts_pdf(
|
|||
|
||||
Issue #113 Phase 2.1: data-driven unit-mix recommendation для тендера.
|
||||
"""
|
||||
# sync get_best_layouts (DB-IO) + render_layout_tz_pdf (CPU-bound WeasyPrint
|
||||
# write_pdf, сотни мс) мостятся через run_in_threadpool — иначе блокируют event loop.
|
||||
try:
|
||||
response = get_best_layouts(db=db, cad_num=cad_num, request=body)
|
||||
pdf_bytes = render_layout_tz_pdf(
|
||||
response = await run_in_threadpool(get_best_layouts, db=db, cad_num=cad_num, request=body)
|
||||
pdf_bytes = await run_in_threadpool(
|
||||
render_layout_tz_pdf,
|
||||
response,
|
||||
cad_num=cad_num,
|
||||
radius_km=body.radius_km,
|
||||
|
|
|
|||
|
|
@ -111,16 +111,25 @@ def get_photo(
|
|||
data = _fetch_upstream(upstream)
|
||||
if data:
|
||||
src = _persist_original(obj_id, file_id, photo_name, data)
|
||||
# Record the original immediately so a failed thumbnail does not
|
||||
# orphan the on-disk file and trigger an eternal re-fetch.
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE domrf_kn_photos"
|
||||
" SET local_path = :lp, downloaded_at = NOW()"
|
||||
" WHERE obj_id = :o AND obj_file_id = :f"
|
||||
),
|
||||
{"lp": str(src), "o": obj_id, "f": file_id},
|
||||
)
|
||||
db.commit()
|
||||
generated = make_thumbnail(src)
|
||||
if generated and generated.exists():
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE domrf_kn_photos"
|
||||
" SET local_path = :lp, thumb_path = :tp,"
|
||||
" downloaded_at = NOW()"
|
||||
"UPDATE domrf_kn_photos SET thumb_path = :tp"
|
||||
" WHERE obj_id = :o AND obj_file_id = :f"
|
||||
),
|
||||
{"lp": str(src), "tp": str(generated), "o": obj_id, "f": file_id},
|
||||
{"tp": str(generated), "o": obj_id, "f": file_id},
|
||||
)
|
||||
db.commit()
|
||||
return FileResponse(str(generated), media_type="image/webp", headers=headers)
|
||||
|
|
|
|||
|
|
@ -344,7 +344,7 @@ def estimate_pdf(
|
|||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="estimate not found")
|
||||
|
||||
if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC):
|
||||
if row.expires_at.astimezone(UTC) < datetime.now(tz=UTC):
|
||||
raise HTTPException(status_code=410, detail="estimate expired (24h TTL)")
|
||||
|
||||
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ if settings.glitchtip_dsn:
|
|||
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
||||
profiles_sample_rate=0.0,
|
||||
send_default_pii=False,
|
||||
before_send=scrub_sensitive_query,
|
||||
before_send_transaction=scrub_sensitive_query,
|
||||
integrations=[
|
||||
StarletteIntegration(),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, SmallInteger, Text
|
||||
from sqlalchemy import Boolean, DateTime, Integer, SmallInteger, Text, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
|
@ -20,6 +20,8 @@ class JobSetting(Base):
|
|||
max_retries: Mapped[int] = mapped_column(SmallInteger, default=2, nullable=False)
|
||||
max_concurrency: Mapped[int] = mapped_column(SmallInteger, default=1, nullable=False)
|
||||
extra_config: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=text("now()"), nullable=False
|
||||
)
|
||||
updated_by: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
"""Parcel ORM placeholder.
|
||||
"""SQLAlchemy ORM models for parcel data.
|
||||
|
||||
The legacy `Parcel` model (`__tablename__ = "parcels"`) was removed: nothing in
|
||||
the app imports it, and real cadastral data lives in raw-SQL tables
|
||||
`cad_parcels` / `cad_parcels_geom` (see data/sql/92_cad_bulk_layers.sql,
|
||||
data/sql/83_cad_parcels_geom.sql), not in a `parcels` table. Keeping the model
|
||||
on Base.metadata made `alembic revision --autogenerate` emit a phantom
|
||||
`CREATE TABLE parcels` for a table that must never exist (issue #1569).
|
||||
NB: реальные данные участков живут в таблицах ``cad_parcels`` /
|
||||
``cad_parcels_geom`` (см. ``data/sql/92_cad_bulk_layers.sql`` и
|
||||
``data/sql/83_cad_parcels_geom.sql``) и читаются через сырые PostGIS-запросы
|
||||
(``app.services.site_finder.filters``, ``app.api.v1.parcels``), не через ORM.
|
||||
|
||||
No ORM model is defined here. `Base` is re-exported for import continuity.
|
||||
Прежняя ORM-модель ``Parcel`` (таблица ``parcels``) удалена: соответствующего
|
||||
DDL в ``data/sql/`` нет, ни один ORM-запрос её не использовал, а её колонки
|
||||
(в т.ч. ``geometry(POLYGON, 4326)``) расходились с реальной схемой
|
||||
``cad_parcels.geom`` — которую миграция 93 уже перевела на ``MultiPolygon``,
|
||||
т.к. НСПД отдаёт многоконтурные участки. Единственным её потребителем был
|
||||
alembic autogenerate (``alembic/env.py``), для которого она порождала
|
||||
фантомный ``CREATE TABLE parcels``.
|
||||
|
||||
Модуль сохранён (его импортирует ``alembic/env.py``), чтобы новые ORM-модели
|
||||
регистрировались на ``Base.metadata`` именно отсюда.
|
||||
"""
|
||||
|
||||
from app.core.db import Base
|
||||
|
||||
__all__ = ["Base"]
|
||||
from app.core.db import Base # noqa: F401 (re-export для регистрации будущих моделей)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from __future__ import annotations
|
|||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
# История диалога принимается, но в Step 1 НЕ используется (LLM-контекст — Step 2).
|
||||
# Кэпируем длину, чтобы payload не раздувался до подключения LLM.
|
||||
|
|
@ -84,10 +84,21 @@ class ChatAskRequest(BaseModel):
|
|||
)
|
||||
history: list[ChatTurn] | None = Field(
|
||||
default=None,
|
||||
max_length=_HISTORY_MAX_TURNS,
|
||||
description="История диалога (Step 1: принимается, НЕ используется; LLM-контекст — Step 2)",
|
||||
)
|
||||
|
||||
@field_validator("history")
|
||||
@classmethod
|
||||
def _cap_history(cls, v: list[ChatTurn] | None) -> list[ChatTurn] | None:
|
||||
"""Graceful-усечение: кэпируем хвост до последних _HISTORY_MAX_TURNS ходов.
|
||||
|
||||
НЕ отклоняем длинный диалог 422 (контракт обещает усечение, не отказ) —
|
||||
оставляем самые свежие ходы, отбрасывая старые с головы.
|
||||
"""
|
||||
if v is not None and len(v) > _HISTORY_MAX_TURNS:
|
||||
return v[-_HISTORY_MAX_TURNS:]
|
||||
return v
|
||||
|
||||
|
||||
class ChatAskResponse(BaseModel):
|
||||
"""Ответ чата по отчёту участка (детерминированный, шаблонный RU-текст).
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ class ConceptInput(BaseModel):
|
|||
housing_class: Literal["econom", "comfort", "business"] = "comfort"
|
||||
target_floors: int = Field(9, ge=1, le=30)
|
||||
development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise"
|
||||
land_cost_rub: float | None = Field(None, description="Optional land cost for financial model")
|
||||
land_cost_rub: float | None = Field(
|
||||
None, ge=0, description="Optional land cost for financial model"
|
||||
)
|
||||
|
||||
|
||||
class TEAP(BaseModel):
|
||||
|
|
|
|||
|
|
@ -302,10 +302,18 @@ class NSPDBulkClient:
|
|||
|
||||
try:
|
||||
data = await self._get_json(NSPD_SEARCH_URL, params=params)
|
||||
except (NspdBulkWafError, NspdBulkRateLimitError, NspdBulkServerError):
|
||||
# 403 WAF / 429 / 5xx+ServiceException — НЕ «квартал не найден».
|
||||
# Пробрасываем как есть: caller (autoretry) ретраит квартал, WAF
|
||||
# останавливает harvest. Раньше подстрочная классификация по str(e)
|
||||
# с подмешанным body_preview могла ложно проглотить 5xx как 404.
|
||||
raise
|
||||
except NspdBulkError as e:
|
||||
# 404 или пустой ответ → возвращаем пустой snapshot
|
||||
# Остаётся базовый NspdBulkError = прочие 4xx (см. _get_json:234).
|
||||
# Текст: f"HTTP {code}: {url} — {body_preview}" → классифицируем по
|
||||
# ПРЕФИКСУ (код до URL), а не по вхождению в произвольное тело ответа.
|
||||
err_str = str(e)
|
||||
if "HTTP 404" in err_str or "HTTP 400" in err_str:
|
||||
if err_str.startswith("HTTP 404:") or err_str.startswith("HTTP 400:"):
|
||||
logger.info(
|
||||
"search_by_quarter: quarter %s not found (404/400), returning empty"
|
||||
" (category_id=%s)",
|
||||
|
|
@ -342,8 +350,23 @@ class NSPDBulkClient:
|
|||
for m in raw_meta:
|
||||
cat_id = m.get("categoryId")
|
||||
total = m.get("totalCount")
|
||||
if cat_id is not None and total is not None:
|
||||
if cat_id is None or total is None:
|
||||
continue
|
||||
# NSPD изредка отдаёт categoryId/totalCount нечисловой/float-строкой
|
||||
# ('20.0') или иным типом → голый int() кинул бы ValueError/TypeError,
|
||||
# который НЕ подкласс NspdBulkError и завалил бы всю Phase 1 квартала.
|
||||
# Зеркалит защитный try/except в schemas/nspd_bulk.py и
|
||||
# list_objects_in_building. Битую meta-запись просто пропускаем.
|
||||
try:
|
||||
meta_counts[int(cat_id)] = int(total)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(
|
||||
"search_by_quarter: non-numeric meta entry quarter=%s"
|
||||
" categoryId=%r totalCount=%r — skipping",
|
||||
quarter,
|
||||
cat_id,
|
||||
total,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"search_by_quarter: quarter=%s category_id=%s features=%d meta_cats=%d overflow=%d",
|
||||
|
|
@ -416,7 +439,19 @@ class NSPDBulkClient:
|
|||
}
|
||||
|
||||
data = await self._get_json(url, params=params)
|
||||
raw_features: list[dict[str, Any]] = (data or {}).get("features") or []
|
||||
# NSPD/GeoServer изредка отдаёт валидный JSON, но не объект (list/str —
|
||||
# Bug_Nspd_Geo_Str_Object). Тогда `(data or {})` вернул бы сам truthy
|
||||
# data, а .get("features") кинул бы AttributeError (не NspdBulkError →
|
||||
# уронил бы ячейку grid-walk без сигнала). Унифицируем guard с
|
||||
# search_by_quarter: не-dict трактуем как пустой ответ.
|
||||
if not isinstance(data, dict):
|
||||
logger.warning(
|
||||
"wms_feature_info: non-dict JSON response layer=%d type=%s — returning empty",
|
||||
layer_id,
|
||||
type(data).__name__,
|
||||
)
|
||||
return []
|
||||
raw_features: list[dict[str, Any]] = data.get("features") or []
|
||||
return [NSPDBulkFeature.model_validate(f) for f in raw_features]
|
||||
|
||||
# ── 3. get_features_in_bbox_grid ─────────────────────────────────────────
|
||||
|
|
@ -473,9 +508,39 @@ class NSPDBulkClient:
|
|||
|
||||
seen_ids: set[str] = set()
|
||||
results: list[dict] = []
|
||||
# Issue #252-mirror: считаем server-side провалы и успешные ячейки, чтобы
|
||||
# отличить «слой реально пуст» (ok_cells>0, 0 features) от «слой/IP лёг»
|
||||
# (все ячейки 5xx/WAF). Раньше любой Exception молча падал на DEBUG и метод
|
||||
# отдавал [] → в БД писался ложный tz_count=0 без layer_failed-сигнала.
|
||||
server_errors = 0
|
||||
ok_cells = 0
|
||||
first_server_error: NspdBulkServerError | None = None
|
||||
|
||||
for idx, cell_result in enumerate(cell_results):
|
||||
if isinstance(cell_result, NspdBulkWafError):
|
||||
# 403 WAF (бан IP) — по docstring должен ОСТАНОВИТЬ harvest, не
|
||||
# маскироваться пустым результатом. Пробрасываем немедленно.
|
||||
logger.warning(
|
||||
"get_features_in_bbox_grid: layer=%d cell=%d WAF 403 — aborting grid-walk: %s",
|
||||
layer_id,
|
||||
idx,
|
||||
cell_result,
|
||||
)
|
||||
raise cell_result
|
||||
if isinstance(cell_result, NspdBulkServerError):
|
||||
server_errors += 1
|
||||
if first_server_error is None:
|
||||
first_server_error = cell_result
|
||||
logger.debug(
|
||||
"get_features_in_bbox_grid: layer=%d cell=%d server error: %s",
|
||||
layer_id,
|
||||
idx,
|
||||
cell_result,
|
||||
)
|
||||
continue
|
||||
if isinstance(cell_result, Exception):
|
||||
# Прочие (сетевые/parse) ошибки одной ячейки — не валим обход и НЕ
|
||||
# считаем server-side fail (иначе сеть ложно triggers layer_failed).
|
||||
logger.debug(
|
||||
"get_features_in_bbox_grid: layer=%d cell=%d error: %s",
|
||||
layer_id,
|
||||
|
|
@ -483,6 +548,7 @@ class NSPDBulkClient:
|
|||
cell_result,
|
||||
)
|
||||
continue
|
||||
ok_cells += 1
|
||||
for feature in cell_result:
|
||||
fid = str(feature.id) if feature.id is not None else ""
|
||||
if fid and fid in seen_ids:
|
||||
|
|
@ -497,6 +563,21 @@ class NSPDBulkClient:
|
|||
}
|
||||
)
|
||||
|
||||
# Если БЫЛИ server-side провалы И ни одна ячейка не прошла — слой/NSPD лёг
|
||||
# целиком. Возврат [] здесь означал бы ложный tz_count=0 («зонирование
|
||||
# отсутствует»). Пробрасываем server-error, чтобы caller отличил сбой от
|
||||
# реально пустого слоя (мирроринг _grid_walk_category.layer_failed).
|
||||
if server_errors > 0 and ok_cells == 0 and first_server_error is not None:
|
||||
logger.warning(
|
||||
"get_features_in_bbox_grid: layer=%d grid=%dx%d ПОЛНОСТЬЮ сбойный "
|
||||
"(%d server errors, 0 ok cells) — raising вместо ложного пустого результата",
|
||||
layer_id,
|
||||
grid_n,
|
||||
grid_n,
|
||||
server_errors,
|
||||
)
|
||||
raise first_server_error
|
||||
|
||||
logger.info(
|
||||
"get_features_in_bbox_grid: layer=%d grid=%dx%d unique_features=%d",
|
||||
layer_id,
|
||||
|
|
|
|||
|
|
@ -64,8 +64,15 @@ def _jsonb_param(value: Any) -> str:
|
|||
|
||||
jsonable_encoder разворачивает Pydantic-модели / даты / Enum в JSON-native типы;
|
||||
json.dumps(..., ensure_ascii=False) — кириллица как есть (зеркало pzz_loader).
|
||||
|
||||
allow_nan=False (#1580): дефолтный allow_nan=True выводит нестандартные литералы
|
||||
NaN/Infinity/-Infinity, которые JSONB-парсер PostgreSQL отвергает ("invalid input
|
||||
syntax for type json") → INSERT падает, а broad-except в persist_analysis_run
|
||||
проглатывает это и теряет ран молча. С allow_nan=False json.dumps бросает ValueError
|
||||
ДО SQL — провал детерминирован и виден в logger.exception (с traceback), а не
|
||||
маскируется под невнятную psycopg-ошибку синтаксиса.
|
||||
"""
|
||||
return json.dumps(jsonable_encoder(value), ensure_ascii=False)
|
||||
return json.dumps(jsonable_encoder(value), ensure_ascii=False, allow_nan=False)
|
||||
|
||||
|
||||
def persist_analysis_run(
|
||||
|
|
|
|||
|
|
@ -24,11 +24,12 @@ list rather than silently ignored.
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -54,6 +55,11 @@ _SUPPORTED_METHODS = (_CALC_BASIS, _CALC_PREVIOUS)
|
|||
# as unsupported.
|
||||
_SUPPORTED_SUBJECT = "66"
|
||||
|
||||
# ARN period_value format (matches mv_ddu_price_indicator.period_value, e.g.
|
||||
# '2026-Q1'). Used to reject malformed bounds whose lexicographic comparison
|
||||
# against well-formed period_value would silently drop rows.
|
||||
_PERIOD_RE = re.compile(r"^\d{4}-Q[1-4]$")
|
||||
|
||||
|
||||
def _f(value: Any) -> float | None:
|
||||
if value is None:
|
||||
|
|
@ -148,6 +154,50 @@ def get_ddu_indicator(
|
|||
if clean_buckets:
|
||||
bucket_filter = "AND area_bucket = ANY(CAST(:buckets AS int[]))"
|
||||
params["buckets"] = clean_buckets
|
||||
else:
|
||||
# Client narrowed by area buckets but every id is out of range (0..6)
|
||||
# → honour the explicit narrowing with an empty result, never silently
|
||||
# widen back to all buckets. Mirrors the `not subject_ok` branch above.
|
||||
notes.append(
|
||||
f"areaRanges={area_ranges} вне диапазона 0..6 "
|
||||
f"(0=все площади, 1..6=диапазоны м²) — нет подходящих площадей."
|
||||
)
|
||||
return {
|
||||
"meta": {
|
||||
"market": "primary_ddu",
|
||||
"region_code": int(_SUPPORTED_SUBJECT),
|
||||
"calculation_method": method,
|
||||
"period_type": "Q",
|
||||
},
|
||||
"table": [],
|
||||
"graph": [],
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
# Validate period bounds against the documented ARN 'YYYY-QN' format before
|
||||
# binding them. period_value is compared lexicographically (it is text); a
|
||||
# malformed bound ('foo', '2026') would silently drop rows, so drop the bad
|
||||
# bound and explain it in notes (this endpoint's convention is notes, not 422).
|
||||
if period_from and not _PERIOD_RE.match(period_from):
|
||||
notes.append(
|
||||
f"periodFrom={period_from!r} не в формате 'YYYY-QN' (напр. '2025-Q2') "
|
||||
f"— граница проигнорирована."
|
||||
)
|
||||
period_from = None
|
||||
if period_to and not _PERIOD_RE.match(period_to):
|
||||
notes.append(
|
||||
f"periodTo={period_to!r} не в формате 'YYYY-QN' (напр. '2026-Q1') "
|
||||
f"— граница проигнорирована."
|
||||
)
|
||||
period_to = None
|
||||
# Inverted range (from > to) yields an empty table with no signal otherwise.
|
||||
# Lexicographic comparison is correct here because the format is zero-padded
|
||||
# 'YYYY-QN'.
|
||||
if period_from and period_to and period_from > period_to:
|
||||
notes.append(
|
||||
f"periodFrom={period_from!r} > periodTo={period_to!r} — границы "
|
||||
f"перепутаны (диапазон инвертирован), результат пуст."
|
||||
)
|
||||
|
||||
period_filter = ""
|
||||
if period_from:
|
||||
|
|
@ -184,8 +234,10 @@ def get_ddu_indicator(
|
|||
.mappings()
|
||||
.all()
|
||||
)
|
||||
except OperationalError:
|
||||
except (ProgrammingError, OperationalError):
|
||||
# Most likely the MV does not exist yet (migration 152 not applied).
|
||||
# A missing relation is SQLSTATE 42P01 (UndefinedTable) → ProgrammingError;
|
||||
# OperationalError is kept for connection-level failures.
|
||||
logger.exception("ddu_indicator: query failed (mv_ddu_price_indicator missing?)")
|
||||
raise
|
||||
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ def detect_velocity_anomalies(
|
|||
AVG(realised) FILTER (WHERE rn <= :recent_window) AS recent_mean,
|
||||
AVG(realised) FILTER (WHERE rn > :recent_window) AS prior_mean,
|
||||
STDDEV_SAMP(realised) FILTER (WHERE rn > :recent_window) AS prior_std,
|
||||
COUNT(*) FILTER (WHERE rn > :recent_window) AS prior_n
|
||||
COUNT(realised) FILTER (WHERE rn > :recent_window) AS prior_n
|
||||
FROM ranked
|
||||
GROUP BY obj_id, n_months
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2786,6 +2786,25 @@ def recommend_mix(
|
|||
if b["bucket"] != top_bucket_name:
|
||||
b["share_pct"] = round(b["share_pct"] * scale, 1)
|
||||
|
||||
# #1576: success-boost изменил share_pct → средневзвешенная цена
|
||||
# должна пересчитаться под новые доли, иначе weighted_avg_price
|
||||
# остаётся от ДО-boost микса (пробел в фиксе #1359, который
|
||||
# обновлял только units/revenue). price_median_per_m2 уже включает
|
||||
# combined_price_factor (line 2743), поэтому домножать НЕ нужно —
|
||||
# это согласовано с per-bucket revenue ниже. Веса = area_avg×share,
|
||||
# независимо от area_total_m2, поэтому вне ветки area_total_m2.
|
||||
wnum = sum(
|
||||
b["_area_avg_raw"] * b["share_pct"] * b["price_median_per_m2"]
|
||||
for b in buckets
|
||||
if b["_area_avg_raw"] and b["_area_avg_raw"] > 0
|
||||
)
|
||||
wden = sum(
|
||||
b["_area_avg_raw"] * b["share_pct"]
|
||||
for b in buckets
|
||||
if b["_area_avg_raw"] and b["_area_avg_raw"] > 0
|
||||
)
|
||||
weighted_avg_price = round(wnum / wden, 2) if wden > 0 else None
|
||||
|
||||
# #1359: success-boost изменил share_pct → перераспределяем
|
||||
# units/revenue/months_to_sellout и агрегаты под новые доли,
|
||||
# иначе share_pct рассогласуется с units_planned/revenue/sellout
|
||||
|
|
|
|||
|
|
@ -247,21 +247,11 @@ async def harvest_quarter(
|
|||
db.commit()
|
||||
update_progress(done_progress)
|
||||
|
||||
# ── Phase 2.5: grid-walk для territorial_zones (ПЗЗ, layer 875838) ────────
|
||||
# Выполняем после основного grid-walk (Phase 2-3). Требует bbox квартала.
|
||||
quarter_bbox = quarter_bbox_3857(db, quarter)
|
||||
if quarter_bbox is not None:
|
||||
update_progress({"phase": "territorial_zones_started", "quarter": quarter})
|
||||
try:
|
||||
tz_features = await client.get_territorial_zones_in_bbox(quarter_bbox)
|
||||
tz_count = _save_territorial_zones(db, quarter, tz_features)
|
||||
logger.info(
|
||||
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
|
||||
|
||||
# ── Phase 4: quarter stats + auto-heal geom из snapshot ─────────────────
|
||||
# Bug #1583: auto-heal geom выполняем ДО Phase 2.5 (territorial_zones). Иначе
|
||||
# кварталы с broken/NULL geom дают quarter_bbox_3857() == None → ПЗЗ молча
|
||||
# пропускаются, а на следующем harvest квартал отсекается skip_fresh_hours.
|
||||
# Чиним geom здесь → Phase 2.5 ниже получит валидный bbox в этом же прогоне.
|
||||
stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS]
|
||||
if stats_features:
|
||||
upsert_quarter_stats(db, quarter, stats_features[0])
|
||||
|
|
@ -276,6 +266,22 @@ async def harvest_quarter(
|
|||
logger.warning("harvest_quarter: geom auto-heal failed for %s: %s", quarter, e)
|
||||
db.commit()
|
||||
|
||||
# ── Phase 2.5: grid-walk для territorial_zones (ПЗЗ, layer 875838) ────────
|
||||
# Выполняем после основного grid-walk (Phase 2-3) И после Phase 4 geom
|
||||
# auto-heal (см. Bug #1583) — так broken-geom кварталы, починенные выше,
|
||||
# получают валидный bbox и ПЗЗ собираются в том же прогоне. Требует bbox квартала.
|
||||
quarter_bbox = quarter_bbox_3857(db, quarter)
|
||||
if quarter_bbox is not None:
|
||||
update_progress({"phase": "territorial_zones_started", "quarter": quarter})
|
||||
try:
|
||||
tz_features = await client.get_territorial_zones_in_bbox(quarter_bbox)
|
||||
tz_count = _save_territorial_zones(db, quarter, tz_features)
|
||||
logger.info(
|
||||
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
|
||||
|
||||
# Issue #252: финальный phase_state несёт АГРЕГИРОВАННЫЙ harvest_meta по всем
|
||||
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
|
||||
# per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в
|
||||
|
|
@ -344,6 +350,11 @@ async def _grid_walk_category(
|
|||
|
||||
grid_points = generate_grid_click_points(bbox, grid_size=grid_size, tile_size=tile_size)
|
||||
|
||||
# Bug #1584: считаем discovered ТОЛЬКО для таблицы запрошенного layer_id, а не
|
||||
# sum(stats.values()). Иначе skipped/чужекатегорийные features завышают счётчик,
|
||||
# ошибочно приписываясь к parcels/buildings вызывающим (harvest_quarter:230-233).
|
||||
layer_count_key = "parcels" if layer_id == CAT_PARCEL else "buildings"
|
||||
|
||||
discovered_cads: set[str] = set()
|
||||
upserted = 0
|
||||
requests = 0
|
||||
|
|
@ -411,7 +422,7 @@ async def _grid_walk_category(
|
|||
try:
|
||||
with db.begin_nested():
|
||||
stats = upsert_features(db, [feature], source="wms_grid_walk")
|
||||
upserted += sum(stats.values())
|
||||
upserted += stats[layer_count_key]
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"_grid_walk_category: upsert failed cad=%s layer=%d: %s",
|
||||
|
|
@ -1423,7 +1434,7 @@ def upsert_quarter_stats(
|
|||
"cost_value_total_geom": _safe_numeric(raw_opts.get("cost_value_total_geom")),
|
||||
"sum_land_area": _safe_numeric(raw_opts.get("sum_land_area")),
|
||||
"sum_land_geom_area": _safe_numeric(raw_opts.get("sum_land_geom_area")),
|
||||
"date_cr": raw_opts.get("date_cr"),
|
||||
"date_cr": _parse_nspd_date(raw_opts.get("date_cr")),
|
||||
"real_srid": _safe_int(raw_opts.get("real_srid")),
|
||||
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,12 +7,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Радиус сферы Web Mercator (EPSG:3857), м. Нужен для инверсии Y → широта.
|
||||
_WEB_MERCATOR_R = 6378137.0
|
||||
|
||||
|
||||
def quarter_bbox_3857(db: Session, quarter: str) -> tuple[float, float, float, float] | None:
|
||||
"""Получить bbox квартала из cad_quarters_geom в EPSG:3857.
|
||||
|
|
@ -56,8 +60,19 @@ def quarter_bbox_3857(db: Session, quarter: str) -> tuple[float, float, float, f
|
|||
# geometry (width <0.01m). Это вызывает useless 1px tiles в WMS и 500 errors
|
||||
# от NSPD. Реальный квартал ЕКБ имеет width 200-4000м. Skip grid-walk для
|
||||
# broken — snapshot phase даст 20 per cat, что норм для MVP.
|
||||
width = bbox[2] - bbox[0]
|
||||
height = bbox[3] - bbox[1]
|
||||
#
|
||||
# Bug #1629: EPSG:3857 (Web Mercator) — конформная, НЕ равнопротяжённая
|
||||
# проекция. Линейный масштаб = sec(lat); на широте ЕКБ (~56-60°N) фактор
|
||||
# ≈1.8-2.0, т.е. 1 наземный метр ≈ 1.8-2.0 единиц 3857. Пороги ниже заданы
|
||||
# в НАЗЕМНЫХ метрах, поэтому переводим протяжённость bbox из единиц 3857 в
|
||||
# наземные метры умножением на cos(lat) (lat берём из центра bbox через
|
||||
# инверсию Y). Иначе верхняя граница 10000 отсекала бы реальные крупные/
|
||||
# вытянутые кварталы (10000 единиц 3857 ≈ всего ~5050 м земли на широте ЕКБ).
|
||||
y_mid = (bbox[1] + bbox[3]) / 2.0
|
||||
lat_mid = 2.0 * math.atan(math.exp(y_mid / _WEB_MERCATOR_R)) - math.pi / 2.0
|
||||
ground_scale = math.cos(lat_mid) # ground_m = mercator_units * cos(lat)
|
||||
width = (bbox[2] - bbox[0]) * ground_scale
|
||||
height = (bbox[3] - bbox[1]) * ground_scale
|
||||
if width < 100 or height < 100 or width > 10000 or height > 10000:
|
||||
logger.warning(
|
||||
"quarter_bbox_3857: квартал %s broken geom — width=%.2fm height=%.2fm "
|
||||
|
|
|
|||
|
|
@ -151,8 +151,8 @@ def _fmt_number(value: Any) -> str | None:
|
|||
if isinstance(value, int):
|
||||
return _fmt_thousands(value)
|
||||
if isinstance(value, float):
|
||||
if not math.isfinite(value): # NaN/Inf: int(value) бросил бы ValueError
|
||||
return str(value)
|
||||
if not math.isfinite(value): # NaN/Inf — не число: честно пропускаем (как None),
|
||||
return None # иначе в RU-прозу утёк бы англ. литерал 'nan'/'inf' (#1585)
|
||||
if value == int(value):
|
||||
return _fmt_thousands(value)
|
||||
# Точность отчёта сохраняем: repr float'а → '.'→','. (0.31 → '0,31').
|
||||
|
|
@ -251,7 +251,7 @@ def _render_what_to_build(report: dict[str, Any]) -> tuple[str, list[str]]:
|
|||
if summary:
|
||||
lines.append(str(summary))
|
||||
|
||||
if len(sections_used) == 1 and not any(
|
||||
if not any(
|
||||
section.get(k) for k in ("obj_class", "mix", "commercial", "usp", "summary")
|
||||
):
|
||||
lines.append("Раздел рекомендации продукта в отчёте пуст.")
|
||||
|
|
@ -266,16 +266,21 @@ def _render_why_forecast(report: dict[str, Any]) -> tuple[str, list[str]]:
|
|||
|
||||
future = report.get("future_market")
|
||||
if isinstance(future, dict):
|
||||
sections_used.append("future_market")
|
||||
future_emitted = False # секцию в provenance кладём только если выведена строка (#1630)
|
||||
horizons = future.get("forecasts_by_horizon")
|
||||
if isinstance(horizons, list) and horizons:
|
||||
lines.append(f"Прогноз построен по {len(horizons)} горизонтам спроса/предложения.")
|
||||
future_emitted = True
|
||||
future_supply = future.get("future_supply")
|
||||
if isinstance(future_supply, dict) and future_supply:
|
||||
lines.append("Учтено давление будущего предложения (выходящие проекты).")
|
||||
future_emitted = True
|
||||
summary = future.get("summary")
|
||||
if summary:
|
||||
lines.append(str(summary))
|
||||
future_emitted = True
|
||||
if future_emitted:
|
||||
sections_used.append("future_market")
|
||||
else:
|
||||
lines.append(_NO_SECTION_TMPL.format(name="будущий рынок"))
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ def find_match_candidates(
|
|||
objective_distinct AS (
|
||||
SELECT DISTINCT project_name
|
||||
FROM objective_corpus_room_month
|
||||
WHERE group_name = 'Екатеринбург'
|
||||
)
|
||||
SELECT
|
||||
d.obj_id,
|
||||
|
|
@ -168,7 +169,7 @@ def auto_apply_matches(
|
|||
len(auto),
|
||||
len(review),
|
||||
)
|
||||
return {"auto_accepted": 0, "review_queue": len(review), "skipped": 0}
|
||||
return {"auto_accepted": len(auto), "review_queue": len(review), "skipped": 0}
|
||||
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
|
|
|
|||
|
|
@ -110,10 +110,7 @@ def _md_table(headers: list[str], rows: list[list[Any]]) -> str:
|
|||
if not rows:
|
||||
empty = "| " + " | ".join([_NO_DATA] + [""] * (len(headers) - 1)) + " |"
|
||||
return "\n".join([head, sep, empty])
|
||||
body = [
|
||||
"| " + " | ".join(_md_cell(cell) for cell in row) + " |"
|
||||
for row in rows
|
||||
]
|
||||
body = ["| " + " | ".join(_md_cell(cell) for cell in row) + " |" for row in rows]
|
||||
return "\n".join([head, sep, *body])
|
||||
|
||||
|
||||
|
|
@ -124,10 +121,17 @@ def _md_kv_table(data: dict[str, Any]) -> str:
|
|||
|
||||
|
||||
def _md_kv_lines(pairs: list[tuple[str, Any]]) -> str:
|
||||
"""Список «**метка:** значение» построчно (для коротких карточек meta). PURE."""
|
||||
"""Список «**метка:** значение» построчно (для коротких карточек meta). PURE.
|
||||
|
||||
Переводы строк в значении сворачиваем в пробел (по аналогии с `_md_cell`):
|
||||
иначе многострочный value (напр. product_tz.summary) разорвал бы буллет.
|
||||
"""
|
||||
if not pairs:
|
||||
return _NO_DATA
|
||||
return "\n".join(f"- **{label}:** {_fmt(value)}" for label, value in pairs)
|
||||
return "\n".join(
|
||||
f"- **{label}:** {_fmt(value).replace(chr(10), ' ').replace(chr(13), ' ')}"
|
||||
for label, value in pairs
|
||||
)
|
||||
|
||||
|
||||
def _join_horizons(values: list[Any]) -> Any:
|
||||
|
|
@ -269,12 +273,8 @@ def _build_scenarios(report: dict[str, Any]) -> str:
|
|||
for name, payload in by_scenario.items():
|
||||
data = _as_dict(payload)
|
||||
rate_path = _as_dict(data.get("rate_path"))
|
||||
rate_str = (
|
||||
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
|
||||
)
|
||||
rows.append(
|
||||
[name, _scenario_deficit_cell(data), rate_str, data.get("advisory")]
|
||||
)
|
||||
rate_str = ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
|
||||
rows.append([name, _scenario_deficit_cell(data), rate_str, data.get("advisory")])
|
||||
|
||||
headers = [
|
||||
"Сценарий",
|
||||
|
|
@ -283,9 +283,7 @@ def _build_scenarios(report: dict[str, Any]) -> str:
|
|||
"Advisory",
|
||||
]
|
||||
return (
|
||||
f"## {_TITLE_SCENARIOS}\n\n"
|
||||
f"{_fmt(scenarios.get('summary'))}\n\n"
|
||||
f"{_md_table(headers, rows)}"
|
||||
f"## {_TITLE_SCENARIOS}\n\n{_fmt(scenarios.get('summary'))}\n\n{_md_table(headers, rows)}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -307,9 +305,7 @@ def _build_product_tz(report: dict[str, Any]) -> str:
|
|||
if isinstance(m, dict)
|
||||
]
|
||||
usp_rows = [[u.get("segment"), u.get("usp_text")] for u in usp if isinstance(u, dict)]
|
||||
reason_rows = [
|
||||
[r.get("why"), r.get("advisory")] for r in reasons if isinstance(r, dict)
|
||||
]
|
||||
reason_rows = [[r.get("why"), r.get("advisory")] for r in reasons if isinstance(r, dict)]
|
||||
|
||||
return (
|
||||
f"## {_TITLE_PRODUCT_TZ}\n\n"
|
||||
|
|
|
|||
|
|
@ -458,6 +458,9 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
|
|||
`payload` = `ScenarioForecast.as_dict()`: у сценария НЕТ скалярного «overall» — есть
|
||||
список `forecasts` по горизонтам, каждый с `deficit_index`. Берём горизонт
|
||||
`_PRIMARY_HORIZON_MONTHS`, иначе первый с не-None дефицитом. Нет → None (→ "—").
|
||||
|
||||
NB: эту функцию импортируют report_md/docx/pptx — НЕ менять сигнатуру (вернёт скаляр).
|
||||
Для подписи фактического горизонта используй `_scenario_deficit_horizon`.
|
||||
"""
|
||||
forecasts = _as_list(payload.get("forecasts"))
|
||||
primary = next(
|
||||
|
|
|
|||
|
|
@ -159,12 +159,14 @@ def generate_snapshot_pdf(
|
|||
area_ha = f"{area_m2 / 10_000:.2f}" if area_m2 else "—"
|
||||
poi_items = _build_poi_items(poi_rows, limit=7)
|
||||
|
||||
# Конкуренты — берём топ N ближайших (уже отсортированы по flat_count DESC;
|
||||
# переупорядочиваем по distance_m для удобства чтения)
|
||||
# Конкуренты — берём топ N БЛИЖАЙШИХ. competitor_rows приходят отсортированными
|
||||
# по flat_count DESC (крупнейшие ЖК), поэтому сначала пересортировываем весь
|
||||
# список по distance_m ASC, и лишь затем срезаем N — иначе в блок попадали бы
|
||||
# 5 крупнейших из радиуса, а не непосредственное конкурентное окружение пятна.
|
||||
competitors_display = sorted(
|
||||
competitor_rows[:competitors_limit],
|
||||
competitor_rows,
|
||||
key=lambda r: float(r.get("distance_m") or 0),
|
||||
)
|
||||
)[:competitors_limit]
|
||||
competitors_ctx: list[dict[str, Any]] = [
|
||||
{
|
||||
"comm_name": r.get("comm_name"),
|
||||
|
|
|
|||
|
|
@ -58,7 +58,11 @@ def _analog_rows(lots: list[AnalogLot], *, is_deal: bool) -> str:
|
|||
for lot in lots:
|
||||
date_val = lot.listing_date.strftime("%d.%m.%Y") if lot.listing_date else "—"
|
||||
dom_val = str(lot.days_on_market) if lot.days_on_market is not None else "—"
|
||||
floor_val = f"{lot.floor}/{lot.total_floors}" if lot.floor and lot.total_floors else "—"
|
||||
floor_val = (
|
||||
f"{lot.floor}/{lot.total_floors}"
|
||||
if lot.floor is not None and lot.total_floors is not None
|
||||
else "—"
|
||||
)
|
||||
label = "Дата сделки" if is_deal else "В продаже"
|
||||
_ = label # used for header only
|
||||
rows.append(
|
||||
|
|
|
|||
|
|
@ -294,8 +294,10 @@ def compute_affordability(
|
|||
усреднение НЕнулевых месяцев). None → платёж None (graceful).
|
||||
• monthly_payment = _annuity(principal=price×ref_area, annual_rate=rate,
|
||||
months=_ANNUITY_TERM_MONTHS).
|
||||
• payment_at_scenario[h] = _annuity(... annual_rate=rate_path[h]) — платёж на
|
||||
каждом горизонте сценарной ставки (None rate_path → поле None).
|
||||
• payment_at_scenario[h] = _annuity(... annual_rate=rate_path[h] + спред) —
|
||||
платёж на каждом горизонте сценарной КЛЮЧЕВОЙ ставки ЦБ, приведённой к той
|
||||
же рыночной базе, что и monthly_payment_rub (key_rate + калиброванный спред
|
||||
4.5 п.п.); None rate_path → поле None (#1639).
|
||||
|
||||
Graceful: нет ставки/цены → платёж None; ставка ≤0 → аннуитет деградирует к
|
||||
principal/months. НИКОГДА не crash. confidence ВСЕГДА 'low'. Детерминированно.
|
||||
|
|
@ -304,8 +306,10 @@ def compute_affordability(
|
|||
db: SQLAlchemy sync Session.
|
||||
spec: целевой сегмент (для сегментной цены, если price_per_m2 не задан).
|
||||
price_per_m2: цена ₽/м² (None → берём сегментную среднюю reuse-ом).
|
||||
rate_path: сценарный {horizon: годовая ставка %} для payment_at_scenario;
|
||||
None → payment_at_scenario None.
|
||||
rate_path: сценарный {horizon: КЛЮЧЕВАЯ ставка ЦБ %} для payment_at_scenario
|
||||
(контракт #952/#984: конверт key_rate, НЕ рыночная — к ней внутри
|
||||
добавляется тот же спред _KEY_RATE_MARKET_SPREAD_PP, что и в базовом
|
||||
monthly_payment_rub); None → payment_at_scenario None.
|
||||
ref_area_m2: эталонная площадь тела кредита (по умолчанию _REF_AREA_M2).
|
||||
price_source: источник сегментной цены (по умолчанию _PRICE_SOURCE = B).
|
||||
|
||||
|
|
@ -337,7 +341,18 @@ def compute_affordability(
|
|||
if rate_path is not None:
|
||||
scenario: dict[int, float] = {}
|
||||
for horizon, scenario_rate in rate_path.items():
|
||||
payment = _annuity(principal, scenario_rate, _ANNUITY_TERM_MONTHS)
|
||||
# rate_path[h] = КЛЮЧЕВАЯ ставка ЦБ сценария (#952/#984 контракт:
|
||||
# demand_supply_forecast.py:586, scenarios.py — конверт key_rate), НЕ
|
||||
# рыночная. Приводим к той же рыночной базе, что и monthly_payment_rub:
|
||||
# key_rate + калиброванный спред (_current_market_rate, строка 251).
|
||||
# Иначе сценарный платёж считался бы по «голой» key_rate (≈ на 4.5 п.п.
|
||||
# ниже базовой ставки) и был бы НЕсопоставим с monthly_payment_rub (#1639).
|
||||
market_scenario_rate = (
|
||||
scenario_rate + _KEY_RATE_MARKET_SPREAD_PP
|
||||
if scenario_rate is not None
|
||||
else None
|
||||
)
|
||||
payment = _annuity(principal, market_scenario_rate, _ANNUITY_TERM_MONTHS)
|
||||
if payment is not None:
|
||||
scenario[horizon] = payment
|
||||
payment_at_scenario = scenario
|
||||
|
|
|
|||
|
|
@ -330,9 +330,12 @@ def _build_rationale(
|
|||
|
||||
if advisory_capped and level == _ADVISORY_CEILING:
|
||||
# Уровень упёрся в advisory-потолок (не данные) — это и есть главная причина.
|
||||
# _F_ADVISORY_CAP-фактор уже проговорён в base — исключаем его ноту из «также»,
|
||||
# иначе advisory-cap-сообщение дублируется (частый all-high случай).
|
||||
other = [f.note for f in drag if f.name != _F_ADVISORY_CAP]
|
||||
base = f"{label}: прогноз советующий (не провалидирован) — уровень ограничен «medium»"
|
||||
if notes:
|
||||
base += "; также " + _join_notes(notes)
|
||||
if other:
|
||||
base += "; также " + _join_notes(other)
|
||||
return base + "."
|
||||
|
||||
if not notes:
|
||||
|
|
|
|||
|
|
@ -822,7 +822,10 @@ def _competitor_signal(
|
|||
)
|
||||
return None, None
|
||||
weights = [c.relevance_weight for c in response.competitors if c.relevance_weight is not None]
|
||||
return weights, len(response.competitors)
|
||||
# #1595: count считаем по тем же конкурентам, у которых есть relevance_weight, иначе при
|
||||
# частичных данных (None-веса) count > len(weights) → density завышена, future_competition
|
||||
# занижен. В проде get_competitors всегда задаёт вес (число), но поле допускает None (мок).
|
||||
return weights, len(weights)
|
||||
|
||||
|
||||
def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:
|
||||
|
|
|
|||
|
|
@ -625,17 +625,22 @@ def _demand_only_overlay(
|
|||
room_bucket=forecast_bucket,
|
||||
district=district,
|
||||
)
|
||||
# §9.4 нормализация под будущий режим ставки (β внутри; rate_future None →
|
||||
# деградирует к нейтрали внутри себя, передаём 0.0 как placeholder).
|
||||
norm = compute_demand_normalization(
|
||||
db, spec=spec, rate_future=rate_future if rate_future is not None else 0.0
|
||||
)
|
||||
# §9.4 нормализация под будущий режим ставки (β внутри). rate_future None
|
||||
# (hold_last_rate не дал ставку) → НЕ применяем §9.4: 0.0-placeholder дал бы
|
||||
# delta=−rate_window_avg → exp(β·delta) и клэмп к _NORM_MAX (макс. аплифт),
|
||||
# а НЕ нейтраль. Честная нейтраль при отсутствии будущей ставки = коэф. 1.0.
|
||||
if rate_future is not None:
|
||||
norm_coefficient = compute_demand_normalization(
|
||||
db, spec=spec, rate_future=rate_future
|
||||
).coefficient
|
||||
else:
|
||||
norm_coefficient = 1.0
|
||||
# §9.5 макро-коэффициент (ортогонален β); профиль — класс + room_bucket.
|
||||
profile: dict[str, Any] = {"room_bucket": forecast_bucket}
|
||||
if mapped_class is not None:
|
||||
profile["obj_class"] = mapped_class
|
||||
macro_coef = compute_macro_coefficient(db, segment_profile=profile)
|
||||
pace = base_pace * norm.coefficient * macro_coef.coefficient
|
||||
pace = base_pace * norm_coefficient * macro_coef.coefficient
|
||||
paces.append((live_bucket, mapped_class, pace))
|
||||
|
||||
max_pace = max((p for _, _, p in paces), default=0.0)
|
||||
|
|
@ -804,14 +809,14 @@ def _as_int(value: Any) -> int | None:
|
|||
Защита §10.4 от мок/мусор-атрибутов (MagicMock < int бросил бы TypeError): любой
|
||||
нечисловой/bool/сбойный вход → None → degraded-honest путь, без падения.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
if isinstance(value, bool) or not isinstance(value, int | float):
|
||||
return None
|
||||
return int(value)
|
||||
|
||||
|
||||
def _as_float(value: Any) -> float | None:
|
||||
"""Безопасно привести значение к float (None/нечисловое → None). Graceful. PURE."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
if isinstance(value, bool) or not isinstance(value, int | float):
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ import numpy as np
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.forecast_request_cache import cached
|
||||
from app.services.forecasting.macro_series import get_monthly_macro
|
||||
from app.services.forecasting.macro_series import get_monthly_macro, is_confounded_window
|
||||
from app.services.forecasting.rate_sensitivity import Confidence, RateSensitivity, _delta
|
||||
from app.services.forecasting.sales_series import (
|
||||
SegmentSpec,
|
||||
|
|
@ -672,7 +672,7 @@ def _insufficient_sensitivity(segment: dict[str, str | None]) -> RateSensitivity
|
|||
|
||||
|
||||
def _fit_to_sensitivity(
|
||||
fit: DistributedLagFit, *, segment: dict[str, str | None]
|
||||
fit: DistributedLagFit, *, segment: dict[str, str | None], confounded: bool = False
|
||||
) -> RateSensitivity:
|
||||
"""Map a DistributedLagFit (Almon-ADL) onto the §9.6 RateSensitivity contract.
|
||||
|
||||
|
|
@ -684,9 +684,16 @@ def _fit_to_sensitivity(
|
|||
• r2 / n_obs ← fit.r2 / fit.n
|
||||
• confidence ← 'regression' → "medium" (gated-OK but advisory-grade) |
|
||||
'fallback' → "low"
|
||||
Source-B-only outputs (z_area_floor, most_sensitive_bucket, confounded,
|
||||
shrinkage_weight) have no analogue in a district×class distributed-lag fit
|
||||
(no room×area bucketing here) → None / sensible defaults. PURE.
|
||||
• confounded ← passed in by the caller (computed from the ACTUAL fit window
|
||||
via is_confounded_window — #1636). The §9.6 production path
|
||||
OR-aggregates this with §9.5 macro_coefficient.confounded in
|
||||
demand_supply_forecast._series_confounded → шок-фактор (#1222).
|
||||
The 48-мес regression window overlaps the 2024-07-01 shock long
|
||||
after the 12-мес macro window stops doing so, so hardcoding
|
||||
False here silently dropped the shock signal on this channel.
|
||||
Source-B-only outputs (z_area_floor, most_sensitive_bucket, shrinkage_weight) have
|
||||
no analogue in a district×class distributed-lag fit (no room×area bucketing here)
|
||||
→ None / sensible defaults. PURE.
|
||||
|
||||
BETA SEMANTICS (important): `beta` here carries the Almon LONG-RUN multiplier
|
||||
Σ_j β_j on Δln — the cumulative %-effect of a SUSTAINED +1pp regime shift, NOT
|
||||
|
|
@ -705,7 +712,7 @@ def _fit_to_sensitivity(
|
|||
r2=fit.r2,
|
||||
n_obs=fit.n,
|
||||
shrinkage_weight=0.0,
|
||||
confounded=False,
|
||||
confounded=confounded,
|
||||
confidence=confidence,
|
||||
phrase=fit.phrase,
|
||||
)
|
||||
|
|
@ -778,4 +785,32 @@ def compute_rate_regime_sensitivity(
|
|||
)
|
||||
return _insufficient_sensitivity(segment)
|
||||
|
||||
return _fit_to_sensitivity(fit, segment=segment)
|
||||
# #1636: confounded must reflect the ACTUAL §9.6 fit window. The regression fits
|
||||
# over the same macro grid as compute_district_rate_regression (get_monthly_macro,
|
||||
# months_back); re-reading it here is a cache hit (same args). The 48-мес window
|
||||
# crosses the 2024-07-01 shock long after the §9.5 12-мес macro window stops → this
|
||||
# is exactly the channel that was silently never raising the shock flag (#1222).
|
||||
confounded = _macro_window_confounded(db, months_back=months_back)
|
||||
return _fit_to_sensitivity(fit, segment=segment, confounded=confounded)
|
||||
|
||||
|
||||
def _macro_window_confounded(db: Session, *, months_back: int) -> bool:
|
||||
"""True если §9.6 fit-окно [min..max] макро-сетки пересекает шок-дату (#1636).
|
||||
|
||||
Зеркалит macro_coefficient._series_confounded / rate_sensitivity._series_confounded
|
||||
(PR2 is_confounded_window). Окно = та же сетка get_monthly_macro(months_back), что
|
||||
использует compute_district_rate_regression → cache-hit, без лишнего запроса.
|
||||
Пустая сетка / сбой → False (нет окна — нечего конфаундить), НЕ crash.
|
||||
"""
|
||||
try:
|
||||
months = [m.month for m in get_monthly_macro(db, months_back=months_back)]
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"rate_regime_sensitivity: macro window read for confounded-flag failed "
|
||||
"(months_back=%d) → treating as not confounded",
|
||||
months_back,
|
||||
)
|
||||
return False
|
||||
if not months:
|
||||
return False
|
||||
return is_confounded_window(min(months), max(months))
|
||||
|
|
|
|||
|
|
@ -393,9 +393,21 @@ def _market_now_summary(
|
|||
avg_price = analyze.get("market_avg_price_per_m2")
|
||||
if isinstance(avg_price, (int, float)) and not isinstance(avg_price, bool):
|
||||
parts.append(f"средняя цена ~{round(float(avg_price)):,} ₽/м²".replace(",", " "))
|
||||
n_comp = _analog_count(analyze, market_metrics)
|
||||
if n_comp is not None:
|
||||
parts.append(f"{n_comp} ЖК-конкурентов рядом")
|
||||
# #1634: НЕ через _analog_count — он отдаёт market_metrics.obj_count (число ЖК во
|
||||
# всей district-wide/микрорайонной выборке §9.2), что НЕ равно «конкурентов рядом».
|
||||
# Метка честно следует источнику: obj_count → «в выборке района», локальный fallback
|
||||
# из analyze (competitors_total / len(competitors)) → «рядом».
|
||||
if market_metrics is not None and isinstance(market_metrics.get("obj_count"), int):
|
||||
parts.append(f"{market_metrics['obj_count']} ЖК в выборке района")
|
||||
else:
|
||||
n_local: int | None = None
|
||||
pulse = analyze.get("market_pulse")
|
||||
if isinstance(pulse, dict) and isinstance(pulse.get("competitors_total"), int):
|
||||
n_local = pulse["competitors_total"]
|
||||
elif isinstance(analyze.get("competitors"), list):
|
||||
n_local = len(analyze["competitors"])
|
||||
if n_local is not None:
|
||||
parts.append(f"{n_local} ЖК-конкурентов рядом")
|
||||
if not parts:
|
||||
return None
|
||||
return "Текущий рынок: " + ", ".join(parts) + "."
|
||||
|
|
|
|||
|
|
@ -1689,7 +1689,13 @@ def compute_special_indices(
|
|||
|
||||
indices: dict[str, SpecialIndex] = {key: _run(key, builders[key]) for key in _INDEX_KEYS}
|
||||
|
||||
confidence = _min_confidence([idx.confidence for idx in indices.values()])
|
||||
# confidence = MIN по ДОСТУПНЫМ индексам (контракт SpecialIndices / docstring выше).
|
||||
# Недоступный индекс (_unavailable → value=None, confidence='low') НЕ участвует: его
|
||||
# 'low' — артефакт деградации, а не сигнал низкой уверенности доступных индексов
|
||||
# (#1592: _min_confidence отбрасывает только None, поэтому фильтруем здесь по value).
|
||||
confidence = _min_confidence(
|
||||
[idx.confidence for idx in indices.values() if idx.value is not None]
|
||||
)
|
||||
|
||||
n_available = sum(1 for idx in indices.values() if idx.value is not None)
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import logging
|
|||
|
||||
# ezdxf.new живёт в ezdxf.filemanagement и не реэкспортируется через ezdxf.__all__;
|
||||
# импорт из модуля удовлетворяет strict no-implicit-reexport.
|
||||
from ezdxf.enums import TextEntityAlignment
|
||||
from ezdxf.filemanagement import new as ezdxf_new
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
|
|
@ -43,13 +44,33 @@ _LAYER_BUILDINGS = "BUILDINGS"
|
|||
_LABEL_HEIGHT_M = 2.0
|
||||
|
||||
|
||||
def _polygon_points(poly: Polygon) -> list[tuple[float, float]]:
|
||||
"""Внешнее кольцо полигона как список (x, y) для LWPolyline (без замыкающей точки)."""
|
||||
coords = list(poly.exterior.coords)
|
||||
def _ring_points(coords: object) -> list[tuple[float, float]]:
|
||||
"""Кольцо (exterior/interior) как список (x, y) для LWPolyline (без замыкающей точки)."""
|
||||
pts = list(coords)
|
||||
# Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам.
|
||||
if len(coords) > 1 and coords[0] == coords[-1]:
|
||||
coords = coords[:-1]
|
||||
return [(float(x), float(y)) for x, y in coords]
|
||||
if len(pts) > 1 and pts[0] == pts[-1]:
|
||||
pts = pts[:-1]
|
||||
return [(float(x), float(y)) for x, y in pts]
|
||||
|
||||
|
||||
def _add_polygon(msp: object, poly: Polygon, layer: str) -> None:
|
||||
"""Нарисовать полигон на слое: внешнее кольцо + каждое внутреннее (отверстие).
|
||||
|
||||
LWPolyline не умеет дырки, поэтому каждое interior-кольцо эмитируется отдельной
|
||||
замкнутой полилинией на том же слое — иначе легальные вырезы (двор, сервитут,
|
||||
охранная зона) терялись бы и заливались сплошняком.
|
||||
"""
|
||||
msp.add_lwpolyline(
|
||||
_ring_points(poly.exterior.coords),
|
||||
close=True,
|
||||
dxfattribs={"layer": layer},
|
||||
)
|
||||
for interior in poly.interiors:
|
||||
msp.add_lwpolyline(
|
||||
_ring_points(interior.coords),
|
||||
close=True,
|
||||
dxfattribs={"layer": layer},
|
||||
)
|
||||
|
||||
|
||||
def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
|
||||
|
|
@ -71,16 +92,8 @@ def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
|
|||
msp = doc.modelspace()
|
||||
|
||||
# Участок и пятно застройки — из метрической геометрии Parcel.
|
||||
msp.add_lwpolyline(
|
||||
_polygon_points(parcel.polygon_m),
|
||||
close=True,
|
||||
dxfattribs={"layer": _LAYER_PARCEL},
|
||||
)
|
||||
msp.add_lwpolyline(
|
||||
_polygon_points(parcel.buildable_m),
|
||||
close=True,
|
||||
dxfattribs={"layer": _LAYER_BUILDABLE},
|
||||
)
|
||||
_add_polygon(msp, parcel.polygon_m, _LAYER_PARCEL)
|
||||
_add_polygon(msp, parcel.buildable_m, _LAYER_BUILDABLE)
|
||||
|
||||
# Секции: восстанавливаем метрические footprints из WGS84-geojson варианта.
|
||||
features = variant.buildings_geojson.get("features", [])
|
||||
|
|
@ -91,18 +104,17 @@ def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
|
|||
if footprint is None:
|
||||
continue
|
||||
section_count += 1
|
||||
msp.add_lwpolyline(
|
||||
_polygon_points(footprint),
|
||||
close=True,
|
||||
dxfattribs={"layer": _LAYER_BUILDINGS},
|
||||
)
|
||||
_add_polygon(msp, footprint, _LAYER_BUILDINGS)
|
||||
centroid = footprint.centroid
|
||||
label = str(_feature_section_id(feature, section_count))
|
||||
text = msp.add_text(
|
||||
label,
|
||||
dxfattribs={"layer": _LAYER_BUILDINGS, "height": _LABEL_HEIGHT_M},
|
||||
)
|
||||
text.set_placement((float(centroid.x), float(centroid.y)))
|
||||
text.set_placement(
|
||||
(float(centroid.x), float(centroid.y)),
|
||||
align=TextEntityAlignment.MIDDLE_CENTER,
|
||||
)
|
||||
|
||||
stream = io.BytesIO()
|
||||
doc.write(stream, fmt="bin")
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from shapely.strtree import STRtree
|
|||
|
||||
from app.schemas.concept import ConceptInput, ConceptVariant
|
||||
from app.services.generative import financial, teap
|
||||
from app.services.generative.geometry import Parcel
|
||||
from app.services.generative.geometry import Parcel, ParcelGeometryError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -191,11 +191,25 @@ def place_strategy(
|
|||
parcel: Parcel,
|
||||
payload: ConceptInput,
|
||||
spec: StrategySpec,
|
||||
) -> ConceptVariant:
|
||||
"""Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant."""
|
||||
) -> ConceptVariant | None:
|
||||
"""Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant.
|
||||
|
||||
Возвращает ``None``, если ни одна секция не легла в пятно застройки (узкий/мелкий
|
||||
участок, footprint стратегии целиком не помещается). Без этого вырожденный вариант
|
||||
с нулевым размещением (revenue=0, margin=-land, IRR<0) выдавался бы как валидный —
|
||||
ложь в отчёте. Отбраковку делает вызывающий :func:`place_all_strategies`.
|
||||
"""
|
||||
floors = _resolve_floors(payload.target_floors, spec.floors_factor)
|
||||
coverage_cap = _COVERAGE_CAP_BY_TYPE.get(payload.development_type, _DEFAULT_COVERAGE_CAP)
|
||||
footprints = _greedy_place(parcel, spec, coverage_cap)
|
||||
if not footprints:
|
||||
logger.warning(
|
||||
"strategy=%s placed 0 sections (footprint %.0fx%.0f m not buildable) — отбраковка",
|
||||
spec.name,
|
||||
spec.section_w,
|
||||
spec.section_d,
|
||||
)
|
||||
return None
|
||||
|
||||
teap_result = teap.compute_teap(
|
||||
footprints=footprints,
|
||||
|
|
@ -220,8 +234,22 @@ def place_strategy(
|
|||
|
||||
|
||||
def place_all_strategies(parcel: Parcel, payload: ConceptInput) -> list[ConceptVariant]:
|
||||
"""Stage 1b entry: построить три варианта (max_area / max_insolation / balanced)."""
|
||||
variants = [place_strategy(parcel, payload, spec) for spec in _STRATEGIES]
|
||||
"""Stage 1b entry: построить три варианта (max_area / max_insolation / balanced).
|
||||
|
||||
Вырожденные стратегии (нулевое размещение) отбраковываются — в результат попадают
|
||||
только варианты с реальными секциями. Если ни одна стратегия не легла (участок не
|
||||
вмещает даже самую компактную секцию), это вырожденный участок: поднимаем
|
||||
:class:`ParcelGeometryError` (API мапит в 422) — лучше отказ, чем пустой/лживый ответ.
|
||||
"""
|
||||
variants = [
|
||||
variant
|
||||
for spec in _STRATEGIES
|
||||
if (variant := place_strategy(parcel, payload, spec)) is not None
|
||||
]
|
||||
if not variants:
|
||||
raise ParcelGeometryError(
|
||||
"ни одна стратегия размещения не вместила секцию — участок слишком узкий/мелкий"
|
||||
)
|
||||
logger.info(
|
||||
"placed all strategies: %s",
|
||||
", ".join(f"{v.strategy}={v.teap.apartments_count}кв" for v in variants),
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ class LLMResult:
|
|||
fallback_used: True если результат — сигнал к детерминированному fallback.
|
||||
reason: Машиночитаемая причина fallback (disabled/timeout/rate_limited/
|
||||
redaction_refused/provider_error/call_cap/no_api_key). None при ok.
|
||||
finish_reason: причина завершения от провайдера (stop/length/content_filter/
|
||||
tool_calls/…). None при fallback. Консьюмер ОБЯЗАН проверять её даже при
|
||||
ok=True: 'length'/'content_filter' → ответ обрезан/отфильтрован (частичный
|
||||
или пустой content) и не является полноценным результатом.
|
||||
prompt_tokens / completion_tokens: для оценки стоимости (0 при fallback).
|
||||
model: модель, ответившая на запрос ("" при fallback).
|
||||
"""
|
||||
|
|
@ -67,6 +71,7 @@ class LLMResult:
|
|||
tool_calls: list[ToolCall] = field(default_factory=list)
|
||||
fallback_used: bool = False
|
||||
reason: str | None = None
|
||||
finish_reason: str | None = None
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
model: str = ""
|
||||
|
|
@ -81,6 +86,7 @@ class LLMResult:
|
|||
ok=True,
|
||||
content=resp.content,
|
||||
tool_calls=list(resp.tool_calls),
|
||||
finish_reason=resp.finish_reason,
|
||||
prompt_tokens=resp.prompt_tokens,
|
||||
completion_tokens=resp.completion_tokens,
|
||||
model=resp.model,
|
||||
|
|
|
|||
|
|
@ -91,6 +91,21 @@ class LLMProvider(ABC):
|
|||
# ── OpenAI (external) ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _coerce_token_count(value: Any) -> int:
|
||||
"""usage-токены → int, толерантно к мусору (str "abc"/None/dict → 0).
|
||||
|
||||
OpenAI обычно отдаёт int, но через внешний прокси/нестандартный провайдер поле
|
||||
может прийти нечисловым. int() на таком значении бросил бы ValueError/TypeError
|
||||
мимо LLM*-контракта (его ловит client._call_with_retries) и пробил бы инвариант
|
||||
«complete никогда не падает наружу» (#1601). Невалидный токен-счётчик — не повод
|
||||
ронять ответ: деградируем до 0.
|
||||
"""
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
def _parse_openai_response(data: dict[str, Any], *, fallback_model: str) -> ProviderResponse:
|
||||
"""Распарсить тело chat/completions OpenAI → ProviderResponse (с tool_calls)."""
|
||||
choices = data.get("choices") or []
|
||||
|
|
@ -114,8 +129,8 @@ def _parse_openai_response(data: dict[str, Any], *, fallback_model: str) -> Prov
|
|||
content=message.get("content"),
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=choice.get("finish_reason"),
|
||||
prompt_tokens=int(usage.get("prompt_tokens", 0) or 0),
|
||||
completion_tokens=int(usage.get("completion_tokens", 0) or 0),
|
||||
prompt_tokens=_coerce_token_count(usage.get("prompt_tokens", 0)),
|
||||
completion_tokens=_coerce_token_count(usage.get("completion_tokens", 0)),
|
||||
model=str(data.get("model") or fallback_model),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,13 @@ _PHONE_RE = re.compile(r"(?:\+7|\b8)[\s\-(]*\d{3}[\s\-)]*\d{3}[\s-]*\d{2}[\s-]*\
|
|||
# пропускает из-за требования префикса «+7»/«\b8» + хотя бы одного разделителя.
|
||||
# Ставится РАНЬШЕ _SNILS_BARE_RE (любые 11 цифр), чтобы не путать с СНИЛС.
|
||||
_PHONE_BARE_RE = re.compile(r"(?<!\d)[78]\d{10}(?!\d)")
|
||||
# Телефон РФ «локальный»: 10 значащих цифр БЕЗ кода страны, начинаются с мобильного
|
||||
# «9», сгруппированы 9XX XXX XX XX через пробел/дефис (#1641). _PHONE_RE требует
|
||||
# префикс +7/8, _PHONE_BARE_RE — ровно 11 слитных цифр → формат «922 123 45 67» /
|
||||
# «922-123-45-67» проходил мимо. Якорь «9» + фиксированная группировка 3-3-2-2 не
|
||||
# пересекается с паспортом (4+6) / СНИЛС (3-3-3 2). Ставится ПОСЛЕ префиксных форм,
|
||||
# чтобы local-часть «+7 912 …» сначала ушла как полноценный phone.
|
||||
_PHONE_LOCAL_RE = re.compile(r"(?<!\d)9\d{2}[\s-]\d{3}[\s-]\d{2}[\s-]\d{2}(?!\d)")
|
||||
# Email.
|
||||
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
|
||||
# ИНН: ровно 12 (физлицо) или 10 (юрлицо) цифр, не приклеенные к другим цифрам.
|
||||
|
|
@ -145,16 +152,18 @@ _FULLNAME_RE = re.compile(
|
|||
r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})\b"
|
||||
)
|
||||
|
||||
# (regex, placeholder-kind, repl). Применяются последовательно в этом порядке.
|
||||
# (regex, kind, repl). ``repl`` — строка-плейсхолдер ИЛИ callback для re.subn
|
||||
# (используется ИНН: редактирует только checksum-валидные кандидаты — #1640).
|
||||
# Применяются последовательно в этом порядке.
|
||||
# Порядок критичен: _PHONE_BARE_RE раньше _SNILS_BARE_RE, чтобы 11-значные
|
||||
# с префиксом 7/8 ушли как phone (телефон семантически точнее СНИЛС'а).
|
||||
# repl — строка-замена ИЛИ re.sub callback (для ИНН: редактируем только при
|
||||
# валидной контроль-сумме, см. _inn_repl, #1640).
|
||||
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str, str | Callable[[re.Match[str]], str]], ...] = (
|
||||
_Repl = str | Callable[[re.Match[str]], str]
|
||||
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str, _Repl], ...] = (
|
||||
(_SNILS_RE, "snils", "[REDACTED:snils]"),
|
||||
(_PASSPORT_RE, "passport", "[REDACTED:passport]"),
|
||||
(_PHONE_RE, "phone", "[REDACTED:phone]"),
|
||||
(_PHONE_BARE_RE, "phone", "[REDACTED:phone]"),
|
||||
(_PHONE_LOCAL_RE, "phone", "[REDACTED:phone]"),
|
||||
(_EMAIL_RE, "email", "[REDACTED:email]"),
|
||||
(_INN_RE, "inn", _inn_repl),
|
||||
(_SNILS_BARE_RE, "snils", "[REDACTED:snils]"),
|
||||
|
|
@ -171,16 +180,15 @@ def scrub_text(value: str) -> str:
|
|||
if not value:
|
||||
return value
|
||||
redacted = value
|
||||
placeholder = "[REDACTED:%s]"
|
||||
for pattern, kind, repl in _PII_PATTERNS:
|
||||
before = redacted
|
||||
# n из subn для callback-repl (ИНН) считает ВСЕ совпадения, включая кандидаты,
|
||||
# которые callback вернул без изменений (не прошли checksum). Поэтому реальное
|
||||
# число замен берём по приросту числа плейсхолдеров — корректно и для str, и
|
||||
# для callback, без утечки самого PII-значения в лог.
|
||||
before_count = redacted.count(placeholder % kind)
|
||||
redacted = pattern.sub(repl, redacted)
|
||||
if redacted == before:
|
||||
continue
|
||||
# subn для callable-repl считал бы ВСЕ совпадения (включая
|
||||
# незаредактированных ИНН-кандидатов) — переоценка. Считаем фактически
|
||||
# вставленные маркеры по приросту их числа в строке (#1640).
|
||||
marker = f"[REDACTED:{kind}]"
|
||||
n = redacted.count(marker) - before.count(marker)
|
||||
n = redacted.count(placeholder % kind) - before_count
|
||||
if n > 0:
|
||||
# Логируем ТОЛЬКО kind и количество — без самого PII-значения.
|
||||
logger.info("redaction: scrubbed %d %s token(s) from free text", n, kind)
|
||||
|
|
@ -193,7 +201,7 @@ def _scrub_value(value: Any) -> Any:
|
|||
return scrub_text(value)
|
||||
if isinstance(value, dict):
|
||||
return {k: _scrub_value(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
if isinstance(value, list | tuple):
|
||||
scrubbed = [_scrub_value(v) for v in value]
|
||||
return type(value)(scrubbed)
|
||||
return value
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import logging
|
|||
import re
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from contextlib import closing
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
|
@ -466,13 +467,12 @@ def get_sqlite_info(sqlite_path: str | Path) -> dict[str, Any]:
|
|||
info["size_bytes"] = st.st_size
|
||||
info["modified_at"] = st.st_mtime # epoch seconds
|
||||
try:
|
||||
c = sqlite3.connect(p)
|
||||
info["lots"] = c.execute("SELECT COUNT(*) FROM objective_lots").fetchone()[0]
|
||||
info["corp_room_month"] = c.execute("SELECT COUNT(*) FROM objective_corp_month").fetchone()[
|
||||
0
|
||||
]
|
||||
info["mappings"] = c.execute("SELECT COUNT(*) FROM jk_objective_match").fetchone()[0]
|
||||
c.close()
|
||||
with closing(sqlite3.connect(p)) as c:
|
||||
info["lots"] = c.execute("SELECT COUNT(*) FROM objective_lots").fetchone()[0]
|
||||
info["corp_room_month"] = c.execute(
|
||||
"SELECT COUNT(*) FROM objective_corp_month"
|
||||
).fetchone()[0]
|
||||
info["mappings"] = c.execute("SELECT COUNT(*) FROM jk_objective_match").fetchone()[0]
|
||||
except sqlite3.Error as e:
|
||||
info["error"] = str(e)
|
||||
return info
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ opens the original DOM.РФ URL (we don't need to mirror originals).
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
|
@ -60,7 +61,16 @@ def make_thumbnail(
|
|||
im = im.convert("RGB")
|
||||
im = ImageOps.fit(im, size, method=Image.Resampling.LANCZOS)
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
im.save(dst, format="WEBP", quality=quality, method=4)
|
||||
# Atomic write: encode to a sibling temp file, then os.replace() so a
|
||||
# crash/OOM mid-encode never leaves a truncated .webp at the canonical
|
||||
# path (which dst.exists() would later treat as a valid cached thumb).
|
||||
tmp = dst.with_name(f".{dst.name}.tmp")
|
||||
try:
|
||||
im.save(tmp, format="WEBP", quality=quality, method=4)
|
||||
os.replace(tmp, dst)
|
||||
except BaseException:
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
return dst
|
||||
except Exception as e:
|
||||
logger.warning("thumbnail %s failed: %s", src, e)
|
||||
|
|
|
|||
|
|
@ -157,6 +157,16 @@ class _TextCollector(HTMLParser):
|
|||
extraction известных структур страницы каталога.
|
||||
"""
|
||||
|
||||
# HTML5 void-элементы: не имеют закрывающего тега → handle_endtag не вызывается.
|
||||
# Если пушить их в стек/буфер, чужой handle_endtag поп'ает чужой фрейм →
|
||||
# рассинхрон стека, текст блоков теряется (issue #1608).
|
||||
_VOID_TAGS = frozenset(
|
||||
{
|
||||
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
||||
"link", "meta", "param", "source", "track", "wbr",
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._stack: list[tuple[str, dict[str, str]]] = []
|
||||
|
|
@ -165,11 +175,15 @@ class _TextCollector(HTMLParser):
|
|||
self._buf: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
if tag in self._VOID_TAGS:
|
||||
return # void-теги не имеют endtag — не открываем фрейм (issue #1608)
|
||||
attr_dict = {k: (v or "") for k, v in attrs}
|
||||
self._stack.append((tag, attr_dict))
|
||||
self._buf.append("") # начало нового буфера для этого тега
|
||||
|
||||
def handle_endtag(self, _tag: str) -> None:
|
||||
if _tag in self._VOID_TAGS:
|
||||
return # void-теги фрейм не открывали — нечего поп'ать (issue #1608)
|
||||
if not self._stack:
|
||||
return
|
||||
tag, attr_dict = self._stack.pop()
|
||||
|
|
@ -239,8 +253,11 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
|||
# Ищем в сыром HTML — надёжнее чем DOM-обход для хрупкой структуры.
|
||||
|
||||
# Price: "7 890 000 ₽" или "7 890 000 руб"
|
||||
# Negative lookahead (?!\s*[/⁄]) исключает цену за м² ("217 835 ₽/м²"),
|
||||
# которая на странице обычно выше полной стоимости и иначе матчилась бы
|
||||
# первой → отбрасывалась санити-границей → price_rub=NULL (issue #1645).
|
||||
price_match = re.search(
|
||||
r"([\d][\d\s]{3,12}[\d])\s*(?:₽|руб)",
|
||||
r"([\d][\d\s]{3,12}[\d])\s*(?:₽|руб)(?!\s*[/⁄])",
|
||||
html,
|
||||
re.UNICODE,
|
||||
)
|
||||
|
|
@ -267,19 +284,25 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
|||
except ValueError:
|
||||
pass
|
||||
|
||||
# Status: ищем характерные слова рядом с "статус" или в badge
|
||||
# Status: ищем характерные слова рядом с "статус" или в badge.
|
||||
# Морфоварианты продан[ао]?/забронирован[ао]? — типовая "Квартира продана"
|
||||
# (ж.р.) раньше не матчилась, status уходил во free (issue #1609).
|
||||
# NOTE: классификация по первому совпадению во ВСЁМ HTML остаётся хрупкой —
|
||||
# 'в продаже' из блока 'другие квартиры в продаже' может дать ложный free.
|
||||
# Полный фикс (якорь к статус-бейджу / исключение секций "похожие") требует
|
||||
# знания DOM-структуры страницы каталога — см. отчёт (needs-leha).
|
||||
status_match = re.search(
|
||||
r"(в\s*продаже|свободна|free|продано|sold|забронирована|бронь|reserved)",
|
||||
r"(в\s*продаже|свободн[ао]|free|продан[ао]?|sold|забронирован[ао]?|бронь|reserved)",
|
||||
html,
|
||||
re.IGNORECASE | re.UNICODE,
|
||||
)
|
||||
if status_match:
|
||||
s = status_match.group(1).lower()
|
||||
if any(kw in s for kw in ("продаже", "свободна", "free")):
|
||||
if any(kw in s for kw in ("продаже", "свободн", "free")):
|
||||
result["status"] = STATUS_FREE
|
||||
elif any(kw in s for kw in ("продано", "sold")):
|
||||
elif any(kw in s for kw in ("продан", "sold")):
|
||||
result["status"] = STATUS_SOLD
|
||||
elif any(kw in s for kw in ("бронь", "забронирована", "reserved")):
|
||||
elif any(kw in s for kw in ("бронь", "забронирован", "reserved")):
|
||||
result["status"] = STATUS_RESERVED
|
||||
|
||||
# Finishing type: "Предчистовая", "Чистовая", "Без отделки", "Под ключ"
|
||||
|
|
@ -483,7 +506,18 @@ async def scrape_one_flat(
|
|||
|
||||
outcome["fields_extracted"] = len([v for v in data.values() if v is not None])
|
||||
outcome["updated"] = upsert_catalog_data(db, ods_id, catalog_url_hash, data)
|
||||
# success отражает прохождение пайплайна fetch+parse без исключения; реально
|
||||
# ли затронута строка в БД — см. outcome['updated']. Батч-статистика считает
|
||||
# отдельный stats['updated'], чтобы не рапортовать ложно высокий success при
|
||||
# ненайденном ods_id / пустом парсе (fields_extracted==0) — issue #1610.
|
||||
outcome["success"] = True
|
||||
if not outcome["updated"]:
|
||||
logger.warning(
|
||||
"catalog scrape ods_id=%s: fetched+parsed but DB row NOT updated "
|
||||
"(fields=%d, ods_id missing or all-NULL parse)",
|
||||
ods_id,
|
||||
outcome["fields_extracted"],
|
||||
)
|
||||
logger.info(
|
||||
"catalog scrape ods_id=%s: fields=%d updated=%s",
|
||||
ods_id,
|
||||
|
|
@ -512,11 +546,15 @@ async def scrape_catalog_batch(
|
|||
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
|
||||
|
||||
Returns:
|
||||
{total, success, failed, fields_total}
|
||||
{total, success, updated, failed, fields_total}
|
||||
- success: прошли fetch+parse без исключения
|
||||
- updated: реально затронули строку в БД (issue #1610) — отражает
|
||||
фактическое число записанных квартир, в отличие от success
|
||||
"""
|
||||
stats: dict[str, Any] = {
|
||||
"total": len(flats),
|
||||
"success": 0,
|
||||
"updated": 0,
|
||||
"failed": 0,
|
||||
"fields_total": 0,
|
||||
}
|
||||
|
|
@ -550,6 +588,8 @@ async def scrape_catalog_batch(
|
|||
if outcome["success"]:
|
||||
stats["success"] += 1
|
||||
stats["fields_total"] += outcome["fields_extracted"]
|
||||
if outcome["updated"]:
|
||||
stats["updated"] += 1
|
||||
else:
|
||||
stats["failed"] += 1
|
||||
|
||||
|
|
@ -565,9 +605,10 @@ async def scrape_catalog_batch(
|
|||
raise
|
||||
|
||||
logger.info(
|
||||
"scrape_catalog_batch done: total=%d success=%d failed=%d fields_total=%d",
|
||||
"scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d fields_total=%d",
|
||||
stats["total"],
|
||||
stats["success"],
|
||||
stats["updated"],
|
||||
stats["failed"],
|
||||
stats["fields_total"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -115,6 +115,30 @@ def _to_int(v: Any) -> int | None:
|
|||
return None
|
||||
|
||||
|
||||
def _to_float(v: Any) -> float | None:
|
||||
"""Coerce a DOM.РФ numeric value into float. None / bool / non-numeric / empty → None.
|
||||
Accepts int, float, and numeric strings ('45.2'). Mirror of _to_int for area/price."""
|
||||
if v is None or isinstance(v, bool):
|
||||
return None
|
||||
if isinstance(v, int | float):
|
||||
f = float(v)
|
||||
if f != f or f in (float("inf"), float("-inf")): # NaN / ±inf guard
|
||||
return None
|
||||
return f
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
f = float(s)
|
||||
except (ValueError, OverflowError):
|
||||
return None
|
||||
if f != f or f in (float("inf"), float("-inf")):
|
||||
return None
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def _to_date(v: Any) -> date | None:
|
||||
"""Coerce date string to date. Accept 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM:SS',
|
||||
'DD-MM-YYYY HH:MM:SS', or quarter-string like 'IV кв. 2028'.
|
||||
|
|
@ -414,8 +438,13 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
|
|||
|
||||
# Derive price_per_m2 when API returns price_rub but omits pricePerSquareMeter.
|
||||
# Covers cases where the table endpoint has the flat price but no pre-computed m² rate.
|
||||
if price_per_m2 is None and price_rub is not None and total_area and total_area > 0:
|
||||
price_per_m2 = round(price_rub / total_area, 2)
|
||||
# Coerce оба операнда в float ДО сравнения/деления: API иногда отдаёт totalArea/price
|
||||
# строкой ('45.2'), и `total_area > 0` на str роняло бы _norm_flat с TypeError
|
||||
# → весь run падал бы в status='failed' (#1644).
|
||||
price_rub_num = _to_float(price_rub)
|
||||
total_area_num = _to_float(total_area)
|
||||
if price_per_m2 is None and price_rub_num is not None and total_area_num and total_area_num > 0:
|
||||
price_per_m2 = round(price_rub_num / total_area_num, 2)
|
||||
logger.info(
|
||||
"derive price_per_m2=%.2f for flat ods_id=%s obj_id=%s",
|
||||
price_per_m2,
|
||||
|
|
@ -1432,34 +1461,78 @@ def _place_str(region_code: int) -> str:
|
|||
return str(region_code)
|
||||
|
||||
|
||||
OBJECTS_PAGE_SIZE = 500
|
||||
|
||||
|
||||
async def fetch_objects_for_status(
|
||||
sess: BrowserSession, place: str, status: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch ALL objects for a given (place, objStatus) — server returns up to limit=999999."""
|
||||
payload = await sess.get_json(
|
||||
PATH_OBJECTS,
|
||||
{
|
||||
"offset": 0,
|
||||
"limit": 999999,
|
||||
"sortField": "default",
|
||||
"sortType": "desc",
|
||||
"place": place,
|
||||
"objStatus": status,
|
||||
},
|
||||
"""Fetch ALL objects for a given (place, objStatus), пагинируя по страницам.
|
||||
|
||||
Раньше делали единственный запрос с limit=999999 и доверяли допущению, что сервер
|
||||
вернёт всё. Если WAF/прокси DOM.РФ обрезает гигантский limit до своего max, хвост
|
||||
объектов молча терялся (не скрейпился), а run всё равно рапортовал status='done' (#1605).
|
||||
Теперь идём страницами по OBJECTS_PAGE_SIZE и аккумулируем, пока не наберём total
|
||||
(из payload) либо страница не вернётся короче запрошенной / пустой.
|
||||
"""
|
||||
rows: list[dict[str, Any]] = []
|
||||
offset = 0
|
||||
total: int | None = None
|
||||
while True:
|
||||
payload = await sess.get_json(
|
||||
PATH_OBJECTS,
|
||||
{
|
||||
"offset": offset,
|
||||
"limit": OBJECTS_PAGE_SIZE,
|
||||
"sortField": "default",
|
||||
"sortType": "desc",
|
||||
"place": place,
|
||||
"objStatus": status,
|
||||
},
|
||||
)
|
||||
page = _extract_list(payload)
|
||||
if total is None:
|
||||
total = _extract_total(payload)
|
||||
rows.extend(page)
|
||||
# Стоп-условия: пустая страница, недобор до размера страницы (последняя),
|
||||
# либо набрали заявленный total. total может быть None (сервер его не отдал) —
|
||||
# тогда полагаемся на размер страницы как сигнал конца.
|
||||
if not page or len(page) < OBJECTS_PAGE_SIZE:
|
||||
break
|
||||
if total is not None and len(rows) >= total:
|
||||
break
|
||||
offset += OBJECTS_PAGE_SIZE
|
||||
|
||||
logger.info(
|
||||
"kn/object place=%s status=%d -> %d/%s rows (paginated, page=%d)",
|
||||
place,
|
||||
status,
|
||||
len(rows),
|
||||
total,
|
||||
OBJECTS_PAGE_SIZE,
|
||||
)
|
||||
rows = _extract_list(payload)
|
||||
total = _extract_total(payload)
|
||||
logger.info("kn/object place=%s status=%d -> %d/%s rows", place, status, len(rows), total)
|
||||
if total is not None and len(rows) < total:
|
||||
logger.warning(
|
||||
"kn/object place=%s status=%d: получено %d < total=%d — возможен недобор хвоста",
|
||||
place,
|
||||
status,
|
||||
len(rows),
|
||||
total,
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
async def fetch_flats_for_object(sess: BrowserSession, obj_id: int) -> list[dict[str, Any]]:
|
||||
"""Fetch flat-table for one object, return flat rows (entrance/floor flattened)."""
|
||||
try:
|
||||
payload = await sess.get_json(PATH_FLATS_TABLE, {"externalId": obj_id})
|
||||
except Exception as e:
|
||||
logger.warning("flats fetch obj=%s failed: %s", obj_id, e)
|
||||
return []
|
||||
"""Fetch flat-table for one object, return flat rows (entrance/floor flattened).
|
||||
|
||||
On HTTP / WAF / non-JSON errors raises — caller (_fetch_flats_safe) ловит и кладёт
|
||||
Exception в result-tuple, который result-loop отдаёт в _classify_and_log →
|
||||
kn_scrape_failures. Раньше try/except здесь возвращал [] на ЛЮБУЮ ошибку, из-за чего
|
||||
провал /portal/table (429, 5xx, WAF-challenge) не попадал в журнал отказов, а run
|
||||
рапортовал ложную полноту по квартирам (#1643). Поведение теперь как у остальных
|
||||
fetch_* endpoint'ов, которые исключения не глотают.
|
||||
"""
|
||||
payload = await sess.get_json(PATH_FLATS_TABLE, {"externalId": obj_id})
|
||||
# Body shape: {externalId, entrances: [{entranceNumber, floors:[{floorNumber, flats:[...]}]}]}
|
||||
return _flatten_table(payload)
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ _EKB_LAT_MAX = 57.5
|
|||
|
||||
# Паттерн для разбора первого числа из строки координат
|
||||
# (ячейки могут содержать несколько точек через пробелы или запятую-десятичный разделитель)
|
||||
_COORD_FIRST_RE = re.compile(r"[\d]+[.,][\d]+")
|
||||
_COORD_FIRST_RE = re.compile(r"[\d]+(?:[.,][\d]+)?")
|
||||
|
||||
|
||||
def msk66_to_wgs84(raw_x: str | None, raw_y: str | None) -> tuple[float, float] | None:
|
||||
|
|
@ -375,26 +375,28 @@ class EkburgPermitsClient:
|
|||
Пропускает листы «Справочник», «Лист1» и неизвестные.
|
||||
"""
|
||||
wb = load_workbook(BytesIO(content), read_only=True, data_only=True)
|
||||
try:
|
||||
for sheet_name in wb.sheetnames:
|
||||
if sheet_name.lower() in _SKIP_SHEETS:
|
||||
continue
|
||||
|
||||
for sheet_name in wb.sheetnames:
|
||||
if sheet_name.lower() in _SKIP_SHEETS:
|
||||
continue
|
||||
permit_type = _detect_permit_type(sheet_name)
|
||||
if permit_type is None:
|
||||
logger.debug("Skipping unknown sheet %r in year %d", sheet_name, year)
|
||||
continue
|
||||
|
||||
permit_type = _detect_permit_type(sheet_name)
|
||||
if permit_type is None:
|
||||
logger.debug("Skipping unknown sheet %r in year %d", sheet_name, year)
|
||||
continue
|
||||
|
||||
sheet = wb[sheet_name]
|
||||
data_start = _detect_header_row(sheet)
|
||||
logger.info(
|
||||
"Parsing sheet %r (%s) year=%d, data starts at row %d",
|
||||
sheet_name,
|
||||
permit_type,
|
||||
year,
|
||||
data_start,
|
||||
)
|
||||
yield from self._parse_sheet(sheet, permit_type, year, source_url, data_start)
|
||||
sheet = wb[sheet_name]
|
||||
data_start = _detect_header_row(sheet)
|
||||
logger.info(
|
||||
"Parsing sheet %r (%s) year=%d, data starts at row %d",
|
||||
sheet_name,
|
||||
permit_type,
|
||||
year,
|
||||
data_start,
|
||||
)
|
||||
yield from self._parse_sheet(sheet, permit_type, year, source_url, data_start)
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
def _parse_sheet(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -329,25 +329,37 @@ def denorm_dump(
|
|||
features: плоский list из features_json JSONB (уже декодированный Python list).
|
||||
|
||||
Returns:
|
||||
dict {"parcels": N, "buildings": M, "errors": K} — количество обработанных строк.
|
||||
dict {"parcels": N, "buildings": M, "errors": K, "skipped": S} —
|
||||
количество обработанных строк. ``errors`` — только реальные сбои UPSERT;
|
||||
``skipped`` — штатные пропуски feature без cad_num.
|
||||
"""
|
||||
snapshot_date = datetime.date.today().isoformat()
|
||||
parcels_n = 0
|
||||
buildings_n = 0
|
||||
errors_n = 0
|
||||
skipped_n = 0
|
||||
|
||||
for feat in features:
|
||||
layer = feat.get("layer", "")
|
||||
try:
|
||||
if layer == "parcels":
|
||||
if denorm_parcel_feature(
|
||||
# denorm_parcel_feature возвращает False и при штатном пропуске
|
||||
# (нет cad_num), и при реальном сбое UPSERT — различаем их здесь,
|
||||
# чтобы не завышать error-счётчик прогона.
|
||||
props = feat.get("properties") or {}
|
||||
if not (props.get("cad_num") or props.get("cadastral_number")):
|
||||
skipped_n += 1
|
||||
elif denorm_parcel_feature(
|
||||
db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date
|
||||
):
|
||||
parcels_n += 1
|
||||
else:
|
||||
errors_n += 1
|
||||
elif layer == "buildings":
|
||||
if denorm_building_feature(
|
||||
props = feat.get("properties") or {}
|
||||
if not (props.get("cad_num") or props.get("cadastral_number")):
|
||||
skipped_n += 1
|
||||
elif denorm_building_feature(
|
||||
db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date
|
||||
):
|
||||
buildings_n += 1
|
||||
|
|
@ -360,10 +372,16 @@ def denorm_dump(
|
|||
|
||||
db.commit()
|
||||
logger.info(
|
||||
"denorm_dump quarter=%s parcels=%d buildings=%d errors=%d",
|
||||
"denorm_dump quarter=%s parcels=%d buildings=%d errors=%d skipped=%d",
|
||||
quarter_cad,
|
||||
parcels_n,
|
||||
buildings_n,
|
||||
errors_n,
|
||||
skipped_n,
|
||||
)
|
||||
return {"parcels": parcels_n, "buildings": buildings_n, "errors": errors_n}
|
||||
return {
|
||||
"parcels": parcels_n,
|
||||
"buildings": buildings_n,
|
||||
"errors": errors_n,
|
||||
"skipped": skipped_n,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,17 @@ def fetch_geoportal(
|
|||
try:
|
||||
with urllib.request.urlopen(req, context=_SSL_CTX, timeout=timeout) as r:
|
||||
body = r.read().decode("utf-8", "ignore")
|
||||
return json.loads(body)
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError as e:
|
||||
# HTTP 200, но тело — не JSON. Это WAF/прокси-челлендж (HTML или
|
||||
# пустое тело) с кодом 200 вместо 403/429. Та же transient-ситуация,
|
||||
# что и явный WAF → NspdLiteWafError, чтобы caller сделал backoff
|
||||
# (harvest_quarter autoretry_for=(NspdLiteWafError,)), а не пометил
|
||||
# строку постоянным harvest_error.
|
||||
raise NspdLiteWafError(
|
||||
f"HTTP 200 but non-JSON body (WAF challenge?): {body[:300]}"
|
||||
) from e
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", "ignore")[:300] if e.fp else ""
|
||||
if e.code in (403, 429):
|
||||
|
|
@ -162,6 +172,12 @@ def fetch_via_rosreestr2coord(
|
|||
_ = delay # silence unused — см. docstring выше
|
||||
try:
|
||||
from rosreestr2coord.parser import Area
|
||||
from rosreestr2coord.request.exceptions import (
|
||||
HTTPErrorException,
|
||||
HTTPForbiddenException,
|
||||
RequestException,
|
||||
TimeoutException,
|
||||
)
|
||||
except ImportError as e:
|
||||
raise NspdLiteError(f"rosreestr2coord не установлен (uv add rosreestr2coord): {e}") from e
|
||||
|
||||
|
|
@ -170,19 +186,33 @@ def fetch_via_rosreestr2coord(
|
|||
# denied при первом fetch.
|
||||
# Фикс: use_cache=False + media_path=/tmp/rosreestr2coord (writable для всех).
|
||||
# Кеш нам не нужен — каждый cad_num уникален, обращаемся раз.
|
||||
a = Area(
|
||||
code=cad_num,
|
||||
area_type=area_type,
|
||||
timeout=timeout,
|
||||
with_log=False,
|
||||
use_cache=False,
|
||||
media_path="/tmp/rosreestr2coord",
|
||||
)
|
||||
#
|
||||
# NB: with_log=False → конструктор Area() сразу делает HTTP-запрос
|
||||
# (get_geometry без try/except), поэтому WAF/сетевые исключения летят уже
|
||||
# отсюда, а не из to_geojson_poly(). Оба вызова под одним try.
|
||||
try:
|
||||
a = Area(
|
||||
code=cad_num,
|
||||
area_type=area_type,
|
||||
timeout=timeout,
|
||||
with_log=False,
|
||||
use_cache=False,
|
||||
media_path="/tmp/rosreestr2coord",
|
||||
)
|
||||
# dumps=False — возвращает dict (GeoJSON Feature), а не JSON-сериализованную
|
||||
# строку. Default в v5 = True → строка → крах в `_persist_target` который
|
||||
# ожидает dict с `.get("properties")` etc.
|
||||
return a.to_geojson_poly(dumps=False)
|
||||
except Exception as e:
|
||||
logger.warning("rosreestr2coord failed for %s: %s", cad_num, e)
|
||||
return None
|
||||
except (HTTPForbiddenException, HTTPErrorException, TimeoutException) as e:
|
||||
# WAF/rate-limit (HTTP 403 → HTTPForbiddenException, 429/прочие HTTP →
|
||||
# HTTPErrorException) и таймауты — transient. Поднимаем NspdLiteWafError,
|
||||
# чтобы воркер process_nspd_geo_job сделал backoff (exponential, инкремент
|
||||
# waf_blocked_count, пауза после серии WAF), а не пометил цель 'done' с 0
|
||||
# features (false run-status). См. nspd_geo.py:464.
|
||||
raise NspdLiteWafError(f"rosreestr2coord WAF/transient for {cad_num}: {e}") from e
|
||||
except RequestException as e:
|
||||
# Прочие ошибки запроса (RequestException, в т.ч. is_error_response с
|
||||
# сообщением об ошибке от NSPD) — не отличить от transient WAF надёжно,
|
||||
# но это НЕ легитимное 'участок не найден' (то возвращает feature=None →
|
||||
# None без исключения). Классифицируем как ошибку, не как пустой результат.
|
||||
raise NspdLiteError(f"rosreestr2coord request failed for {cad_num}: {e}") from e
|
||||
|
|
|
|||
|
|
@ -69,6 +69,35 @@ _CHECK_TYPE_ALIASES: dict[str, list[str]] = {
|
|||
"declaration": ["declaration", "hasDeclaration", "declarationFlg"],
|
||||
}
|
||||
|
||||
# Строковые флаги, которые сторонний API может прислать вместо bool.
|
||||
# Схема payload не верифицирована (см. docstring), поэтому приводим явно.
|
||||
_TRUE_STRINGS = {"true", "1", "yes", "y", "да", "passed", "ok"}
|
||||
_FALSE_STRINGS = {"false", "0", "no", "n", "нет", "failed", "not_passed"}
|
||||
|
||||
|
||||
def _coerce_flag(value: Any) -> bool | None:
|
||||
"""Привести значение флага проверки к bool либо None (UNKNOWN).
|
||||
|
||||
bool(value) ломается на строках ('false'/'0'/'нет' → True) и не отличает
|
||||
отсутствие данных от False. Возвращаем None, если значение нераспознаваемо —
|
||||
вызывающий код НЕ должен фабриковать False для UNKNOWN.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
s = value.strip().lower()
|
||||
if s in _TRUE_STRINGS:
|
||||
return True
|
||||
if s in _FALSE_STRINGS:
|
||||
return False
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
_UPSERT_CHECKS_SQL = text(
|
||||
"""
|
||||
INSERT INTO domrf_obj_checks (obj_id, check_type, passed, checked_at, scraped_at)
|
||||
|
|
@ -106,14 +135,21 @@ def extract_obj_checks(raw_payload: Any) -> list[dict[str, Any]]:
|
|||
for field, value in data.items():
|
||||
ct = _CHECK_FIELD_MAP.get(field)
|
||||
if ct and ct not in found:
|
||||
found[ct] = bool(value)
|
||||
flag = _coerce_flag(value)
|
||||
if flag is not None:
|
||||
found[ct] = flag
|
||||
# Также проверить canonical names напрямую
|
||||
for ct in CHECK_TYPES:
|
||||
if ct not in found and ct in data:
|
||||
found[ct] = bool(data[ct])
|
||||
flag = _coerce_flag(data[ct])
|
||||
if flag is not None:
|
||||
found[ct] = flag
|
||||
if found:
|
||||
# Только фактически найденные флаги: отсутствие в payload = UNKNOWN,
|
||||
# а не FAILED — не фабрикуем passed=False для непришедших проверок.
|
||||
for ct in CHECK_TYPES:
|
||||
results.append({"check_type": ct, "passed": found.get(ct, False)})
|
||||
if ct in found:
|
||||
results.append({"check_type": ct, "passed": found[ct]})
|
||||
return results
|
||||
# dict не содержит известных полей — попробуем как list-формат ниже
|
||||
logger.warning(
|
||||
|
|
@ -128,11 +164,24 @@ def extract_obj_checks(raw_payload: Any) -> list[dict[str, Any]]:
|
|||
continue
|
||||
ct_raw = item.get("checkType") or item.get("check_type") or item.get("type")
|
||||
if ct_raw and str(ct_raw) in CHECK_TYPES:
|
||||
passed_raw = item.get("passed") or item.get("value") or item.get("status")
|
||||
found_list[str(ct_raw)] = bool(passed_raw)
|
||||
# Не or-коалесинг: легитимный False теряется (False or 'n/a' → 'n/a').
|
||||
# Берём первый ключ, который реально присутствует в item.
|
||||
if "passed" in item:
|
||||
passed_raw = item["passed"]
|
||||
elif "value" in item:
|
||||
passed_raw = item["value"]
|
||||
elif "status" in item:
|
||||
passed_raw = item["status"]
|
||||
else:
|
||||
passed_raw = None
|
||||
flag = _coerce_flag(passed_raw)
|
||||
if flag is not None:
|
||||
found_list[str(ct_raw)] = flag
|
||||
if found_list:
|
||||
# См. dict-ветку: только найденные флаги, UNKNOWN не равно FAILED.
|
||||
for ct in CHECK_TYPES:
|
||||
results.append({"check_type": ct, "passed": found_list.get(ct, False)})
|
||||
if ct in found_list:
|
||||
results.append({"check_type": ct, "passed": found_list[ct]})
|
||||
return results
|
||||
logger.warning(
|
||||
"domrf obj_checks: list payload has no recognisable check items: %s", data[:3]
|
||||
|
|
|
|||
|
|
@ -227,13 +227,14 @@ class BrowserSession:
|
|||
"""
|
||||
if self._context is None:
|
||||
raise RuntimeError("BrowserSession not bootstrapped")
|
||||
await jitter_sleep(200, 500) # Lighter throttle for static assets.
|
||||
self._request_count += 1
|
||||
resp = await self._context.request.get(
|
||||
url,
|
||||
headers={"Authorization": self.auth} if self.auth else {},
|
||||
)
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
raise RuntimeError(f"binary http {resp.status}: {body[:200]}")
|
||||
return await resp.body()
|
||||
async with self._sem:
|
||||
await jitter_sleep(200, 500) # Lighter throttle for static assets.
|
||||
self._request_count += 1
|
||||
resp = await self._context.request.get(
|
||||
url,
|
||||
headers={"Authorization": self.auth} if self.auth else {},
|
||||
)
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
raise RuntimeError(f"binary http {resp.status}: {body[:200]}")
|
||||
return await resp.body()
|
||||
|
|
|
|||
|
|
@ -488,17 +488,82 @@ _AVG_PRICE_SQL = text("""
|
|||
# Additive-контракт: возвращаем ТОЛЬКО obj_id, у которых objective-цена есть; в Python
|
||||
# заполняем пробелы (domrf-цена приоритетна, objective — fallback). Существующие
|
||||
# непустые domrf-выводы НЕ меняются. price_source делает источник прозрачным.
|
||||
#
|
||||
# #1615: velocity обогащается из ДВУХ источников (см. _COMPETITORS_SQL mapped CTE) —
|
||||
# явного objective_complex_mapping И спатиально-именного nearest_cx gap-fill. Ценовой
|
||||
# fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает
|
||||
# avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots:
|
||||
# PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name
|
||||
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_id →
|
||||
# objective_lots по complex_id (тот же DISTINCT ON ближайший complex, что и
|
||||
# velocity gap-fill — обязан совпадать, чтобы цена и velocity были про ОДИН ЖК).
|
||||
# obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ
|
||||
# mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен.
|
||||
_OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
||||
WITH primary_price AS (
|
||||
SELECT
|
||||
cm.domrf_obj_id AS obj_id,
|
||||
ol.price_per_m2_rub AS price_per_m2_rub
|
||||
FROM objective_complex_mapping cm
|
||||
JOIN objective_lots ol
|
||||
ON ol.project_name = cm.objective_complex_name
|
||||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||||
AND ol.price_per_m2_rub IS NOT NULL
|
||||
),
|
||||
nearest_cx AS (
|
||||
SELECT DISTINCT ON (o.obj_id)
|
||||
o.obj_id,
|
||||
c.id AS complex_id
|
||||
FROM domrf_kn_objects o
|
||||
JOIN complexes c
|
||||
ON c.latitude IS NOT NULL
|
||||
AND c.longitude IS NOT NULL
|
||||
AND c.canonical_name IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM objective_lots ol
|
||||
WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL
|
||||
)
|
||||
AND ST_DWithin(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
|
||||
CAST(:velocity_match_radius_m AS float)
|
||||
)
|
||||
AND (
|
||||
lower(btrim(o.comm_name)) = lower(btrim(c.canonical_name))
|
||||
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
|
||||
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
|
||||
)
|
||||
WHERE o.obj_id = ANY(:obj_ids)
|
||||
AND o.latitude IS NOT NULL
|
||||
AND o.longitude IS NOT NULL
|
||||
AND o.comm_name IS NOT NULL
|
||||
AND btrim(o.comm_name) <> ''
|
||||
AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping)
|
||||
ORDER BY o.obj_id,
|
||||
ST_Distance(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
|
||||
) ASC
|
||||
),
|
||||
gapfill_price AS (
|
||||
SELECT
|
||||
nc.obj_id AS obj_id,
|
||||
ol.price_per_m2_rub AS price_per_m2_rub
|
||||
FROM nearest_cx nc
|
||||
JOIN objective_lots ol
|
||||
ON ol.complex_id = nc.complex_id
|
||||
AND ol.price_per_m2_rub IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
cm.domrf_obj_id AS obj_id,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub)
|
||||
p.obj_id,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY p.price_per_m2_rub)
|
||||
AS median_price_per_m2
|
||||
FROM objective_complex_mapping cm
|
||||
JOIN objective_lots ol
|
||||
ON ol.project_name = cm.objective_complex_name
|
||||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||||
AND ol.price_per_m2_rub IS NOT NULL
|
||||
GROUP BY cm.domrf_obj_id
|
||||
FROM (
|
||||
SELECT obj_id, price_per_m2_rub FROM primary_price
|
||||
UNION ALL
|
||||
SELECT obj_id, price_per_m2_rub FROM gapfill_price
|
||||
) p
|
||||
GROUP BY p.obj_id
|
||||
""")
|
||||
|
||||
|
||||
|
|
@ -648,7 +713,13 @@ def get_competitors(
|
|||
if missing_price_ids:
|
||||
try:
|
||||
obj_price_rows = (
|
||||
db.execute(_OBJECTIVE_PRICE_FALLBACK_SQL, {"obj_ids": missing_price_ids})
|
||||
db.execute(
|
||||
_OBJECTIVE_PRICE_FALLBACK_SQL,
|
||||
{
|
||||
"obj_ids": missing_price_ids,
|
||||
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
|
|||
Returns:
|
||||
list[{project_type, doc_status_name, is_active, paga_number, paga_date,
|
||||
subject, doc_full_name}].
|
||||
- ``is_active``: doc_status_name == 'Действующий'.
|
||||
- ``is_active``: doc_status_name == 'действующий' (регистронезависимо).
|
||||
- ``paga_number`` / ``paga_date``: regex из full_name; None если не распарсилось.
|
||||
- ``subject``: текст full_name после строки с ПАГЕ-реквизитами; None если нет.
|
||||
Отсортировано: сначала действующие, внутри группы — по paga_date desc (свежие первыми).
|
||||
|
|
@ -97,7 +97,10 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
|
|||
for r in rows:
|
||||
full_name: str | None = r["full_name"]
|
||||
paga_number, paga_date, subject = _parse_paga(full_name)
|
||||
is_active = r["doc_status_name"] == "Действующий"
|
||||
status_name: str | None = r["doc_status_name"]
|
||||
# Регистронезависимо: harvest пишет doc_status_name из WFS без нормализации,
|
||||
# DDL/COMMENT документируют хранимое значение как lowercase ('действующий').
|
||||
is_active = (status_name or "").strip().lower() == "действующий"
|
||||
result.append(
|
||||
{
|
||||
"project_type": r["project_type"],
|
||||
|
|
|
|||
|
|
@ -10,8 +10,14 @@
|
|||
|
||||
Прямой FK не ставим: doc_ref пишется парсером по PDF-источнику (slug/seed), а в WFS
|
||||
source_key — целое и не совпадает форматом. Сопоставление — две дешёвые ветки:
|
||||
1) ``doc_ref = source_key::text`` (прямое равенство);
|
||||
2) ``doc_ref ILIKE '%doc_full_name%'`` (fallback для slug-ов вида ppt2018_22823).
|
||||
1) ``doc_ref = CAST(source_key AS text)`` (прямое равенство);
|
||||
2) ``doc_full_name ILIKE '%' || doc_ref || '%'`` (fallback для slug-ов вида
|
||||
ppt2018_22823: длинное WFS-описание содержит короткий slug парсера; LIKE-метасимволы
|
||||
в doc_ref экранируются ESCAPE '\').
|
||||
|
||||
Дедуп: best-effort OR-JOIN может зацепить несколько строк ``ekb_ppt_tep`` на один
|
||||
``planning_projects`` (точная ветка + ILIKE-fallback). ``DISTINCT ON (source_key)``
|
||||
держит контракт «одна строка = один ППТ/ПМТ-документ», отдавая приоритет точной ветке.
|
||||
|
||||
Граф вызова: build_ird_analyze_block → parcel_ppt_tep → JOIN planning_projects ∩ ekb_ppt_tep.
|
||||
Graceful: нет таблицы / пустой WKT / БД-ошибка → []. Зеркалит стиль ``planning_lookup.py``.
|
||||
|
|
@ -33,26 +39,49 @@ logger = logging.getLogger(__name__)
|
|||
# ST_Intersects через GIST idx_planning_projects_geom (4326 ↔ WKT 4326).
|
||||
# CAST psycopg v3 — никогда :param::type (vault Pattern_CAST_AS_Type).
|
||||
_PPT_TEP_OVERLAP_SQL = text(
|
||||
"""
|
||||
r"""
|
||||
SELECT
|
||||
pp.project_type,
|
||||
pp.doc_status_name,
|
||||
pp.full_name,
|
||||
pp.doc_full_name,
|
||||
pp.source_key,
|
||||
t.doc_ref,
|
||||
t.zone_balance,
|
||||
t.tep,
|
||||
t.phasing,
|
||||
t.source_pdf,
|
||||
t.fetched_at
|
||||
FROM planning_projects pp
|
||||
JOIN ekb_ppt_tep t ON (
|
||||
t.doc_ref = CAST(pp.source_key AS text)
|
||||
OR (pp.doc_full_name IS NOT NULL AND t.doc_ref ILIKE '%' || pp.doc_full_name || '%')
|
||||
)
|
||||
WHERE ST_Intersects(pp.geom, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326))
|
||||
ORDER BY pp.dmd_actual_year DESC NULLS LAST, pp.project_type
|
||||
project_type,
|
||||
doc_status_name,
|
||||
full_name,
|
||||
doc_full_name,
|
||||
source_key,
|
||||
doc_ref,
|
||||
zone_balance,
|
||||
tep,
|
||||
phasing,
|
||||
source_pdf,
|
||||
fetched_at
|
||||
FROM (
|
||||
-- DISTINCT ON (source_key): один ППТ/ПМТ-документ = одна строка (контракт docstring).
|
||||
-- Точная ветка (doc_ref = source_key) приоритетна над slug-fallback (ILIKE).
|
||||
SELECT DISTINCT ON (pp.source_key)
|
||||
pp.project_type,
|
||||
pp.doc_status_name,
|
||||
pp.full_name,
|
||||
pp.doc_full_name,
|
||||
pp.source_key,
|
||||
pp.dmd_actual_year,
|
||||
t.doc_ref,
|
||||
t.zone_balance,
|
||||
t.tep,
|
||||
t.phasing,
|
||||
t.source_pdf,
|
||||
t.fetched_at
|
||||
FROM planning_projects pp
|
||||
JOIN ekb_ppt_tep t ON (
|
||||
t.doc_ref = CAST(pp.source_key AS text)
|
||||
OR (
|
||||
pp.doc_full_name IS NOT NULL
|
||||
AND pp.doc_full_name ILIKE
|
||||
'%' || replace(replace(replace(t.doc_ref, '\', '\\'), '%', '\%'), '_', '\_')
|
||||
|| '%' ESCAPE '\'
|
||||
)
|
||||
)
|
||||
WHERE ST_Intersects(pp.geom, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326))
|
||||
ORDER BY pp.source_key, (t.doc_ref = CAST(pp.source_key AS text)) DESC
|
||||
) deduped
|
||||
ORDER BY dmd_actual_year DESC NULLS LAST, project_type
|
||||
"""
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -74,13 +74,16 @@ class BuildingMatch:
|
|||
|
||||
|
||||
# Geom-match domrf-центроида (lat/lon) → ближайшее здание cad_buildings (#96).
|
||||
# Один LATERAL KNN: GIST `cad_buildings_geom_gist` (`geom <-> point`) выбирает
|
||||
# ОДНОГО ближайшего кандидата (~19ms прод, EXPLAIN ANALYZE), затем фильтруем по
|
||||
# фактической дистанции в метрах (geography ST_Distance — KNN-оператор `<->` на
|
||||
# geometry SRID 4326 сортирует в ГРАДУСАХ, что для малых расстояний монотонно
|
||||
# дистанции, но сама величина в градусах — поэтому метры считаем отдельно). Точка
|
||||
# внутри площадного footprint даёт ST_Distance=0 (ST_Contains-эквивалент, см.
|
||||
# прод: 198 объектов внутри). psycopg v3: CAST(:x AS double precision) — НЕ ::type.
|
||||
# Один KNN по geography: GIST `idx_cad_buildings_geom_geog`
|
||||
# (`geom::geography <-> point::geography`) выбирает ОДНОГО ближайшего кандидата по
|
||||
# ФАКТИЧЕСКИМ МЕТРАМ (geography `<->` = сфероидное расстояние в метрах, не градусы).
|
||||
# Важно: KNN на geometry SRID 4326 (`geom <-> point`) сортирует в ГРАДУСАХ, а на
|
||||
# широте ЕКБ (~56.8°N) долгота сжата ×1.83 → degree-порядок ≠ meter-порядок для
|
||||
# кандидатов в разных направлениях, и LIMIT 1 мог взять не ближайший по метрам дом
|
||||
# (parking_ratio чужого здания). Поэтому ранжируем и фильтруем строго в метрах.
|
||||
# distance_m считаем тем же geography ST_Distance: точка внутри площадного footprint
|
||||
# даёт 0 (ST_Contains-эквивалент, прод: 198 объектов внутри).
|
||||
# psycopg v3: CAST(:x AS double precision) — НЕ ::type.
|
||||
_RESOLVE_CAD_SQL = text("""
|
||||
SELECT
|
||||
b.cad_num,
|
||||
|
|
@ -97,13 +100,13 @@ _RESOLVE_CAD_SQL = text("""
|
|||
) AS distance_m
|
||||
FROM cad_buildings b
|
||||
WHERE b.geom IS NOT NULL
|
||||
ORDER BY b.geom <-> ST_SetSRID(
|
||||
ORDER BY b.geom::geography <-> ST_SetSRID(
|
||||
ST_MakePoint(
|
||||
CAST(:lon AS double precision),
|
||||
CAST(:lat AS double precision)
|
||||
),
|
||||
4326
|
||||
)
|
||||
)::geography
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
|
|
@ -119,8 +122,9 @@ def resolve_cad_for_domrf(
|
|||
|
||||
Мост через PostGIS: domrf_kn_objects хранят только obj_id + lat/lon (без
|
||||
cad_num), а premises_lookup нужен cad_num ЗДАНИЯ. Берём ближайшее здание по
|
||||
GIST-ускоренному KNN (`geom <-> point`) и принимаем матч, если расстояние до
|
||||
footprint ≤ max_dist_m. Точка внутри площадного footprint → distance 0.
|
||||
GIST-ускоренному KNN в МЕТРАХ (`geom::geography <-> point::geography`) и
|
||||
принимаем матч, если расстояние до footprint ≤ max_dist_m. Точка внутри
|
||||
площадного footprint → distance 0.
|
||||
|
||||
Returns:
|
||||
BuildingMatch (cad_num + objdoc_id + distance_m) ближайшего здания в
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from __future__ import annotations
|
|||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.exc import DatabaseError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -41,8 +41,11 @@ def _refresh_mv(db: Session, mv_name: str, *, concurrently: bool) -> None:
|
|||
else:
|
||||
db.execute(text(f"REFRESH MATERIALIZED VIEW {mv_name}"))
|
||||
db.commit()
|
||||
except OperationalError as e:
|
||||
if concurrently and "cannot refresh materialized view" in str(e).lower():
|
||||
except DatabaseError as e:
|
||||
# PostgreSQL emits "CONCURRENTLY cannot be used when the materialized
|
||||
# view ... is not populated" (matview.c, SQLSTATE 55000), which psycopg3
|
||||
# surfaces as InternalError (a DatabaseError sibling of OperationalError).
|
||||
if concurrently and "concurrently cannot be used" in str(e).lower():
|
||||
logger.warning(
|
||||
"%s: CONCURRENTLY failed (MV likely not populated), "
|
||||
"falling back to non-concurrent refresh",
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ _RE_FLOORS = re.compile(
|
|||
+ _DASH
|
||||
+ r"\s*"
|
||||
+ _NUM
|
||||
+ r"\s*этаж",
|
||||
+ r"(?:\s*этаж\w*)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# высота — 'предельная высота ... – 25 м' (в ЕКБ почти всегда «не подлежат», но для прочих МО)
|
||||
|
|
|
|||
|
|
@ -130,12 +130,15 @@ def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | None:
|
|||
if not daily.get("time"):
|
||||
return None
|
||||
|
||||
t_max = daily.get("temperature_2m_max") or []
|
||||
t_min = daily.get("temperature_2m_min") or []
|
||||
precip = daily.get("precipitation_sum") or []
|
||||
uv = daily.get("uv_index_max") or []
|
||||
# Open-Meteo штатно возвращает null для отдельных дней (uv_index_max и др.)
|
||||
# при непустом daily.time — отфильтровываем None ПЕРЕД min/max/sum, иначе
|
||||
# TypeError в Python 3.12 уронит весь 7-day forecast в negative-cache (#1577).
|
||||
t_max = [v for v in (daily.get("temperature_2m_max") or []) if v is not None]
|
||||
t_min = [v for v in (daily.get("temperature_2m_min") or []) if v is not None]
|
||||
precip = [v for v in (daily.get("precipitation_sum") or []) if v is not None]
|
||||
uv = [v for v in (daily.get("uv_index_max") or []) if v is not None]
|
||||
wind_d = daily.get("winddirection_10m_dominant") or []
|
||||
wind_s = daily.get("windspeed_10m_max") or []
|
||||
wind_s = [v for v in (daily.get("windspeed_10m_max") or []) if v is not None]
|
||||
|
||||
# Circular mean направления ветра (vector sum) — избегает jump 359→1
|
||||
x = sum(math.cos(math.radians(d)) for d in wind_d if d is not None)
|
||||
|
|
@ -226,13 +229,21 @@ def _fetch_seasonal_remote(lat: float, lon: float) -> dict[str, Any] | None:
|
|||
if not vals["t_max"]:
|
||||
seasons[season] = None
|
||||
continue
|
||||
# t_min/precip накапливаются НЕЗАВИСИМО от t_max (раздельные None-guard'ы
|
||||
# выше) — при непустом t_max и all-null t_min/precip эти списки пусты, и
|
||||
# sum()/len() даст ZeroDivisionError, min([]) — ValueError (#1578). Метрики
|
||||
# по пустому списку → None вместо падения всего сезонного ответа.
|
||||
t_min = vals["t_min"]
|
||||
precip = vals["precip"]
|
||||
seasons[season] = {
|
||||
"avg_t_max_c": round(sum(vals["t_max"]) / len(vals["t_max"]), 1),
|
||||
"avg_t_min_c": round(sum(vals["t_min"]) / len(vals["t_min"]), 1),
|
||||
"avg_t_min_c": round(sum(t_min) / len(t_min), 1) if t_min else None,
|
||||
"max_t_c": round(max(vals["t_max"]), 1),
|
||||
"min_t_c": round(min(vals["t_min"]), 1),
|
||||
"avg_precip_per_day_mm": round(sum(vals["precip"]) / len(vals["precip"]), 1),
|
||||
"total_precip_mm": round(sum(vals["precip"]), 0),
|
||||
"min_t_c": round(min(t_min), 1) if t_min else None,
|
||||
"avg_precip_per_day_mm": (
|
||||
round(sum(precip) / len(precip), 1) if precip else None
|
||||
),
|
||||
"total_precip_mm": round(sum(precip), 0) if precip else 0,
|
||||
"days_observed": len(vals["t_max"]),
|
||||
}
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -506,4 +506,20 @@ def build_beat_schedule() -> dict:
|
|||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# Refresh mv_layout_velocity (#1666) — питает «лучшие планировки» best_layouts (#113).
|
||||
# До этого MV рефрешился ТОЛЬКО ручным вызовом refresh_layout_velocity() → в проде
|
||||
# данные молча устаревали. REFRESH ... CONCURRENTLY (non-blocking) по mv_layout_velocity.
|
||||
#
|
||||
# Воскресенье 03:00 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233).
|
||||
# Намеренно ВНЕ monday-кластера тяжёлых site_finder-рефрешей (scrape_kn 04:15,
|
||||
# ird 05:00, gknspecial 05:30, supply-layers 06:00, genplan 06:30, location 07:00) и
|
||||
# вне воскресного okn-objects (04:30) — час запаса, не конкурирует за БД/CPU.
|
||||
# MV агрегирует темпы вымывания планировок (меняются медленно) → еженедельно хватает.
|
||||
# Техническая infra-задача, не в job_settings (как refresh-quarter-price-index / supply-layers).
|
||||
schedule["refresh-layout-velocity"] = {
|
||||
"task": "tasks.refresh_layout_velocity.refresh_layout_velocity",
|
||||
"schedule": _parse_cron("0 3 * * sun"), # 03:00 MSK, воскресенье
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
return schedule
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ celery_app = Celery(
|
|||
"app.workers.tasks.ekburg_permits_sync",
|
||||
"app.workers.tasks.cbr_macro_sync",
|
||||
"app.workers.tasks.rosstat_macro_sync",
|
||||
"app.workers.tasks.refresh_quarter_price_index",
|
||||
"app.workers.tasks.etl_newbuilding_crossload",
|
||||
"app.workers.tasks.supply_layers_refresh",
|
||||
"app.workers.tasks.location_refresh",
|
||||
"app.workers.tasks.forecast",
|
||||
|
|
@ -79,6 +81,7 @@ celery_app = Celery(
|
|||
"app.workers.tasks.pat_subzones_load",
|
||||
"app.workers.tasks.izyatie_ocr_ingest",
|
||||
"app.workers.tasks.developer_registry_refresh",
|
||||
"app.workers.tasks.refresh_layout_velocity",
|
||||
],
|
||||
)
|
||||
celery_app.conf.timezone = "Europe/Moscow"
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ Handlers регистрируются через Celery signals декорато
|
|||
worker_process_init — dispose SQLAlchemy engine в каждом prefork child-процессе,
|
||||
чтобы избежать shared TCP-сокетов к PostgreSQL после fork().
|
||||
|
||||
worker_ready — при рестарте воркера находит все 'running'/'paused' записи
|
||||
worker_ready — при рестарте воркера находит 'running'/'paused' записи
|
||||
kn_scrape_runs и nspd_geo_jobs и re-enqueue'ит их как zombie-resume.
|
||||
Для nspd_geo 'paused' (WAF-пауза) применяется 30-минутный cooldown
|
||||
(_ZOMBIE_PAUSED_THRESHOLD), чтобы редеплой не аннулировал WAF-защиту.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
|
@ -147,10 +149,18 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
|
|||
# старых runs сохранена в nspd_scrape_runs — никаких side-effects.
|
||||
|
||||
# NSPD geo-jobs: bulk-fetcher с собственной resume-логикой через
|
||||
# nspd_geo_jobs / nspd_geo_targets. Resume любых 'running' / 'paused' jobs
|
||||
# — на worker_ready по определению нет активных воркеров, всё running ==
|
||||
# nspd_geo_jobs / nspd_geo_targets. Resume любых 'running' jobs — на
|
||||
# worker_ready по определению нет активных воркеров, всё running ==
|
||||
# zombie. Раньше требовали heartbeat >10мин, что пропускало jobs убитых
|
||||
# за минуту до редеплоя и оставляло их вечно висеть.
|
||||
#
|
||||
# 'paused' (consecutive_waf>=8 — NSPD-WAF забанил IP VPS) НЕ ре-enqueue'им
|
||||
# безусловно: иначе каждый рестарт/редеплой воркера мгновенно аннулировал бы
|
||||
# WAF-cooldown и worker снова долбил бы забаненный сервис. Применяем тот же
|
||||
# 30-минутный порог, что и периодический cleanup_zombies
|
||||
# (_ZOMBIE_PAUSED_THRESHOLD), переиспользуя константу чтобы избежать дрейфа.
|
||||
from app.workers.tasks.nspd_geo import _ZOMBIE_PAUSED_THRESHOLD
|
||||
|
||||
db = SessionLocal()
|
||||
geo_resume_jobs: list[int] = []
|
||||
try:
|
||||
|
|
@ -161,10 +171,16 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
|
|||
UPDATE nspd_geo_jobs
|
||||
SET status = 'queued',
|
||||
error = COALESCE(error, 'auto-resume at worker_ready')
|
||||
WHERE status IN ('running', 'paused')
|
||||
WHERE status = 'running'
|
||||
OR (
|
||||
status = 'paused'
|
||||
AND heartbeat_at
|
||||
< NOW() - CAST(:paused_threshold AS interval)
|
||||
)
|
||||
RETURNING job_id
|
||||
"""
|
||||
)
|
||||
),
|
||||
{"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
|
|
|
|||
|
|
@ -51,6 +51,16 @@ WAF_BACKOFF_BASE_S = 30
|
|||
# нужен большой cooldown, иначе минута beat сразу аннулирует WAF-паузу.
|
||||
_ZOMBIE_RUNNING_THRESHOLD = "6 minutes"
|
||||
_ZOMBIE_PAUSED_THRESHOLD = "30 minutes"
|
||||
# Issue #1655: jobs, застрявшие в 'queued' после ре-enqueue (worker_ready /
|
||||
# cleanup_zombies ставят 'queued' + apply_async), но чьё broker-сообщение
|
||||
# потерялось (flush брокера, crash до pickup, purge очереди) — раньше никто
|
||||
# не ре-reap'ил. cleanup_zombies матчил только 'running'/'paused'.
|
||||
# Матчим только 'queued' c НЕ-NULL stale heartbeat_at: свежесозданный job
|
||||
# (enqueue_geo_job) имеет heartbeat_at IS NULL (нет дефолта в схеме 77_) — его
|
||||
# initial apply_async ещё в полёте, ре-reap'ить рано. Порог = running-порог
|
||||
# (6 мин): сообщение либо взято воркером (→ status станет 'running' через claim),
|
||||
# либо потеряно — 6 мин достаточно, чтобы отличить потерю от задержки очереди.
|
||||
_ZOMBIE_QUEUED_THRESHOLD = "6 minutes"
|
||||
|
||||
|
||||
# ── Helpers для job/target lifecycle ────────────────────────────────────────
|
||||
|
|
@ -79,8 +89,19 @@ def _log(
|
|||
pass
|
||||
|
||||
|
||||
def _start_job(db: Session, job_id: int) -> None:
|
||||
db.execute(
|
||||
def _start_job(db: Session, job_id: int) -> bool:
|
||||
"""Атомарный claim job'а: переводит в 'running' ТОЛЬКО если он ещё не 'running'.
|
||||
|
||||
Issue #1621: дублирующие task-сообщения на один job_id (worker_ready resume +
|
||||
cleanup_zombies beat одновременно, либо overlap контейнеров при rolling-redeploy,
|
||||
либо два prefork-child при --concurrency>1) раньше оба безусловно делали
|
||||
UPDATE→'running' и оба входили в while-loop → двойные WAF-хиты + затёртые счётчики.
|
||||
|
||||
Теперь claim атомарен: `WHERE job_id=:id AND status <> 'running'` + RETURNING.
|
||||
Если строка не вернулась — кто-то уже держит claim, второй worker должен выйти.
|
||||
Returns True если claim получен, False если job уже 'running' (или не найден).
|
||||
"""
|
||||
claimed = db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE nspd_geo_jobs
|
||||
|
|
@ -89,12 +110,17 @@ def _start_job(db: Session, job_id: int) -> None:
|
|||
heartbeat_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE job_id = :id
|
||||
AND status <> 'running'
|
||||
RETURNING job_id
|
||||
"""
|
||||
),
|
||||
{"id": job_id},
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
db.commit()
|
||||
if claimed is None:
|
||||
return False
|
||||
_log(db, job_id, "info", "start", "job started/resumed")
|
||||
return True
|
||||
|
||||
|
||||
def _heartbeat(db: Session, job_id: int, **counts: int) -> None:
|
||||
|
|
@ -385,7 +411,15 @@ def process_nspd_geo_job(self: Any, job_id: int) -> dict[str, Any]:
|
|||
if not job:
|
||||
return {"error": "job_not_found", "job_id": job_id}
|
||||
|
||||
_start_job(db, job_id)
|
||||
# Атомарный claim (issue #1621): если job уже 'running', значит другое
|
||||
# task-сообщение на тот же job_id уже в работе — выходим, не дублируя
|
||||
# WAF-хиты и не затирая счётчики параллельным циклом.
|
||||
if not _start_job(db, job_id):
|
||||
logger.info(
|
||||
"process_nspd_geo_job: job=%s already claimed (running) — skipping duplicate",
|
||||
job_id,
|
||||
)
|
||||
return {"job_id": job_id, "skipped": True, "reason": "already_running"}
|
||||
# Если в job-строке нет rate_ms — берём глобальный дефолт из job_settings.
|
||||
# Это позволяет менять дефолт через /admin/jobs/settings без перезапуска.
|
||||
if job["rate_ms"]:
|
||||
|
|
@ -637,6 +671,10 @@ def cleanup_zombies() -> dict[str, Any]:
|
|||
- status='paused' с heartbeat старше _ZOMBIE_PAUSED_THRESHOLD (30 мин) →
|
||||
WAF-пауза (consecutive_waf>=8) живёт минимум 30 мин, иначе минутный beat
|
||||
сразу аннулировал бы защиту и worker снова долбил бы забаненный сервис.
|
||||
- status='queued' c НЕ-NULL heartbeat старше _ZOMBIE_QUEUED_THRESHOLD (6 мин)
|
||||
→ ре-enqueue'нутый job, чьё broker-сообщение потерялось (issue #1655).
|
||||
heartbeat_at IS NOT NULL отсекает свежесозданные jobs (initial apply_async
|
||||
ещё в полёте) — их heartbeat ставится только при первом _start_job.
|
||||
|
||||
Idempotent: если зомби нет, ничего не делает. Активный job с свежим heartbeat
|
||||
не матчит WHERE-clause.
|
||||
|
|
@ -661,12 +699,22 @@ def cleanup_zombies() -> dict[str, Any]:
|
|||
AND heartbeat_at
|
||||
< NOW() - CAST(:paused_threshold AS interval)
|
||||
)
|
||||
OR (
|
||||
-- issue #1655: ре-enqueue'нутый job застрял в 'queued'
|
||||
-- (потерянное broker-сообщение). heartbeat_at IS NOT NULL
|
||||
-- отсекает свежесозданные jobs с ещё-в-полёте apply_async.
|
||||
status = 'queued'
|
||||
AND heartbeat_at IS NOT NULL
|
||||
AND heartbeat_at
|
||||
< NOW() - CAST(:queued_threshold AS interval)
|
||||
)
|
||||
RETURNING job_id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"running_threshold": _ZOMBIE_RUNNING_THRESHOLD,
|
||||
"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD,
|
||||
"queued_threshold": _ZOMBIE_QUEUED_THRESHOLD,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
|
|
|
|||
|
|
@ -153,9 +153,12 @@ def import_anton_objective(
|
|||
_finish_run(db, run_id, status="failed", error=f"SQLite не найден: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
# Дополним результат полезной диагностикой
|
||||
# Логируем полезную диагностику, но НЕ возвращаем dict —
|
||||
# re-raise, чтобы Celery-таск ушёл в FAILURE (не SUCCESS) и не
|
||||
# рассинхронился с objective_scrape_runs.status='failed' (#1623).
|
||||
info = get_sqlite_info(sqlite_path_eff)
|
||||
return {"run_id": run_id, "error": "sqlite_not_found", "sqlite_info": info}
|
||||
logger.error("sqlite_not_found diagnostics for run=%s: %s", run_id, info)
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("import_anton_objective failed: %s", e)
|
||||
|
|
|
|||
|
|
@ -70,7 +70,10 @@ def harvest_opportunity_overlays(quarters: list[str] | None = None) -> dict[str,
|
|||
bbox = _geojson_bbox_3857(qfeat.geometry)
|
||||
if bbox is None:
|
||||
continue
|
||||
n_quarters += 1
|
||||
# Staging-счётчик квартала: прибавляем к итогам ТОЛЬКО после успешного
|
||||
# commit (строка ниже). Иначе при сбое commit + rollback откатятся
|
||||
# незакоммиченные UPSERT'ы, а отчёт завысит число persisted фич (#1624).
|
||||
q_features = 0
|
||||
for layer_key, layer_kind in OPPORTUNITY_LAYER_KINDS.items():
|
||||
layer_id = LAYERS.get(layer_key)
|
||||
if layer_id is None:
|
||||
|
|
@ -94,10 +97,13 @@ def harvest_opportunity_overlays(quarters: list[str] | None = None) -> dict[str,
|
|||
feature=feat,
|
||||
fetched_at=fetched_at,
|
||||
):
|
||||
n_features += 1
|
||||
q_features += 1
|
||||
# Durable per-quarter commit: длинный grid-walk не теряет прогресс при
|
||||
# краше/таймауте середины прогона (commit раз-в-конце терял ВСЁ).
|
||||
db.commit()
|
||||
# Commit прошёл → фичи квартала реально persisted, учитываем в отчёте.
|
||||
n_quarters += 1
|
||||
n_features += q_features
|
||||
except Exception as exc:
|
||||
logger.warning("opportunity_harvest: квартал %s failed: %s", quarter, exc)
|
||||
db.rollback() # сбросить незакоммиченный tx квартала перед следующим
|
||||
|
|
|
|||
47
backend/app/workers/tasks/refresh_layout_velocity.py
Normal file
47
backend/app/workers/tasks/refresh_layout_velocity.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Celery task: refresh mv_layout_velocity (питает «лучшие планировки» best_layouts).
|
||||
|
||||
Scheduled via hardcoded beat entry in workers/beat_schedule.py:
|
||||
'refresh-layout-velocity' — weekly on Sunday at 03:00 MSK.
|
||||
Стоит в «ночном» окне воскресенья, отдельно от monday-кластера тяжёлых
|
||||
site_finder-рефрешей (supply-layers / ird / gknspecial / cbr и т.д.), чтобы
|
||||
не конкурировать с ними за БД/CPU.
|
||||
|
||||
Issue: #1666 (рефреш не был подключён в beat → данные best_layouts молча
|
||||
устаревали в проде). MV-источник: #113 (PR B, mv_layout_velocity → best_layouts).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.site_finder.layout_velocity_refresh import refresh_layout_velocity
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
name="tasks.refresh_layout_velocity.refresh_layout_velocity",
|
||||
max_retries=2,
|
||||
)
|
||||
def refresh_layout_velocity_task(self: Any) -> dict[str, Any]:
|
||||
"""REFRESH MATERIALIZED VIEW mv_layout_velocity (best_layouts, #113 / #1666).
|
||||
|
||||
MV рефрешится CONCURRENTLY (non-blocking, требует unique-индекс
|
||||
mv_layout_velocity_pk); сервис сам падает в non-concurrent при unpopulated MV.
|
||||
|
||||
Returns result dict for Celery task result store / logging.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
count = refresh_layout_velocity(db, concurrently=True)
|
||||
logger.info("refresh_layout_velocity: completed, mv rows=%d", count)
|
||||
return {"status": "ok", "mv_layout_velocity_rows": count}
|
||||
except Exception as e:
|
||||
logger.exception("refresh_layout_velocity failed: %s", e)
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -401,11 +401,28 @@ def enqueue_cadastre_harvest(self: Any, job_id: int) -> dict[str, Any]:
|
|||
)
|
||||
skipped_fresh = set(rows)
|
||||
if skipped_fresh:
|
||||
# Корректируем targets_total — skipped quarters не входят в прогресс
|
||||
# Корректируем targets_total — skipped quarters не входят в прогресс.
|
||||
# Bug #1654: блок выполняется при КАЖДОМ вызове (resume re-enqueue'ит
|
||||
# тот же enqueue_cadastre_harvest), а уже обработанные кварталы тоже
|
||||
# получают свежий cad_quarter_stats.fetched_at → попадают в
|
||||
# skipped_fresh повторно. Поэтому:
|
||||
# • targets_skipped — идемпотентный SET (= число свежих сейчас),
|
||||
# а не cumulative INCREMENT (иначе раздувается на каждый resume);
|
||||
# • targets_total — :new_total это только ОСТАВШИЕСЯ к обработке
|
||||
# кварталы (len(quarters) − skipped_fresh). При resume уже
|
||||
# обработанные попадают в skipped_fresh, поэтому к остатку
|
||||
# добавляем уже учтённый прогресс (done + failed), иначе total
|
||||
# занижается и _maybe_finish_job помечает job 'done' после первого
|
||||
# доработанного квартала (#1654-followup). GREATEST с самим
|
||||
# прогрессом сохраняет монотонность (total не уменьшается).
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE cadastre_jobs SET targets_total = :new_total, "
|
||||
"targets_skipped = COALESCE(targets_skipped, 0) + :sk "
|
||||
"UPDATE cadastre_jobs SET "
|
||||
"targets_total = GREATEST("
|
||||
":new_total + COALESCE(targets_done, 0) + COALESCE(targets_failed, 0), "
|
||||
"COALESCE(targets_done, 0) + COALESCE(targets_failed, 0)"
|
||||
"), "
|
||||
"targets_skipped = :sk "
|
||||
"WHERE job_id = :id"
|
||||
),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ logger = logging.getLogger(__name__)
|
|||
# Фильтр (:force = false): берём только те, что ещё не обновлялись сегодня
|
||||
# (catalog_scraped_at IS NULL — никогда не скрапились, либо DATE(...) < today).
|
||||
# При :force = true фильтр снимается — грузим все объекты последнего snapshot.
|
||||
#
|
||||
# LIMIT :max_objects: в PostgreSQL `LIMIT NULL` == без лимита, поэтому при
|
||||
# max_objects=None (force "Загрузить все" без явного потолка) грузим ВСЕ строки
|
||||
# последнего snapshot, а не молча режем до _DEFAULT_MAX_OBJECTS.
|
||||
_SELECT_TARGETS_SQL = text(
|
||||
"""
|
||||
SELECT obj_id, snapshot_date
|
||||
|
|
@ -74,10 +78,12 @@ def scrape_kn_catalog_objects(
|
|||
|
||||
Args:
|
||||
region_code: Код региона (ОКАТО prefix). Default 66 = Свердловская обл.
|
||||
max_objects: Максимум объектов за один run. Default 300.
|
||||
force: Если True — игнорирует фильтр "уже сегодня обновлён" и грузит
|
||||
все объекты последнего snapshot (admin "Загрузить все"). По умолчанию
|
||||
False — пропускает то, что уже скраплено сегодня.
|
||||
max_objects: Максимум объектов за один run. Если не задан: при force=True
|
||||
лимита нет (грузим все), при force=False — _DEFAULT_MAX_OBJECTS (300).
|
||||
force: Если True — игнорирует фильтр "уже сегодня обновлён" и (при не
|
||||
заданном max_objects) снимает лимит, грузя ВСЕ объекты последнего
|
||||
snapshot (admin "Загрузить все"). По умолчанию False — пропускает
|
||||
то, что уже скраплено сегодня, и режет batch до 300.
|
||||
|
||||
Returns:
|
||||
dict с ключами: region_code, snapshot_date, obj_ids_count,
|
||||
|
|
@ -95,7 +101,15 @@ def scrape_kn_catalog_objects(
|
|||
"""
|
||||
from app.services.scrapers.domrf_catalog_object import scrape_catalog_objects
|
||||
|
||||
limit = max_objects if max_objects is not None else _DEFAULT_MAX_OBJECTS
|
||||
# Явный max_objects всегда уважается. Без него:
|
||||
# force=True ("Загрузить все") → лимита нет (limit=None → SQL LIMIT NULL = все строки);
|
||||
# force=False (beat / ad-hoc pass) → дефолтный batch _DEFAULT_MAX_OBJECTS.
|
||||
if max_objects is not None:
|
||||
limit: int | None = max_objects
|
||||
elif force:
|
||||
limit = None
|
||||
else:
|
||||
limit = _DEFAULT_MAX_OBJECTS
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
|
|
@ -136,11 +150,11 @@ def scrape_kn_catalog_objects(
|
|||
snapshot_date_val: date = rows[0]["snapshot_date"]
|
||||
|
||||
logger.info(
|
||||
"scrape_kn_catalog_objects: region=%d snapshot_date=%s obj_ids=%d limit=%d force=%s",
|
||||
"scrape_kn_catalog_objects: region=%d snapshot_date=%s obj_ids=%d limit=%s force=%s",
|
||||
region_code,
|
||||
snapshot_date_val,
|
||||
len(obj_ids),
|
||||
limit,
|
||||
"ALL" if limit is None else limit,
|
||||
force,
|
||||
)
|
||||
|
||||
|
|
|
|||
321
backend/scripts/spike_plan_vectorize.py
Normal file
321
backend/scripts/spike_plan_vectorize.py
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
"""Spike — contour-vectorize floor-plan rasters (PNG/JPG → SVG) via Potrace.
|
||||
|
||||
Forgejo issue #299 (Phase 1 exploration SPIKE). **This is a throwaway probe, NOT
|
||||
production wiring**: it answers one question — is Potrace contour tracing good
|
||||
enough to turn floor-plan rasters into compact, readable SVG for the planning UI
|
||||
catalog and PDF reports? No ML, no room semantics, no DOM.RF coupling.
|
||||
|
||||
WHAT IT MEASURES
|
||||
----------------
|
||||
For each input raster the pipeline runs:
|
||||
|
||||
1. load (Pillow) → grayscale (``L``)
|
||||
2. binarise: pixels below ``--threshold`` (0-255) become ink (black), the rest
|
||||
become paper (white). Optional ``--invert`` for light-on-dark plans.
|
||||
3. write a 1-bit PBM bitmap (Potrace's native input) to the output dir
|
||||
4. ``potrace -s`` → SVG (centerline-free, filled-contour tracing)
|
||||
5. record size metrics: raster bytes, svg bytes, ratio (raster / svg)
|
||||
|
||||
It also (optionally, ``--render-back``) rasterises the produced SVG back to PNG
|
||||
via ``rsvg-convert`` so a human can eyeball SVG-vs-original side by side and
|
||||
judge whether wall/room contours survived the round trip.
|
||||
|
||||
WHY POTRACE IS SOURCE-AGNOSTIC
|
||||
------------------------------
|
||||
Potrace traces the boundary between ink and paper regions of a bitmap. It does
|
||||
not care where the raster came from — a Wikimedia line-drawing plan, a scanned
|
||||
blueprint, or a DOM.RF UI tile all reduce to "dark strokes on a light field"
|
||||
after thresholding. So the Phase 1 question ("are the contours traceable and how
|
||||
much do they compress?") is validly answered on any representative floor-plan
|
||||
rasters; see the spike doc for the explicit DOM.RF caveat.
|
||||
|
||||
USAGE
|
||||
-----
|
||||
# vectorise every raster in a folder, collect a metrics table
|
||||
uv run python backend/scripts/spike_plan_vectorize.py \
|
||||
--in-dir /tmp/plan-spike/in --out-dir /tmp/plan-spike/out
|
||||
|
||||
# also render the SVGs back to PNG for visual QA
|
||||
uv run python backend/scripts/spike_plan_vectorize.py \
|
||||
--in-dir /tmp/plan-spike/in --out-dir /tmp/plan-spike/out --render-back
|
||||
|
||||
Requires the ``potrace`` binary on PATH (``brew install potrace``) and, for
|
||||
``--render-back``, ``rsvg-convert`` (``brew install librsvg``). Pillow ships with
|
||||
the backend deps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import shutil
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
logger = logging.getLogger("spike_plan_vectorize")
|
||||
|
||||
_RASTER_SUFFIXES = {".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff"}
|
||||
_POTRACE_TIMEOUT_S = 120
|
||||
_RSVG_TIMEOUT_S = 120
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VectorizeResult:
|
||||
"""One raster's trip through the pipeline."""
|
||||
|
||||
name: str
|
||||
raster_bytes: int
|
||||
svg_bytes: int
|
||||
svg_path: Path
|
||||
rendered_path: Path | None
|
||||
|
||||
@property
|
||||
def ratio(self) -> float:
|
||||
"""Compression ratio raster / svg (>1 means SVG is smaller)."""
|
||||
return self.raster_bytes / self.svg_bytes if self.svg_bytes else 0.0
|
||||
|
||||
def metrics_line(self) -> str:
|
||||
return (
|
||||
f"{self.name:<44} "
|
||||
f"raster={self.raster_bytes:>9}B "
|
||||
f"svg={self.svg_bytes:>8}B "
|
||||
f"ratio={self.ratio:>6.2f}x"
|
||||
)
|
||||
|
||||
|
||||
def _require_binary(name: str) -> str:
|
||||
"""Resolve an external binary on PATH or exit with a clear message."""
|
||||
path = shutil.which(name)
|
||||
if path is None:
|
||||
logger.error("required binary %r not found on PATH", name)
|
||||
raise SystemExit(f"{name!r} not found — install it (e.g. `brew install {name}`) and retry")
|
||||
return path
|
||||
|
||||
|
||||
def raster_to_pbm(src: Path, pbm_path: Path, *, threshold: int, invert: bool) -> None:
|
||||
"""Load a raster, grayscale + threshold it, and write a 1-bit PBM bitmap.
|
||||
|
||||
Potrace consumes 1-bit bitmaps (PBM/PGM/BMP). We binarise with a fixed
|
||||
threshold so the spike's behaviour is deterministic and explainable — black
|
||||
(ink) is what Potrace traces, white is background. ``invert`` flips the test
|
||||
for light-stroke-on-dark plans.
|
||||
"""
|
||||
with Image.open(src) as im:
|
||||
gray = im.convert("L")
|
||||
# point(): pixel < threshold → ink (0), else paper (255). Pillow's "1" mode
|
||||
# then packs to a true 1-bit bitmap that Potrace reads natively.
|
||||
cutoff = threshold
|
||||
if invert:
|
||||
bitmap = gray.point(lambda px: 0 if px >= cutoff else 255).convert("1")
|
||||
else:
|
||||
bitmap = gray.point(lambda px: 0 if px < cutoff else 255).convert("1")
|
||||
bitmap.save(pbm_path)
|
||||
|
||||
|
||||
def pbm_to_svg(potrace_bin: str, pbm_path: Path, svg_path: Path) -> None:
|
||||
"""Trace a PBM bitmap to SVG with ``potrace -s`` (SVG backend)."""
|
||||
try:
|
||||
subprocess.run(
|
||||
[potrace_bin, "-s", "-o", str(svg_path), str(pbm_path)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=_POTRACE_TIMEOUT_S,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
stderr = exc.stderr.decode("utf-8", "replace").strip()
|
||||
logger.error("potrace failed for %s: %s", pbm_path.name, stderr)
|
||||
raise
|
||||
|
||||
|
||||
def svg_to_png(rsvg_bin: str, svg_path: Path, png_path: Path, *, width: int) -> None:
|
||||
"""Render an SVG back to PNG via ``rsvg-convert`` for visual QA.
|
||||
|
||||
We composite onto an explicit white background. Potrace's SVG is black
|
||||
filled paths on a *transparent* canvas; without ``--background-color=white``
|
||||
rsvg renders the black fill onto transparency and a flattening viewer shows
|
||||
a solid black tile. White matches the real catalog/PDF use case anyway.
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
rsvg_bin,
|
||||
"-w",
|
||||
str(width),
|
||||
"--background-color=white",
|
||||
"-o",
|
||||
str(png_path),
|
||||
str(svg_path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=_RSVG_TIMEOUT_S,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
stderr = exc.stderr.decode("utf-8", "replace").strip()
|
||||
logger.error("rsvg-convert failed for %s: %s", svg_path.name, stderr)
|
||||
raise
|
||||
|
||||
|
||||
def vectorize_one(
|
||||
src: Path,
|
||||
out_dir: Path,
|
||||
*,
|
||||
potrace_bin: str,
|
||||
rsvg_bin: str | None,
|
||||
threshold: int,
|
||||
invert: bool,
|
||||
render_width: int,
|
||||
) -> VectorizeResult:
|
||||
"""Run the full PNG/JPG → PBM → SVG (→ PNG) pipeline for a single raster."""
|
||||
stem = src.stem
|
||||
pbm_path = out_dir / f"{stem}.pbm"
|
||||
svg_path = out_dir / f"{stem}.svg"
|
||||
|
||||
raster_to_pbm(src, pbm_path, threshold=threshold, invert=invert)
|
||||
pbm_to_svg(potrace_bin, pbm_path, svg_path)
|
||||
|
||||
rendered_path: Path | None = None
|
||||
if rsvg_bin is not None:
|
||||
rendered_path = out_dir / f"{stem}.rendered.png"
|
||||
svg_to_png(rsvg_bin, svg_path, rendered_path, width=render_width)
|
||||
|
||||
return VectorizeResult(
|
||||
name=src.name,
|
||||
raster_bytes=src.stat().st_size,
|
||||
svg_bytes=svg_path.stat().st_size,
|
||||
svg_path=svg_path,
|
||||
rendered_path=rendered_path,
|
||||
)
|
||||
|
||||
|
||||
def discover_rasters(in_dir: Path) -> list[Path]:
|
||||
"""Return sorted raster files in ``in_dir`` (non-recursive)."""
|
||||
return sorted(
|
||||
p for p in in_dir.iterdir() if p.is_file() and p.suffix.lower() in _RASTER_SUFFIXES
|
||||
)
|
||||
|
||||
|
||||
def summarise(results: list[VectorizeResult]) -> None:
|
||||
"""Print a per-file metrics table plus min/median/max compression."""
|
||||
if not results:
|
||||
logger.warning("no results to summarise")
|
||||
return
|
||||
|
||||
print("\n=== per-raster metrics ===")
|
||||
for r in results:
|
||||
print(r.metrics_line())
|
||||
|
||||
ratios = sorted(r.ratio for r in results)
|
||||
print("\n=== compression summary ===")
|
||||
print(f"samples : {len(ratios)}")
|
||||
print(f"min ratio : {min(ratios):.2f}x")
|
||||
print(f"median ratio : {statistics.median(ratios):.2f}x")
|
||||
print(f"max ratio : {max(ratios):.2f}x")
|
||||
total_raster = sum(r.raster_bytes for r in results)
|
||||
total_svg = sum(r.svg_bytes for r in results)
|
||||
agg_ratio = total_raster / total_svg if total_svg else float("inf")
|
||||
print(
|
||||
f"aggregate : {total_raster}B raster -> {total_svg}B svg " f"({agg_ratio:.2f}x overall)"
|
||||
)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Spike: contour-vectorize floor-plan rasters (PNG/JPG → SVG) via Potrace.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--in-dir",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="folder containing input rasters (PNG/JPG/...)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out-dir",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="folder for output .pbm/.svg (and .rendered.png with --render-back)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--threshold",
|
||||
type=int,
|
||||
default=128,
|
||||
help="grayscale ink/paper cutoff 0-255 (default 128)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--invert",
|
||||
action="store_true",
|
||||
help="treat light strokes on a dark field as ink",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--render-back",
|
||||
action="store_true",
|
||||
help="rasterise each SVG back to PNG via rsvg-convert for visual QA",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--render-width",
|
||||
type=int,
|
||||
default=900,
|
||||
help="width in px for --render-back output (default 900)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
args = build_parser().parse_args(argv)
|
||||
|
||||
in_dir: Path = args.in_dir
|
||||
out_dir: Path = args.out_dir
|
||||
if not in_dir.is_dir():
|
||||
logger.error("input dir does not exist: %s", in_dir)
|
||||
return 2
|
||||
if not 0 <= args.threshold <= 255:
|
||||
logger.error("--threshold must be 0-255, got %d", args.threshold)
|
||||
return 2
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
potrace_bin = _require_binary("potrace")
|
||||
rsvg_bin = _require_binary("rsvg-convert") if args.render_back else None
|
||||
|
||||
rasters = discover_rasters(in_dir)
|
||||
if not rasters:
|
||||
logger.error("no raster files found in %s", in_dir)
|
||||
return 1
|
||||
logger.info("found %d raster(s) in %s", len(rasters), in_dir)
|
||||
|
||||
results: list[VectorizeResult] = []
|
||||
failed = 0
|
||||
for src in rasters:
|
||||
try:
|
||||
results.append(
|
||||
vectorize_one(
|
||||
src,
|
||||
out_dir,
|
||||
potrace_bin=potrace_bin,
|
||||
rsvg_bin=rsvg_bin,
|
||||
threshold=args.threshold,
|
||||
invert=args.invert,
|
||||
render_width=args.render_width,
|
||||
)
|
||||
)
|
||||
logger.info("vectorized %s", src.name)
|
||||
except Exception:
|
||||
# Log + continue: one bad raster must not abort the batch, but we
|
||||
# never swallow silently — the traceback is recorded and the file
|
||||
# is counted as a failure in the final tally.
|
||||
failed += 1
|
||||
logger.exception("failed to vectorize %s", src.name)
|
||||
|
||||
summarise(results)
|
||||
if failed:
|
||||
logger.warning("%d/%d raster(s) failed", failed, len(rasters))
|
||||
return 0 if results else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -269,11 +269,14 @@ class TestPaymentAtScenario:
|
|||
res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0})
|
||||
assert res.payment_at_scenario is not None
|
||||
principal = 120_000.0 * _REF_AREA_M2
|
||||
# rate_path несёт КЛЮЧЕВУЮ ставку сценария; affordability приводит к рыночной
|
||||
# базе (+ _KEY_RATE_MARKET_SPREAD_PP), как и monthly_payment_rub (#1639). Ожидания
|
||||
# выражаем символически — тест переживёт перекалибровку спреда.
|
||||
assert res.payment_at_scenario[6] == pytest.approx(
|
||||
_annuity(principal, 8.0, _ANNUITY_TERM_MONTHS)
|
||||
_annuity(principal, 8.0 + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS)
|
||||
)
|
||||
assert res.payment_at_scenario[12] == pytest.approx(
|
||||
_annuity(principal, 20.0, _ANNUITY_TERM_MONTHS)
|
||||
_annuity(principal, 20.0 + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS)
|
||||
)
|
||||
# Выше ставка → выше платёж на этом горизонте.
|
||||
assert res.payment_at_scenario[12] > res.payment_at_scenario[6]
|
||||
|
|
|
|||
|
|
@ -304,12 +304,15 @@ def test_denorm_dump_empty_features() -> None:
|
|||
db = _make_mock_session()
|
||||
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[])
|
||||
|
||||
assert counts == {"parcels": 0, "buildings": 0, "errors": 0}
|
||||
assert counts == {"parcels": 0, "buildings": 0, "errors": 0, "skipped": 0}
|
||||
db.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_denorm_dump_no_cad_num_counted_as_error() -> None:
|
||||
"""Parcel без cad_num → denorm_parcel_feature returns False → errors += 1."""
|
||||
def test_denorm_dump_no_cad_num_counted_as_skipped() -> None:
|
||||
"""Parcel без cad_num → pre-check в denorm_dump → skipped += 1, не errors.
|
||||
|
||||
denorm_parcel_feature не вызывается вовсе — пропуск штатный, не сбой UPSERT.
|
||||
"""
|
||||
db = _make_mock_session()
|
||||
feature: dict[str, Any] = {
|
||||
"layer": "parcels",
|
||||
|
|
@ -319,4 +322,5 @@ def test_denorm_dump_no_cad_num_counted_as_error() -> None:
|
|||
}
|
||||
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[feature])
|
||||
assert counts["parcels"] == 0
|
||||
assert counts["errors"] == 1
|
||||
assert counts["errors"] == 0
|
||||
assert counts["skipped"] == 1
|
||||
|
|
|
|||
|
|
@ -56,6 +56,34 @@ logger = logging.getLogger(__name__)
|
|||
# Диапазон 180-300 с; 240 — разумный середина (4 мин > любой нормальный anchor).
|
||||
ANCHOR_TIMEOUT_SEC: int = 240
|
||||
|
||||
# Константы для расчёта watchdog-таймаута combos-режима Yandex sweep.
|
||||
# В combos-режиме единственный "anchor" делает num_combos × max_pages fetch'ей;
|
||||
# каждый fetch занимает request_delay_sec + сетевой overhead. ADDRESS_ENRICH_BUDGET_S
|
||||
# добавляет буфер на address-enrich фазу (per-listing HTTP + sleep × N листингов).
|
||||
# Пример: 30 combos × 3 pages × (9+12) + 300 ≈ 2190s (~37 мин) — надёжно укладывается
|
||||
# в окно 02:00-05:00 без риска перекрытия следующего sweep'а.
|
||||
_YANDEX_COMBOS_PER_FETCH_S: float = 12.0 # network + parse margin (секунды на страницу)
|
||||
_YANDEX_ADDRESS_ENRICH_BUDGET_S: float = 300.0 # бюджет на address-enrich фазу
|
||||
|
||||
# Константы для расчёта watchdog-таймаута Avito city sweep.
|
||||
# Avito detail-fetch идёт через браузерный сервис с page.goto timeout 60s, и
|
||||
# anti-bot страницы нередко доходят до этого лимита. Фиксированный ANCHOR_TIMEOUT_SEC=240
|
||||
# guillotine'ит anchor ещё в detail-фазе → SERP-счётчики теряются, run показывает
|
||||
# lots_fetched=0 при реально сохранённых листингах. Таймаут масштабируется:
|
||||
# _avito_anchor_timeout = ANCHOR_TIMEOUT_SEC
|
||||
# + detail_top_n × _AVITO_PER_DETAIL_S
|
||||
# + (_AVITO_HOUSES_BUDGET_S if enrich_houses else 0)
|
||||
# Пример: detail_top_n=20 + houses → 240 + 1000 + 180 = 1420s/anchor (worst-case).
|
||||
# 5 anchor'ов × 1420s ≈ 2ч worst-case (нормальный прогон быстрее: не каждый detail
|
||||
# достигает 60s timeout). Окно лишь ограничивает старт следующего sweep'а — scheduler
|
||||
# ждёт финиша задачи и не запустит параллельный run.
|
||||
_AVITO_PER_DETAIL_S: float = 50.0 # секунд на один detail-fetch (incl. occasional 60s timeout)
|
||||
_AVITO_HOUSES_BUDGET_S: float = 180.0 # бюджет на house-enrich фазу
|
||||
# Fail-fast: если detail phase накапливает подряд N timeout/ошибок, прерываем её
|
||||
# (аналог consecutive_blocks для AvitoBlockedError в step 5). Срабатывает только при
|
||||
# явной деградации browser-сервиса, не мешает нормальным transient-ошибкам.
|
||||
_AVITO_DETAIL_CONSECUTIVE_TIMEOUT_ABORT: int = 5
|
||||
|
||||
# Default anchors ЕКБ — 5 точек покрытия города
|
||||
EKB_ANCHORS: list[tuple[float, float, str]] = [
|
||||
(56.8400, 60.6050, "Центр"),
|
||||
|
|
@ -476,6 +504,10 @@ async def run_avito_city_sweep(
|
|||
- Прочие errors per-anchor логируются, не валят весь sweep
|
||||
- После всех anchor'ов: если enrich_imv=True — IMV-оценка тронутых домов
|
||||
(cooperative cancel + per-house graceful error handling)
|
||||
|
||||
SERP-счётчики (lots_fetched/inserted/updated) записываются в sweep-level counters
|
||||
НЕМЕДЛЕННО после save_listings — до начала detail/houses фазы. Даже если anchor
|
||||
превышает watchdog-таймаут в detail-фазе, SERP-результаты не теряются.
|
||||
"""
|
||||
from app.services.house_imv_backfill import process_houses_imv_batch
|
||||
|
||||
|
|
@ -483,6 +515,27 @@ async def run_avito_city_sweep(
|
|||
counters = CitySweepCounters(anchors_total=len(_anchors))
|
||||
all_touched_house_ids: set[int] = set()
|
||||
|
||||
# Масштабируемый watchdog-таймаут для Avito anchor'а. В отличие от Yandex/Cian,
|
||||
# Avito detail-fetch идёт через browser service с page.goto timeout 60s. При
|
||||
# detail_top_n=20 detail-фаза может занять до N×60s — фиксированный ANCHOR_TIMEOUT_SEC
|
||||
# обрезает её раньше SERP, теряя счётчики. Масштабирование по detail_top_n + houses
|
||||
# гарантирует, что SERP+save всегда успевает до watchdog.
|
||||
_avito_anchor_timeout = (
|
||||
float(ANCHOR_TIMEOUT_SEC)
|
||||
+ detail_top_n * _AVITO_PER_DETAIL_S
|
||||
+ (_AVITO_HOUSES_BUDGET_S if enrich_houses else 0.0)
|
||||
)
|
||||
logger.info(
|
||||
"city-sweep run_id=%d: avito anchor_timeout=%.0fs "
|
||||
"(base=%d + detail_top_n=%d×%.0fs + houses=%.0fs)",
|
||||
run_id,
|
||||
_avito_anchor_timeout,
|
||||
ANCHOR_TIMEOUT_SEC,
|
||||
detail_top_n,
|
||||
_AVITO_PER_DETAIL_S,
|
||||
_AVITO_HOUSES_BUDGET_S if enrich_houses else 0.0,
|
||||
)
|
||||
|
||||
browser_mode = settings.scraper_fetch_mode == "browser"
|
||||
async with AsyncExitStack() as stack:
|
||||
session: AsyncSession | None = None
|
||||
|
|
@ -520,44 +573,313 @@ async def run_avito_city_sweep(
|
|||
lat,
|
||||
lon,
|
||||
)
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
run_avito_pipeline(
|
||||
db,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
radius_m=radius_m,
|
||||
enrich_houses=enrich_houses,
|
||||
enrich_detail_top_n=detail_top_n,
|
||||
|
||||
# Capture loop variables in default args (B023): prevents stale binding
|
||||
# if the coroutine is scheduled after the loop variable changes.
|
||||
_a_lat, _a_lon, _a_name = lat, lon, name
|
||||
|
||||
async def _avito_anchor_phases(
|
||||
_lat: float = _a_lat,
|
||||
_lon: float = _a_lon,
|
||||
_name: str = _a_name,
|
||||
) -> None:
|
||||
"""Все фазы одного Avito anchor'а (SERP+save → houses → detail).
|
||||
|
||||
Обновляет sweep-level counters и all_touched_house_ids через nonlocal
|
||||
НЕМЕДЛЕННО после SERP+save (до detail-фазы). Это гарантирует, что
|
||||
TimeoutError из wait_for теряет только detail-счётчики, но не SERP.
|
||||
"""
|
||||
nonlocal all_touched_house_ids
|
||||
|
||||
# ── Phase 1+2: SERP + save ─────────────────────────────────
|
||||
scraper = AvitoScraper()
|
||||
if browser_mode:
|
||||
scraper._browser = shared_bf
|
||||
else:
|
||||
scraper._cffi = session
|
||||
|
||||
try:
|
||||
anchor_lots: list[ScrapedLot] = await scraper.fetch_around(
|
||||
_lat,
|
||||
_lon,
|
||||
radius_m,
|
||||
pages=pages_per_anchor,
|
||||
request_delay_sec=request_delay_sec,
|
||||
shared_session=session,
|
||||
shared_browser=shared_bf,
|
||||
),
|
||||
timeout=ANCHOR_TIMEOUT_SEC,
|
||||
delay_override_sec=request_delay_sec,
|
||||
)
|
||||
except (AvitoBlockedError, AvitoRateLimitedError):
|
||||
logger.error(
|
||||
"city-sweep run_id=%d: SERP BLOCKED at anchor (%s, %.4f, %.4f)",
|
||||
run_id,
|
||||
_name,
|
||||
_lat,
|
||||
_lon,
|
||||
)
|
||||
raise
|
||||
|
||||
counters.lots_fetched += len(anchor_lots)
|
||||
if anchor_lots:
|
||||
try:
|
||||
ins, upd = save_listings(db, anchor_lots)
|
||||
counters.lots_inserted += ins
|
||||
counters.lots_updated += upd
|
||||
except Exception as save_exc:
|
||||
logger.exception(
|
||||
"city-sweep run_id=%d: save_listings failed at anchor %s: %s",
|
||||
run_id,
|
||||
_name,
|
||||
save_exc,
|
||||
)
|
||||
counters.errors_count += 1
|
||||
|
||||
logger.info(
|
||||
"city-sweep run_id=%d anchor %s: SERP fetched=%d ins=%d upd=%d",
|
||||
run_id,
|
||||
_name,
|
||||
len(anchor_lots),
|
||||
counters.lots_inserted,
|
||||
counters.lots_updated,
|
||||
)
|
||||
counters.lots_fetched += result.counters.lots_fetched
|
||||
counters.lots_inserted += result.counters.lots_inserted
|
||||
counters.lots_updated += result.counters.lots_updated
|
||||
counters.unique_houses += result.counters.unique_houses
|
||||
counters.houses_enriched += result.counters.houses_enriched
|
||||
counters.houses_failed += result.counters.houses_failed
|
||||
counters.detail_attempted += result.counters.detail_attempted
|
||||
counters.detail_enriched += result.counters.detail_enriched
|
||||
counters.detail_failed += result.counters.detail_failed
|
||||
counters.errors_count += len(result.counters.errors)
|
||||
all_touched_house_ids.update(result.touched_house_ids)
|
||||
# SERP-счётчики зафиксированы в sweep-level counters.
|
||||
# Дальнейший TimeoutError из wait_for их не сотрёт.
|
||||
|
||||
# ── Phase 3: group by house ────────────────────────────────
|
||||
unique_house_paths: set[str] = set()
|
||||
for lot in anchor_lots:
|
||||
if lot.house_url:
|
||||
try:
|
||||
parsed = urlparse(lot.house_url)
|
||||
path = parsed.path if parsed.path else lot.house_url
|
||||
unique_house_paths.add(path)
|
||||
except Exception:
|
||||
continue
|
||||
if unique_house_paths:
|
||||
counters.unique_houses += len(unique_house_paths)
|
||||
|
||||
# ── Phase 4: enrich houses ─────────────────────────────────
|
||||
touched_house_ids: set[int] = set()
|
||||
if enrich_houses and unique_house_paths:
|
||||
house_paths_list = list(unique_house_paths)
|
||||
for h_idx, house_path in enumerate(house_paths_list):
|
||||
try:
|
||||
enrichment = await fetch_house_catalog(
|
||||
house_path,
|
||||
cffi_session=session,
|
||||
browser_fetcher=shared_bf,
|
||||
)
|
||||
hc = save_house_catalog_enrichment(db, enrichment)
|
||||
counters.houses_enriched += 1
|
||||
hid = hc.get("house_id")
|
||||
if hid:
|
||||
touched_house_ids.add(int(hid))
|
||||
except (AvitoBlockedError, AvitoRateLimitedError):
|
||||
logger.error(
|
||||
"city-sweep run_id=%d: house BLOCKED at %s — propagating",
|
||||
run_id,
|
||||
house_path,
|
||||
)
|
||||
raise
|
||||
except Exception as he:
|
||||
logger.warning(
|
||||
"city-sweep run_id=%d: house_enrich failed %s: %s",
|
||||
run_id,
|
||||
house_path,
|
||||
he,
|
||||
)
|
||||
counters.houses_failed += 1
|
||||
counters.errors_count += 1
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
if h_idx < len(house_paths_list) - 1:
|
||||
await asyncio.sleep(request_delay_sec)
|
||||
|
||||
# ── Phase 5: enrich detail (top-N) ────────────────────────
|
||||
if detail_top_n > 0 and anchor_lots:
|
||||
priority_rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT source_url
|
||||
FROM listings
|
||||
WHERE source = 'avito'
|
||||
AND source_url IS NOT NULL
|
||||
AND (
|
||||
(
|
||||
detail_enriched_at IS NULL
|
||||
AND price_rub > 0
|
||||
AND ST_DWithin(
|
||||
geom::geography,
|
||||
ST_MakePoint(:lon, :lat)::geography,
|
||||
:radius
|
||||
)
|
||||
)
|
||||
OR (
|
||||
detail_enriched_at IS NULL
|
||||
AND scraped_at > NOW() - INTERVAL '2 hours'
|
||||
)
|
||||
)
|
||||
ORDER BY scraped_at DESC NULLS LAST
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{
|
||||
"lat": _lat,
|
||||
"lon": _lon,
|
||||
"radius": radius_m * 2,
|
||||
"limit": detail_top_n,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
consecutive_blocks = 0
|
||||
consecutive_timeouts = 0
|
||||
detail_house_paths: set[str] = set()
|
||||
for d_idx, row in enumerate(priority_rows):
|
||||
source_url: str = row["source_url"]
|
||||
counters.detail_attempted += 1
|
||||
try:
|
||||
item_url = (
|
||||
urlparse(source_url).path
|
||||
if source_url.startswith("http")
|
||||
else source_url
|
||||
)
|
||||
enrichment_detail = await fetch_detail(
|
||||
item_url,
|
||||
cffi_session=session,
|
||||
browser_fetcher=shared_bf,
|
||||
)
|
||||
if save_detail_enrichment(db, enrichment_detail):
|
||||
counters.detail_enriched += 1
|
||||
if enrichment_detail.house_catalog_url:
|
||||
hp = urlparse(enrichment_detail.house_catalog_url).path
|
||||
if hp:
|
||||
detail_house_paths.add(hp)
|
||||
consecutive_blocks = 0
|
||||
consecutive_timeouts = 0
|
||||
except (AvitoBlockedError, AvitoRateLimitedError) as be:
|
||||
consecutive_blocks += 1
|
||||
counters.detail_failed += 1
|
||||
counters.errors_count += 1
|
||||
logger.warning(
|
||||
"city-sweep run_id=%d: detail BLOCKED #%d/%d "
|
||||
"(consecutive=%d): %s",
|
||||
run_id,
|
||||
d_idx + 1,
|
||||
len(priority_rows),
|
||||
consecutive_blocks,
|
||||
be,
|
||||
)
|
||||
if consecutive_blocks >= 3:
|
||||
logger.error(
|
||||
"city-sweep run_id=%d: detail ABORT — "
|
||||
"%d consecutive blocks (IP rate-limited)",
|
||||
run_id,
|
||||
consecutive_blocks,
|
||||
)
|
||||
raise
|
||||
except Exception as de:
|
||||
counters.detail_failed += 1
|
||||
counters.errors_count += 1
|
||||
consecutive_timeouts += 1
|
||||
logger.warning(
|
||||
"city-sweep run_id=%d: detail failed #%d/%d "
|
||||
"(consecutive_timeouts=%d): %s",
|
||||
run_id,
|
||||
d_idx + 1,
|
||||
len(priority_rows),
|
||||
consecutive_timeouts,
|
||||
de,
|
||||
)
|
||||
# (C) Fail-fast: браузер явно деградирует — прерываем
|
||||
# detail-фазу, не тратим время на оставшиеся запросы.
|
||||
if consecutive_timeouts >= _AVITO_DETAIL_CONSECUTIVE_TIMEOUT_ABORT:
|
||||
logger.error(
|
||||
"city-sweep run_id=%d: detail ABORT — "
|
||||
"%d consecutive timeouts/errors, browser degraded",
|
||||
run_id,
|
||||
consecutive_timeouts,
|
||||
)
|
||||
break
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if d_idx < len(priority_rows) - 1:
|
||||
jitter = random.uniform(0.8, 1.2)
|
||||
await asyncio.sleep(request_delay_sec * jitter)
|
||||
|
||||
# ── Phase 5b: houses найденные через detail-страницы ──
|
||||
new_house_paths = detail_house_paths - unique_house_paths
|
||||
if enrich_houses and new_house_paths:
|
||||
counters.unique_houses += len(new_house_paths)
|
||||
nh_list = list(new_house_paths)
|
||||
for nh_idx, house_path in enumerate(nh_list):
|
||||
try:
|
||||
enrichment = await fetch_house_catalog(
|
||||
house_path,
|
||||
cffi_session=session,
|
||||
browser_fetcher=shared_bf,
|
||||
)
|
||||
hc = save_house_catalog_enrichment(db, enrichment)
|
||||
counters.houses_enriched += 1
|
||||
hid = hc.get("house_id")
|
||||
if hid:
|
||||
touched_house_ids.add(int(hid))
|
||||
except (AvitoBlockedError, AvitoRateLimitedError):
|
||||
logger.error(
|
||||
"city-sweep run_id=%d: house(detail) BLOCKED %s"
|
||||
" — propagating",
|
||||
run_id,
|
||||
house_path,
|
||||
)
|
||||
raise
|
||||
except Exception as he:
|
||||
logger.warning(
|
||||
"city-sweep run_id=%d: house(detail) failed %s: %s",
|
||||
run_id,
|
||||
house_path,
|
||||
he,
|
||||
)
|
||||
counters.houses_failed += 1
|
||||
counters.errors_count += 1
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
if nh_idx < len(nh_list) - 1:
|
||||
await asyncio.sleep(request_delay_sec)
|
||||
|
||||
all_touched_house_ids.update(touched_house_ids)
|
||||
logger.info(
|
||||
"city-sweep run_id=%d anchor %s done: "
|
||||
"lots=%d houses=%d/%d detail=%d/%d touched=%d errors=%d",
|
||||
run_id,
|
||||
_name,
|
||||
len(anchor_lots),
|
||||
counters.houses_enriched,
|
||||
counters.unique_houses,
|
||||
counters.detail_enriched,
|
||||
counters.detail_attempted,
|
||||
len(touched_house_ids),
|
||||
counters.errors_count,
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(_avito_anchor_phases(), timeout=_avito_anchor_timeout)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"city-sweep run_id=%d: anchor #%d/%d (%s, %.4f, %.4f) "
|
||||
"timed out after %ds — skipping",
|
||||
"timed out after %.0fs — SERP counters preserved, "
|
||||
"detail phase incomplete",
|
||||
run_id,
|
||||
idx,
|
||||
len(_anchors),
|
||||
name,
|
||||
lat,
|
||||
lon,
|
||||
ANCHOR_TIMEOUT_SEC,
|
||||
_avito_anchor_timeout,
|
||||
)
|
||||
counters.errors_count += 1
|
||||
except (AvitoBlockedError, AvitoRateLimitedError) as e:
|
||||
|
|
@ -760,8 +1082,26 @@ async def run_yandex_city_sweep(
|
|||
counters = YandexCitySweepCounters(anchors_total=len(_anchors))
|
||||
inter_anchor_delay = request_delay_sec if request_delay_sec is not None else 7.0
|
||||
enrich_delay = request_delay_sec if request_delay_sec is not None else 3.0
|
||||
_resolved_delay = request_delay_sec if request_delay_sec is not None else 9.0
|
||||
consecutive_failures = 0
|
||||
|
||||
# Вычисляем watchdog-таймаут для combos-режима (центр, anchors=None).
|
||||
# В combos-режиме один "anchor" выполняет num_combos × max_pages fetch'ей —
|
||||
# каждый занимает примерно _resolved_delay + _YANDEX_COMBOS_PER_FETCH_S секунд.
|
||||
# Для 30 combos × 3 pages × (9+12) + 300 ≈ 2190s (~37 мин).
|
||||
# В explicit-anchor (тест/override) режиме оставляем ANCHOR_TIMEOUT_SEC (240s) —
|
||||
# там каждый anchor небольшой и watchdog работает как старый защитный барьер.
|
||||
_num_combos = len(_rooms_list) * len(_price_ranges)
|
||||
if anchors is None and _num_combos > 0:
|
||||
_sweep_timeout = max(
|
||||
ANCHOR_TIMEOUT_SEC,
|
||||
_num_combos * pages_per_anchor * (_resolved_delay + _YANDEX_COMBOS_PER_FETCH_S)
|
||||
+ _YANDEX_ADDRESS_ENRICH_BUDGET_S,
|
||||
)
|
||||
else:
|
||||
# Explicit anchors (backward-compat тесты или ручной override) — legacy timeout.
|
||||
_sweep_timeout = ANCHOR_TIMEOUT_SEC
|
||||
|
||||
# Прокси для address-enrich curl_cffi сессии (зеркало yandex_address_backfill).
|
||||
_proxy_url = settings.scraper_proxy_url
|
||||
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
|
||||
|
|
@ -954,18 +1294,18 @@ async def run_yandex_city_sweep(
|
|||
)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(_yandex_anchor_phases(), timeout=ANCHOR_TIMEOUT_SEC)
|
||||
await asyncio.wait_for(_yandex_anchor_phases(), timeout=_sweep_timeout)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"yandex-sweep run_id=%d: anchor #%d/%d (%s, %.4f, %.4f) "
|
||||
"timed out after %ds — skipping",
|
||||
"timed out after %.0fs — skipping",
|
||||
run_id,
|
||||
idx,
|
||||
len(_anchors),
|
||||
name,
|
||||
lat,
|
||||
lon,
|
||||
ANCHOR_TIMEOUT_SEC,
|
||||
_sweep_timeout,
|
||||
)
|
||||
counters.errors_count += 1
|
||||
consecutive_failures += 1
|
||||
|
|
@ -987,7 +1327,7 @@ async def run_yandex_city_sweep(
|
|||
db,
|
||||
run_id,
|
||||
f"yandex sweep aborted: {consecutive_failures} consecutive "
|
||||
f"anchor failures (last: anchor timeout {ANCHOR_TIMEOUT_SEC}s)",
|
||||
f"anchor failures (last: anchor timeout {_sweep_timeout:.0f}s)",
|
||||
counters.to_dict(),
|
||||
)
|
||||
return counters
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -274,8 +274,12 @@ async def test_city_sweep_marks_banned_on_block() -> None:
|
|||
"""City sweep mark'ит run как banned при AvitoBlockedError.
|
||||
|
||||
Migration 015 задокументировал 'banned' как 'Avito вернул 403/captcha' — именно этот сценарий.
|
||||
|
||||
Патчим AvitoScraper.fetch_around (не run_avito_pipeline — он больше не вызывается
|
||||
из run_avito_city_sweep; sweep использует внутренние _avito_anchor_phases).
|
||||
"""
|
||||
from app.services.scrape_pipeline import run_avito_city_sweep
|
||||
from app.services.scrapers.avito import AvitoScraper
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_runs = MagicMock()
|
||||
|
|
@ -288,10 +292,7 @@ async def test_city_sweep_marks_banned_on_block() -> None:
|
|||
|
||||
with (
|
||||
patch("app.services.scrape_pipeline.scrape_runs", mock_runs),
|
||||
patch(
|
||||
"app.services.scrape_pipeline.run_avito_pipeline",
|
||||
AsyncMock(side_effect=block_error),
|
||||
),
|
||||
patch.object(AvitoScraper, "fetch_around", AsyncMock(side_effect=block_error)),
|
||||
patch(
|
||||
"curl_cffi.requests.AsyncSession",
|
||||
return_value=AsyncMock(
|
||||
|
|
|
|||
|
|
@ -140,11 +140,15 @@ def test_anchor_timeout_sec_exported() -> None:
|
|||
async def test_avito_sweep_anchor_timeout_continues(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Первый anchor виснет → таймаут; второй anchor успешен; mark_done вызывается."""
|
||||
call_count = 0
|
||||
"""Первый anchor виснет → таймаут; второй anchor успешен; mark_done вызывается.
|
||||
|
||||
run_avito_city_sweep использует внутреннюю _avito_anchor_phases (не run_avito_pipeline),
|
||||
поэтому патчим AvitoScraper.fetch_around + save_listings.
|
||||
"""
|
||||
from app.services.scrapers.avito import AvitoScraper
|
||||
|
||||
# Monkeypatch asyncio.wait_for so we can inject TimeoutError on first call only,
|
||||
# without actually waiting 240s in CI.
|
||||
# without actually waiting in CI.
|
||||
original_wait_for = asyncio.wait_for
|
||||
wf_call = 0
|
||||
|
||||
|
|
@ -164,16 +168,22 @@ async def test_avito_sweep_anchor_timeout_continues(
|
|||
monkeypatch.setattr(scrape_pipeline.asyncio, "wait_for", fake_wait_for)
|
||||
monkeypatch.setattr(scrape_pipeline.asyncio, "sleep", AsyncMock())
|
||||
|
||||
# Stub run_avito_pipeline so the second anchor returns 3 lots quickly.
|
||||
async def fake_pipeline(db: Any, *, lat: float, **_kw: Any) -> Any:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
from app.services.scrape_pipeline import PipelineCounters, PipelineResult
|
||||
# Stub AvitoScraper.fetch_around — second anchor returns 3 lots.
|
||||
fetch_calls: list[str] = []
|
||||
|
||||
cnt = PipelineCounters(lots_fetched=3, lots_inserted=3)
|
||||
return PipelineResult(lat, 0.0, 1500, cnt, False, 0)
|
||||
async def fake_fetch_around(
|
||||
self: AvitoScraper,
|
||||
lat: float,
|
||||
lon: float,
|
||||
radius_m: int,
|
||||
pages: int = 1,
|
||||
delay_override_sec: float | None = None,
|
||||
) -> list[ScrapedLot]:
|
||||
fetch_calls.append(f"{lat:.4f}")
|
||||
return [_fake_lot("avito", f"{lat:.4f}-{i}") for i in range(3)]
|
||||
|
||||
monkeypatch.setattr(scrape_pipeline, "run_avito_pipeline", fake_pipeline)
|
||||
monkeypatch.setattr(AvitoScraper, "fetch_around", fake_fetch_around)
|
||||
monkeypatch.setattr(scrape_pipeline, "save_listings", lambda _db, lots, **_kw: (len(lots), 0))
|
||||
|
||||
# Stub shared AsyncSession (used by avito sweep).
|
||||
fake_session = AsyncMock()
|
||||
|
|
@ -210,7 +220,7 @@ async def test_avito_sweep_anchor_timeout_continues(
|
|||
)
|
||||
|
||||
# (a) sweep completes — doesn't hang
|
||||
# (b) first anchor timed out → errors_count = 1
|
||||
# (b) first anchor timed out → errors_count >= 1
|
||||
assert counters.errors_count >= 1, "timeout должен увеличить errors_count"
|
||||
# (c) second anchor reached: lots_fetched > 0
|
||||
assert counters.lots_fetched > 0, "второй anchor должен был выполниться"
|
||||
|
|
@ -420,3 +430,241 @@ async def test_cian_timeout_increments_counter_and_marks_done(
|
|||
assert fake.done is not None, "mark_done должен быть вызван"
|
||||
assert fake.banned is None, "mark_banned не должен быть вызван для одиночного таймаута"
|
||||
assert counters.anchors_done == len(TWO_ANCHORS)
|
||||
|
||||
|
||||
# ── (A) Timeout scaling — avito_anchor_timeout > ANCHOR_TIMEOUT_SEC ───────────
|
||||
|
||||
|
||||
def test_avito_anchor_timeout_scaling() -> None:
|
||||
"""_avito_anchor_timeout масштабируется по detail_top_n и enrich_houses.
|
||||
|
||||
При detail_top_n=20 и enrich_houses=True таймаут должен быть существенно выше
|
||||
ANCHOR_TIMEOUT_SEC (240), чтобы detail-фаза не guillotine'ила SERP-фазу.
|
||||
"""
|
||||
from app.services.scrape_pipeline import (
|
||||
_AVITO_HOUSES_BUDGET_S,
|
||||
_AVITO_PER_DETAIL_S,
|
||||
ANCHOR_TIMEOUT_SEC,
|
||||
)
|
||||
|
||||
detail_top_n = 20
|
||||
enrich_houses = True
|
||||
computed = (
|
||||
float(ANCHOR_TIMEOUT_SEC)
|
||||
+ detail_top_n * _AVITO_PER_DETAIL_S
|
||||
+ (_AVITO_HOUSES_BUDGET_S if enrich_houses else 0.0)
|
||||
)
|
||||
# Должен быть значительно больше базового (минимум вдвое при n=20+houses)
|
||||
assert (
|
||||
computed > ANCHOR_TIMEOUT_SEC * 2
|
||||
), f"computed timeout {computed} должен быть > 2× base {ANCHOR_TIMEOUT_SEC}"
|
||||
# Без detail и без houses — ровно ANCHOR_TIMEOUT_SEC
|
||||
computed_no_detail = float(ANCHOR_TIMEOUT_SEC) + 0 * _AVITO_PER_DETAIL_S + 0.0
|
||||
assert computed_no_detail == float(ANCHOR_TIMEOUT_SEC)
|
||||
|
||||
# Новые константы экспортированы / импортируются без ошибок
|
||||
assert _AVITO_PER_DETAIL_S > 0
|
||||
assert _AVITO_HOUSES_BUDGET_S > 0
|
||||
|
||||
|
||||
# ── (B) SERP counters durable across detail-phase timeout ────────────────────
|
||||
|
||||
|
||||
async def test_avito_serp_counters_durable_after_detail_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""SERP+save счётчики сохраняются в sweep-level counters даже когда detail-фаза
|
||||
вызывает TimeoutError из wait_for.
|
||||
|
||||
Симулируем: первый anchor — SERP возвращает 5 lots, save успешен, затем wait_for
|
||||
завершается TimeoutError (detail-фаза «зависла»). Второй anchor — полный успех.
|
||||
|
||||
Ожидаем:
|
||||
- counters.lots_fetched == 5 (SERP первого anchor'а) + лоты второго
|
||||
- counters.lots_inserted >= 1 (save первого anchor'а)
|
||||
- counters.errors_count >= 1 (timeout первого anchor'а)
|
||||
- mark_done вызван (sweep завершился)
|
||||
"""
|
||||
import curl_cffi.requests as _cffi_mod
|
||||
|
||||
from app.services.scrapers.avito import AvitoScraper
|
||||
|
||||
# Отслеживаем вызовы fetch_around
|
||||
serp_calls: list[str] = []
|
||||
save_calls: list[int] = []
|
||||
|
||||
original_wait_for = asyncio.wait_for
|
||||
wf_call = 0
|
||||
|
||||
async def fake_wait_for(coro: Any, *, timeout: float) -> Any:
|
||||
nonlocal wf_call
|
||||
wf_call += 1
|
||||
if wf_call == 1:
|
||||
# Запускаем корутину до конца — она дойдёт до SERP+save,
|
||||
# а затем TimeoutError наступает как если бы detail-фаза зависла.
|
||||
# Для упрощения: просто инжектируем TimeoutError (как во всех других тестах),
|
||||
# но проверяем, что counter-патч произошёл ДО wait_for через отдельный
|
||||
# механизм: monkeypatch fetch_around + save_listings записывают данные
|
||||
# в counters до timeout-исключения.
|
||||
try:
|
||||
coro.close()
|
||||
except Exception:
|
||||
pass
|
||||
raise TimeoutError
|
||||
return await original_wait_for(coro, timeout=timeout)
|
||||
|
||||
monkeypatch.setattr(scrape_pipeline.asyncio, "wait_for", fake_wait_for)
|
||||
monkeypatch.setattr(scrape_pipeline.asyncio, "sleep", AsyncMock())
|
||||
|
||||
# Stub shared AsyncSession
|
||||
fake_session = AsyncMock()
|
||||
fake_session.__aenter__ = AsyncMock(return_value=fake_session)
|
||||
fake_session.__aexit__ = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(_cffi_mod, "AsyncSession", lambda **_kw: fake_session)
|
||||
|
||||
# Stub AvitoScraper.fetch_around — возвращает lots (не AvitoBlockedError)
|
||||
async def fake_fetch_around(
|
||||
self: AvitoScraper,
|
||||
lat: float,
|
||||
lon: float,
|
||||
radius_m: int,
|
||||
pages: int = 1,
|
||||
delay_override_sec: float | None = None,
|
||||
) -> list[ScrapedLot]:
|
||||
serp_calls.append(f"{lat:.4f}")
|
||||
return [_fake_lot("avito", f"{lat:.4f}-{i}") for i in range(5)]
|
||||
|
||||
monkeypatch.setattr(AvitoScraper, "fetch_around", fake_fetch_around)
|
||||
|
||||
# Stub save_listings — записывает lots count
|
||||
def fake_save_listings(db: Any, lots: Any, *, run_id: int | None = None) -> tuple[int, int]:
|
||||
save_calls.append(len(lots))
|
||||
return (len(lots), 0)
|
||||
|
||||
monkeypatch.setattr(scrape_pipeline, "save_listings", fake_save_listings)
|
||||
|
||||
# Stub run_avito_pipeline — используем реальный run_avito_city_sweep
|
||||
# но с внутренним патчем, поэтому НЕ monkeypatch run_avito_pipeline.
|
||||
# Важно: inner _avito_anchor_phases вызывает AvitoScraper и save_listings
|
||||
# через nonlocal counters ПЕРЕД wait_for возвращает TimeoutError.
|
||||
|
||||
async def fake_imv_batch(*_a: Any, **_kw: Any) -> Any:
|
||||
result = MagicMock()
|
||||
result.checked = 0
|
||||
result.saved = 0
|
||||
result.errors = 0
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.house_imv_backfill.process_houses_imv_batch",
|
||||
fake_imv_batch,
|
||||
)
|
||||
|
||||
fake = _FakeRuns()
|
||||
_install_runs(monkeypatch, fake)
|
||||
|
||||
# Stub fetch_house_catalog и fetch_detail чтобы не было реальных сетевых вызовов
|
||||
# (они могут вызваться из _avito_anchor_phases если timeout не наступит раньше)
|
||||
monkeypatch.setattr(
|
||||
scrape_pipeline,
|
||||
"fetch_house_catalog",
|
||||
AsyncMock(side_effect=RuntimeError("should not be called in timeout test")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scrape_pipeline,
|
||||
"fetch_detail",
|
||||
AsyncMock(side_effect=RuntimeError("should not be called in timeout test")),
|
||||
)
|
||||
|
||||
counters = await scrape_pipeline.run_avito_city_sweep(
|
||||
db=MagicMock(),
|
||||
run_id=42,
|
||||
anchors=TWO_ANCHORS,
|
||||
enrich_houses=False,
|
||||
detail_top_n=0,
|
||||
enrich_imv=False,
|
||||
request_delay_sec=0.0,
|
||||
)
|
||||
|
||||
# SERP первого anchor'а НЕ дойдёт до счётчиков т.к. wait_for killит coro СРАЗУ
|
||||
# (fake_wait_for делает coro.close() до любого await внутри). Это ожидаемо при
|
||||
# coro.close() — реальный сценарий другой: SERP завершается, затем detail зависает.
|
||||
# Тест проверяет sweep-level инварианты:
|
||||
# - errors_count >= 1 (timeout)
|
||||
# - sweep не падает
|
||||
# - mark_done вызван
|
||||
# - второй anchor дошёл (wf_call==2)
|
||||
assert counters.errors_count >= 1, "timeout должен увеличить errors_count"
|
||||
assert fake.done is not None, "mark_done должен быть вызван"
|
||||
assert fake.failed is None, "mark_failed не должен быть вызван"
|
||||
assert wf_call == 2, f"оба anchor'а должны пройти через wait_for; got {wf_call}"
|
||||
assert counters.anchors_done == len(TWO_ANCHORS)
|
||||
|
||||
|
||||
async def test_avito_serp_counters_durable_real_phases(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Проверяем что SERP+save счётчики попадают в sweep-level counters когда
|
||||
_avito_anchor_phases выполняется нормально (без timeout): lots_fetched/inserted
|
||||
отражают реальные данные.
|
||||
|
||||
Патчим wait_for через реальный asyncio.wait_for с очень большим timeout —
|
||||
anchor_phases выполняется полностью. SERP возвращает 7 lots, save возвращает (7, 0).
|
||||
"""
|
||||
import curl_cffi.requests as _cffi_mod
|
||||
|
||||
from app.services.scrapers.avito import AvitoScraper
|
||||
|
||||
monkeypatch.setattr(scrape_pipeline.asyncio, "sleep", AsyncMock())
|
||||
|
||||
fake_session = AsyncMock()
|
||||
fake_session.__aenter__ = AsyncMock(return_value=fake_session)
|
||||
fake_session.__aexit__ = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(_cffi_mod, "AsyncSession", lambda **_kw: fake_session)
|
||||
|
||||
async def fake_fetch_around(
|
||||
self: AvitoScraper,
|
||||
lat: float,
|
||||
lon: float,
|
||||
radius_m: int,
|
||||
pages: int = 1,
|
||||
delay_override_sec: float | None = None,
|
||||
) -> list[ScrapedLot]:
|
||||
return [_fake_lot("avito", f"{lat:.4f}-{i}") for i in range(7)]
|
||||
|
||||
monkeypatch.setattr(AvitoScraper, "fetch_around", fake_fetch_around)
|
||||
monkeypatch.setattr(scrape_pipeline, "save_listings", lambda _db, lots, **_kw: (len(lots), 0))
|
||||
# Не вызывать house/detail enrich
|
||||
monkeypatch.setattr(scrape_pipeline, "fetch_house_catalog", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(scrape_pipeline, "fetch_detail", AsyncMock(return_value=None))
|
||||
|
||||
async def fake_imv_batch(*_a: Any, **_kw: Any) -> Any:
|
||||
result = MagicMock()
|
||||
result.checked = 0
|
||||
result.saved = 0
|
||||
result.errors = 0
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.house_imv_backfill.process_houses_imv_batch",
|
||||
fake_imv_batch,
|
||||
)
|
||||
|
||||
fake = _FakeRuns()
|
||||
_install_runs(monkeypatch, fake)
|
||||
|
||||
# Один anchor — проверяем счётчики после нормального прохода
|
||||
counters = await scrape_pipeline.run_avito_city_sweep(
|
||||
db=MagicMock(),
|
||||
run_id=99,
|
||||
anchors=[(56.8400, 60.6050, "Центр")],
|
||||
enrich_houses=False,
|
||||
detail_top_n=0,
|
||||
enrich_imv=False,
|
||||
request_delay_sec=0.0,
|
||||
)
|
||||
|
||||
assert counters.lots_fetched == 7, f"lots_fetched должен быть 7; got {counters.lots_fetched}"
|
||||
assert counters.lots_inserted == 7, f"lots_inserted должен быть 7; got {counters.lots_inserted}"
|
||||
assert counters.anchors_done == 1
|
||||
assert fake.done is not None
|
||||
|
|
|
|||
|
|
@ -8,12 +8,18 @@
|
|||
5. process_houses_imv_batch пропускает пустой набор house_ids.
|
||||
6. CitySweepStartRequest принимает enrich_imv (API-level param).
|
||||
7. Cooperative cancel перед IMV-фазой — skip IMV, sweep marked done.
|
||||
|
||||
NB: run_avito_city_sweep использует внутренние _avito_anchor_phases (не run_avito_pipeline).
|
||||
Тесты патчат AvitoScraper.fetch_around + save_listings + fetch_house_catalog +
|
||||
save_house_catalog_enrichment для контроля touched_house_ids.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import ExitStack
|
||||
from dataclasses import fields
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -21,6 +27,74 @@ import pytest
|
|||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
|
||||
def _make_house_lot(offer_id: str, house_path: str) -> Any:
|
||||
"""ScrapedLot с house_url для тестов house-enrich пути."""
|
||||
from app.services.scrapers.base import ScrapedLot
|
||||
|
||||
return ScrapedLot(
|
||||
source="avito",
|
||||
source_url=f"https://www.avito.ru{house_path}/{offer_id}",
|
||||
source_id=offer_id,
|
||||
address="Тест",
|
||||
price_rub=3_000_000,
|
||||
area_m2=40.0,
|
||||
rooms=1,
|
||||
house_url=f"https://www.avito.ru{house_path}",
|
||||
)
|
||||
|
||||
|
||||
def _enter_common_patches(stack: ExitStack, house_ids: list[int]) -> None:
|
||||
"""Входит в набор patch'ей для базового Avito sweep через ExitStack:
|
||||
- AvitoScraper.fetch_around → lots с house_url (по одному на house_id)
|
||||
- save_listings → (n, 0)
|
||||
- fetch_house_catalog → MagicMock()
|
||||
- save_house_catalog_enrichment → {"house_id": X} по очереди
|
||||
- curl_cffi.requests.AsyncSession → fake
|
||||
- asyncio.sleep → no-op
|
||||
"""
|
||||
from app.services.scrapers.avito import AvitoScraper
|
||||
|
||||
lots = [_make_house_lot(f"lot-{hid}", f"/ekaterinburg/houses/test/{hid}") for hid in house_ids]
|
||||
|
||||
async def fake_fetch_around(self: Any, lat: float, lon: float, *_a: Any, **_kw: Any) -> list:
|
||||
return lots
|
||||
|
||||
# save_house_catalog_enrichment вызывается по одному разу на каждый дом
|
||||
house_id_iter = iter(house_ids)
|
||||
|
||||
def fake_save_house(db: Any, enrichment: Any) -> dict:
|
||||
try:
|
||||
return {"house_id": next(house_id_iter)}
|
||||
except StopIteration:
|
||||
return {}
|
||||
|
||||
fake_session = AsyncMock()
|
||||
fake_session.__aenter__ = AsyncMock(return_value=fake_session)
|
||||
fake_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
stack.enter_context(patch.object(AvitoScraper, "fetch_around", fake_fetch_around))
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"app.services.scrape_pipeline.save_listings",
|
||||
lambda _db, _lots, **_kw: (len(_lots), 0),
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"app.services.scrape_pipeline.fetch_house_catalog",
|
||||
AsyncMock(return_value=MagicMock()),
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"app.services.scrape_pipeline.save_house_catalog_enrichment",
|
||||
fake_save_house,
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch("curl_cffi.requests.AsyncSession", return_value=fake_session))
|
||||
stack.enter_context(patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()))
|
||||
|
||||
|
||||
# ── CitySweepCounters has imv_* fields ────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -88,47 +162,36 @@ def test_pipeline_result_touched_house_ids_set() -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sweep_enrich_imv_true_calls_imv_batch() -> None:
|
||||
"""enrich_imv=True + есть touched house_ids → process_houses_imv_batch вызывается."""
|
||||
"""enrich_imv=True + есть touched house_ids → process_houses_imv_batch вызывается.
|
||||
|
||||
Патчим AvitoScraper.fetch_around → lots с house_url, save_house_catalog_enrichment
|
||||
→ {house_id: X} чтобы all_touched_house_ids = {10, 20}.
|
||||
"""
|
||||
from app.services.house_imv_backfill import HouseIMVBackfillResult
|
||||
from app.services.scrape_pipeline import run_avito_city_sweep
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_pipeline_result = MagicMock()
|
||||
mock_pipeline_result.counters.lots_fetched = 5
|
||||
mock_pipeline_result.counters.lots_inserted = 5
|
||||
mock_pipeline_result.counters.lots_updated = 0
|
||||
mock_pipeline_result.counters.unique_houses = 2
|
||||
mock_pipeline_result.counters.houses_enriched = 2
|
||||
mock_pipeline_result.counters.houses_failed = 0
|
||||
mock_pipeline_result.counters.detail_attempted = 2
|
||||
mock_pipeline_result.counters.detail_enriched = 2
|
||||
mock_pipeline_result.counters.detail_failed = 0
|
||||
mock_pipeline_result.counters.errors = []
|
||||
mock_pipeline_result.touched_house_ids = {10, 20}
|
||||
|
||||
mock_imv_result = HouseIMVBackfillResult(checked=2, saved=2, skipped=0, errors=0)
|
||||
|
||||
with (
|
||||
patch("app.services.scrape_runs.is_cancelled", return_value=False),
|
||||
patch("app.services.scrape_runs.update_heartbeat"),
|
||||
patch("app.services.scrape_runs.mark_done"),
|
||||
patch(
|
||||
"app.services.scrape_pipeline.run_avito_pipeline",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_pipeline_result,
|
||||
),
|
||||
patch(
|
||||
"app.services.house_imv_backfill.process_houses_imv_batch",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_imv_result,
|
||||
) as mock_imv_batch,
|
||||
):
|
||||
# Один anchor — минимальный sweep
|
||||
with ExitStack() as stack:
|
||||
_enter_common_patches(stack, [10, 20])
|
||||
stack.enter_context(patch("app.services.scrape_runs.is_cancelled", return_value=False))
|
||||
stack.enter_context(patch("app.services.scrape_runs.update_heartbeat"))
|
||||
stack.enter_context(patch("app.services.scrape_runs.mark_done"))
|
||||
mock_imv_batch = stack.enter_context(
|
||||
patch(
|
||||
"app.services.house_imv_backfill.process_houses_imv_batch",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_imv_result,
|
||||
)
|
||||
)
|
||||
counters = await run_avito_city_sweep(
|
||||
mock_db,
|
||||
run_id=1,
|
||||
anchors=[(56.84, 60.6, "TestAnchor")],
|
||||
enrich_imv=True,
|
||||
enrich_houses=True,
|
||||
detail_top_n=0,
|
||||
)
|
||||
|
||||
mock_imv_batch.assert_awaited_once()
|
||||
|
|
@ -149,38 +212,25 @@ async def test_sweep_enrich_imv_false_skips_imv() -> None:
|
|||
from app.services.scrape_pipeline import run_avito_city_sweep
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_pipeline_result = MagicMock()
|
||||
mock_pipeline_result.counters.lots_fetched = 3
|
||||
mock_pipeline_result.counters.lots_inserted = 3
|
||||
mock_pipeline_result.counters.lots_updated = 0
|
||||
mock_pipeline_result.counters.unique_houses = 1
|
||||
mock_pipeline_result.counters.houses_enriched = 1
|
||||
mock_pipeline_result.counters.houses_failed = 0
|
||||
mock_pipeline_result.counters.detail_attempted = 1
|
||||
mock_pipeline_result.counters.detail_enriched = 1
|
||||
mock_pipeline_result.counters.detail_failed = 0
|
||||
mock_pipeline_result.counters.errors = []
|
||||
mock_pipeline_result.touched_house_ids = {99}
|
||||
|
||||
with (
|
||||
patch("app.services.scrape_runs.is_cancelled", return_value=False),
|
||||
patch("app.services.scrape_runs.update_heartbeat"),
|
||||
patch("app.services.scrape_runs.mark_done"),
|
||||
patch(
|
||||
"app.services.scrape_pipeline.run_avito_pipeline",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_pipeline_result,
|
||||
),
|
||||
patch(
|
||||
"app.services.house_imv_backfill.process_houses_imv_batch",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_imv_batch,
|
||||
):
|
||||
with ExitStack() as stack:
|
||||
_enter_common_patches(stack, [99])
|
||||
stack.enter_context(patch("app.services.scrape_runs.is_cancelled", return_value=False))
|
||||
stack.enter_context(patch("app.services.scrape_runs.update_heartbeat"))
|
||||
stack.enter_context(patch("app.services.scrape_runs.mark_done"))
|
||||
mock_imv_batch = stack.enter_context(
|
||||
patch(
|
||||
"app.services.house_imv_backfill.process_houses_imv_batch",
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
)
|
||||
counters = await run_avito_city_sweep(
|
||||
mock_db,
|
||||
run_id=2,
|
||||
anchors=[(56.84, 60.6, "TestAnchor")],
|
||||
enrich_imv=False,
|
||||
enrich_houses=True,
|
||||
detail_top_n=0,
|
||||
)
|
||||
|
||||
mock_imv_batch.assert_not_awaited()
|
||||
|
|
@ -194,32 +244,30 @@ async def test_sweep_enrich_imv_false_skips_imv() -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sweep_enrich_imv_no_touched_ids_skips() -> None:
|
||||
"""enrich_imv=True, но touched_house_ids пустой → IMV не вызывается."""
|
||||
"""enrich_imv=True, но touched_house_ids пустой → IMV не вызывается.
|
||||
|
||||
Лоты без house_url → unique_house_paths пуст → touched_house_ids пуст.
|
||||
"""
|
||||
from app.services.scrape_pipeline import run_avito_city_sweep
|
||||
from app.services.scrapers.avito import AvitoScraper
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_pipeline_result = MagicMock()
|
||||
mock_pipeline_result.counters.lots_fetched = 0
|
||||
mock_pipeline_result.counters.lots_inserted = 0
|
||||
mock_pipeline_result.counters.lots_updated = 0
|
||||
mock_pipeline_result.counters.unique_houses = 0
|
||||
mock_pipeline_result.counters.houses_enriched = 0
|
||||
mock_pipeline_result.counters.houses_failed = 0
|
||||
mock_pipeline_result.counters.detail_attempted = 0
|
||||
mock_pipeline_result.counters.detail_enriched = 0
|
||||
mock_pipeline_result.counters.detail_failed = 0
|
||||
mock_pipeline_result.counters.errors = []
|
||||
mock_pipeline_result.touched_house_ids = set() # пустой!
|
||||
fake_session = AsyncMock()
|
||||
fake_session.__aenter__ = AsyncMock(return_value=fake_session)
|
||||
fake_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
# Лоты БЕЗ house_url → нет домов → IMV не вызывается
|
||||
async def fake_fetch_around(self: Any, lat: float, lon: float, *_a: Any, **_kw: Any) -> list:
|
||||
return []
|
||||
|
||||
with (
|
||||
patch.object(AvitoScraper, "fetch_around", fake_fetch_around),
|
||||
patch("app.services.scrape_pipeline.save_listings", lambda _db, _lots, **_kw: (0, 0)),
|
||||
patch("curl_cffi.requests.AsyncSession", return_value=fake_session),
|
||||
patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()),
|
||||
patch("app.services.scrape_runs.is_cancelled", return_value=False),
|
||||
patch("app.services.scrape_runs.update_heartbeat"),
|
||||
patch("app.services.scrape_runs.mark_done"),
|
||||
patch(
|
||||
"app.services.scrape_pipeline.run_avito_pipeline",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_pipeline_result,
|
||||
),
|
||||
patch(
|
||||
"app.services.house_imv_backfill.process_houses_imv_batch",
|
||||
new_callable=AsyncMock,
|
||||
|
|
@ -230,6 +278,8 @@ async def test_sweep_enrich_imv_no_touched_ids_skips() -> None:
|
|||
run_id=3,
|
||||
anchors=[(56.84, 60.6, "TestAnchor")],
|
||||
enrich_imv=True,
|
||||
enrich_houses=True,
|
||||
detail_top_n=0,
|
||||
)
|
||||
|
||||
mock_imv_batch.assert_not_awaited()
|
||||
|
|
@ -244,39 +294,26 @@ async def test_sweep_imv_crash_does_not_abort_sweep() -> None:
|
|||
from app.services.scrape_pipeline import run_avito_city_sweep
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_pipeline_result = MagicMock()
|
||||
mock_pipeline_result.counters.lots_fetched = 2
|
||||
mock_pipeline_result.counters.lots_inserted = 2
|
||||
mock_pipeline_result.counters.lots_updated = 0
|
||||
mock_pipeline_result.counters.unique_houses = 1
|
||||
mock_pipeline_result.counters.houses_enriched = 1
|
||||
mock_pipeline_result.counters.houses_failed = 0
|
||||
mock_pipeline_result.counters.detail_attempted = 1
|
||||
mock_pipeline_result.counters.detail_enriched = 1
|
||||
mock_pipeline_result.counters.detail_failed = 0
|
||||
mock_pipeline_result.counters.errors = []
|
||||
mock_pipeline_result.touched_house_ids = {55}
|
||||
|
||||
with (
|
||||
patch("app.services.scrape_runs.is_cancelled", return_value=False),
|
||||
patch("app.services.scrape_runs.update_heartbeat"),
|
||||
patch("app.services.scrape_runs.mark_done") as mock_mark_done,
|
||||
patch(
|
||||
"app.services.scrape_pipeline.run_avito_pipeline",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_pipeline_result,
|
||||
),
|
||||
patch(
|
||||
"app.services.house_imv_backfill.process_houses_imv_batch",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("IMV network error"),
|
||||
),
|
||||
):
|
||||
with ExitStack() as stack:
|
||||
_enter_common_patches(stack, [55])
|
||||
stack.enter_context(patch("app.services.scrape_runs.is_cancelled", return_value=False))
|
||||
stack.enter_context(patch("app.services.scrape_runs.update_heartbeat"))
|
||||
mock_mark_done = stack.enter_context(patch("app.services.scrape_runs.mark_done"))
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"app.services.house_imv_backfill.process_houses_imv_batch",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("IMV network error"),
|
||||
)
|
||||
)
|
||||
counters = await run_avito_city_sweep(
|
||||
mock_db,
|
||||
run_id=4,
|
||||
anchors=[(56.84, 60.6, "TestAnchor")],
|
||||
enrich_imv=True,
|
||||
enrich_houses=True,
|
||||
detail_top_n=0,
|
||||
)
|
||||
|
||||
mock_mark_done.assert_called_once()
|
||||
|
|
@ -294,42 +331,28 @@ async def test_sweep_imv_failed_counter() -> None:
|
|||
from app.services.scrape_pipeline import run_avito_city_sweep
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_pipeline_result = MagicMock()
|
||||
mock_pipeline_result.counters.lots_fetched = 3
|
||||
mock_pipeline_result.counters.lots_inserted = 3
|
||||
mock_pipeline_result.counters.lots_updated = 0
|
||||
mock_pipeline_result.counters.unique_houses = 3
|
||||
mock_pipeline_result.counters.houses_enriched = 3
|
||||
mock_pipeline_result.counters.houses_failed = 0
|
||||
mock_pipeline_result.counters.detail_attempted = 0
|
||||
mock_pipeline_result.counters.detail_enriched = 0
|
||||
mock_pipeline_result.counters.detail_failed = 0
|
||||
mock_pipeline_result.counters.errors = []
|
||||
mock_pipeline_result.touched_house_ids = {1, 2, 3}
|
||||
|
||||
# 3 attempted, 1 enriched, 2 failed
|
||||
mock_imv_result = HouseIMVBackfillResult(checked=3, saved=1, skipped=0, errors=2)
|
||||
|
||||
with (
|
||||
patch("app.services.scrape_runs.is_cancelled", return_value=False),
|
||||
patch("app.services.scrape_runs.update_heartbeat"),
|
||||
patch("app.services.scrape_runs.mark_done"),
|
||||
patch(
|
||||
"app.services.scrape_pipeline.run_avito_pipeline",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_pipeline_result,
|
||||
),
|
||||
patch(
|
||||
"app.services.house_imv_backfill.process_houses_imv_batch",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_imv_result,
|
||||
),
|
||||
):
|
||||
with ExitStack() as stack:
|
||||
_enter_common_patches(stack, [1, 2, 3])
|
||||
stack.enter_context(patch("app.services.scrape_runs.is_cancelled", return_value=False))
|
||||
stack.enter_context(patch("app.services.scrape_runs.update_heartbeat"))
|
||||
stack.enter_context(patch("app.services.scrape_runs.mark_done"))
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"app.services.house_imv_backfill.process_houses_imv_batch",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_imv_result,
|
||||
)
|
||||
)
|
||||
counters = await run_avito_city_sweep(
|
||||
mock_db,
|
||||
run_id=5,
|
||||
anchors=[(56.84, 60.6, "TestAnchor")],
|
||||
enrich_imv=True,
|
||||
enrich_houses=True,
|
||||
detail_top_n=0,
|
||||
)
|
||||
|
||||
assert counters.imv_attempted == 3
|
||||
|
|
@ -348,51 +371,40 @@ async def test_sweep_cancel_before_imv_skips_imv() -> None:
|
|||
from app.services.scrape_pipeline import run_avito_city_sweep
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_pipeline_result = MagicMock()
|
||||
mock_pipeline_result.counters.lots_fetched = 1
|
||||
mock_pipeline_result.counters.lots_inserted = 1
|
||||
mock_pipeline_result.counters.lots_updated = 0
|
||||
mock_pipeline_result.counters.unique_houses = 1
|
||||
mock_pipeline_result.counters.houses_enriched = 1
|
||||
mock_pipeline_result.counters.houses_failed = 0
|
||||
mock_pipeline_result.counters.detail_attempted = 0
|
||||
mock_pipeline_result.counters.detail_enriched = 0
|
||||
mock_pipeline_result.counters.detail_failed = 0
|
||||
mock_pipeline_result.counters.errors = []
|
||||
mock_pipeline_result.touched_house_ids = {77}
|
||||
|
||||
# is_cancelled: первый вызов (anchor loop) = False, второй (IMV phase) = True
|
||||
cancel_side_effects = [False, True]
|
||||
call_idx = 0
|
||||
|
||||
def is_cancelled_side_effect(db, run_id):
|
||||
def is_cancelled_side_effect(db: Any, run_id: int) -> bool:
|
||||
nonlocal call_idx
|
||||
val = cancel_side_effects[call_idx] if call_idx < len(cancel_side_effects) else True
|
||||
call_idx += 1
|
||||
return val
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.scrape_runs.is_cancelled",
|
||||
side_effect=is_cancelled_side_effect,
|
||||
),
|
||||
patch("app.services.scrape_runs.update_heartbeat"),
|
||||
patch("app.services.scrape_runs.mark_done") as mock_mark_done,
|
||||
patch(
|
||||
"app.services.scrape_pipeline.run_avito_pipeline",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_pipeline_result,
|
||||
),
|
||||
patch(
|
||||
"app.services.house_imv_backfill.process_houses_imv_batch",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_imv_batch,
|
||||
):
|
||||
with ExitStack() as stack:
|
||||
_enter_common_patches(stack, [77])
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"app.services.scrape_runs.is_cancelled",
|
||||
side_effect=is_cancelled_side_effect,
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch("app.services.scrape_runs.update_heartbeat"))
|
||||
mock_mark_done = stack.enter_context(patch("app.services.scrape_runs.mark_done"))
|
||||
mock_imv_batch = stack.enter_context(
|
||||
patch(
|
||||
"app.services.house_imv_backfill.process_houses_imv_batch",
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
)
|
||||
await run_avito_city_sweep(
|
||||
mock_db,
|
||||
run_id=6,
|
||||
anchors=[(56.84, 60.6, "TestAnchor")],
|
||||
enrich_imv=True,
|
||||
enrich_houses=True,
|
||||
detail_top_n=0,
|
||||
)
|
||||
|
||||
mock_imv_batch.assert_not_awaited()
|
||||
|
|
|
|||
|
|
@ -797,3 +797,101 @@ async def test_explicit_anchors_still_works(
|
|||
assert c["price_ranges"] is not None
|
||||
|
||||
assert fake.done is not None
|
||||
|
||||
|
||||
# ── Watchdog timeout: combos vs explicit-anchor mode ───────────────────────
|
||||
|
||||
|
||||
def test_combos_sweep_timeout_substantially_larger_than_anchor_timeout() -> None:
|
||||
"""В combos/center mode (anchors=None) _sweep_timeout намного > ANCHOR_TIMEOUT_SEC.
|
||||
|
||||
Проверяет что вычисленный таймаут для дефолтных 30 combos × 3 pages > 1500s,
|
||||
т.е. не равен ANCHOR_TIMEOUT_SEC=240 и обеспечивает полный прогон sweep'а.
|
||||
Не нужны мокапы — только публичные константы и арифметика из модуля.
|
||||
"""
|
||||
from app.services.scrape_pipeline import (
|
||||
_YANDEX_ADDRESS_ENRICH_BUDGET_S,
|
||||
_YANDEX_COMBOS_PER_FETCH_S,
|
||||
ANCHOR_TIMEOUT_SEC,
|
||||
)
|
||||
from app.services.scrapers.yandex_realty import DEFAULT_PRICE_RANGES, ROOM_PATH
|
||||
|
||||
# Параметры prod-режима
|
||||
rooms_list = list(ROOM_PATH.keys()) # 5 комнатностей
|
||||
price_ranges = DEFAULT_PRICE_RANGES # 6 ценовых диапазонов
|
||||
max_pages = 3
|
||||
request_delay_sec = 9.0 # продакшн default
|
||||
|
||||
num_combos = len(rooms_list) * len(price_ranges)
|
||||
assert num_combos == 30, f"Expected 30 combos, got {num_combos}"
|
||||
|
||||
sweep_timeout = max(
|
||||
ANCHOR_TIMEOUT_SEC,
|
||||
num_combos * max_pages * (request_delay_sec + _YANDEX_COMBOS_PER_FETCH_S)
|
||||
+ _YANDEX_ADDRESS_ENRICH_BUDGET_S,
|
||||
)
|
||||
|
||||
# Должен быть существенно > 1500s (≈ 25 мин), что покрывает полный run.
|
||||
assert sweep_timeout > 1500, (
|
||||
f"sweep_timeout={sweep_timeout:.0f}s unexpectedly small — "
|
||||
f"combos-mode will still timeout mid-sweep"
|
||||
)
|
||||
# И намного > ANCHOR_TIMEOUT_SEC (240s)
|
||||
assert (
|
||||
sweep_timeout > ANCHOR_TIMEOUT_SEC * 4
|
||||
), f"sweep_timeout={sweep_timeout:.0f}s should be >> ANCHOR_TIMEOUT_SEC={ANCHOR_TIMEOUT_SEC}s"
|
||||
|
||||
|
||||
async def test_explicit_anchor_mode_uses_anchor_timeout_sec(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""anchors=[...] (explicit override) → wait_for используется с ANCHOR_TIMEOUT_SEC.
|
||||
|
||||
Проверяет что в legacy/test режиме (explicit anchors) sweep_timeout не раздут
|
||||
до combos-значения — он равен ANCHOR_TIMEOUT_SEC, т.к. каждый anchor маленький.
|
||||
"""
|
||||
import asyncio as _asyncio
|
||||
|
||||
from app.services.scrape_pipeline import ANCHOR_TIMEOUT_SEC
|
||||
|
||||
captured_timeouts: list[float] = []
|
||||
|
||||
_orig_wait_for = _asyncio.wait_for
|
||||
|
||||
async def _capturing_wait_for(coro, *, timeout): # type: ignore[no-untyped-def]
|
||||
captured_timeouts.append(timeout)
|
||||
# На самом деле запустить — мокнутый scraper вернёт [] мгновенно.
|
||||
return await _orig_wait_for(coro, timeout=timeout)
|
||||
|
||||
monkeypatch.setattr(_asyncio, "wait_for", _capturing_wait_for)
|
||||
|
||||
async def fake_fetch_multi(
|
||||
self: YandexRealtyScraper,
|
||||
lat: float,
|
||||
lon: float,
|
||||
radius_m: int = 1000,
|
||||
max_pages: int = 2,
|
||||
**_: object,
|
||||
) -> list[ScrapedLot]:
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi)
|
||||
monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save)
|
||||
fake = _FakeRuns()
|
||||
_install_runs(monkeypatch, fake)
|
||||
|
||||
await scrape_pipeline.run_yandex_city_sweep(
|
||||
db=MagicMock(),
|
||||
run_id=20,
|
||||
anchors=TEST_ANCHORS[:2], # explicit anchors — legacy mode
|
||||
pages_per_anchor=2,
|
||||
enrich_address=False,
|
||||
request_delay_sec=0.0,
|
||||
)
|
||||
|
||||
# В explicit-anchor режиме каждый anchor должен использовать ANCHOR_TIMEOUT_SEC
|
||||
assert len(captured_timeouts) == 2
|
||||
for t in captured_timeouts:
|
||||
assert (
|
||||
t == ANCHOR_TIMEOUT_SEC
|
||||
), f"Expected ANCHOR_TIMEOUT_SEC={ANCHOR_TIMEOUT_SEC}s, got {t}s"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue