Date: Mon, 10 Aug 2026 14:32:25 +0500
Subject: [PATCH 05/27] =?UTF-8?q?fix(tradein/avito):=20=D1=80=D0=B5=D0=B9?=
=?UTF-8?q?=D1=82=D0=B8=D0=BD=D0=B3=20=D0=B4=D0=BE=D0=BC=D0=B0=20=D0=BF?=
=?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=81=D1=82=D0=B0=D1=91=D1=82=20=D1=83=D0=B5?=
=?UTF-8?q?=D0=B7=D0=B6=D0=B0=D1=82=D1=8C=20=D0=B2=20=D0=B0=D0=B4=D1=80?=
=?UTF-8?q?=D0=B5=D1=81=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD?=
=?UTF-8?q?=D0=B8=D1=8F=20(#2814)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Авито с 27.07 рендерит рейтинг и число отзывов внутри того же в
data-marker="item-location", откуда serp.py берёт адрес: «ул. Ткачей,17·5,0 · 4
отзыва». Прод 2026-08-10: 1 123 активных объявления с таким адресом, и у 1 123 из
1 123 нет координат — доля 100%, 711 из них геокодер уже пробовал. Контроль в тех
же данных: у чистых адресов координаты есть у 4 766 из 5 715 (83%).
Строка без geom молча выпадает из comp-пула: Tier W отбирает через ST_DWithin, а
NULL не проходит предикат и нигде не считается. 2 446 из 3 270 безкоординатных
попадают в свежий пул аналогов — 12.7% аналогов невидимы радиусному поиску.
Режем по «·», за которой идёт ЦИФРА (рейтинг «·4,9», счётчик «·2 отзыва»). По
любой «·» нельзя: разделитель района пишется «, 59 · р-н Академический» — за
точкой буква, и этот хвост _deglue_house_marker намеренно сохраняет (#1773).
Тест проверяет обе стороны плюс три прежних хвоста (CSS, метро, «от N мин.»).
Чинит только новые вставки: base.py пишет address = COALESCE(listings.address,
EXCLUDED.address), адрес при конфликте не перезаписывается осознанно (#2777).
Бэкфилл 1 123 существующих строк вынесен в #2814 для database-expert.
---
.../test_avito_clean_address_rating_tail.py | 59 +++++++++++++++++++
.../src/scraper_kit/providers/avito/serp.py | 10 +++-
2 files changed, 68 insertions(+), 1 deletion(-)
create mode 100644 tradein-mvp/backend/tests/scrapers/test_avito_clean_address_rating_tail.py
diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_clean_address_rating_tail.py b/tradein-mvp/backend/tests/scrapers/test_avito_clean_address_rating_tail.py
new file mode 100644
index 00000000..2e9da4bf
--- /dev/null
+++ b/tradein-mvp/backend/tests/scrapers/test_avito_clean_address_rating_tail.py
@@ -0,0 +1,59 @@
+"""Адрес Авито не должен утаскивать за собой рейтинг дома и число отзывов.
+
+Прод 2026-08-10: 1 123 активных объявления Авито с адресом вида
+«ул. Ткачей,17·5,0 · 4 отзыва», и у 1 123 из 1 123 нет координат — доля 100%.
+У объявлений с чистым адресом координаты есть у 4 766. Хвост появился 27.07.2026,
+когда Авито начал рендерить рейтинг внутри того же
в item-location.
+"""
+
+from __future__ import annotations
+
+import pytest
+from scraper_kit.providers.avito.serp import _clean_address
+
+
+@pytest.mark.parametrize(
+ ("raw", "expected"),
+ [
+ # Живые строки с прода — рейтинг + счётчик отзывов.
+ ("Авиационная ул.,10·4,9 · 11 отзывов", "Авиационная ул.,10"),
+ ("ул. 8 Марта,204Г/2·4,3 · 3 отзыва", "ул. 8 Марта,204Г/2"),
+ ("ул. Академика Шварца,18к2·1 отзыв", "ул. Академика Шварца,18к2"),
+ # Только счётчик, без рейтинга.
+ ("ул. Фурманова,59·2 отзыва", "ул. Фурманова,59"),
+ # Пробелы вокруг разделителя.
+ ("ул. Ткачей, 17 · 5,0 · 4 отзыва", "ул. Ткачей, 17"),
+ ],
+)
+def test_rating_tail_stripped(raw: str, expected: str) -> None:
+ assert _clean_address(raw) == expected
+
+
+def test_district_marker_after_dot_survives() -> None:
+ """Режем «·» только перед цифрой — район за точкой остаётся на месте.
+
+ Иначе фикс рейтинга съел бы разделитель района, который _deglue_house_marker
+ намеренно восстанавливает (#1773).
+ """
+ assert (
+ _clean_address("улица Вильгельма де Геннина, 59 · р-н Академический")
+ == "улица Вильгельма де Геннина, 59 · р-н Академический"
+ )
+
+
+def test_clean_address_unchanged() -> None:
+ assert _clean_address("с. Новоалексеевское, ул. 8 Марта,35") == (
+ "с. Новоалексеевское, ул. 8 Марта,35"
+ )
+
+
+def test_existing_noise_still_stripped() -> None:
+ """Регрессия: старые хвосты (CSS/метро/«от N мин.») режутся как раньше."""
+ assert _clean_address("ул. Токарей, 56к1Площадь 1905 года.css-39hgr0{fill:red}") == (
+ "ул. Токарей, 56к1"
+ )
+ assert _clean_address("ул. Малышева, 1.css-xxx{color:blue}") == "ул. Малышева, 1"
+ # #1773: слипшийся маркер района по-прежнему расклеивается.
+ assert _clean_address("ул. Евгения Савкова, 29р-н Академический") == (
+ "ул. Евгения Савкова, 29, р-н Академический"
+ )
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py
index 484403c3..3c3d717a 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py
@@ -2097,8 +2097,16 @@ def _avito_room_label(name: str) -> str:
_CSS_NOISE_RE = re.compile(r"\.?css-[a-z0-9_-]+\s*\{[^}]*\}", flags=re.I)
+# Хвост «·<цифра>» — рейтинг дома и число отзывов, которые Авито с 27.07.2026 рендерит
+# ВНУТРИ того же
в data-marker="item-location": «ул. Ткачей,17·5,0 · 4 отзыва».
+# Прод 2026-08-10: 1 123 активных объявления с таким адресом, и у 1 123 из 1 123 нет
+# координат (доля 100%) — ни один геокодер такую строку не берёт. Для сравнения, у
+# объявлений с чистым адресом координаты есть у 4 766.
+# Режем по «·», ЗА КОТОРОЙ ИДЁТ ЦИФРА (рейтинг «·4,9» либо счётчик «·2 отзыва»), а не
+# по любой «·»: разделитель района у площадок пишется как «, 59 · р-н Академический» —
+# за точкой буква, и такой хвост _deglue_house_marker намеренно сохраняет.
_NOT_ADDRESS_TAIL_RE = re.compile(
- r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+)",
+ r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d)",
flags=re.I,
)
From a227877905515b86e7a95bfd4de42ba3b2fa164f Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Mon, 10 Aug 2026 09:36:23 +0000
Subject: [PATCH 06/27] =?UTF-8?q?fix(tradein/scraper):=20=D0=BF=D1=80?=
=?UTF-8?q?=D0=BE=D0=B3=D0=BE=D0=BD,=20=D1=83=20=D0=BA=D0=BE=D1=82=D0=BE?=
=?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=BE=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D0=B0?=
=?UTF-8?q?=D0=BB=20=D0=BA=D0=B0=D0=B6=D0=B4=D1=8B=D0=B9=20=D1=8F=D0=BA?=
=?UTF-8?q?=D0=BE=D1=80=D1=8C,=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82?=
=?UTF-8?q?=D0=B0=D1=91=D1=82=20=D0=B1=D1=8B=D1=82=D1=8C=20=D1=83=D1=81?=
=?UTF-8?q?=D0=BF=D0=B5=D1=85=D0=BE=D0=BC=20(#2625)=20(#2813)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../backend/app/services/scrape_runs.py | 57 ++++++
.../tests/test_2625_run_that_did_nothing.py | 188 ++++++++++++++++++
.../src/scraper_kit/orchestration/runs.py | 57 ++++++
3 files changed, 302 insertions(+)
create mode 100644 tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py
diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py
index c83f178e..c7c887d5 100644
--- a/tradein-mvp/backend/app/services/scrape_runs.py
+++ b/tradein-mvp/backend/app/services/scrape_runs.py
@@ -179,6 +179,52 @@ def _warn_source_has_no_result_metric(source: str, keys: tuple[str, ...]) -> Non
)
+def _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None:
+ """Развёртка, у которой КАЖДЫЙ якорь кончился отказом и не принесла ничего (#2625).
+
+ Возвращает текст причины (для error) либо None, если прогон таким не является.
+
+ Третий исход, у которого не было терминального статуса. Развёртка различает:
+ 1. «площадка отбила» — попытки разбора были, структура не извлеклась ни разу →
+ `mark_banned` в самих sweep'ах (#2642, cian/yandex);
+ 2. «площадка честно отдала пустоту» — валидный ответ, ноль предложений →
+ `done` с нулём, это здоровый результат (в Серове реально 10 объявлений);
+ 3. «мы не дошли» — якорь упал по таймауту или исключению ДО того, как
+ что-либо стало разбирать. Ровно этот случай в счётчики бана не попадает
+ НАМЕРЕННО (#2600 п.1: transport_error не должен выглядеть баном площадки),
+ и статуса ему никто не выдал — прогон уходил в `done`.
+
+ Признак — собственная бухгалтерия прогона, а не список известных антибот-маркеров:
+ `errors_count >= anchors_total` при нулевом ИЗМЕРЕННОМ результате означает, что
+ отказом кончился каждый якорь, который у прогона был, и собрано ноль. Это НЕ
+ доказывает, КТО виноват (капча площадки / наш прокси / наш баг), поэтому статус
+ 'failed' без диагноза, а не 'banned' с 'platform' (#2764: диагноз не назначается
+ по умолчанию).
+
+ Что признак НЕ ловит: прогон, где часть якорей отдала данные, а часть отказала —
+ `errors_count < anchors_total`, статус остаётся 'done' (частичный сбор — сбор).
+
+ Замер на проде 2026-08-10 за 90 суток: под правило попадают 28 прогонов
+ (yandex_city_sweep_nizhniy_tagil 16 подряд по 15-30.07 — каждый ровно 240 с,
+ таймаут якоря, 0 лотов, 'done'; yandex_city_sweep 6; avito_city_sweep 5;
+ yandex_city_sweep_pervouralsk 1 от 09.08 — 155 мс, исключение до первого запроса).
+ НЕ затронуты: 132 прогона с отказами, но ненулевым сбором, и 37 прогонов честной
+ пустоты (errors_count=0) — они остаются 'done'.
+ """
+ anchors = _pick_int(counters, "anchors_total")
+ errors = _pick_int(counters, "errors_count")
+ if not anchors or anchors <= 0 or errors is None or errors < anchors:
+ return None
+ if _run_result_count(counters) != 0: # None (не измерено) сюда тоже НЕ попадает
+ return None
+ return (
+ f"sweep-honest-status: отказом кончились все {anchors} якорей прогона "
+ f"(errors_count={errors}), собрано 0 — работа не сделана. Причина НЕ "
+ f"установлена: якорь мог упасть по таймауту, из-за нашего прокси или "
+ f"блокировкой площадки — статус 'failed' без диагноза (#2625)"
+ )
+
+
def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
@@ -445,7 +491,18 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся
в выделенные колонки — иначе admin/observability показывает 0 (audit #1926).
+
+ #2625: сюда же сведён отказ называть успехом прогон, у которого отказом кончился
+ каждый якорь и собрано ноль — см. _sweep_run_did_nothing. Проверка стоит здесь, а
+ не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка:
+ страж, который надо не забыть позвать, — это тот же дефект оборванной проводки,
+ из-за которого задача и появилась.
"""
+ did_nothing = _sweep_run_did_nothing(counters)
+ if did_nothing is not None:
+ logger.error("%s run_id=%d", did_nothing, run_id)
+ mark_failed(db, run_id, did_nothing, counters)
+ return
total_seen, new_count = _column_counts(counters)
row = db.execute(
text(
diff --git a/tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py b/tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py
new file mode 100644
index 00000000..9dd3b3b0
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py
@@ -0,0 +1,188 @@
+"""#2625: прогон, у которого отказом кончился каждый якорь, перестаёт быть 'done'.
+
+Задача заводилась про капчу Циана и пустые выдачи Яндекса. Основную её часть закрыл
+#2642 (детект провала извлечения структуры → 'banned'). Замер на проде 2026-08-10
+подтверждает эффект симптоматически: развёртки cian/yandex, статус 'done' с нулём —
+42 из 89 до деплоя #2642 (04.08 19:00 UTC) и 1 из 30 после.
+
+Этот единственный оставшийся — прогон 3557 (`yandex_city_sweep_pervouralsk`,
+09.08 17:11): 155 миллисекунд, `{"anchors_total": 1, "anchors_done": 1,
+"errors_count": 1, "lots_fetched": 0}`, статус 'done'. И он не новый: за 90 суток под
+тот же признак попадают 28 прогонов, включая `yandex_city_sweep_nizhniy_tagil` —
+16 суток подряд (15-30.07), каждый ровно 240 секунд (таймаут якоря), ноль лотов,
+'done' каждый раз.
+
+Почему детект #2642 их не видит — и правильно не видит. Он считает попытки РАЗБОРА
+(`_track_gate_result`), а transport_error туда НАМЕРЕННО не попадает (#2600 п.1:
+«наш прокси сдох» не должен выглядеть баном площадки). Якорь, упавший по таймауту
+или исключению до первого разобранного ответа, даёт `attempts == 0`, условие
+`attempts > 0 and failures == attempts` молчит — и прогон уходит в 'done'.
+
+То есть третий исход существовал, но терминального статуса у него не было:
+ * площадка отбила → 'banned' (#2642, есть);
+ * площадка честно пуста → 'done' (есть, и это здоровый ответ);
+ * мы не дошли → ??? → 'done' ← дефект.
+
+Признак намеренно НЕ опирается на список антибот-маркеров: маркеры объясняют уже
+случившийся отказ и молчат про неизвестный (урок 09.08, #2798). Здесь считается
+собственная бухгалтерия прогона — `errors_count >= anchors_total` при измеренном
+нуле. Что она доказывает: каждый якорь кончился отказом и собрано ноль. Чего НЕ
+доказывает: кто виноват. Поэтому 'failed' без ban_kind, а не 'banned'/'platform'.
+"""
+
+from __future__ import annotations
+
+import os
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+from scraper_kit.orchestration import runs as kit_runs
+
+from app.services import scrape_runs as app_runs
+
+_MODULES = {"kit": kit_runs, "app": app_runs}
+
+# Реальные counters с прода (2026-08-10), не выдуманные.
+PROD_3557_PERVOURALSK = {
+ "anchors_done": 1,
+ "errors_count": 1,
+ "lots_fetched": 0,
+ "lots_updated": 0,
+ "anchors_total": 1,
+ "lots_inserted": 0,
+ "address_failed": 0,
+ "combos_skipped": 0,
+ "address_enriched": 0,
+ "address_attempted": 0,
+ "price_history_rows": 0,
+}
+# Тот же source, прогон 3320 от 06.08 — отказ якоря БЫЛ (errors_count=1), но 117 лотов
+# собрано. Частичный сбор — сбор, статус обязан остаться 'done'.
+PROD_3320_PARTIAL = {**PROD_3557_PERVOURALSK, "lots_fetched": 117, "lots_inserted": 63}
+# Прогон 2930 (cian_city_sweep_serov, 02.08): ноль лотов БЕЗ единого отказа —
+# это либо честная пустота, либо недетект капчи; и то и другое — не наша тема,
+# капча — предмет #2642, а честная пустота обязана оставаться 'done'.
+PROD_2930_HONEST_EMPTY = {**PROD_3557_PERVOURALSK, "errors_count": 0}
+
+
+def _capture_status(mod: Any, counters: dict[str, int]) -> list[str]:
+ """Прогнать mark_done на фейковой сессии, вернуть статусы всех UPDATE'ов.
+
+ Читаем СТАТУС В SQL, а не имя вызванной функции: тест должен краснеть на
+ поведении финализатора, а не на отсутствии нового имени в старом коде.
+ """
+ statuses: list[str] = []
+
+ def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
+ sql = str(stmt)
+ for status in ("done", "failed", "banned"):
+ if f"status = '{status}'" in sql:
+ statuses.append(status)
+ return MagicMock()
+
+ db = MagicMock()
+ db.execute.side_effect = _execute
+ # sentry заглушаем: алерт-хуки best-effort и к предмету теста отношения не имеют.
+ with patch.object(mod, "sentry_sdk", MagicMock()):
+ mod.mark_done(db, 3557, dict(counters))
+ return statuses
+
+
+# ── 1. Три исхода разведены ──────────────────────────────────────────────────
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+def test_all_anchors_failed_zero_lots_is_not_done(name: str) -> None:
+ """Прод-прогон 3557: все якоря отказали, собрано 0 → 'failed', НЕ 'done'.
+
+ Красный на старом коде: mark_done писал status='done'.
+ """
+ assert _capture_status(_MODULES[name], PROD_3557_PERVOURALSK) == ["failed"]
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+def test_partial_harvest_stays_done(name: str) -> None:
+ """Прод-прогон 3320: отказ якоря был, но 117 лотов собрано → остаётся 'done'."""
+ assert _capture_status(_MODULES[name], PROD_3320_PARTIAL) == ["done"]
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+def test_honest_empty_stays_done(name: str) -> None:
+ """Ноль лотов без единого отказа — честная пустота, 'done' (ложной тревоги нет)."""
+ assert _capture_status(_MODULES[name], PROD_2930_HONEST_EMPTY) == ["done"]
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+def test_failed_run_carries_no_ban_diagnosis(name: str) -> None:
+ """Причина не установлена → ban_kind не пишется вовсе (#2764).
+
+ 'banned' с диагнозом означал бы «нас забанила площадка» — а мы знаем только,
+ что якоря отказали. Ротацию IP (#2611) на догадке дёргать нельзя.
+ """
+ mod = _MODULES[name]
+ sqls: list[str] = []
+
+ def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
+ sqls.append(str(stmt))
+ return MagicMock()
+
+ db = MagicMock()
+ db.execute.side_effect = _execute
+ with patch.object(mod, "sentry_sdk", MagicMock()):
+ mod.mark_done(db, 3557, dict(PROD_3557_PERVOURALSK))
+
+ assert not any("ban_kind" in s for s in sqls), "статус не должен нести диагноз"
+ assert any("status = 'failed'" in s for s in sqls)
+
+
+# ── 2. Классификатор: границы, на которых легко получить ложную тревогу ──────
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+@pytest.mark.parametrize(
+ ("counters", "flagged", "why"),
+ [
+ ({"anchors_total": 1, "errors_count": 1, "lots_fetched": 0}, True, "1 из 1 отказал"),
+ ({"anchors_total": 5, "errors_count": 5, "lots_fetched": 0}, True, "5 из 5 (avito ЕКБ)"),
+ ({"anchors_total": 5, "errors_count": 1, "lots_fetched": 0}, False, "1 из 5 — не все"),
+ ({"anchors_total": 5, "errors_count": 5, "lots_fetched": 12}, False, "собрано 12"),
+ ({"anchors_total": 1, "errors_count": 0, "lots_fetched": 0}, False, "честная пустота"),
+ # full-load'ы пишут unique_fetched, а не lots_fetched — тот же смысл.
+ ({"anchors_total": 2, "errors_count": 2, "unique_fetched": 0}, True, "full-load ноль"),
+ ({"anchors_total": 2, "errors_count": 2, "unique_fetched": 340}, False, "full-load сбор"),
+ # Результат НЕ измерен — судить нечем, ноль не выдумывается (#2703).
+ ({"anchors_total": 1, "errors_count": 1}, False, "результата в counters нет"),
+ # Якорей нет вовсе: это не развёртка, чужой словарь счётчиков не трогаем.
+ ({"errors_count": 9, "lots_fetched": 0}, False, "не развёртка"),
+ ({"attempted": 5, "enriched": 0, "failed": 5}, False, "detail-backfill, чужой словарь"),
+ ({}, False, "пустые counters"),
+ ({"anchors_total": 0, "errors_count": 0, "lots_fetched": 0}, False, "нуль якорей"),
+ ],
+)
+def test_classifier_boundaries(
+ name: str, counters: dict[str, Any], flagged: bool, why: str
+) -> None:
+ reason = _MODULES[name]._sweep_run_did_nothing(counters)
+ assert (reason is not None) is flagged, why
+ if flagged:
+ assert "#2625" in (reason or "")
+
+
+# ── 3. Ровно те 16 суток, что прод прожил молча ──────────────────────────────
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+def test_sixteen_silent_tagil_runs_would_have_been_failed(name: str) -> None:
+ """15-30.07: 16 прогонов `yandex_city_sweep_nizhniy_tagil` по 240 с, 0 лотов.
+
+ Каждый отчитался 'done'. Ни один алерт их не поднял: `_alert_if_consecutive_failures`
+ считает только failed/banned. С этой правкой все 16 — 'failed', то есть первый же
+ из них попадает в лестницу вех failed-стрика (#2670).
+ """
+ tagil = {"anchors_total": 1, "anchors_done": 1, "errors_count": 1, "lots_fetched": 0}
+ statuses = [_capture_status(_MODULES[name], tagil) for _ in range(16)]
+ assert statuses == [["failed"]] * 16
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py
index 1389e9b4..108a7891 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py
@@ -174,6 +174,52 @@ def _warn_source_has_no_result_metric(source: str, keys: tuple[str, ...]) -> Non
)
+def _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None:
+ """Развёртка, у которой КАЖДЫЙ якорь кончился отказом и не принесла ничего (#2625).
+
+ Возвращает текст причины (для error) либо None, если прогон таким не является.
+
+ Третий исход, у которого не было терминального статуса. Развёртка различает:
+ 1. «площадка отбила» — попытки разбора были, структура не извлеклась ни разу →
+ `mark_banned` в самих sweep'ах (#2642, cian/yandex);
+ 2. «площадка честно отдала пустоту» — валидный ответ, ноль предложений →
+ `done` с нулём, это здоровый результат (в Серове реально 10 объявлений);
+ 3. «мы не дошли» — якорь упал по таймауту или исключению ДО того, как
+ что-либо стало разбирать. Ровно этот случай в счётчики бана не попадает
+ НАМЕРЕННО (#2600 п.1: transport_error не должен выглядеть баном площадки),
+ и статуса ему никто не выдал — прогон уходил в `done`.
+
+ Признак — собственная бухгалтерия прогона, а не список известных антибот-маркеров:
+ `errors_count >= anchors_total` при нулевом ИЗМЕРЕННОМ результате означает, что
+ отказом кончился каждый якорь, который у прогона был, и собрано ноль. Это НЕ
+ доказывает, КТО виноват (капча площадки / наш прокси / наш баг), поэтому статус
+ 'failed' без диагноза, а не 'banned' с 'platform' (#2764: диагноз не назначается
+ по умолчанию).
+
+ Что признак НЕ ловит: прогон, где часть якорей отдала данные, а часть отказала —
+ `errors_count < anchors_total`, статус остаётся 'done' (частичный сбор — сбор).
+
+ Замер на проде 2026-08-10 за 90 суток: под правило попадают 28 прогонов
+ (yandex_city_sweep_nizhniy_tagil 16 подряд по 15-30.07 — каждый ровно 240 с,
+ таймаут якоря, 0 лотов, 'done'; yandex_city_sweep 6; avito_city_sweep 5;
+ yandex_city_sweep_pervouralsk 1 от 09.08 — 155 мс, исключение до первого запроса).
+ НЕ затронуты: 132 прогона с отказами, но ненулевым сбором, и 37 прогонов честной
+ пустоты (errors_count=0) — они остаются 'done'.
+ """
+ anchors = _pick_int(counters, "anchors_total")
+ errors = _pick_int(counters, "errors_count")
+ if not anchors or anchors <= 0 or errors is None or errors < anchors:
+ return None
+ if _run_result_count(counters) != 0: # None (не измерено) сюда тоже НЕ попадает
+ return None
+ return (
+ f"sweep-honest-status: отказом кончились все {anchors} якорей прогона "
+ f"(errors_count={errors}), собрано 0 — работа не сделана. Причина НЕ "
+ f"установлена: якорь мог упасть по таймауту, из-за нашего прокси или "
+ f"блокировкой площадки — статус 'failed' без диагноза (#2625)"
+ )
+
+
def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
@@ -510,7 +556,18 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся
в выделенные колонки — иначе admin/observability показывает 0 (audit #1926).
+
+ #2625: сюда же сведён отказ называть успехом прогон, у которого отказом кончился
+ каждый якорь и собрано ноль — см. _sweep_run_did_nothing. Проверка стоит здесь, а
+ не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка:
+ страж, который надо не забыть позвать, — это тот же дефект оборванной проводки,
+ из-за которого задача и появилась.
"""
+ did_nothing = _sweep_run_did_nothing(counters)
+ if did_nothing is not None:
+ logger.error("%s run_id=%d", did_nothing, run_id)
+ mark_failed(db, run_id, did_nothing, counters)
+ return
total_seen, new_count = _column_counts(counters)
row = db.execute(
text(
From 405d2f2eeccf3e44f636ac6f19ce19e8d891032b Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Mon, 10 Aug 2026 10:22:33 +0000
Subject: [PATCH 07/27] =?UTF-8?q?fix(tradein/db):=20=D1=80=D0=B0=D0=B7?=
=?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=8F=20=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0?=
=?UTF-8?q?=201123=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=BE=D0=B2=20=D0=90?=
=?UTF-8?q?=D0=B2=D0=B8=D1=82=D0=BE=20=D1=81=20=D0=BF=D1=80=D0=B8=D0=BA?=
=?UTF-8?q?=D0=BB=D0=B5=D0=B5=D0=BD=D0=BD=D1=8B=D0=BC=20=D1=80=D0=B5=D0=B9?=
=?UTF-8?q?=D1=82=D0=B8=D0=BD=D0=B3=D0=BE=D0=BC=20(#2814)=20(#2818)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...gs_backfill_avito_rating_glued_address.sql | 155 ++++++++++++++++++
.../backend/data/sql/_manifest_applied.txt | 1 +
2 files changed, 156 insertions(+)
create mode 100644 tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql
diff --git a/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql b/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql
new file mode 100644
index 00000000..61120223
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql
@@ -0,0 +1,155 @@
+-- 254_listings_backfill_avito_rating_glued_address.sql
+-- Разовая чистка адресов Авито, в которые уехал рейтинг дома (#2814).
+--
+-- WHY. С 27.07.2026 Авито рендерит рейтинг дома и число отзывов ВНУТРИ того же
+-- в data-marker="item-location", откуда serp.py берёт адрес: «ул. Ткачей,17·5,0 · 4
+-- отзыва». Парсер починен в #2815 (merged, прод-verified 2026-08-10 09:57 UTC), но
+-- УЖЕ ЗАПИСАННЫЕ строки сами не вылечатся: апсерт пишет
+-- `address = COALESCE(listings.address, EXCLUDED.address)` (base.py:614) — при
+-- конфликте адрес осознанно НЕ перезаписывается (#2777: свежий сырой адрес от
+-- площадки откатил бы чистку миграций 062/108/124). Эта миграция — единственный
+-- путь, которым старые строки могут стать чистыми.
+--
+-- ЗАМЕР НА ПРОДЕ 2026-08-10, после деплоя #2815 (не «по релиз-метке», а по данным):
+--
+-- класс адреса (source='avito', is_active) строк с координатами
+-- ------------------------------------------ ------ --------------
+-- чистый 7892 6111 (77.4%)
+-- загрязнён рейтингом (address ~ '·\s*\d') 1123 0 (0.0%)
+-- NULL 360 0 (0.0%)
+--
+-- 1123 не изменились после деплоя парсера — ни одной из этих строк свип не касался
+-- с 09:57 (max(last_seen_at) = 2026-08-09 16:53), и не коснётся с толком: COALESCE.
+-- Все 1123 — source='avito', все is_active. Других источников с таким хвостом нет.
+-- Цена простоя: строка без geom молча выпадает из радиусного отбора аналогов
+-- (Tier W, ST_DWithin — NULL не проходит предикат и нигде не считается).
+--
+-- ПРАВИЛО РЕЗКИ — ДОСЛОВНО ПАРСЕРНОЕ, не изобретённое здесь.
+-- providers/avito/serp.py: _NOT_ADDRESS_TAIL_RE = re.compile(
+-- r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d)", flags=re.I)
+-- _clean_address: split(maxsplit=1)[0] → _deglue_house_marker → strip(" ,.\n\t")
+-- → `return cleaned or None`.
+-- Ниже — тот же альтернатив-набор, флаг 'i' = flags=re.I, `.*$` + regexp_replace =
+-- взять текст ДО первого совпадения (обе реализации leftmost), тот же набор символов
+-- в trim, NULLIF(...,'') = `or None`.
+-- Ключевая тонкость (#1773): резать по «·» можно ТОЛЬКО когда за ней идёт ЦИФРА.
+-- За буквой идёт район — «улица Бебеля, 138 · р-н Железнодорожный», и этот хвост
+-- сохраняется намеренно. На проде таких строк 296, и они обязаны остаться целыми
+-- (проверено в dry-run: 296 до = 296 после).
+-- _deglue_house_marker в SQL НЕ повторяется — замерено, что он здесь no-op: после
+-- резки хвоста ни одна из 1123 строк не содержит слипшегося «29р-н» (0 совпадений
+-- паттерном _DEGLUE_RE). Повторять в SQL лукахеды ради нуля строк незачем.
+--
+-- ПАРИТЕТ ПРОВЕРЕН ТЕМ ЖЕ КОДОМ, А НЕ ПО ГЛАЗАМ. Все 1123 сырых адреса выгружены с
+-- прода и прогнаны через ЖИВОЙ парсер в боевом контейнере:
+-- docker exec tradein-scraper python /tmp/m2814-parity.py
+-- → rows=1123 mismatches=0
+-- т.е. SQL-выражение ниже даёт побайтово то же, что `_clean_address` в проде.
+--
+-- DRY-RUN НА ПРОДЕ (BEGIN … ROLLBACK, 2026-08-10):
+-- UPDATE 1123 · осталось загрязнённых 0 · районных «·» сохранено 296/296
+-- ул. Ткачей,17·5,0 · 4 отзыва → ул. Ткачей,17
+-- ул. Свердлова,32Б·4,2 · 5 отзывов → ул. Свердлова,32Б
+-- ул. Щорса,103·4,3 · 15 отзывов → ул. Щорса,103
+-- Уральская ул.,5·4,8 · 15 отзывов → Уральская ул.,5
+-- Селькоровская ул.,60·5,0 · 3 отзыва → Селькоровская ул.,60
+-- ул. Азина,22/2·4,6 · 17 отзывов → ул. Азина,22/2
+-- ул. 8 Марта,204Г/2·4,3 · 3 отзыва → ул. 8 Марта,204Г/2
+-- жилой район Сортировочный, мкр-н Старая Сортировка, Кунарская ул.,14к2·4,3 · 6 отзывов
+-- → жилой район Сортировочный, мкр-н Старая
+-- Сортировка, Кунарская ул.,14к2
+-- мкр-н Широкая Речка, ул. Анатолия Муранова,18·4,7 · 11 отзывов
+-- → мкр-н Широкая Речка, ул. Анатолия Муранова,18
+-- ·3,1 · 11 отзывов → NULL (id 10377315, ровно 1 строка: адрес
+-- состоял ИЗ рейтинга целиком. Парсер на такой строке возвращает None — здесь то
+-- же самое через NULLIF. Оставлять «·3,1 · 11 отзывов» в колонке хуже пустоты:
+-- NULL апсерт теперь ДОзаполняет (#2777), мусор — нет.)
+--
+-- ОБРАТИМОСТЬ — без новой таблицы и без новой колонки: прежнее значение УЖЕ хранится.
+-- `listings.raw_payload->>'address'` пишется скрейпером на INSERT и НЕ входит в
+-- `ON CONFLICT DO UPDATE SET` (проверено по base.py: raw_payload отсутствует в SET) —
+-- т.е. переживает любой свип. Замерено на проде: у 1123 из 1123 строк
+-- raw_payload->>'address' = address ПОБАЙТОВО, NULL-ов нет ни одного.
+-- Откат (idempotent, безопасен к повторному запуску):
+--
+-- UPDATE listings
+-- SET address = raw_payload->>'address'
+-- WHERE source = 'avito'
+-- AND raw_payload->>'address' ~ '·\s*\d'
+-- AND address IS NOT DISTINCT FROM NULLIF(trim(both E' ,.\n\t' FROM
+-- regexp_replace(raw_payload->>'address',
+-- '\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$', '', 'i')), '');
+--
+-- Предикат самоидентифицирующий, список id хранить не нужно, и это ПРОВЕРЕНО, а не
+-- предположено. В dry-run (BEGIN…ROLLBACK) после UPDATE он дал по всей таблице ровно
+-- 1123 совпадения, все 1123 — наши; restored = before побайтово у 1123 из 1123.
+-- Ложных срабатываний нет и на строках-соседях: есть 10 строк, где raw_payload грязный,
+-- а address уже чистый (их адрес позже перезаписал avito_detail полным «Свердловская
+-- обл., Первоуральск, …») — второе условие их не берёт (замерено: 0), и это ПРАВИЛЬНО:
+-- возвращать рейтинг поверх нормализованного адреса не надо. Со временем предикат сам
+-- перестаёт брать строки, у которых address улучшил detail-путь, — откат не деградирует
+-- в порчу.
+-- `geocode_tried_at` откатывать нечего: это метка backoff'а, не данные.
+--
+-- ПОЧЕМУ geocode_tried_at = NULL. Очередь geocode_missing_listings отбирает по
+-- `geocode_tried_at IS NULL OR < NOW() - 7 days`, и метка привязана к ТЕКСТУ
+-- (address, city). У 711 из 1123 строк она стоит (у 370 — свежее 7 суток) — но стоит
+-- она на СТАРОМ, заведомо негеокодируемом тексте. После смены текста она смысла не
+-- имеет и лишь держала бы вычищенный адрес вне очереди до 7 суток. Сброс — это не
+-- «попробовать ещё раз то же самое», а «текст другой». Побочный расход честно измерен:
+-- 19 пар из 854 имеют соседа, которому геокодер отказал за последние 7 суток, т.е. до
+-- 19 лишних запросов к Nominatim — цена ниже, чем неделя ожидания у 370 строк.
+--
+-- ЧТО БУДЕТ ДАЛЬШЕ (и чего НЕ будет). Чистый адрес координат сам не даёт. После миграции
+-- 1122 строки (854 уникальные пары address+city; 1123-я — та самая NULL) попадают в
+-- выборку geocode_missing_listings: `lat IS NULL AND is_active AND address IS NOT NULL
+-- AND length(trim(address)) >= 5 AND (geocode_tried_at IS NULL OR < 7 days)`. Очередь
+-- станет 1938 строк / 1370 пар против 1569 / 1241 сейчас (+369 строк: 753 из 1123 уже
+-- стояли в ней СО СВОИМ ГРЯЗНЫМ адресом и жгли бюджет Nominatim впустую — этот расход
+-- миграция тоже снимает). Расписание: enabled, окно 0-23 UTC, batch_size=200,
+-- budget_sec=1800, ближайший next_run_at = 2026-08-10 17:45 UTC.
+-- Гарантированный низ (замер по живому geocode_cache тем же ключом, что строит
+-- `_cache_key`): 138 из 854 пар уже лежат в кэше с координатами и не истекли → 245
+-- строк получат geom мгновенно, без единого внешнего запроса. Остальное — как повезёт
+-- тирам (кадастровый FDW → Nominatim): последние 5 ночных прогонов давали 17-53%
+-- успеха на адрес, гадать точнее не буду.
+--
+-- ЧЕГО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ, СОЗНАТЕЛЬНО:
+-- * не трогает COALESCE в апсерте — поведение осознанное (#2777);
+-- * не трогает 360 строк с address IS NULL — их #2777 ДОзаполняет сам на ближайшем
+-- свипе (замерено: пустых строк '' среди них 0, все именно NULL);
+-- * не переносит координаты с соседних строк того же адреса. Такая возможность есть
+-- (789 из 1123 строк имеют соседа с координатами по тому же cleaned address+city),
+-- но у 88 из 548 донорских пар соседи расходятся между собой больше чем на 50 м, у
+-- 32 — больше 250 м, худший разброс 15 км. Выбирать победителя между ними — это
+-- новая политика, а не бэкфилл; отдельным решением, не тихо здесь.
+--
+-- Dependencies: 002_core_tables.sql (listings), 089_listings_geo_precision.sql
+-- (geocode_tried_at). Триггер listings_set_geom_trg тут не участвует: он BEFORE
+-- INSERT OR UPDATE OF lat, lon — эта миграция координат не пишет.
+-- Идемпотентность: по построению. Второй прогон видит 0 строк с '·<цифра>' и не делает
+-- ничего (WHERE самоисчерпывающийся). Новые вставки чисты с #2815.
+-- lock_timeout: блокирующего DDL здесь нет, но UPDATE по «горячей» listings берёт
+-- ROW EXCLUSIVE, и ждать его выдачи за чужой ACCESS EXCLUSIVE сессией — ровно та
+-- очередь перед приложением, из-за которой заведён #2752. Пусть лучше деплой упадёт
+-- громко (ON_ERROR_STOP=on), чем встанет тихо.
+
+BEGIN;
+
+SET LOCAL lock_timeout = '5s';
+
+UPDATE listings
+SET address = NULLIF(
+ trim(both E' ,.\n\t' FROM
+ regexp_replace(
+ address,
+ '\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$',
+ '',
+ 'i'
+ )),
+ ''),
+ geocode_tried_at = NULL
+WHERE source = 'avito'
+ AND address ~ '·\s*\d';
+
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt
index 80028a39..71642631 100644
--- a/tradein-mvp/backend/data/sql/_manifest_applied.txt
+++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt
@@ -244,3 +244,4 @@
240_trade_in_estimates_retain_until.sql
250_drop_duplicate_expires_at_index.sql
251_listings_drop_ceiling_height.sql
+254_listings_backfill_avito_rating_glued_address.sql
From 1307d55da6431d69e6330a98adf485b502aadef5 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Mon, 10 Aug 2026 10:34:39 +0000
Subject: [PATCH 08/27] =?UTF-8?q?fix(site-finder):=20=D0=BC=D0=B5=D1=82?=
=?UTF-8?q?=D0=BA=D0=B0=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA?=
=?UTF-8?q?=D0=B0=20=D0=B2=D0=B5=D1=81=D0=BE=D0=B2=20=D0=B2=D1=8B=D0=B2?=
=?UTF-8?q?=D0=BE=D0=B4=D0=B8=D1=82=D1=81=D1=8F=20=D0=B8=D0=B7=20=D1=80?=
=?UTF-8?q?=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D0=B0=20=D1=80?=
=?UTF-8?q?=D0=B5=D0=B7=D0=BE=D0=BB=D0=B2=D0=B0,=20=D0=B0=20=D0=BD=D0=B5?=
=?UTF-8?q?=20=D0=B8=D0=B7=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0=20(#2811)=20(#?=
=?UTF-8?q?2817)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/v1/parcels.py | 25 +++++--
.../services/site_finder/weight_profiles.py | 58 ++++++++++++---
.../api/v1/test_analyze_inline_weights.py | 74 +++++++++++++++++++
backend/tests/test_weight_profiles.py | 68 +++++++++++++++--
4 files changed, 200 insertions(+), 25 deletions(-)
diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py
index 74bfb5c9..191ed5c4 100644
--- a/backend/app/api/v1/parcels.py
+++ b/backend/app/api/v1/parcels.py
@@ -2189,12 +2189,21 @@ def analyze_parcel(
_effective_weights = {**_POI_WEIGHTS, **_inline_weights}
_weights_source = "inline"
else:
- _effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
- _weights_source = (
- "profile"
- if profile_id is not None
- else ("user_default" if profile_user_id is not None else "system")
- )
+ # Метка — из РЕЗУЛЬТАТА резолва, не из того, что клиент прислал (#2811):
+ # profile_id мог не найтись (нет owner'а в запросе / чужой / удалён), и
+ # тогда веса системные или дефолтные, а не профильные.
+ _resolved = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
+ _effective_weights = _resolved.weights
+ _weights_source = _resolved.source
+
+ # «Что просили» vs «что получилось»: profile_id echo'ит запрос, флаг говорит,
+ # был ли запрос удовлетворён. Отдельное поле, а не подмена source на "system" —
+ # иначе пропадёт разница «профиль не запрашивали» / «запрашивали, но не нашли».
+ # None когда profile_id не передавали; False когда передали, но применилось
+ # другое (не найден / чужой / перебит inline-весами).
+ _requested_profile_applied: bool | None = (
+ None if profile_id is None else _weights_source == "profile"
+ )
# 4) Scoring: weighted sum с distance decay
score = 0.0
@@ -4085,9 +4094,12 @@ def analyze_parcel(
# (None когда вердикт позитивный / нет площади / считать нечего). caveat внутри.
"program_alternatives": program_alternatives,
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
+ # source — что ФАКТИЧЕСКИ применилось; requested_profile_applied — был ли
+ # удовлетворён запрошенный profile_id (#2811). None = профиль не запрашивали.
"weights_profile": {
"source": _weights_source,
"profile_id": profile_id,
+ "requested_profile_applied": _requested_profile_applied,
"user_id": profile_user_id,
"weights_applied": _effective_weights,
"inline_weights": _inline_weights,
@@ -4203,6 +4215,7 @@ def analyze_parcel(
"profile_user_id": profile_user_id,
"inline_weights": _inline_weights,
"weights_source": _weights_source,
+ "requested_profile_applied": _requested_profile_applied,
"x_session_id": _session_id,
},
district=_district_name,
diff --git a/backend/app/services/site_finder/weight_profiles.py b/backend/app/services/site_finder/weight_profiles.py
index 08a253d1..7639c02d 100644
--- a/backend/app/services/site_finder/weight_profiles.py
+++ b/backend/app/services/site_finder/weight_profiles.py
@@ -10,7 +10,7 @@ API surface:
- create_profile(db, payload) → WeightProfile
- update_profile(db, user_id, profile_id, payload) → WeightProfile | None
- delete_profile(db, user_id, profile_id) → bool
-- resolve_weights(db, user_id, profile_id) → dict[str, float]
+- resolve_weights(db, user_id, profile_id) → ResolvedWeights(weights, source)
"""
from __future__ import annotations
@@ -19,7 +19,7 @@ import json
import logging
import math
from datetime import datetime
-from typing import Any
+from typing import Any, NamedTuple
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import text
@@ -346,13 +346,34 @@ def delete_profile(db: Any, user_id: str, profile_id: int) -> bool:
return True
-def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dict[str, float]:
- """Вернуть эффективные веса для analyze_parcel.
+class ResolvedWeights(NamedTuple):
+ """Веса + КАКОЙ источник фактически применился (#2811).
+
+ Лестница приоритетов ниже по построению стирает разницу между «взял, что
+ просили» и «не нашёл, взял что было» — а метка в ответе /analyze строится
+ именно на этой разнице. Поэтому источник возвращается вместе с весами, а не
+ выводится вызывающим из своих же входных параметров. NamedTuple, а не голый
+ dict: старый вызов `w = resolve_weights(...); w["school"]` падает громко,
+ молча «весами» этот объект не притворится.
+ """
+
+ weights: dict[str, float]
+ source: str # "profile" | "user_default" | "system"
+
+
+def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> ResolvedWeights:
+ """Вернуть эффективные веса для analyze_parcel + фактический их источник.
Порядок приоритетов:
- 1. profile_id задан → загрузить именно этот профиль
- 2. user_id задан → загрузить default-профиль пользователя
- 3. Иначе → вернуть системные значения _SYSTEM_POI_WEIGHTS
+ 1. profile_id задан → загрузить именно этот профиль → source="profile"
+ 2. user_id задан → загрузить default-профиль пользователя → source="user_default"
+ 3. Иначе → системные значения _SYSTEM_POI_WEIGHTS → source="system"
+
+ Запрошенный, но НЕ применённый profile_id — не тишина: warning с
+ идентификаторами (см. ниже). HTTP-статус на этом не меняем: profile_id для
+ /analyze — необязательный модификатор, а не адресуемый ресурс; 404 превратил
+ бы гонку «профиль удалили между списком и анализом» в отказ вместо честно
+ помеченного ответа. Клиенту хватает source + requested_profile_not_found.
"""
if profile_id is not None and user_id is not None:
profile = get_profile(db, user_id, profile_id)
@@ -360,13 +381,26 @@ def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dic
logger.debug(
"resolve_weights: user=%s profile_id=%s → custom weights", user_id, profile_id
)
- return dict(profile.weights)
+ return ResolvedWeights(dict(profile.weights), "profile")
+ resolved = ResolvedWeights(dict(_SYSTEM_POI_WEIGHTS), "system")
if user_id is not None:
profile = get_default_profile(db, user_id)
if profile is not None and profile.weights:
- logger.debug("resolve_weights: user=%s → default profile weights", user_id)
- return dict(profile.weights)
+ resolved = ResolvedWeights(dict(profile.weights), "user_default")
- logger.debug("resolve_weights: returning system defaults")
- return dict(_SYSTEM_POI_WEIGHTS)
+ if profile_id is not None:
+ # Сюда попадаем, если запрошенный профиль не применился: owner не передан
+ # (первая ветка требует ОБА аргумента), профиль чужой/удалён, либо weights
+ # пустые. Раньше это был logger.debug, которого на проде нет, — и оценка
+ # молча считалась не по тем весам (#2811, ранее #2788).
+ logger.warning(
+ "resolve_weights: запрошенный profile_id=%s (user_id=%r) НЕ применён — "
+ "фактический источник весов %r",
+ profile_id,
+ user_id,
+ resolved.source,
+ )
+ else:
+ logger.debug("resolve_weights: источник весов %s", resolved.source)
+ return resolved
diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py
index ad7a2248..cc61f0ef 100644
--- a/backend/tests/api/v1/test_analyze_inline_weights.py
+++ b/backend/tests/api/v1/test_analyze_inline_weights.py
@@ -316,3 +316,77 @@ def test_analyze_inline_weights_beats_profile_id() -> None:
finally:
app.dependency_overrides.clear()
_stop_patches()
+
+
+def test_analyze_missing_profile_is_not_labelled_profile() -> None:
+ """#2811: profile_id задан, профиль НЕ найден → метка НЕ смеет быть 'profile'.
+
+ Три способа промахнуться мимо профиля (все три воспроизведены живым запросом
+ на проде 2026-08-10): owner не передан вовсе, чужой профиль, удалённый id.
+ В mock-БД профилей нет — значит применились системные веса, и ответ обязан
+ это признать, а не утверждать, что считал по профилю.
+ """
+ from app.core.db import get_db
+ from app.services.site_finder.weight_profiles import _SYSTEM_POI_WEIGHTS
+
+ for qs in ("profile_id=999999", "profile_id=999999&profile_user_id=nobody"):
+ db = _make_db_for_analyze() # профилей нет → get_profile/get_default_profile → None
+ app.dependency_overrides[get_db] = _override_db(db)
+ _start_patches()
+ try:
+ client = TestClient(app)
+ resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?{qs}")
+ assert resp.status_code == 200, resp.text
+ wp = resp.json()["weights_profile"]
+ # sanity: веса и правда системные, промах реальный
+ assert wp["weights_applied"]["tram_stop"] == pytest.approx(
+ _SYSTEM_POI_WEIGHTS["tram_stop"]
+ )
+ assert wp["source"] != "profile", (
+ f"?{qs}: применились системные веса, а метка source='profile' — "
+ "ответ утверждает то, чего не было (#2811)"
+ )
+ assert wp["source"] == "system"
+ # «что просили» не теряется: запрошенный id + явный признак промаха
+ assert wp["profile_id"] == 999999
+ assert wp["requested_profile_applied"] is False
+ finally:
+ app.dependency_overrides.clear()
+ _stop_patches()
+
+
+def test_analyze_found_profile_keeps_label_and_flag() -> None:
+ """Обратная сторона: профиль найден → source='profile', флаг промаха False."""
+ from datetime import UTC, datetime
+
+ import app.services.site_finder.weight_profiles as wp_module
+ from app.core.db import get_db
+ from app.services.site_finder.weight_profiles import WeightProfile
+
+ profile = WeightProfile(
+ id=7,
+ user_id="user-1",
+ profile_name="test",
+ weights={"tram_stop": -0.4},
+ is_default=False,
+ description=None,
+ created_at=datetime.now(UTC),
+ updated_at=datetime.now(UTC),
+ )
+ db = _make_db_for_analyze()
+ app.dependency_overrides[get_db] = _override_db(db)
+ _start_patches()
+ original = wp_module.get_profile
+ wp_module.get_profile = lambda _db, uid, pid: profile
+ try:
+ client = TestClient(app)
+ resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?profile_id=7&profile_user_id=user-1")
+ assert resp.status_code == 200, resp.text
+ wp = resp.json()["weights_profile"]
+ assert wp["source"] == "profile"
+ assert wp["requested_profile_applied"] is True
+ assert wp["weights_applied"]["tram_stop"] == pytest.approx(-0.4)
+ finally:
+ wp_module.get_profile = original
+ app.dependency_overrides.clear()
+ _stop_patches()
diff --git a/backend/tests/test_weight_profiles.py b/backend/tests/test_weight_profiles.py
index 9cbb97c0..87b62883 100644
--- a/backend/tests/test_weight_profiles.py
+++ b/backend/tests/test_weight_profiles.py
@@ -8,11 +8,12 @@ Mock-based — без реальной БД. Проверяет:
- resolve_weights: нет user_id и profile_id → системные дефолты
- resolve_weights: user_id задан, default-профиль есть → его веса
- resolve_weights: profile_id задан → его веса
-- resolve_weights: профиль не найден → системные дефолты (fallback)
+- resolve_weights: профиль не найден → системные дефолты (fallback) + source != profile
"""
from __future__ import annotations
+import logging
from unittest.mock import MagicMock
import pytest
@@ -110,7 +111,8 @@ def test_resolve_weights_system_default() -> None:
"""Оба аргумента None → возвращаются системные веса."""
db = MagicMock()
result = resolve_weights(db, user_id=None, profile_id=None)
- assert result == _SYSTEM_POI_WEIGHTS
+ assert result.weights == _SYSTEM_POI_WEIGHTS
+ assert result.source == "system"
# db не должен вызываться вообще
db.execute.assert_not_called()
@@ -119,7 +121,7 @@ def test_resolve_weights_system_default_returns_copy() -> None:
"""Возвращается копия словаря, не ссылка на _SYSTEM_POI_WEIGHTS."""
db = MagicMock()
result = resolve_weights(db, user_id=None, profile_id=None)
- result["school"] = 999.0
+ result.weights["school"] = 999.0
# Оригинал не изменён
assert _SYSTEM_POI_WEIGHTS["school"] == 1.5
@@ -156,7 +158,8 @@ def test_resolve_weights_uses_default_profile() -> None:
finally:
wp_module.get_default_profile = original
- assert result == custom_weights
+ assert result.weights == custom_weights
+ assert result.source == "user_default"
def test_resolve_weights_uses_specific_profile() -> None:
@@ -175,7 +178,8 @@ def test_resolve_weights_uses_specific_profile() -> None:
finally:
wp_module.get_profile = original
- assert result == custom_weights
+ assert result.weights == custom_weights
+ assert result.source == "profile"
def test_resolve_weights_profile_not_found_fallback() -> None:
@@ -194,7 +198,9 @@ def test_resolve_weights_profile_not_found_fallback() -> None:
wp_module.get_profile = original_get
wp_module.get_default_profile = original_default
- assert result == _SYSTEM_POI_WEIGHTS
+ assert result.weights == _SYSTEM_POI_WEIGHTS
+ # #2811: главное — источник НЕ выдаёт себя за профиль, которого не нашли
+ assert result.source == "system"
def test_resolve_weights_empty_profile_weights_fallback() -> None:
@@ -212,4 +218,52 @@ def test_resolve_weights_empty_profile_weights_fallback() -> None:
finally:
wp_module.get_default_profile = original_default
- assert result == _SYSTEM_POI_WEIGHTS
+ assert result.weights == _SYSTEM_POI_WEIGHTS
+ assert result.source == "system"
+
+
+def test_resolve_weights_profile_id_without_owner_is_not_profile(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """#2811 сценарий 1: profile_id есть, user_id нет → первая ветка не выполняется.
+
+ Ровно это жило на проде: ран analysis_runs #4000 от 2026-08-07 —
+ source='profile', profile_id=1, а tram_stop=-0.5 (системный, у профиля 1 он
+ -0.4). Метка обязана быть 'system', а промах — попасть в warning.
+ """
+ db = MagicMock()
+ with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"):
+ result = resolve_weights(db, user_id=None, profile_id=1)
+
+ assert result.source == "system"
+ assert result.weights == _SYSTEM_POI_WEIGHTS
+ assert "profile_id=1" in caplog.text
+ db.execute.assert_not_called() # профиль даже не искали
+
+
+def test_resolve_weights_missing_profile_falls_to_user_default_not_profile(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """#2811 сценарий 3: profile_id не найден, но у юзера есть default-профиль.
+
+ Худший вариант: веса НЕ системные, поэтому по значениям подмена вообще не
+ видна. Метка должна сказать 'user_default', а не 'profile'.
+ """
+ import app.services.site_finder.weight_profiles as wp_module
+
+ default_profile = _make_profile_mock({"school": 2.0})
+ db = MagicMock()
+ original_get = wp_module.get_profile
+ original_default = wp_module.get_default_profile
+ wp_module.get_profile = lambda _db, uid, pid: None
+ wp_module.get_default_profile = lambda _db, uid: default_profile
+ try:
+ with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"):
+ result = resolve_weights(db, user_id="user-1", profile_id=999)
+ finally:
+ wp_module.get_profile = original_get
+ wp_module.get_default_profile = original_default
+
+ assert result.source == "user_default"
+ assert result.weights == {"school": 2.0}
+ assert "profile_id=999" in caplog.text
From 0ed0140c9e81da0b307488af388196739d6bc3d6 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Mon, 10 Aug 2026 11:18:19 +0000
Subject: [PATCH 09/27] =?UTF-8?q?fix(tradein/dedup):=20=D0=BE=D1=81=D1=82?=
=?UTF-8?q?=D0=B0=D1=82=D0=BE=D0=BA=20=D1=81=D1=85=D0=BB=D0=BE=D0=BF=D1=8B?=
=?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BE=D0=BC=D0=BE=D0=B2?=
=?UTF-8?q?=20=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=81=D1=8F?=
=?UTF-8?q?=20=D0=B8=D0=B7=D0=BC=D0=B5=D1=80=D1=8F=D0=B5=D0=BC=D1=8B=D0=BC?=
=?UTF-8?q?=20=D1=87=D0=B8=D1=81=D0=BB=D0=BE=D0=BC,=20=D0=B0=20=D0=BD?=
=?UTF-8?q?=D0=B5=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=BE=D0=B9=20(#2690)?=
=?UTF-8?q?=20(#2820)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../backend/app/services/house_dedup_merge.py | 229 +++++++++++++++---
.../backend/tests/test_house_dedup_merge.py | 90 +++++++
2 files changed, 290 insertions(+), 29 deletions(-)
diff --git a/tradein-mvp/backend/app/services/house_dedup_merge.py b/tradein-mvp/backend/app/services/house_dedup_merge.py
index 0f244b97..511386a1 100644
--- a/tradein-mvp/backend/app/services/house_dedup_merge.py
+++ b/tradein-mvp/backend/app/services/house_dedup_merge.py
@@ -13,8 +13,10 @@ WHAT this is:
pipeline, run inside ONE transaction so a crash leaves the table untouched.
Cluster key: CANONICAL address via tradein_canon_addr() over the CLEAN address
- COALESCE(short_address, full_address, address) (cadastral_number is 100% NULL on prod —
- confirmed in migration 040 — so address is the real building key). The clean source matters:
+ COALESCE(short_address, full_address, address) — the address is the only building key we
+ have (why: the KEY section below; the older claim here, «cadastral_number is 100% NULL on
+ prod», is no longer true — 2 648 of 9 179 rows carry one — and the conclusion no longer
+ rests on it). The clean source matters:
`address` can carry район-noise the canon does not strip (e.g. «улица Вайнера, 66 · р-н Центр»
→ canon «вайнера66рнцентр»), while `short_address` holds the clean «улица Вайнера, 66»
(→ «вайнера66») — preferring the clean field lets such a row cluster with its twin. The canon
@@ -116,6 +118,43 @@ MERGE JOURNAL — the merge is REVERSIBLE (#2690, migration 230):
asymmetry — merge allowed without a proximity check — was invisible in data before; now
«how many merges happened beyond N metres, on which key» is one query.
+KEY — there is no second, address-independent observation. Measured on prod 2026-08-10 (#2690):
+ #2690 asked for a cluster key that does not come from the normalized address, so that two
+ rows merge on two independent statements of identity rather than one restated twice. Every
+ field `houses` carries was checked against the live table. None qualifies:
+
+ cadastral_number 2 648 filled, ALL 2 648 values DISTINCT → collapses nothing. Provenance:
+ all 2 648 also carry dadata_enriched_at and house_fias_id, i.e. they are
+ DaData's answer to our address string, not a second observation of the
+ building. (The other cadastre we hold, listings.building_cadastral_number,
+ is the KNN geo-nearest hint — 20.1% of its values cover >1 ГАР building;
+ #2674 refused it as an identity key and that stands.)
+ house_fias_id 3 678 filled, ALL DISTINCT → the FIAS pass merges 0 rows today. Same
+ DaData provenance.
+ gar_house_guid the key #2690 rejected, re-measured: of 458 same-guid pairs, 441 share
+ the canon (the guid restates it), 17 do not — and 5 of those 17 are
+ >250 m apart, worst 5 064 km. Still circular, still noisy.
+ zhkh_house_guid looks independent (ГИС ЖКХ is an external registry) and is not: the
+ loader sets it WHERE gar_house_guid = , i.e. it IS the ГАР guid for
+ 4 268 of 4 663 rows. The 395 that differ come from the cadastre fallback
+ — keyed by that same KNN hint. Of its 194 pairs with a DIFFERENT canon,
+ 193 come through the fallback, and 30 of the 31 pairs >250 m apart do too.
+ source+ext_house_id, cian_internal_house_id, yandex_jk_id
+ distinct by construction / 39 / 0 rows → nothing to cluster.
+ coordinates a real independent observation, but not an IDENTITY: neighbours share a
+ yard. It is already used the only way it can be — as the guard.
+ year_built+total_floors
+ a FALSE witness, not a corroborator: of the 391 same-canon pairs the
+ guard cannot judge, only 18 agree on both fields (357 have a NULL), while
+ 306 pairs the guard rejected at >250 m DO agree — it would confirm merges
+ that are provably wrong.
+
+ Conclusion: do NOT strengthen the key, and do not read the leftover as a backlog. What the
+ canon key + 250 m guard reach IS the ceiling; what is left is counted, not queued — see the
+ residual census (`_RESIDUAL_SQL`), whose buckets keep «the guard was silent» apart from «the
+ guard rejected on the merits». Prod 2026-08-10, 963 excess rows: 568 of them are >250 m apart
+ (median 1 084 m) — those are not duplicates at all, the canon key is wrong about them.
+
IDEMPOTENCY:
Every UPDATE/DELETE keys off a temp mapping of (loser→keeper). On a clean table the
mapping is empty → every statement touches 0 rows → no-op. Re-running is safe.
@@ -165,6 +204,14 @@ _COMPLETENESS_EXPR = """
# правилу. Последствие не косметическое: объявления проигравшего переезжают на запись, на которую
# корпус никогда не ссылался, а COALESCE-перенос полей неполон (год постройки / тип дома /
# этажность / застройщик не переносятся) — данные богатого проигравшего удаляются безвозвратно.
+#
+# ПРОВЕРЕНО ЗАДНИМ ЧИСЛОМ (#2690 п.3, 2026-08-10): первый прогон на исправленном правиле —
+# 08.08, 821 слияние — разобран по house_merge_log (у проигравшего число объявлений = длина
+# children_repointed['listings.house_id_fk'], у победителя — что висело на нём до слияния).
+# Слияний, где победитель беднее проигравшего по объявлениям: 0 из 821. Контрфактика старого
+# правила на тех же кластерах: 6 из 762 забрали бы пустого победителя (8 объявлений). Мерить
+# «победителя до слияния» по listings.scraped_at НЕЛЬЗЯ — #2206 двигает его при каждом
+# ре-подтверждении, отчего появляются 207 несуществующих «худших победителей».
_KEEPER_ORDER = f"""
(h.geom IS NOT NULL) DESC,
listing_cnt DESC NULLS LAST,
@@ -199,37 +246,17 @@ _CANON_KEY_EXPR = """
"""
-def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
- """Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression.
+def _ranked_cte(cluster_key_case: str) -> str:
+ """Render the `WITH … ranked AS (…)` prelude: cluster → rank → expose the keeper per row.
- Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers
- are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered
- row shares one fias by construction).
-
- apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
- - CANON pass → True: the canon strips город/район, so same-street-number buildings in
- different region-66 towns share a canon; the guard stops the cross-town over-merge.
- - FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks
- proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the
- geom-first keeper rule simultaneously repairs the broken coordinate).
+ Shared verbatim by the merge mapping (`_mapping_sql`) and the residual census
+ (`_RESIDUAL_SQL`) so the census counts EXACTLY the rows the merge reasons about — a census
+ built from its own copy of the clustering would drift from the pass it describes and the
+ drift would be invisible (it is the same class of error as #2690's cluster key: two
+ expressions that look alike and are not).
`cluster_key_case` is a STATIC module constant (never runtime data) — no value injection.
"""
- geo_guard = (
- """
- -- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two
- -- different buildings sharing a street+number canon («Ленина 5» in different region-66
- -- towns) collapse to one cluster_key. A loser merges only when geographically next to the
- -- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at
- -- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL
- -- geom on either side, → left as separate rows (conservative — never over-merges).
- AND keeper_geom IS NOT NULL
- AND loser_geom IS NOT NULL
- AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"""
- if apply_geo_guard
- else ""
- )
return f"""
- CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS
WITH clustered AS (
SELECT
id,
@@ -281,7 +308,41 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
FROM dup_houses dh
JOIN houses h ON h.id = dh.id
LEFT JOIN listing_counts lc ON lc.house_id = dh.id
+ )"""
+
+
+def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
+ """Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression.
+
+ Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers
+ are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered
+ row shares one fias by construction).
+
+ apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
+ - CANON pass → True: the canon strips город/район, so same-street-number buildings in
+ different region-66 towns share a canon; the guard stops the cross-town over-merge.
+ - FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks
+ proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the
+ geom-first keeper rule simultaneously repairs the broken coordinate).
+ `cluster_key_case` is a STATIC module constant (never runtime data) — no value injection.
+ """
+ geo_guard = (
+ """
+ -- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two
+ -- different buildings sharing a street+number canon («Ленина 5» in different region-66
+ -- towns) collapse to one cluster_key. A loser merges only when geographically next to the
+ -- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at
+ -- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL
+ -- geom on either side, → left as separate rows (conservative — never over-merges).
+ AND keeper_geom IS NOT NULL
+ AND loser_geom IS NOT NULL
+ AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"""
+ if apply_geo_guard
+ else ""
)
+ return f"""
+ CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS
+ {_ranked_cte(cluster_key_case)}
-- CROSS-FIAS guard (#1772 follow-up): never merge two rows that BOTH carry a non-null but
-- DIFFERENT house_fias_id — provably different buildings the cluster key collapsed (canon
-- slash-collapse «Сулимова, 32»/«Сулимова, 3/2»). No-op for the fias pass (one fias per
@@ -314,6 +375,54 @@ _BUILD_MAPPING_SQL = text(_mapping_sql(_CANON_KEY_EXPR))
# merge even with NULL geom or >250 m apart (the geom-first keeper rule fixes broken coords).
_BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR, apply_geo_guard=False))
+# ── RESIDUAL CENSUS (#2690 п.2/п.4) ───────────────────────────────────────────
+#
+# Read-only, run AFTER both passes: how many same-canon rows the merge LEFT BEHIND, and WHY.
+# Same `ranked` prelude as the canon mapping, minus the guard — so every row the guard filtered
+# out is counted here, bucketed by the reason it survived.
+#
+# WHY this exists. #2690 asked for a second, address-independent key; measured 2026-08-10, there
+# is none (see the KEY section in the module docstring), so the remainder is a CEILING, not a
+# backlog — and a ceiling has to be a live number, not a one-off. The one-off rots fast: the
+# issue's own census (781 excess rows, 06.08) was 963 four days later, after a run deleted 821.
+#
+# The buckets are deliberately NOT summed into one «остаток». «Guard was silent» and «guard
+# rejected» are opposite facts:
+# residual_no_geom — one side has no coordinates: the guard could not speak. UNKNOWN.
+# residual_far — both geocoded, >250 m apart: the guard spoke on the merits. These are
+# NOT duplicates — the canon key is wrong about them (prod 2026-08-10:
+# 568 rows, median 1084 m). Counting them as «дубли» inflates the debt.
+# residual_cross_fias — provably different buildings (two different ФИАС UUIDs).
+# residual_mergeable — passes every guard and STILL was not merged. Must be 0 after a real
+# run; non-zero is a tripwire on the pass itself, not a census entry.
+# residual_listings is the user-visible size of the remainder (listings hanging on those rows).
+_RESIDUAL_SQL = text(
+ f"""
+ {_ranked_cte(_CANON_KEY_EXPR)}
+ SELECT
+ count(*) FILTER (WHERE rn > 1) AS residual_rows,
+ COALESCE(sum(lcnt) FILTER (WHERE rn > 1), 0) AS residual_listings,
+ count(*) FILTER (WHERE rn > 1 AND cross_fias) AS residual_cross_fias,
+ count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist IS NULL)
+ AS residual_no_geom,
+ count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist > 250) AS residual_far,
+ count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist <= 250)
+ AS residual_mergeable
+ FROM (
+ SELECT rn,
+ COALESCE(lc.listing_cnt, 0) AS lcnt,
+ CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL
+ THEN ST_DistanceSphere(loser_geom, keeper_geom)
+ END AS dist,
+ (NULLIF(loser_fias, '') IS NOT NULL
+ AND NULLIF(keeper_fias, '') IS NOT NULL
+ AND lower(loser_fias) <> lower(keeper_fias)) AS cross_fias
+ FROM ranked
+ LEFT JOIN listing_counts lc ON lc.house_id = ranked.id
+ ) r
+ """
+)
+
# Each step keys off _1772_dup_mapping → empty mapping ⇒ 0 rows touched ⇒ idempotent no-op.
_STEPS: list[tuple[str, str]] = [
# ── Plain re-point (no UNIQUE on the FK column) ───────────────────────────
@@ -726,6 +835,15 @@ class DedupMergeResult:
listings_repointed: int = 0 # listings.house_id_fk moved loser→keeper
children_deleted: int = 0 # collision/dedup deletions across all UNIQUE children
children_repointed: int = 0 # survivor child rows moved loser→keeper
+ # Residual census (#2690): same-canon rows STILL in the table after this run, by reason.
+ # Not a backlog — measured 2026-08-10 there is no address-independent key to shrink it with,
+ # so this is the ceiling of what this pass can reach. See _RESIDUAL_SQL.
+ residual_rows: int = 0 # excess same-canon rows left behind (sum of the three buckets)
+ residual_listings: int = 0 # listings hanging on them (the user-visible size)
+ residual_no_geom: int = 0 # guard was SILENT — one side has no coordinates
+ residual_far: int = 0 # guard SPOKE — >250 m apart, i.e. not the same building
+ residual_cross_fias: int = 0 # two different ФИАС UUIDs — provably different buildings
+ residual_mergeable: int = 0 # passed every guard and still unmerged — TRIPWIRE, expect 0
dry_run: bool = False
duration_sec: float = field(default=0.0)
@@ -736,6 +854,12 @@ class DedupMergeResult:
"listings_repointed": self.listings_repointed,
"children_deleted": self.children_deleted,
"children_repointed": self.children_repointed,
+ "residual_rows": self.residual_rows,
+ "residual_listings": self.residual_listings,
+ "residual_no_geom": self.residual_no_geom,
+ "residual_far": self.residual_far,
+ "residual_cross_fias": self.residual_cross_fias,
+ "residual_mergeable": self.residual_mergeable,
"dry_run": int(self.dry_run),
"duration_sec": int(self.duration_sec),
}
@@ -853,6 +977,49 @@ def _run_merge_pass(
db.execute(_BACKFILL_ALIASES_SQL)
+def _measure_residual(db: Session, result: DedupMergeResult) -> None:
+ """Count the same-canon rows this run did NOT merge, bucketed by the reason (#2690).
+
+ Read-only; runs after both passes, so it describes the table as the run leaves it (under
+ dry_run it sees the not-yet-rolled-back state, which is the correct preview). Kept out of
+ `_run_merge_pass` because the census is about the CANON key only and must be taken once per
+ call, not once per pass.
+
+ Never fails the merge: the merge itself is the product, the census is instrumentation, and a
+ census that can abort a committed-by-now transaction would be worse than a missing number.
+ """
+ try:
+ rows = db.execute(_RESIDUAL_SQL).all()
+ except Exception:
+ logger.exception("merge_duplicate_houses: residual census failed — counters left at 0")
+ return
+ if not rows:
+ return
+ r = rows[0]
+ result.residual_rows = int(r.residual_rows or 0)
+ result.residual_listings = int(r.residual_listings or 0)
+ result.residual_no_geom = int(r.residual_no_geom or 0)
+ result.residual_far = int(r.residual_far or 0)
+ result.residual_cross_fias = int(r.residual_cross_fias or 0)
+ result.residual_mergeable = int(r.residual_mergeable or 0)
+ logger.info(
+ "merge_duplicate_houses: residual rows=%d listings=%d "
+ "(страж молчит=%d · страж отверг >250м=%d · cross-fias=%d · сливаемых=%d)",
+ result.residual_rows,
+ result.residual_listings,
+ result.residual_no_geom,
+ result.residual_far,
+ result.residual_cross_fias,
+ result.residual_mergeable,
+ )
+ if result.residual_mergeable:
+ logger.warning(
+ "merge_duplicate_houses: %d rows pass every guard yet were NOT merged — the pass "
+ "left work on the table (expected 0)",
+ result.residual_mergeable,
+ )
+
+
def merge_duplicate_houses(
db: Session,
*,
@@ -908,6 +1075,10 @@ def merge_duplicate_houses(
result=result,
)
+ # Census of what is LEFT (read-only). Runs before the no-op early return on purpose:
+ # a run that merged nothing is exactly the run whose remainder needs a number.
+ _measure_residual(db, result)
+
if result.losers_deleted == 0:
# Clean table — both passes empty. Roll back (we only opened temp tables).
db.rollback()
diff --git a/tradein-mvp/backend/tests/test_house_dedup_merge.py b/tradein-mvp/backend/tests/test_house_dedup_merge.py
index 63db7822..4355e323 100644
--- a/tradein-mvp/backend/tests/test_house_dedup_merge.py
+++ b/tradein-mvp/backend/tests/test_house_dedup_merge.py
@@ -18,6 +18,7 @@ import inspect
import os
import re
from pathlib import Path
+from types import SimpleNamespace
from typing import Any
import pytest
@@ -486,9 +487,13 @@ class _FakeDB:
mapping_rows: list[_Row],
step_rowcount: int = 1,
fk_children: dict[str, str] | None = None,
+ residual: dict[str, int] | None = None,
+ residual_raises: bool = False,
):
self._mapping_rows = mapping_rows
self._step_rowcount = step_rowcount
+ self._residual = residual
+ self._residual_raises = residual_raises
self._mapping_served = False
# The catalog the FK-child guard reads; defaults to the real live set.
self._fk_children = _FK_CHILDREN if fk_children is None else fk_children
@@ -503,6 +508,12 @@ class _FakeDB:
return _FakeResult()
if "FROM pg_constraint" in sql:
return _FakeResult(rows=[_FKChild(t, c) for t, c in self._fk_children.items()])
+ if "AS residual_rows" in sql: # residual census (#2690) — read-only, after both passes
+ if self._residual_raises:
+ raise RuntimeError("census exploded")
+ if self._residual is None:
+ return _FakeResult(rows=[])
+ return _FakeResult(rows=[SimpleNamespace(**self._residual)])
if "SELECT loser_id, keeper_id, norm_address" in sql:
# The service now runs TWO passes (fias, then canon). Model «fias pass found the
# duplicates, canon pass is clean»: serve the scripted mapping once, empty afterwards.
@@ -550,6 +561,85 @@ def test_dry_run_computes_counts_but_rolls_back() -> None:
assert db.rollbacks == 1
+# ── Residual census (#2690 п.2/п.4): остаток фиксируется числом, а не оценкой ──
+
+
+def test_residual_census_reuses_the_canon_mapping_prelude() -> None:
+ """Перепись остатка обязана считать РОВНО те строки, о которых рассуждает канон-проход.
+
+ Общий `_ranked_cte` — не косметика: собственная копия кластеризации разъехалась бы с
+ проходом, который она описывает, и разъезд был бы невидим (это тот же класс ошибки, что и
+ ключ в #2690 — два выражения, похожие друг на друга и не равные). RED до фикса: переписи
+ нет вовсе.
+ """
+ residual = _flat(str(hdm._RESIDUAL_SQL.text))
+ prelude = _flat(hdm._ranked_cte(hdm._CANON_KEY_EXPR))
+ assert prelude in residual
+ assert prelude in _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR))
+
+
+def test_residual_census_keeps_silent_guard_apart_from_rejecting_guard() -> None:
+ """«Координат нет» и «дальше 250 м» — противоположные факты, в одну сумму их нельзя.
+
+ Первое означает, что страж не смог высказаться (остаток неизвестен), второе — что он
+ высказался по существу и дома РАЗНЫЕ (это вообще не дубли). Сумма из этих двух читается как
+ «долг», которого нет. Плюс: перепись НЕ применяет гео-фильтр — иначе она считала бы только
+ то, что и так слилось.
+ """
+ residual = _flat(str(hdm._RESIDUAL_SQL.text))
+ for bucket in (
+ "AS residual_rows",
+ "AS residual_listings",
+ "AS residual_no_geom",
+ "AS residual_far",
+ "AS residual_cross_fias",
+ "AS residual_mergeable",
+ ):
+ assert bucket in residual, bucket
+ # Гео-страж 250 м здесь — РАЗДЕЛИТЕЛЬ корзин, а не фильтр строк.
+ assert "AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in residual
+ assert set(hdm.DedupMergeResult().to_counters()) >= {
+ "residual_rows",
+ "residual_listings",
+ "residual_no_geom",
+ "residual_far",
+ "residual_cross_fias",
+ "residual_mergeable",
+ }
+
+
+def test_residual_counters_reported_even_when_nothing_merged() -> None:
+ """Прогон, который не слил ничего, — ровно тот, чьему остатку нужно число."""
+ db = _FakeDB(
+ mapping_rows=[],
+ residual={
+ "residual_rows": 963,
+ "residual_listings": 1765,
+ "residual_no_geom": 326,
+ "residual_far": 568,
+ "residual_cross_fias": 8,
+ "residual_mergeable": 61,
+ },
+ )
+ out = hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type]
+ assert out["losers_deleted"] == 0
+ assert out["residual_rows"] == 963
+ assert out["residual_listings"] == 1765
+ assert out["residual_no_geom"] == 326
+ assert out["residual_far"] == 568
+ assert out["residual_cross_fias"] == 8
+ assert out["residual_mergeable"] == 61
+
+
+def test_residual_census_failure_never_breaks_the_merge() -> None:
+ """Перепись — приборы, слияние — продукт: упавший счётчик не отменяет коммит."""
+ db = _FakeDB(mapping_rows=[_Row(2, 1, "ул. мира, 10")], residual_raises=True)
+ out = hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type]
+ assert out["losers_deleted"] == 1
+ assert out["residual_rows"] == 0 # неизвестно — и это видно, а не выдумано
+ assert db.commits == 1
+
+
def test_real_merge_commits() -> None:
"""dry_run=False with dups → exactly one commit, no rollback."""
rows = [_Row(2, 1, "ул. мира, 10")]
From 20ec6a5d33cd83d04510456ee06708aa62cf01e1 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Mon, 10 Aug 2026 11:29:19 +0000
Subject: [PATCH 10/27] =?UTF-8?q?fix(tradein/cian):=20403=20=D1=81=D0=BD?=
=?UTF-8?q?=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=20=D1=83=D0=B7=D0=B5=D0=BB=20?=
=?UTF-8?q?=D1=81=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D0=B8,=20=D0=B0=20?=
=?UTF-8?q?=D0=BD=D0=B5=20=D0=B3=D0=B0=D1=81=D0=BD=D0=B5=D1=82=20=D0=B2=20?=
=?UTF-8?q?return=20None=20(#2700)=20(#2821)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
tradein-mvp/backend/app/api/v1/admin.py | 9 +-
.../backend/app/services/scrape_runs.py | 64 ++++++
.../tests/test_2700_cian_detail_403_node.py | 208 ++++++++++++++++++
.../src/scraper_kit/cian_exceptions.py | 34 +++
.../src/scraper_kit/orchestration/runs.py | 64 ++++++
.../src/scraper_kit/providers/cian/detail.py | 30 +++
6 files changed, 408 insertions(+), 1 deletion(-)
create mode 100644 tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py
create mode 100644 tradein-mvp/packages/scraper-kit/src/scraper_kit/cian_exceptions.py
diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py
index 856a7213..07a08672 100644
--- a/tradein-mvp/backend/app/api/v1/admin.py
+++ b/tradein-mvp/backend/app/api/v1/admin.py
@@ -1904,9 +1904,16 @@ async def scrape_cian_detail(
Without it → debug-only (no DB write).
"""
_assert_allowed_url(offer_url)
+ from scraper_kit.cian_exceptions import CianBlockedError
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
- enrichment = await fetch_detail(offer_url, config=RealScraperConfig())
+ try:
+ enrichment = await fetch_detail(offer_url, config=RealScraperConfig())
+ except CianBlockedError as exc:
+ # #2700: 403 теперь исключение (узел снимается с выдачи Циану). Ad-hoc ручке
+ # нужен внятный ответ, а не 500: «страницу не разобрали» и «нас не пустили с
+ # этого узла» — разные новости для того, кто дёргает ручку руками.
+ raise HTTPException(502, f"Cian заблокировал наш узел: {exc}") from exc
if enrichment is None:
raise HTTPException(404, f"Could not parse Cian detail page: {offer_url}")
diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py
index c7c887d5..d1471ebf 100644
--- a/tradein-mvp/backend/app/services/scrape_runs.py
+++ b/tradein-mvp/backend/app/services/scrape_runs.py
@@ -225,6 +225,61 @@ def _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None:
)
+# #2700: сколько попыток фазы должно быть, чтобы «отказали все» что-то значило.
+# 3 — не круглое число, а порог, на котором сам сбор уже сдаётся: столько подряд
+# неудачных detail'ов достаточно оркестратору, чтобы ротировать прокси и оборвать фазу
+# (_cian_detail_abort в orchestration/pipeline.py). Замер на проде 2026-08-10 за 90
+# суток: порог отсекает 2 прогона с ЕДИНСТВЕННОЙ попыткой (одиночный отказ — шум, не
+# диагноз) и оставляет 50 прогонов, где отказали 3-50 попыток подряд.
+_PHASE_MIN_ATTEMPTS = 3
+
+
+def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None:
+ """Фаза прогона, у которой отказала КАЖДАЯ попытка (#2700). Текст причины или None.
+
+ Прогон состоит из фаз, а статус у него один. `_sweep_run_did_nothing` (#2625) ловит
+ случай, когда не сделано НИЧЕГО; этот — когда целое направление работы отказало на
+ сто процентов, а соседнее сработало, и суммарный ненулевой сбор прячет отказ.
+
+ Живой повод (#2700): `cian_city_sweep` 15 суток подряд писал `detail_attempted=50,
+ detail_failed=50, errors_count=0, status=done` — каждая detail-страница отдавала
+ HTTP 403. Ноль обогащённых при 1 680 собранных лотах внешне неотличим от здорового
+ прогона: результатный счётчик (lots_fetched) ненулевой, а до `errors_count` отказ
+ подзадачи не доходил вовсе (403 гасился внутри провайдера в `return None`).
+
+ Признак — собственная бухгалтерия фазы: `_failed == _attempted` при
+ `attempted >= _PHASE_MIN_ATTEMPTS`. Пары ищутся В САМИХ counters (любой ключ
+ `X_attempted` со спутником `X_failed`), а не по зашитому списку фаз: список — это
+ ровно то место, куда забывают дописать новую фазу, и тогда сторож молчит, выглядя
+ настроенным. На проде за 90 суток таких пар четыре: detail/houses/address/imv.
+
+ Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому
+ 'failed' без диагноза, как и в #2625/#2764, а не 'banned'/'platform'.
+
+ Замер на проде 2026-08-10 за 90 суток: правило переводит в 'failed' 52 прогона из
+ 3 292 'done' (1.6%) — 33 cian_city_sweep* (detail 26, houses 15 — часть прогонов
+ попадает по обеим фазам) и 11 avito_city_sweep* (detail; про эти никто не знал).
+ Остальные 3 240 остаются 'done'.
+ """
+ for key in sorted(counters):
+ if not key.endswith("_attempted"):
+ continue
+ phase = key[: -len("_attempted")]
+ attempted = _pick_int(counters, key)
+ failed = _pick_int(counters, f"{phase}_failed")
+ if attempted is None or failed is None:
+ continue
+ if attempted >= _PHASE_MIN_ATTEMPTS and failed == attempted:
+ return (
+ f"phase-honest-status: фаза '{phase}' отказала полностью — "
+ f"{failed} из {attempted} попыток неудачны, обогащено 0. Остальные фазы "
+ f"прогона могли отработать, поэтому ненулевой сбор это НЕ опровергает. "
+ f"Причина НЕ установлена: блок площадки, наш прокси или разбор — статус "
+ f"'failed' без диагноза (#2700)"
+ )
+ return None
+
+
def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
@@ -497,12 +552,21 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка:
страж, который надо не забыть позвать, — это тот же дефект оборванной проводки,
из-за которого задача и появилась.
+
+ #2700: там же — отказ называть успехом прогон, у которого отказала КАЖДАЯ попытка
+ целой фазы (см. _phase_totally_failed). Отличие от #2625: тот случай про «не сделано
+ ничего», этот — про «одно направление работы мертво, а суммарный сбор это прячет».
"""
did_nothing = _sweep_run_did_nothing(counters)
if did_nothing is not None:
logger.error("%s run_id=%d", did_nothing, run_id)
mark_failed(db, run_id, did_nothing, counters)
return
+ phase_dead = _phase_totally_failed(counters)
+ if phase_dead is not None:
+ logger.error("%s run_id=%d", phase_dead, run_id)
+ mark_failed(db, run_id, phase_dead, counters)
+ return
total_seen, new_count = _column_counts(counters)
row = db.execute(
text(
diff --git a/tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py b/tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py
new file mode 100644
index 00000000..d6637486
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py
@@ -0,0 +1,208 @@
+"""#2700: 403 Циана перестаёт умирать внутри провайдера — узел снимается, прогон честен.
+
+Живая различающая проба на проде 2026-08-10 (один и тот же detail-URL, один и тот же
+код, менялся ТОЛЬКО прокси-узел):
+
+ узел 1 (asocks-residential-1, 46.8.110.92) → HTTP 403, 21 564 б, `cian_waf_block`
+ узел 9 (asocks-mobile-1, 5.227.16.0) → HTTP 200, 617 352 б, state ok
+ узел 10 (asocks-mobile-2, 95.104.183.29) → HTTP 200, 617 355 б, state ok
+ узел 11 (asocks-mobile-3, 95.55.49.98) → HTTP 200, 617 407 б, state ok
+
+То есть отбита была ПАРА «узел × Циан», а не площадка (и не наши заголовки: те же 200
+пришли без единой куки — detail-страница авторизации не требует). Пятнадцать суток
+подряд это выглядело как «Циан нас забанил» ровно потому, что 403 гасился в
+`return None`: пул получал `mark_health(ok=True)` на отбитый узел и продолжал выдавать
+его Циану, а прогон писал `detail_failed=50` при `errors_count=0` и статусе `done`.
+
+Тесты красные на старом коде:
+ * `fetch_detail` возвращал None и НЕ звал `mark_banned` → узел оставался в выдаче;
+ * `mark_done` писал `status='done'` прогону, у которого отказали все 50 попыток.
+"""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+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 scraper_kit.cian_exceptions import CianBlockedError
+from scraper_kit.contracts import ProxyLease
+from scraper_kit.orchestration import runs as kit_runs
+from scraper_kit.providers.cian import detail as cian_detail
+from scraper_kit.proxy_errors import ProxyBanError
+
+from app.services import scrape_runs as app_runs
+
+_MODULES = {"kit": kit_runs, "app": app_runs}
+_LEASE = ProxyLease(id=1, url="http://user:pass@node-1:10423", kind="http", rotate_url=None)
+
+
+@dataclass
+class _FakeConfig:
+ use_proxy_pool_curl: bool = True
+ cian_proxy_url: str | None = None
+ environment: str = "production"
+
+
+class _SpyProvider:
+ """ProxyProvider-заглушка (тот же контракт, что в test_proxy_pool_curl_paths)."""
+
+ def __init__(self) -> None:
+ self.mark_health_calls: list[tuple[int, bool]] = []
+ self.mark_banned_calls: list[tuple[int, str]] = []
+ self.release_calls: list[int] = []
+
+ def acquire(self, provider: str) -> ProxyLease:
+ return _LEASE
+
+ def release(self, lease: ProxyLease) -> None:
+ self.release_calls.append(lease.id)
+
+ def mark_health(
+ self, lease: ProxyLease, ok: bool, *, exit_ip: Any = None, latency_ms: Any = None
+ ) -> None:
+ self.mark_health_calls.append((lease.id, ok))
+
+ def mark_banned(self, lease: ProxyLease, *, source: str) -> None:
+ self.mark_banned_calls.append((lease.id, source))
+
+
+def _session_returning(status_code: int, text: str = "") -> MagicMock:
+ session = MagicMock()
+ session.get = AsyncMock(return_value=MagicMock(status_code=status_code, text=text))
+ session.close = AsyncMock()
+ return session
+
+
+async def _fetch(status_code: int, spy: _SpyProvider) -> Any:
+ with patch.object(
+ cian_detail, "build_curl_cffi_session", return_value=_session_returning(status_code)
+ ):
+ return await cian_detail.fetch_detail(
+ "https://ekb.cian.ru/sale/flat/332775238/",
+ config=_FakeConfig(),
+ proxy_provider=spy,
+ )
+
+
+# ── 1. 403 доходит до пула ────────────────────────────────────────────────────
+
+
+async def test_403_bans_the_node_for_cian_only() -> None:
+ """Красный на старом коде: было `return None`, бана узла не происходило.
+
+ Проверяется ПОВЕДЕНИЕ пула (`mark_banned` на паре «узел × cian»), а не наличие
+ нового имени в коде.
+ """
+ spy = _SpyProvider()
+ with pytest.raises(CianBlockedError):
+ await _fetch(403, spy)
+ assert spy.mark_banned_calls == [(1, "cian")]
+ assert spy.mark_health_calls == [(1, False)]
+ assert spy.release_calls == [1] # lease не течёт даже на бане
+
+
+def test_blocked_error_is_recognised_by_generic_proxy_layer() -> None:
+ """Generic curl-слой узнаёт бан по `ProxyBanError`, не зная про Циан."""
+ assert issubclass(CianBlockedError, ProxyBanError)
+
+
+# ── 2. Не-бан остаётся не-баном ───────────────────────────────────────────────
+
+
+async def test_404_does_not_ban_the_node() -> None:
+ """Снятое объявление — не бан: наказывать за него здоровый узел нельзя."""
+ spy = _SpyProvider()
+ assert await _fetch(404, spy) is None
+ assert spy.mark_banned_calls == []
+ assert spy.mark_health_calls == [(1, True)]
+
+
+# ── 3. Прогон с полностью отказавшей фазой перестаёт быть 'done' ──────────────
+
+
+def _capture_status(mod: Any, counters: dict[str, int]) -> list[str]:
+ """Статусы всех UPDATE'ов, которые сделал mark_done на фейковой сессии.
+
+ Читаем СТАТУС В SQL (как в test_2625_run_that_did_nothing), а не имя вызванной
+ функции: тест обязан краснеть на поведении финализатора.
+ """
+ statuses: list[str] = []
+
+ def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
+ sql = str(stmt)
+ for status in ("done", "failed", "banned"):
+ if f"status = '{status}'" in sql:
+ statuses.append(status)
+ return MagicMock()
+
+ db = MagicMock()
+ db.execute.side_effect = _execute
+ with patch.object(mod, "sentry_sdk", MagicMock()):
+ mod.mark_done(db, 3258, dict(counters))
+ return statuses
+
+
+# Реальные counters с прода, не выдуманные.
+# Прогон 3258 (cian_city_sweep, 06.08): 50 из 50 detail'ов отказали, статус 'done'.
+PROD_3258_ALL_DETAIL_FAILED = {
+ "anchors_done": 5,
+ "anchors_total": 5,
+ "errors_count": 0,
+ "lots_fetched": 1680,
+ "lots_inserted": 59,
+ "lots_updated": 626,
+ "detail_attempted": 50,
+ "detail_failed": 50,
+ "detail_enriched": 0,
+ "houses_attempted": 40,
+ "houses_failed": 40,
+ "houses_enriched": 0,
+}
+# Прогон 3597 (cian_city_sweep, 10.08, уже после того как 403 ушёл): 10 из 11 удачны.
+PROD_3597_MOSTLY_OK = {
+ **PROD_3258_ALL_DETAIL_FAILED,
+ "errors_count": 1,
+ "detail_attempted": 11,
+ "detail_failed": 1,
+ "detail_enriched": 10,
+ "houses_attempted": 0,
+ "houses_failed": 0,
+}
+# Одиночная попытка, и та неудачна — шум, а не диагноз: прогон остаётся 'done'.
+SINGLE_ATTEMPT_FAILED = {
+ **PROD_3597_MOSTLY_OK,
+ "detail_attempted": 1,
+ "detail_failed": 1,
+ "detail_enriched": 0,
+}
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+def test_phase_failed_100_percent_is_not_done(name: str) -> None:
+ """Прод-прогон 3258: detail 50/50 отказ → 'failed'. Красный на старом коде."""
+ assert _capture_status(_MODULES[name], PROD_3258_ALL_DETAIL_FAILED) == ["failed"]
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+def test_partial_phase_failure_stays_done(name: str) -> None:
+ """Прод-прогон 3597: 1 отказ из 11 → остаётся 'done' (частичный отказ — не отказ)."""
+ assert _capture_status(_MODULES[name], PROD_3597_MOSTLY_OK) == ["done"]
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+def test_single_failed_attempt_stays_done(name: str) -> None:
+ """Порог _PHASE_MIN_ATTEMPTS: одна неудачная попытка прогон не роняет."""
+ assert _capture_status(_MODULES[name], SINGLE_ATTEMPT_FAILED) == ["done"]
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+def test_zero_attempts_stays_done(name: str) -> None:
+ """Фаза не запускалась (0 попыток) — 0 == 0 не должно читаться как отказ."""
+ counters = {**PROD_3597_MOSTLY_OK, "detail_attempted": 0, "detail_failed": 0}
+ assert _capture_status(_MODULES[name], counters) == ["done"]
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/cian_exceptions.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/cian_exceptions.py
new file mode 100644
index 00000000..e1e38ab5
--- /dev/null
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/cian_exceptions.py
@@ -0,0 +1,34 @@
+"""Cian-specific exceptions для anti-bot detection."""
+
+from scraper_kit.proxy_errors import ProxyBanError
+
+
+class CianBlockedError(ProxyBanError):
+ """HTTP 403 от Циана — узел, с которого мы пришли, отбит WAF'ом площадки.
+
+ Живая различающая проба 2026-08-10 (#2700), один и тот же detail-URL, один и тот
+ же код, менялся ТОЛЬКО прокси-узел:
+
+ узел 1 (asocks-residential, 46.8.110.92) → HTTP 403, 21 564 байт,
+ маркер `cian_waf_block`
+ узел 9 (asocks-mobile-1, 5.227.16.0) → HTTP 200, 617 352 байт, state ok
+ узел 10 (asocks-mobile-2, 95.104.183.29) → HTTP 200, 617 355 байт, state ok
+ узел 11 (asocks-mobile-3, 95.55.49.98) → HTTP 200, 617 407 байт, state ok
+
+ То есть 403 — свойство ПАРЫ «узел × Циан», а не площадки вообще и не нашего
+ запроса: detail-страница Циана авторизации не требует и отдаётся без единой куки
+ (проба выше шла без них). Поэтому исключение наследует `ProxyBanError` — тот же
+ приём, что у `AvitoBlockedError`/`DomClickBlockedError`: generic curl-слой
+ (`providers/_proxy.py::curl_proxy_url`) увидит `isinstance(exc, ProxyBanError)` и
+ снимет узел с выдачи ИМЕННО Циану (per-source бан, #2600 п.2), не трогая остальные
+ источники.
+
+ Почему это заводится отдельным исключением, а не остаётся `return None`: пока 403
+ гасился внутри `fetch_detail`, наружу не выходило НИЧЕГО — пул получал
+ `mark_health(ok=True)` на отбитый узел и продолжал выдавать его Циану, а прогон
+ писал `detail_failed=50` при `errors_count=0` и статусе `done` (#2700: 15 суток
+ подряд, 50 из 50 отказов ежедневно).
+
+ 404 сюда НЕ относится: удалённое объявление — не бан, узел за него наказывать
+ нельзя. Остальные не-200 остаются прежним мягким отказом (`None` + WARNING).
+ """
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py
index 108a7891..645d7420 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py
@@ -220,6 +220,61 @@ def _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None:
)
+# #2700: сколько попыток фазы должно быть, чтобы «отказали все» что-то значило.
+# 3 — не круглое число, а порог, на котором сам сбор уже сдаётся: столько подряд
+# неудачных detail'ов достаточно оркестратору, чтобы ротировать прокси и оборвать фазу
+# (_cian_detail_abort в orchestration/pipeline.py). Замер на проде 2026-08-10 за 90
+# суток: порог отсекает 2 прогона с ЕДИНСТВЕННОЙ попыткой (одиночный отказ — шум, не
+# диагноз) и оставляет 50 прогонов, где отказали 3-50 попыток подряд.
+_PHASE_MIN_ATTEMPTS = 3
+
+
+def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None:
+ """Фаза прогона, у которой отказала КАЖДАЯ попытка (#2700). Текст причины или None.
+
+ Прогон состоит из фаз, а статус у него один. `_sweep_run_did_nothing` (#2625) ловит
+ случай, когда не сделано НИЧЕГО; этот — когда целое направление работы отказало на
+ сто процентов, а соседнее сработало, и суммарный ненулевой сбор прячет отказ.
+
+ Живой повод (#2700): `cian_city_sweep` 15 суток подряд писал `detail_attempted=50,
+ detail_failed=50, errors_count=0, status=done` — каждая detail-страница отдавала
+ HTTP 403. Ноль обогащённых при 1 680 собранных лотах внешне неотличим от здорового
+ прогона: результатный счётчик (lots_fetched) ненулевой, а до `errors_count` отказ
+ подзадачи не доходил вовсе (403 гасился внутри провайдера в `return None`).
+
+ Признак — собственная бухгалтерия фазы: `_failed == _attempted` при
+ `attempted >= _PHASE_MIN_ATTEMPTS`. Пары ищутся В САМИХ counters (любой ключ
+ `X_attempted` со спутником `X_failed`), а не по зашитому списку фаз: список — это
+ ровно то место, куда забывают дописать новую фазу, и тогда сторож молчит, выглядя
+ настроенным. На проде за 90 суток таких пар четыре: detail/houses/address/imv.
+
+ Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому
+ 'failed' без диагноза, как и в #2625/#2764, а не 'banned'/'platform'.
+
+ Замер на проде 2026-08-10 за 90 суток: правило переводит в 'failed' 52 прогона из
+ 3 292 'done' (1.6%) — 33 cian_city_sweep* (detail 26, houses 15 — часть прогонов
+ попадает по обеим фазам) и 11 avito_city_sweep* (detail; про эти никто не знал).
+ Остальные 3 240 остаются 'done'.
+ """
+ for key in sorted(counters):
+ if not key.endswith("_attempted"):
+ continue
+ phase = key[: -len("_attempted")]
+ attempted = _pick_int(counters, key)
+ failed = _pick_int(counters, f"{phase}_failed")
+ if attempted is None or failed is None:
+ continue
+ if attempted >= _PHASE_MIN_ATTEMPTS and failed == attempted:
+ return (
+ f"phase-honest-status: фаза '{phase}' отказала полностью — "
+ f"{failed} из {attempted} попыток неудачны, обогащено 0. Остальные фазы "
+ f"прогона могли отработать, поэтому ненулевой сбор это НЕ опровергает. "
+ f"Причина НЕ установлена: блок площадки, наш прокси или разбор — статус "
+ f"'failed' без диагноза (#2700)"
+ )
+ return None
+
+
def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
@@ -562,12 +617,21 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка:
страж, который надо не забыть позвать, — это тот же дефект оборванной проводки,
из-за которого задача и появилась.
+
+ #2700: там же — отказ называть успехом прогон, у которого отказала КАЖДАЯ попытка
+ целой фазы (см. _phase_totally_failed). Отличие от #2625: тот случай про «не сделано
+ ничего», этот — про «одно направление работы мертво, а суммарный сбор это прячет».
"""
did_nothing = _sweep_run_did_nothing(counters)
if did_nothing is not None:
logger.error("%s run_id=%d", did_nothing, run_id)
mark_failed(db, run_id, did_nothing, counters)
return
+ phase_dead = _phase_totally_failed(counters)
+ if phase_dead is not None:
+ logger.error("%s run_id=%d", phase_dead, run_id)
+ mark_failed(db, run_id, phase_dead, counters)
+ return
total_seen, new_count = _column_counts(counters)
row = db.execute(
text(
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py
index 85dfbd4f..196845d3 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py
@@ -23,6 +23,7 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from scraper_kit.ceiling_height import plausible_ceiling_m
+from scraper_kit.cian_exceptions import CianBlockedError
from scraper_kit.cian_state_parser import extract_all_states, extract_state
from scraper_kit.offer_price_history import clamp_diff_percent
from scraper_kit.providers._base import build_curl_cffi_session
@@ -76,6 +77,23 @@ class DetailEnrichment:
raw_sister_states: dict[str, Any] = field(default_factory=dict)
+def _raise_if_blocked(offer_url: str, status_code: int) -> None:
+ """HTTP 403 → `CianBlockedError`; остальные статусы — молча возврат (#2700).
+
+ Ровно один статус, и это не список маркеров: 403 отдаёт WAF-страница Циана
+ (`cian_waf_block`, 21 КБ), и живая проба 2026-08-10 показала, что через здоровые
+ узлы тот же URL отдаётся 200-й. 404 (объявление снято) баном не считается —
+ иначе мёртвый лот снимал бы с выдачи здоровый узел.
+
+ Маркеры страницы намеренно НЕ проверяются: список маркеров объясняет уже
+ случившийся отказ и молчит про неизвестный (урок #2767 от 09.08 — страница
+ блокировки восемь суток проходила как «маркеров нет»). Статус ответа такого
+ свойства не имеет.
+ """
+ if status_code == 403:
+ raise CianBlockedError(f"Cian detail {offer_url} → HTTP 403 (WAF-блок узла)")
+
+
async def fetch_detail(
offer_url: str,
*,
@@ -99,6 +117,12 @@ async def fetch_detail(
Caller is responsible for the context-manager lifecycle of the fetcher.
Returns: DetailEnrichment, or None если fetch / parse failed.
+
+ Raises:
+ CianBlockedError: HTTP 403 на curl-путях — WAF Циана отбил узел, с которого мы
+ пришли (#2700). Оба вызывающих в orchestration/pipeline.py уже считают
+ исключение в `errors_count`, а на own-session-пути оно дополнительно снимает
+ узел с выдачи Циану через `curl_proxy_url`.
"""
if browser_fetcher is not None:
# Browser path: get fully JS-rendered HTML; same parse path follows.
@@ -111,6 +135,7 @@ async def fetch_detail(
# Shared curl_cffi-сессия (прокси уже применён caller'ом) — пул не трогаем.
resp = await session.get(offer_url, allow_redirects=True)
if resp.status_code != 200:
+ _raise_if_blocked(offer_url, resp.status_code)
logger.warning("Cian detail fetch %s → HTTP %d", offer_url, resp.status_code)
return None
html = resp.text
@@ -132,6 +157,11 @@ async def fetch_detail(
try:
resp = await own_session.get(offer_url, allow_redirects=True)
if resp.status_code != 200:
+ # ВНУТРИ curl_proxy_url: поднятый отсюда ProxyBanError доходит до
+ # пула (mark_banned на пару «узел × cian», #2600 п.2). Раньше здесь
+ # был `return None` — узел получал mark_health(ok=True) и оставался
+ # в выдаче Циану (#2700, 15 суток по 50 отказов в сутки).
+ _raise_if_blocked(offer_url, resp.status_code)
logger.warning("Cian detail fetch %s → HTTP %d", offer_url, resp.status_code)
return None
html = resp.text
From 74344f7b8b9713c0a31f4a97d1190e327f6b4f83 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Mon, 10 Aug 2026 11:49:27 +0000
Subject: [PATCH 11/27] =?UTF-8?q?docs(tradein/scraper):=2042=20=D0=BF?=
=?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D0=B0,=20=D0=B0=20=D0=BD=D0=B5?=
=?UTF-8?q?=2052=20=E2=80=94=20=D0=B2=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=80?=
=?UTF-8?q?=D0=B5=20=D1=81=D1=87=D0=B8=D1=82=D0=B0=D0=BB=D0=B8=D1=81=D1=8C?=
=?UTF-8?q?=20=D0=BF=D0=B0=D1=80=D1=8B=20=C2=AB=D0=BF=D1=80=D0=BE=D0=B3?=
=?UTF-8?q?=D0=BE=D0=BD=20=C3=97=20=D1=84=D0=B0=D0=B7=D0=B0=C2=BB=20(#2700?=
=?UTF-8?q?)=20(#2822)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
tradein-mvp/backend/app/services/scrape_runs.py | 10 ++++++----
.../scraper-kit/src/scraper_kit/orchestration/runs.py | 10 ++++++----
2 files changed, 12 insertions(+), 8 deletions(-)
diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py
index d1471ebf..0ecf5ee2 100644
--- a/tradein-mvp/backend/app/services/scrape_runs.py
+++ b/tradein-mvp/backend/app/services/scrape_runs.py
@@ -256,10 +256,12 @@ def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None:
Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому
'failed' без диагноза, как и в #2625/#2764, а не 'banned'/'platform'.
- Замер на проде 2026-08-10 за 90 суток: правило переводит в 'failed' 52 прогона из
- 3 292 'done' (1.6%) — 33 cian_city_sweep* (detail 26, houses 15 — часть прогонов
- попадает по обеим фазам) и 11 avito_city_sweep* (detail; про эти никто не знал).
- Остальные 3 240 остаются 'done'.
+ Замер на проде 2026-08-10 за 90 суток, ПРОГНАННЫЙ УЖЕ ДЕПЛОЙНУТОЙ функцией по
+ боевым counters (3 574 прогона, из них 3 293 'done'): правило переводит в 'failed'
+ 42 прогона (1.3%) — 31 cian_city_sweep* и 11 avito_city_sweep*; про вторые никто не
+ знал. Остальные 3 251 остаются 'done'. Первая версия этого абзаца называла 52 —
+ это было число ПАР «прогон × фаза» из SQL-замера, а не прогонов: у 10 прогонов
+ отказали обе фазы (detail и houses) сразу, и они посчитались дважды.
"""
for key in sorted(counters):
if not key.endswith("_attempted"):
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py
index 645d7420..c0996e1b 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py
@@ -251,10 +251,12 @@ def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None:
Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому
'failed' без диагноза, как и в #2625/#2764, а не 'banned'/'platform'.
- Замер на проде 2026-08-10 за 90 суток: правило переводит в 'failed' 52 прогона из
- 3 292 'done' (1.6%) — 33 cian_city_sweep* (detail 26, houses 15 — часть прогонов
- попадает по обеим фазам) и 11 avito_city_sweep* (detail; про эти никто не знал).
- Остальные 3 240 остаются 'done'.
+ Замер на проде 2026-08-10 за 90 суток, ПРОГНАННЫЙ УЖЕ ДЕПЛОЙНУТОЙ функцией по
+ боевым counters (3 574 прогона, из них 3 293 'done'): правило переводит в 'failed'
+ 42 прогона (1.3%) — 31 cian_city_sweep* и 11 avito_city_sweep*; про вторые никто не
+ знал. Остальные 3 251 остаются 'done'. Первая версия этого абзаца называла 52 —
+ это было число ПАР «прогон × фаза» из SQL-замера, а не прогонов: у 10 прогонов
+ отказали обе фазы (detail и houses) сразу, и они посчитались дважды.
"""
for key in sorted(counters):
if not key.endswith("_attempted"):
From 9d9457f67dc24c2d724fd504112e006846edf68f Mon Sep 17 00:00:00 2001
From: lekss361
Date: Mon, 10 Aug 2026 15:42:22 +0000
Subject: [PATCH 12/27] =?UTF-8?q?fix(tradein/estimate):=20=D0=BD=D0=B5=20?=
=?UTF-8?q?=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82?=
=?UTF-8?q?=D1=8C=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D1=83=20=E2=80=94=20?=
=?UTF-8?q?=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80=D1=8F=D1=82=D1=8C=20=D0=BF?=
=?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=20=D0=B8=20=D1=87=D0=B5=D1=81?=
=?UTF-8?q?=D1=82=D0=BD=D0=BE=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80?=
=?UTF-8?q?=D0=B5=D0=B6=D0=B4=D0=B0=D1=82=D1=8C=20(#2823)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
tradein-mvp/backend/app/schemas/trade_in.py | 35 ++
tradein-mvp/backend/app/services/estimator.py | 453 +++++++++++++++---
.../app/services/exporters/trade_in_pdf.py | 68 ++-
.../test_estimator_headline_sufficiency.py | 348 ++++++++++----
.../backend/tests/test_pdf_security.py | 80 ++++
.../tests/test_street_deals_endpoint.py | 58 +++
tradein-mvp/frontend/src/app/v2/page.tsx | 42 +-
.../src/components/trade-in/HeroSummary.tsx | 11 +-
.../src/components/trade-in/ListingsCard.tsx | 24 +-
.../trade-in/v2/LowConfidenceBanner.tsx | 112 +++++
.../components/trade-in/v2/ParamsPanel.tsx | 10 +-
.../src/components/trade-in/v2/SourcesMap.tsx | 18 +-
.../src/components/trade-in/v2/mappers.ts | 15 +-
.../src/components/trade-in/v2/ui-config.ts | 7 +-
tradein-mvp/frontend/src/types/trade-in.ts | 15 +
15 files changed, 1117 insertions(+), 179 deletions(-)
create mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/LowConfidenceBanner.tsx
diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py
index 4f3d4996..b7cb3556 100644
--- a/tradein-mvp/backend/app/schemas/trade_in.py
+++ b/tradein-mvp/backend/app/schemas/trade_in.py
@@ -319,6 +319,41 @@ class AggregatedEstimate(BaseModel):
cv: float | None = None
source_counts: dict[str, int] = Field(default_factory=dict)
created_at: datetime | None = None
+ # ── #oblast-F (never-block relaxation cascade, product decision 2026-08-10,
+ # #oblast-E priority RESTORED same day — see estimator.py module
+ # docstring for the full 3-way headline-source rule) ──────────────────
+ # Product requirement: an estimate is ALWAYS surfaced — a thin base sample
+ # (< HEADLINE_LISTINGS_MIN_N) no longer means "недостаточно данных". First
+ # estimator.estimate_quality() progressively relaxes the analog SEARCH
+ # (room-count adjacency → freshness window → novostroyki segment → radius)
+ # trying to grow the sample past the threshold; if it's STILL thin,
+ # _price_from_inputs() prefers a usable ДКП deals corridor over a noisy
+ # thin listings median when one is available (restored #oblast-E
+ # priority — the Серов repro: 3 listings must not outrank 54 deals), and
+ # only falls back to the thin listings median itself when no corridor
+ # exists. Real refusal happens only at genuine zero (no listings AND no
+ # usable anchor/deals).
+ # relaxations — RU-подписи КАЖДОГО применённого (реально помогшего) шага
+ # ослабления, готовые к показу пользователю как честный дисклеймер рядом с
+ # confidence_explanation. Пусто — базовой (4-tier) выборки хватило, каскад
+ # не понадобился (обычный случай). Возможные значения (дословно, фронт
+ # может на них завязываться): "снят фильтр по году постройки",
+ # "учтены студии", "комнатность ±1", "объявления за 60 дней",
+ # "учтены новостройки", "площадь ±25%", "радиус расширен до {N} м",
+ # "оценка по сделкам — мало объявлений рядом" (headline ceded to the ДКП
+ # deals corridor because the base listings sample was thin — a source
+ # SWITCH, not a search widening, but surfaced the same way).
+ # reliability — надёжность итоговой выборки, ПРОИЗВОДНАЯ от n_analogs
+ # (>=8 → ok; 3..7 → low; <3 → very_low), с доп. даунгрейдом ok→low, если
+ # relaxations непусто (выборка набралась только ценой ослаблений); капается
+ # на 'low' (не 'very_low'), когда headline ушёл по сделкам из-за тонкой
+ # выборки — реальный ДКП-коридор это настоящий сигнал, не «почти ничего».
+ # НЕ персистится на GET-rehydrate (пусто/"ok" по умолчанию там — известное
+ # ограничение, каскад не переигрывается из сохранённых analogs). НЕ
+ # путать с `confidence` (Literal low/medium/high — старая метрика на
+ # основе уникальных адресов/IQR, см. её собственный докстринг выше).
+ relaxations: list[str] = Field(default_factory=list)
+ reliability: Literal["ok", "low", "very_low"] = "ok"
# ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку
# при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ──
area_m2: float | None = None
diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py
index eeced17f..01f8871a 100644
--- a/tradein-mvp/backend/app/services/estimator.py
+++ b/tradein-mvp/backend/app/services/estimator.py
@@ -185,6 +185,24 @@ DEALS_HEADLINE_FALLBACK_MIN_N = 3
# ИТОГОВОЙ выборке как headline-источнику.
HEADLINE_LISTINGS_MIN_N = 5
+# #oblast-F (never-block relaxation cascade, product decision 2026-08-10, live
+# repro: Академика Парина 46/5 студия 23.1 м² — rooms=1 exact match gave n=4
+# и попадала под #oblast-E выше, хотя rooms=0 по тому же адресу давал n=34;
+# в радиусе 2 км rooms=0 17-29 м² — 327 активных лотов, rooms=1 — всего 10).
+# Продукт: НИКОГДА не отказывать в оценке. Если после существующего 4-шагового
+# каскада (tier0-когорта → без когорты → radius=fallback → area ±25%) выборка
+# всё ещё < HEADLINE_LISTINGS_MIN_N — estimate_quality() продолжает ослаблять
+# параметры подбора (см. #oblast-F блок там), от наименее к наиболее
+# искажающему: (a) смежность комнатности, (b) свежесть объявлений, (c) сегмент
+# (допустить новостройки), (d) радиус. Каждый применённый шаг попадает в
+# AggregatedEstimate.relaxations (честный дисклеймер для пользователя) — гейт
+# #oblast-E при этом больше НЕ обнуляет медиану (см. _price_from_inputs), а
+# только помечает результат как низконадёжный.
+RELAX_ROOMS_ADJACENT_DELTA = 1 # #oblast-F (a): rooms>=2 → BETWEEN rooms-1 AND rooms+1
+LISTINGS_FRESH_DAYS_RELAXED = 60 # #oblast-F (b): LISTINGS_FRESH_DAYS 14 → 60 дней
+RELAX_RADIUS_STEP1_M = 3000 # #oblast-F (d.1): max(текущий search_radius_m, 3000)
+RELAX_RADIUS_STEP2_M = 5000 # #oblast-F (d.2): финальный максимум
+
# #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals.
# Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm²
# to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ).
@@ -2562,12 +2580,28 @@ class PricingResult:
# headline. Anchor-путь → CV комплов (anchor["cv"]); radius-путь → CV
# радиусной ₽/м²-выборки. None если <2 цен (недостаточно данных).
cv: float | None = None
- # #oblast-E: >0 когда n листингов было найдено но ниже HEADLINE_LISTINGS_MIN_N
- # (headline suppressed, listings_clean deliberately left intact — see gate
- # comment above). Caller uses this to also keep the thin listings out of the
- # display `analogs` cards when no anchor overrides the headline. 0 = either
- # sufficient listings were used, or genuinely zero were found.
+ # #oblast-E/#oblast-F: >0 когда n листингов было найдено но ниже
+ # HEADLINE_LISTINGS_MIN_N. С #oblast-F (2026-08-10) больше НЕ обнуляет
+ # headline/listings_clean — median/n_analogs остаются реальными, поле лишь
+ # маркирует «низкая надёжность» (confidence='low' + честный explanation,
+ # см. gate comment ниже). 0 = либо выборка была достаточной, либо аналогов
+ # вообще не нашлось.
listings_headline_thin_n: int = 0
+ # #oblast-E (restored priority, product correction 2026-08-10): True когда
+ # headline построен из #oblast-D deals-corridor ИМЕННО потому, что базовая
+ # выборка листингов была тонкой (0 < n < HEADLINE_LISTINGS_MIN_N) И доступен
+ # достаточно надёжный ДКП-коридор (см. deals-headline-fallback блок ниже).
+ # Caller (estimate_quality) читает это чтобы (a) добавить relaxation-подпись
+ # «оценка по сделкам — мало объявлений рядом», (b) закэпить reliability на
+ # 'low' (не выше). False во всех остальных случаях, включая genuinely-zero
+ # listings deals-fallback (тот же блок, но без тонкой выборки позади).
+ deals_headline_due_to_thin_listings: bool = False
+
+
+def _analog_word_dative(n: int) -> str:
+ """Дательный падеж существительного «аналог» для confidence_explanation
+ тонкой (#oblast-E) выборки — «построена по N аналогу/аналогам»."""
+ return "аналогу" if n == 1 else "аналогам"
def _price_from_inputs(
@@ -2646,45 +2680,82 @@ def _price_from_inputs(
n_analogs = 0
cv = None
- # 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above).
- # 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find but too thin to trust
- # as a market median — suppress the AGGREGATE (median/range/n_analogs/cv)
- # exactly like "no usable listings", so the anchor/#oblast-D-deals-fallback/
- # insufficient_data chain below all take the already-honest zero-analogs
- # path automatically (no new branches there). `listings_clean` itself is
- # deliberately LEFT INTACT (not cleared) — the same-building anchor's own
- # ghost-anchor guard (#1871, `if not listings_clean`) uses it to tell
- # "genuinely zero nearby listings" from "some nearby listings, just too few
- # to trust as THIS estimate's headline" — those are different confidence
- # signals and clearing the list here would conflate them. The caller
- # (estimate_quality) uses `listings_headline_thin_n` on the returned
- # PricingResult to also keep suppressed listings out of the display
- # `analogs` cards when no anchor overrides the headline (n_analogs
- # invariant: cards shown ⊆ what n_analogs counts).
+ # 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above)
+ # — priority RESTORED 2026-08-10 (product correction on top of #oblast-F):
+ # 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find, but not trustworthy
+ # enough to headline on their OWN — a more reliable source should win when
+ # one exists. Two sub-cases:
+ # (i) a usable ДКП deals corridor is available (same threshold the
+ # #oblast-D deals-headline-fallback block below itself requires,
+ # DEALS_HEADLINE_FALLBACK_MIN_N deals with a positive median) → the
+ # listings AGGREGATE is suppressed to zero here so that block takes
+ # over the headline, EXACTLY like original #oblast-E. This is the
+ # Серов repro this gate exists for: n=3 listings must not outrank a
+ # 54-deal corridor. `listings_clean` stays intact (never cleared) —
+ # both for the anchor ghost-anchor guard (#1871) AND so
+ # estimate_quality() still surfaces these listings as display
+ # `analogs` cards even though they no longer drive n_analogs/median.
+ # (ii) no usable corridor → #oblast-F (never-block, 2026-08-10): keep the
+ # real thin median rather than refusing outright. By the time
+ # control reaches this function, estimate_quality() has already run
+ # the #oblast-F relaxation cascade (room-adjacency / freshness /
+ # novostroyki / radius) trying to grow the sample past the
+ # threshold — `listings` here is whatever that cascade could find.
+ # NOTE: `gate_ceded_to_deals` (local, this function only) is DIFFERENT from
+ # the `deals_headline_due_to_thin_listings` PricingResult field set later —
+ # this one fires as soon as the gate DECIDES to cede (used below to skip
+ # the repair-coefficient/explanation blocks safely, regardless of whether
+ # anchor later overrides); the field fires only once the #oblast-D
+ # deals-headline-fallback block ACTUALLY builds the headline from deals
+ # (anchor may still override in between — see that block).
listings_headline_thin_n = 0
+ gate_ceded_to_deals = False
+ # Outward PricingResult field — set True below, ONLY inside the actual
+ # #oblast-D deals-headline-fallback block, once it fires for THIS reason.
+ deals_headline_due_to_thin_listings = False
if 0 < n_analogs < HEADLINE_LISTINGS_MIN_N:
listings_headline_thin_n = n_analogs
- logger.info(
- "headline sufficiency gate #oblast-E: n=%d < %d listings — suppressing "
- "listings-derived median (falling back to anchor/deals/insufficient_data)",
- n_analogs,
- HEADLINE_LISTINGS_MIN_N,
+ dkp_corridor_usable = (
+ dkp_raw is not None
+ and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N
+ and dkp_raw.get("median_ppm2", 0) > 0
)
- median_ppm2 = 0.0
- q1_ppm2 = 0.0
- q3_ppm2 = 0.0
- median_price = 0
- range_low = 0
- range_high = 0
- n_analogs = 0
- cv = None
+ if dkp_corridor_usable:
+ gate_ceded_to_deals = True
+ logger.info(
+ "headline sufficiency gate #oblast-E: n=%d < %d listings, usable ДКП "
+ "corridor (n=%s) available — suppressing listings-derived median, "
+ "ceding headline to deals/anchor chain",
+ n_analogs,
+ HEADLINE_LISTINGS_MIN_N,
+ dkp_raw.get("count", 0) if dkp_raw else None,
+ )
+ median_ppm2 = 0.0
+ q1_ppm2 = 0.0
+ q3_ppm2 = 0.0
+ median_price = 0
+ range_low = 0
+ range_high = 0
+ n_analogs = 0
+ cv = None
+ else:
+ logger.info(
+ "headline sufficiency note #oblast-E: n=%d < %d listings, no usable "
+ "ДКП corridor — keeping real median, flagged low-reliability "
+ "(#oblast-F: never suppressed to zero without a fallback source)",
+ n_analogs,
+ HEADLINE_LISTINGS_MIN_N,
+ )
- # 4b. Repair coefficient — skipped when the headline was thin-suppressed
- # above (median_price is already 0; applying a coefficient would leave it
- # 0 but still emit a misleading "adjusted for repair state" note).
+ # 4b. Repair coefficient — applies to any real (non-zero) median, INCLUDING
+ # thin-but-kept (#oblast-F case ii) samples — a repair-state adjustment is
+ # meaningful there. Skipped when the gate ceded the headline to deals
+ # (gate_ceded_to_deals — median_price is already 0 above; applying a
+ # coefficient would leave it 0 but still emit a misleading "adjusted for
+ # repair state" note, same reasoning original #oblast-E used).
repair_coef = _repair_coefficient(repair_state)
repair_note = ""
- if listings_clean and not listings_headline_thin_n and repair_coef != 1.0:
+ if listings_clean and not gate_ceded_to_deals and repair_coef != 1.0:
median_price = int(median_price * repair_coef)
range_low = int(range_low * repair_coef)
range_high = int(range_high * repair_coef)
@@ -2725,19 +2796,20 @@ def _price_from_inputs(
area_widened,
listings=listings_clean,
)
- # #oblast-E: honest override — _compute_confidence's generic "не найдено
- # аналогов" is FALSE here (we DID find listings_headline_thin_n of them,
- # just too few to trust). Stays the final explanation unless a later block
- # (anchor / #oblast-D deals-fallback) overwrites it with its OWN honest
- # reasoning — both of those already check truthy `explanation` and either
- # replace it (anchor) or append a construction-method clause that reads
- # this same thin-count (deals-fallback), so no contradiction either way.
- if listings_headline_thin_n:
+ # #oblast-E/#oblast-F: honest low-reliability note — ONLY for case (ii) of
+ # the gate above (real thin median kept, no usable deals corridor to cede
+ # to). Case (i) (gate_ceded_to_deals) must NOT set this text — the
+ # deals-headline-fallback block below writes its OWN "built from
+ # Rosreestr deals" explanation; setting this first would leave a
+ # contradictory "Оценка построена по N аналогам" sentence stapled in front
+ # of it. Stays the final explanation unless a later block (anchor /
+ # #oblast-D deals-fallback) overwrites it with its OWN honest reasoning.
+ if listings_headline_thin_n and not gate_ceded_to_deals:
confidence = "low"
explanation = (
- f"Рядом найдено недостаточно объявлений ({listings_headline_thin_n} шт., "
- f"минимум для оценки по рынку — {HEADLINE_LISTINGS_MIN_N}) — медиана по "
- "такой маленькой выборке слишком чувствительна к случайным лотам."
+ f"Оценка построена по {listings_headline_thin_n} "
+ f"{_analog_word_dative(listings_headline_thin_n)} — выборка мала, "
+ "точность снижена."
)
# Tier note — информируем пользователя о качестве house-match
@@ -3316,10 +3388,16 @@ def _price_from_inputs(
n_analogs = 0
confidence = "low"
cv = None
- # #oblast-E: differentiate "genuinely zero listings" (unchanged wording)
- # from "found some but below HEADLINE_LISTINGS_MIN_N, suppressed above" —
- # the latter must NOT claim "рядом нет объявлений" (false, contradicts the
- # thin-sufficiency explanation already set above this block).
+ # #oblast-E (priority restored 2026-08-10): differentiate "genuinely
+ # zero listings" from "found some but below HEADLINE_LISTINGS_MIN_N,
+ # ceded to the deals corridor" (gate above, case i) — the latter must
+ # NOT claim "рядом нет объявлений" (false — some WERE found, just not
+ # trusted as headline on their own). `deals_headline_due_to_thin_
+ # listings` (returned on PricingResult) tells estimate_quality() this
+ # was the thin-cession path specifically, so it can (a) append the
+ # "оценка по сделкам — мало объявлений рядом" relaxation label, (b)
+ # cap reliability at 'low' — a real deals corridor is a real signal,
+ # just not a listings-comp one.
no_listings_clause = (
f" Из {listings_headline_thin_n} найденных объявлений недостаточно для "
"надёжной медианы —"
@@ -3331,9 +3409,11 @@ def _price_from_inputs(
f"сделкам Росреестра ({dkp_raw['count']} шт. за {dkp_raw['period_months']} мес.),"
" точность ориентировочная."
)
+ if listings_headline_thin_n:
+ deals_headline_due_to_thin_listings = True
logger.info(
"deals_headline_fallback #oblast-D: dkp median=%d (n=%d) → headline"
- " (listings=0 [thin_suppressed=%d], anchor=None)",
+ " (listings=0 [thin_ceded=%d], anchor=None)",
int(median_ppm2),
dkp_raw["count"],
listings_headline_thin_n,
@@ -3469,6 +3549,7 @@ def _price_from_inputs(
listings_clean=listings_clean,
cv=cv,
listings_headline_thin_n=listings_headline_thin_n,
+ deals_headline_due_to_thin_listings=deals_headline_due_to_thin_listings,
)
@@ -3730,6 +3811,9 @@ async def estimate_quality(
house_type=target_house_type,
total_floors=payload.total_floors,
)
+ # #oblast-F: True only when there WAS a cohort (year_built) filter to drop —
+ # surfaced later as the "снят фильтр по году постройки" relaxation label.
+ cohort_dropped = cohort_range is not None and len(listings_tier0) < MIN_ANALOGS_TIER_0
area_widened = False
if len(listings) < 5:
@@ -3778,6 +3862,146 @@ async def estimate_quality(
analog_tier = analog_tier_wa
search_radius_m = fallback_radius_m
+ # ── #oblast-F: relaxation cascade (never-block estimate, product decision
+ # 2026-08-10) ──────────────────────────────────────────────────────────
+ # Product requirement: NEVER refuse an estimate outright. If the 4-tier
+ # cascade above still leaves the sample thinner than HEADLINE_LISTINGS_MIN_N,
+ # keep loosening search criteria — least → most distorting — until either
+ # the sample clears the threshold or we run out of steps. Every step that
+ # ACTUALLY grew the sample is recorded in `relaxations` (RU labels, surfaced
+ # via AggregatedEstimate.relaxations + appended to confidence_explanation
+ # below) so a low-reliability estimate honestly explains why it stretched
+ # the search. Each step carries FORWARD the relaxations already applied by
+ # earlier steps (cumulative widening), not just its own single criterion.
+ relaxations: list[str] = []
+ if cohort_dropped:
+ relaxations.append("снят фильтр по году постройки")
+
+ cur_rooms_min: int | None = None
+ cur_rooms_max: int | None = None
+ cur_fresh_days = LISTINGS_FRESH_DAYS
+ cur_allow_novostroyki = False
+ cur_area_tolerance = 0.25 if area_widened else AREA_TOLERANCE
+
+ async def _try_relax(
+ *,
+ rooms_min: int | None,
+ rooms_max: int | None,
+ fresh_days: int,
+ allow_novostroyki: bool,
+ radius_m: int,
+ area_tolerance: float,
+ ) -> tuple[list[dict[str, Any]], str] | None:
+ """Один шаг каскада #oblast-F. Возвращает (listings, tier) только если
+ кандидат СТРОГО больше текущей выборки — иначе релаксация не засчитана
+ (ничего реально не выиграла) и вызывающий её не применяет."""
+ candidate, _, tier = await asyncio.to_thread(
+ _fetch_analogs,
+ db,
+ lat=geo.lat,
+ lon=geo.lon,
+ rooms=payload.rooms,
+ rooms_min=rooms_min,
+ rooms_max=rooms_max,
+ area=payload.area_m2,
+ radius_m=radius_m,
+ area_tolerance=area_tolerance,
+ fresh_days=fresh_days,
+ allow_novostroyki=allow_novostroyki,
+ full_address=geo.full_address,
+ target_house_id=target_house_id,
+ year_built=target_year,
+ house_type=target_house_type,
+ total_floors=payload.total_floors,
+ )
+ if len(candidate) > len(listings):
+ return candidate, tier
+ return None
+
+ # (a) room-count adjacency — самое дешёвое искажение: студия↔1-комн для
+ # rooms<=1 (live repro: Академика Парина 46/5, rooms=1 давал n=4, rooms=0
+ # тем же адресом — n=34), иначе комнатность ±RELAX_ROOMS_ADJACENT_DELTA.
+ if len(listings) < HEADLINE_LISTINGS_MIN_N:
+ if payload.rooms <= 1:
+ try_rooms_min, try_rooms_max, rooms_label = 0, 1, "учтены студии"
+ else:
+ try_rooms_min = payload.rooms - RELAX_ROOMS_ADJACENT_DELTA
+ try_rooms_max = payload.rooms + RELAX_ROOMS_ADJACENT_DELTA
+ rooms_label = "комнатность ±1"
+ rooms_result = await _try_relax(
+ rooms_min=try_rooms_min,
+ rooms_max=try_rooms_max,
+ fresh_days=cur_fresh_days,
+ allow_novostroyki=cur_allow_novostroyki,
+ radius_m=search_radius_m,
+ area_tolerance=cur_area_tolerance,
+ )
+ if rooms_result is not None:
+ listings, analog_tier = rooms_result
+ cur_rooms_min, cur_rooms_max = try_rooms_min, try_rooms_max
+ relaxations.append(rooms_label)
+
+ # (b) свежесть объявлений: LISTINGS_FRESH_DAYS (14) → LISTINGS_FRESH_DAYS_RELAXED (60).
+ if len(listings) < HEADLINE_LISTINGS_MIN_N:
+ fresh_result = await _try_relax(
+ rooms_min=cur_rooms_min,
+ rooms_max=cur_rooms_max,
+ fresh_days=LISTINGS_FRESH_DAYS_RELAXED,
+ allow_novostroyki=cur_allow_novostroyki,
+ radius_m=search_radius_m,
+ area_tolerance=cur_area_tolerance,
+ )
+ if fresh_result is not None:
+ listings, analog_tier = fresh_result
+ cur_fresh_days = LISTINGS_FRESH_DAYS_RELAXED
+ relaxations.append("объявления за 60 дней")
+
+ # (c) снять guard listing_segment — допустить новостройки в comp-пул.
+ if len(listings) < HEADLINE_LISTINGS_MIN_N:
+ novo_result = await _try_relax(
+ rooms_min=cur_rooms_min,
+ rooms_max=cur_rooms_max,
+ fresh_days=cur_fresh_days,
+ allow_novostroyki=True,
+ radius_m=search_radius_m,
+ area_tolerance=cur_area_tolerance,
+ )
+ if novo_result is not None:
+ listings, analog_tier = novo_result
+ cur_allow_novostroyki = True
+ relaxations.append("учтены новостройки")
+
+ # (d) радиус → max(текущий, RELAX_RADIUS_STEP1_M), затем → RELAX_RADIUS_STEP2_M.
+ # Пропускается, когда пользователь явно зафиксировал radius_m — тот же
+ # контракт, что и у существующего radius-fallback выше (#2044: сервер не
+ # авто-расширяет поиск за пределы выбранного пользователем радиуса).
+ if len(listings) < HEADLINE_LISTINGS_MIN_N and payload.radius_m is None:
+ for relax_radius in (max(search_radius_m, RELAX_RADIUS_STEP1_M), RELAX_RADIUS_STEP2_M):
+ if relax_radius <= search_radius_m:
+ continue
+ radius_result = await _try_relax(
+ rooms_min=cur_rooms_min,
+ rooms_max=cur_rooms_max,
+ fresh_days=cur_fresh_days,
+ allow_novostroyki=cur_allow_novostroyki,
+ radius_m=relax_radius,
+ area_tolerance=cur_area_tolerance,
+ )
+ if radius_result is not None:
+ listings, analog_tier = radius_result
+ search_radius_m = relax_radius
+ fallback_used = True
+ if len(listings) >= HEADLINE_LISTINGS_MIN_N:
+ break
+
+ # Area/radius relaxations derived from FINAL state (covers both the
+ # pre-existing Tier B/C radius/area widening above AND step (d) here) —
+ # a single check avoids double-labelling the same underlying widening.
+ if area_widened:
+ relaxations.append("площадь ±25%")
+ if search_radius_m > base_radius_m:
+ relaxations.append(f"радиус расширен до {search_radius_m} м")
+
# ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ──────────────
# #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы
# corridor_high был доступен для Tier C-гейта и soft-клампа headline.
@@ -3990,7 +4214,41 @@ async def estimate_quality(
ratio_basis = pr.ratio_basis
listings_clean = pr.listings_clean
cv = pr.cv
- listings_headline_thin_n = pr.listings_headline_thin_n
+
+ # #oblast-E (priority restored 2026-08-10): headline ceded to the ДКП deals
+ # corridor because the base listings sample was thin — a real signal (real
+ # Rosreestr deals), just not a listings-comp one. Recorded as its own
+ # relaxation label (distinct from the #oblast-F cascade labels above, which
+ # describe attempts to grow the LISTINGS sample — this describes switching
+ # sources entirely).
+ if pr.deals_headline_due_to_thin_listings:
+ relaxations.append("оценка по сделкам — мало объявлений рядом")
+
+ # #oblast-F: reliability tier derived from the FINAL n_analogs (post anchor/
+ # deals-fallback override above) — independent of `confidence` (older
+ # unique-address/IQR metric, see AggregatedEstimate docstring). If the
+ # #oblast-F cascade had to relax anything to get here, an otherwise-"ok"
+ # sample is downgraded to "low" — the raw count looks fine, but it only
+ # exists because we widened the search past the user's exact criteria.
+ if n_analogs >= 8:
+ reliability: Literal["ok", "low", "very_low"] = "ok"
+ elif n_analogs >= 3:
+ reliability = "low"
+ else:
+ reliability = "very_low"
+ if pr.deals_headline_due_to_thin_listings:
+ # #oblast-E: n_analogs is 0 here (listings-comp count, honestly zero —
+ # the headline came from deals instead), which would otherwise bucket
+ # to 'very_low'. Pin to 'low' instead: a 54-deal Rosreestr corridor is
+ # a real, meaningful signal — "не выше low" (product spec), not
+ # "почти нет сигнала" (what 'very_low' would imply here).
+ reliability = "low"
+ elif relaxations and reliability == "ok":
+ reliability = "low"
+ if relaxations:
+ explanation = (explanation or "") + (
+ " Применены послабления подбора: " + ", ".join(relaxations) + "."
+ )
# 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals.
# Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков
@@ -4026,14 +4284,6 @@ async def estimate_quality(
# иначе «обновлено N мин назад»/дата парсинга/срок продажи относятся к другому
# набору (или = None при пустом listings_clean, хотя у комплов данные есть).
metadata_lots = display_pool
- elif listings_headline_thin_n:
- # #oblast-E: headline was suppressed (thin radius sample, no anchor to
- # take over) — do NOT surface those same listings as display cards
- # either, else `analogs` would show N cards while n_analogs==0 (broken
- # invariant, same dishonesty this gate exists to remove). Degrades to
- # the exact same empty-display state as "genuinely zero listings".
- analogs_lots = []
- metadata_lots = []
else:
# display-consistency fix: только ЦЕНОВЫЕ листинги — та же популяция, что
# дала n_analogs = len(prices_ppm2) в radius-ветке _price_from_inputs.
@@ -4355,6 +4605,11 @@ async def estimate_quality(
cv=cv,
source_counts=source_counts,
created_at=now,
+ # #oblast-F (never-block relaxation cascade) — применённые ослабления
+ # подбора + производная надёжность выборки (см. reliability computation
+ # above, независимо от `confidence`).
+ relaxations=relaxations,
+ reliability=reliability,
)
@@ -4760,11 +5015,27 @@ def _extract_short_addr(full_address: str | None) -> str | None:
# Ищет keyword типа улицы (ул./улица/пр./проспект/...) в адресе.
# Работает для FORWARD и REVERSE форматов Nominatim.
+# #pdf-honesty/#oblast-E-follow-up (live-prod fix 2026-08-10): точка после
+# сокращений (ул., пр., пер., ш., наб., пл., мкр.) сделана ОПЦИОНАЛЬНОЙ
+# (`\.?`) — DaData (основной источник адресов, дом-уровень геокодинга) отдаёт
+# формат БЕЗ точки: "ул Академика Парина", а не "ул. Академика Парина". Старый
+# regex требовал точку строго → keyword не матчился НИ НА ОДНОМ DaData-адресе
+# → street-deals/sales-vs-listings блоки молчали (WARNING "could not extract
+# street") на КАЖДОМ запросе с DaData-геокодингом, не только на репро-адресе.
+# Порядок альтернатив принципиален: `ул\.?` идёт ПЕРЕД полным словом `улица` —
+# но это безопасно за счёт backtracking Python `re` (NFA, не POSIX longest-
+# match): если `ул\.?` матчит только "ул" из "улица" и последующий `\s+`
+# после этого не находит пробел (следующий символ — "и"), движок
+# откатывается и пробует СЛЕДУЮЩУЮ альтернативу — "улица" — которая матчит
+# полностью. Проверено на "ул. X" / "ул X" / "улица X" — все три дают
+# идентичный результат (см. test_street_deals_endpoint.py). Бывшая отдельная
+# bare-альтернатива "мкр" убрана как ставшая избыточной — "мкр\.?" уже
+# покрывает оба варианта (с точкой и без).
_STREET_KW_RE = re.compile(
r"(? NOW() - (:fresh_days || ' days')::interval
@@ -4956,8 +5230,13 @@ _COMMON_WHERE = """
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
-- Исключаем новостройки из comp-пула вторички: девелоперский прайс искажает
-- медиану ₽/м². NULL сегмент пропускаем (rosreestr/avito/yandex без сегмента —
- -- это вторичка или неклассифицированный объект).
- AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
+ -- это вторичка или неклассифицированный объект). #oblast-F (c): allow_novostroyki
+ -- пробрасывается как последняя-по-очереди relaxation-ступень (estimate_quality) —
+ -- дефолт False сохраняет канон-guard byte-identical.
+ AND (
+ CAST(:allow_novostroyki AS boolean) IS TRUE
+ OR (listing_segment IS NULL OR listing_segment = 'vtorichka')
+ )
-- #2012 is_apartments hard-filter (флаг estimate_is_apartments_filter_enabled,
-- default OFF pending backtest). Флаг выключен ⇒ CAST(... ) IS NOT TRUE ⇒
-- условие прозрачно (byte-identical старому поведению). Включён ⇒ исключает
@@ -5012,6 +5291,14 @@ def _fetch_analogs(
cohort_year_min: int | None = None, # NEW: lower bound year_built inclusive
cohort_year_max: int | None = None, # NEW: upper bound year_built inclusive
target_house_id: int | None = None, # #6: canonical house for same-building Tier S
+ # #oblast-F (never-block relaxation cascade) — все три опциональны, дефолты
+ # byte-identical старому поведению (exact rooms match / 14 дней / без
+ # новостроек). estimate_quality() передаёт неполные (widened) значения ТОЛЬКО
+ # когда базовая выборка тоньше HEADLINE_LISTINGS_MIN_N — см. module docstring.
+ rooms_min: int | None = None, # #oblast-F (a): None → эффективно = rooms
+ rooms_max: int | None = None, # #oblast-F (a): None → эффективно = rooms
+ fresh_days: int = LISTINGS_FRESH_DAYS, # #oblast-F (b): relaxed = LISTINGS_FRESH_DAYS_RELAXED
+ allow_novostroyki: bool = False, # #oblast-F (c)
) -> tuple[list[dict[str, Any]], bool, str]:
"""SELECT аналогов — трёхуровневый house-match (S → H → W).
@@ -5052,21 +5339,29 @@ def _fetch_analogs(
"""
area_min = area * (1 - area_tolerance)
area_max = area * (1 + area_tolerance)
+ # #oblast-F (a): None → эффективно exact-match (rooms_min=rooms_max=rooms),
+ # byte-identical старому `rooms = :rooms`. Caller (estimate_quality) passes a
+ # widened range only past HEADLINE_LISTINGS_MIN_N thinness.
+ eff_rooms_min = rooms if rooms_min is None else rooms_min
+ eff_rooms_max = rooms if rooms_max is None else rooms_max
# #1871 P2: (source, source_id) dedup в radius-тирах. rn_dup-окно всегда в SQL
# (безвредно без фильтра); статический фрагмент управляет только применением
# `AND rn_dup = 1` в outer WHERE. Это SQL-литерал (static), НЕ data — psycopg3
# bind-параметры не задействованы, инъекции нет.
dup_filter = "AND rn_dup = 1"
base_params: dict[str, Any] = {
- "rooms": rooms,
+ "rooms_min": eff_rooms_min,
+ "rooms_max": eff_rooms_max,
"area_min": area_min,
"area_max": area_max,
- "fresh_days": LISTINGS_FRESH_DAYS,
+ "fresh_days": fresh_days,
"max_per_addr": MAX_ANALOGS_PER_ADDRESS,
"cohort_year_min": cohort_year_min,
"cohort_year_max": cohort_year_max,
# #2012: is_apartments hard-filter — see _COMMON_WHERE comment above.
"is_apartments_filter": settings.estimate_is_apartments_filter_enabled,
+ # #oblast-F (c): allow_novostroyki — see _COMMON_WHERE comment above.
+ "allow_novostroyki": allow_novostroyki,
}
# ── Tier S (canonical): same building via house_id_fk ─────────────────────
@@ -5391,7 +5686,8 @@ def _fetch_analogs(
FROM listings
WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
AND (geo_precision IS DISTINCT FROM 'city')
- AND rooms = :rooms
+ -- #oblast-F (a): sync с _COMMON_WHERE — см. комментарий там же.
+ AND rooms BETWEEN :rooms_min AND :rooms_max
AND area_m2 BETWEEN :area_min AND :area_max
AND is_active = true
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
@@ -5408,7 +5704,11 @@ def _fetch_analogs(
)
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
-- Tier W: исключаем новостройки из comp-пула (sync с _COMMON_WHERE).
- AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
+ -- #oblast-F (c): allow_novostroyki relaxation, sync с _COMMON_WHERE.
+ AND (
+ CAST(:allow_novostroyki AS boolean) IS TRUE
+ OR (listing_segment IS NULL OR listing_segment = 'vtorichka')
+ )
-- #2012 is_apartments hard-filter, sync с _COMMON_WHERE (см. комментарий
-- там же). Флаг выключен ⇒ прозрачно (byte-identical старому поведению).
AND (
@@ -5450,16 +5750,18 @@ def _fetch_analogs(
"lat": lat,
"lon": lon,
"radius": radius_m,
- "rooms": rooms,
+ "rooms_min": eff_rooms_min,
+ "rooms_max": eff_rooms_max,
"area_min": area_min,
"area_max": area_max,
- "fresh_days": LISTINGS_FRESH_DAYS,
+ "fresh_days": fresh_days,
"target_year": year_built,
"target_house_type": house_type,
"max_per_addr": MAX_ANALOGS_PER_ADDRESS,
"cohort_year_min": cohort_year_min, # NEW
"cohort_year_max": cohort_year_max, # NEW
"is_apartments_filter": settings.estimate_is_apartments_filter_enabled, # #2012
+ "allow_novostroyki": allow_novostroyki, # #oblast-F (c)
},
)
.mappings()
@@ -6570,4 +6872,7 @@ def _empty_estimate(
# Адрес не геокодирован (DaData не отрабатывала) → точность неизвестна.
address_precision=None,
analog_tier=None, # нет данных при empty estimate
+ # #oblast-F: n_analogs=0 здесь честно — поиск аналогов вообще не выполнялся
+ # (geocode failed / no coords), а не просто "мало нашлось".
+ reliability="very_low",
)
diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
index 8282f83c..676d8f71 100644
--- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
+++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
@@ -1243,11 +1243,71 @@ def _deals_range(deals: list[AnalogLot], fallback: tuple[int, int]) -> tuple[int
return min(prices), max(prices)
+def _deals_sourced_thin_listings_note_html(estimate: AggregatedEstimate) -> str:
+ """#pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): honest
+ footnote for the specific case n_analogs==0 (headline ceded to the ДКП deals
+ corridor, estimator.py `deals_headline_due_to_thin_listings`) BUT
+ estimate.analogs is non-empty (the thin listings that triggered the cession
+ are still shown below as reference cards — never cleared, see estimator.py
+ #1871 ghost-anchor guard). Same tone/plain-sentence style as the web
+ LowConfidenceBanner for this scenario. Empty string (no-op) otherwise —
+ covers both "healthy sample" and "genuinely zero, nothing to show" cases."""
+ if estimate.n_analogs != 0 or not estimate.analogs:
+ return ""
+ return (
+ f''
+ "Оценка построена по зарегистрированным сделкам Росреестра — подходящих "
+ "объявлений поблизости почти нет. Объявления ниже приведены справочно, "
+ "для наглядности рынка.
"
+ )
+
+
+def _reliability_note_html(estimate: AggregatedEstimate, n_shown: int) -> str:
+ """#pdf-honesty: surfaces `AggregatedEstimate.relaxations`/`reliability`
+ (estimator.py #oblast-F cascade + #oblast-E deals-priority) — the web report
+ already shows this (LowConfidenceBanner); the PDF stayed silent, a
+ client-visible discrepancy between the two. Empty string (no-op) when
+ reliability=='ok' and relaxations is empty — the common, unrelaxed case,
+ byte-identical to the report before these fields existed."""
+ if estimate.reliability == "ok" and not estimate.relaxations:
+ return ""
+ if estimate.relaxations:
+ detail = "Подбор аналогов расширен: " + ", ".join(
+ _html.escape(r) for r in estimate.relaxations
+ )
+ else:
+ # relaxations пуст, но reliability всё же не 'ok' (напр. тонкая выборка,
+ # которую каскад ослаблений не смог расширить, см. estimator.py
+ # #oblast-F) — n_shown, не сырой n_analogs (та же #pdf-honesty логика,
+ # что и в счётчике выше страницы).
+ detail = f"Оценка построена по небольшой выборке ({n_shown} шт.)"
+ return f"""
+
+ Точность оценки снижена.
+ {detail} — данные ниже приведены с этой оговоркой.
+
+"""
+
+
# ── Page 2: Listings (market) ────────────────────────────────────────────────
def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
- n_total = estimate.n_analogs
+ # #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): raw
+ # estimate.n_analogs is the count of listings that drove the HEADLINE math —
+ # it is deliberately 0 when the headline was ceded to the ДКП deals corridor
+ # (estimator.py `deals_headline_due_to_thin_listings`), even though the thin
+ # listings that triggered that cession are still shown below as display cards
+ # (estimate.analogs — never cleared, see estimator.py #1871 ghost-anchor
+ # guard comment). Printing raw n_analogs there read as "0 шт." above a
+ # non-empty examples table — a client-visible contradiction. n_analogs is
+ # normally >= len(analogs) (analogs is a top-10-capped SUBSET of what
+ # n_analogs counts, see AnalogLot/AggregatedEstimate docstring) — max() is a
+ # no-op in that common case (count stays the honest FULL n_analogs) and only
+ # changes anything in this one pathological case, where it falls back to
+ # "how many are actually shown" instead of the dishonest zero.
+ n_total = max(estimate.n_analogs, len(estimate.analogs))
# #1531: убрана строка-дубль «(с учётом ремонта)». Estimator НЕ фильтрует
# аналоги по repair_state (coverage listings.repair_state ~2%, см. estimator.py:160),
# а лишь применяет ценовой коэффициент к медиане/диапазону — поэтому отдельного
@@ -1306,6 +1366,10 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
examples_rows = _examples_rows(top5)
heading_html = _section_heading("02", "РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")
+ # #pdf-honesty — see helper docstrings above. Both no-op ("") in the common
+ # (unrelaxed, non-deals-sourced) case — byte-identical page in that case.
+ deals_sourced_note = _deals_sourced_thin_listings_note_html(estimate)
+ reliability_note = _reliability_note_html(estimate, n_total)
return f"""
@@ -1320,6 +1384,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
| Количество объявлений по аналогичным объектам |
{_mono(f"{n_total} шт.")} |
+ {deals_sourced_note}
Источники данных
{sources_html}
@@ -1344,6 +1409,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
+ {reliability_note}
Диапазон цен в объявлениях
diff --git a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py
index 3ae13ef7..a4033a0f 100644
--- a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py
+++ b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py
@@ -1,22 +1,55 @@
-"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02).
+"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02, priority
+RESTORED 2026-08-10) + #oblast-F — never-block relaxation cascade (product
+decision, 2026-08-10).
-Live-prod repro that motivated this gate: Серов 2к/45м², n=3 scraped listings →
-headline 42 391 ₽/м² (−36% vs the city ДКП corridor, 54 126 ₽/м²); a neighbouring
-street in the same town swung ±66% on 1-2 different random listings. Каменск-
-Уральский returned a LITERAL 0 ₽ for a room/area combo with no local ДКП match
-either, with no honest refusal surfaced. Первоуральск (0 listings) already fell
-back to the (pre-existing) ДКП deals-headline fallback correctly — this gate
-routes the THIN (1..HEADLINE_LISTINGS_MIN_N-1 listings) case into that SAME,
-already-tested path instead of trusting a 1-4-lot median as the headline.
+History:
+ 1. #oblast-E (2026-08-02) SUPPRESSED a thin (1..HEADLINE_LISTINGS_MIN_N-1)
+ listings sample to a literal zero, forcing the anchor/#oblast-D-deals-
+ fallback/insufficient_data chain to take over — motivated by a live
+ Серов repro (n=3 → 42 391 ₽/м², −36% vs the town's ДКП corridor of
+ 54 126 ₽/м²).
+ 2. #oblast-F (2026-08-10, first pass) reversed that suppression WHOLESALE —
+ a thin sample always kept its own median, even when a much more reliable
+ deals corridor was available. That accidentally REOPENED the exact Серов
+ bug #oblast-E existed to close.
+ 3. #oblast-E priority RESTORED (2026-08-10, same day, product correction):
+ "никогда не блокировать вывод" ≠ "предпочитать шумную медиану по 3
+ объявлениям надёжному коридору по 54 сделкам". Final 3-way rule, in
+ `_price_from_inputs`'s gate:
+ - n_analogs >= HEADLINE_LISTINGS_MIN_N → listings median (unaffected).
+ - 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND a usable ДКП corridor
+ exists (count >= DEALS_HEADLINE_FALLBACK_MIN_N, median_ppm2 > 0) →
+ listings aggregate suppressed to zero, headline ceded to the
+ #oblast-D deals-headline-fallback chain (original #oblast-E
+ behaviour, restored). `PricingResult.deals_headline_due_to_thin_
+ listings=True` — estimate_quality() adds relaxation label "оценка по
+ сделкам — мало объявлений рядом" and caps reliability at 'low'.
+ Listings display cards are NOT hidden (unlike original #oblast-E) —
+ `listings_clean` stays intact and estimate_quality() still surfaces
+ them as context even though they no longer drive n_analogs/median.
+ - 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND no usable ДКП corridor →
+ #oblast-F: keep the real thin median (never refuse outright).
+ Real refusal ("недостаточно данных") now happens ONLY at genuine n=0
+ (no listings AND no usable anchor/deals) — the never-block requirement
+ with an honest, priority-ordered source selection.
+
+`estimate_quality()` tries to grow a thin sample FIRST via the #oblast-F
+relaxation cascade (room-adjacency / freshness / novostroyki / radius, see
+estimator.py module docstring) BEFORE `_price_from_inputs` (tested here in
+Layer 1) ever runs the 3-way gate above — `listings` here is whatever that
+cascade could find.
Two layers:
1. `_price_from_inputs` unit tests (no DB, no estimate_quality overhead) —
- boundary behaviour of the gate itself.
+ boundary behaviour of the gate itself: the 3-way rule, low-reliability
+ wording, listings_clean/listings_headline_thin_n/deals_headline_due_to_
+ thin_listings bookkeeping.
2. `estimate_quality` integration tests — proves the money-path invariants
- that matter to a caller: literal 0 never leaks as a "confident" price,
- display `analogs` cards never outnumber what `n_analogs` claims, and the
- explanation text describes what actually happened (not a stock "аналогов
- не найдено" when some WERE found, just too few).
+ that matter to a caller: thin+usable-deals routes to the deals corridor
+ (Серов repro), thin+no-deals keeps its own median, display `analogs`
+ cards are shown either way, and the #oblast-F room-adjacency relaxation
+ (studio↔1-комн) actually grows a thin sample and is reported via
+ `AggregatedEstimate.relaxations` / `reliability`.
"""
from __future__ import annotations
@@ -103,15 +136,14 @@ def test_threshold_is_five_not_lower() -> None:
assert HEADLINE_LISTINGS_MIN_N == 5
-def test_four_listings_below_threshold_suppressed_no_fallback() -> None:
- """n=4 (< 5), no ДКП signal → headline suppressed to the honest zero state,
- NOT the naive median of 4 listings."""
+def test_four_listings_below_threshold_kept_not_suppressed() -> None:
+ """#oblast-F: n=4 (< 5) → the REAL 4-listing median is kept (product decision
+ 2026-08-10 — never zero out a thin-but-real sample), just flagged low."""
pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0, 230_000.0]))
- assert pr.median_ppm2 == 0.0
- assert pr.median_price == 0
- assert pr.n_analogs == 0
- assert pr.range_low == 0
- assert pr.range_high == 0
+ assert pr.median_ppm2 == 215_000.0
+ assert pr.n_analogs == 4
+ assert pr.median_price == round(215_000.0 * 45.0)
+ assert pr.confidence == "low"
def test_five_listings_at_threshold_not_suppressed() -> None:
@@ -122,18 +154,21 @@ def test_five_listings_at_threshold_not_suppressed() -> None:
assert pr.median_price == round(210_000.0 * 45.0)
-def test_one_listing_below_threshold_suppressed() -> None:
- """n=1 — the sharpest form of the Серов bug (a single random lot deciding
- the whole headline) — must be suppressed exactly like n=4."""
+def test_one_listing_below_threshold_kept_not_suppressed() -> None:
+ """#oblast-F: n=1 — the sharpest thin case — still keeps its own (single-lot)
+ median rather than being zeroed; confidence stays 'low'."""
pr = _call(listings=_lots([200_000.0]))
- assert pr.median_ppm2 == 0.0
- assert pr.n_analogs == 0
+ assert pr.median_ppm2 == 200_000.0
+ assert pr.n_analogs == 1
+ assert pr.confidence == "low"
def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None:
- """n=3 listings (thin) + a usable ДКП corridor → headline comes from the
- deal corridor median, NOT the 3-listing median (live Серов repro: 3
- listings gave 42 391 vs the honest ДКП-based ~54 126)."""
+ """#oblast-E priority RESTORED (2026-08-10 product correction): a thin
+ (n=3) listings sample must NOT outrank a usable ДКП deals corridor — this
+ is the exact live Серов repro #oblast-E exists for (3 noisy listings gave
+ 42 391 ₽/м², the honest 54-deal corridor gives 65 957 ₽/м²). Headline
+ comes from the deal corridor median, NOT the 3-listing median."""
dkp_raw = {
"count": 54,
"low_ppm2": 44_000,
@@ -151,12 +186,23 @@ def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None:
)
assert pr.n_analogs == 0, "honest: 0 scraped-listing analogs back this headline"
assert pr.confidence == "low"
+ assert pr.deals_headline_due_to_thin_listings is True
+ assert pr.listings_clean, "listings_clean must stay intact — display cards still show them"
+ # #4: explanation must not falsely claim "рядом нет объявлений" (some WERE
+ # found, just ceded priority to the more reliable deals corridor) and must
+ # NOT also carry the separate "Оценка построена по N аналогам" thin-kept
+ # wording (that phrasing is reserved for the no-usable-corridor branch).
+ assert pr.explanation is not None
+ assert "рядом нет актуальных объявлений" not in pr.explanation.lower()
+ assert "сделкам росреестра" in pr.explanation.lower()
+ assert "оценка построена по 3" not in pr.explanation.lower()
-def test_thin_sample_with_insufficient_deals_stays_zero() -> None:
+def test_thin_sample_with_thin_deals_also_uses_real_listings_median() -> None:
"""n=3 listings (thin) + a ДКП corridor that is ITSELF too thin
- (< DEALS_HEADLINE_FALLBACK_MIN_N) → neither source is trusted; honest zero,
- not a fabricated number from either side."""
+ (< DEALS_HEADLINE_FALLBACK_MIN_N) → the corridor is NOT usable, so
+ #oblast-F's never-block rule applies: the real listings median is kept
+ rather than refusing (neither source alone would justify a hard zero)."""
dkp_raw = {
"count": 1,
"low_ppm2": 40_000,
@@ -165,49 +211,51 @@ def test_thin_sample_with_insufficient_deals_stays_zero() -> None:
"period_months": 12,
}
pr = _call(listings=_lots([42_391.0, 26_818.0, 75_058.0]), dkp_raw=dkp_raw)
- assert pr.median_ppm2 == 0.0
- assert pr.median_price == 0
- assert pr.n_analogs == 0
+ assert pr.median_ppm2 == 42_391.0
+ assert pr.n_analogs == 3
+ assert pr.deals_headline_due_to_thin_listings is False
-def test_thin_sample_explanation_is_honest_about_count() -> None:
- """The explanation for a thin-but-nonzero sample must say HOW MANY listings
- were found (not the generic 'ничего не найдено' text used for a genuine
- zero-listing case) — #4 in the task: explanation must match reality."""
+def test_thin_sample_explanation_is_honest_about_low_accuracy() -> None:
+ """#4 (task spec): the explanation for a thin-but-real sample must read as
+ "small sample, lower accuracy" — NOT the old refusal-flavoured "минимум для
+ оценки по рынку" copy, and NOT the generic zero-analogs text."""
pr = _call(listings=_lots([200_000.0, 210_000.0])) # n=2
assert pr.explanation is not None
assert "2" in pr.explanation
- assert "недостаточно" in pr.explanation.lower()
- # Must NOT reuse the "nothing found at all" copy — 2 listings WERE found.
+ assert "выборка мала" in pr.explanation.lower()
+ assert "точность снижена" in pr.explanation.lower()
+ assert "минимум для оценки по рынку" not in pr.explanation.lower()
assert "не найдено аналогов" not in pr.explanation.lower()
-def test_thin_sample_deals_fallback_explanation_does_not_claim_zero_listings() -> None:
- """#4: once the ДКП fallback fires for a thin (not zero) sample, the
- explanation must not falsely claim 'рядом нет объявлений' — some WERE
- found, just not enough to trust."""
+def test_zero_listings_with_sufficient_deals_still_uses_deals_headline() -> None:
+ """Control: the #oblast-D deals-headline-fallback path is UNCHANGED for
+ GENUINELY zero listings (n=0) — #oblast-F only affects the 1..N-1 thin
+ case, not the true-zero case, which still needs a fallback source."""
dkp_raw = {
- "count": 20,
- "low_ppm2": 40_000,
- "median_ppm2": 60_000,
- "high_ppm2": 80_000,
+ "count": 54,
+ "low_ppm2": 44_000,
+ "median_ppm2": 65_957,
+ "high_ppm2": 89_000,
"period_months": 12,
}
- pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0]), dkp_raw=dkp_raw)
+ pr = _call(listings=[], dkp_raw=dkp_raw)
+ assert pr.median_ppm2 == 65_957.0
+ assert pr.n_analogs == 0
+ assert pr.confidence == "low"
assert pr.explanation is not None
- assert "рядом нет актуальных объявлений" not in pr.explanation.lower()
+ assert "рядом нет актуальных объявлений" in pr.explanation.lower()
assert "сделкам росреестра" in pr.explanation.lower()
-def test_thin_sample_listings_clean_preserved_for_anchor_ghost_guard() -> None:
- """Regression guard: the gate must suppress the AGGREGATE (median/n_analogs)
- without clearing `listings_clean` itself — the same-building anchor's own
- ghost-anchor guard (#1871) reads `listings_clean` truthiness to tell
- "genuinely zero nearby listings" from "some nearby, just too few to trust
- as headline", and conflating the two was caught regressing
- test_estimator_split_corridor_1871.py during this change."""
+def test_thin_sample_listings_clean_preserved_and_thin_n_still_tracked() -> None:
+ """listings_clean stays intact (unchanged invariant — same-building anchor's
+ ghost-anchor guard #1871 depends on it) AND, post-#oblast-F, n_analogs is
+ the REAL count (not zeroed) while listings_headline_thin_n still marks the
+ sample as thin for the low-reliability note upstream."""
pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0]))
- assert pr.n_analogs == 0
+ assert pr.n_analogs == 3
assert len(pr.listings_clean) == 3
assert pr.listings_headline_thin_n == 3
@@ -219,6 +267,42 @@ def test_sufficient_sample_listings_headline_thin_n_is_zero() -> None:
assert pr.listings_headline_thin_n == 0
+def test_repair_coefficient_now_applies_to_thin_sample() -> None:
+ """#oblast-F: pre-#oblast-F, the repair-state coefficient was skipped for a
+ thin sample because the headline was already zeroed (applying it would be a
+ no-op). Now that the real median is kept, the coefficient must apply."""
+ pr_no_repair = _call(listings=_lots([200_000.0, 210_000.0])) # n=2, thin
+ pr = _price_from_inputs(
+ listings=_lots([200_000.0, 210_000.0]),
+ area_m2=45.0,
+ rooms=2,
+ repair_state="excellent",
+ floor=5,
+ total_floors=9,
+ target_year=None,
+ analog_tier="W",
+ fallback_used=False,
+ area_widened=False,
+ anchor_comps=[],
+ anchor_tier_fetched=None,
+ dkp_raw=None,
+ imv_anchor=None,
+ imv_eval=None,
+ yandex_val_present=False,
+ cian_val_present=False,
+ ratio_resolver=lambda _appm2: (None, None),
+ quarter_index_lookup=lambda q: None,
+ quarter_indexes_lookup=lambda qs: {},
+ target_house_cadnum=None,
+ dadata_coarse=False,
+ geo=_geo(),
+ dadata_qc_geo=None,
+ )
+ assert (
+ pr.median_price != pr_no_repair.median_price
+ ), "repair coefficient must be applied even for a thin (#oblast-E-flagged) sample"
+
+
# ─────────────────────────────────────────────────────────────────────────────
# Layer 2 — `estimate_quality` integration tests (full stub-patched I/O path)
# ─────────────────────────────────────────────────────────────────────────────
@@ -261,24 +345,31 @@ def _serov_payload() -> Any:
def _run_estimate(
*,
- analogs: list[dict[str, Any]],
+ analogs: list[dict[str, Any]] | None = None,
dkp_raw: dict[str, Any] | None,
+ fetch_analogs_side_effect: Any = None,
+ payload: Any = None,
+ geo: GeocodeResult | None = None,
) -> Any:
from app.services.estimator import estimate_quality
db = MagicMock()
- payload = _serov_payload()
+ payload = payload or _serov_payload()
+ geo = geo or _geo()
+
+ fetch_analogs_kwargs: dict[str, Any] = (
+ {"side_effect": fetch_analogs_side_effect}
+ if fetch_analogs_side_effect is not None
+ else {"return_value": (list(analogs or []), False, "W")}
+ )
async def _run() -> Any:
with (
- patch("app.services.estimator.geocode", new=AsyncMock(return_value=_geo())),
+ patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
- patch(
- "app.services.estimator._fetch_analogs",
- return_value=(list(analogs), False, "W"),
- ),
+ patch("app.services.estimator._fetch_analogs", **fetch_analogs_kwargs),
patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch(
@@ -301,40 +392,47 @@ def _run_estimate(
return anyio.run(_run)
-def test_e2e_thin_no_deals_never_leaks_literal_zero_as_confident_price() -> None:
- """Каменск-Уральский-style repro: thin listings, no usable ДКП corridor —
- median_price_rub must be 0 AND insufficient_data must be True TOGETHER
- (the AggregatedEstimate.insufficient_data computed_field invariant that
- stops a literal 0 ₽ reaching the user as a confident number)."""
+def test_e2e_thin_sample_no_relaxation_help_keeps_real_median() -> None:
+ """#oblast-F: 2 thin listings, no ДКП, and the mocked `_fetch_analogs` always
+ returns the SAME 2 listings regardless of relaxation params (none of them
+ help) — median_price_rub must be the REAL non-zero 2-listing median,
+ insufficient_data False, n_analogs=2, confidence='low', reliability
+ 'very_low' (n<3), relaxations empty (nothing actually helped)."""
analogs = [
_make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"),
_make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"),
]
est = _run_estimate(analogs=analogs, dkp_raw=None)
- assert est.median_price_rub == 0
- assert est.insufficient_data is True
- assert est.n_analogs == 0
+ assert est.median_price_rub == round(205_000.0 * 45.0)
+ assert est.insufficient_data is False
+ assert est.n_analogs == 2
assert est.confidence == "low"
+ assert est.relaxations == []
+ assert est.reliability == "very_low"
-def test_e2e_thin_sample_display_cards_never_outnumber_n_analogs() -> None:
- """The 2 thin listings must NOT be surfaced as `analogs` display cards while
- n_analogs reports 0 — that would be the same dishonesty (confident-looking
- UI) this whole gate exists to remove."""
+def test_e2e_thin_sample_display_cards_match_n_analogs() -> None:
+ """#oblast-F: display `analogs` cards are NO LONGER suppressed for a thin
+ sample — they must match n_analogs exactly (both = 2), never hidden."""
analogs = [
_make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"),
_make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"),
]
est = _run_estimate(analogs=analogs, dkp_raw=None)
- assert est.n_analogs == 0
- assert est.analogs == []
+ assert est.n_analogs == 2
+ assert len(est.analogs) == 2
def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None:
- """Live Серов repro (n=3 scraped listings, wide ДКП corridor available):
- headline must come from the deal corridor, not the noisy 3-listing median,
- and the estimate must be honestly non-'insufficient' (a real number, low
- confidence, deals-sourced)."""
+ """Live Серов repro (n=3 scraped listings, wide ДКП corridor available) —
+ #oblast-E priority RESTORED: headline must come from the deal corridor,
+ not the noisy 3-listing median. Also proves the #4 task-spec requirements
+ layered on top of the restored priority: the estimate is honestly non-
+ 'insufficient' (a real number, low confidence), reliability is capped at
+ 'low' (not 'very_low' — a 54-deal corridor is real signal), the
+ relaxation label names the source switch, AND the 3 thin listings are
+ still shown as display cards (not discarded) even though they no longer
+ drive n_analogs/median."""
analogs = [
_make_listing(price_per_m2=42_391.0, address="ул. Льва Толстого, 8А"),
_make_listing(price_per_m2=26_818.0, address="ул. Кирова, 4"),
@@ -354,12 +452,15 @@ def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None:
assert est.confidence == "low"
assert est.confidence_explanation is not None
assert "сделкам росреестра" in est.confidence_explanation.lower()
+ assert est.reliability == "low", "a 54-deal corridor is real signal, not 'very_low'"
+ assert "оценка по сделкам — мало объявлений рядом" in est.relaxations
+ assert len(est.analogs) == 3, "thin listings must still surface as display cards"
def test_e2e_sufficient_five_analogs_unaffected_control() -> None:
"""Control (mirrors the Екатеринбург prod check in the PR): a sample that
clears the threshold is priced exactly as before — headline is the real
- listings median, all 5 analogs counted."""
+ listings median, all 5 analogs counted, no relaxations needed."""
analogs = [
_make_listing(price_per_m2=195_000.0, address="ул. Ленина, 5"),
_make_listing(price_per_m2=205_000.0, address="ул. Ленина, 7"),
@@ -371,3 +472,78 @@ def test_e2e_sufficient_five_analogs_unaffected_control() -> None:
assert est.median_price_per_m2 == 210_000
assert est.n_analogs == 5
assert est.insufficient_data is False
+ assert est.relaxations == []
+ assert est.reliability == "low" # n=5 falls in the 3..7 bucket
+
+
+def test_e2e_rooms_relaxation_includes_studios_when_thin() -> None:
+ """#oblast-F step (a) — the exact scenario from the task spec: rooms=1 thin
+ sample (studio-adjacent building, live prod repro Академика Парина 46/5) →
+ cascade retries with rooms IN (0,1) and finds a trustworthy sample there.
+ Asserts: studios pulled in, `relaxations` names it, real non-zero median,
+ reliability downgraded to 'low' (thin base sample)."""
+ from app.schemas.trade_in import TradeInEstimateInput
+
+ exact_rooms1 = [
+ _make_listing(price_per_m2=150_000.0, address="ул. Парина, 1", area_m2=23.0),
+ _make_listing(price_per_m2=155_000.0, address="ул. Парина, 2", area_m2=23.0),
+ ]
+ studio_pool = [
+ *exact_rooms1,
+ _make_listing(price_per_m2=140_000.0, address="ул. Парина, 3", area_m2=20.0),
+ _make_listing(price_per_m2=145_000.0, address="ул. Парина, 4", area_m2=21.0),
+ _make_listing(price_per_m2=148_000.0, address="ул. Парина, 5", area_m2=22.0),
+ ]
+
+ def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]:
+ if kwargs.get("rooms_min") == 0 and kwargs.get("rooms_max") == 1:
+ return list(studio_pool), False, "W"
+ return list(exact_rooms1), False, "W"
+
+ geo = GeocodeResult(
+ lat=56.838,
+ lon=60.595,
+ full_address="Свердловская обл., Екатеринбург, ул. Парина, 46/5",
+ provider="nominatim",
+ )
+ payload = TradeInEstimateInput(
+ address="ЕКБ, ул. Парина, 46/5",
+ area_m2=23.1,
+ rooms=1,
+ )
+
+ est = _run_estimate(
+ dkp_raw=None,
+ fetch_analogs_side_effect=_fetch_analogs_stub,
+ payload=payload,
+ geo=geo,
+ )
+
+ assert "учтены студии" in est.relaxations
+ assert est.median_price_rub > 0
+ assert est.reliability == "low"
+ assert est.n_analogs == 5
+
+
+def test_e2e_radius_relaxation_respects_explicit_user_radius() -> None:
+ """#oblast-F step (d) contract: when the user explicitly picked radius_m
+ (#2044), the cascade must NOT auto-expand past it — mirrors the existing
+ radius-fallback contract above (no auto-expansion beyond user's choice)."""
+ from app.schemas.trade_in import TradeInEstimateInput
+
+ thin = [
+ _make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"),
+ _make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"),
+ ]
+ payload = TradeInEstimateInput(
+ address="Серов, ул. Ленина, 5",
+ area_m2=45.0,
+ rooms=2,
+ floor=5,
+ total_floors=9,
+ city_hint="Серов",
+ radius_m=1500,
+ )
+ est = _run_estimate(analogs=thin, dkp_raw=None, payload=payload)
+ assert not any("радиус расширен" in r for r in est.relaxations)
+ assert est.search_radius_m == 1500
diff --git a/tradein-mvp/backend/tests/test_pdf_security.py b/tradein-mvp/backend/tests/test_pdf_security.py
index da5722ec..7d6d21ed 100644
--- a/tradein-mvp/backend/tests/test_pdf_security.py
+++ b/tradein-mvp/backend/tests/test_pdf_security.py
@@ -423,6 +423,86 @@ def test_build_listings_page_none_year_built_no_crash() -> None:
assert "РЫНОК КВАРТИР" in html
+# ── #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10) ────────
+# n_analogs==0 (headline ceded to the ДКП deals corridor, estimator.py
+# `deals_headline_due_to_thin_listings`) with a non-empty `analogs` display list
+# (thin listings kept as reference cards) used to print "0 шт." above a
+# non-empty examples table — a client-visible contradiction that leaked into
+# the PDF handed to clients. See _build_listings_page / _deals_sourced_thin_
+# listings_note_html / _reliability_note_html.
+
+
+def test_listings_page_zero_analogs_shown_cards_no_false_zero_count() -> None:
+ """The exact bug: n_analogs=0 + 3 shown analogs must NOT print '0 шт.' —
+ falls back to the actually-shown population (3) and adds an honest
+ deals-sourced footnote."""
+ analogs = [
+ _analog(address="ул. Льва Толстого, 8А"),
+ _analog(address="ул. Кирова, 4"),
+ _analog(address="ул. Льва Толстого, 34"),
+ ]
+ est = _estimate(n_analogs=0, analogs=analogs, sources_used=["avito"])
+ html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
+ assert "0 шт." not in html
+ assert "3 шт." in html
+ assert "Оценка построена по зарегистрированным сделкам Росреестра" in html
+ assert "почти нет" in html
+
+
+def test_listings_page_zero_analogs_empty_cards_stays_honest_zero() -> None:
+ """Control: genuinely zero listings (no cards to show either) — '0 шт.' is
+ honest here, and the deals-sourced footnote (which explains a MISMATCH)
+ must NOT appear since there is nothing to reconcile."""
+ est = _estimate(n_analogs=0, analogs=[])
+ html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
+ assert "0 шт." in html
+ assert "Оценка построена по зарегистрированным сделкам Росреестра" not in html
+
+
+def test_listings_page_healthy_sample_keeps_full_n_analogs_not_capped_len() -> None:
+ """Control/regression guard for the max() choice: a healthy sample where
+ n_analogs (15) EXCEEDS the capped display list (10, AggregatedEstimate's
+ own top-10 cap) must keep printing the full honest count (15 шт.), NOT
+ silently understate it to len(analogs) (10 шт.)."""
+ analogs = [_analog(address=f"ул. Тест, {i}") for i in range(10)]
+ est = _estimate(n_analogs=15, analogs=analogs, sources_used=["avito"])
+ html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
+ assert "15 шт." in html
+ assert "10 шт." not in html
+
+
+def test_listings_page_relaxations_warning_shown_with_labels() -> None:
+ """relaxations non-empty → warning block present, names the labels, and
+ reliability != 'ok' — mirrors what the web LowConfidenceBanner already
+ shows (see AggregatedEstimate docstring)."""
+ est = _estimate(relaxations=["учтены студии", "радиус расширен до 3000 м"], reliability="low")
+ html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
+ assert "Точность оценки снижена." in html
+ assert "учтены студии" in html
+ assert "радиус расширен до 3000 м" in html
+
+
+def test_listings_page_reliability_downgraded_no_relaxations_fallback_text() -> None:
+ """reliability != 'ok' but relaxations is empty (cascade couldn't grow a
+ thin sample, estimator.py #oblast-F) → warning block still shown, with a
+ fallback sentence (not an empty label list)."""
+ est = _estimate(n_analogs=2, reliability="very_low", relaxations=[])
+ html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
+ assert "Точность оценки снижена." in html
+ assert "небольшой выборке" in html
+
+
+def test_listings_page_no_warning_block_when_ok_and_no_relaxations() -> None:
+ """Control: the common/unrelaxed case (reliability='ok' default, no
+ relaxations) — no warning block at all, byte-identical to the report
+ before these fields existed."""
+ est = _estimate()
+ assert est.reliability == "ok"
+ assert est.relaxations == []
+ html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
+ assert "Точность оценки снижена." not in html
+
+
def test_build_deals_page_none_year_built_no_crash() -> None:
snap = dict(_SNAPSHOT)
snap["year_built"] = None
diff --git a/tradein-mvp/backend/tests/test_street_deals_endpoint.py b/tradein-mvp/backend/tests/test_street_deals_endpoint.py
index c1ea1249..003883e1 100644
--- a/tradein-mvp/backend/tests/test_street_deals_endpoint.py
+++ b/tradein-mvp/backend/tests/test_street_deals_endpoint.py
@@ -107,6 +107,64 @@ def test_extract_street_name_parametrized(address: str | None, expected: str | N
assert extract_street_name(address) == expected
+@pytest.mark.parametrize(
+ "address,expected",
+ [
+ # Live-prod repro (2026-08-10): DaData format — abbreviations WITHOUT a
+ # trailing dot ("ул Академика Парина", not "ул. ..."), plus a leading
+ # postal index + admin parts ("620105, Свердловская обл, г
+ # Екатеринбург, Академический р-н, ..."). Old `_STREET_KW_RE` required
+ # the dot → keyword never matched on ANY DaData address → street-deals
+ # / sales-vs-listings endpoints silently returned empty for every
+ # DaData-geocoded request, not just this one.
+ (
+ "620105, Свердловская обл, г Екатеринбург, Академический р-н, "
+ "ул Академика Парина, д 46/5",
+ "Академика Парина",
+ ),
+ # Same address, WITH the dot — must give the identical result (dot
+ # optional, not dot-forbidden).
+ (
+ "620105, Свердловская обл, г Екатеринбург, Академический р-н, "
+ "ул. Академика Парина, д 46/5",
+ "Академика Парина",
+ ),
+ # Same address, full word "улица" — the alternation-order/backtracking
+ # concern: "ул\\.?" must NOT eat the "ул" prefix of "улица" and leave
+ # "ица ..." behind.
+ (
+ "620105, Свердловская обл, г Екатеринбург, Академический р-н, "
+ "улица Академика Парина, д 46/5",
+ "Академика Парина",
+ ),
+ # Without the leading postal index — same admin prefix otherwise.
+ (
+ "Свердловская обл, г Екатеринбург, Академический р-н, ул Академика Парина, д 46/5",
+ "Академика Парина",
+ ),
+ # Bare street+house, no admin prefix at all.
+ ("ул Академика Парина, д 46/5", "Академика Парина"),
+ # Other dot-optional abbreviations from _STREET_KW_RE (пр/пер/ш/наб/пл/мкр).
+ ("г Екатеринбург, пр Ленина, 5", "Ленина"),
+ ("г Екатеринбург, пер Красный, 4", "Красный"),
+ ("г Екатеринбург, наб Реки Исеть, 1", "Реки Исеть"),
+ # "ул. X" / "ул X" / "улица X" must all agree (no dot-optional regression).
+ ("Екатеринбург, ул. Малышева, 1", "Малышева"),
+ ("Екатеринбург, ул Малышева, 1", "Малышева"),
+ ("Екатеринбург, улица Малышева, 1", "Малышева"),
+ ],
+)
+def test_extract_street_name_dadata_no_dot_abbreviations(
+ address: str | None, expected: str | None
+) -> None:
+ """#pdf-honesty/street-deals live-prod fix (2026-08-10): DaData addresses
+ use dot-less abbreviations ("ул", "пр", "пер", "ш", "наб", "пл", "мкр")
+ — _STREET_KW_RE must match them exactly like the dotted forms."""
+ from app.services.estimator import extract_street_name
+
+ assert extract_street_name(address) == expected
+
+
# ── Helpers ───────────────────────────────────────────────────────────────────
diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx
index 49cc2846..f0164c23 100644
--- a/tradein-mvp/frontend/src/app/v2/page.tsx
+++ b/tradein-mvp/frontend/src/app/v2/page.tsx
@@ -20,6 +20,7 @@ import TopNav from "@/components/trade-in/v2/TopNav";
import HeroBar from "@/components/trade-in/v2/HeroBar";
import ParamsPanel from "@/components/trade-in/v2/ParamsPanel";
import ResultPanel from "@/components/trade-in/v2/ResultPanel";
+import { LowConfidenceBanner } from "@/components/trade-in/v2/LowConfidenceBanner";
import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary";
import { LeadForm } from "@/components/trade-in/v2/LeadForm";
import { Footer } from "@/components/trade-in/v2/Footer";
@@ -584,6 +585,18 @@ export default function TradeInV2Page() {
// — no hydration drift, same reason the PDF control is gated behind `mounted`.
const hasEstimate = mounted && estimate != null && !insufficient;
+ // fix (never-block estimate) — reliability/relaxations are optional on the
+ // wire (old/cached estimates predate the backend fields), default to the
+ // "nothing to disclose" values so a stale response never fabricates a
+ // warning. LowConfidenceBanner mounts above the result whenever the sample
+ // was thin (reliability !== "ok") or the backend had to relax the search to
+ // produce a price at all (relaxations.length > 0) — never on insufficient
+ // (no price at all — that stays InsufficientPanel, no banner to layer over).
+ const reliability = estimate?.reliability ?? "ok";
+ const relaxations = estimate?.relaxations ?? [];
+ const showLowConfidenceBanner =
+ !insufficient && (reliability !== "ok" || relaxations.length > 0);
+
// ── Mapped presentation data (memoised so nav/drawer toggles don't recompute
// geometry). ──────────────────────────────────────────────────────────
const report = useMemo(
@@ -772,12 +785,31 @@ export default function TradeInV2Page() {
/>
);
} else if (estimate && !insufficient && resultPanelData) {
+ // Banner is a sibling ABOVE ResultPanel, not a change to ResultPanel
+ // itself — the wrapper only replaces the direct grid child; ResultPanel's
+ // own markup/props are untouched from before this fix.
middleContent = (
-
+
+ {showLowConfidenceBanner && (
+
+ )}
+
+
);
} else if (estimate && insufficient) {
middleContent =
;
diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
index b55e7e62..1b89abe9 100644
--- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
@@ -185,6 +185,15 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
const [enrichRepairState, setEnrichRepairState] = useState
("");
// Фото первого аналога с картинкой — вместо пустого серого плейсхолдера.
const heroPhoto = estimate.analogs.find((a) => a.photo_url)?.photo_url ?? null;
+ // fix (v1 stale-tail) — n_analogs=0 больше не значит «аналогов нет»: бэкенд
+ // может посчитать headline по зарегистрированным сделкам ДКП, но всё равно
+ // отдать тонкую выборку объявлений в estimate.analogs (её же показывает
+ // ListingsCard ниже на этой странице) — «0 аналогов» рядом с видимыми
+ // карточками было бы прямым противоречием. Тон — как у v2 LowConfidenceBanner.
+ const analogsCaption =
+ estimate.n_analogs > 0 || estimate.analogs.length === 0
+ ? `${estimate.n_analogs} аналогов`
+ : "оценка построена по зарегистрированным сделкам";
// Расчёт ширины для price bar (50% = середина): медиана внутри min/max
const span = hi - lo;
const medianPctRaw = span > 0 ? ((m - lo) / span) * 100 : 50;
@@ -292,7 +301,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{heroPhoto
? `фото аналога${estimate.sources_used[0] ? ` · ${sourceLabel(estimate.sources_used[0])}` : ""}`
: estimate.sources_used.length > 0
- ? `${sourceLabel(estimate.sources_used[0])} · ${estimate.n_analogs} аналогов`
+ ? `${sourceLabel(estimate.sources_used[0])} · ${analogsCaption}`
: "Нет фото"}