fix(week-review): backend-аудит v2 — 82 фиксов (#1660)
Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
parent
50db2b82aa
commit
14f3ef2019
60 changed files with 1345 additions and 397 deletions
|
|
@ -11,7 +11,7 @@ from app.core.db import Base
|
|||
|
||||
# Import models so they register on Base.metadata.
|
||||
# Add new model modules here as they appear.
|
||||
from app.models import parcel # noqa: F401
|
||||
from app.models import job_settings, parcel # noqa: F401
|
||||
|
||||
config = context.config
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import logging
|
|||
from datetime import UTC, datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -268,7 +268,7 @@ def revoke_task(
|
|||
def list_failures(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
run_id: int | None = None,
|
||||
limit: int = 50,
|
||||
limit: int = Query(default=50, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Per-request failure log for manual browser verification."""
|
||||
where = ""
|
||||
|
|
@ -314,7 +314,7 @@ def list_logs(
|
|||
db: Annotated[Session, Depends(get_db)],
|
||||
run_id: int | None = None,
|
||||
since_id: int | None = None,
|
||||
limit: int = 200,
|
||||
limit: int = Query(default=200, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Per-run progress events. Use since_id to poll incrementally:
|
||||
pass the highest log_id seen → returns only newer rows."""
|
||||
|
|
@ -483,7 +483,7 @@ def trigger_objective_etl(
|
|||
@router.get("/objective/runs")
|
||||
def list_objective_runs(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
limit: int = 20,
|
||||
limit: int = Query(default=20, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = (
|
||||
db.execute(
|
||||
|
|
@ -889,14 +889,20 @@ def bulk_enqueue_geo(
|
|||
|
||||
geo_queue = get_setting_value("nspd_geo", "queue_name", "geo")
|
||||
|
||||
# Валидируем ВСЕ thematic_ids ДО любых сайд-эффектов (создание jobs / apply_async),
|
||||
# иначе невалидный id в середине списка приводит к partial execution: для предыдущих
|
||||
# валидных id строки в nspd_geo_jobs уже созданы и задачи улетели в очередь geo,
|
||||
# а клиент получает 400 без идемпотентного отката (#1562).
|
||||
invalid_ids = [tid for tid in payload.thematic_ids if tid not in _THEMATIC_META]
|
||||
if invalid_ids:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"thematic_id={invalid_ids} не поддерживается (допустимы: 1, 2, 5)",
|
||||
)
|
||||
|
||||
jobs_summary: list[dict[str, Any]] = []
|
||||
|
||||
for thematic_id in payload.thematic_ids:
|
||||
if thematic_id not in _THEMATIC_META:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"thematic_id={thematic_id} не поддерживается (допустимы: 1, 2, 5)",
|
||||
)
|
||||
meta = _THEMATIC_META[thematic_id]
|
||||
|
||||
# 1) Собрать cad-номера
|
||||
|
|
@ -967,7 +973,7 @@ def bulk_enqueue_geo(
|
|||
@router.get("/geo/jobs")
|
||||
def list_geo_jobs(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
limit: int = 30,
|
||||
limit: int = Query(default=30, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Список последних geo-jobs (для UI dashboard)."""
|
||||
rows = (
|
||||
|
|
@ -1087,7 +1093,7 @@ def trigger_newbuilding_crossload() -> dict[str, Any]:
|
|||
def list_all_runs(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
scraper_type: str | None = None,
|
||||
limit: int = 30,
|
||||
limit: int = Query(default=30, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Унифицированный список прогонов (kn + nspd + objective).
|
||||
|
||||
|
|
@ -1145,7 +1151,7 @@ def list_all_logs(
|
|||
db: Annotated[Session, Depends(get_db)],
|
||||
scraper_type: str | None = None,
|
||||
run_id: int | None = None,
|
||||
limit: int = 200,
|
||||
limit: int = Query(default=200, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Унифицированный список логов (kn + nspd). Objective пока не пишет log."""
|
||||
where: list[str] = []
|
||||
|
|
@ -1416,7 +1422,7 @@ def scrape_freshness(
|
|||
@router.get("/runs")
|
||||
def list_runs(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
limit: int = 20,
|
||||
limit: int = Query(default=20, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = (
|
||||
db.execute(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from typing import Annotated, Any, Literal
|
|||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from shapely import wkt as _shp_wkt
|
||||
from shapely.geometry import Polygon
|
||||
from sqlalchemy import text
|
||||
|
|
@ -2900,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)
|
||||
# §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}
|
||||
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}
|
||||
|
||||
# ИРД-слой (#1067 D9b «GG-форсайт»): parcel_ird_overlaps (м.132, incl opportunity) +
|
||||
# функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент зоны (C8b). Flag-gated (default off):
|
||||
|
|
@ -2987,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
|
||||
|
||||
|
||||
|
|
@ -3228,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:
|
||||
|
|
@ -3282,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:
|
||||
|
|
@ -3301,9 +3324,12 @@ async def get_parcel_best_layouts_pdf(
|
|||
|
||||
Issue #113 Phase 2.1: data-driven unit-mix recommendation для тендера.
|
||||
"""
|
||||
# sync get_best_layouts (DB-IO) + render_layout_tz_pdf (CPU-bound WeasyPrint
|
||||
# write_pdf, сотни мс) мостятся через run_in_threadpool — иначе блокируют event loop.
|
||||
try:
|
||||
response = get_best_layouts(db=db, cad_num=cad_num, request=body)
|
||||
pdf_bytes = render_layout_tz_pdf(
|
||||
response = await run_in_threadpool(get_best_layouts, db=db, cad_num=cad_num, request=body)
|
||||
pdf_bytes = await run_in_threadpool(
|
||||
render_layout_tz_pdf,
|
||||
response,
|
||||
cad_num=cad_num,
|
||||
radius_km=body.radius_km,
|
||||
|
|
|
|||
|
|
@ -111,16 +111,25 @@ def get_photo(
|
|||
data = _fetch_upstream(upstream)
|
||||
if data:
|
||||
src = _persist_original(obj_id, file_id, photo_name, data)
|
||||
# Record the original immediately so a failed thumbnail does not
|
||||
# orphan the on-disk file and trigger an eternal re-fetch.
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE domrf_kn_photos"
|
||||
" SET local_path = :lp, downloaded_at = NOW()"
|
||||
" WHERE obj_id = :o AND obj_file_id = :f"
|
||||
),
|
||||
{"lp": str(src), "o": obj_id, "f": file_id},
|
||||
)
|
||||
db.commit()
|
||||
generated = make_thumbnail(src)
|
||||
if generated and generated.exists():
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE domrf_kn_photos"
|
||||
" SET local_path = :lp, thumb_path = :tp,"
|
||||
" downloaded_at = NOW()"
|
||||
"UPDATE domrf_kn_photos SET thumb_path = :tp"
|
||||
" WHERE obj_id = :o AND obj_file_id = :f"
|
||||
),
|
||||
{"lp": str(src), "tp": str(generated), "o": obj_id, "f": file_id},
|
||||
{"tp": str(generated), "o": obj_id, "f": file_id},
|
||||
)
|
||||
db.commit()
|
||||
return FileResponse(str(generated), media_type="image/webp", headers=headers)
|
||||
|
|
|
|||
|
|
@ -344,7 +344,7 @@ def estimate_pdf(
|
|||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="estimate not found")
|
||||
|
||||
if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC):
|
||||
if row.expires_at.astimezone(UTC) < datetime.now(tz=UTC):
|
||||
raise HTTPException(status_code=410, detail="estimate expired (24h TTL)")
|
||||
|
||||
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ if settings.glitchtip_dsn:
|
|||
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
||||
profiles_sample_rate=0.0,
|
||||
send_default_pii=False,
|
||||
before_send=scrub_sensitive_query,
|
||||
before_send_transaction=scrub_sensitive_query,
|
||||
integrations=[
|
||||
StarletteIntegration(),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, SmallInteger, Text
|
||||
from sqlalchemy import Boolean, DateTime, Integer, SmallInteger, Text, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
|
@ -20,6 +20,8 @@ class JobSetting(Base):
|
|||
max_retries: Mapped[int] = mapped_column(SmallInteger, default=2, nullable=False)
|
||||
max_concurrency: Mapped[int] = mapped_column(SmallInteger, default=1, nullable=False)
|
||||
extra_config: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=text("now()"), nullable=False
|
||||
)
|
||||
updated_by: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
|
|
|||
|
|
@ -1,29 +1,20 @@
|
|||
"""SQLAlchemy + GeoAlchemy2 ORM models.
|
||||
"""SQLAlchemy ORM models for parcel data.
|
||||
|
||||
Stage 2a: real Parcel model. Geometry stored in WGS84 (EPSG:4326);
|
||||
project to МСК-66 via pyproj when computing distances/areas.
|
||||
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.
|
||||
|
||||
Прежняя 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 datetime import datetime
|
||||
|
||||
from geoalchemy2 import Geometry
|
||||
from sqlalchemy import JSON, DateTime, Float, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base
|
||||
|
||||
|
||||
class Parcel(Base):
|
||||
__tablename__ = "parcels"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
cadastral_number: Mapped[str] = mapped_column(String, unique=True, index=True)
|
||||
vri: Mapped[str] = mapped_column(String, index=True)
|
||||
area_sqm: Mapped[float] = mapped_column(Float)
|
||||
address: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
geometry: Mapped[object] = mapped_column(Geometry("POLYGON", srid=4326))
|
||||
enrichment: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
from app.core.db import Base # noqa: F401 (re-export для регистрации будущих моделей)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from __future__ import annotations
|
|||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
# История диалога принимается, но в Step 1 НЕ используется (LLM-контекст — Step 2).
|
||||
# Кэпируем длину, чтобы payload не раздувался до подключения LLM.
|
||||
|
|
@ -84,10 +84,21 @@ class ChatAskRequest(BaseModel):
|
|||
)
|
||||
history: list[ChatTurn] | None = Field(
|
||||
default=None,
|
||||
max_length=_HISTORY_MAX_TURNS,
|
||||
description="История диалога (Step 1: принимается, НЕ используется; LLM-контекст — Step 2)",
|
||||
)
|
||||
|
||||
@field_validator("history")
|
||||
@classmethod
|
||||
def _cap_history(cls, v: list[ChatTurn] | None) -> list[ChatTurn] | None:
|
||||
"""Graceful-усечение: кэпируем хвост до последних _HISTORY_MAX_TURNS ходов.
|
||||
|
||||
НЕ отклоняем длинный диалог 422 (контракт обещает усечение, не отказ) —
|
||||
оставляем самые свежие ходы, отбрасывая старые с головы.
|
||||
"""
|
||||
if v is not None and len(v) > _HISTORY_MAX_TURNS:
|
||||
return v[-_HISTORY_MAX_TURNS:]
|
||||
return v
|
||||
|
||||
|
||||
class ChatAskResponse(BaseModel):
|
||||
"""Ответ чата по отчёту участка (детерминированный, шаблонный RU-текст).
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ class ConceptInput(BaseModel):
|
|||
housing_class: Literal["econom", "comfort", "business"] = "comfort"
|
||||
target_floors: int = Field(9, ge=1, le=30)
|
||||
development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise"
|
||||
land_cost_rub: float | None = Field(None, description="Optional land cost for financial model")
|
||||
land_cost_rub: float | None = Field(
|
||||
None, ge=0, description="Optional land cost for financial model"
|
||||
)
|
||||
|
||||
|
||||
class TEAP(BaseModel):
|
||||
|
|
|
|||
|
|
@ -302,10 +302,18 @@ class NSPDBulkClient:
|
|||
|
||||
try:
|
||||
data = await self._get_json(NSPD_SEARCH_URL, params=params)
|
||||
except (NspdBulkWafError, NspdBulkRateLimitError, NspdBulkServerError):
|
||||
# 403 WAF / 429 / 5xx+ServiceException — НЕ «квартал не найден».
|
||||
# Пробрасываем как есть: caller (autoretry) ретраит квартал, WAF
|
||||
# останавливает harvest. Раньше подстрочная классификация по str(e)
|
||||
# с подмешанным body_preview могла ложно проглотить 5xx как 404.
|
||||
raise
|
||||
except NspdBulkError as e:
|
||||
# 404 или пустой ответ → возвращаем пустой snapshot
|
||||
# Остаётся базовый NspdBulkError = прочие 4xx (см. _get_json:234).
|
||||
# Текст: f"HTTP {code}: {url} — {body_preview}" → классифицируем по
|
||||
# ПРЕФИКСУ (код до URL), а не по вхождению в произвольное тело ответа.
|
||||
err_str = str(e)
|
||||
if "HTTP 404" in err_str or "HTTP 400" in err_str:
|
||||
if err_str.startswith("HTTP 404:") or err_str.startswith("HTTP 400:"):
|
||||
logger.info(
|
||||
"search_by_quarter: quarter %s not found (404/400), returning empty"
|
||||
" (category_id=%s)",
|
||||
|
|
@ -342,8 +350,23 @@ class NSPDBulkClient:
|
|||
for m in raw_meta:
|
||||
cat_id = m.get("categoryId")
|
||||
total = m.get("totalCount")
|
||||
if cat_id is not None and total is not None:
|
||||
if cat_id is None or total is None:
|
||||
continue
|
||||
# NSPD изредка отдаёт categoryId/totalCount нечисловой/float-строкой
|
||||
# ('20.0') или иным типом → голый int() кинул бы ValueError/TypeError,
|
||||
# который НЕ подкласс NspdBulkError и завалил бы всю Phase 1 квартала.
|
||||
# Зеркалит защитный try/except в schemas/nspd_bulk.py и
|
||||
# list_objects_in_building. Битую meta-запись просто пропускаем.
|
||||
try:
|
||||
meta_counts[int(cat_id)] = int(total)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(
|
||||
"search_by_quarter: non-numeric meta entry quarter=%s"
|
||||
" categoryId=%r totalCount=%r — skipping",
|
||||
quarter,
|
||||
cat_id,
|
||||
total,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"search_by_quarter: quarter=%s category_id=%s features=%d meta_cats=%d overflow=%d",
|
||||
|
|
@ -416,7 +439,19 @@ class NSPDBulkClient:
|
|||
}
|
||||
|
||||
data = await self._get_json(url, params=params)
|
||||
raw_features: list[dict[str, Any]] = (data or {}).get("features") or []
|
||||
# NSPD/GeoServer изредка отдаёт валидный JSON, но не объект (list/str —
|
||||
# Bug_Nspd_Geo_Str_Object). Тогда `(data or {})` вернул бы сам truthy
|
||||
# data, а .get("features") кинул бы AttributeError (не NspdBulkError →
|
||||
# уронил бы ячейку grid-walk без сигнала). Унифицируем guard с
|
||||
# search_by_quarter: не-dict трактуем как пустой ответ.
|
||||
if not isinstance(data, dict):
|
||||
logger.warning(
|
||||
"wms_feature_info: non-dict JSON response layer=%d type=%s — returning empty",
|
||||
layer_id,
|
||||
type(data).__name__,
|
||||
)
|
||||
return []
|
||||
raw_features: list[dict[str, Any]] = data.get("features") or []
|
||||
return [NSPDBulkFeature.model_validate(f) for f in raw_features]
|
||||
|
||||
# ── 3. get_features_in_bbox_grid ─────────────────────────────────────────
|
||||
|
|
@ -473,9 +508,39 @@ class NSPDBulkClient:
|
|||
|
||||
seen_ids: set[str] = set()
|
||||
results: list[dict] = []
|
||||
# Issue #252-mirror: считаем server-side провалы и успешные ячейки, чтобы
|
||||
# отличить «слой реально пуст» (ok_cells>0, 0 features) от «слой/IP лёг»
|
||||
# (все ячейки 5xx/WAF). Раньше любой Exception молча падал на DEBUG и метод
|
||||
# отдавал [] → в БД писался ложный tz_count=0 без layer_failed-сигнала.
|
||||
server_errors = 0
|
||||
ok_cells = 0
|
||||
first_server_error: NspdBulkServerError | None = None
|
||||
|
||||
for idx, cell_result in enumerate(cell_results):
|
||||
if isinstance(cell_result, NspdBulkWafError):
|
||||
# 403 WAF (бан IP) — по docstring должен ОСТАНОВИТЬ harvest, не
|
||||
# маскироваться пустым результатом. Пробрасываем немедленно.
|
||||
logger.warning(
|
||||
"get_features_in_bbox_grid: layer=%d cell=%d WAF 403 — aborting grid-walk: %s",
|
||||
layer_id,
|
||||
idx,
|
||||
cell_result,
|
||||
)
|
||||
raise cell_result
|
||||
if isinstance(cell_result, NspdBulkServerError):
|
||||
server_errors += 1
|
||||
if first_server_error is None:
|
||||
first_server_error = cell_result
|
||||
logger.debug(
|
||||
"get_features_in_bbox_grid: layer=%d cell=%d server error: %s",
|
||||
layer_id,
|
||||
idx,
|
||||
cell_result,
|
||||
)
|
||||
continue
|
||||
if isinstance(cell_result, Exception):
|
||||
# Прочие (сетевые/parse) ошибки одной ячейки — не валим обход и НЕ
|
||||
# считаем server-side fail (иначе сеть ложно triggers layer_failed).
|
||||
logger.debug(
|
||||
"get_features_in_bbox_grid: layer=%d cell=%d error: %s",
|
||||
layer_id,
|
||||
|
|
@ -483,6 +548,7 @@ class NSPDBulkClient:
|
|||
cell_result,
|
||||
)
|
||||
continue
|
||||
ok_cells += 1
|
||||
for feature in cell_result:
|
||||
fid = str(feature.id) if feature.id is not None else ""
|
||||
if fid and fid in seen_ids:
|
||||
|
|
@ -497,6 +563,21 @@ class NSPDBulkClient:
|
|||
}
|
||||
)
|
||||
|
||||
# Если БЫЛИ server-side провалы И ни одна ячейка не прошла — слой/NSPD лёг
|
||||
# целиком. Возврат [] здесь означал бы ложный tz_count=0 («зонирование
|
||||
# отсутствует»). Пробрасываем server-error, чтобы caller отличил сбой от
|
||||
# реально пустого слоя (мирроринг _grid_walk_category.layer_failed).
|
||||
if server_errors > 0 and ok_cells == 0 and first_server_error is not None:
|
||||
logger.warning(
|
||||
"get_features_in_bbox_grid: layer=%d grid=%dx%d ПОЛНОСТЬЮ сбойный "
|
||||
"(%d server errors, 0 ok cells) — raising вместо ложного пустого результата",
|
||||
layer_id,
|
||||
grid_n,
|
||||
grid_n,
|
||||
server_errors,
|
||||
)
|
||||
raise first_server_error
|
||||
|
||||
logger.info(
|
||||
"get_features_in_bbox_grid: layer=%d grid=%dx%d unique_features=%d",
|
||||
layer_id,
|
||||
|
|
|
|||
|
|
@ -64,8 +64,15 @@ def _jsonb_param(value: Any) -> str:
|
|||
|
||||
jsonable_encoder разворачивает Pydantic-модели / даты / Enum в JSON-native типы;
|
||||
json.dumps(..., ensure_ascii=False) — кириллица как есть (зеркало pzz_loader).
|
||||
|
||||
allow_nan=False (#1580): дефолтный allow_nan=True выводит нестандартные литералы
|
||||
NaN/Infinity/-Infinity, которые JSONB-парсер PostgreSQL отвергает ("invalid input
|
||||
syntax for type json") → INSERT падает, а broad-except в persist_analysis_run
|
||||
проглатывает это и теряет ран молча. С allow_nan=False json.dumps бросает ValueError
|
||||
ДО SQL — провал детерминирован и виден в logger.exception (с traceback), а не
|
||||
маскируется под невнятную psycopg-ошибку синтаксиса.
|
||||
"""
|
||||
return json.dumps(jsonable_encoder(value), ensure_ascii=False)
|
||||
return json.dumps(jsonable_encoder(value), ensure_ascii=False, allow_nan=False)
|
||||
|
||||
|
||||
def persist_analysis_run(
|
||||
|
|
|
|||
|
|
@ -24,11 +24,12 @@ list rather than silently ignored.
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -54,6 +55,11 @@ _SUPPORTED_METHODS = (_CALC_BASIS, _CALC_PREVIOUS)
|
|||
# as unsupported.
|
||||
_SUPPORTED_SUBJECT = "66"
|
||||
|
||||
# ARN period_value format (matches mv_ddu_price_indicator.period_value, e.g.
|
||||
# '2026-Q1'). Used to reject malformed bounds whose lexicographic comparison
|
||||
# against well-formed period_value would silently drop rows.
|
||||
_PERIOD_RE = re.compile(r"^\d{4}-Q[1-4]$")
|
||||
|
||||
|
||||
def _f(value: Any) -> float | None:
|
||||
if value is None:
|
||||
|
|
@ -148,6 +154,50 @@ def get_ddu_indicator(
|
|||
if clean_buckets:
|
||||
bucket_filter = "AND area_bucket = ANY(CAST(:buckets AS int[]))"
|
||||
params["buckets"] = clean_buckets
|
||||
else:
|
||||
# Client narrowed by area buckets but every id is out of range (0..6)
|
||||
# → honour the explicit narrowing with an empty result, never silently
|
||||
# widen back to all buckets. Mirrors the `not subject_ok` branch above.
|
||||
notes.append(
|
||||
f"areaRanges={area_ranges} вне диапазона 0..6 "
|
||||
f"(0=все площади, 1..6=диапазоны м²) — нет подходящих площадей."
|
||||
)
|
||||
return {
|
||||
"meta": {
|
||||
"market": "primary_ddu",
|
||||
"region_code": int(_SUPPORTED_SUBJECT),
|
||||
"calculation_method": method,
|
||||
"period_type": "Q",
|
||||
},
|
||||
"table": [],
|
||||
"graph": [],
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
# Validate period bounds against the documented ARN 'YYYY-QN' format before
|
||||
# binding them. period_value is compared lexicographically (it is text); a
|
||||
# malformed bound ('foo', '2026') would silently drop rows, so drop the bad
|
||||
# bound and explain it in notes (this endpoint's convention is notes, not 422).
|
||||
if period_from and not _PERIOD_RE.match(period_from):
|
||||
notes.append(
|
||||
f"periodFrom={period_from!r} не в формате 'YYYY-QN' (напр. '2025-Q2') "
|
||||
f"— граница проигнорирована."
|
||||
)
|
||||
period_from = None
|
||||
if period_to and not _PERIOD_RE.match(period_to):
|
||||
notes.append(
|
||||
f"periodTo={period_to!r} не в формате 'YYYY-QN' (напр. '2026-Q1') "
|
||||
f"— граница проигнорирована."
|
||||
)
|
||||
period_to = None
|
||||
# Inverted range (from > to) yields an empty table with no signal otherwise.
|
||||
# Lexicographic comparison is correct here because the format is zero-padded
|
||||
# 'YYYY-QN'.
|
||||
if period_from and period_to and period_from > period_to:
|
||||
notes.append(
|
||||
f"periodFrom={period_from!r} > periodTo={period_to!r} — границы "
|
||||
f"перепутаны (диапазон инвертирован), результат пуст."
|
||||
)
|
||||
|
||||
period_filter = ""
|
||||
if period_from:
|
||||
|
|
@ -184,8 +234,10 @@ def get_ddu_indicator(
|
|||
.mappings()
|
||||
.all()
|
||||
)
|
||||
except OperationalError:
|
||||
except (ProgrammingError, OperationalError):
|
||||
# Most likely the MV does not exist yet (migration 152 not applied).
|
||||
# A missing relation is SQLSTATE 42P01 (UndefinedTable) → ProgrammingError;
|
||||
# OperationalError is kept for connection-level failures.
|
||||
logger.exception("ddu_indicator: query failed (mv_ddu_price_indicator missing?)")
|
||||
raise
|
||||
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ def detect_velocity_anomalies(
|
|||
AVG(realised) FILTER (WHERE rn <= :recent_window) AS recent_mean,
|
||||
AVG(realised) FILTER (WHERE rn > :recent_window) AS prior_mean,
|
||||
STDDEV_SAMP(realised) FILTER (WHERE rn > :recent_window) AS prior_std,
|
||||
COUNT(*) FILTER (WHERE rn > :recent_window) AS prior_n
|
||||
COUNT(realised) FILTER (WHERE rn > :recent_window) AS prior_n
|
||||
FROM ranked
|
||||
GROUP BY obj_id, n_months
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2786,6 +2786,25 @@ def recommend_mix(
|
|||
if b["bucket"] != top_bucket_name:
|
||||
b["share_pct"] = round(b["share_pct"] * scale, 1)
|
||||
|
||||
# #1576: success-boost изменил share_pct → средневзвешенная цена
|
||||
# должна пересчитаться под новые доли, иначе weighted_avg_price
|
||||
# остаётся от ДО-boost микса (пробел в фиксе #1359, который
|
||||
# обновлял только units/revenue). price_median_per_m2 уже включает
|
||||
# combined_price_factor (line 2743), поэтому домножать НЕ нужно —
|
||||
# это согласовано с per-bucket revenue ниже. Веса = area_avg×share,
|
||||
# независимо от area_total_m2, поэтому вне ветки area_total_m2.
|
||||
wnum = sum(
|
||||
b["_area_avg_raw"] * b["share_pct"] * b["price_median_per_m2"]
|
||||
for b in buckets
|
||||
if b["_area_avg_raw"] and b["_area_avg_raw"] > 0
|
||||
)
|
||||
wden = sum(
|
||||
b["_area_avg_raw"] * b["share_pct"]
|
||||
for b in buckets
|
||||
if b["_area_avg_raw"] and b["_area_avg_raw"] > 0
|
||||
)
|
||||
weighted_avg_price = round(wnum / wden, 2) if wden > 0 else None
|
||||
|
||||
# #1359: success-boost изменил share_pct → перераспределяем
|
||||
# units/revenue/months_to_sellout и агрегаты под новые доли,
|
||||
# иначе share_pct рассогласуется с units_planned/revenue/sellout
|
||||
|
|
|
|||
|
|
@ -247,21 +247,11 @@ async def harvest_quarter(
|
|||
db.commit()
|
||||
update_progress(done_progress)
|
||||
|
||||
# ── Phase 2.5: grid-walk для territorial_zones (ПЗЗ, layer 875838) ────────
|
||||
# Выполняем после основного grid-walk (Phase 2-3). Требует bbox квартала.
|
||||
quarter_bbox = quarter_bbox_3857(db, quarter)
|
||||
if quarter_bbox is not None:
|
||||
update_progress({"phase": "territorial_zones_started", "quarter": quarter})
|
||||
try:
|
||||
tz_features = await client.get_territorial_zones_in_bbox(quarter_bbox)
|
||||
tz_count = _save_territorial_zones(db, quarter, tz_features)
|
||||
logger.info(
|
||||
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
|
||||
|
||||
# ── Phase 4: quarter stats + auto-heal geom из snapshot ─────────────────
|
||||
# Bug #1583: auto-heal geom выполняем ДО Phase 2.5 (territorial_zones). Иначе
|
||||
# кварталы с broken/NULL geom дают quarter_bbox_3857() == None → ПЗЗ молча
|
||||
# пропускаются, а на следующем harvest квартал отсекается skip_fresh_hours.
|
||||
# Чиним geom здесь → Phase 2.5 ниже получит валидный bbox в этом же прогоне.
|
||||
stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS]
|
||||
if stats_features:
|
||||
upsert_quarter_stats(db, quarter, stats_features[0])
|
||||
|
|
@ -276,6 +266,22 @@ async def harvest_quarter(
|
|||
logger.warning("harvest_quarter: geom auto-heal failed for %s: %s", quarter, e)
|
||||
db.commit()
|
||||
|
||||
# ── Phase 2.5: grid-walk для territorial_zones (ПЗЗ, layer 875838) ────────
|
||||
# Выполняем после основного grid-walk (Phase 2-3) И после Phase 4 geom
|
||||
# auto-heal (см. Bug #1583) — так broken-geom кварталы, починенные выше,
|
||||
# получают валидный bbox и ПЗЗ собираются в том же прогоне. Требует bbox квартала.
|
||||
quarter_bbox = quarter_bbox_3857(db, quarter)
|
||||
if quarter_bbox is not None:
|
||||
update_progress({"phase": "territorial_zones_started", "quarter": quarter})
|
||||
try:
|
||||
tz_features = await client.get_territorial_zones_in_bbox(quarter_bbox)
|
||||
tz_count = _save_territorial_zones(db, quarter, tz_features)
|
||||
logger.info(
|
||||
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
|
||||
|
||||
# Issue #252: финальный phase_state несёт АГРЕГИРОВАННЫЙ harvest_meta по всем
|
||||
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
|
||||
# per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в
|
||||
|
|
@ -344,6 +350,11 @@ async def _grid_walk_category(
|
|||
|
||||
grid_points = generate_grid_click_points(bbox, grid_size=grid_size, tile_size=tile_size)
|
||||
|
||||
# Bug #1584: считаем discovered ТОЛЬКО для таблицы запрошенного layer_id, а не
|
||||
# sum(stats.values()). Иначе skipped/чужекатегорийные features завышают счётчик,
|
||||
# ошибочно приписываясь к parcels/buildings вызывающим (harvest_quarter:230-233).
|
||||
layer_count_key = "parcels" if layer_id == CAT_PARCEL else "buildings"
|
||||
|
||||
discovered_cads: set[str] = set()
|
||||
upserted = 0
|
||||
requests = 0
|
||||
|
|
@ -411,7 +422,7 @@ async def _grid_walk_category(
|
|||
try:
|
||||
with db.begin_nested():
|
||||
stats = upsert_features(db, [feature], source="wms_grid_walk")
|
||||
upserted += sum(stats.values())
|
||||
upserted += stats[layer_count_key]
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"_grid_walk_category: upsert failed cad=%s layer=%d: %s",
|
||||
|
|
@ -1423,7 +1434,7 @@ def upsert_quarter_stats(
|
|||
"cost_value_total_geom": _safe_numeric(raw_opts.get("cost_value_total_geom")),
|
||||
"sum_land_area": _safe_numeric(raw_opts.get("sum_land_area")),
|
||||
"sum_land_geom_area": _safe_numeric(raw_opts.get("sum_land_geom_area")),
|
||||
"date_cr": raw_opts.get("date_cr"),
|
||||
"date_cr": _parse_nspd_date(raw_opts.get("date_cr")),
|
||||
"real_srid": _safe_int(raw_opts.get("real_srid")),
|
||||
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,12 +7,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Радиус сферы Web Mercator (EPSG:3857), м. Нужен для инверсии Y → широта.
|
||||
_WEB_MERCATOR_R = 6378137.0
|
||||
|
||||
|
||||
def quarter_bbox_3857(db: Session, quarter: str) -> tuple[float, float, float, float] | None:
|
||||
"""Получить bbox квартала из cad_quarters_geom в EPSG:3857.
|
||||
|
|
@ -56,8 +60,19 @@ def quarter_bbox_3857(db: Session, quarter: str) -> tuple[float, float, float, f
|
|||
# geometry (width <0.01m). Это вызывает useless 1px tiles в WMS и 500 errors
|
||||
# от NSPD. Реальный квартал ЕКБ имеет width 200-4000м. Skip grid-walk для
|
||||
# broken — snapshot phase даст 20 per cat, что норм для MVP.
|
||||
width = bbox[2] - bbox[0]
|
||||
height = bbox[3] - bbox[1]
|
||||
#
|
||||
# Bug #1629: EPSG:3857 (Web Mercator) — конформная, НЕ равнопротяжённая
|
||||
# проекция. Линейный масштаб = sec(lat); на широте ЕКБ (~56-60°N) фактор
|
||||
# ≈1.8-2.0, т.е. 1 наземный метр ≈ 1.8-2.0 единиц 3857. Пороги ниже заданы
|
||||
# в НАЗЕМНЫХ метрах, поэтому переводим протяжённость bbox из единиц 3857 в
|
||||
# наземные метры умножением на cos(lat) (lat берём из центра bbox через
|
||||
# инверсию Y). Иначе верхняя граница 10000 отсекала бы реальные крупные/
|
||||
# вытянутые кварталы (10000 единиц 3857 ≈ всего ~5050 м земли на широте ЕКБ).
|
||||
y_mid = (bbox[1] + bbox[3]) / 2.0
|
||||
lat_mid = 2.0 * math.atan(math.exp(y_mid / _WEB_MERCATOR_R)) - math.pi / 2.0
|
||||
ground_scale = math.cos(lat_mid) # ground_m = mercator_units * cos(lat)
|
||||
width = (bbox[2] - bbox[0]) * ground_scale
|
||||
height = (bbox[3] - bbox[1]) * ground_scale
|
||||
if width < 100 or height < 100 or width > 10000 or height > 10000:
|
||||
logger.warning(
|
||||
"quarter_bbox_3857: квартал %s broken geom — width=%.2fm height=%.2fm "
|
||||
|
|
|
|||
|
|
@ -151,8 +151,8 @@ def _fmt_number(value: Any) -> str | None:
|
|||
if isinstance(value, int):
|
||||
return _fmt_thousands(value)
|
||||
if isinstance(value, float):
|
||||
if not math.isfinite(value): # NaN/Inf: int(value) бросил бы ValueError
|
||||
return str(value)
|
||||
if not math.isfinite(value): # NaN/Inf — не число: честно пропускаем (как None),
|
||||
return None # иначе в RU-прозу утёк бы англ. литерал 'nan'/'inf' (#1585)
|
||||
if value == int(value):
|
||||
return _fmt_thousands(value)
|
||||
# Точность отчёта сохраняем: repr float'а → '.'→','. (0.31 → '0,31').
|
||||
|
|
@ -251,7 +251,7 @@ def _render_what_to_build(report: dict[str, Any]) -> tuple[str, list[str]]:
|
|||
if summary:
|
||||
lines.append(str(summary))
|
||||
|
||||
if len(sections_used) == 1 and not any(
|
||||
if not any(
|
||||
section.get(k) for k in ("obj_class", "mix", "commercial", "usp", "summary")
|
||||
):
|
||||
lines.append("Раздел рекомендации продукта в отчёте пуст.")
|
||||
|
|
@ -266,16 +266,21 @@ def _render_why_forecast(report: dict[str, Any]) -> tuple[str, list[str]]:
|
|||
|
||||
future = report.get("future_market")
|
||||
if isinstance(future, dict):
|
||||
sections_used.append("future_market")
|
||||
future_emitted = False # секцию в provenance кладём только если выведена строка (#1630)
|
||||
horizons = future.get("forecasts_by_horizon")
|
||||
if isinstance(horizons, list) and horizons:
|
||||
lines.append(f"Прогноз построен по {len(horizons)} горизонтам спроса/предложения.")
|
||||
future_emitted = True
|
||||
future_supply = future.get("future_supply")
|
||||
if isinstance(future_supply, dict) and future_supply:
|
||||
lines.append("Учтено давление будущего предложения (выходящие проекты).")
|
||||
future_emitted = True
|
||||
summary = future.get("summary")
|
||||
if summary:
|
||||
lines.append(str(summary))
|
||||
future_emitted = True
|
||||
if future_emitted:
|
||||
sections_used.append("future_market")
|
||||
else:
|
||||
lines.append(_NO_SECTION_TMPL.format(name="будущий рынок"))
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ def find_match_candidates(
|
|||
objective_distinct AS (
|
||||
SELECT DISTINCT project_name
|
||||
FROM objective_corpus_room_month
|
||||
WHERE group_name = 'Екатеринбург'
|
||||
)
|
||||
SELECT
|
||||
d.obj_id,
|
||||
|
|
@ -168,7 +169,7 @@ def auto_apply_matches(
|
|||
len(auto),
|
||||
len(review),
|
||||
)
|
||||
return {"auto_accepted": 0, "review_queue": len(review), "skipped": 0}
|
||||
return {"auto_accepted": len(auto), "review_queue": len(review), "skipped": 0}
|
||||
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
|
|
|
|||
|
|
@ -123,10 +123,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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -476,6 +479,30 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
|
|||
return None
|
||||
|
||||
|
||||
def _scenario_deficit_horizon(payload: dict[str, Any]) -> Any:
|
||||
"""Фактический горизонт (мес), из которого взят `_scenario_deficit_index`. PURE.
|
||||
|
||||
Зеркалит выбор `_scenario_deficit_index`: основной горизонт, иначе первый с не-None
|
||||
дефицитом. Нужен, чтобы НЕ врать подписью «(12 мес)» при fallback на чужой горизонт
|
||||
(#1590). Нет дефицита → None.
|
||||
"""
|
||||
forecasts = _as_list(payload.get("forecasts"))
|
||||
primary = next(
|
||||
(
|
||||
f
|
||||
for f in forecasts
|
||||
if isinstance(f, dict) and f.get("horizon_months") == _PRIMARY_HORIZON_MONTHS
|
||||
),
|
||||
None,
|
||||
)
|
||||
if primary is not None and primary.get("deficit_index") is not None:
|
||||
return _PRIMARY_HORIZON_MONTHS
|
||||
for f in forecasts:
|
||||
if isinstance(f, dict) and f.get("deficit_index") is not None:
|
||||
return f.get("horizon_months")
|
||||
return None
|
||||
|
||||
|
||||
def _build_scenarios(report: dict[str, Any]) -> str:
|
||||
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
|
||||
scenarios = _as_dict(report.get("scenarios"))
|
||||
|
|
@ -484,9 +511,19 @@ def _build_scenarios(report: dict[str, Any]) -> str:
|
|||
rows: list[list[Any]] = []
|
||||
for name, payload in by_scenario.items():
|
||||
data = _as_dict(payload)
|
||||
rows.append([name, _scenario_deficit_index(data), data.get("advisory")])
|
||||
deficit = _scenario_deficit_index(data)
|
||||
horizon = _scenario_deficit_horizon(data)
|
||||
# Подпись столбца жёстко «(12 мес)» — если значение от другого горизонта
|
||||
# (fallback), помечаем ячейку фактическим горизонтом, чтобы не врать (#1590).
|
||||
if deficit is not None and horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS:
|
||||
deficit = f"{_fmt(deficit)} (гор. {horizon} мес)"
|
||||
rows.append([name, deficit, data.get("advisory")])
|
||||
|
||||
headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"]
|
||||
headers = [
|
||||
"Сценарий",
|
||||
f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)",
|
||||
"Advisory",
|
||||
]
|
||||
return f"""
|
||||
<div class="section" id="scenarios">
|
||||
<h2>{html.escape(_TITLE_SCENARIOS)}</h2>
|
||||
|
|
|
|||
|
|
@ -159,12 +159,14 @@ def generate_snapshot_pdf(
|
|||
area_ha = f"{area_m2 / 10_000:.2f}" if area_m2 else "—"
|
||||
poi_items = _build_poi_items(poi_rows, limit=7)
|
||||
|
||||
# Конкуренты — берём топ N ближайших (уже отсортированы по flat_count DESC;
|
||||
# переупорядочиваем по distance_m для удобства чтения)
|
||||
# Конкуренты — берём топ N БЛИЖАЙШИХ. competitor_rows приходят отсортированными
|
||||
# по flat_count DESC (крупнейшие ЖК), поэтому сначала пересортировываем весь
|
||||
# список по distance_m ASC, и лишь затем срезаем N — иначе в блок попадали бы
|
||||
# 5 крупнейших из радиуса, а не непосредственное конкурентное окружение пятна.
|
||||
competitors_display = sorted(
|
||||
competitor_rows[:competitors_limit],
|
||||
competitor_rows,
|
||||
key=lambda r: float(r.get("distance_m") or 0),
|
||||
)
|
||||
)[:competitors_limit]
|
||||
competitors_ctx: list[dict[str, Any]] = [
|
||||
{
|
||||
"comm_name": r.get("comm_name"),
|
||||
|
|
|
|||
|
|
@ -58,7 +58,11 @@ def _analog_rows(lots: list[AnalogLot], *, is_deal: bool) -> str:
|
|||
for lot in lots:
|
||||
date_val = lot.listing_date.strftime("%d.%m.%Y") if lot.listing_date else "—"
|
||||
dom_val = str(lot.days_on_market) if lot.days_on_market is not None else "—"
|
||||
floor_val = f"{lot.floor}/{lot.total_floors}" if lot.floor and lot.total_floors else "—"
|
||||
floor_val = (
|
||||
f"{lot.floor}/{lot.total_floors}"
|
||||
if lot.floor is not None and lot.total_floors is not None
|
||||
else "—"
|
||||
)
|
||||
label = "Дата сделки" if is_deal else "В продаже"
|
||||
_ = label # used for header only
|
||||
rows.append(
|
||||
|
|
|
|||
|
|
@ -294,8 +294,10 @@ def compute_affordability(
|
|||
усреднение НЕнулевых месяцев). None → платёж None (graceful).
|
||||
• monthly_payment = _annuity(principal=price×ref_area, annual_rate=rate,
|
||||
months=_ANNUITY_TERM_MONTHS).
|
||||
• payment_at_scenario[h] = _annuity(... annual_rate=rate_path[h]) — платёж на
|
||||
каждом горизонте сценарной ставки (None rate_path → поле None).
|
||||
• payment_at_scenario[h] = _annuity(... annual_rate=rate_path[h] + спред) —
|
||||
платёж на каждом горизонте сценарной КЛЮЧЕВОЙ ставки ЦБ, приведённой к той
|
||||
же рыночной базе, что и monthly_payment_rub (key_rate + калиброванный спред
|
||||
4.5 п.п.); None rate_path → поле None (#1639).
|
||||
|
||||
Graceful: нет ставки/цены → платёж None; ставка ≤0 → аннуитет деградирует к
|
||||
principal/months. НИКОГДА не crash. confidence ВСЕГДА 'low'. Детерминированно.
|
||||
|
|
@ -304,8 +306,10 @@ def compute_affordability(
|
|||
db: SQLAlchemy sync Session.
|
||||
spec: целевой сегмент (для сегментной цены, если price_per_m2 не задан).
|
||||
price_per_m2: цена ₽/м² (None → берём сегментную среднюю reuse-ом).
|
||||
rate_path: сценарный {horizon: годовая ставка %} для payment_at_scenario;
|
||||
None → payment_at_scenario None.
|
||||
rate_path: сценарный {horizon: КЛЮЧЕВАЯ ставка ЦБ %} для payment_at_scenario
|
||||
(контракт #952/#984: конверт key_rate, НЕ рыночная — к ней внутри
|
||||
добавляется тот же спред _KEY_RATE_MARKET_SPREAD_PP, что и в базовом
|
||||
monthly_payment_rub); None → payment_at_scenario None.
|
||||
ref_area_m2: эталонная площадь тела кредита (по умолчанию _REF_AREA_M2).
|
||||
price_source: источник сегментной цены (по умолчанию _PRICE_SOURCE = B).
|
||||
|
||||
|
|
@ -337,7 +341,18 @@ def compute_affordability(
|
|||
if rate_path is not None:
|
||||
scenario: dict[int, float] = {}
|
||||
for horizon, scenario_rate in rate_path.items():
|
||||
payment = _annuity(principal, scenario_rate, _ANNUITY_TERM_MONTHS)
|
||||
# rate_path[h] = КЛЮЧЕВАЯ ставка ЦБ сценария (#952/#984 контракт:
|
||||
# demand_supply_forecast.py:586, scenarios.py — конверт key_rate), НЕ
|
||||
# рыночная. Приводим к той же рыночной базе, что и monthly_payment_rub:
|
||||
# key_rate + калиброванный спред (_current_market_rate, строка 251).
|
||||
# Иначе сценарный платёж считался бы по «голой» key_rate (≈ на 4.5 п.п.
|
||||
# ниже базовой ставки) и был бы НЕсопоставим с monthly_payment_rub (#1639).
|
||||
market_scenario_rate = (
|
||||
scenario_rate + _KEY_RATE_MARKET_SPREAD_PP
|
||||
if scenario_rate is not None
|
||||
else None
|
||||
)
|
||||
payment = _annuity(principal, market_scenario_rate, _ANNUITY_TERM_MONTHS)
|
||||
if payment is not None:
|
||||
scenario[horizon] = payment
|
||||
payment_at_scenario = scenario
|
||||
|
|
|
|||
|
|
@ -330,9 +330,12 @@ def _build_rationale(
|
|||
|
||||
if advisory_capped and level == _ADVISORY_CEILING:
|
||||
# Уровень упёрся в advisory-потолок (не данные) — это и есть главная причина.
|
||||
# _F_ADVISORY_CAP-фактор уже проговорён в base — исключаем его ноту из «также»,
|
||||
# иначе advisory-cap-сообщение дублируется (частый all-high случай).
|
||||
other = [f.note for f in drag if f.name != _F_ADVISORY_CAP]
|
||||
base = f"{label}: прогноз советующий (не провалидирован) — уровень ограничен «medium»"
|
||||
if notes:
|
||||
base += "; также " + _join_notes(notes)
|
||||
if other:
|
||||
base += "; также " + _join_notes(other)
|
||||
return base + "."
|
||||
|
||||
if not notes:
|
||||
|
|
|
|||
|
|
@ -818,7 +818,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:
|
||||
|
|
|
|||
|
|
@ -609,17 +609,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)
|
||||
|
|
@ -698,13 +703,14 @@ def _overlay(
|
|||
def _commercial_signal(
|
||||
db: Session, district: str | None, horizon_months: int
|
||||
) -> dict[str, Any] | None:
|
||||
"""§10.4 советующий коммерческий сигнал (доля коммерции) — degraded-honest. Graceful.
|
||||
"""§10.4 советующий коммерческий сигнал (темп распродажи нежилого) — degraded-honest.
|
||||
|
||||
Пробует измерить нежилой сток через `compute_market_metrics(premise_kind=
|
||||
"нежилое")`. objective покрывает в основном жильё → выборка обычно тонкая. Тогда
|
||||
возвращаем degraded-honest {available: False, caveat, advisory} — НЕ фабрикуем число.
|
||||
Если данных достаточно (≥ _COMMERCIAL_MIN_LOTS лотов) → советующая оценка доли
|
||||
коммерции (sell_through_pct как прокси реализованной доли) + §16-подобный reason.
|
||||
Если данных достаточно (≥ _COMMERCIAL_MIN_LOTS лотов) → советующая оценка ТЕМПА
|
||||
РАСПРОДАЖИ нежилого стока (sell_through_pct = проданные ÷ (проданные+доступные)·100,
|
||||
прокси ликвидности/спроса — НЕ доля нежилого в объёме застройки) + §16-подобный reason.
|
||||
НИКОГДА не бросает: любой сбой движка/импорта → degraded-honest None-сигнал.
|
||||
|
||||
Args:
|
||||
|
|
@ -747,22 +753,26 @@ def _commercial_signal(
|
|||
)
|
||||
return {"available": False, "caveat": caveat, "advisory": True}
|
||||
|
||||
# Достаточно данных: советующая оценка реализованной доли коммерции (прокси).
|
||||
share_pct = round(sell_through, 1)
|
||||
# Достаточно данных: советующая оценка ТЕМПА РАСПРОДАЖИ нежилого (sell_through_pct
|
||||
# = проданные ÷ (проданные+доступные)·100 — ликвидность/спрос, НЕ доля застройки).
|
||||
# NB: ключ commercial_share_pct мислейблит метрику; честное переименование требует
|
||||
# согласованной правки product_scoring._score_commercial (другой файл) → не трогаем.
|
||||
sell_through_pct = round(sell_through, 1)
|
||||
confidence = confidence if confidence in ("high", "medium", "low") else "low"
|
||||
return {
|
||||
"available": True,
|
||||
"premise_kind": _COMMERCIAL_PREMISE_KIND,
|
||||
"commercial_share_pct": share_pct,
|
||||
"commercial_share_pct": sell_through_pct,
|
||||
"n_lots": n_lots,
|
||||
"confidence": confidence,
|
||||
"reason": {
|
||||
"why": (
|
||||
f"Коммерция (нежилое): реализованная доля ~{share_pct}% по {n_lots} "
|
||||
f"лотам на горизонте {horizon_months} мес (прокси спроса на нежилые помещения)."
|
||||
f"Коммерция (нежилое): темп распродажи ~{sell_through_pct}% по {n_lots} "
|
||||
f"лотам на горизонте {horizon_months} мес (прокси ликвидности/спроса на "
|
||||
f"нежилые помещения, НЕ доля нежилого в объёме застройки)."
|
||||
),
|
||||
"drivers": [
|
||||
{"factor": "sell_through_pct", "value": share_pct, "direction": "+"},
|
||||
{"factor": "sell_through_pct", "value": sell_through_pct, "direction": "+"},
|
||||
{"factor": "n_lots", "value": n_lots, "direction": "+"},
|
||||
],
|
||||
"rejected": [],
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ import numpy as np
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.forecast_request_cache import cached
|
||||
from app.services.forecasting.macro_series import get_monthly_macro
|
||||
from app.services.forecasting.macro_series import get_monthly_macro, is_confounded_window
|
||||
from app.services.forecasting.rate_sensitivity import Confidence, RateSensitivity, _delta
|
||||
from app.services.forecasting.sales_series import (
|
||||
SegmentSpec,
|
||||
|
|
@ -672,7 +672,7 @@ def _insufficient_sensitivity(segment: dict[str, str | None]) -> RateSensitivity
|
|||
|
||||
|
||||
def _fit_to_sensitivity(
|
||||
fit: DistributedLagFit, *, segment: dict[str, str | None]
|
||||
fit: DistributedLagFit, *, segment: dict[str, str | None], confounded: bool = False
|
||||
) -> RateSensitivity:
|
||||
"""Map a DistributedLagFit (Almon-ADL) onto the §9.6 RateSensitivity contract.
|
||||
|
||||
|
|
@ -684,9 +684,16 @@ def _fit_to_sensitivity(
|
|||
• r2 / n_obs ← fit.r2 / fit.n
|
||||
• confidence ← 'regression' → "medium" (gated-OK but advisory-grade) |
|
||||
'fallback' → "low"
|
||||
Source-B-only outputs (z_area_floor, most_sensitive_bucket, confounded,
|
||||
shrinkage_weight) have no analogue in a district×class distributed-lag fit
|
||||
(no room×area bucketing here) → None / sensible defaults. PURE.
|
||||
• confounded ← passed in by the caller (computed from the ACTUAL fit window
|
||||
via is_confounded_window — #1636). The §9.6 production path
|
||||
OR-aggregates this with §9.5 macro_coefficient.confounded in
|
||||
demand_supply_forecast._series_confounded → шок-фактор (#1222).
|
||||
The 48-мес regression window overlaps the 2024-07-01 shock long
|
||||
after the 12-мес macro window stops doing so, so hardcoding
|
||||
False here silently dropped the shock signal on this channel.
|
||||
Source-B-only outputs (z_area_floor, most_sensitive_bucket, shrinkage_weight) have
|
||||
no analogue in a district×class distributed-lag fit (no room×area bucketing here)
|
||||
→ None / sensible defaults. PURE.
|
||||
|
||||
BETA SEMANTICS (important): `beta` here carries the Almon LONG-RUN multiplier
|
||||
Σ_j β_j on Δln — the cumulative %-effect of a SUSTAINED +1pp regime shift, NOT
|
||||
|
|
@ -705,7 +712,7 @@ def _fit_to_sensitivity(
|
|||
r2=fit.r2,
|
||||
n_obs=fit.n,
|
||||
shrinkage_weight=0.0,
|
||||
confounded=False,
|
||||
confounded=confounded,
|
||||
confidence=confidence,
|
||||
phrase=fit.phrase,
|
||||
)
|
||||
|
|
@ -778,4 +785,32 @@ def compute_rate_regime_sensitivity(
|
|||
)
|
||||
return _insufficient_sensitivity(segment)
|
||||
|
||||
return _fit_to_sensitivity(fit, segment=segment)
|
||||
# #1636: confounded must reflect the ACTUAL §9.6 fit window. The regression fits
|
||||
# over the same macro grid as compute_district_rate_regression (get_monthly_macro,
|
||||
# months_back); re-reading it here is a cache hit (same args). The 48-мес window
|
||||
# crosses the 2024-07-01 shock long after the §9.5 12-мес macro window stops → this
|
||||
# is exactly the channel that was silently never raising the shock flag (#1222).
|
||||
confounded = _macro_window_confounded(db, months_back=months_back)
|
||||
return _fit_to_sensitivity(fit, segment=segment, confounded=confounded)
|
||||
|
||||
|
||||
def _macro_window_confounded(db: Session, *, months_back: int) -> bool:
|
||||
"""True если §9.6 fit-окно [min..max] макро-сетки пересекает шок-дату (#1636).
|
||||
|
||||
Зеркалит macro_coefficient._series_confounded / rate_sensitivity._series_confounded
|
||||
(PR2 is_confounded_window). Окно = та же сетка get_monthly_macro(months_back), что
|
||||
использует compute_district_rate_regression → cache-hit, без лишнего запроса.
|
||||
Пустая сетка / сбой → False (нет окна — нечего конфаундить), НЕ crash.
|
||||
"""
|
||||
try:
|
||||
months = [m.month for m in get_monthly_macro(db, months_back=months_back)]
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"rate_regime_sensitivity: macro window read for confounded-flag failed "
|
||||
"(months_back=%d) → treating as not confounded",
|
||||
months_back,
|
||||
)
|
||||
return False
|
||||
if not months:
|
||||
return False
|
||||
return is_confounded_window(min(months), max(months))
|
||||
|
|
|
|||
|
|
@ -393,9 +393,21 @@ def _market_now_summary(
|
|||
avg_price = analyze.get("market_avg_price_per_m2")
|
||||
if isinstance(avg_price, (int, float)) and not isinstance(avg_price, bool):
|
||||
parts.append(f"средняя цена ~{round(float(avg_price)):,} ₽/м²".replace(",", " "))
|
||||
n_comp = _analog_count(analyze, market_metrics)
|
||||
if n_comp is not None:
|
||||
parts.append(f"{n_comp} ЖК-конкурентов рядом")
|
||||
# #1634: НЕ через _analog_count — он отдаёт market_metrics.obj_count (число ЖК во
|
||||
# всей district-wide/микрорайонной выборке §9.2), что НЕ равно «конкурентов рядом».
|
||||
# Метка честно следует источнику: obj_count → «в выборке района», локальный fallback
|
||||
# из analyze (competitors_total / len(competitors)) → «рядом».
|
||||
if market_metrics is not None and isinstance(market_metrics.get("obj_count"), int):
|
||||
parts.append(f"{market_metrics['obj_count']} ЖК в выборке района")
|
||||
else:
|
||||
n_local: int | None = None
|
||||
pulse = analyze.get("market_pulse")
|
||||
if isinstance(pulse, dict) and isinstance(pulse.get("competitors_total"), int):
|
||||
n_local = pulse["competitors_total"]
|
||||
elif isinstance(analyze.get("competitors"), list):
|
||||
n_local = len(analyze["competitors"])
|
||||
if n_local is not None:
|
||||
parts.append(f"{n_local} ЖК-конкурентов рядом")
|
||||
if not parts:
|
||||
return None
|
||||
return "Текущий рынок: " + ", ".join(parts) + "."
|
||||
|
|
|
|||
|
|
@ -1689,7 +1689,13 @@ def compute_special_indices(
|
|||
|
||||
indices: dict[str, SpecialIndex] = {key: _run(key, builders[key]) for key in _INDEX_KEYS}
|
||||
|
||||
confidence = _min_confidence([idx.confidence for idx in indices.values()])
|
||||
# confidence = MIN по ДОСТУПНЫМ индексам (контракт SpecialIndices / docstring выше).
|
||||
# Недоступный индекс (_unavailable → value=None, confidence='low') НЕ участвует: его
|
||||
# 'low' — артефакт деградации, а не сигнал низкой уверенности доступных индексов
|
||||
# (#1592: _min_confidence отбрасывает только None, поэтому фильтруем здесь по value).
|
||||
confidence = _min_confidence(
|
||||
[idx.confidence for idx in indices.values() if idx.value is not None]
|
||||
)
|
||||
|
||||
n_available = sum(1 for idx in indices.values() if idx.value is not None)
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import logging
|
|||
|
||||
# ezdxf.new живёт в ezdxf.filemanagement и не реэкспортируется через ezdxf.__all__;
|
||||
# импорт из модуля удовлетворяет strict no-implicit-reexport.
|
||||
from ezdxf.enums import TextEntityAlignment
|
||||
from ezdxf.filemanagement import new as ezdxf_new
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
|
|
@ -43,13 +44,33 @@ _LAYER_BUILDINGS = "BUILDINGS"
|
|||
_LABEL_HEIGHT_M = 2.0
|
||||
|
||||
|
||||
def _polygon_points(poly: Polygon) -> list[tuple[float, float]]:
|
||||
"""Внешнее кольцо полигона как список (x, y) для LWPolyline (без замыкающей точки)."""
|
||||
coords = list(poly.exterior.coords)
|
||||
def _ring_points(coords: object) -> list[tuple[float, float]]:
|
||||
"""Кольцо (exterior/interior) как список (x, y) для LWPolyline (без замыкающей точки)."""
|
||||
pts = list(coords)
|
||||
# Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам.
|
||||
if len(coords) > 1 and coords[0] == coords[-1]:
|
||||
coords = coords[:-1]
|
||||
return [(float(x), float(y)) for x, y in coords]
|
||||
if len(pts) > 1 and pts[0] == pts[-1]:
|
||||
pts = pts[:-1]
|
||||
return [(float(x), float(y)) for x, y in pts]
|
||||
|
||||
|
||||
def _add_polygon(msp: object, poly: Polygon, layer: str) -> None:
|
||||
"""Нарисовать полигон на слое: внешнее кольцо + каждое внутреннее (отверстие).
|
||||
|
||||
LWPolyline не умеет дырки, поэтому каждое interior-кольцо эмитируется отдельной
|
||||
замкнутой полилинией на том же слое — иначе легальные вырезы (двор, сервитут,
|
||||
охранная зона) терялись бы и заливались сплошняком.
|
||||
"""
|
||||
msp.add_lwpolyline(
|
||||
_ring_points(poly.exterior.coords),
|
||||
close=True,
|
||||
dxfattribs={"layer": layer},
|
||||
)
|
||||
for interior in poly.interiors:
|
||||
msp.add_lwpolyline(
|
||||
_ring_points(interior.coords),
|
||||
close=True,
|
||||
dxfattribs={"layer": layer},
|
||||
)
|
||||
|
||||
|
||||
def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
|
||||
|
|
@ -71,16 +92,8 @@ def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
|
|||
msp = doc.modelspace()
|
||||
|
||||
# Участок и пятно застройки — из метрической геометрии Parcel.
|
||||
msp.add_lwpolyline(
|
||||
_polygon_points(parcel.polygon_m),
|
||||
close=True,
|
||||
dxfattribs={"layer": _LAYER_PARCEL},
|
||||
)
|
||||
msp.add_lwpolyline(
|
||||
_polygon_points(parcel.buildable_m),
|
||||
close=True,
|
||||
dxfattribs={"layer": _LAYER_BUILDABLE},
|
||||
)
|
||||
_add_polygon(msp, parcel.polygon_m, _LAYER_PARCEL)
|
||||
_add_polygon(msp, parcel.buildable_m, _LAYER_BUILDABLE)
|
||||
|
||||
# Секции: восстанавливаем метрические footprints из WGS84-geojson варианта.
|
||||
features = variant.buildings_geojson.get("features", [])
|
||||
|
|
@ -91,18 +104,17 @@ def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
|
|||
if footprint is None:
|
||||
continue
|
||||
section_count += 1
|
||||
msp.add_lwpolyline(
|
||||
_polygon_points(footprint),
|
||||
close=True,
|
||||
dxfattribs={"layer": _LAYER_BUILDINGS},
|
||||
)
|
||||
_add_polygon(msp, footprint, _LAYER_BUILDINGS)
|
||||
centroid = footprint.centroid
|
||||
label = str(_feature_section_id(feature, section_count))
|
||||
text = msp.add_text(
|
||||
label,
|
||||
dxfattribs={"layer": _LAYER_BUILDINGS, "height": _LABEL_HEIGHT_M},
|
||||
)
|
||||
text.set_placement((float(centroid.x), float(centroid.y)))
|
||||
text.set_placement(
|
||||
(float(centroid.x), float(centroid.y)),
|
||||
align=TextEntityAlignment.MIDDLE_CENTER,
|
||||
)
|
||||
|
||||
stream = io.BytesIO()
|
||||
doc.write(stream, fmt="bin")
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from shapely.strtree import STRtree
|
|||
|
||||
from app.schemas.concept import ConceptInput, ConceptVariant
|
||||
from app.services.generative import financial, teap
|
||||
from app.services.generative.geometry import Parcel
|
||||
from app.services.generative.geometry import Parcel, ParcelGeometryError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -191,11 +191,25 @@ def place_strategy(
|
|||
parcel: Parcel,
|
||||
payload: ConceptInput,
|
||||
spec: StrategySpec,
|
||||
) -> ConceptVariant:
|
||||
"""Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant."""
|
||||
) -> ConceptVariant | None:
|
||||
"""Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant.
|
||||
|
||||
Возвращает ``None``, если ни одна секция не легла в пятно застройки (узкий/мелкий
|
||||
участок, footprint стратегии целиком не помещается). Без этого вырожденный вариант
|
||||
с нулевым размещением (revenue=0, margin=-land, IRR<0) выдавался бы как валидный —
|
||||
ложь в отчёте. Отбраковку делает вызывающий :func:`place_all_strategies`.
|
||||
"""
|
||||
floors = _resolve_floors(payload.target_floors, spec.floors_factor)
|
||||
coverage_cap = _COVERAGE_CAP_BY_TYPE.get(payload.development_type, _DEFAULT_COVERAGE_CAP)
|
||||
footprints = _greedy_place(parcel, spec, coverage_cap)
|
||||
if not footprints:
|
||||
logger.warning(
|
||||
"strategy=%s placed 0 sections (footprint %.0fx%.0f m not buildable) — отбраковка",
|
||||
spec.name,
|
||||
spec.section_w,
|
||||
spec.section_d,
|
||||
)
|
||||
return None
|
||||
|
||||
teap_result = teap.compute_teap(
|
||||
footprints=footprints,
|
||||
|
|
@ -220,8 +234,22 @@ def place_strategy(
|
|||
|
||||
|
||||
def place_all_strategies(parcel: Parcel, payload: ConceptInput) -> list[ConceptVariant]:
|
||||
"""Stage 1b entry: построить три варианта (max_area / max_insolation / balanced)."""
|
||||
variants = [place_strategy(parcel, payload, spec) for spec in _STRATEGIES]
|
||||
"""Stage 1b entry: построить три варианта (max_area / max_insolation / balanced).
|
||||
|
||||
Вырожденные стратегии (нулевое размещение) отбраковываются — в результат попадают
|
||||
только варианты с реальными секциями. Если ни одна стратегия не легла (участок не
|
||||
вмещает даже самую компактную секцию), это вырожденный участок: поднимаем
|
||||
:class:`ParcelGeometryError` (API мапит в 422) — лучше отказ, чем пустой/лживый ответ.
|
||||
"""
|
||||
variants = [
|
||||
variant
|
||||
for spec in _STRATEGIES
|
||||
if (variant := place_strategy(parcel, payload, spec)) is not None
|
||||
]
|
||||
if not variants:
|
||||
raise ParcelGeometryError(
|
||||
"ни одна стратегия размещения не вместила секцию — участок слишком узкий/мелкий"
|
||||
)
|
||||
logger.info(
|
||||
"placed all strategies: %s",
|
||||
", ".join(f"{v.strategy}={v.teap.apartments_count}кв" for v in variants),
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ class LLMResult:
|
|||
fallback_used: True если результат — сигнал к детерминированному fallback.
|
||||
reason: Машиночитаемая причина fallback (disabled/timeout/rate_limited/
|
||||
redaction_refused/provider_error/call_cap/no_api_key). None при ok.
|
||||
finish_reason: причина завершения от провайдера (stop/length/content_filter/
|
||||
tool_calls/…). None при fallback. Консьюмер ОБЯЗАН проверять её даже при
|
||||
ok=True: 'length'/'content_filter' → ответ обрезан/отфильтрован (частичный
|
||||
или пустой content) и не является полноценным результатом.
|
||||
prompt_tokens / completion_tokens: для оценки стоимости (0 при fallback).
|
||||
model: модель, ответившая на запрос ("" при fallback).
|
||||
"""
|
||||
|
|
@ -67,6 +71,7 @@ class LLMResult:
|
|||
tool_calls: list[ToolCall] = field(default_factory=list)
|
||||
fallback_used: bool = False
|
||||
reason: str | None = None
|
||||
finish_reason: str | None = None
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
model: str = ""
|
||||
|
|
@ -81,6 +86,7 @@ class LLMResult:
|
|||
ok=True,
|
||||
content=resp.content,
|
||||
tool_calls=list(resp.tool_calls),
|
||||
finish_reason=resp.finish_reason,
|
||||
prompt_tokens=resp.prompt_tokens,
|
||||
completion_tokens=resp.completion_tokens,
|
||||
model=resp.model,
|
||||
|
|
|
|||
|
|
@ -91,6 +91,21 @@ class LLMProvider(ABC):
|
|||
# ── OpenAI (external) ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _coerce_token_count(value: Any) -> int:
|
||||
"""usage-токены → int, толерантно к мусору (str "abc"/None/dict → 0).
|
||||
|
||||
OpenAI обычно отдаёт int, но через внешний прокси/нестандартный провайдер поле
|
||||
может прийти нечисловым. int() на таком значении бросил бы ValueError/TypeError
|
||||
мимо LLM*-контракта (его ловит client._call_with_retries) и пробил бы инвариант
|
||||
«complete никогда не падает наружу» (#1601). Невалидный токен-счётчик — не повод
|
||||
ронять ответ: деградируем до 0.
|
||||
"""
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
def _parse_openai_response(data: dict[str, Any], *, fallback_model: str) -> ProviderResponse:
|
||||
"""Распарсить тело chat/completions OpenAI → ProviderResponse (с tool_calls)."""
|
||||
choices = data.get("choices") or []
|
||||
|
|
@ -114,8 +129,8 @@ def _parse_openai_response(data: dict[str, Any], *, fallback_model: str) -> Prov
|
|||
content=message.get("content"),
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=choice.get("finish_reason"),
|
||||
prompt_tokens=int(usage.get("prompt_tokens", 0) or 0),
|
||||
completion_tokens=int(usage.get("completion_tokens", 0) or 0),
|
||||
prompt_tokens=_coerce_token_count(usage.get("prompt_tokens", 0)),
|
||||
completion_tokens=_coerce_token_count(usage.get("completion_tokens", 0)),
|
||||
model=str(data.get("model") or fallback_model),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -77,10 +78,54 @@ _PHONE_RE = re.compile(r"(?:\+7|\b8)[\s\-(]*\d{3}[\s\-)]*\d{3}[\s-]*\d{2}[\s-]*\
|
|||
# пропускает из-за требования префикса «+7»/«\b8» + хотя бы одного разделителя.
|
||||
# Ставится РАНЬШЕ _SNILS_BARE_RE (любые 11 цифр), чтобы не путать с СНИЛС.
|
||||
_PHONE_BARE_RE = re.compile(r"(?<!\d)[78]\d{10}(?!\d)")
|
||||
# Телефон РФ «локальный»: 10 значащих цифр БЕЗ кода страны, начинаются с мобильного
|
||||
# «9», сгруппированы 9XX XXX XX XX через пробел/дефис (#1641). _PHONE_RE требует
|
||||
# префикс +7/8, _PHONE_BARE_RE — ровно 11 слитных цифр → формат «922 123 45 67» /
|
||||
# «922-123-45-67» проходил мимо. Якорь «9» + фиксированная группировка 3-3-2-2 не
|
||||
# пересекается с паспортом (4+6) / СНИЛС (3-3-3 2). Ставится ПОСЛЕ префиксных форм,
|
||||
# чтобы local-часть «+7 912 …» сначала ушла как полноценный phone.
|
||||
_PHONE_LOCAL_RE = re.compile(r"(?<!\d)9\d{2}[\s-]\d{3}[\s-]\d{2}[\s-]\d{2}(?!\d)")
|
||||
# Email.
|
||||
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
|
||||
# ИНН: ровно 12 (физлицо) или 10 (юрлицо) цифр, не приклеенные к другим цифрам.
|
||||
# ИНН-кандидат: ровно 12 (физлицо) или 10 (юрлицо) цифр, не приклеенные к другим
|
||||
# цифрам. ВНИМАНИЕ: regex здесь — лишь поиск кандидата. Голый «N{10}|N{12}» ловил бы и
|
||||
# легитимные слитные суммы (1 млрд+ руб = 10 цифр, ~трлн = 12) и молча затирал бы их
|
||||
# до отправки в LLM (#1640). Поэтому замена проходит ТОЛЬКО если кандидат проходит
|
||||
# официальную проверку контрольных цифр ИНН (_inn_checksum_valid) — см. _inn_repl.
|
||||
_INN_RE = re.compile(r"(?<!\d)(?:\d{12}|\d{10})(?!\d)")
|
||||
|
||||
|
||||
def _inn_checksum_valid(digits: str) -> bool:
|
||||
"""Проверить контрольные цифры ИНН (официальный алгоритм ФНС).
|
||||
|
||||
10-значный (юрлицо): одна контрольная цифра. 12-значный (физлицо/ИП): две.
|
||||
Вес-коэффициенты фиксированы стандартом. Любая слитная группа 10/12 цифр, НЕ
|
||||
проходящая checksum (напр. круглая сумма «1000000000»), считается НЕ-ИНН и не
|
||||
редактируется — это и закрывает ложные срабатывания #1640.
|
||||
|
||||
NB: checksum резко снижает false-positive rate, но не доводит его до нуля —
|
||||
~1/11 случайных 10-значных чисел совпадает с валидным ИНН по контрольной цифре.
|
||||
Это приемлемо для вторичной (belt-and-suspenders) защиты.
|
||||
"""
|
||||
if len(digits) == 10:
|
||||
weights = (2, 4, 10, 3, 5, 9, 4, 6, 8)
|
||||
control = sum(int(digits[i]) * weights[i] for i in range(9)) % 11 % 10
|
||||
return control == int(digits[9])
|
||||
if len(digits) == 12:
|
||||
w1 = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
|
||||
w2 = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
|
||||
c1 = sum(int(digits[i]) * w1[i] for i in range(10)) % 11 % 10
|
||||
c2 = sum(int(digits[i]) * w2[i] for i in range(11)) % 11 % 10
|
||||
return c1 == int(digits[10]) and c2 == int(digits[11])
|
||||
return False
|
||||
|
||||
|
||||
def _inn_repl(match: re.Match[str]) -> str:
|
||||
"""re.sub-callback: редактировать кандидат ТОЛЬКО если checksum валиден."""
|
||||
token = match.group(0)
|
||||
return "[REDACTED:inn]" if _inn_checksum_valid(token) else token
|
||||
|
||||
|
||||
# СНИЛС «голый»: ровно 11 цифр без разделителей (#1207). _SNILS_RE требует
|
||||
# формат «NNN-NNN-NNN NN»; raw «12345678901» проходит мимо. По длине не пересекается
|
||||
# с ИНН (10/12); пересекается с _PHONE_BARE_RE (тоже 11 цифр), поэтому идёт ПОСЛЕ
|
||||
|
|
@ -97,18 +142,22 @@ _FULLNAME_RE = re.compile(
|
|||
r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})\b"
|
||||
)
|
||||
|
||||
# (regex, placeholder-kind). Применяются последовательно в этом порядке.
|
||||
# (regex, kind, repl). ``repl`` — строка-плейсхолдер ИЛИ callback для re.subn
|
||||
# (используется ИНН: редактирует только checksum-валидные кандидаты — #1640).
|
||||
# Применяются последовательно в этом порядке.
|
||||
# Порядок критичен: _PHONE_BARE_RE раньше _SNILS_BARE_RE, чтобы 11-значные
|
||||
# с префиксом 7/8 ушли как phone (телефон семантически точнее СНИЛС'а).
|
||||
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
(_SNILS_RE, "snils"),
|
||||
(_PASSPORT_RE, "passport"),
|
||||
(_PHONE_RE, "phone"),
|
||||
(_PHONE_BARE_RE, "phone"),
|
||||
(_EMAIL_RE, "email"),
|
||||
(_INN_RE, "inn"),
|
||||
(_SNILS_BARE_RE, "snils"),
|
||||
(_FULLNAME_RE, "name"),
|
||||
_Repl = str | Callable[[re.Match[str]], str]
|
||||
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str, _Repl], ...] = (
|
||||
(_SNILS_RE, "snils", "[REDACTED:snils]"),
|
||||
(_PASSPORT_RE, "passport", "[REDACTED:passport]"),
|
||||
(_PHONE_RE, "phone", "[REDACTED:phone]"),
|
||||
(_PHONE_BARE_RE, "phone", "[REDACTED:phone]"),
|
||||
(_PHONE_LOCAL_RE, "phone", "[REDACTED:phone]"),
|
||||
(_EMAIL_RE, "email", "[REDACTED:email]"),
|
||||
(_INN_RE, "inn", _inn_repl),
|
||||
(_SNILS_BARE_RE, "snils", "[REDACTED:snils]"),
|
||||
(_FULLNAME_RE, "name", "[REDACTED:name]"),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -121,9 +170,16 @@ def scrub_text(value: str) -> str:
|
|||
if not value:
|
||||
return value
|
||||
redacted = value
|
||||
for pattern, kind in _PII_PATTERNS:
|
||||
redacted, n = pattern.subn(f"[REDACTED:{kind}]", redacted)
|
||||
if n:
|
||||
placeholder = "[REDACTED:%s]"
|
||||
for pattern, kind, repl in _PII_PATTERNS:
|
||||
# n из subn для callback-repl (ИНН) считает ВСЕ совпадения, включая кандидаты,
|
||||
# которые callback вернул без изменений (не прошли checksum). Поэтому реальное
|
||||
# число замен берём по приросту числа плейсхолдеров — корректно и для str, и
|
||||
# для callback, без утечки самого PII-значения в лог.
|
||||
before_count = redacted.count(placeholder % kind)
|
||||
redacted = pattern.sub(repl, redacted)
|
||||
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)
|
||||
return redacted
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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["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()
|
||||
except sqlite3.Error as e:
|
||||
info["error"] = str(e)
|
||||
return info
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ opens the original DOM.РФ URL (we don't need to mirror originals).
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
|
@ -50,7 +51,16 @@ def make_thumbnail(
|
|||
im = im.convert("RGB")
|
||||
im = ImageOps.fit(im, size, method=Image.Resampling.LANCZOS)
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
im.save(dst, format="WEBP", quality=quality, method=4)
|
||||
# Atomic write: encode to a sibling temp file, then os.replace() so a
|
||||
# crash/OOM mid-encode never leaves a truncated .webp at the canonical
|
||||
# path (which dst.exists() would later treat as a valid cached thumb).
|
||||
tmp = dst.with_name(f".{dst.name}.tmp")
|
||||
try:
|
||||
im.save(tmp, format="WEBP", quality=quality, method=4)
|
||||
os.replace(tmp, dst)
|
||||
except BaseException:
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
return dst
|
||||
except Exception as e:
|
||||
logger.warning("thumbnail %s failed: %s", src, e)
|
||||
|
|
|
|||
|
|
@ -157,6 +157,16 @@ class _TextCollector(HTMLParser):
|
|||
extraction известных структур страницы каталога.
|
||||
"""
|
||||
|
||||
# HTML5 void-элементы: не имеют закрывающего тега → handle_endtag не вызывается.
|
||||
# Если пушить их в стек/буфер, чужой handle_endtag поп'ает чужой фрейм →
|
||||
# рассинхрон стека, текст блоков теряется (issue #1608).
|
||||
_VOID_TAGS = frozenset(
|
||||
{
|
||||
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
||||
"link", "meta", "param", "source", "track", "wbr",
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._stack: list[tuple[str, dict[str, str]]] = []
|
||||
|
|
@ -165,11 +175,15 @@ class _TextCollector(HTMLParser):
|
|||
self._buf: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
if tag in self._VOID_TAGS:
|
||||
return # void-теги не имеют endtag — не открываем фрейм (issue #1608)
|
||||
attr_dict = {k: (v or "") for k, v in attrs}
|
||||
self._stack.append((tag, attr_dict))
|
||||
self._buf.append("") # начало нового буфера для этого тега
|
||||
|
||||
def handle_endtag(self, _tag: str) -> None:
|
||||
if _tag in self._VOID_TAGS:
|
||||
return # void-теги фрейм не открывали — нечего поп'ать (issue #1608)
|
||||
if not self._stack:
|
||||
return
|
||||
tag, attr_dict = self._stack.pop()
|
||||
|
|
@ -239,8 +253,11 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
|||
# Ищем в сыром HTML — надёжнее чем DOM-обход для хрупкой структуры.
|
||||
|
||||
# Price: "7 890 000 ₽" или "7 890 000 руб"
|
||||
# Negative lookahead (?!\s*[/⁄]) исключает цену за м² ("217 835 ₽/м²"),
|
||||
# которая на странице обычно выше полной стоимости и иначе матчилась бы
|
||||
# первой → отбрасывалась санити-границей → price_rub=NULL (issue #1645).
|
||||
price_match = re.search(
|
||||
r"([\d][\d\s]{3,12}[\d])\s*(?:₽|руб)",
|
||||
r"([\d][\d\s]{3,12}[\d])\s*(?:₽|руб)(?!\s*[/⁄])",
|
||||
html,
|
||||
re.UNICODE,
|
||||
)
|
||||
|
|
@ -267,19 +284,25 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
|||
except ValueError:
|
||||
pass
|
||||
|
||||
# Status: ищем характерные слова рядом с "статус" или в badge
|
||||
# Status: ищем характерные слова рядом с "статус" или в badge.
|
||||
# Морфоварианты продан[ао]?/забронирован[ао]? — типовая "Квартира продана"
|
||||
# (ж.р.) раньше не матчилась, status уходил во free (issue #1609).
|
||||
# NOTE: классификация по первому совпадению во ВСЁМ HTML остаётся хрупкой —
|
||||
# 'в продаже' из блока 'другие квартиры в продаже' может дать ложный free.
|
||||
# Полный фикс (якорь к статус-бейджу / исключение секций "похожие") требует
|
||||
# знания DOM-структуры страницы каталога — см. отчёт (needs-leha).
|
||||
status_match = re.search(
|
||||
r"(в\s*продаже|свободна|free|продано|sold|забронирована|бронь|reserved)",
|
||||
r"(в\s*продаже|свободн[ао]|free|продан[ао]?|sold|забронирован[ао]?|бронь|reserved)",
|
||||
html,
|
||||
re.IGNORECASE | re.UNICODE,
|
||||
)
|
||||
if status_match:
|
||||
s = status_match.group(1).lower()
|
||||
if any(kw in s for kw in ("продаже", "свободна", "free")):
|
||||
if any(kw in s for kw in ("продаже", "свободн", "free")):
|
||||
result["status"] = STATUS_FREE
|
||||
elif any(kw in s for kw in ("продано", "sold")):
|
||||
elif any(kw in s for kw in ("продан", "sold")):
|
||||
result["status"] = STATUS_SOLD
|
||||
elif any(kw in s for kw in ("бронь", "забронирована", "reserved")):
|
||||
elif any(kw in s for kw in ("бронь", "забронирован", "reserved")):
|
||||
result["status"] = STATUS_RESERVED
|
||||
|
||||
# Finishing type: "Предчистовая", "Чистовая", "Без отделки", "Под ключ"
|
||||
|
|
@ -483,7 +506,18 @@ async def scrape_one_flat(
|
|||
|
||||
outcome["fields_extracted"] = len([v for v in data.values() if v is not None])
|
||||
outcome["updated"] = upsert_catalog_data(db, ods_id, catalog_url_hash, data)
|
||||
# success отражает прохождение пайплайна fetch+parse без исключения; реально
|
||||
# ли затронута строка в БД — см. outcome['updated']. Батч-статистика считает
|
||||
# отдельный stats['updated'], чтобы не рапортовать ложно высокий success при
|
||||
# ненайденном ods_id / пустом парсе (fields_extracted==0) — issue #1610.
|
||||
outcome["success"] = True
|
||||
if not outcome["updated"]:
|
||||
logger.warning(
|
||||
"catalog scrape ods_id=%s: fetched+parsed but DB row NOT updated "
|
||||
"(fields=%d, ods_id missing or all-NULL parse)",
|
||||
ods_id,
|
||||
outcome["fields_extracted"],
|
||||
)
|
||||
logger.info(
|
||||
"catalog scrape ods_id=%s: fields=%d updated=%s",
|
||||
ods_id,
|
||||
|
|
@ -512,11 +546,15 @@ async def scrape_catalog_batch(
|
|||
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
|
||||
|
||||
Returns:
|
||||
{total, success, failed, fields_total}
|
||||
{total, success, updated, failed, fields_total}
|
||||
- success: прошли fetch+parse без исключения
|
||||
- updated: реально затронули строку в БД (issue #1610) — отражает
|
||||
фактическое число записанных квартир, в отличие от success
|
||||
"""
|
||||
stats: dict[str, Any] = {
|
||||
"total": len(flats),
|
||||
"success": 0,
|
||||
"updated": 0,
|
||||
"failed": 0,
|
||||
"fields_total": 0,
|
||||
}
|
||||
|
|
@ -550,6 +588,8 @@ async def scrape_catalog_batch(
|
|||
if outcome["success"]:
|
||||
stats["success"] += 1
|
||||
stats["fields_total"] += outcome["fields_extracted"]
|
||||
if outcome["updated"]:
|
||||
stats["updated"] += 1
|
||||
else:
|
||||
stats["failed"] += 1
|
||||
|
||||
|
|
@ -565,9 +605,10 @@ async def scrape_catalog_batch(
|
|||
raise
|
||||
|
||||
logger.info(
|
||||
"scrape_catalog_batch done: total=%d success=%d failed=%d fields_total=%d",
|
||||
"scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d fields_total=%d",
|
||||
stats["total"],
|
||||
stats["success"],
|
||||
stats["updated"],
|
||||
stats["failed"],
|
||||
stats["fields_total"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -115,6 +115,30 @@ def _to_int(v: Any) -> int | None:
|
|||
return None
|
||||
|
||||
|
||||
def _to_float(v: Any) -> float | None:
|
||||
"""Coerce a DOM.РФ numeric value into float. None / bool / non-numeric / empty → None.
|
||||
Accepts int, float, and numeric strings ('45.2'). Mirror of _to_int for area/price."""
|
||||
if v is None or isinstance(v, bool):
|
||||
return None
|
||||
if isinstance(v, int | float):
|
||||
f = float(v)
|
||||
if f != f or f in (float("inf"), float("-inf")): # NaN / ±inf guard
|
||||
return None
|
||||
return f
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
f = float(s)
|
||||
except (ValueError, OverflowError):
|
||||
return None
|
||||
if f != f or f in (float("inf"), float("-inf")):
|
||||
return None
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def _to_date(v: Any) -> date | None:
|
||||
"""Coerce date string to date. Accept 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM:SS',
|
||||
'DD-MM-YYYY HH:MM:SS', or quarter-string like 'IV кв. 2028'.
|
||||
|
|
@ -414,8 +438,13 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
|
|||
|
||||
# Derive price_per_m2 when API returns price_rub but omits pricePerSquareMeter.
|
||||
# Covers cases where the table endpoint has the flat price but no pre-computed m² rate.
|
||||
if price_per_m2 is None and price_rub is not None and total_area and total_area > 0:
|
||||
price_per_m2 = round(price_rub / total_area, 2)
|
||||
# Coerce оба операнда в float ДО сравнения/деления: API иногда отдаёт totalArea/price
|
||||
# строкой ('45.2'), и `total_area > 0` на str роняло бы _norm_flat с TypeError
|
||||
# → весь run падал бы в status='failed' (#1644).
|
||||
price_rub_num = _to_float(price_rub)
|
||||
total_area_num = _to_float(total_area)
|
||||
if price_per_m2 is None and price_rub_num is not None and total_area_num and total_area_num > 0:
|
||||
price_per_m2 = round(price_rub_num / total_area_num, 2)
|
||||
logger.info(
|
||||
"derive price_per_m2=%.2f for flat ods_id=%s obj_id=%s",
|
||||
price_per_m2,
|
||||
|
|
@ -1432,34 +1461,78 @@ def _place_str(region_code: int) -> str:
|
|||
return str(region_code)
|
||||
|
||||
|
||||
OBJECTS_PAGE_SIZE = 500
|
||||
|
||||
|
||||
async def fetch_objects_for_status(
|
||||
sess: BrowserSession, place: str, status: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch ALL objects for a given (place, objStatus) — server returns up to limit=999999."""
|
||||
"""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": 0,
|
||||
"limit": 999999,
|
||||
"offset": offset,
|
||||
"limit": OBJECTS_PAGE_SIZE,
|
||||
"sortField": "default",
|
||||
"sortType": "desc",
|
||||
"place": place,
|
||||
"objStatus": status,
|
||||
},
|
||||
)
|
||||
rows = _extract_list(payload)
|
||||
page = _extract_list(payload)
|
||||
if total is None:
|
||||
total = _extract_total(payload)
|
||||
logger.info("kn/object place=%s status=%d -> %d/%s rows", place, status, len(rows), total)
|
||||
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,
|
||||
)
|
||||
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:
|
||||
"""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})
|
||||
except Exception as e:
|
||||
logger.warning("flats fetch obj=%s failed: %s", obj_id, e)
|
||||
return []
|
||||
# Body shape: {externalId, entrances: [{entranceNumber, floors:[{floorNumber, flats:[...]}]}]}
|
||||
return _flatten_table(payload)
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ _EKB_LAT_MAX = 57.5
|
|||
|
||||
# Паттерн для разбора первого числа из строки координат
|
||||
# (ячейки могут содержать несколько точек через пробелы или запятую-десятичный разделитель)
|
||||
_COORD_FIRST_RE = re.compile(r"[\d]+[.,][\d]+")
|
||||
_COORD_FIRST_RE = re.compile(r"[\d]+(?:[.,][\d]+)?")
|
||||
|
||||
|
||||
def msk66_to_wgs84(raw_x: str | None, raw_y: str | None) -> tuple[float, float] | None:
|
||||
|
|
@ -375,7 +375,7 @@ 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
|
||||
|
|
@ -395,6 +395,8 @@ class EkburgPermitsClient:
|
|||
data_start,
|
||||
)
|
||||
yield from self._parse_sheet(sheet, permit_type, year, source_url, data_start)
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
def _parse_sheet(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -329,25 +329,37 @@ def denorm_dump(
|
|||
features: плоский list из features_json JSONB (уже декодированный Python list).
|
||||
|
||||
Returns:
|
||||
dict {"parcels": N, "buildings": M, "errors": K} — количество обработанных строк.
|
||||
dict {"parcels": N, "buildings": M, "errors": K, "skipped": S} —
|
||||
количество обработанных строк. ``errors`` — только реальные сбои UPSERT;
|
||||
``skipped`` — штатные пропуски feature без cad_num.
|
||||
"""
|
||||
snapshot_date = datetime.date.today().isoformat()
|
||||
parcels_n = 0
|
||||
buildings_n = 0
|
||||
errors_n = 0
|
||||
skipped_n = 0
|
||||
|
||||
for feat in features:
|
||||
layer = feat.get("layer", "")
|
||||
try:
|
||||
if layer == "parcels":
|
||||
if denorm_parcel_feature(
|
||||
# denorm_parcel_feature возвращает False и при штатном пропуске
|
||||
# (нет cad_num), и при реальном сбое UPSERT — различаем их здесь,
|
||||
# чтобы не завышать error-счётчик прогона.
|
||||
props = feat.get("properties") or {}
|
||||
if not (props.get("cad_num") or props.get("cadastral_number")):
|
||||
skipped_n += 1
|
||||
elif denorm_parcel_feature(
|
||||
db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date
|
||||
):
|
||||
parcels_n += 1
|
||||
else:
|
||||
errors_n += 1
|
||||
elif layer == "buildings":
|
||||
if denorm_building_feature(
|
||||
props = feat.get("properties") or {}
|
||||
if not (props.get("cad_num") or props.get("cadastral_number")):
|
||||
skipped_n += 1
|
||||
elif denorm_building_feature(
|
||||
db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date
|
||||
):
|
||||
buildings_n += 1
|
||||
|
|
@ -360,10 +372,16 @@ def denorm_dump(
|
|||
|
||||
db.commit()
|
||||
logger.info(
|
||||
"denorm_dump quarter=%s parcels=%d buildings=%d errors=%d",
|
||||
"denorm_dump quarter=%s parcels=%d buildings=%d errors=%d skipped=%d",
|
||||
quarter_cad,
|
||||
parcels_n,
|
||||
buildings_n,
|
||||
errors_n,
|
||||
skipped_n,
|
||||
)
|
||||
return {"parcels": parcels_n, "buildings": buildings_n, "errors": errors_n}
|
||||
return {
|
||||
"parcels": parcels_n,
|
||||
"buildings": buildings_n,
|
||||
"errors": errors_n,
|
||||
"skipped": skipped_n,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,17 @@ def fetch_geoportal(
|
|||
try:
|
||||
with urllib.request.urlopen(req, context=_SSL_CTX, timeout=timeout) as r:
|
||||
body = r.read().decode("utf-8", "ignore")
|
||||
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,6 +186,11 @@ def fetch_via_rosreestr2coord(
|
|||
# denied при первом fetch.
|
||||
# Фикс: use_cache=False + media_path=/tmp/rosreestr2coord (writable для всех).
|
||||
# Кеш нам не нужен — каждый cad_num уникален, обращаемся раз.
|
||||
#
|
||||
# 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,
|
||||
|
|
@ -178,11 +199,20 @@ def fetch_via_rosreestr2coord(
|
|||
use_cache=False,
|
||||
media_path="/tmp/rosreestr2coord",
|
||||
)
|
||||
try:
|
||||
# dumps=False — возвращает dict (GeoJSON Feature), а не JSON-сериализованную
|
||||
# строку. Default в v5 = True → строка → крах в `_persist_target` который
|
||||
# ожидает dict с `.get("properties")` etc.
|
||||
return a.to_geojson_poly(dumps=False)
|
||||
except Exception as e:
|
||||
logger.warning("rosreestr2coord failed for %s: %s", cad_num, e)
|
||||
return None
|
||||
except (HTTPForbiddenException, HTTPErrorException, TimeoutException) as e:
|
||||
# WAF/rate-limit (HTTP 403 → HTTPForbiddenException, 429/прочие HTTP →
|
||||
# HTTPErrorException) и таймауты — transient. Поднимаем NspdLiteWafError,
|
||||
# чтобы воркер process_nspd_geo_job сделал backoff (exponential, инкремент
|
||||
# waf_blocked_count, пауза после серии WAF), а не пометил цель 'done' с 0
|
||||
# features (false run-status). См. nspd_geo.py:464.
|
||||
raise NspdLiteWafError(f"rosreestr2coord WAF/transient for {cad_num}: {e}") from e
|
||||
except RequestException as e:
|
||||
# Прочие ошибки запроса (RequestException, в т.ч. is_error_response с
|
||||
# сообщением об ошибке от NSPD) — не отличить от transient WAF надёжно,
|
||||
# но это НЕ легитимное 'участок не найден' (то возвращает feature=None →
|
||||
# None без исключения). Классифицируем как ошибку, не как пустой результат.
|
||||
raise NspdLiteError(f"rosreestr2coord request failed for {cad_num}: {e}") from e
|
||||
|
|
|
|||
|
|
@ -69,6 +69,35 @@ _CHECK_TYPE_ALIASES: dict[str, list[str]] = {
|
|||
"declaration": ["declaration", "hasDeclaration", "declarationFlg"],
|
||||
}
|
||||
|
||||
# Строковые флаги, которые сторонний API может прислать вместо bool.
|
||||
# Схема payload не верифицирована (см. docstring), поэтому приводим явно.
|
||||
_TRUE_STRINGS = {"true", "1", "yes", "y", "да", "passed", "ok"}
|
||||
_FALSE_STRINGS = {"false", "0", "no", "n", "нет", "failed", "not_passed"}
|
||||
|
||||
|
||||
def _coerce_flag(value: Any) -> bool | None:
|
||||
"""Привести значение флага проверки к bool либо None (UNKNOWN).
|
||||
|
||||
bool(value) ломается на строках ('false'/'0'/'нет' → True) и не отличает
|
||||
отсутствие данных от False. Возвращаем None, если значение нераспознаваемо —
|
||||
вызывающий код НЕ должен фабриковать False для UNKNOWN.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
s = value.strip().lower()
|
||||
if s in _TRUE_STRINGS:
|
||||
return True
|
||||
if s in _FALSE_STRINGS:
|
||||
return False
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
_UPSERT_CHECKS_SQL = text(
|
||||
"""
|
||||
INSERT INTO domrf_obj_checks (obj_id, check_type, passed, checked_at, scraped_at)
|
||||
|
|
@ -106,14 +135,21 @@ def extract_obj_checks(raw_payload: Any) -> list[dict[str, Any]]:
|
|||
for field, value in data.items():
|
||||
ct = _CHECK_FIELD_MAP.get(field)
|
||||
if ct and ct not in found:
|
||||
found[ct] = bool(value)
|
||||
flag = _coerce_flag(value)
|
||||
if flag is not None:
|
||||
found[ct] = flag
|
||||
# Также проверить canonical names напрямую
|
||||
for ct in CHECK_TYPES:
|
||||
if ct not in found and ct in data:
|
||||
found[ct] = bool(data[ct])
|
||||
flag = _coerce_flag(data[ct])
|
||||
if flag is not None:
|
||||
found[ct] = flag
|
||||
if found:
|
||||
# Только фактически найденные флаги: отсутствие в payload = UNKNOWN,
|
||||
# а не FAILED — не фабрикуем passed=False для непришедших проверок.
|
||||
for ct in CHECK_TYPES:
|
||||
results.append({"check_type": ct, "passed": found.get(ct, False)})
|
||||
if ct in found:
|
||||
results.append({"check_type": ct, "passed": found[ct]})
|
||||
return results
|
||||
# dict не содержит известных полей — попробуем как list-формат ниже
|
||||
logger.warning(
|
||||
|
|
@ -128,11 +164,24 @@ def extract_obj_checks(raw_payload: Any) -> list[dict[str, Any]]:
|
|||
continue
|
||||
ct_raw = item.get("checkType") or item.get("check_type") or item.get("type")
|
||||
if ct_raw and str(ct_raw) in CHECK_TYPES:
|
||||
passed_raw = item.get("passed") or item.get("value") or item.get("status")
|
||||
found_list[str(ct_raw)] = bool(passed_raw)
|
||||
# Не or-коалесинг: легитимный False теряется (False or 'n/a' → 'n/a').
|
||||
# Берём первый ключ, который реально присутствует в item.
|
||||
if "passed" in item:
|
||||
passed_raw = item["passed"]
|
||||
elif "value" in item:
|
||||
passed_raw = item["value"]
|
||||
elif "status" in item:
|
||||
passed_raw = item["status"]
|
||||
else:
|
||||
passed_raw = None
|
||||
flag = _coerce_flag(passed_raw)
|
||||
if flag is not None:
|
||||
found_list[str(ct_raw)] = flag
|
||||
if found_list:
|
||||
# См. dict-ветку: только найденные флаги, UNKNOWN не равно FAILED.
|
||||
for ct in CHECK_TYPES:
|
||||
results.append({"check_type": ct, "passed": found_list.get(ct, False)})
|
||||
if ct in found_list:
|
||||
results.append({"check_type": ct, "passed": found_list[ct]})
|
||||
return results
|
||||
logger.warning(
|
||||
"domrf obj_checks: list payload has no recognisable check items: %s", data[:3]
|
||||
|
|
|
|||
|
|
@ -227,6 +227,7 @@ class BrowserSession:
|
|||
"""
|
||||
if self._context is None:
|
||||
raise RuntimeError("BrowserSession not bootstrapped")
|
||||
async with self._sem:
|
||||
await jitter_sleep(200, 500) # Lighter throttle for static assets.
|
||||
self._request_count += 1
|
||||
resp = await self._context.request.get(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub)
|
||||
AS median_price_per_m2
|
||||
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
|
||||
GROUP BY cm.domrf_obj_id
|
||||
),
|
||||
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
|
||||
p.obj_id,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY p.price_per_m2_rub)
|
||||
AS median_price_per_m2
|
||||
FROM (
|
||||
SELECT obj_id, price_per_m2_rub FROM primary_price
|
||||
UNION ALL
|
||||
SELECT obj_id, price_per_m2_rub FROM gapfill_price
|
||||
) p
|
||||
GROUP BY p.obj_id
|
||||
""")
|
||||
|
||||
|
||||
|
|
@ -648,7 +713,13 @@ def get_competitors(
|
|||
if missing_price_ids:
|
||||
try:
|
||||
obj_price_rows = (
|
||||
db.execute(_OBJECTIVE_PRICE_FALLBACK_SQL, {"obj_ids": missing_price_ids})
|
||||
db.execute(
|
||||
_OBJECTIVE_PRICE_FALLBACK_SQL,
|
||||
{
|
||||
"obj_ids": missing_price_ids,
|
||||
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
|
|||
Returns:
|
||||
list[{project_type, doc_status_name, is_active, paga_number, paga_date,
|
||||
subject, doc_full_name}].
|
||||
- ``is_active``: doc_status_name == 'Действующий'.
|
||||
- ``is_active``: doc_status_name == 'действующий' (регистронезависимо).
|
||||
- ``paga_number`` / ``paga_date``: regex из full_name; None если не распарсилось.
|
||||
- ``subject``: текст full_name после строки с ПАГЕ-реквизитами; None если нет.
|
||||
Отсортировано: сначала действующие, внутри группы — по paga_date desc (свежие первыми).
|
||||
|
|
@ -97,7 +97,10 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
|
|||
for r in rows:
|
||||
full_name: str | None = r["full_name"]
|
||||
paga_number, paga_date, subject = _parse_paga(full_name)
|
||||
is_active = r["doc_status_name"] == "Действующий"
|
||||
status_name: str | None = r["doc_status_name"]
|
||||
# Регистронезависимо: harvest пишет doc_status_name из WFS без нормализации,
|
||||
# DDL/COMMENT документируют хранимое значение как lowercase ('действующий').
|
||||
is_active = (status_name or "").strip().lower() == "действующий"
|
||||
result.append(
|
||||
{
|
||||
"project_type": r["project_type"],
|
||||
|
|
|
|||
|
|
@ -10,8 +10,14 @@
|
|||
|
||||
Прямой FK не ставим: doc_ref пишется парсером по PDF-источнику (slug/seed), а в WFS
|
||||
source_key — целое и не совпадает форматом. Сопоставление — две дешёвые ветки:
|
||||
1) ``doc_ref = source_key::text`` (прямое равенство);
|
||||
2) ``doc_ref ILIKE '%doc_full_name%'`` (fallback для slug-ов вида ppt2018_22823).
|
||||
1) ``doc_ref = CAST(source_key AS text)`` (прямое равенство);
|
||||
2) ``doc_full_name ILIKE '%' || doc_ref || '%'`` (fallback для slug-ов вида
|
||||
ppt2018_22823: длинное WFS-описание содержит короткий slug парсера; LIKE-метасимволы
|
||||
в doc_ref экранируются ESCAPE '\').
|
||||
|
||||
Дедуп: best-effort OR-JOIN может зацепить несколько строк ``ekb_ppt_tep`` на один
|
||||
``planning_projects`` (точная ветка + ILIKE-fallback). ``DISTINCT ON (source_key)``
|
||||
держит контракт «одна строка = один ППТ/ПМТ-документ», отдавая приоритет точной ветке.
|
||||
|
||||
Граф вызова: build_ird_analyze_block → parcel_ppt_tep → JOIN planning_projects ∩ ekb_ppt_tep.
|
||||
Graceful: нет таблицы / пустой WKT / БД-ошибка → []. Зеркалит стиль ``planning_lookup.py``.
|
||||
|
|
@ -33,13 +39,29 @@ 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
|
||||
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,
|
||||
|
|
@ -49,10 +71,17 @@ _PPT_TEP_OVERLAP_SQL = text(
|
|||
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 || '%')
|
||||
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.dmd_actual_year DESC NULLS LAST, pp.project_type
|
||||
ORDER BY pp.source_key, (t.doc_ref = CAST(pp.source_key AS text)) DESC
|
||||
) deduped
|
||||
ORDER BY dmd_actual_year DESC NULLS LAST, project_type
|
||||
"""
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -74,13 +74,16 @@ class BuildingMatch:
|
|||
|
||||
|
||||
# Geom-match domrf-центроида (lat/lon) → ближайшее здание cad_buildings (#96).
|
||||
# Один LATERAL KNN: GIST `cad_buildings_geom_gist` (`geom <-> point`) выбирает
|
||||
# ОДНОГО ближайшего кандидата (~19ms прод, EXPLAIN ANALYZE), затем фильтруем по
|
||||
# фактической дистанции в метрах (geography ST_Distance — KNN-оператор `<->` на
|
||||
# geometry SRID 4326 сортирует в ГРАДУСАХ, что для малых расстояний монотонно
|
||||
# дистанции, но сама величина в градусах — поэтому метры считаем отдельно). Точка
|
||||
# внутри площадного footprint даёт ST_Distance=0 (ST_Contains-эквивалент, см.
|
||||
# прод: 198 объектов внутри). psycopg v3: CAST(:x AS double precision) — НЕ ::type.
|
||||
# Один KNN по geography: GIST `idx_cad_buildings_geom_geog`
|
||||
# (`geom::geography <-> point::geography`) выбирает ОДНОГО ближайшего кандидата по
|
||||
# ФАКТИЧЕСКИМ МЕТРАМ (geography `<->` = сфероидное расстояние в метрах, не градусы).
|
||||
# Важно: KNN на geometry SRID 4326 (`geom <-> point`) сортирует в ГРАДУСАХ, а на
|
||||
# широте ЕКБ (~56.8°N) долгота сжата ×1.83 → degree-порядок ≠ meter-порядок для
|
||||
# кандидатов в разных направлениях, и LIMIT 1 мог взять не ближайший по метрам дом
|
||||
# (parking_ratio чужого здания). Поэтому ранжируем и фильтруем строго в метрах.
|
||||
# distance_m считаем тем же geography ST_Distance: точка внутри площадного footprint
|
||||
# даёт 0 (ST_Contains-эквивалент, прод: 198 объектов внутри).
|
||||
# psycopg v3: CAST(:x AS double precision) — НЕ ::type.
|
||||
_RESOLVE_CAD_SQL = text("""
|
||||
SELECT
|
||||
b.cad_num,
|
||||
|
|
@ -97,13 +100,13 @@ _RESOLVE_CAD_SQL = text("""
|
|||
) AS distance_m
|
||||
FROM cad_buildings b
|
||||
WHERE b.geom IS NOT NULL
|
||||
ORDER BY b.geom <-> ST_SetSRID(
|
||||
ORDER BY b.geom::geography <-> ST_SetSRID(
|
||||
ST_MakePoint(
|
||||
CAST(:lon AS double precision),
|
||||
CAST(:lat AS double precision)
|
||||
),
|
||||
4326
|
||||
)
|
||||
)::geography
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
|
|
@ -119,8 +122,9 @@ def resolve_cad_for_domrf(
|
|||
|
||||
Мост через PostGIS: domrf_kn_objects хранят только obj_id + lat/lon (без
|
||||
cad_num), а premises_lookup нужен cad_num ЗДАНИЯ. Берём ближайшее здание по
|
||||
GIST-ускоренному KNN (`geom <-> point`) и принимаем матч, если расстояние до
|
||||
footprint ≤ max_dist_m. Точка внутри площадного footprint → distance 0.
|
||||
GIST-ускоренному KNN в МЕТРАХ (`geom::geography <-> point::geography`) и
|
||||
принимаем матч, если расстояние до footprint ≤ max_dist_m. Точка внутри
|
||||
площадного footprint → distance 0.
|
||||
|
||||
Returns:
|
||||
BuildingMatch (cad_num + objdoc_id + distance_m) ближайшего здания в
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from __future__ import annotations
|
|||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.exc import DatabaseError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -41,8 +41,11 @@ def _refresh_mv(db: Session, mv_name: str, *, concurrently: bool) -> None:
|
|||
else:
|
||||
db.execute(text(f"REFRESH MATERIALIZED VIEW {mv_name}"))
|
||||
db.commit()
|
||||
except OperationalError as e:
|
||||
if concurrently and "cannot refresh materialized view" in str(e).lower():
|
||||
except DatabaseError as e:
|
||||
# PostgreSQL emits "CONCURRENTLY cannot be used when the materialized
|
||||
# view ... is not populated" (matview.c, SQLSTATE 55000), which psycopg3
|
||||
# surfaces as InternalError (a DatabaseError sibling of OperationalError).
|
||||
if concurrently and "concurrently cannot be used" in str(e).lower():
|
||||
logger.warning(
|
||||
"%s: CONCURRENTLY failed (MV likely not populated), "
|
||||
"falling back to non-concurrent refresh",
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ _RE_FLOORS = re.compile(
|
|||
+ _DASH
|
||||
+ r"\s*"
|
||||
+ _NUM
|
||||
+ r"\s*этаж",
|
||||
+ r"(?:\s*этаж\w*)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# высота — 'предельная высота ... – 25 м' (в ЕКБ почти всегда «не подлежат», но для прочих МО)
|
||||
|
|
|
|||
|
|
@ -130,12 +130,15 @@ def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | None:
|
|||
if not daily.get("time"):
|
||||
return None
|
||||
|
||||
t_max = daily.get("temperature_2m_max") or []
|
||||
t_min = daily.get("temperature_2m_min") or []
|
||||
precip = daily.get("precipitation_sum") or []
|
||||
uv = daily.get("uv_index_max") or []
|
||||
# Open-Meteo штатно возвращает null для отдельных дней (uv_index_max и др.)
|
||||
# при непустом daily.time — отфильтровываем None ПЕРЕД min/max/sum, иначе
|
||||
# TypeError в Python 3.12 уронит весь 7-day forecast в negative-cache (#1577).
|
||||
t_max = [v for v in (daily.get("temperature_2m_max") or []) if v is not None]
|
||||
t_min = [v for v in (daily.get("temperature_2m_min") or []) if v is not None]
|
||||
precip = [v for v in (daily.get("precipitation_sum") or []) if v is not None]
|
||||
uv = [v for v in (daily.get("uv_index_max") or []) if v is not None]
|
||||
wind_d = daily.get("winddirection_10m_dominant") or []
|
||||
wind_s = daily.get("windspeed_10m_max") or []
|
||||
wind_s = [v for v in (daily.get("windspeed_10m_max") or []) if v is not None]
|
||||
|
||||
# Circular mean направления ветра (vector sum) — избегает jump 359→1
|
||||
x = sum(math.cos(math.radians(d)) for d in wind_d if d is not None)
|
||||
|
|
@ -226,13 +229,21 @@ def _fetch_seasonal_remote(lat: float, lon: float) -> dict[str, Any] | None:
|
|||
if not vals["t_max"]:
|
||||
seasons[season] = None
|
||||
continue
|
||||
# t_min/precip накапливаются НЕЗАВИСИМО от t_max (раздельные None-guard'ы
|
||||
# выше) — при непустом t_max и all-null t_min/precip эти списки пусты, и
|
||||
# sum()/len() даст ZeroDivisionError, min([]) — ValueError (#1578). Метрики
|
||||
# по пустому списку → None вместо падения всего сезонного ответа.
|
||||
t_min = vals["t_min"]
|
||||
precip = vals["precip"]
|
||||
seasons[season] = {
|
||||
"avg_t_max_c": round(sum(vals["t_max"]) / len(vals["t_max"]), 1),
|
||||
"avg_t_min_c": round(sum(vals["t_min"]) / len(vals["t_min"]), 1),
|
||||
"avg_t_min_c": round(sum(t_min) / len(t_min), 1) if t_min else None,
|
||||
"max_t_c": round(max(vals["t_max"]), 1),
|
||||
"min_t_c": round(min(vals["t_min"]), 1),
|
||||
"avg_precip_per_day_mm": round(sum(vals["precip"]) / len(vals["precip"]), 1),
|
||||
"total_precip_mm": round(sum(vals["precip"]), 0),
|
||||
"min_t_c": round(min(t_min), 1) if t_min else None,
|
||||
"avg_precip_per_day_mm": (
|
||||
round(sum(precip) / len(precip), 1) if precip else None
|
||||
),
|
||||
"total_precip_mm": round(sum(precip), 0) if precip else 0,
|
||||
"days_observed": len(vals["t_max"]),
|
||||
}
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ Handlers регистрируются через Celery signals декорато
|
|||
worker_process_init — dispose SQLAlchemy engine в каждом prefork child-процессе,
|
||||
чтобы избежать shared TCP-сокетов к PostgreSQL после fork().
|
||||
|
||||
worker_ready — при рестарте воркера находит все 'running'/'paused' записи
|
||||
worker_ready — при рестарте воркера находит 'running'/'paused' записи
|
||||
kn_scrape_runs и nspd_geo_jobs и re-enqueue'ит их как zombie-resume.
|
||||
Для nspd_geo 'paused' (WAF-пауза) применяется 30-минутный cooldown
|
||||
(_ZOMBIE_PAUSED_THRESHOLD), чтобы редеплой не аннулировал WAF-защиту.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
|
@ -147,10 +149,18 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
|
|||
# старых runs сохранена в nspd_scrape_runs — никаких side-effects.
|
||||
|
||||
# NSPD geo-jobs: bulk-fetcher с собственной resume-логикой через
|
||||
# nspd_geo_jobs / nspd_geo_targets. Resume любых 'running' / 'paused' jobs
|
||||
# — на worker_ready по определению нет активных воркеров, всё running ==
|
||||
# nspd_geo_jobs / nspd_geo_targets. Resume любых 'running' jobs — на
|
||||
# worker_ready по определению нет активных воркеров, всё running ==
|
||||
# zombie. Раньше требовали heartbeat >10мин, что пропускало jobs убитых
|
||||
# за минуту до редеплоя и оставляло их вечно висеть.
|
||||
#
|
||||
# 'paused' (consecutive_waf>=8 — NSPD-WAF забанил IP VPS) НЕ ре-enqueue'им
|
||||
# безусловно: иначе каждый рестарт/редеплой воркера мгновенно аннулировал бы
|
||||
# WAF-cooldown и worker снова долбил бы забаненный сервис. Применяем тот же
|
||||
# 30-минутный порог, что и периодический cleanup_zombies
|
||||
# (_ZOMBIE_PAUSED_THRESHOLD), переиспользуя константу чтобы избежать дрейфа.
|
||||
from app.workers.tasks.nspd_geo import _ZOMBIE_PAUSED_THRESHOLD
|
||||
|
||||
db = SessionLocal()
|
||||
geo_resume_jobs: list[int] = []
|
||||
try:
|
||||
|
|
@ -161,10 +171,16 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
|
|||
UPDATE nspd_geo_jobs
|
||||
SET status = 'queued',
|
||||
error = COALESCE(error, 'auto-resume at worker_ready')
|
||||
WHERE status IN ('running', 'paused')
|
||||
WHERE status = 'running'
|
||||
OR (
|
||||
status = 'paused'
|
||||
AND heartbeat_at
|
||||
< NOW() - CAST(:paused_threshold AS interval)
|
||||
)
|
||||
RETURNING job_id
|
||||
"""
|
||||
)
|
||||
),
|
||||
{"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
|
|
|
|||
|
|
@ -51,6 +51,16 @@ WAF_BACKOFF_BASE_S = 30
|
|||
# нужен большой cooldown, иначе минута beat сразу аннулирует WAF-паузу.
|
||||
_ZOMBIE_RUNNING_THRESHOLD = "6 minutes"
|
||||
_ZOMBIE_PAUSED_THRESHOLD = "30 minutes"
|
||||
# Issue #1655: jobs, застрявшие в 'queued' после ре-enqueue (worker_ready /
|
||||
# cleanup_zombies ставят 'queued' + apply_async), но чьё broker-сообщение
|
||||
# потерялось (flush брокера, crash до pickup, purge очереди) — раньше никто
|
||||
# не ре-reap'ил. cleanup_zombies матчил только 'running'/'paused'.
|
||||
# Матчим только 'queued' c НЕ-NULL stale heartbeat_at: свежесозданный job
|
||||
# (enqueue_geo_job) имеет heartbeat_at IS NULL (нет дефолта в схеме 77_) — его
|
||||
# initial apply_async ещё в полёте, ре-reap'ить рано. Порог = running-порог
|
||||
# (6 мин): сообщение либо взято воркером (→ status станет 'running' через claim),
|
||||
# либо потеряно — 6 мин достаточно, чтобы отличить потерю от задержки очереди.
|
||||
_ZOMBIE_QUEUED_THRESHOLD = "6 minutes"
|
||||
|
||||
|
||||
# ── Helpers для job/target lifecycle ────────────────────────────────────────
|
||||
|
|
@ -79,8 +89,19 @@ def _log(
|
|||
pass
|
||||
|
||||
|
||||
def _start_job(db: Session, job_id: int) -> None:
|
||||
db.execute(
|
||||
def _start_job(db: Session, job_id: int) -> bool:
|
||||
"""Атомарный claim job'а: переводит в 'running' ТОЛЬКО если он ещё не 'running'.
|
||||
|
||||
Issue #1621: дублирующие task-сообщения на один job_id (worker_ready resume +
|
||||
cleanup_zombies beat одновременно, либо overlap контейнеров при rolling-redeploy,
|
||||
либо два prefork-child при --concurrency>1) раньше оба безусловно делали
|
||||
UPDATE→'running' и оба входили в while-loop → двойные WAF-хиты + затёртые счётчики.
|
||||
|
||||
Теперь claim атомарен: `WHERE job_id=:id AND status <> 'running'` + RETURNING.
|
||||
Если строка не вернулась — кто-то уже держит claim, второй worker должен выйти.
|
||||
Returns True если claim получен, False если job уже 'running' (или не найден).
|
||||
"""
|
||||
claimed = db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE nspd_geo_jobs
|
||||
|
|
@ -89,12 +110,17 @@ def _start_job(db: Session, job_id: int) -> None:
|
|||
heartbeat_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE job_id = :id
|
||||
AND status <> 'running'
|
||||
RETURNING job_id
|
||||
"""
|
||||
),
|
||||
{"id": job_id},
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
db.commit()
|
||||
if claimed is None:
|
||||
return False
|
||||
_log(db, job_id, "info", "start", "job started/resumed")
|
||||
return True
|
||||
|
||||
|
||||
def _heartbeat(db: Session, job_id: int, **counts: int) -> None:
|
||||
|
|
@ -385,7 +411,15 @@ def process_nspd_geo_job(self: Any, job_id: int) -> dict[str, Any]:
|
|||
if not job:
|
||||
return {"error": "job_not_found", "job_id": job_id}
|
||||
|
||||
_start_job(db, job_id)
|
||||
# Атомарный claim (issue #1621): если job уже 'running', значит другое
|
||||
# task-сообщение на тот же job_id уже в работе — выходим, не дублируя
|
||||
# WAF-хиты и не затирая счётчики параллельным циклом.
|
||||
if not _start_job(db, job_id):
|
||||
logger.info(
|
||||
"process_nspd_geo_job: job=%s already claimed (running) — skipping duplicate",
|
||||
job_id,
|
||||
)
|
||||
return {"job_id": job_id, "skipped": True, "reason": "already_running"}
|
||||
# Если в job-строке нет rate_ms — берём глобальный дефолт из job_settings.
|
||||
# Это позволяет менять дефолт через /admin/jobs/settings без перезапуска.
|
||||
if job["rate_ms"]:
|
||||
|
|
@ -637,6 +671,10 @@ def cleanup_zombies() -> dict[str, Any]:
|
|||
- status='paused' с heartbeat старше _ZOMBIE_PAUSED_THRESHOLD (30 мин) →
|
||||
WAF-пауза (consecutive_waf>=8) живёт минимум 30 мин, иначе минутный beat
|
||||
сразу аннулировал бы защиту и worker снова долбил бы забаненный сервис.
|
||||
- status='queued' c НЕ-NULL heartbeat старше _ZOMBIE_QUEUED_THRESHOLD (6 мин)
|
||||
→ ре-enqueue'нутый job, чьё broker-сообщение потерялось (issue #1655).
|
||||
heartbeat_at IS NOT NULL отсекает свежесозданные jobs (initial apply_async
|
||||
ещё в полёте) — их heartbeat ставится только при первом _start_job.
|
||||
|
||||
Idempotent: если зомби нет, ничего не делает. Активный job с свежим heartbeat
|
||||
не матчит WHERE-clause.
|
||||
|
|
@ -661,12 +699,22 @@ def cleanup_zombies() -> dict[str, Any]:
|
|||
AND heartbeat_at
|
||||
< NOW() - CAST(:paused_threshold AS interval)
|
||||
)
|
||||
OR (
|
||||
-- issue #1655: ре-enqueue'нутый job застрял в 'queued'
|
||||
-- (потерянное broker-сообщение). heartbeat_at IS NOT NULL
|
||||
-- отсекает свежесозданные jobs с ещё-в-полёте apply_async.
|
||||
status = 'queued'
|
||||
AND heartbeat_at IS NOT NULL
|
||||
AND heartbeat_at
|
||||
< NOW() - CAST(:queued_threshold AS interval)
|
||||
)
|
||||
RETURNING job_id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"running_threshold": _ZOMBIE_RUNNING_THRESHOLD,
|
||||
"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD,
|
||||
"queued_threshold": _ZOMBIE_QUEUED_THRESHOLD,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
|
|
|
|||
|
|
@ -153,9 +153,12 @@ def import_anton_objective(
|
|||
_finish_run(db, run_id, status="failed", error=f"SQLite не найден: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
# Дополним результат полезной диагностикой
|
||||
# Логируем полезную диагностику, но НЕ возвращаем dict —
|
||||
# re-raise, чтобы Celery-таск ушёл в FAILURE (не SUCCESS) и не
|
||||
# рассинхронился с objective_scrape_runs.status='failed' (#1623).
|
||||
info = get_sqlite_info(sqlite_path_eff)
|
||||
return {"run_id": run_id, "error": "sqlite_not_found", "sqlite_info": info}
|
||||
logger.error("sqlite_not_found diagnostics for run=%s: %s", run_id, info)
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("import_anton_objective failed: %s", e)
|
||||
|
|
|
|||
|
|
@ -70,7 +70,10 @@ def harvest_opportunity_overlays(quarters: list[str] | None = None) -> dict[str,
|
|||
bbox = _geojson_bbox_3857(qfeat.geometry)
|
||||
if bbox is None:
|
||||
continue
|
||||
n_quarters += 1
|
||||
# Staging-счётчик квартала: прибавляем к итогам ТОЛЬКО после успешного
|
||||
# commit (строка ниже). Иначе при сбое commit + rollback откатятся
|
||||
# незакоммиченные UPSERT'ы, а отчёт завысит число persisted фич (#1624).
|
||||
q_features = 0
|
||||
for layer_key, layer_kind in OPPORTUNITY_LAYER_KINDS.items():
|
||||
layer_id = LAYERS.get(layer_key)
|
||||
if layer_id is None:
|
||||
|
|
@ -94,10 +97,13 @@ def harvest_opportunity_overlays(quarters: list[str] | None = None) -> dict[str,
|
|||
feature=feat,
|
||||
fetched_at=fetched_at,
|
||||
):
|
||||
n_features += 1
|
||||
q_features += 1
|
||||
# Durable per-quarter commit: длинный grid-walk не теряет прогресс при
|
||||
# краше/таймауте середины прогона (commit раз-в-конце терял ВСЁ).
|
||||
db.commit()
|
||||
# Commit прошёл → фичи квартала реально persisted, учитываем в отчёте.
|
||||
n_quarters += 1
|
||||
n_features += q_features
|
||||
except Exception as exc:
|
||||
logger.warning("opportunity_harvest: квартал %s failed: %s", quarter, exc)
|
||||
db.rollback() # сбросить незакоммиченный tx квартала перед следующим
|
||||
|
|
|
|||
|
|
@ -401,11 +401,28 @@ def enqueue_cadastre_harvest(self: Any, job_id: int) -> dict[str, Any]:
|
|||
)
|
||||
skipped_fresh = set(rows)
|
||||
if skipped_fresh:
|
||||
# Корректируем targets_total — skipped quarters не входят в прогресс
|
||||
# Корректируем targets_total — skipped quarters не входят в прогресс.
|
||||
# Bug #1654: блок выполняется при КАЖДОМ вызове (resume re-enqueue'ит
|
||||
# тот же enqueue_cadastre_harvest), а уже обработанные кварталы тоже
|
||||
# получают свежий cad_quarter_stats.fetched_at → попадают в
|
||||
# skipped_fresh повторно. Поэтому:
|
||||
# • targets_skipped — идемпотентный SET (= число свежих сейчас),
|
||||
# а не cumulative INCREMENT (иначе раздувается на каждый resume);
|
||||
# • targets_total — :new_total это только ОСТАВШИЕСЯ к обработке
|
||||
# кварталы (len(quarters) − skipped_fresh). При resume уже
|
||||
# обработанные попадают в skipped_fresh, поэтому к остатку
|
||||
# добавляем уже учтённый прогресс (done + failed), иначе total
|
||||
# занижается и _maybe_finish_job помечает job 'done' после первого
|
||||
# доработанного квартала (#1654-followup). GREATEST с самим
|
||||
# прогрессом сохраняет монотонность (total не уменьшается).
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE cadastre_jobs SET targets_total = :new_total, "
|
||||
"targets_skipped = COALESCE(targets_skipped, 0) + :sk "
|
||||
"UPDATE cadastre_jobs SET "
|
||||
"targets_total = GREATEST("
|
||||
":new_total + COALESCE(targets_done, 0) + COALESCE(targets_failed, 0), "
|
||||
"COALESCE(targets_done, 0) + COALESCE(targets_failed, 0)"
|
||||
"), "
|
||||
"targets_skipped = :sk "
|
||||
"WHERE job_id = :id"
|
||||
),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ logger = logging.getLogger(__name__)
|
|||
# Фильтр (:force = false): берём только те, что ещё не обновлялись сегодня
|
||||
# (catalog_scraped_at IS NULL — никогда не скрапились, либо DATE(...) < today).
|
||||
# При :force = true фильтр снимается — грузим все объекты последнего snapshot.
|
||||
#
|
||||
# LIMIT :max_objects: в PostgreSQL `LIMIT NULL` == без лимита, поэтому при
|
||||
# max_objects=None (force "Загрузить все" без явного потолка) грузим ВСЕ строки
|
||||
# последнего snapshot, а не молча режем до _DEFAULT_MAX_OBJECTS.
|
||||
_SELECT_TARGETS_SQL = text(
|
||||
"""
|
||||
SELECT obj_id, snapshot_date
|
||||
|
|
@ -74,10 +78,12 @@ def scrape_kn_catalog_objects(
|
|||
|
||||
Args:
|
||||
region_code: Код региона (ОКАТО prefix). Default 66 = Свердловская обл.
|
||||
max_objects: Максимум объектов за один run. Default 300.
|
||||
force: Если True — игнорирует фильтр "уже сегодня обновлён" и грузит
|
||||
все объекты последнего snapshot (admin "Загрузить все"). По умолчанию
|
||||
False — пропускает то, что уже скраплено сегодня.
|
||||
max_objects: Максимум объектов за один run. Если не задан: при force=True
|
||||
лимита нет (грузим все), при force=False — _DEFAULT_MAX_OBJECTS (300).
|
||||
force: Если True — игнорирует фильтр "уже сегодня обновлён" и (при не
|
||||
заданном max_objects) снимает лимит, грузя ВСЕ объекты последнего
|
||||
snapshot (admin "Загрузить все"). По умолчанию False — пропускает
|
||||
то, что уже скраплено сегодня, и режет batch до 300.
|
||||
|
||||
Returns:
|
||||
dict с ключами: region_code, snapshot_date, obj_ids_count,
|
||||
|
|
@ -95,7 +101,15 @@ def scrape_kn_catalog_objects(
|
|||
"""
|
||||
from app.services.scrapers.domrf_catalog_object import scrape_catalog_objects
|
||||
|
||||
limit = max_objects if max_objects is not None else _DEFAULT_MAX_OBJECTS
|
||||
# Явный max_objects всегда уважается. Без него:
|
||||
# force=True ("Загрузить все") → лимита нет (limit=None → SQL LIMIT NULL = все строки);
|
||||
# force=False (beat / ad-hoc pass) → дефолтный batch _DEFAULT_MAX_OBJECTS.
|
||||
if max_objects is not None:
|
||||
limit: int | None = max_objects
|
||||
elif force:
|
||||
limit = None
|
||||
else:
|
||||
limit = _DEFAULT_MAX_OBJECTS
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
|
|
@ -136,11 +150,11 @@ def scrape_kn_catalog_objects(
|
|||
snapshot_date_val: date = rows[0]["snapshot_date"]
|
||||
|
||||
logger.info(
|
||||
"scrape_kn_catalog_objects: region=%d snapshot_date=%s obj_ids=%d limit=%d force=%s",
|
||||
"scrape_kn_catalog_objects: region=%d snapshot_date=%s obj_ids=%d limit=%s force=%s",
|
||||
region_code,
|
||||
snapshot_date_val,
|
||||
len(obj_ids),
|
||||
limit,
|
||||
"ALL" if limit is None else limit,
|
||||
force,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -269,11 +269,14 @@ class TestPaymentAtScenario:
|
|||
res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0})
|
||||
assert res.payment_at_scenario is not None
|
||||
principal = 120_000.0 * _REF_AREA_M2
|
||||
# rate_path несёт КЛЮЧЕВУЮ ставку сценария; affordability приводит к рыночной
|
||||
# базе (+ _KEY_RATE_MARKET_SPREAD_PP), как и monthly_payment_rub (#1639). Ожидания
|
||||
# выражаем символически — тест переживёт перекалибровку спреда.
|
||||
assert res.payment_at_scenario[6] == pytest.approx(
|
||||
_annuity(principal, 8.0, _ANNUITY_TERM_MONTHS)
|
||||
_annuity(principal, 8.0 + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS)
|
||||
)
|
||||
assert res.payment_at_scenario[12] == pytest.approx(
|
||||
_annuity(principal, 20.0, _ANNUITY_TERM_MONTHS)
|
||||
_annuity(principal, 20.0 + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS)
|
||||
)
|
||||
# Выше ставка → выше платёж на этом горизонте.
|
||||
assert res.payment_at_scenario[12] > res.payment_at_scenario[6]
|
||||
|
|
|
|||
|
|
@ -304,12 +304,15 @@ def test_denorm_dump_empty_features() -> None:
|
|||
db = _make_mock_session()
|
||||
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[])
|
||||
|
||||
assert counts == {"parcels": 0, "buildings": 0, "errors": 0}
|
||||
assert counts == {"parcels": 0, "buildings": 0, "errors": 0, "skipped": 0}
|
||||
db.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_denorm_dump_no_cad_num_counted_as_error() -> None:
|
||||
"""Parcel без cad_num → denorm_parcel_feature returns False → errors += 1."""
|
||||
def test_denorm_dump_no_cad_num_counted_as_skipped() -> None:
|
||||
"""Parcel без cad_num → pre-check в denorm_dump → skipped += 1, не errors.
|
||||
|
||||
denorm_parcel_feature не вызывается вовсе — пропуск штатный, не сбой UPSERT.
|
||||
"""
|
||||
db = _make_mock_session()
|
||||
feature: dict[str, Any] = {
|
||||
"layer": "parcels",
|
||||
|
|
@ -319,4 +322,5 @@ def test_denorm_dump_no_cad_num_counted_as_error() -> None:
|
|||
}
|
||||
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[feature])
|
||||
assert counts["parcels"] == 0
|
||||
assert counts["errors"] == 1
|
||||
assert counts["errors"] == 0
|
||||
assert counts["skipped"] == 1
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue