From 148da3e5a79068a4185df291db64421452bb1471 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:36:22 +0500 Subject: [PATCH 01/13] =?UTF-8?q?fix(tradein):=20thin-market=20IMV=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=B4=D0=B2=D0=B8=D0=B3=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D1=8C=D0=B3=D0=B8=20(#3323)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit avito_imv_thin_market_threshold рождал только warning: IMV с market_count=1 всё равно уходил в blend (w=0.5 при A > median*1.15) и растягивал range_high. Гейт поставлен в _apply_imv_blend — единственной точке, через которую IMV влияет на деньги (обе ветки якоря, imv_anchor и imv_eval, сходятся там): market_count < threshold → no-op, якорь остаётся display-only в карточке. market_count >= threshold и market_count=None (порог не передан) — поведение прежнее. market_count=0 больше не читается как «неизвестно». Warning теперь говорит, что IMV ОТБРОШЕН, а не просто «тонкий рынок». --- tradein-mvp/backend/app/services/estimator.py | 39 +++++++-- .../backend/tests/test_estimator_imv_blend.py | 79 +++++++++++++++++++ 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 06763e6f..1a5fae76 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1320,6 +1320,8 @@ def _apply_imv_blend( anchor_higher: int | None, weight: float, threshold: float, + market_count: int | None = None, + thin_market_threshold: int = 0, ) -> tuple[int, int, float, bool, int | None]: """Чистая (testable без БД) blend-трансформация для #651. @@ -1330,12 +1332,22 @@ def _apply_imv_blend( Если A ниже медианы — медиану НЕ трогаем, но диапазон можем расширить, чтобы включить A (информативность). Null-guard: при anchor_total=None — no-op. + #3323: тонкий рынок (`market_count` < `thin_market_threshold`) → якорь + статистически ненадёжен и ОТБРАСЫВАЕТСЯ из денежного пути целиком: ни blend + медианы, ни расширение range_high. Гейт стоит здесь — в единственной точке, + через которую IMV влияет на деньги, а не в ветках построения якоря. + `thin_market_threshold=0` (default) = гейт выключен: market_count неизвестен → + поведение прежнее. + Returns (new_median_price, new_range_high, new_median_ppm2, blended, anchor_used_total). """ if anchor_total is None or anchor_total <= 0 or median_price <= 0 or area <= 0: return median_price, range_high, median_ppm2, False, None + if market_count is not None and market_count < thin_market_threshold: + return median_price, range_high, median_ppm2, False, None + blended = False new_median = median_price new_ppm2 = median_ppm2 @@ -3348,7 +3360,12 @@ def _price_from_inputs( int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None ) anchor_label = "оценке Avito IMV" - _imv_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None + # `is not None`: market_count=0 — самый тонкий рынок, а не «неизвестно». + _imv_mc = ( + int(imv_anchor["market_count"]) + if imv_anchor.get("market_count") is not None + else None + ) avito_imv_summary = AvitoImvSummary( recommended_price=anchor_total, lower_price=( @@ -3391,11 +3408,15 @@ def _price_from_inputs( ), ) - # #audit-5b: thin-market warning. + # #audit-5b / #3323: thin-market warning. Раньше порог рождал ТОЛЬКО warning, + # а IMV всё равно двигал headline и растягивал range_high — теперь гейт в + # `_apply_imv_blend` отбрасывает якорь, и текст говорит именно это. if avito_imv_summary is not None and avito_imv_summary.thin_market: logger.warning( - "avito_imv thin_market #audit-5b: market_count=%s" - " (< avito_imv_thin_market_threshold=%d) — IMV reliability low", + "avito_imv thin_market #3323: market_count=%s" + " (< avito_imv_thin_market_threshold=%d) — IMV ОТБРОШЕН из денежного" + " пути (ни blend медианы, ни расширение range_high); остаётся" + " display-only в карточке avito_imv", avito_imv_summary.market_count, settings.avito_imv_thin_market_threshold, ) @@ -3411,6 +3432,10 @@ def _price_from_inputs( anchor_higher=anchor_higher, weight=settings.estimate_imv_blend_weight, threshold=settings.estimate_imv_blend_threshold, + market_count=( + avito_imv_summary.market_count if avito_imv_summary is not None else None + ), + thin_market_threshold=settings.avito_imv_thin_market_threshold, ) if blended: logger.info( @@ -3442,7 +3467,11 @@ def _price_from_inputs( # median/expected_sold/ranges блок не трогает. if avito_imv_summary is None: if imv_anchor is not None and imv_anchor.get("recommended_price"): - _disp_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None + _disp_mc = ( + int(imv_anchor["market_count"]) + if imv_anchor.get("market_count") is not None + else None + ) avito_imv_summary = AvitoImvSummary( recommended_price=int(imv_anchor["recommended_price"]), lower_price=( diff --git a/tradein-mvp/backend/tests/test_estimator_imv_blend.py b/tradein-mvp/backend/tests/test_estimator_imv_blend.py index 77bf6a57..024bbf5e 100644 --- a/tradein-mvp/backend/tests/test_estimator_imv_blend.py +++ b/tradein-mvp/backend/tests/test_estimator_imv_blend.py @@ -55,6 +55,85 @@ def test_blend_premium_raises_median_and_extends_range() -> None: assert anchor_used == 100_000_000 +def test_blend_thin_market_gate_drops_imv_from_money_path_3323() -> None: + """#3323: market_count=1 → IMV не двигает ни headline, ни range_high. + + Те же числа, что в test_blend_premium_raises_median_and_extends_range + (там 50М → 75М, range 60М → 110М). С тонким рынком ждём ЧИСТЫЙ расчёт: + медиана 50М и range_high 60М — без вклада IMV. + """ + area = 80.0 + median_price = 50_000_000 + range_high = 60_000_000 + median_ppm2 = median_price / area + + new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend( + median_price=median_price, + range_high=range_high, + median_ppm2=median_ppm2, + area=area, + anchor_total=100_000_000, + anchor_higher=110_000_000, + weight=0.5, + threshold=1.15, + market_count=1, + thin_market_threshold=10, + ) + + assert blended is False + assert new_median == 50_000_000 + assert new_range_high == 60_000_000 + assert new_ppm2 == median_ppm2 + assert anchor_used is None + + +def test_blend_thick_market_unchanged_regression_3323() -> None: + """#3323 регрессия: market_count >= threshold → числа как до гейта.""" + area = 80.0 + new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend( + median_price=50_000_000, + range_high=60_000_000, + median_ppm2=50_000_000 / area, + area=area, + anchor_total=100_000_000, + anchor_higher=110_000_000, + weight=0.5, + threshold=1.15, + market_count=10, + thin_market_threshold=10, + ) + + assert blended is True + assert new_median == 75_000_000 + assert new_range_high == 110_000_000 + assert new_ppm2 == 75_000_000 / area + assert anchor_used == 100_000_000 + + +def test_blend_thin_market_gate_boundary_3323() -> None: + """#3323 граница: threshold-1 → гейт закрыт, threshold → открыт.""" + area = 80.0 + kwargs: dict[str, Any] = { + "median_price": 50_000_000, + "range_high": 60_000_000, + "median_ppm2": 50_000_000 / area, + "area": area, + "anchor_total": 100_000_000, + "anchor_higher": 110_000_000, + "weight": 0.5, + "threshold": 1.15, + "thin_market_threshold": 10, + } + + gated = _apply_imv_blend(**kwargs, market_count=9) + assert gated[:2] == (50_000_000, 60_000_000) + assert gated[3] is False + + passed = _apply_imv_blend(**kwargs, market_count=10) + assert passed[:2] == (75_000_000, 110_000_000) + assert passed[3] is True + + def test_blend_no_op_when_anchor_below_median() -> None: """A < median → медиану НЕ понижаем (однонаправленность), но диапазон может расшириться.""" area = 50.0 From 4f4345e27c2aeb7038c14b1ac51cd38f535a4d9e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:44:32 +0500 Subject: [PATCH 02/13] =?UTF-8?q?fix(tradein):=20=D0=B7=D0=B0=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=88=D0=B8=D1=82=D1=8C=20thin-market=20=D0=B3=D0=B5?= =?UTF-8?q?=D0=B9=D1=82=20IMV=20=E2=80=94=20Guard-1b=20=D0=B8=20GET-=D0=BF?= =?UTF-8?q?=D1=83=D1=82=D1=8C=20(#3323)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MEDIUM-1: imv_anchor_present ставился по anchor_total МИМО гейта — на тонком рынке якорь отброшен, а Guard-1b (#764) продолжал глушить квартальную поправку «потому что якорь есть»: headline не получал ни одной поправки, отброшенный якорь двигал деньги вычитанием. Теперь present = not thin_market. MEDIUM-2: trade_in.py (GET ?id= — расшаренная ссылка/PDF) — третья точка сборки карточки: market_count=0 читался как «неизвестно», thin_market не передавался вовсе → одна оценка показывала thin_market=True в POST и False при переоткрытии. --- tradein-mvp/backend/app/api/v1/trade_in.py | 11 +++- tradein-mvp/backend/app/services/estimator.py | 8 ++- .../backend/tests/test_estimator_imv_blend.py | 18 ++++++ .../tests/test_estimator_quarter_index.py | 56 ++++++++++++++++++- 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 61e086cd..e5542b73 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -776,7 +776,16 @@ def load_estimate( recommended_price=int(imv_raw["recommended_price"]), lower_price=int(imv_raw["lower_price"]) if imv_raw.get("lower_price") else None, higher_price=int(imv_raw["higher_price"]) if imv_raw.get("higher_price") else None, - market_count=int(imv_raw["market_count"]) if imv_raw.get("market_count") else None, + # #3323: `is not None` (0 — самый тонкий рынок, не «неизвестно») + thin_market + # считаем тем же порогом, что POST-путь в estimator, иначе одна и та же + # оценка при переоткрытии по ссылке / в PDF теряла флаг тонкого рынка. + market_count=( + int(imv_raw["market_count"]) if imv_raw.get("market_count") is not None else None + ), + thin_market=( + imv_raw.get("market_count") is not None + and int(imv_raw["market_count"]) < settings.avito_imv_thin_market_threshold + ), ) if imv_raw is not None and imv_raw.get("recommended_price") else None diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 1a5fae76..3541e3fd 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -3422,7 +3422,13 @@ def _price_from_inputs( ) if anchor_total is not None: - imv_anchor_present = True + # #3323: на тонком рынке якорь отброшен гейтом ниже, значит и Guard-1b + # (#764, quarter-index) не должен глушить поправку «потому что якорь есть» — + # иначе headline не получит НИ ОДНОЙ поправки, и отброшенный IMV подвинет + # деньги вычитанием. avito_imv_summary здесь уже собран обеими ветками. + imv_anchor_present = ( + not avito_imv_summary.thin_market if avito_imv_summary is not None else True + ) new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend( median_price=median_price, range_high=range_high, diff --git a/tradein-mvp/backend/tests/test_estimator_imv_blend.py b/tradein-mvp/backend/tests/test_estimator_imv_blend.py index 024bbf5e..3decd220 100644 --- a/tradein-mvp/backend/tests/test_estimator_imv_blend.py +++ b/tradein-mvp/backend/tests/test_estimator_imv_blend.py @@ -110,6 +110,24 @@ def test_blend_thick_market_unchanged_regression_3323() -> None: assert anchor_used == 100_000_000 +def test_blend_market_count_none_passes_gate_3323() -> None: + """#3323 контракт: market_count неизвестен (None) → гейт не срабатывает.""" + new_median, _, _, blended, _ = _apply_imv_blend( + median_price=50_000_000, + range_high=60_000_000, + median_ppm2=625_000.0, + area=80.0, + anchor_total=100_000_000, + anchor_higher=110_000_000, + weight=0.5, + threshold=1.15, + market_count=None, + thin_market_threshold=10, + ) + assert blended is True + assert new_median == 75_000_000 + + def test_blend_thin_market_gate_boundary_3323() -> None: """#3323 граница: threshold-1 → гейт закрыт, threshold → открыт.""" area = 80.0 diff --git a/tradein-mvp/backend/tests/test_estimator_quarter_index.py b/tradein-mvp/backend/tests/test_estimator_quarter_index.py index dea55ceb..13b89e2d 100644 --- a/tradein-mvp/backend/tests/test_estimator_quarter_index.py +++ b/tradein-mvp/backend/tests/test_estimator_quarter_index.py @@ -388,6 +388,7 @@ def _run_estimate_qi( *, anchor_tier_override: str | None = None, analog_indexes: dict[str, float] | None = None, + imv_anchor: dict[str, Any] | None = None, ): """Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate. @@ -446,7 +447,7 @@ def _run_estimate_qi( "app.services.estimator._get_asking_sold_ratio", return_value=(None, None), ), - patch("app.services.estimator._fetch_house_imv_anchor", return_value=None), + patch("app.services.estimator._fetch_house_imv_anchor", return_value=imv_anchor), # Stub singular target-quarter lookup patch( "app.services.estimator._lookup_quarter_index", @@ -881,6 +882,59 @@ def test_guard1b_imv_blend_prevents_correction() -> None: assert "квартал" not in (est.confidence_explanation or "").lower() +def test_guard1b_thin_market_imv_does_not_block_quarter_index_3323() -> None: + """#3323: тонкий рынок → якорь отброшен, значит Guard-1b НЕ глушит поправку. + + Тот же вход, что в test_guard1b_imv_blend_prevents_correction (anchor 30М ≫ + медианы 6М), но market_count=1 < порога 10. Ждём по значению: blend не + сработал (не 18М) и квартальная поправка ПРИМЕНИЛАСЬ → 6М × 1.2. + """ + base_median = round(_BASE_PPM2 * _AREA) # 6_000_000 + thin_anchor = { + "recommended_price": 30_000_000, + "lower_price": 25_000_000, + "higher_price": 35_000_000, + "market_count": 1, # < settings.avito_imv_thin_market_threshold (10) + "rooms": 1, + "area_m2": _AREA, + } + + est = _run_estimate_qi( + analogs=_ANALOGS_OTHER_QUARTER, + dadata_cadnum=f"{_TARGET_QUARTER}:350", + qi_lookup_result=(1.2, 30), + analog_indexes={_OTHER_QUARTER: 1.0}, + imv_anchor=thin_anchor, + ) + + assert est.median_price_rub == round(base_median * 1.2) # 7_200_000 + assert est.median_price_rub != round(6_000_000 * 0.5 + 30_000_000 * 0.5) # не blend + assert "квартал" in (est.confidence_explanation or "").lower() + + +def test_guard1b_thick_market_imv_still_blocks_quarter_index_3323() -> None: + """#3323 регрессия: market_count >= порога → blend как раньше, поправка подавлена.""" + thick_anchor = { + "recommended_price": 30_000_000, + "lower_price": 25_000_000, + "higher_price": 35_000_000, + "market_count": 500, + "rooms": 1, + "area_m2": _AREA, + } + + est = _run_estimate_qi( + analogs=_ANALOGS_OTHER_QUARTER, + dadata_cadnum=f"{_TARGET_QUARTER}:350", + qi_lookup_result=(1.2, 30), + analog_indexes={_OTHER_QUARTER: 1.0}, + imv_anchor=thick_anchor, + ) + + assert est.median_price_rub == round(6_000_000 * 0.5 + 30_000_000 * 0.5) # 18_000_000 + assert "квартал" not in (est.confidence_explanation or "").lower() + + def test_guard1b_imv_anchor_below_blend_threshold_prevents_correction() -> None: """Guard-1b: IMV anchor присутствует но ниже blend-порога (blended=False). From ac11156f7d1136a9a9d3c307d5c4952626149790 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 5 Sep 2026 22:44:46 +0500 Subject: [PATCH 03/13] =?UTF-8?q?fix(scraper-kit):=20=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B4=D1=80=D0=B5=D0=B9=D0=BD=D0=B0=20interrupt?= =?UTF-8?q?ed=3D1=20=D0=B2=D0=BE=20=D0=B2=D1=81=D0=B5=D1=85=20city-=D1=81?= =?UTF-8?q?=D0=B2=D0=B8=D0=BF=D0=B0=D1=85=20(#3333)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После #3319 резюм подхватывает 'done'-прогоны только с counters.interrupted (_drained_done в scheduler._resume_decision), но ставил метку ровно один avito_city_sweep. У yandex, cian и newbuilding SIGTERM-drain финализировался чистым 'done' с частичными счётчиками: оборванный деплоем обход неотличим от полного и из резюма выпадал, хотя чекпоинт done_buckets есть у всех трёх (combo-метки / имена якорей / номера страниц). done_buckets в дрейн-payload не добавляю: heartbeat мержит jsonb, уже записанные единицы обхода переживают финализатор, а пустой список у multi-anchor yandex затёр бы унаследованный при claim чекпоинт. --- .../tests/test_3333_drain_mark_all_sweeps.py | 212 ++++++++++++++++++ .../src/scraper_kit/orchestration/pipeline.py | 24 +- 2 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3333_drain_mark_all_sweeps.py diff --git a/tradein-mvp/backend/tests/test_3333_drain_mark_all_sweeps.py b/tradein-mvp/backend/tests/test_3333_drain_mark_all_sweeps.py new file mode 100644 index 00000000..80a62dcf --- /dev/null +++ b/tradein-mvp/backend/tests/test_3333_drain_mark_all_sweeps.py @@ -0,0 +1,212 @@ +"""SIGTERM-дрейн помечен `interrupted=1` во ВСЕХ свипах, не только у avito (#3333). + +После #3319 резюм подхватывает 'done'-прогоны только с меткой `interrupted` +(`_drained_done` в scheduler._resume_decision), а ставил её ровно один +avito_city_sweep. У yandex (~2356 стр.), cian (~2960) и newbuilding (~2098) +дрейн финализировался чистым `done` с частичными счётчиками: недоделанный обход +объявлен полным, статус тот же, что у честного, — и из резюма он выпадал. + +Резюм есть у всех трёх (чекпоинт `done_buckets`: combo-метки у yandex, имена +якорей у cian, номера страниц у newbuilding), так что метка не диагностическая: +у каждого есть что подхватывать. Оговорка одна — yandex подхватывает только при +единственном якоре (combo-ключ не содержит якоря); прод-режим ровно такой. + +Анти-цикл (последний тест): метка не должна превратить ЛЮБОЙ 'done' в +резюмируемый — иначе источник больше никогда не обходится целиком. +""" + +from __future__ import annotations + +import os + +# Settings собирается автофикстурой conftest'а и требует database_url — как в +# test_3319_citysweep_checkpoint.py, до остальных импортов. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import json +import types +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +ANCHOR_A = (56.83, 60.60, "ekb-center") +ANCHOR_B = (56.79, 60.63, "ekb-south") + + +class _FakeDb: + """Все UPDATE'ы с counters (heartbeat И финализаторы) складываются по порядку.""" + + def __init__(self) -> None: + self.writes: list[dict[str, Any]] = [] + + def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any: + if params and "counters" in params: + self.writes.append(json.loads(params["counters"])) + return MagicMock() + + def commit(self) -> None: ... + def rollback(self) -> None: ... + + +class _FakeAsyncSession: + def __init__(self, *_a: Any, **_kw: Any) -> None: ... + + async def __aenter__(self) -> _FakeAsyncSession: + return self + + async def __aexit__(self, *_e: Any) -> None: + return None + + +class _NeverCalledScraper: + """Дрейн срабатывает ДО скрапера: любой запрос тут — сломанный порядок проверок.""" + + def __init__(self, *_a: Any, **_kw: Any) -> None: + self._browser = None + self._cffi = None + self.state_extraction_attempts = 1 + self.state_extraction_failures = 0 + self.request_delay_sec = 0.0 + + async def __aenter__(self) -> _NeverCalledScraper: + return self + + async def __aexit__(self, *_e: Any) -> None: + return None + + def __getattr__(self, name: str) -> Any: + async def _boom(*_a: Any, **_kw: Any) -> Any: + raise AssertionError(f"скрапер вызван при дрейне: {name}") + + return _boom + + +def _config() -> types.SimpleNamespace: + return types.SimpleNamespace( + scraper_fetch_mode="cffi", + scraper_proxy_url=None, + use_proxy_pool_browser=False, + browser_http_endpoint=None, + environment="test", + avito_serp_ok_not_banned=True, + ) + + +@pytest.mark.asyncio +async def test_yandex_drain_is_marked_interrupted() -> None: + """yandex-sweep: дрейн на границе якоря → counters.interrupted == 1.""" + from scraper_kit.orchestration import pipeline as pl + + db = _FakeDb() + enrichment = MagicMock() + enrichment.record_yandex_price_history.return_value = 0 + + with ( + patch.object(pl, "YandexRealtyScraper", _NeverCalledScraper), + patch.object(pl, "save_listings", lambda *_a, **_kw: (0, 0)), + patch.object(pl.runs, "is_cancelled", lambda *_a: False), + ): + await pl.run_yandex_city_sweep( + db, # type: ignore[arg-type] + run_id=3333, + config=_config(), + matcher=MagicMock(), + enrichment=enrichment, + enrich_address=False, + shutdown_requested=lambda: True, + ) + + assert db.writes, "дрейн не оставил ни одной записи counters" + assert db.writes[-1].get("interrupted") == 1, ( + "yandex: оборванный дрейном прогон неотличим от полного обхода — резюм его не возьмёт" + ) + + +@pytest.mark.asyncio +async def test_cian_drain_is_marked_interrupted() -> None: + """cian-sweep: дрейн на границе якоря → counters.interrupted == 1.""" + from scraper_kit.orchestration import pipeline as pl + + db = _FakeDb() + + with ( + patch.object(pl, "CianScraper", _NeverCalledScraper), + patch.object(pl, "save_listings", lambda *_a, **_kw: (0, 0)), + patch.object(pl.runs, "is_cancelled", lambda *_a: False), + ): + await pl.run_cian_city_sweep( + db, # type: ignore[arg-type] + run_id=3333, + config=_config(), + matcher=MagicMock(), + anchors=[ANCHOR_A, ANCHOR_B], + enrich_houses=False, + detail_top_n=0, + request_delay_sec=0.0, + shutdown_requested=lambda: True, + ) + + assert db.writes, "дрейн не оставил ни одной записи counters" + assert db.writes[-1].get("interrupted") == 1, ( + "cian: оборванный дрейном прогон неотличим от полного обхода — резюм его не возьмёт" + ) + + +@pytest.mark.asyncio +async def test_newbuilding_drain_is_marked_interrupted() -> None: + """nb-sweep: дрейн до SERP-фазы → counters.interrupted == 1.""" + from scraper_kit.orchestration import pipeline as pl + + db = _FakeDb() + + with ( + patch.object(pl, "AvitoScraper", _NeverCalledScraper), + patch.object(pl, "AsyncSession", _FakeAsyncSession), + patch.object(pl, "save_listings", lambda *_a, **_kw: (0, 0)), + patch.object(pl.runs, "is_cancelled", lambda *_a: False), + ): + await pl.run_avito_newbuilding_sweep( + db, # type: ignore[arg-type] + run_id=3333, + config=_config(), + matcher=MagicMock(), + pages=4, + request_delay_sec=0.0, + shutdown_requested=lambda: True, + ) + + assert db.writes, "дрейн не оставил ни одной записи counters" + assert db.writes[-1].get("interrupted") == 1, ( + "newbuilding: оборванный дрейном прогон неотличим от полного обхода — резюм его не возьмёт" + ) + + +def _prev_run(counters: dict[str, Any]) -> types.SimpleNamespace: + return types.SimpleNamespace( + prev_id=4707, + prev_status="done", + prev_counters=counters, + same_params=True, + age_h=2.0, + interval_days="7", + ) + + +def test_clean_done_is_not_resumed_but_marked_drain_is() -> None: + """Анти-цикл: подхватывается ТОЛЬКО помеченный дрейн, чистый 'done' — нет. + + Общий на все три провайдера: `_resume_decision` смотрит на counters, а не на + источник, поэтому разбор один. Если бы метка была не нужна для подхвата, все + три правки выше были бы записью в лог ради записи в лог. + """ + from scraper_kit.orchestration.scheduler import _resume_decision + + ckpt = {"done_buckets": ["combo-1"], "resume_chain": 0} + + resume_from, verdict = _resume_decision(_prev_run({**ckpt, "interrupted": 1})) + assert resume_from == 4707, f"помеченный дрейн не подхвачен: {verdict}" + + resume_from, verdict = _resume_decision(_prev_run(ckpt)) + assert resume_from is None, "чистый 'done' — полный обход, подхватывать нечего" + assert verdict["resume_reason"] == "status_done" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 654927ef..818acf74 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -2112,8 +2112,14 @@ async def run_avito_newbuilding_sweep( logger.info( "nb-sweep run_id=%d: SIGTERM-drain — stopping before SERP phase", run_id ) - runs.update_heartbeat(db, run_id, counters.to_dict()) - runs.mark_done(db, run_id, counters.to_dict()) + # #3333: та же метка дрейна, что у avito_city_sweep (_ckpt(interrupted=1)). + # Без неё оборванный деплоем обход неотличим от полного: статус 'done', + # счётчики частичные — и резюм (`_drained_done` в scheduler) его не берёт. + # done_buckets тут не пишем: heartbeat мержит jsonb, уже записанные + # страницы переживают финализатор. + _drain = {**counters.to_dict(), "interrupted": 1} + runs.update_heartbeat(db, run_id, _drain) + runs.mark_done(db, run_id, _drain) return counters # proxy_provider прокинут для консистентности (#2616) — не load-bearing, @@ -2370,8 +2376,11 @@ async def run_yandex_city_sweep( len(_anchors), name, ) - runs.update_heartbeat(db, run_id, counters.to_dict()) - runs.mark_done(db, run_id, counters.to_dict()) + # #3333: та же метка дрейна, что у avito_city_sweep (_ckpt(interrupted=1)) — + # см. там же. done_buckets пишет combo-heartbeat, jsonb-мерж их сохраняет. + _drain = {**counters.to_dict(), "interrupted": 1} + runs.update_heartbeat(db, run_id, _drain) + runs.mark_done(db, run_id, _drain) return counters logger.info( @@ -2985,8 +2994,11 @@ async def run_cian_city_sweep( len(_anchors), name, ) - runs.update_heartbeat(db, run_id, counters.to_dict()) - runs.mark_done(db, run_id, counters.to_dict()) + # #3333: та же метка дрейна, что у avito_city_sweep (_ckpt(interrupted=1)) — + # см. там же. done_buckets пишет end-of-anchor heartbeat, jsonb-мерж хранит. + _drain = {**counters.to_dict(), "interrupted": 1} + runs.update_heartbeat(db, run_id, _drain) + runs.mark_done(db, run_id, _drain) return counters logger.info( From 3658434c03e46ca7f1f82233cf4b80f09990eed0 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 5 Sep 2026 22:47:08 +0500 Subject: [PATCH 04/13] =?UTF-8?q?fix(tradein/backfill):=20save-False=20?= =?UTF-8?q?=D1=83=20yandex/avito=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=82=D0=B5=D1=80=D1=8F=D0=B5=D1=82=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BF=D1=8B=D1=82=D0=BA=D1=83=20=E2=80=94=20attempted=20?= =?UTF-8?q?=3D=3D=20=D1=81=D1=83=D0=BC=D0=BC=D0=B0=20=D0=B8=D1=81=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тот же дефект, что #3332 у domclick (#3335), у обоих соседей: `if save(...): enriched += 1` без else. UPDATE, не задевший строку (объявление удалено между снимком и записью), оставлял попытку без исхода — attempted переставал сходиться с суммой исходов, и расхождение читается как потерянный отказ площадки. Разбор ВСЕХ точек выхода из цикла попыток показал, что у соседей это единственная дыра: обрыва «пул прокси пуст» у них нет (resolve_proxy_url бросает ProxyPoolExhaustedError ДО цикла), а budget/SIGTERM-брейки стоят до `attempted += 1`. Исход — failed с отдельным warning про ненайденную строку. Формы тождества разные и это не описка: у yandex blocked ⊆ failed (#3196), у avito blocked/gone/failed — непересекающиеся корзины. Тесты — по значению (числа, не «не бросило»), плюс контроль на противоположную ошибку (исход не начисляется дважды). В test_3332 добавлен запрошенный ревью кейс: транспортный сбой → пустой пул даёт attempted=2 failed=2, а не 3. Closes #3338 --- .../app/tasks/avito_detail_backfill.py | 16 + .../app/tasks/yandex_detail_backfill.py | 16 + .../test_3332_domclick_counter_identity.py | 28 ++ .../test_3338_backfill_counter_identity.py | 280 ++++++++++++++++++ 4 files changed, 340 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_3338_backfill_counter_identity.py diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index 40c041c7..2ccb60ee 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -684,6 +684,22 @@ async def run_avito_detail_backfill( ) if save_detail_enrichment(db, enrichment): counters.enriched += 1 + else: + # #3338 (та же дыра, что #3332 у domclick): карточка взята и + # разобрана, а UPDATE не задел ни одной строки — объявление + # удалено/деактивировано между снимком и записью. Попытка была, + # исхода не было: attempted переставал сходиться с + # enriched + blocked + gone + failed, и расхождение читается как + # потерянный отказ площадки. Исход failed: непрошедший UPDATE — не + # успех, не блок и не gone (снятие метит is_active=FALSE сам, по 404). + counters.failed += 1 + logger.warning( + "avito_detail_backfill: run_id=%d listing %s -- карточка " + "разобрана, но UPDATE не нашёл строку id=%s", + run_id, + source_url, + row["id"], + ) if use_curl: items_since_warm += 1 breaker.record_success() diff --git a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py index 49454c09..05842817 100644 --- a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py @@ -458,6 +458,22 @@ async def run_yandex_detail_backfill( consecutive_blocks = 0 if save_detail_enrichment(db, listing_id, enrichment): counters.enriched += 1 + else: + # #3338 (та же дыра, что #3332 у domclick): страница взята и + # разобрана, а UPDATE не задел ни одной строки — объявление + # удалено/деактивировано между снимком и записью. Попытка была, + # исхода не было: attempted переставал сходиться с enriched + + # failed, и расхождение читается как потерянный отказ площадки. + # Исход failed: непрошедший UPDATE — не успех и не блок. + counters.failed += 1 + logger.warning( + "yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s " + "-- карточка разобрана, но UPDATE не нашёл строку id=%d", + run_id, + listing_id, + source_url, + listing_id, + ) except Exception as exc: counters.failed += 1 diff --git a/tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py b/tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py index feb35609..e4e14923 100644 --- a/tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py +++ b/tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py @@ -31,6 +31,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: _wp_mock = MagicMock() sys.modules.setdefault("weasyprint", _wp_mock) +import httpx # noqa: E402 import pytest # noqa: E402 from scraper_kit.domclick_exceptions import DomClickBlockedError # noqa: E402 from scraper_kit.proxy_errors import NoProxyAvailableError # noqa: E402 @@ -92,6 +93,14 @@ def _empty_pool_block() -> DomClickBlockedError: return blocked +def _transport_block() -> DomClickBlockedError: + """Сбой НАШЕЙ стороны (таймаут/5xx сайдкара): httpx-ошибка в __cause__ — + ровно то, что читает `_is_transport_failure` (#3283).""" + blocked = DomClickBlockedError("browser fetch failed") + blocked.__cause__ = httpx.ConnectTimeout("sidecar timed out") + return blocked + + async def _run( fetch: AsyncMock, *, snapshot: int, save_ok: bool = True ) -> tuple[DomClickDetailBackfillResult, MagicMock]: @@ -160,6 +169,25 @@ async def test_missing_row_on_save_keeps_identity() -> None: ) +@pytest.mark.asyncio +async def test_transport_failure_then_empty_pool_counts_each_once() -> None: + """Контроль двойного начисления на самом пути пула (#3338, просьба ревью). + + Транспортный сбой уже начисляет failed и идёт `continue`; следующая попытка + упирается в пустой пул и начисляет failed повторно — но СВОЙ, за СВОЮ + попытку. attempted=2 и failed=2, а не 3: правка #3332 не должна начислять + исход второй раз за ту же попытку. + """ + fetch = AsyncMock(side_effect=[_transport_block(), _empty_pool_block()]) + counters, _ = await _run(fetch, snapshot=5) + + _assert_identity(counters, expected_attempted=2) + assert (counters.failed, counters.blocked, counters.enriched) == (2, 0, 0), ( + f"failed={counters.failed} blocked={counters.blocked} enriched={counters.enriched}, " + "ожидали 2/0/0 — по одному отказу нашей стороны на каждую из двух попыток" + ) + + @pytest.mark.asyncio async def test_blocked_and_enriched_counted_once() -> None: """Контроль на противоположную ошибку: блоки/успехи по-прежнему по одному разу.""" diff --git a/tradein-mvp/backend/tests/test_3338_backfill_counter_identity.py b/tradein-mvp/backend/tests/test_3338_backfill_counter_identity.py new file mode 100644 index 00000000..555dadf3 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3338_backfill_counter_identity.py @@ -0,0 +1,280 @@ +"""Тождество счётчиков у соседей domclick: yandex/avito detail-backfill (#3338). + +Тот же дефект, что #3332 (см. tests/test_3332_domclick_counter_identity.py): +`counters.attempted` инкрементируется ДО попытки, исход дописывается при разборе +результата — а ветка `save_detail_enrichment(...) -> False` исхода не дописывала +вовсе (`if save(...): enriched += 1` без else). Страницу взяли, разобрали, а +UPDATE не задел ни одной строки (объявление удалено/деактивировано между +снимком и записью) — попытка была, исхода не было. Расхождение +`attempted - сумма исходов` читается как потерянный отказ площадки. + +Разбор ВСЕХ точек выхода из цикла попыток показал, что это единственная дыра у +обоих (у domclick второй был обрыв «пул прокси пуст» — у соседей такого пути +нет: `resolve_proxy_url` бросает ProxyPoolExhaustedError ДО цикла): + + yandex — fetch-исключение → failed; non-200 → blocked+failed; parse→None → + failed; save→False → БЫЛА ДЫРА; общий except → failed. + avito — save→False → БЫЛА ДЫРА; AvitoListingGoneError → gone; + Blocked/RateLimited → blocked; TimeoutError → failed; + общий except → failed. Обрывы по budget/SIGTERM стоят ДО + `attempted += 1`, они попытку не создают. + +Формы тождества у файлов РАЗНЫЕ, и это не описка: + * yandex: `blocked` документирован как ПОДМНОЖЕСТВО `failed` (dataclass, + #3196) — non-200 инкрементирует оба, поэтому сумма исходов = enriched + failed; + * avito: `blocked`/`gone`/`failed` — непересекающиеся корзины, сумма исходов = + enriched + blocked + gone + failed. + +Проверка ПО ЗНАЧЕНИЮ: сравниваются числа, а не «не бросило исключение». Вторым +кейсом на каждый файл идёт контроль на противоположную ошибку — что правка не +начисляет исход дважды. Харнессы зеркалят tests/test_3196_yandex_ban_kind.py и +tests/test_3283g_rotate_on_platform_ban.py. +""" + +from __future__ import annotations + +import os +import sys +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) + +import pytest # noqa: E402 +from scraper_kit.avito_exceptions import AvitoBlockedError # noqa: E402 + +from app.core import shutdown as _sd # noqa: E402 +from app.tasks.avito_detail_backfill import ( # noqa: E402 + AvitoDetailBackfillResult, + run_avito_detail_backfill, +) +from app.tasks.yandex_detail_backfill import ( # noqa: E402 + YandexDetailBackfillResult, + run_yandex_detail_backfill, +) + +# ── yandex ──────────────────────────────────────────────────────────────────── +_Y_ASYNC_SESSION = "app.tasks.yandex_detail_backfill.AsyncSession" +_Y_PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse" +_Y_SAVE = "app.tasks.yandex_detail_backfill.save_detail_enrichment" +_Y_RUNS = "app.tasks.yandex_detail_backfill.runs_mod" +_Y_SLEEP = "app.tasks.yandex_detail_backfill.asyncio.sleep" +_Y_RESOLVE_PROXY_URL = "app.tasks.yandex_detail_backfill.resolve_proxy_url" + +# ── avito ───────────────────────────────────────────────────────────────────── +_A_FETCH = "app.tasks.avito_detail_backfill.fetch_detail" +_A_SAVE = "app.tasks.avito_detail_backfill.save_detail_enrichment" +_A_RUNS = "app.tasks.avito_detail_backfill.runs_mod" +_A_SLEEP = "app.tasks.avito_detail_backfill.asyncio.sleep" +_A_SETTINGS = "app.tasks.avito_detail_backfill.settings" +_A_SESSION = "app.tasks.avito_detail_backfill.AsyncSession" +_A_SCRAPER = "app.tasks.avito_detail_backfill.AvitoScraper" +_A_BROWSER_FETCHER = "app.tasks.avito_detail_backfill.BrowserFetcher" +_A_ROTATE_PROXY = "app.tasks.avito_detail_backfill.rotate_proxy" + + +@pytest.fixture(autouse=True) +def _reset_shutdown() -> None: + _sd.reset_shutdown() + yield + _sd.reset_shutdown() + + +def _assert_identity( + attempted: int, outcomes: int, *, expected_attempted: int, detail: str +) -> None: + assert attempted == expected_attempted, ( + f"attempted={attempted}, ожидали {expected_attempted} попыток" + ) + assert attempted == outcomes, ( + f"тождество нарушено: attempted={attempted}, сумма исходов={outcomes} ({detail}), " + f"потеряно {attempted - outcomes} попыток без исхода" + ) + + +def _assert_yandex_identity(c: YandexDetailBackfillResult, *, expected_attempted: int) -> None: + # blocked ⊆ failed (см. докстринг модуля) — в сумму входит только failed. + _assert_identity( + c.attempted, + c.enriched + c.failed, + expected_attempted=expected_attempted, + detail=f"enriched={c.enriched} failed={c.failed} (blocked={c.blocked} ⊆ failed)", + ) + + +def _assert_avito_identity(c: AvitoDetailBackfillResult, *, expected_attempted: int) -> None: + _assert_identity( + c.attempted, + c.enriched + c.blocked + c.gone + c.failed, + expected_attempted=expected_attempted, + detail=(f"enriched={c.enriched} blocked={c.blocked} gone={c.gone} failed={c.failed}"), + ) + + +def _mock_yandex_db(n: int) -> MagicMock: + snapshot = [ + {"id": i + 1, "source_url": f"https://realty.yandex.ru/offer/{i + 1}/"} for i in range(n) + ] + db = MagicMock() + sel = MagicMock() + sel.mappings.return_value.all.return_value = snapshot + sel.one.return_value = SimpleNamespace(url_from_offer_id=0, unenrichable_pending=0) + db.execute.return_value = sel + return db + + +def _resp(status: int) -> MagicMock: + resp = MagicMock() + resp.status_code = status + resp.text = "ok" + return resp + + +def _yandex_session_cls(responses: list[MagicMock]) -> MagicMock: + session = AsyncMock() + session.get = AsyncMock(side_effect=responses) + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=session) + ctx.__aexit__ = AsyncMock(return_value=None) + return MagicMock(return_value=ctx) + + +async def _run_yandex( + responses: list[MagicMock], *, save_ok: bool, parse_result: Any = None +) -> YandexDetailBackfillResult: + count = len(responses) + db = _mock_yandex_db(count) + with ( + patch(_Y_ASYNC_SESSION, _yandex_session_cls(responses)), + patch(_Y_PARSE, return_value=parse_result or MagicMock()), + patch(_Y_SAVE, return_value=save_ok), + patch(_Y_RUNS, MagicMock()), + patch(_Y_SLEEP, new_callable=AsyncMock), + patch(_Y_RESOLVE_PROXY_URL, MagicMock(return_value="http://proxy:3128")), + ): + return await run_yandex_detail_backfill( + db, + run_id=3338, + params={"batch_size": count, "budget_sec": 3600, "max_consecutive_blocks": 10}, + ) + + +@pytest.mark.asyncio +async def test_yandex_missing_row_on_save_keeps_identity() -> None: + """save_detail_enrichment вернул False (строки уже нет) → попытка не теряется.""" + counters = await _run_yandex([_resp(200), _resp(200)], save_ok=False) + + _assert_yandex_identity(counters, expected_attempted=2) + assert (counters.enriched, counters.failed, counters.blocked) == (0, 2, 0), ( + f"enriched={counters.enriched} failed={counters.failed} blocked={counters.blocked}: " + "непрошедший UPDATE — не успех и не блок площадки" + ) + + +@pytest.mark.asyncio +async def test_yandex_outcomes_counted_once() -> None: + """Контроль на противоположную ошибку: успех и non-200 — по одному разу. + + non-200 инкрементирует и blocked, и failed НАРОЧНО (blocked ⊆ failed, #3196); + правка #3338 не должна добавлять там третий инкремент. + """ + counters = await _run_yandex([_resp(200), _resp(403)], save_ok=True) + + _assert_yandex_identity(counters, expected_attempted=2) + assert (counters.enriched, counters.failed, counters.blocked) == (1, 1, 1), ( + f"enriched={counters.enriched} failed={counters.failed} blocked={counters.blocked}, " + "ожидали 1/1/1 — исход начислен дважды" + ) + + +def _fake_avito_settings() -> MagicMock: + return MagicMock( + scraper_fetch_mode="browser", + avito_detail_backfill_use_curl=False, + detail_backfill_block_ratio_window=20, + detail_backfill_block_ratio_threshold=0.7, + browser_http_endpoint="http://browser:9000", + avito_detail_backfill_rotate_after_attempts=15, + avito_detail_backfill_rotate_on_ban_max=0, + avito_detail_backfill_rotate_on_ban_min_gap=10, + ) + + +def _mock_avito_db(n: int) -> MagicMock: + snapshot = [ + { + "id": i + 1, + "source_url": f"https://www.avito.ru/ekaterinburg/kvartiry/1-k._kvartira_{i + 1}", + } + for i in range(n) + ] + db = MagicMock() + sel = MagicMock() + sel.mappings.return_value.all.return_value = snapshot + db.execute.return_value = sel + return db + + +def _mock_avito_browser_fetcher_cls() -> MagicMock: + instance = AsyncMock() + instance.__aenter__ = AsyncMock(return_value=instance) + instance.__aexit__ = AsyncMock(return_value=False) + instance.request_context_reset = MagicMock() + instance.lease_id = 42 + return MagicMock(return_value=instance) + + +async def _run_avito(fetch_results: list[Any], *, save_ok: bool) -> AvitoDetailBackfillResult: + count = len(fetch_results) + db = _mock_avito_db(count) + with ( + patch(_A_SETTINGS, _fake_avito_settings()), + patch(_A_SESSION), + patch(_A_SCRAPER), + patch(_A_RUNS, MagicMock()), + patch(_A_BROWSER_FETCHER, _mock_avito_browser_fetcher_cls()), + patch(_A_FETCH, AsyncMock(side_effect=fetch_results)), + patch(_A_SAVE, return_value=save_ok), + patch(_A_ROTATE_PROXY, AsyncMock()), + patch(_A_SLEEP, new_callable=AsyncMock), + ): + return await run_avito_detail_backfill( + db, + run_id=3338, + params={"batch_size": count, "budget_sec": 3600, "max_consecutive_blocks": 10}, + ) + + +@pytest.mark.asyncio +async def test_avito_missing_row_on_save_keeps_identity() -> None: + """save_detail_enrichment вернул False (строки уже нет) → попытка не теряется.""" + counters = await _run_avito([MagicMock(), MagicMock()], save_ok=False) + + _assert_avito_identity(counters, expected_attempted=2) + assert (counters.enriched, counters.failed) == (0, 2), ( + f"enriched={counters.enriched} failed={counters.failed}: непрошедший UPDATE — не успех" + ) + assert (counters.blocked, counters.gone) == (0, 0), ( + f"blocked={counters.blocked} gone={counters.gone}: площадка ответила и ничего " + "не снимала — исход отказа НАШЕЙ стороны, чужие корзины трогать нельзя" + ) + + +@pytest.mark.asyncio +async def test_avito_outcomes_counted_once() -> None: + """Контроль на противоположную ошибку: успех/блок/таймаут — по одному разу.""" + counters = await _run_avito( + [MagicMock(), AvitoBlockedError("firewall/soft-block"), TimeoutError("fetch stalled")], + save_ok=True, + ) + + _assert_avito_identity(counters, expected_attempted=3) + assert (counters.enriched, counters.blocked, counters.failed, counters.gone) == (1, 1, 1, 0), ( + f"enriched={counters.enriched} blocked={counters.blocked} " + f"failed={counters.failed} gone={counters.gone}, " + "ожидали 1/1/1/0 — правка #3338 не должна начислять исход дважды" + ) From 5356d564e63a1a76ea5599ea39d4992ca69748af Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 5 Sep 2026 22:48:07 +0500 Subject: [PATCH 05/13] =?UTF-8?q?fix(mera):=20=D0=BF=D0=BE=D0=B4=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B0=D1=82=D1=8C=20IMV=20=D0=BD=D0=B0=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BD=D0=BA=D0=BE=D0=BC=20=D1=80=D1=8B=D0=BD=D0=BA=D0=B5?= =?UTF-8?q?=20=E2=80=94=20=D1=87=D0=B8=D1=81=D0=BB=D0=BE=20=D0=B2=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=80=D0=B0=D1=81=D1=87=D1=91=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бэкенд (#3336, thin_market) при market_count ниже порога исключает IMV из headline, но витрина ставила число рядом с итоговой ценой молча — читалось как расчётная величина. Подпись называет реальный N из market_count. Closes #3339 --- .../src/components/trade-in/HeroSummary.tsx | 22 ++++- .../__tests__/thinMarketBadge.test.tsx | 83 +++++++++++++++++++ tradein-mvp/frontend/src/types/trade-in.ts | 4 + 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/frontend/src/components/trade-in/__tests__/thinMarketBadge.test.tsx diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index 0a0134e6..fe1d0ae9 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -10,7 +10,7 @@ * 14.08.2026 (блок 4.1): SHORT_ESTIMATE_DISCLAIMER — см. `lib/legal-copy.ts`. */ import { useState } from "react"; -import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState, ConfidenceLevel } from "@/types/trade-in"; +import type { AggregatedEstimate, AvitoImvSummary, TradeInEstimateInput, HouseType, RepairState, ConfidenceLevel } from "@/types/trade-in"; import { asHouseType, asRepairState } from "@/types/trade-in"; import { useActiveBrandSlug, useBrand } from "@/lib/useBrand"; import { sourceDot, sourcePublicLabel } from "@/lib/source-registry"; @@ -18,6 +18,22 @@ import { dealsAsOfLabel } from "@/lib/rosreestr"; import { SHORT_ESTIMATE_DISCLAIMER } from "@/lib/legal-copy"; import { HeroTransparency } from "./HeroTransparency"; +/** + * #3339 — подпись «тонкого рынка» под числом оценочной модели площадки. + * + * Бэкенд (thin_market, #audit-5b) при market_count ниже порога ИСКЛЮЧАЕТ IMV из + * headline — число остаётся справочным. Показывать его рядом с итогом молча — + * то же самое, что выдать не участвующую в расчёте величину за расчётную. + * null (нет флага / старая оценка без поля) → подписи нет. + */ +export function thinMarketNote(imv: AvitoImvSummary | null | undefined): string | null { + if (imv?.thin_market !== true) return null; + const n = imv.market_count; + if (n == null) return "тонкий рынок — в расчёт не входит"; + // После «из» счётная форма родительная у всех чисел, кроме единицы. + return `рынок из ${n} ${n === 1 ? "объявления" : "объявлений"} — в расчёт не входит`; +} + interface Props { estimate: AggregatedEstimate; input: TradeInEstimateInput; @@ -175,6 +191,8 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals imv && typeof imv.recommended_price === "number" && imv.recommended_price > 0 ? imv.recommended_price : null; + // #3339: число IMV показываем, но при тонком рынке честно называем его вне расчёта. + const imvThin = thinMarketNote(imv); // ── #652: коридор реальных ДКП-сделок (advisory). ₽/м² → млн через площадь. ── const dkp = estimate.dkp_corridor; const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0; @@ -537,6 +555,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{sourcePublicLabel("avito_imv")} {formatMln(imvPrice)} ₽ + {imvThin && {imvThin}}
)} {estimate.cian_valuation?.sale_price_rub != null && ( @@ -599,6 +618,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals style={{ marginTop: 6, fontSize: 12, color: "var(--muted)" }} > {sourcePublicLabel("avito_imv")}, оценочная модель: {formatMln(imvPrice)} ₽ + {imvThin ? ` · ${imvThin}` : ""} )} diff --git a/tradein-mvp/frontend/src/components/trade-in/__tests__/thinMarketBadge.test.tsx b/tradein-mvp/frontend/src/components/trade-in/__tests__/thinMarketBadge.test.tsx new file mode 100644 index 00000000..01c5d7f6 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/__tests__/thinMarketBadge.test.tsx @@ -0,0 +1,83 @@ +/** + * #3339 — число, исключённое из расчёта, обязано быть подписано. + * + * Бэкенд (#3336, thin_market) при market_count ниже порога не пускает IMV в + * headline: величина остаётся справочной. На витрине она всё равно стоит рядом + * с итоговой ценой — без пометки читается как участвующая в расчёте. + * + * Проверяется ЗНАЧЕНИЕ подписи (реальный N из market_count) и оба безопасных + * состояния: флаг false и полное отсутствие поля (оценка со старого бэкенда). + */ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { HeroSummary, thinMarketNote } from "../HeroSummary"; +import { FIXTURE_ESTIMATE, FIXTURE_INPUT } from "@/app/ui-preview/estimate/fixture"; +import type { AggregatedEstimate, AvitoImvSummary } from "@/types/trade-in"; + +// Бренд и блок прозрачности ходят в сеть (TanStack Query) — к подписи отношения +// не имеют, гасим, чтобы рендер hero был оффлайновым. +vi.mock("@/lib/useBrand", () => ({ + useActiveBrandSlug: () => null, + useBrand: () => ({ data: null }), +})); +vi.mock("../HeroTransparency", () => ({ HeroTransparency: () => null })); + +const IMV: AvitoImvSummary = { + recommended_price: 10_200_000, + lower_price: 9_400_000, + higher_price: 11_000_000, + market_count: 3, +}; + +function estimateWithImv(imv: AvitoImvSummary): AggregatedEstimate { + return { ...FIXTURE_ESTIMATE, avito_imv: imv }; +} + +function renderHero(imv: AvitoImvSummary) { + return render( + {}} + />, + ); +} + +describe("thin_market — подпись у числа оценочной модели площадки", () => { + it("thin_market=true, market_count=3 → подпись называет реальные 3 объявления", () => { + expect(thinMarketNote({ ...IMV, thin_market: true })).toBe( + "рынок из 3 объявлений — в расчёт не входит", + ); + + const { container } = renderHero({ ...IMV, thin_market: true }); + + // Число IMV осталось на экране — гасить его нечестно, оно посчитано. + expect(container.textContent).toContain("10,20 млн"); + // Но рядом с ним стоит N из market_count, а не общая формулировка. + expect(screen.getAllByText(/рынок из 3 объявлений — в расчёт не входит/).length) + .toBeGreaterThan(0); + }); + + it("thin_market=false → подписи нет (IMV участвует в расчёте)", () => { + expect(thinMarketNote({ ...IMV, thin_market: false })).toBeNull(); + + const { container } = renderHero({ ...IMV, thin_market: false }); + expect(container.textContent).toContain("10,20 млн"); + expect(container.textContent).not.toContain("в расчёт не входит"); + }); + + it("поля нет вовсе (оценка со старого бэкенда) → подписи нет", () => { + expect(thinMarketNote(IMV)).toBeNull(); + expect(thinMarketNote(null)).toBeNull(); + + const { container } = renderHero(IMV); + expect(container.textContent).not.toContain("в расчёт не входит"); + }); + + it("единственное объявление — счётная форма не ломается", () => { + expect(thinMarketNote({ ...IMV, market_count: 1, thin_market: true })).toBe( + "рынок из 1 объявления — в расчёт не входит", + ); + }); +}); diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 518adb4a..6bcbd92c 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -147,6 +147,10 @@ export interface AvitoImvSummary { lower_price: number | null; // нижняя граница IMV-коридора, ₽ higher_price: number | null; // верхняя граница IMV-коридора, ₽ market_count: number | null; // объём рынка, на котором построена оценка + // #3339 / backend #audit-5b: market_count < avito_imv_thin_market_threshold — + // IMV построен на малой выборке и в headline НЕ участвует (display-only). + // optional: оценки, посчитанные до деплоя гейта, поля не содержат. + thin_market?: boolean; } // ── #652: коридор реальных ДКП-сделок Росреестра (advisory, не клампит) ── From ecd6d5c91de1544f4bc8d284cf544e5b3a45d6bd Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 5 Sep 2026 22:48:37 +0500 Subject: [PATCH 06/13] =?UTF-8?q?test(auth):=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=8F=D1=82=D1=8C=20=D0=B2=D1=8B=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=20bcrypt=20=D0=BC=D0=B5=D1=85=D0=B0=D0=BD=D0=B8=D0=B7=D0=BC?= =?UTF-8?q?=D0=BE=D0=BC,=20=D0=B0=20=D0=BD=D0=B5=20=D1=81=D0=B5=D0=BA?= =?UTF-8?q?=D1=83=D0=BD=D0=B4=D0=BE=D0=BC=D0=B5=D1=80=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `probe_latencies[-1] < 0.5` мерил на общем раннере не свойство кода, а свободен ли CPU у соседей: 03.09 деплой Trade-In встал на «худший сторонний запрос 544мс» на коммите, который часом раньше прошёл на незанятой машине (#3343). Тест из #2712 проверял правильную вещь неправильной метрикой. Вердикт теперь дискретный: сверка пароля обязана исполниться НЕ в потоке событийного цикла (двойник bcrypt пишет `threading.get_ident()`). Загрузка раннера этого не подделает. Второй половиной остаётся счётчик тиков пробы: при bcrypt в `async def` цикл стоит всю секунду флуда и проба не успевает почти никогда, при выносе в пул успевает ~100 — порог 10 лежит посередине с запасом ×10 в обе стороны. Абсолютные латентности сохранены в тексте падения как диагностика, но не гейтят. Фальсификация: возврат `verify_password` из пула прямо в `async def` роняет тест на новом утверждении («сверка пароля исполнилась в потоке событийного цикла», `assert 8387963968 not in {8387963968}`), 5 прогонов подряд под load average 24 — зелёные. Closes #3343 --- tradein-mvp/backend/tests/test_auth_api.py | 45 +++++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/tradein-mvp/backend/tests/test_auth_api.py b/tradein-mvp/backend/tests/test_auth_api.py index 9436d1f7..ab5c5880 100644 --- a/tradein-mvp/backend/tests/test_auth_api.py +++ b/tradein-mvp/backend/tests/test_auth_api.py @@ -35,6 +35,7 @@ import asyncio import logging import os import re +import threading import time from datetime import UTC, datetime, timedelta from types import SimpleNamespace @@ -763,14 +764,19 @@ async def test_login_flood_capped_by_rate_while_api_stays_responsive( app = _build_test_app(store) attempts: list[float] = [] + verify_threads: set[int] = set() def _slow_verify(plain: str, hashed: str) -> bool: """Стенд-двойник bcrypt: столько же БЛОКИРУЮЩЕГО времени, только меньше. Блокирующий `time.sleep`, а не `await` — суть проблемы в том, что bcrypt не отпускает поток; двойник с `await` проверял бы не то. + + Заодно записывает ПОТОК исполнения: это и есть механизм выноса (#3343) — + величина дискретная, от загрузки раннера не зависящая. """ attempts.append(time.monotonic()) + verify_threads.add(threading.get_ident()) time.sleep(verify_s) return False @@ -824,19 +830,38 @@ async def test_login_flood_capped_by_rate_while_api_stays_responsive( # Проверяется ПЕРВЫМ: если цикл занят, встаёт и сам флуд, и тогда # остальные числа мерят не потолок, а паралич — их надо читать после # этого вердикта, а не вместо него. - assert probe_latencies, "проба не сделала ни одного запроса" + # + # Вердикт — про МЕХАНИЗМ, а не про секундомер (#3343). Раньше здесь стоял + # абсолютный порог латентности пробы (`худший < 500мс`, `медиана < 50мс`). + # На общем раннере он мерил не свойство кода, а свободен ли CPU у соседей: + # 03.09 деплой встал на «худший сторонний запрос 544мс» ровно на том + # коммите, который часом раньше прошёл на незанятой машине. Порог, который + # краснеет от чужой параллельной сборки, не отличает поломку от нагрузки, + # а красный обязан значить «значение неверно», иначе его начинают + # переспрашивать. Обе замены переживают любую загрузку: + # - ПОТОК, в котором исполнилась сверка, — величина дискретная. Вернись + # bcrypt в `async def` — здесь окажется поток цикла, и никакой простой + # раннера этого не замаскирует и не подделает; + # - ТИКИ пробы: цикл с bcrypt внутри стоит практически всю секунду + # флуда (сверки идут подряд по 50мс), и проба не успевает почти + # никогда; при выносе в пул она просыпается раз в 10мс, то есть ~100 + # раз. Порог 10 лежит в середине этого разрыва с запасом ×10 в обе + # стороны — на общем раннере съедается пропускная способность, но не + # порядок величины. + # Сами латентности остаются в сообщении как ДИАГНОСТИКА: числа полезны, + # когда тест красный, и не годятся в вердикт, пока машина общая. + assert verify_threads, "ни одной сверки пароля не состоялось — мерить нечего" + assert threading.get_ident() not in verify_threads, ( + "сверка пароля исполнилась в потоке событийного цикла — bcrypt держит весь " + "API на всё время проверки (вынос в пул из #2665 отменён)" + ) + assert probe_latencies, "проба не сделала ни одного запроса — цикл был занят" probe_latencies.sort() - assert probe_latencies[-1] < 0.5, ( - f"худший сторонний запрос {probe_latencies[-1] * 1000:.0f}мс — API встаёт под флудом входа" - ) median_probe = probe_latencies[len(probe_latencies) // 2] - assert median_probe < verify_s, ( - f"медиана стороннего запроса {median_probe * 1000:.0f}мс ≥ времени одной " - f"сверки — цикл занят проверкой пароля, API стоит" - ) - # Мало проб за секунду — тоже занятый цикл: проба просыпается раз в 10мс. assert len(probe_latencies) >= 10, ( - f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят" + f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с " + f"(медиана {median_probe * 1000:.0f}мс, худший {probe_latencies[-1] * 1000:.0f}мс) " + f"— цикл был занят" ) # 2. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает From d177b916e34657ba890b326090498a2383bfd082 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 5 Sep 2026 22:48:54 +0500 Subject: [PATCH 07/13] =?UTF-8?q?fix(caddy):=20=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D0=B0=20=C2=AB=D1=81=D0=B5=D1=80=D0=B2=D0=B8?= =?UTF-8?q?=D1=81=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D1=8F=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=C2=BB=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D0=B3=D0=BE=D0=BB=D0=BE=D0=B3=D0=BE=20502=20=D0=B2=20=D0=BE?= =?UTF-8?q?=D0=BA=D0=BD=D0=B5=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Каждый мерж в tradein оставляет 30-90 с, в которые контейнера нет и Caddy набирает несуществующий апстрим: 15 x 502 на meraocenka.ru и 23 на gendsgn.ru за 4 суток, все на окнах деплоя (duration < 2 мс = мгновенный отказ соединения, а не перегрузка). handle_errors 502/503/504 подменяет это на 503 + Retry-After: 30 и читаемое тело. 502 читается как постоянная поломка — поисковик выкидывает страницу из индекса, клиенты не ретраят; 503 + Retry-After означает ровно то, что происходит. Для /trade-in/api/* тело JSON, а не HTML: эти ручки вызывают из JS и внешних клиентов, HTML у них превращается в ошибку разбора. На gendsgn.ru перехват гейтится по пути /trade-in* — Site Finder («Птица») деплоится отдельным пайплайном и в задачу не входит; вне матчера ошибка остаётся необработанной и поведение прежнее. Окно простоя это НЕ убирает — zero-downtime деплой (п.1 issue) требует решения владельца. Меняется только то, что видно внутри окна. Refs #3274 --- caddy/deploy-window.caddy.snippet | 54 +++++++++++++++++++++++++++++++ caddy/sites/apps.caddy | 39 ++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 caddy/deploy-window.caddy.snippet diff --git a/caddy/deploy-window.caddy.snippet b/caddy/deploy-window.caddy.snippet new file mode 100644 index 00000000..971464de --- /dev/null +++ b/caddy/deploy-window.caddy.snippet @@ -0,0 +1,54 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# caddy/deploy-window.caddy.snippet — ответ на окно деплоя (#3274) +# +# Импортируется ВНУТРЬ `handle_errors 502 503 504 { ... }` (см. apps.caddy): +# сам по себе снипет ничего не перехватывает, он только решает, ЧТО отдать, +# когда апстрим не отвечает. +# +# ЧТО ЭТО ЛЕЧИТ, А ЧТО НЕТ. Каждый деплой tradein-frontend/tradein-backend +# оставляет окно 30–90 с, в котором контейнера просто нет: Caddy набирает +# новый апстрим сразу, тот ещё не слушает (замер по access-логам, #3274 — +# все 502 кластеризуются на окнах мержа, duration < 2 мс = мгновенный отказ +# соединения). Снипет НЕ УБИРАЕТ окно — он меняет то, что видит человек и +# клиент внутри окна. Настоящее лечение (готовность нового контейнера до +# переключения) — п.1 issue, решение владельца, здесь его нет. +# +# ПОЧЕМУ 503, А НЕ 502. 502 значит «апстрим ответил мусором» — постоянная +# поломка; поисковик по нему выкидывает страницу из индекса, клиентские +# библиотеки не ретраят. 503 + `Retry-After: 30` — стандартный код «временно +# недоступен, приходи через 30 секунд»: Googlebot держит страницу в индексе, +# HTTP-клиенты понимают, что повтор осмыслен. +# +# ПОЧЕМУ ДВА ТЕЛА. `/trade-in/api/*` вызывают из JS и внешних клиентов — они +# парсят JSON, и HTML-страница у них превращается в ошибку разбора вместо +# читаемого статуса. Всё остальное открывает человек браузером. +# +# ВНЕШНИХ РЕСУРСОВ В СТРАНИЦЕ НЕТ ВООБЩЕ — ни шрифта, ни CSS-файла, ни +# картинки. В окне деплоя они пришли бы с того же мёртвого апстрима, и +# страница-заглушка отрисовалась бы голым текстом. Отсюда же инлайновые +# `style=` вместо блока `