From 1a658a65a679d5aeb4f6abf229cc32e1f35764d0 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 18 Jun 2026 09:45:54 +0300 Subject: [PATCH 1/7] feat(avito): dedicated newbuilding (novostroyka) citywide sweep --- tradein-mvp/backend/app/services/scheduler.py | 43 +++ .../backend/app/services/scrape_pipeline.py | 113 +++++++- .../backend/app/services/scrapers/avito.py | 83 +++++- .../123_avito_newbuilding_sweep_schedule.sql | 41 +++ .../fixtures/avito_serp_novostroyka.html | 35 +++ .../tests/test_avito_newbuilding_sweep.py | 245 ++++++++++++++++++ 6 files changed, 552 insertions(+), 8 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/123_avito_newbuilding_sweep_schedule.sql create mode 100644 tradein-mvp/backend/tests/fixtures/avito_serp_novostroyka.html create mode 100644 tradein-mvp/backend/tests/test_avito_newbuilding_sweep.py diff --git a/tradein-mvp/backend/app/services/scheduler.py b/tradein-mvp/backend/app/services/scheduler.py index 831623a7..e97f4e5c 100644 --- a/tradein-mvp/backend/app/services/scheduler.py +++ b/tradein-mvp/backend/app/services/scheduler.py @@ -9,6 +9,8 @@ Sources: - avito_city_sweep → run_avito_city_sweep (scrape_pipeline.py) + - avito_newbuilding_sweep → run_avito_newbuilding_sweep (scrape_pipeline.py; dedicated + novostroyka-filtered citywide SERP → save, 100% new-build cards) - yandex_city_sweep → run_yandex_city_sweep (scrape_pipeline.py, #561; shipped DORMANT) - cian_history_backfill → run_cian_backfill (this module, #560) - rosreestr_dkp_import → import_rosreestr_dkp (this module, #563) @@ -85,6 +87,7 @@ from app.core.db import SessionLocal from app.services import scrape_runs as runs_mod from app.services.scrape_pipeline import ( run_avito_city_sweep, + run_avito_newbuilding_sweep, run_cian_city_sweep, run_domclick_city_sweep, run_yandex_city_sweep, @@ -299,6 +302,44 @@ async def trigger_avito_city_sweep_run(db: Session, schedule_row: dict[str, Any] return run_id +async def trigger_avito_newbuilding_sweep_run( + db: Session, schedule_row: dict[str, Any] +) -> int | None: + """Создать новый scrape_runs + launch run_avito_newbuilding_sweep в asyncio.create_task. + + Зеркало trigger_avito_city_sweep_run, но citywide novostroyka-обход (без anchor/ + houses/detail-параметров): dedicated novostroyka-SERP → save. + + Returns run_id (или None если skip — есть running run). + """ + run_id = _claim_run(db, schedule_row) + if run_id is None: + return None + + params = schedule_row.get("default_params") or {} + + # Spawn asyncio task — pass NEW session (avoid sharing with this scheduler tick) + async def _run() -> None: + run_db = SessionLocal() + try: + await run_avito_newbuilding_sweep( + run_db, + run_id=run_id, + pages=int(params.get("pages", 20)), + request_delay_sec=float(params.get("request_delay_sec", 7.0)), + ) + except Exception: + logger.exception("scheduler: run_avito_newbuilding_sweep crashed run_id=%d", run_id) + finally: + run_db.close() + + task = asyncio.create_task(_run()) + # Keep reference to avoid GC before task completes (RUF006) + task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None) + logger.info("scheduler: triggered newbuilding_sweep run_id=%d", run_id) + return run_id + + async def trigger_yandex_city_sweep_run(db: Session, schedule_row: dict[str, Any]) -> int | None: """Создать новый scrape_runs + launch run_yandex_city_sweep в asyncio.create_task. @@ -1350,6 +1391,8 @@ async def scheduler_loop() -> None: source = sch["source"] if source == "avito_city_sweep": await trigger_avito_city_sweep_run(db, sch) + elif source == "avito_newbuilding_sweep": + await trigger_avito_newbuilding_sweep_run(db, sch) elif source == "yandex_city_sweep": await trigger_yandex_city_sweep_run(db, sch) elif source == "cian_history_backfill": diff --git a/tradein-mvp/backend/app/services/scrape_pipeline.py b/tradein-mvp/backend/app/services/scrape_pipeline.py index c8b2531d..29c5f99a 100644 --- a/tradein-mvp/backend/app/services/scrape_pipeline.py +++ b/tradein-mvp/backend/app/services/scrape_pipeline.py @@ -990,6 +990,114 @@ async def run_avito_city_sweep( raise +# ── Avito newbuilding (novostroyka) citywide sweep ──────────────── + + +@dataclass +class NewbuildingSweepCounters: + """Aggregate counters для Avito novostroyka citywide sweep run.""" + + lots_fetched: int = 0 + lots_inserted: int = 0 + lots_updated: int = 0 + errors_count: int = 0 + + def to_dict(self) -> dict[str, int]: + return {f.name: getattr(self, f.name) for f in fields(self)} + + +async def run_avito_newbuilding_sweep( + db: Session, + *, + run_id: int, + pages: int = 20, + request_delay_sec: float = 7.0, +) -> NewbuildingSweepCounters: + """Citywide-обход ЕКБ-выборки только новостроек (novostroyka-filter) → save. + + В отличие от run_avito_city_sweep (anchor-based fetch_around + houses/detail/IMV), + это простой citywide paginated-обход через AvitoScraper.fetch_newbuildings: + dedicated novostroyka-SERP отдаёт 100% new-build карточки (listing_segment= + "novostroyki", newbuilding_id/newbuilding_url заполнены парсером). Сохраняем через + стандартный save_listings, ведём SERP-счётчики через scrape_runs. + + - Single shared AsyncSession/BrowserFetcher на весь sweep (один TLS fingerprint) + - AvitoBlockedError/AvitoRateLimitedError → mark_banned (status='banned') + - is_cancelled-чек до старта SERP-фазы + """ + counters = NewbuildingSweepCounters() + + browser_mode = settings.scraper_fetch_mode == "browser" + async with AsyncExitStack() as stack: + session: AsyncSession | None = None + shared_bf: BrowserFetcher | None = None + if browser_mode: + shared_bf = await stack.enter_async_context(BrowserFetcher(source="avito")) + else: + session = await stack.enter_async_context( + AsyncSession( + impersonate="chrome120", + timeout=25, + headers=_CHROME_HEADERS, + proxies=_avito_proxies(), + ) + ) + try: + if scrape_runs.is_cancelled(db, run_id): + logger.info("nb-sweep run_id=%d: cancelled before SERP phase", run_id) + scrape_runs.mark_done(db, run_id, counters.to_dict()) + return counters + + scraper = AvitoScraper() + if browser_mode: + scraper._browser = shared_bf + else: + scraper._cffi = session + + try: + lots: list[ScrapedLot] = await scraper.fetch_newbuildings( + pages=pages, + delay_override_sec=request_delay_sec, + ) + except (AvitoBlockedError, AvitoRateLimitedError) as e: + logger.error("nb-sweep run_id=%d: SERP BLOCKED — %s", run_id, e) + scrape_runs.mark_banned(db, run_id, str(e), counters.to_dict()) + return counters + + counters.lots_fetched += len(lots) + if lots: + try: + ins, upd = save_listings(db, lots) + counters.lots_inserted += ins + counters.lots_updated += upd + except Exception as save_exc: + logger.exception( + "nb-sweep run_id=%d: save_listings failed: %s", run_id, save_exc + ) + counters.errors_count += 1 + try: + db.rollback() + except Exception: + pass + + scrape_runs.update_heartbeat(db, run_id, counters.to_dict()) + scrape_runs.mark_done(db, run_id, counters.to_dict()) + logger.info( + "nb-sweep run_id=%d done: lots=%d (ins=%d/upd=%d) errors=%d", + run_id, + counters.lots_fetched, + counters.lots_inserted, + counters.lots_updated, + counters.errors_count, + ) + return counters + + except Exception as exc: + logger.exception("nb-sweep run_id=%d: fatal error", run_id) + scrape_runs.mark_failed(db, run_id, str(exc), counters.to_dict()) + raise + + # ── Yandex city sweep ─────────────────────────────────────────── @@ -2266,8 +2374,7 @@ async def run_domclick_city_sweep( return counters logger.info( - "domclick-sweep run_id=%d: citywide SERP city_id=%d rooms=%s pages=%d " - "(watchdog %ds)", + "domclick-sweep run_id=%d: citywide SERP city_id=%d rooms=%s pages=%d (watchdog %ds)", run_id, city_id, _rooms, @@ -2333,11 +2440,13 @@ __all__ = [ "CianFullLoadCounters", "CitySweepCounters", "DomClickCitySweepCounters", + "NewbuildingSweepCounters", "PipelineCounters", "PipelineResult", "YandexCitySweepCounters", "YandexFullLoadCounters", "run_avito_city_sweep", + "run_avito_newbuilding_sweep", "run_avito_pipeline", "run_cian_city_sweep", "run_cian_full_load", diff --git a/tradein-mvp/backend/app/services/scrapers/avito.py b/tradein-mvp/backend/app/services/scrapers/avito.py index d5002826..d2304fb7 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito.py +++ b/tradein-mvp/backend/app/services/scrapers/avito.py @@ -26,6 +26,7 @@ from __future__ import annotations import asyncio import logging import re +from collections.abc import Callable from datetime import date, datetime, timedelta, timezone from typing import Any from urllib.parse import urlencode, urljoin, urlparse, urlunparse @@ -216,6 +217,12 @@ def _build_sort_timestamp_map(html: str) -> dict[str, date]: # маркерам в начале документа (firewall-страница объёмна, не сканируем целиком). _FIREWALL_MARKERS = ("доступ ограничен", "проблема с ip", "firewall-container") +# EKB-фильтр «Новостройка» (slug категории kvartiry/prodam). Кодирует +# ASgB-параметры выборки только новостроек — SERP возвращает 100% new-build +# карточки (с data-marker="item-development-name"). Извлечён из реального +# search-URL: /ekaterinburg/kvartiry/prodam/novostroyka-ASgBAgICAkSSA8YQ5geOUg +NOVOSTROYKA_SLUG = "novostroyka-ASgBAgICAkSSA8YQ5geOUg" + def _is_firewall_page(html: str) -> bool: """True если Avito вернул firewall-страницу IP-блока (на HTTP 200).""" @@ -500,6 +507,16 @@ class AvitoScraper(BaseScraper): params = {"s": 104, "p": page} return f"{self.base_url}/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?{urlencode(params)}" + def _build_newbuilding_url(self, page: int = 1) -> str: + """URL ЕКБ-выборки только новостроек (novostroyka-filter), сортировка по дате. + + Тот же shape параметров, что _build_citywide_url (s=104, p=page), но slug + категории NOVOSTROYKA_SLUG отдаёт исключительно new-build карточки. + """ + params = {"s": 104, "p": page} + path = f"/ekaterinburg/kvartiry/prodam/{NOVOSTROYKA_SLUG}" + return f"{self.base_url}{path}?{urlencode(params)}" + def _build_rooms_url(self, room_slug: str, page: int = 1) -> str: """T6: URL с фильтром по комнатности для всего ЕКБ (no geo). @@ -532,6 +549,30 @@ class AvitoScraper(BaseScraper): Returns: Список ScrapedLot (все страницы, дедуп по source_id). """ + all_lots = await self._paginate_sweep( + pages, + self._build_citywide_url, + label="citywide", + delay_override_sec=delay_override_sec, + ) + logger.info("avito fetch_city_wide pages=%d total_lots=%d", pages, len(all_lots)) + return all_lots + + async def _paginate_sweep( + self, + pages: int, + url_builder: Callable[[int], str], + *, + label: str, + delay_override_sec: float | None = None, + ) -> list[ScrapedLot]: + """Общий paginated-обход ЕКБ (citywide / novostroyka) с break-on-empty. + + url_builder(page) — функция построения URL страницы (citywide или + novostroyka). Сохраняет anti-block pipeline (_fetch_serp_html: firewall, + IP rotation, ретраи), дедуп по source_id, break-on-empty. label — только + для логов. Не меняет наблюдаемое поведение fetch_city_wide. + """ if delay_override_sec is not None: self.request_delay_sec = delay_override_sec @@ -539,21 +580,21 @@ class AvitoScraper(BaseScraper): seen_ids: set[str] = set() for page in range(1, pages + 1): - url = self._build_citywide_url(page) + url = url_builder(page) try: html = await self._fetch_serp_html(url, page) except (AvitoBlockedError, AvitoRateLimitedError): raise except Exception: - logger.exception("avito citywide page=%d fetch failed url=%s", page, url) + logger.exception("avito %s page=%d fetch failed url=%s", label, page, url) break if html is None: - logger.info("avito citywide page=%d: non-200 — end of pagination", page) + logger.info("avito %s page=%d: non-200 — end of pagination", label, page) break lots = self._parse_html(html, source_url_base=url) if not lots: - logger.info("avito citywide page=%d: 0 lots — end of pagination", page) + logger.info("avito %s page=%d: 0 lots — end of pagination", label, page) break new_lots = [lot for lot in lots if lot.source_id not in seen_ids] @@ -562,7 +603,8 @@ class AvitoScraper(BaseScraper): seen_ids.add(lot.source_id) all_lots.extend(new_lots) logger.info( - "avito citywide page=%d: %d lots (%d new, %d dedup'd, total=%d)", + "avito %s page=%d: %d lots (%d new, %d dedup'd, total=%d)", + label, page, len(lots), len(new_lots), @@ -571,8 +613,37 @@ class AvitoScraper(BaseScraper): ) if page < pages: await self.sleep_between_requests() + return all_lots - logger.info("avito fetch_city_wide pages=%d total_lots=%d", pages, len(all_lots)) + async def fetch_newbuildings( + self, + pages: int = 30, + *, + delay_override_sec: float | None = None, + ) -> list[ScrapedLot]: + """Обход ЕКБ-выборки только новостроек (novostroyka-filter), paginated. + + Avito отдаёт dedicated SERP с фильтром «Новостройка» — 100% new-build + карточек (с маркером застройщика → listing_segment="novostroyki", + newbuilding_id/newbuilding_url заполнены). Citywide (без geo/anchor). + Возвращает дедуплицированный список (по source_id), break-on-empty. + Сохраняет весь anti-block pipeline. Не трогает fetch_city_wide/fetch_around. + + Args: + pages: максимальное число страниц (default 30). + delay_override_sec: если задан — переопределяет request_delay_sec для + этого вызова. + + Returns: + Список ScrapedLot новостроек (все страницы, дедуп по source_id). + """ + all_lots = await self._paginate_sweep( + pages, + self._build_newbuilding_url, + label="newbuilding", + delay_override_sec=delay_override_sec, + ) + logger.info("avito fetch_newbuildings pages=%d total_lots=%d", pages, len(all_lots)) return all_lots async def fetch_by_rooms( diff --git a/tradein-mvp/backend/data/sql/123_avito_newbuilding_sweep_schedule.sql b/tradein-mvp/backend/data/sql/123_avito_newbuilding_sweep_schedule.sql new file mode 100644 index 00000000..de012f0c --- /dev/null +++ b/tradein-mvp/backend/data/sql/123_avito_newbuilding_sweep_schedule.sql @@ -0,0 +1,41 @@ +-- 123_avito_newbuilding_sweep_schedule.sql +-- Seed row для Avito newbuilding (novostroyka) citywide sweep. +-- +-- Avito отдаёт dedicated novostroyka-filtered SERP (slug +-- novostroyka-ASgBAgICAkSSA8YQ5geOUg) — 100% new-build карточки с маркером +-- застройщика (listing_segment='novostroyki', newbuilding_id заполнен парсером). +-- Текущий general sweep ловит новостройки лишь инцидентно (~20 строк); этот +-- dedicated обход даёт сотни. Citywide (без anchor/geo), save через стандартный путь. +-- +-- default_params: +-- pages — макс. число страниц SERP за прогон. +-- request_delay_sec — пауза между страницами (anti-block). +-- +-- next_run_at = завтра 02:00 UTC — чтобы НЕ выстрелить мгновенно на деплое (enabled +-- row с NULL next_run_at трактуется due немедленно); первый прогон в окне 02:00-05:00. +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)). +-- Idempotent: ON CONFLICT (source) DO NOTHING — безопасно запускать повторно. + +BEGIN; + +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +( + 'avito_newbuilding_sweep', + true, + 2, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages": 20, "request_delay_sec": 8}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +COMMIT; diff --git a/tradein-mvp/backend/tests/fixtures/avito_serp_novostroyka.html b/tradein-mvp/backend/tests/fixtures/avito_serp_novostroyka.html new file mode 100644 index 00000000..3d79c627 --- /dev/null +++ b/tradein-mvp/backend/tests/fixtures/avito_serp_novostroyka.html @@ -0,0 +1,35 @@ + + +Avito novostroyka SERP fixture + +
+
+ 1-к. квартира, 40 м², 5/10 эт. + +

ул. Меридиан, 1

+
ЖК «Меридиан»
+ ЖК «Меридиан» +

2 часа назад

+
+
+ 2-к. квартира, 62 м², 8/12 эт. + +

ул. Северная, 7

+
ЖК «Северный квартал»
+ ЖК «Северный квартал» +

3 часа назад

+
+
+ + diff --git a/tradein-mvp/backend/tests/test_avito_newbuilding_sweep.py b/tradein-mvp/backend/tests/test_avito_newbuilding_sweep.py new file mode 100644 index 00000000..f90208c9 --- /dev/null +++ b/tradein-mvp/backend/tests/test_avito_newbuilding_sweep.py @@ -0,0 +1,245 @@ +"""Avito newbuilding (novostroyka) citywide sweep. + +Тесты (без сети, без реального DB): +1. _build_newbuilding_url — novostroyka-slug в пути, s=104, page param, без geo. +2. _parse_html на novostroyka-SERP fixture → listing_segment='novostroyki' + + newbuilding_id заполнен (DOM-маркер застройщика + zhk-якорь). +3. fetch_newbuildings break-on-empty + dedup (mirror fetch_city_wide). +4. NewbuildingSweepCounters — defaults + to_dict. +5. run_avito_newbuilding_sweep wiring — мокаем fetch_newbuildings + save_listings, + assert save вызван и SERP-счётчики записаны (mark_done). +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import scrape_pipeline +from app.services.scrapers.avito import NOVOSTROYKA_SLUG, AvitoScraper +from app.services.scrapers.base import ScrapedLot + +FIXTURE = Path(__file__).parent / "fixtures" / "avito_serp_novostroyka.html" + + +def _make_lot(source_id: str) -> ScrapedLot: + return ScrapedLot( + source="avito", + source_url=f"https://www.avito.ru/ekaterinburg/kvartiry/{source_id}", + source_id=source_id, + price_rub=6_000_000, + listing_segment="novostroyki", + ) + + +# ── 1. _build_newbuilding_url ─────────────────────────────────────────────── + + +def test_build_newbuilding_url_contains_slug_and_sort() -> None: + s = AvitoScraper() + url = s._build_newbuilding_url(page=1) + assert NOVOSTROYKA_SLUG in url + assert "novostroyka-" in url + assert "geoCoords" not in url + assert "radius" not in url + assert "s=104" in url + assert "p=1" in url + assert "ekaterinburg/kvartiry/prodam/" in url + + +def test_build_newbuilding_url_page_param() -> None: + s = AvitoScraper() + url = s._build_newbuilding_url(page=2) + assert "novostroyka-" in url + assert "p=2" in url + + +def test_build_newbuilding_url_differs_from_citywide() -> None: + s = AvitoScraper() + assert s._build_newbuilding_url(page=1) != s._build_citywide_url(page=1) + + +# ── 2. _parse_html classifies novostroyka cards ───────────────────────────── + + +def test_parse_novostroyka_fixture_segments_and_newbuilding_id() -> None: + html = FIXTURE.read_text(encoding="utf-8") + s = AvitoScraper() + lots = s._parse_html(html, source_url_base=s._build_newbuilding_url(page=1)) + + assert len(lots) == 2 + for lot in lots: + assert lot.listing_segment == "novostroyki" + assert lot.newbuilding_id is not None + assert lot.newbuilding_url is not None + + ids = {lot.newbuilding_id for lot in lots} + assert "meridian-ekaterinburg" in ids + assert "severnyy-kvartal-ekaterinburg" in ids + + +# ── 3. fetch_newbuildings break-on-empty + dedup ──────────────────────────── + + +@pytest.mark.asyncio +async def test_fetch_newbuildings_break_on_empty() -> None: + s = AvitoScraper() + pages_data: list[list[ScrapedLot]] = [[_make_lot("A"), _make_lot("B")], []] + call_n = 0 + + async def mock_html(url: str, page: int) -> str: + assert "novostroyka-" in url + return f"{page}" + + def mock_parse(html: str, source_url_base: str) -> list[ScrapedLot]: + nonlocal call_n + idx = call_n + call_n += 1 + return pages_data[idx] if idx < len(pages_data) else [] + + with patch.object(s, "_fetch_serp_html", side_effect=mock_html): + with patch.object(s, "_parse_html", side_effect=mock_parse): + with patch.object(s, "sleep_between_requests", new=AsyncMock()): + result = await s.fetch_newbuildings(pages=10, delay_override_sec=0) + + assert len(result) == 2 + assert call_n == 2 # page1 + page2(empty) → stop + + +@pytest.mark.asyncio +async def test_fetch_newbuildings_dedup_by_source_id() -> None: + s = AvitoScraper() + dup = _make_lot("SAME") + pages_data: list[list[ScrapedLot]] = [[dup], [dup], []] + call_n = 0 + + async def mock_html(url: str, page: int) -> str: + return f"{page}" + + def mock_parse(html: str, source_url_base: str) -> list[ScrapedLot]: + nonlocal call_n + idx = call_n + call_n += 1 + return pages_data[idx] if idx < len(pages_data) else [] + + with patch.object(s, "_fetch_serp_html", side_effect=mock_html): + with patch.object(s, "_parse_html", side_effect=mock_parse): + with patch.object(s, "sleep_between_requests", new=AsyncMock()): + result = await s.fetch_newbuildings(pages=10, delay_override_sec=0) + + assert len(result) == 1 + assert result[0].source_id == "SAME" + + +def test_fetch_city_wide_url_unchanged() -> None: + """fetch_city_wide остаётся citywide-URL — рефактор не сменил его поведение.""" + s = AvitoScraper() + assert "novostroyka-" not in s._build_citywide_url(page=1) + assert "prodam-ASgBAgICAUSSA8YQ" in s._build_citywide_url(page=1) + + +# ── 4. NewbuildingSweepCounters ───────────────────────────────────────────── + + +def test_newbuilding_counters_defaults_and_to_dict() -> None: + from app.services.scrape_pipeline import NewbuildingSweepCounters + + c = NewbuildingSweepCounters() + assert c.lots_fetched == 0 + assert c.lots_inserted == 0 + assert c.lots_updated == 0 + assert c.errors_count == 0 + d = c.to_dict() + assert set(d.keys()) == {"lots_fetched", "lots_inserted", "lots_updated", "errors_count"} + + +# ── 5. run_avito_newbuilding_sweep wiring ─────────────────────────────────── + + +class _FakeRuns: + def __init__(self) -> None: + self.heartbeats: list[dict[str, int]] = [] + self.done: dict[str, int] | None = None + self.failed: tuple[str, dict[str, int]] | None = None + self.banned: tuple[str, dict[str, int]] | None = None + + def is_cancelled(self, _db: Any, _run_id: int) -> bool: + return False + + def update_heartbeat(self, _db: Any, _run_id: int, counters: dict[str, int]) -> None: + self.heartbeats.append(dict(counters)) + + def mark_done(self, _db: Any, _run_id: int, counters: dict[str, int]) -> None: + self.done = dict(counters) + + def mark_failed(self, _db: Any, _run_id: int, error: str, counters: dict[str, int]) -> None: + self.failed = (error, dict(counters)) + + def mark_banned(self, _db: Any, _run_id: int, error: str, counters: dict[str, int]) -> None: + self.banned = (error, dict(counters)) + + +def _install_runs(monkeypatch: pytest.MonkeyPatch, fake: _FakeRuns) -> None: + monkeypatch.setattr(scrape_pipeline.scrape_runs, "is_cancelled", fake.is_cancelled) + monkeypatch.setattr(scrape_pipeline.scrape_runs, "update_heartbeat", fake.update_heartbeat) + monkeypatch.setattr(scrape_pipeline.scrape_runs, "mark_done", fake.mark_done) + monkeypatch.setattr(scrape_pipeline.scrape_runs, "mark_failed", fake.mark_failed) + monkeypatch.setattr(scrape_pipeline.scrape_runs, "mark_banned", fake.mark_banned) + + +@pytest.mark.asyncio +async def test_run_avito_newbuilding_sweep_saves_and_counts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_runs = _FakeRuns() + _install_runs(monkeypatch, fake_runs) + + fake_lots = [_make_lot("N1"), _make_lot("N2"), _make_lot("N3")] + + async def fake_fetch_newbuildings( + self: AvitoScraper, + pages: int = 30, + *, + delay_override_sec: float | None = None, + ) -> list[ScrapedLot]: + return list(fake_lots) + + monkeypatch.setattr(AvitoScraper, "fetch_newbuildings", fake_fetch_newbuildings) + + save_calls: list[int] = [] + + def fake_save_listings(_db: Any, lots: Any, **_kw: Any) -> tuple[int, int]: + save_calls.append(len(lots)) + return (len(lots), 0) + + monkeypatch.setattr(scrape_pipeline, "save_listings", fake_save_listings) + # Force cffi-mode (no browser) so we don't spin up BrowserFetcher. + monkeypatch.setattr(scrape_pipeline.settings, "scraper_fetch_mode", "cffi") + + # Stub shared AsyncSession (used by the sweep in cffi-mode) — no real session. + fake_session = AsyncMock() + fake_session.__aenter__ = AsyncMock(return_value=fake_session) + fake_session.__aexit__ = AsyncMock(return_value=None) + monkeypatch.setattr(scrape_pipeline, "AsyncSession", lambda **_kw: fake_session) + + counters = await scrape_pipeline.run_avito_newbuilding_sweep( + MagicMock(), + run_id=42, + pages=2, + request_delay_sec=0.0, + ) + + assert save_calls == [3] + assert counters.lots_fetched == 3 + assert counters.lots_inserted == 3 + assert counters.lots_updated == 0 + assert fake_runs.done is not None + assert fake_runs.done["lots_fetched"] == 3 + assert fake_runs.failed is None + assert fake_runs.banned is None From b1c0ea1268cf60ee8fa3a8d237dc4a9d9d40ce30 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Thu, 18 Jun 2026 11:47:21 +0500 Subject: [PATCH 2/7] =?UTF-8?q?fix(site-finder):=205=20=D0=B4=D0=B5=D1=84?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20=D0=B8=D0=B7=20=D0=B1=D0=BE?= =?UTF-8?q?=D0=B5=D0=B2=D0=BE=D0=B3=D0=BE=20=D1=84=D0=B8=D0=B4=D0=B1=D0=B5?= =?UTF-8?q?=D0=BA=D0=B0=20analyze=20(#1736=20#1737=20#1738=20#1739=20#1740?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #1736 MiniMap: проброс useConnectionPoints → точки подключения на карте analyze (были только в /legacy) - #1737 confidence: пронесено имя сервиса → RU-ярлык (Рынок/Будущее предложение/…) вместо «Компонент вкладывающий сервис» - #1738 pipeline: self-exclusion субъекта (ST_DWithin 80м) — проект не считает сам себя будущим конкурентом - #1739 PDF: snapshot_pdf обёрнут в try/except+logger.exception (причина 500 видна) + format=pdf в forecast export + font_url fallback - #1740 gate↔recommendation: при can_build_mkd=False — gate_caveat на обоих рекомендаторах (противоречие явное, не молчит) Verify: py_compile 5/5, tsc 0, ruff clean, pytest confidence/forecast 95 passed. Closes #1736 Closes #1737 Closes #1738 Closes #1739 Closes #1740 --- backend/app/api/v1/parcels.py | 234 ++++++++++++------ .../app/services/exporters/snapshot_pdf.py | 11 +- .../services/forecasting/confidence_engine.py | 64 ++++- .../services/forecasting/report_assembler.py | 20 +- backend/app/templates/parcel_snapshot.html | 2 + backend/tests/api/v1/test_parcels_forecast.py | 83 ++++++- .../analysis/ForecastConfidenceBlock.tsx | 14 +- .../site-finder/analysis/MiniMap.tsx | 14 +- frontend/src/types/forecast.ts | 7 + 9 files changed, 349 insertions(+), 100 deletions(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index a12e1946..faf49018 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -49,6 +49,7 @@ from app.services.exporters.report_md import ( render_report_markdown, render_report_telegram_summary, ) +from app.services.exporters.report_pdf import export_report_pdf from app.services.exporters.report_pptx import render_report_pptx from app.services.exporters.snapshot_pdf import generate_snapshot_pdf from app.services.site_finder.best_layouts import get_best_layouts @@ -244,6 +245,12 @@ PIPELINE_HORIZON_MONTHS = 24 PIPELINE_SEVERITY_MEDIUM_THRESHOLD = 500 # flats_total < это → low PIPELINE_SEVERITY_HIGH_THRESHOLD = 3000 # flats_total >= это → high PIPELINE_TOP_OBJECTS_LIMIT = 10 +# #1738: самоисключение СУБЪЕКТА анализа из pipeline. Если анализируемый участок +# уже застраивается (его ЖК есть в domrf_kn_objects), он не должен считаться +# собственным «конкурентом». Отсекаем объекты, чья точка попадает в геометрию +# участка или в ~80м от неё (домрф-координата ставится на корпус/въезд, может +# не совпадать с центроидом ЗУ → буфер ловит «свой» объект). +PIPELINE_SELF_EXCLUDE_M = 80 def _coord_round(value: Any) -> float | None: @@ -1163,11 +1170,35 @@ def get_parcel_forecast( return {"status": "pending"} if run is not None: + report = run.result + # #1740: gate↔recommendation — если последний analyze-ран дал gate-вердикт + # «Нельзя строить МКД», помечаем forecast-рекомендатор (product_tz) caveat-флагом. + # Рекомендацию НЕ удаляем (строгость = продуктовое решение), только делаем + # противоречие явным. Best-effort: сбой чтения analyze-рана не валит выдачу форсайта. + try: + analyze_run = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION) + gate = (analyze_run.result or {}).get("gate_verdict") if analyze_run else None + if ( + isinstance(gate, dict) + and gate.get("can_build_mkd") is False + and isinstance(report, dict) + and isinstance(report.get("product_tz"), dict) + ): + report["product_tz"]["gate_caveat"] = ( + "Строительство МКД под вопросом (gate: Нельзя) — " + "рекомендация носит условный характер" + ) + except Exception: + logger.warning( + "gate_caveat enrich failed for forecast cad=%s — report unaffected", + cad_num, + exc_info=True, + ) return { "status": "ready", "run_id": run.id, "created_at": run.created_at, - "report": run.result, + "report": report, } # Форсайт ещё не посчитан (таска в работе или /analyze не вызывали) — клиент поллит. @@ -1180,14 +1211,14 @@ def export_parcel_forecast( cad_num: str, db: Annotated[Session, Depends(get_db)], format: Annotated[ - Literal["md", "json", "tg", "docx", "pptx"], + Literal["md", "json", "tg", "docx", "pptx", "pdf"], Query( description="Формат выгрузки: md (Markdown) | json (сырой отчёт) | " - "tg (сводка) | docx (Word-документ) | pptx (презентация)" + "tg (сводка) | docx (Word-документ) | pptx (презентация) | pdf (PDF-отчёт)" ), ] = "md", ) -> Response: - """Экспорт §22-форсайта — Markdown / JSON / DOCX / PPTX (файл) или TG-сводка (#959). + """Экспорт §22-форсайта — Markdown / JSON / DOCX / PPTX / PDF (файл) или TG-сводка (#959). Читает ПОСЛЕДНИЙ §22-ран schema_version "1.0" (тот же блоб, что отдаёт GET /{cad}/forecast inline) и отдаёт его в нужной форме. @@ -1197,6 +1228,8 @@ def export_parcel_forecast( зеркало содержания md/pdf; python-docx). • format=pptx → `render_report_pptx(run.result)` — attachment (.pptx, презентация, сжатое зеркало содержания docx; python-pptx). + • format=pdf → `export_report_pdf(run.result)` — attachment (.pdf, §13-отчёт, + зеркало содержания md/docx; WeasyPrint). • format=tg → `render_report_telegram_summary(run.result)` — КРАТКАЯ plain-text сводка для копипаста в Telegram, INLINE (без Content-Disposition: это сниппет читать/копировать и отправить, а не файл-download). @@ -1208,10 +1241,10 @@ def export_parcel_forecast( Args: cad_num: кадастровый номер участка (в имени файла `:` → `_`). - format: "md" (default) | "json" | "tg" | "docx" | "pptx". + format: "md" (default) | "json" | "tg" | "docx" | "pptx" | "pdf". Returns: - Response — для md/json/docx/pptx attachment с Content-Disposition (имя + Response — для md/json/docx/pptx/pdf attachment с Content-Disposition (имя `gendesign_forecast__.`); для tg — inline text/plain сводка. """ run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION) @@ -1249,6 +1282,15 @@ def export_parcel_forecast( headers={"Content-Disposition": f'attachment; filename="{base_name}.pptx"'}, ) + # pdf — байты (WeasyPrint), отдельной веткой (зеркало pptx early-return): §13-отчёт + # из export_report_pdf (#1739). md/json string-ветки остаются байт-в-байт прежними. + if format == "pdf": + return Response( + content=export_report_pdf(run.result), + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{base_name}.pdf"'}, + ) + if format == "json": payload = json.dumps(run.result, ensure_ascii=False, default=str) media_type = "application/json" @@ -1705,6 +1747,13 @@ def analyze_parcel( ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, :radius_m ) + -- #1738: исключаем СУБЪЕКТ анализа — объект на самом участке + -- (его точка внутри/в ~80м геометрии ЗУ) не считаем конкурентом. + AND NOT ST_DWithin( + ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, + ST_GeomFromText(:wkt, 4326)::geography, + :self_exclude_m + ) AND ready_dt >= CURRENT_DATE AND ready_dt < CURRENT_DATE + cast(:horizon_months || ' months' AS interval) ORDER BY ready_dt ASC @@ -1712,6 +1761,7 @@ def analyze_parcel( { "wkt": geom_wkt, "radius_m": PIPELINE_RADIUS_M, + "self_exclude_m": PIPELINE_SELF_EXCLUDE_M, "horizon_months": str(PIPELINE_HORIZON_MONTHS), }, ) @@ -2731,6 +2781,26 @@ def analyze_parcel( e, ) + # #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner. + # Вычисляем ДО сборки result_payload, чтобы при can_build_mkd is False (#1740) + # пометить рекомендации флагом-кейватом (противоречие gate↔рекомендация явное). + gate_verdict = compute_gate_verdict( + nspd_zoning=nspd_dump_data["nspd_zoning"], + nspd_zouit_overlaps=nspd_dump_data["nspd_zouit_overlaps"], + nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"], + nspd_dump=nspd_dump_data["nspd_dump"], + ) + + # #1740: gate говорит «Нельзя строить МКД», но рекомендатор всё равно выдаёт + # квартирографию — не удаляем рекомендацию (строгость = продуктовое решение), + # а делаем противоречие явным через caveat-флаг. success_recommendation — + # analyze-рекомендатор (топ-квартирография по district). + if gate_verdict.get("can_build_mkd") is False and success_recommendation is not None: + success_recommendation["gate_caveat"] = ( + "Строительство МКД под вопросом (gate: Нельзя) — " + "рекомендация носит условный характер" + ) + result_payload: dict[str, Any] = { "cad_num": cad_num, "source": source, @@ -2827,12 +2897,8 @@ def analyze_parcel( "nspd_red_lines": [RedLine(**rl) for rl in nspd_dump_data.get("nspd_red_lines", [])], "nspd_dump": nspd_dump_data["nspd_dump"], # #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner - "gate_verdict": compute_gate_verdict( - nspd_zoning=nspd_dump_data["nspd_zoning"], - nspd_zouit_overlaps=nspd_dump_data["nspd_zouit_overlaps"], - nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"], - nspd_dump=nspd_dump_data["nspd_dump"], - ), + # (вычислен выше, до сборки payload — нужен для gate_caveat #1740). + "gate_verdict": gate_verdict, # #114/#201: кастомные веса POI — source + applied dict для прозрачности. "weights_profile": { "source": _weights_source, @@ -3301,52 +3367,70 @@ def parcel_snapshot_pdf( Не является официальной выпиской ЕГРН — только аналитические данные НСПД. Генерация <2 сек. Открывается в Adobe Reader / Chrome. """ - # 1) Получить метаданные участка из cad_parcels - parcel_row = ( - db.execute( - text(""" - SELECT readable_address AS address, - land_record_area AS area_m2, - land_record_category_type AS land_category, - permitted_use_established_by_document AS vri, - cost_value AS cadastral_cost, - updated_at AS last_update - FROM cad_parcels - WHERE cad_num = CAST(:c AS text) - LIMIT 1 - """), - {"c": cad_num}, - ) - .mappings() - .first() - ) - - if not parcel_row: - raise HTTPException( - status_code=404, - detail=f"Участок {cad_num} не найден в БД. Используйте POST /analyze для загрузки.", + # 1-6) Сбор данных участка из БД (метаданные + геометрия + POI + конкуренты + + # district + last_update). Обёрнуто в try/except (#1739): иначе сырая DB/PostGIS- + # ошибка всплывает 500-кой без объяснения причины. logger.exception сохраняет + # traceback; HTTPException(404) при отсутствии участка и (422) при отсутствии + # геометрии — re-raise НЕ оборачиваем (это валидные клиентские ответы, а не 500). + try: + # 1) Получить метаданные участка из cad_parcels + parcel_row = ( + db.execute( + text(""" + SELECT readable_address AS address, + land_record_area AS area_m2, + land_record_category_type AS land_category, + permitted_use_established_by_document AS vri, + cost_value AS cadastral_cost, + updated_at AS last_update + FROM cad_parcels + WHERE cad_num = CAST(:c AS text) + LIMIT 1 + """), + {"c": cad_num}, + ) + .mappings() + .first() ) - # 2) Получить геометрию (WKT) для POI / competitor queries - geom_row = ( - db.execute( - text(""" - SELECT ST_AsText(COALESCE( - (SELECT geom FROM cad_parcels_geom WHERE cad_num = CAST(:c AS text) LIMIT 1), - (SELECT geom FROM cad_parcels WHERE cad_num = CAST(:c AS text) LIMIT 1) - )) AS wkt - """), - {"c": cad_num}, - ) - .mappings() - .first() - ) - geom_wkt: str | None = geom_row["wkt"] if geom_row else None + if not parcel_row: + raise HTTPException( + status_code=404, + detail=( + f"Участок {cad_num} не найден в БД. " + "Используйте POST /analyze для загрузки." + ), + ) - # 3) POI в радиусе 1 км (только если есть геометрия) - poi_rows: list[dict[str, Any]] = [] - if geom_wkt: - poi_rows = [ + # 2) Получить геометрию (WKT) для POI / competitor queries + geom_row = ( + db.execute( + text(""" + SELECT ST_AsText(COALESCE( + (SELECT geom FROM cad_parcels_geom WHERE cad_num = CAST(:c AS text) LIMIT 1), + (SELECT geom FROM cad_parcels WHERE cad_num = CAST(:c AS text) LIMIT 1) + )) AS wkt + """), + {"c": cad_num}, + ) + .mappings() + .first() + ) + geom_wkt: str | None = geom_row["wkt"] if geom_row else None + + # #1739: без геометрии участка PDF-снимок бессмыслен (нет POI/конкурентов/ + # района). 422 — клиентский кейс «данные неполны», не серверная 500. + if not geom_wkt: + raise HTTPException( + status_code=422, + detail=( + f"Геометрия участка {cad_num} отсутствует в БД — " + "PDF-снимок недоступен. Запустите POST /analyze для дозагрузки." + ), + ) + + # 3) POI в радиусе 1 км + poi_rows: list[dict[str, Any]] = [ dict(r) for r in db.execute( text(""" @@ -3371,10 +3455,8 @@ def parcel_snapshot_pdf( .all() ] - # 4) Конкуренты в радиусе 3 км (только если есть геометрия) - competitor_rows: list[dict[str, Any]] = [] - if geom_wkt: - competitor_rows = [ + # 4) Конкуренты в радиусе 3 км + competitor_rows: list[dict[str, Any]] = [ dict(r) for r in db.execute( text(""" @@ -3408,9 +3490,8 @@ def parcel_snapshot_pdf( .all() ] - # 5) Получить district (через пересечение с ekb_districts если есть геом) - district: str | None = None - if geom_wkt: + # 5) Получить district (через пересечение с ekb_districts) + district: str | None = None district_row = ( db.execute( text(""" @@ -3427,16 +3508,25 @@ def parcel_snapshot_pdf( if district_row: district = district_row["district_name"] - # 6) Форматировать last_update - raw_update = parcel_row["last_update"] - last_update_str: str | None = None - if raw_update is not None: - try: - last_update_str = raw_update.strftime("%d.%m.%Y") - except AttributeError: - last_update_str = str(raw_update)[:10] + # 6) Форматировать last_update + raw_update = parcel_row["last_update"] + last_update_str: str | None = None + if raw_update is not None: + try: + last_update_str = raw_update.strftime("%d.%m.%Y") + except AttributeError: + last_update_str = str(raw_update)[:10] + except HTTPException: + # 404/422 — валидные клиентские ответы, пропускаем без обёртки в 500. + raise + except Exception as exc: + logger.exception("snapshot data fetch failed for %s", cad_num) + raise HTTPException( + status_code=500, detail="Ошибка сбора данных для PDF-снимка" + ) from exc - # 7) Сгенерировать PDF + # 7) Сгенерировать PDF (WeasyPrint-рендер — отдельная зона отказа: шрифты, + # шаблон, движок). logger.exception сохраняет traceback причины 500 (#1739). try: pdf_bytes = generate_snapshot_pdf( cad_num=cad_num, @@ -3456,7 +3546,7 @@ def parcel_snapshot_pdf( competitors_limit=5, ) except Exception as exc: - logger.error("snapshot PDF generation failed for %s: %s", cad_num, exc) + logger.exception("snapshot PDF render failed for %s", cad_num) raise HTTPException(status_code=500, detail="Ошибка генерации PDF") from exc cad_safe = cad_num.replace(":", "-") diff --git a/backend/app/services/exporters/snapshot_pdf.py b/backend/app/services/exporters/snapshot_pdf.py index 756e25a6..894e4e6c 100644 --- a/backend/app/services/exporters/snapshot_pdf.py +++ b/backend/app/services/exporters/snapshot_pdf.py @@ -13,8 +13,11 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape logger = logging.getLogger(__name__) -# Путь к директории шаблонов (относительно этого файла — 2 уровня вверх, затем templates) -_TEMPLATE_DIR = pathlib.Path(__file__).parent.parent / "templates" +# Путь к директории шаблонов: app/templates/parcel_snapshot.html (#1739). +# Файл лежит в app/services/exporters/, поэтому до app/ — 3 уровня вверх +# (exporters → services → app), затем templates. parent.parent давал +# несуществующий app/services/templates → TemplateNotFound → 500 на snapshot. +_TEMPLATE_DIR = pathlib.Path(__file__).parent.parent.parent / "templates" # Системные пути DejaVu Sans (Ubuntu/Debian Docker-образ + Alpine резерв) _DEJAVU_CANDIDATES: list[str] = [ @@ -61,6 +64,10 @@ def _find_font_url() -> str: WeasyPrint умеет сам находить системные шрифты через fonttools/fontconfig, поэтому пустая строка допустима — шрифт тогда подбирается CSS generic. + + #1739: при пустой строке шаблон НЕ эмитит `@font-face { src: url('') }` + (обёрнут в `{% if font_url %}`). Иначе WeasyPrint пытался резолвить пустой + URL → 'failed to load font' ошибка вместо тихого fallback на Arial/sans-serif. """ for path in _DEJAVU_CANDIDATES: if pathlib.Path(path).exists(): diff --git a/backend/app/services/forecasting/confidence_engine.py b/backend/app/services/forecasting/confidence_engine.py index 6ae2a02e..ceed14a6 100644 --- a/backend/app/services/forecasting/confidence_engine.py +++ b/backend/app/services/forecasting/confidence_engine.py @@ -42,11 +42,16 @@ JSON-safe и ложится в слот ReportConfidence (#987): {level, rationa from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass, field from typing import Any, Literal Confidence = Literal["high", "medium", "low"] +# Вход component_confidences может быть плоским уровнем (legacy) ИЛИ парой +# (имя-сервиса, уровень) — чтобы пронести ИМЯ сервиса сквозь стек (#1737). +ComponentConfidence = Confidence | tuple[str, Confidence] + # ── Порядок уверенности для weakest-link MIN (хуже = ниже). Зеркало vocab # future_supply._CONFIDENCE_RANK / product_scoring._CONFIDENCE_RANK. ─────────────── _CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2} @@ -61,6 +66,18 @@ _F_CONFOUNDED_WINDOW: str = "confounded_window" _F_ADVISORY_CAP: str = "advisory_cap" _F_COMPONENT: str = "component" # один вкладывающий per-service confidence +# ── RU-имена вкладывающих под-сервисов (#1737). Машинное имя сервиса (как его кормит +# сборщик #988) → человекочитаемое RU-имя для отчёта/UI. Неизвестный/пустой ключ → +# дефолт «Компонент» (graceful — не теряем фактор, просто без точечного имени). ────── +_SERVICE_RU: dict[str, str] = { + "market_metrics": "Рынок", + "future_supply": "Будущее предложение", + "product_scores": "Скоринг продукта", + "special_indices": "Спец-индексы", + "forecasts": "Прогноз спрос/предложение", +} +_SERVICE_RU_DEFAULT: str = "Компонент" + # ── Пороги счётчиков (align с per-service gate'ами, прочитанными в #949/#951) ────── # deal_count: число сделок (продаж) за окно. high — длинная плотная выборка, @@ -97,20 +114,29 @@ class ConfidenceFactor: (число сделок / ЖК / покрытие / месяцы / bool шок-окна — или None); `level` — его собственный вклад high/medium/low; `note` — короткая RU-фраза с числом («7 сделок за 6 мес — мало»), из которой собирается структурная причина §15. + `label` — человекочитаемое RU-имя фактора (#1737: для component-факторов это ИМЯ + под-сервиса — «Рынок» / «Будущее предложение» …, чтобы UI не показывал сырой + «Компонент N»); пусто для счётчиков (у них своя RU-метка на стороне UI). """ name: str value: Any # число/доля/флаг, обосновавшие level (или None) level: Confidence note: str # RU, с реальным числом + label: str = "" # RU-имя фактора (для component — имя сервиса, #1737); опционально def as_dict(self) -> dict[str, Any]: - return { + # `label` кладём ТОЛЬКО когда задан — схема обратно-совместима (старые + # потребители без поля `label` продолжают читать name/value/level/note). + out: dict[str, Any] = { "name": self.name, "value": self.value, "level": self.level, "note": self.note, } + if self.label: + out["label"] = self.label + return out @dataclass(frozen=True) @@ -145,7 +171,12 @@ class ReportConfidenceResult: while f"{key}_{i}" in factors: i += 1 key = f"{key}_{i}" - factors[key] = {"value": f.value, "level": f.level, "note": f.note} + entry: dict[str, Any] = {"value": f.value, "level": f.level, "note": f.note} + # RU-имя фактора (для component — имя сервиса, #1737) кладём ТОЛЬКО когда + # задано — плоский dict остаётся обратно-совместимым по форме. + if f.label: + entry["label"] = f.label + factors[key] = entry factors["advisory_capped"] = self.advisory_capped return { "level": self.level, @@ -256,17 +287,23 @@ def _confounded_factor(confounded: bool) -> ConfidenceFactor: ) -def _component_factor(level: Confidence) -> ConfidenceFactor: +def _component_factor(level: Confidence, service: str | None = None) -> ConfidenceFactor: """Один вкладывающий per-service confidence → ConfidenceFactor. PURE. Per-service уже свернул свои тонкие сигналы в high/medium/low; берём как факт, - нота поясняет вклад. value=None (число — внутри самого под-сервиса). + нота поясняет вклад. value=None (число — внутри самого под-сервиса). `service` — + машинное имя под-сервиса (#1737): из него берём человекочитаемое RU-имя + («Рынок» / «Будущее предложение» / «Скоринг продукта» / «Спец-индексы»…) в + `label` и НАЗЫВАЕМ сервис в `note` (вместо безличного «вкладывающий сервис: high»). + Неизвестное/None имя → дефолт «Компонент» (graceful — фактор не теряется). """ + ru_name = _SERVICE_RU.get(service or "", _SERVICE_RU_DEFAULT) return ConfidenceFactor( name=_F_COMPONENT, value=None, level=level, - note=f"вкладывающий сервис: {level}", + note=f"{ru_name}: {level}", + label=ru_name, ) @@ -370,7 +407,7 @@ def _join_notes(notes: list[str], *, limit: int = 3) -> str: def compute_report_confidence( *, - component_confidences: list[Confidence] | None = None, + component_confidences: Sequence[ComponentConfidence] | None = None, deal_count: int | None = None, analog_count: int | None = None, domrf_coverage: float | None = None, @@ -398,7 +435,9 @@ def compute_report_confidence( Args: component_confidences: per-service confidence (#950/#952/#985/#986…), None/[]→ - нет вкладывающих компонентов. + нет вкладывающих компонентов. Каждый элемент — ЛИБО плоский уровень + 'high|medium|low' (legacy), ЛИБО пара (имя-сервиса, уровень) (#1737: + имя сервиса проносится сквозь стек в RU-метку фактора). deal_count: число сделок за окно (None → нет данных, тянет в low). analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count). domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта). @@ -451,9 +490,16 @@ def compute_report_confidence( factors.append(_confounded_factor(True)) # ── 2. Вкладывающие per-service confidence → факторы ────────────────────── + # Элемент — ЛИБО плоский уровень (legacy), ЛИБО пара (имя-сервиса, уровень) + # (#1737: проносим имя сервиса в RU-метку фактора). Мусор/неизвестный уровень + # отбрасываем (whitelist), как и раньше. for c in component_confidences or []: - if c in _CONFIDENCE_RANK: - factors.append(_component_factor(c)) + if isinstance(c, tuple): + service, level = c + else: + service, level = None, c + if level in _CONFIDENCE_RANK: + factors.append(_component_factor(level, service)) # ── 3. weakest-link агрегат (худший тянет вниз) ─────────────────────────── raw_level = _aggregate(factors) diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py index cd45e8f1..5fab475f 100644 --- a/backend/app/services/forecasting/report_assembler.py +++ b/backend/app/services/forecasting/report_assembler.py @@ -246,23 +246,33 @@ def _component_confidences( forecasts: Sequence[dict[str, Any]], product_scores: dict[str, Any] | None, special_indices: dict[str, Any] | None, -) -> list[Confidence]: +) -> list[tuple[str, Confidence]]: """Собрать per-service confidence вкладывающих под-сервисов — для #990. PURE. #990 свернёт их weakest-link (MIN) вместе с сырыми счётчиками. Берём confidence- метку каждого доступного под-вывода (market_metrics §9.2 / future_supply §9.3 / каждый per-горизонт forecast #952 / product_scores #985 / special_indices #986). Только whitelisted 'high|medium|low' (мусор/None отбрасываем). + + #1737: возвращаем ПАРЫ (имя-сервиса, уровень) — машинное имя под-сервиса + («market_metrics» / «future_supply» / «product_scores» / «special_indices» / + «forecasts»), чтобы #990 пронёс ИМЯ сервиса в RU-метку фактора и UI не показывал + безличный «Компонент N». """ - out: list[Confidence] = [] - for source in (market_metrics, future_supply, product_scores, special_indices): + out: list[tuple[str, Confidence]] = [] + for name, source in ( + ("market_metrics", market_metrics), + ("future_supply", future_supply), + ("product_scores", product_scores), + ("special_indices", special_indices), + ): conf = _confidence_of(source) if conf is not None: - out.append(conf) + out.append((name, conf)) for f in forecasts: conf = _confidence_of(f) if conf is not None: - out.append(conf) + out.append(("forecasts", conf)) return out diff --git a/backend/app/templates/parcel_snapshot.html b/backend/app/templates/parcel_snapshot.html index 88a24406..f8cedeb0 100644 --- a/backend/app/templates/parcel_snapshot.html +++ b/backend/app/templates/parcel_snapshot.html @@ -4,10 +4,12 @@ Карточка участка {{ cad_num }}