diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index 71ee7f53..1c10cf23 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -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/**' diff --git a/backend/app/api/v1/admin_scrape.py b/backend/app/api/v1/admin_scrape.py index 6dad9e82..c8a1293e 100644 --- a/backend/app/api/v1/admin_scrape.py +++ b/backend/app/api/v1/admin_scrape.py @@ -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( diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index a12e1946..926b2e88 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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, diff --git a/backend/app/api/v1/photos.py b/backend/app/api/v1/photos.py index 7b3c5d7d..2f781f8a 100644 --- a/backend/app/api/v1/photos.py +++ b/backend/app/api/v1/photos.py @@ -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) diff --git a/backend/app/api/v1/trade_in.py b/backend/app/api/v1/trade_in.py index ed771099..e67eee7a 100644 --- a/backend/app/api/v1/trade_in.py +++ b/backend/app/api/v1/trade_in.py @@ -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 [])] diff --git a/backend/app/main.py b/backend/app/main.py index 82910f2b..296ab9a2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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(), diff --git a/backend/app/models/job_settings.py b/backend/app/models/job_settings.py index 76365043..efa62293 100644 --- a/backend/app/models/job_settings.py +++ b/backend/app/models/job_settings.py @@ -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) diff --git a/backend/app/models/parcel.py b/backend/app/models/parcel.py index 99a86ad2..bc0627d7 100644 --- a/backend/app/models/parcel.py +++ b/backend/app/models/parcel.py @@ -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 для регистрации будущих моделей) diff --git a/backend/app/schemas/chat.py b/backend/app/schemas/chat.py index fdd625f7..4aa494a7 100644 --- a/backend/app/schemas/chat.py +++ b/backend/app/schemas/chat.py @@ -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-текст). diff --git a/backend/app/schemas/concept.py b/backend/app/schemas/concept.py index 4a00b50b..e4625448 100644 --- a/backend/app/schemas/concept.py +++ b/backend/app/schemas/concept.py @@ -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): diff --git a/backend/app/scrapers/nspd_bulk_client.py b/backend/app/scrapers/nspd_bulk_client.py index e2875e2c..c9dc89f3 100644 --- a/backend/app/scrapers/nspd_bulk_client.py +++ b/backend/app/scrapers/nspd_bulk_client.py @@ -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, diff --git a/backend/app/services/analysis_runs/repository.py b/backend/app/services/analysis_runs/repository.py index 5267f7f3..c7004b8f 100644 --- a/backend/app/services/analysis_runs/repository.py +++ b/backend/app/services/analysis_runs/repository.py @@ -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( diff --git a/backend/app/services/analytics/ddu_price_indicator.py b/backend/app/services/analytics/ddu_price_indicator.py index 3958d4b2..cf7268e8 100644 --- a/backend/app/services/analytics/ddu_price_indicator.py +++ b/backend/app/services/analytics/ddu_price_indicator.py @@ -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 diff --git a/backend/app/services/analytics/velocity_alerts.py b/backend/app/services/analytics/velocity_alerts.py index 61160284..89a1bfcb 100644 --- a/backend/app/services/analytics/velocity_alerts.py +++ b/backend/app/services/analytics/velocity_alerts.py @@ -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 ), diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index 2083ed3a..9abb8021 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -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 diff --git a/backend/app/services/cadastre/bulk_harvest.py b/backend/app/services/cadastre/bulk_harvest.py index c5726b64..5e4aad8c 100644 --- a/backend/app/services/cadastre/bulk_harvest.py +++ b/backend/app/services/cadastre/bulk_harvest.py @@ -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), }, diff --git a/backend/app/services/cadastre/grid_geometry.py b/backend/app/services/cadastre/grid_geometry.py index 40c4cb7c..281f38ec 100644 --- a/backend/app/services/cadastre/grid_geometry.py +++ b/backend/app/services/cadastre/grid_geometry.py @@ -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 " diff --git a/backend/app/services/chat/intents.py b/backend/app/services/chat/intents.py index 59c2ff75..d821c7e9 100644 --- a/backend/app/services/chat/intents.py +++ b/backend/app/services/chat/intents.py @@ -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="будущий рынок")) diff --git a/backend/app/services/etl/objective_backfill.py b/backend/app/services/etl/objective_backfill.py index bc7023ed..120ce993 100644 --- a/backend/app/services/etl/objective_backfill.py +++ b/backend/app/services/etl/objective_backfill.py @@ -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 diff --git a/backend/app/services/exporters/report_md.py b/backend/app/services/exporters/report_md.py index 2f9caeaa..1c49a0b1 100644 --- a/backend/app/services/exporters/report_md.py +++ b/backend/app/services/exporters/report_md.py @@ -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" diff --git a/backend/app/services/exporters/report_pdf.py b/backend/app/services/exporters/report_pdf.py index f6f74822..3a9faefd 100644 --- a/backend/app/services/exporters/report_pdf.py +++ b/backend/app/services/exporters/report_pdf.py @@ -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( diff --git a/backend/app/services/exporters/snapshot_pdf.py b/backend/app/services/exporters/snapshot_pdf.py index 756e25a6..3f1db81a 100644 --- a/backend/app/services/exporters/snapshot_pdf.py +++ b/backend/app/services/exporters/snapshot_pdf.py @@ -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"), diff --git a/backend/app/services/exporters/trade_in_pdf.py b/backend/app/services/exporters/trade_in_pdf.py index e043d013..c917b295 100644 --- a/backend/app/services/exporters/trade_in_pdf.py +++ b/backend/app/services/exporters/trade_in_pdf.py @@ -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( diff --git a/backend/app/services/forecasting/affordability.py b/backend/app/services/forecasting/affordability.py index 9baf644b..04235f7d 100644 --- a/backend/app/services/forecasting/affordability.py +++ b/backend/app/services/forecasting/affordability.py @@ -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 diff --git a/backend/app/services/forecasting/confidence_engine.py b/backend/app/services/forecasting/confidence_engine.py index 6ae2a02e..16fd9399 100644 --- a/backend/app/services/forecasting/confidence_engine.py +++ b/backend/app/services/forecasting/confidence_engine.py @@ -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: diff --git a/backend/app/services/forecasting/product_scoring.py b/backend/app/services/forecasting/product_scoring.py index cffaa811..aa19d5d5 100644 --- a/backend/app/services/forecasting/product_scoring.py +++ b/backend/app/services/forecasting/product_scoring.py @@ -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: diff --git a/backend/app/services/forecasting/recommendation.py b/backend/app/services/forecasting/recommendation.py index 11654df8..07e592ff 100644 --- a/backend/app/services/forecasting/recommendation.py +++ b/backend/app/services/forecasting/recommendation.py @@ -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) diff --git a/backend/app/services/forecasting/regression.py b/backend/app/services/forecasting/regression.py index cf17dfec..4af2bcb1 100644 --- a/backend/app/services/forecasting/regression.py +++ b/backend/app/services/forecasting/regression.py @@ -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)) diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py index cd45e8f1..9dd27051 100644 --- a/backend/app/services/forecasting/report_assembler.py +++ b/backend/app/services/forecasting/report_assembler.py @@ -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) + "." diff --git a/backend/app/services/forecasting/special_indices.py b/backend/app/services/forecasting/special_indices.py index 79a14142..d5c048f4 100644 --- a/backend/app/services/forecasting/special_indices.py +++ b/backend/app/services/forecasting/special_indices.py @@ -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( diff --git a/backend/app/services/generative/exporters/dxf.py b/backend/app/services/generative/exporters/dxf.py index 5ba88db8..fd1eb296 100644 --- a/backend/app/services/generative/exporters/dxf.py +++ b/backend/app/services/generative/exporters/dxf.py @@ -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") diff --git a/backend/app/services/generative/placement.py b/backend/app/services/generative/placement.py index 677a69be..55441b7b 100644 --- a/backend/app/services/generative/placement.py +++ b/backend/app/services/generative/placement.py @@ -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), diff --git a/backend/app/services/llm/client.py b/backend/app/services/llm/client.py index 86e0c85b..772db0b3 100644 --- a/backend/app/services/llm/client.py +++ b/backend/app/services/llm/client.py @@ -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, diff --git a/backend/app/services/llm/provider.py b/backend/app/services/llm/provider.py index 24be52f0..2dfdd565 100644 --- a/backend/app/services/llm/provider.py +++ b/backend/app/services/llm/provider.py @@ -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), ) diff --git a/backend/app/services/llm/redaction.py b/backend/app/services/llm/redaction.py index fddde4ba..5ace6d7f 100644 --- a/backend/app/services/llm/redaction.py +++ b/backend/app/services/llm/redaction.py @@ -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"(? 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 diff --git a/backend/app/services/objective_etl.py b/backend/app/services/objective_etl.py index 7bdc201c..55efced9 100644 --- a/backend/app/services/objective_etl.py +++ b/backend/app/services/objective_etl.py @@ -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 diff --git a/backend/app/services/photos/thumbs.py b/backend/app/services/photos/thumbs.py index c613ca80..25820162 100644 --- a/backend/app/services/photos/thumbs.py +++ b/backend/app/services/photos/thumbs.py @@ -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) diff --git a/backend/app/services/scrapers/domrf_catalog.py b/backend/app/services/scrapers/domrf_catalog.py index 43eae883..76d3a04c 100644 --- a/backend/app/services/scrapers/domrf_catalog.py +++ b/backend/app/services/scrapers/domrf_catalog.py @@ -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"], ) diff --git a/backend/app/services/scrapers/domrf_kn.py b/backend/app/services/scrapers/domrf_kn.py index 5cdc0094..d018cb6e 100644 --- a/backend/app/services/scrapers/domrf_kn.py +++ b/backend/app/services/scrapers/domrf_kn.py @@ -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) diff --git a/backend/app/services/scrapers/ekburg_permits.py b/backend/app/services/scrapers/ekburg_permits.py index 4a678f95..50217aa8 100644 --- a/backend/app/services/scrapers/ekburg_permits.py +++ b/backend/app/services/scrapers/ekburg_permits.py @@ -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, diff --git a/backend/app/services/scrapers/nspd_denorm.py b/backend/app/services/scrapers/nspd_denorm.py index aef36139..58082cb7 100644 --- a/backend/app/services/scrapers/nspd_denorm.py +++ b/backend/app/services/scrapers/nspd_denorm.py @@ -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, + } diff --git a/backend/app/services/scrapers/nspd_lite.py b/backend/app/services/scrapers/nspd_lite.py index 0015c34c..f439aadf 100644 --- a/backend/app/services/scrapers/nspd_lite.py +++ b/backend/app/services/scrapers/nspd_lite.py @@ -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 diff --git a/backend/app/services/scrapers/obj_checks.py b/backend/app/services/scrapers/obj_checks.py index 23a78aed..9fbf7cb1 100644 --- a/backend/app/services/scrapers/obj_checks.py +++ b/backend/app/services/scrapers/obj_checks.py @@ -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] diff --git a/backend/app/services/scrapers/stealth.py b/backend/app/services/scrapers/stealth.py index acd590af..53b1f635 100644 --- a/backend/app/services/scrapers/stealth.py +++ b/backend/app/services/scrapers/stealth.py @@ -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() diff --git a/backend/app/services/site_finder/competitors.py b/backend/app/services/site_finder/competitors.py index 0235565c..5d436a0a 100644 --- a/backend/app/services/site_finder/competitors.py +++ b/backend/app/services/site_finder/competitors.py @@ -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() ) diff --git a/backend/app/services/site_finder/granddoc_lookup.py b/backend/app/services/site_finder/granddoc_lookup.py index e91c3f5d..6b71b9c0 100644 --- a/backend/app/services/site_finder/granddoc_lookup.py +++ b/backend/app/services/site_finder/granddoc_lookup.py @@ -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"], diff --git a/backend/app/services/site_finder/ppt_tep_lookup.py b/backend/app/services/site_finder/ppt_tep_lookup.py index ef6ebcec..b499970e 100644 --- a/backend/app/services/site_finder/ppt_tep_lookup.py +++ b/backend/app/services/site_finder/ppt_tep_lookup.py @@ -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 """ ) diff --git a/backend/app/services/site_finder/premises_lookup.py b/backend/app/services/site_finder/premises_lookup.py index 892a0be7..dfdb476f 100644 --- a/backend/app/services/site_finder/premises_lookup.py +++ b/backend/app/services/site_finder/premises_lookup.py @@ -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) ближайшего здания в diff --git a/backend/app/services/site_finder/quarter_price_index_refresh.py b/backend/app/services/site_finder/quarter_price_index_refresh.py index 56e7a558..2031f015 100644 --- a/backend/app/services/site_finder/quarter_price_index_refresh.py +++ b/backend/app/services/site_finder/quarter_price_index_refresh.py @@ -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", diff --git a/backend/app/services/site_finder/zone_regulation.py b/backend/app/services/site_finder/zone_regulation.py index 9c00238c..34e653c1 100644 --- a/backend/app/services/site_finder/zone_regulation.py +++ b/backend/app/services/site_finder/zone_regulation.py @@ -78,7 +78,7 @@ _RE_FLOORS = re.compile( + _DASH + r"\s*" + _NUM - + r"\s*этаж", + + r"(?:\s*этаж\w*)?", re.IGNORECASE, ) # высота — 'предельная высота ... – 25 м' (в ЕКБ почти всегда «не подлежат», но для прочих МО) diff --git a/backend/app/services/weather_cache.py b/backend/app/services/weather_cache.py index a037924f..f79c44fb 100644 --- a/backend/app/services/weather_cache.py +++ b/backend/app/services/weather_cache.py @@ -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 { diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index defdc47c..0252d9b7 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -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 diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 22d2345b..cec38d12 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -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" diff --git a/backend/app/workers/lifecycle.py b/backend/app/workers/lifecycle.py index ea12b219..c440c56f 100644 --- a/backend/app/workers/lifecycle.py +++ b/backend/app/workers/lifecycle.py @@ -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() diff --git a/backend/app/workers/tasks/nspd_geo.py b/backend/app/workers/tasks/nspd_geo.py index 3d0c1b74..027a9a1f 100644 --- a/backend/app/workers/tasks/nspd_geo.py +++ b/backend/app/workers/tasks/nspd_geo.py @@ -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() diff --git a/backend/app/workers/tasks/objective_etl.py b/backend/app/workers/tasks/objective_etl.py index da447a15..22ab2a4f 100644 --- a/backend/app/workers/tasks/objective_etl.py +++ b/backend/app/workers/tasks/objective_etl.py @@ -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) diff --git a/backend/app/workers/tasks/opportunity_harvest.py b/backend/app/workers/tasks/opportunity_harvest.py index 5a7589c8..e3e53cc7 100644 --- a/backend/app/workers/tasks/opportunity_harvest.py +++ b/backend/app/workers/tasks/opportunity_harvest.py @@ -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 квартала перед следующим diff --git a/backend/app/workers/tasks/refresh_layout_velocity.py b/backend/app/workers/tasks/refresh_layout_velocity.py new file mode 100644 index 00000000..7f67c4aa --- /dev/null +++ b/backend/app/workers/tasks/refresh_layout_velocity.py @@ -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() diff --git a/backend/app/workers/tasks/scrape_cadastre.py b/backend/app/workers/tasks/scrape_cadastre.py index 70b6fcea..bee2adeb 100644 --- a/backend/app/workers/tasks/scrape_cadastre.py +++ b/backend/app/workers/tasks/scrape_cadastre.py @@ -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" ), { diff --git a/backend/app/workers/tasks/scrape_kn_catalog_objects.py b/backend/app/workers/tasks/scrape_kn_catalog_objects.py index b956e85b..40d5b786 100644 --- a/backend/app/workers/tasks/scrape_kn_catalog_objects.py +++ b/backend/app/workers/tasks/scrape_kn_catalog_objects.py @@ -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, ) diff --git a/backend/scripts/spike_plan_vectorize.py b/backend/scripts/spike_plan_vectorize.py new file mode 100644 index 00000000..2ffd2a77 --- /dev/null +++ b/backend/scripts/spike_plan_vectorize.py @@ -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()) diff --git a/backend/tests/services/forecasting/test_affordability.py b/backend/tests/services/forecasting/test_affordability.py index 2e3dd4f6..153cdba0 100644 --- a/backend/tests/services/forecasting/test_affordability.py +++ b/backend/tests/services/forecasting/test_affordability.py @@ -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] diff --git a/backend/tests/services/test_nspd_denorm.py b/backend/tests/services/test_nspd_denorm.py index 3420a761..ca194b10 100644 --- a/backend/tests/services/test_nspd_denorm.py +++ b/backend/tests/services/test_nspd_denorm.py @@ -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 diff --git a/tradein-mvp/backend/app/services/scrape_pipeline.py b/tradein-mvp/backend/app/services/scrape_pipeline.py index 48a2a015..2d02d49c 100644 --- a/tradein-mvp/backend/app/services/scrape_pipeline.py +++ b/tradein-mvp/backend/app/services/scrape_pipeline.py @@ -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 diff --git a/tradein-mvp/backend/app/services/scrapers/yandex_realty.py b/tradein-mvp/backend/app/services/scrapers/yandex_realty.py index 731e0647..93723356 100644 --- a/tradein-mvp/backend/app/services/scrapers/yandex_realty.py +++ b/tradein-mvp/backend/app/services/scrapers/yandex_realty.py @@ -1,85 +1,58 @@ -"""Yandex.Недвижимость scraper (realty.yandex.ru) — DOM-based SERP parser. +"""Yandex.Nedvizhimost scraper (realty.yandex.ru) -- gate-API JSON parser. -History: Yandex SSR used to embed full state in ` -
- -