From f1f0d297cf85c23be31b7ced06fc528b6f1dd582 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:23:47 +0500 Subject: [PATCH 01/47] =?UTF-8?q?=D0=9C=D0=95=D0=A0=D0=90=20v2:=20=D0=BA?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA=20=D0=BF=D0=BE=20=C2=AB=D0=9F=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D1=8B=D0=B4=D1=83=D1=89=D0=B8=D0=B5=20=D0=BE=D1=86=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=B8=C2=BB=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=BD=D0=BD=D1=83=D1=8E=20=D0=BE=D1=86=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D1=83=20(#2425)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После отправки оценки freshResult оставался выставленным, и клик по строке истории ничего не менял на экране: useEstimate получал null (восстановление по id вообще не запускалось), а estimate и currentEstimateId брались из только что посчитанного результата. onSelectEstimate теперь сбрасывает freshResult так же, как это делает handleNew. Тест рендерит страницу с заглушками хуков: submit, затем клик по другой строке истории — в useEstimate уходит id строки, HeroBar и форма получают её id и адрес. Без правки тест красный (useEstimate вызван с null). Co-Authored-By: Claude Opus 5 --- .../historyClickAfterSubmit.test.tsx | 121 ++++++++++++++++++ tradein-mvp/frontend/src/app/v2/page.tsx | 5 + 2 files changed, 126 insertions(+) create mode 100644 tradein-mvp/frontend/src/app/v2/__tests__/historyClickAfterSubmit.test.tsx diff --git a/tradein-mvp/frontend/src/app/v2/__tests__/historyClickAfterSubmit.test.tsx b/tradein-mvp/frontend/src/app/v2/__tests__/historyClickAfterSubmit.test.tsx new file mode 100644 index 00000000..e7d36a7d --- /dev/null +++ b/tradein-mvp/frontend/src/app/v2/__tests__/historyClickAfterSubmit.test.tsx @@ -0,0 +1,121 @@ +/** + * #2425 — клик по строке «Предыдущие оценки» после отправки оценки в той же + * SPA-сессии должен показать ВЫБРАННУЮ запись, а не только что посчитанную. + * + * Раньше `freshResult` оставался выставленным: `useEstimate` получал null + * (восстановление по id не запускалось вовсе), а экран продолжал показывать + * свежий результат. Проверяется по значению: какой id ушёл в `useEstimate`, + * какой id получил HeroBar и чей адрес лёг в форму. + */ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture"; +import type { AggregatedEstimate } from "@/types/trade-in"; + +const FRESH: AggregatedEstimate = { + ...FIXTURE_ESTIMATE, + estimate_id: "fresh-1", + target_address: "Екатеринбург, ул. Свежая, 1", +}; +const HIST: AggregatedEstimate = { + ...FIXTURE_ESTIMATE, + estimate_id: "hist-1", + target_address: "Екатеринбург, ул. Историческая, 2", +}; + +const useEstimateMock = vi.hoisted(() => vi.fn()); +const router = vi.hoisted(() => ({ replace: vi.fn(), push: vi.fn() })); + +vi.mock("next/navigation", () => ({ useRouter: () => router })); +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +vi.mock("@/lib/trade-in-api", () => { + const empty = () => ({ data: undefined }); + return { + useEstimate: useEstimateMock, + useEstimateMutation: () => ({ + mutate: (_input: unknown, opts: { onSuccess: (e: AggregatedEstimate) => void }) => + opts.onSuccess(FRESH), + isPending: false, + error: null, + }), + useEstimateHistory: empty, + useEstimateHouseAnalytics: empty, + useEstimatePlacementHistory: empty, + useEstimateSellTimeSensitivity: empty, + useLocationIndex: empty, + useSalesVsListings: empty, + useStreetDeals: empty, + }; +}); +vi.mock("@/lib/useQuota", () => ({ useQuota: () => ({ data: undefined }) })); +vi.mock("@/lib/useMe", () => ({ useMe: () => ({ data: undefined }) })); +vi.mock("@/lib/useLogout", () => ({ useLogout: () => ({ mutate: vi.fn() }) })); + +// Дочерние компоненты — заглушки: проверяется только, что страница им отдаёт. +vi.mock("@/components/trade-in/v2/TopNav", () => ({ + default: ({ onNavigate }: { onNavigate: (n: number) => void }) => ( + + ), +})); +vi.mock("@/components/trade-in/v2/HeroBar", () => ({ + default: ({ estimateId }: { estimateId: string | null }) => ( +
{estimateId ?? ""}
+ ), +})); +vi.mock("@/components/trade-in/v2/ParamsPanel", () => ({ + default: ({ + onSubmit, + initialValues, + }: { + onSubmit: (i: unknown) => void; + initialValues?: { address?: string }; + }) => ( +
+
{initialValues?.address ?? ""}
+ +
+ ), +})); +vi.mock("@/components/trade-in/v2/SectionOverlay", () => ({ + default: ({ onSelectEstimate }: { onSelectEstimate: (id: string) => void }) => ( + + ), +})); +vi.mock("@/components/trade-in/v2/ResultPanel", () => ({ default: () => null })); +vi.mock("@/components/trade-in/v2/LowConfidenceBanner", () => ({ + LowConfidenceBanner: () => null, +})); +vi.mock("@/components/trade-in/v2/ObjectSummary", () => ({ ObjectSummary: () => null })); +vi.mock("@/components/trade-in/v2/LeadForm", () => ({ LeadForm: () => null })); +vi.mock("@/components/trade-in/v2/Footer", () => ({ Footer: () => null })); +vi.mock("@/components/trade-in/v2/LocationDrawer", () => ({ LocationDrawer: () => null })); + +import TradeInV2Page from "../page"; + +describe("#2425 — строка истории после submit", () => { + it("клик по другой оценке показывает её, а не только что посчитанную", () => { + useEstimateMock.mockImplementation((id: string | null) => + id === "hist-1" + ? { data: HIST, isPending: false, isError: false, error: null } + : { data: undefined, isPending: id !== null, isError: false, error: null }, + ); + + render(); + + fireEvent.click(screen.getByText("submit")); + expect(screen.getByTestId("hero-id")).toHaveTextContent("fresh-1"); + expect(screen.getByTestId("form-address")).toHaveTextContent("ул. Свежая, 1"); + + fireEvent.click(screen.getByText("open-history")); + fireEvent.click(screen.getByText("row-hist-1")); + + expect(useEstimateMock).toHaveBeenLastCalledWith("hist-1"); + expect(router.replace).toHaveBeenLastCalledWith("/v2?id=hist-1", { scroll: false }); + expect(screen.getByTestId("hero-id")).toHaveTextContent("hist-1"); + expect(screen.getByTestId("form-address")).toHaveTextContent("ул. Историческая, 2"); + }); +}); diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 4917d454..2c7a7b2a 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -1190,6 +1190,11 @@ export default function TradeInV2Page() { // hands `urlId` the correct target id, mirroring how // `freshResult` already overrides this same race for the // post-submit path (handleSubmit above). + // + // #2425: a fresh submit and restore-by-id are mutually exclusive + // both ways (as in handleNew) — a leftover freshResult would + // disable useEstimate(urlId) and keep shadowing the clicked row. + setFreshResult(null); setPendingRestoreId(id); router.replace(`/v2?id=${id}`, { scroll: false }); setNav(0); From 71d60ff13e4d9f229f67447b7180020a6160e47b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:24:20 +0500 Subject: [PATCH 02/47] =?UTF-8?q?fix(mera/=D0=B2=D0=B8=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0):=20=D0=BF=D1=83=D1=81=D1=82=D0=BE=D0=B9=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=81=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA=20=D0=B1?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D1=81=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D1=83=D1=81=D0=BF?= =?UTF-8?q?=D0=B5=D1=88=D0=BD=D1=8B=D0=BC=20(#3511)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обработчик landing_showcase_deals безусловно ставил прогону done. У счётчиков витрины (considered/eligible/written) нет результатного ключа кита, поэтому сводка просроченных судит её только по статусу: прогон с written=0 обнулял часы свежести так же, как удачный, а страница тем временем теряла таблицу. Теперь written=0 — mark_failed с причиной и logger.error. Тест идёт путём сводки: обработчик -> freshness_rows -> stale_sources; пустой последний прогон при старом непустом даёт витрину в тревоге, непустой — нет. Co-Authored-By: Claude Opus 5 --- .../backend/app/services/product_handlers.py | 18 +++- .../tests/test_3469_showcase_schedule.py | 82 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index 42630b40..7cc7d6d6 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -357,7 +357,23 @@ async def _job_landing_showcase_deals( counters = await loop.run_in_executor( None, lambda: refresh_landing_showcase_deals(db, **kwargs) ) - ctx.runs.mark_done(db, run_id, counters) + # #3511: пустая витрина — не успех. Результатного ключа кита + # (`_RESULT_COUNTER_KEYS`) у счётчиков витрины нет, сводка просроченных + # судит её по статусу, и `done` с written=0 гасил часы свежести так же, + # как удачный прогон, — а страница тем временем теряла таблицу целиком. + if not counters.get("written"): + logger.error( + "landing_showcase_deals run_id=%d: витрина пуста (written=0, " + "considered=%s, eligible=%s) — прогон неуспешен", + run_id, + counters.get("considered"), + counters.get("eligible"), + ) + ctx.runs.mark_failed( + db, run_id, "витрина пуста: прогон не записал ни одной строки (written=0)", counters + ) + else: + ctx.runs.mark_done(db, run_id, counters) except Exception: logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id) ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {}) diff --git a/tradein-mvp/backend/tests/test_3469_showcase_schedule.py b/tradein-mvp/backend/tests/test_3469_showcase_schedule.py index 4394079b..79a9a5b0 100644 --- a/tradein-mvp/backend/tests/test_3469_showcase_schedule.py +++ b/tradein-mvp/backend/tests/test_3469_showcase_schedule.py @@ -53,6 +53,7 @@ from datetime import UTC, datetime, timedelta from pathlib import Path from types import SimpleNamespace from typing import Any +from unittest.mock import MagicMock, patch import pytest @@ -211,6 +212,87 @@ def test_showcase_counters_do_not_fake_freshness() -> None: assert sched.run_brought_data("failed", {"considered": 200, "written": 0}) is False +# ── 3а. Пустой прогон витрины не гасит часы свежести (#3511) ──────────────── + + +class _RecordedRuns: + """`ctx.runs`, который кладёт финал прогона строкой `_STALE_SOURCES_SQL`. + + Тогда тест идёт ТЕМ ЖЕ путём, что и сводка на проде: обработчик ставит статус, + `freshness_rows` судит, принёс ли прогон данные, `stale_sources` — просрочку. + Проверка «вызван mark_failed» такой путь не проходит: она зелёная и тогда, + когда статус верный, а мера свежести его не видит. + """ + + def __init__(self) -> None: + self.rows: list[Any] = [] + self.finished_at = NOW + + def _row(self, status: str, counters: dict[str, Any]) -> None: + self.rows.append( + SimpleNamespace( + source=SOURCE, + interval_days=str(_seeded_interval_days()), + created_at=NOW - timedelta(days=30), + finished_at=self.finished_at, + status=status, + counters=counters, + ) + ) + + def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + self._row("done", counters) + + def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + self._row("failed", counters) + + +async def _run_handler(runs: _RecordedRuns, counters: dict[str, int], age_days: float) -> None: + runs.finished_at = NOW - timedelta(days=age_days) + with patch( + "app.tasks.landing_showcase_deals.refresh_landing_showcase_deals", + return_value=counters, + ): + await _job_landing_showcase_deals(MagicMock(), 1, {}, SimpleNamespace(runs=runs)) + + +_FULL = {"considered": 200, "priced": 180, "eligible": 160, "written": 20} +_EMPTY = {"considered": 200, "priced": 180, "eligible": 160, "written": 0} + + +@pytest.mark.asyncio +async def test_empty_showcase_run_does_not_reset_freshness() -> None: + """Последний непустой прогон старше 3× такта, вчерашний пустой → витрина в тревоге. + + До #3511 пустой прогон завершался `done`, и `run_brought_data` (судит по + статусу: результатного ключа кита у витрины нет) засчитывал его свежестью — + опустевший блок лэндинга молчал бы сколько угодно. + """ + runs = _RecordedRuns() + await _run_handler(runs, _FULL, age_days=_ACCEPTANCE_CYCLES * _seeded_interval_days() + 1) + await _run_handler(runs, _EMPTY, age_days=1) + + stale = _stale_now(runs.rows) + assert [s.source for s in stale] == [SOURCE], ( + f"пустой прогон витрины засчитан свежестью: статусы {[r.status for r in runs.rows]}" + ) + assert stale[0].age_days > _ACCEPTANCE_CYCLES * _seeded_interval_days() + + +@pytest.mark.asyncio +async def test_nonempty_showcase_run_keeps_freshness() -> None: + """Контроль: тот же сценарий с непустым последним прогоном — тревоги нет. + + Зелёный с обеих сторон правки; без него правка могла бы валить любой прогон. + """ + runs = _RecordedRuns() + await _run_handler(runs, _FULL, age_days=_ACCEPTANCE_CYCLES * _seeded_interval_days() + 1) + await _run_handler(runs, _FULL, age_days=1) + + assert _stale_now(runs.rows) == [] + assert [r.status for r in runs.rows] == ["done", "done"] + + # ── 4. Живая БД: строка реально ложится в таблицу ──────────────────────────── From 9f11b8ed69b83061383d2e46a4739da8ab2184c1 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:25:50 +0500 Subject: [PATCH 03/47] =?UTF-8?q?fix(mera/estimator):=20=D0=B4=D0=B5=D0=B4?= =?UTF-8?q?=D1=83=D0=BF=20=D1=81=D0=BA=D0=BB=D0=B5=D0=B8=D0=B2=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D0=BD=D1=83=D0=BC=D0=B5=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D1=83=D0=BB=D0=B8=D1=86=D1=8B=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20=D0=B4=D0=B5=D1=84=D0=B8=D1=81=20(#2?= =?UTF-8?q?291,=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Регексы названия улицы в _parse_street_house пускали ведущую цифру только через пробел («8 Марта»), а через дефис нет. «40-летия Комсомола», «22-го Партсъезда», «3-го Интернационала» разбирались так: - avito «ул. 40-летия Комсомола,32Г» и yandex — ('', ''), дедупа нет вовсе; - cian «мкр. ЖБИ, улица 40-летия Комсомола, 8А» — ('mkr жби', '40'): все дома улицы получали один ключ и дом «40», разные квартиры с одинаковыми этажом/площадью/ценой сливались в одну; - domklik «60-летия Октября проспект» — ('октября', …). Фикс: (?:\d+\s+)? → (?:\d+(?:\s+|-))? в _DEDUP_STREET_NAME_RE и _DEDUP_STREET_NAME_SUFFIX_RE. На 5310 живых адресах прода с «цифра-буква» меняется разбор у 730 объявлений, все новые токены — реальные нумерованные улицы (14 штук, от «40-летия комсомола» до «2-ая новосибирская»). Бэктест-гейт: baseline перегенерирован. В реплее дедуп включён (как на захвате), поэтому правка разбора сдвигает пул: изменились 99 из 1600 сделок, у 48 ошибка меньше, у 51 больше. expected_sold MAPE 15.88→15.92, покрытие вилки 1056→1057, headline не сдвинулся. Refs #2291 Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/services/estimator.py | 9 +-- .../tests/fixtures/backtest_baseline.json | 62 ++++++++--------- .../test_estimator_dedup_cross_source_2087.py | 67 +++++++++++++++++++ 3 files changed, 103 insertions(+), 35 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index cd477ac6..825c5f0f 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -7328,15 +7328,16 @@ _DEDUP_STREET_TYPE_ALT = "|".join( ) # «<тип>[.] <название>»: название = опциональная ведущая цифро-группа + 1-3 # буквенных слова, до запятой/цифры. Ведущая цифра нужна для нумерованных улиц -# («8 Марта», «40 лет Октября», «22 Партсъезда»); она разрешена ТОЛЬКО перед -# буквенным словом (?:\d+\s+)?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия, +# («8 Марта», «40 лет Октября», «22 Партсъезда»; #2291 класс 4 — и через дефис: +# «40-летия Комсомола», «22-го Партсъезда»); она разрешена ТОЛЬКО перед +# буквенным словом (?:\d+(?:\s+|-))?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия, # не сопровождаемое буквой) в токен не попадает: «Белинского 86» → «белинского». # #2269 (б): тип-суффикс может быть приклеен запятой/точкой без пробела # («ул.,6к1»): после `\.?` разрешаем ЛИБО пробел (обычный формат), ЛИБО запятую # (→ следом номер дома). Ветка «тип ПЕРЕД именем» — только для формата с пробелом. _DEDUP_STREET_NAME_RE = re.compile( rf"(?:^|[,\s])(?:{_DEDUP_STREET_TYPE_ALT})\.?\s+" - r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})", + r"((?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})", flags=re.IGNORECASE, ) # #2269 (а): «<название> <тип>» — тип-слово ПОСЛЕ имени (формат domklik: @@ -7348,7 +7349,7 @@ _DEDUP_STREET_NAME_RE = re.compile( # Рассветная». Тип НЕ входит в захват (group 1) — токен байт-в-байт совпадает. _DEDUP_STREET_NAME_SUFFIX_RE = re.compile( r"(?:^|[,\s])" - r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+" + r"((?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+" rf"(?:{_DEDUP_STREET_TYPE_ALT})(?=\.|,|\s|$)", flags=re.IGNORECASE, ) diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json index 12017ca0..6b445063 100644 --- a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json +++ b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json @@ -2,19 +2,19 @@ "calibration": { "high": { "coverage_pct": 75.0, - "mape_pct": 19.6, + "mape_pct": 19.97, "n": 8, "n_covered": 6 }, "low": { - "coverage_pct": 83.59, + "coverage_pct": 83.67, "mape_pct": 15.64, "n": 1562, - "n_covered": 1029 + "n_covered": 1030 }, "medium": { "coverage_pct": 70.0, - "mape_pct": 27.59, + "mape_pct": 26.53, "n": 30, "n_covered": 21 } @@ -26,17 +26,17 @@ ], "expected_sold": { "overall": { - "mape_pct": 15.88, + "mape_pct": 15.92, "median_bias_pct": 2.87, "n": 1269, "n_no_analogs": 0, - "p25_pct": -10.88, - "p75_pct": 22.98 + "p25_pct": -10.94, + "p75_pct": 22.81 }, "per_area_bucket": { "0 <30": { "mape_pct": 19.76, - "median_bias_pct": -1.49, + "median_bias_pct": -1.02, "n": 161, "p25_pct": -16.45, "p75_pct": 34.7 @@ -45,21 +45,21 @@ "mape_pct": 15.64, "median_bias_pct": 4.37, "n": 503, - "p25_pct": -10.58, + "p25_pct": -10.56, "p75_pct": 22.86 }, "2 44-62": { - "mape_pct": 13.86, - "median_bias_pct": 4.15, + "mape_pct": 13.93, + "median_bias_pct": 4.13, "n": 379, - "p25_pct": -7.92, - "p75_pct": 23.85 + "p25_pct": -8.12, + "p75_pct": 23.5 }, "3 62-85": { "mape_pct": 15.97, "median_bias_pct": -2.33, "n": 179, - "p25_pct": -12.1, + "p25_pct": -11.99, "p75_pct": 18.18 }, "4 >=85": { @@ -74,7 +74,7 @@ "0": { "label": "студия", "mape_pct": 19.76, - "median_bias_pct": -1.49, + "median_bias_pct": -1.02, "n": 161, "n_no_analogs": 0, "p25_pct": -16.45, @@ -86,17 +86,17 @@ "median_bias_pct": 4.37, "n": 503, "n_no_analogs": 0, - "p25_pct": -10.58, + "p25_pct": -10.56, "p75_pct": 22.86 }, "2": { "label": "2к", - "mape_pct": 13.86, - "median_bias_pct": 4.15, + "mape_pct": 13.93, + "median_bias_pct": 4.13, "n": 379, "n_no_analogs": 0, - "p25_pct": -7.92, - "p75_pct": 23.85 + "p25_pct": -8.12, + "p75_pct": 23.5 }, "3": { "label": "3к", @@ -104,7 +104,7 @@ "median_bias_pct": -2.33, "n": 179, "n_no_analogs": 0, - "p25_pct": -12.1, + "p25_pct": -11.99, "p75_pct": 18.18 }, "4": { @@ -126,11 +126,11 @@ "p75_pct": -12.79 }, "комфорт": { - "mape_pct": 10.68, + "mape_pct": 10.69, "median_bias_pct": -5.78, "n": 425, - "p25_pct": -15.88, - "p75_pct": 4.49 + "p25_pct": -15.98, + "p75_pct": 4.54 }, "премиум": { "mape_pct": null, @@ -140,11 +140,11 @@ "p75_pct": null }, "эконом": { - "mape_pct": 20.17, + "mape_pct": 20.22, "median_bias_pct": 18.98, "n": 697, - "p25_pct": 1.25, - "p75_pct": 41.97 + "p25_pct": 0.96, + "p75_pct": 42.31 }, "элит": { "mape_pct": 36.11, @@ -162,9 +162,9 @@ }, "range_coverage": { "overall": { - "coverage_pct": 83.22, + "coverage_pct": 83.29, "n": 1269, - "n_covered": 1056 + "n_covered": 1057 }, "per_confidence": { "high": { @@ -173,9 +173,9 @@ "n_covered": 6 }, "low": { - "coverage_pct": 83.59, + "coverage_pct": 83.67, "n": 1231, - "n_covered": 1029 + "n_covered": 1030 }, "medium": { "coverage_pct": 70.0, diff --git a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py index 4564c412..3b91ea57 100644 --- a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py +++ b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py @@ -977,3 +977,70 @@ def test_parse_street_house_dangling_corpus_letter_no_digit_is_liter() -> None: # сразу после буквы ⇒ литер, см. design §3), остаётся «65к» — НЕ # схлопывается ни во что и не отбрасывается. assert estimator._parse_street_house("ул. Ленина, 65к") == ("ленина", "65к") + + +# --------------------------------------------------------------------------- # +# #2291 класс 4 — нумерованные улицы с дефисом («40-летия Комсомола») +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + ("addr", "expected"), + [ + # Живые форматы источников с прода (tradein-postgres, 2026-09-17). + ("ул. 40-летия Комсомола,32Г", ("40-летия комсомола", "32г")), # avito + ( + "Россия, Свердловская область, Екатеринбург, улица 40-летия Комсомола, 32Г", + ("40-летия комсомола", "32г"), + ), # yandex + ("р-н Кировский, мкр. ЖБИ, улица 40-летия Комсомола, 32Г", ("40-летия комсомола", "32г")), + ("Екатеринбург, ЖБИ м-н, 40-летия Комсомола, 32Г", ("40-летия комсомола", "32г")), + ("Москва, 60-летия Октября проспект, 18 к3", ("60-летия октября", "18/3")), + ("улица 22-го Партсъезда, 10", ("22-го партсъезда", "10")), + # Регрессии: пробельная нумерованная, мкр-шум при уличном типе, bare-street, mkr. + ("улица 8 Марта, 12", ("8 марта", "12")), + ("мкр. ЖБИ, улица Сыромолотова, 5", ("сыромолотова", "5")), + ("Заречный м-н, Готвальда, 24", ("готвальда", "24")), + ("мкр Пионерский, 5", ("mkr пионерский", "5")), + ], +) +def test_parse_street_house_hyphen_numbered_street(addr: str, expected: tuple[str, str]) -> None: + assert estimator._parse_street_house(addr) == expected + + +def test_dedup_collapses_hyphen_numbered_street_four_sources( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Один физлот в четырёх форматах: раньше avito/yandex давали ('', ''), cian — + # ('mkr жби', '40'), и слиться не мог никто. + monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) + addrs = [ + ("avito", "ул. 40-летия Комсомола,32Г"), + ("yandex", "Россия, Свердловская область, Екатеринбург, улица 40-летия Комсомола, 32Г"), + ("cian", "р-н Кировский, мкр. ЖБИ, улица 40-летия Комсомола, 32Г"), + ("domklik", "Екатеринбург, ЖБИ м-н, 40-летия Комсомола, 32Г"), + ] + lots = [ + _lot(source=s, address=a, area=44.0, price=5_900_000, floor=7, cad=None) for s, a in addrs + ] + assert len(estimator._dedup_cross_source(lots)) == 1 + + +def test_dedup_hyphen_numbered_street_different_houses_stay_distinct( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Раньше cian ключевал все дома улицы как ('mkr жби', '40'): дома 8А и 35 с + # одинаковыми этажом/площадью/ценой сливались в один лот. + monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) + lots = [ + _lot( + source="cian", + address=f"р-н Кировский, мкр. ЖБИ, улица 40-летия Комсомола, {h}", + area=44.0, + price=5_900_000, + floor=7, + cad=None, + ) + for h in ("8А", "35") + ] + assert len(estimator._dedup_cross_source(lots)) == 2 From 4dffb2d1fbaf1d6cbce552c7200b3761fa309916 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:27:33 +0500 Subject: [PATCH 04/47] =?UTF-8?q?fix(mera/=D0=BB=D0=B5=D0=BD=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3):=20=D0=B8=D1=82=D0=BE=D0=B3=20=D0=B8=D0=B3=D1=80?= =?UTF-8?q?=D1=8B=20=C2=AB=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D1=8C=D1=82?= =?UTF-8?q?=D0=B5=20=D1=81=D0=B5=D0=B1=D1=8F=C2=BB=20=D0=B3=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D1=82,=20=D1=87=D1=82=D0=BE=20=D0=BA=D0=B2?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=B8=D1=80=D1=8B=20=D0=B2=D0=B7=D1=8F=D1=82?= =?UTF-8?q?=D1=8B=20=D0=B8=D0=B7=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D0=B9=20=D0=BF=D0=BE=D0=BB=D0=BE=D1=81=D1=8B?= =?UTF-8?q?=20(#2903)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раунды игры — строки витрины, а витрина с 12.09 отобрана по ошибке МЕРЫ (полоса -5..+20 %). Плитка «ошибка МЕРЫ на этих же квартирах» выходила без оговорки и читалась как точность расчёта: на проде 17.09 медиана |ошибки| двадцати строк 6,5 %. Лента и таблица «Точность» полосу называли, игра — нет. Теперь итог печатает медиану по всей сверке (landing-facts) и, если все строки витрины лежат в полосе (allWithinBand, как у ленты и таблицы), говорит, что квартиры из отобранной полосы, а не из всей сверки. Co-Authored-By: Claude Opus 5 --- .../mera-public/__tests__/deal-map.test.tsx | 4 +- .../__tests__/landing-v3-render.test.tsx | 58 ++++++++++++++++++- .../_components/v3/GuessGameV3.tsx | 28 ++++++++- .../src/app/mera-public/landing-v3.module.css | 5 ++ .../frontend/src/app/mera-public/page.tsx | 6 +- 5 files changed, 94 insertions(+), 7 deletions(-) diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx index 987bb353..da8b6541 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx @@ -216,12 +216,12 @@ describe("карточка игры выбирает карту по налич }); it("схема есть — показана улица", () => { - const { container } = render(); + const { container } = render(); expect(container.textContent).toContain("ДОМ НЕ ИЗВЕСТЕН"); }); it("схемы нет — остаётся район, а не пустое место", () => { - const { container } = render(); + const { container } = render(); expect(container.textContent).toContain("ЛЕНИНСКИЙ"); expect(container.textContent).not.toContain("ДОМ НЕ ИЗВЕСТЕН"); }); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx index b1405fdf..3598d5e8 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx @@ -6,11 +6,13 @@ * ИСЧЕЗАЕТ, а не показывает ноль, прочерк или подпись без числа. Отказ ручки * `/stats` в проде выглядит именно так: пустой объект. */ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { AccuracyV3 } from "../_components/v3/AccuracyV3"; import { DealsTickerV3 } from "../_components/v3/DealsTickerV3"; +import { GuessGameV3 } from "../_components/v3/GuessGameV3"; +import { toGameRound } from "../_components/v3/deal-view"; import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3"; import { HeroV3 } from "../_components/v3/HeroV3"; import { BACKTEST } from "../landing-facts"; @@ -315,3 +317,57 @@ describe("лента сделок: контекст разброса", () => { expect(note.textContent).toContain("75,7 %"); }); }); + +/** + * ИТОГ ИГРЫ НАЗЫВАЕТ ОТБОР (#2903). + * + * Раунды — строки витрины, а витрина с 12.09.2026 отобрана по ошибке МЕРЫ + * (полоса −5 %..+20 %). Плитка «ошибка МЕРЫ на этих же квартирах» без + * оговорки читается как точность расчёта, хотя это лучший отобранный кусок: + * на проде 17.09 медиана |ошибки| двадцати строк 6,5 %, по всей сверке + * кратно больше. Проверяется по тексту итога, до которого доходим кликами. + */ +describe("игра: итог называет отбор раундов", () => { + const rounds = [0.58, -3.2, 17.9].map((err_pct, i) => + toGameRound( + { + ...SHOWCASE.deals[0], + rooms: i, + err_pct, + predicted_rub: Math.round(SHOWCASE.deals[0].fact_rub * (1 + err_pct / 100)), + }, + i, + ), + ); + + const finish = () => { + for (let i = 0; i < rounds.length; i += 1) { + fireEvent.click(screen.getByRole("button", { name: "Ответить" })); + fireEvent.click( + screen.getByRole("button", { + name: i === rounds.length - 1 ? "Показать итог" : "Следующая квартира →", + }), + ); + } + return screen.getByText(/Медиана расхождения МЕРЫ по всей сверке/u); + }; + + it("строки витрины в полосе → итог говорит, что квартиры из отобранной полосы, и печатает медиану сверки", () => { + render(); + const note = finish(); + expect(screen.getByText("ошибка МЕРЫ на этих же квартирах")).toBeTruthy(); + expect( + note.textContent, + "итог сравнивает игрока с МЕРОЙ на отобранных по её ошибке квартирах и молчит об отборе", + ).toMatch(/отобранной полосы.*от -5 % до \+20 %.*не вся сверка/u); + expect(note.textContent).toContain(BACKTEST.priceError.text); + expect(note.textContent).toContain(String(BACKTEST.priceError.sampleN)); + }); + + it("строки витрины вне полосы → про полосу молчит, медиана сверки остаётся", () => { + render(); + const note = finish(); + expect(note.textContent).not.toMatch(/полос/u); + expect(note.textContent).toContain(BACKTEST.priceError.text); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx index e59f0ffc..564663fd 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx @@ -27,12 +27,22 @@ * — той же формулой, что и ошибка МЕРЫ на этих же сделках (`err_pct` витрины). * Обе величины меряют одно и то же на одной выборке, поэтому их и можно * ставить рядом. + * + * НО ВЫБОРКА ОТОБРАНА ПО ОШИБКЕ МЕРЫ (#2903). С 12.09.2026 на витрину, а + * значит и в раунды, попадают только сделки с расхождением от −5 % до +20 % + * (`select_rows` в app/tasks/landing_showcase_deals.py). «Ошибка МЕРЫ на этих + * же квартирах» без оговорки читается как точность расчёта, а это лучший + * отобранный кусок. Поэтому итог называет полосу и держит рядом медиану по + * всей сверке — как подписи ленты и таблицы. Про полосу говорим, только если + * её подтверждают сами строки витрины (`bandSelected` = `allWithinBand`, + * считает родитель: раунды — лишь три из этих строк). */ import { useState } from "react"; +import { BACKTEST } from "../../landing-facts"; import styles from "../../landing-v3.module.css"; -import type { GameRound } from "./deal-view"; +import { BAND_MAX_PCT, BAND_MIN_PCT, count, errPct, type GameRound } from "./deal-view"; import { DealMapV3 } from "./DealMapV3"; import { StreetMapV3 } from "./StreetMapV3"; @@ -49,7 +59,14 @@ function median(xs: number[]): number { return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; } -export function GuessGameV3({ rounds }: { rounds: readonly GameRound[] }) { +export function GuessGameV3({ + rounds, + bandSelected, +}: { + rounds: readonly GameRound[]; + /** Все строки витрины лежат в полосе — значит раунды взяты из отобранной полосы. */ + bandSelected: boolean; +}) { const [idx, setIdx] = useState(0); const [phase, setPhase] = useState("playing"); const [guess, setGuess] = useState(rounds[0].start); @@ -188,6 +205,13 @@ export function GuessGameV3({ rounds }: { rounds: readonly GameRound[] }) { +

+ {`${ + bandSelected + ? `Квартиры для игры взяты из отобранной полосы, где МЕРА разошлась с ценой ДКП от ${errPct(BAND_MIN_PCT)} до ${errPct(BAND_MAX_PCT)}: это не вся сверка. ` + : "" + }Медиана расхождения МЕРЫ по всей сверке — ${BACKTEST.priceError.text} (${count(BACKTEST.priceError.sampleN)} сделок).`} +

Рынок сложно угадать на глаз — для этого и существует расчёт по данным. diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css index fa5f4963..d3d02b07 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css +++ b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css @@ -1920,6 +1920,11 @@ .gameFinal { text-align: center; } +/* Оговорка итога игры (#2903): отбор раундов и медиана всей сверки. */ +.gameFinalNote { + composes: accFootnote; + margin: 0 0 18px; +} .gameFinalGrid { display: grid; grid-template-columns: 1fr 1fr; diff --git a/tradein-mvp/frontend/src/app/mera-public/page.tsx b/tradein-mvp/frontend/src/app/mera-public/page.tsx index 662aba96..8bf611fc 100644 --- a/tradein-mvp/frontend/src/app/mera-public/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/page.tsx @@ -12,7 +12,7 @@ import { StepsV3 } from "./_components/v3/StepsV3"; import { StickyCtaV3 } from "./_components/v3/StickyCtaV3"; import { TwoPathsV3 } from "./_components/v3/TwoPathsV3"; import { WhatYouGetV3 } from "./_components/v3/WhatYouGetV3"; -import { pickVariedDeals, toGameRound } from "./_components/v3/deal-view"; +import { allWithinBand, pickVariedDeals, toGameRound } from "./_components/v3/deal-view"; import { serviceNode } from "./_lib/schema"; import { fetchLandingStats, fetchShowcase } from "./public-api"; import styles from "./landing.module.css"; @@ -120,7 +120,9 @@ export default async function MeraPublicPage() { - {rounds.length >= MIN_ROUNDS && } + {rounds.length >= MIN_ROUNDS && ( + + )} From 56f0cdbdf76661724a9a0687d14609198e2498f2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:27:44 +0500 Subject: [PATCH 05/47] =?UTF-8?q?fix(mera/lead):=20=D0=BE=20=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B5=20?= =?UTF-8?q?=D1=83=D0=B7=D0=BD=D0=B0=D1=91=D1=82=20=D0=BE=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=82=D0=B2=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9,=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20=C2=AB?= =?UTF-8?q?=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=B0=20=E2=86=92=20=D0=B7=D0=B0?= =?UTF-8?q?=D1=8F=D0=B2=D0=BA=D0=B0=C2=BB=20=D0=BD=D0=B0=20=D0=B4=D0=B0?= =?UTF-8?q?=D1=88=D0=B1=D0=BE=D1=80=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1971, два невыполненных пункта DoD. 1. Уведомления не было. Заявка с результата оценки только ложилась в trade_in_leads (docstring прямо называл уведомление «вне scope»). На проде 17.09: 4 заявки, notified_at пуст у всех, последняя 12.07. Теперь после ответа клиенту фоновая задача шлёт сообщение в support-топик тем же ботом, что и веб-чат поддержки, и при успехе ставит notified_at. Отказ Telegram не меняет ни ответ (200), ни сохранённый лид — только лог. Телефона в сообщении нет: копию в Telegram не стирает механизм удаления ПДн, поэтому туда идут id заявки, пользователь и id оценки. 2. Доли заявок от оценок не было нигде. Панель на продуктовом дашборде: лиды за 7 суток / успешные оценки за 7 суток (знаменатель — только outcome=ok: форма заявки показывается только при посчитанной оценке). Выражение проверено на боевом Prometheus 17.09: 0 / 91.008 = 0. Closes #1971 Co-Authored-By: Claude Opus 5 --- ops/metrics/grafana/dashboards/product.json | 23 +++++ tradein-mvp/backend/app/api/v1/lead.py | 66 ++++++++++++-- .../backend/tests/test_trade_in_lead.py | 87 ++++++++++++++++++- 3 files changed, 170 insertions(+), 6 deletions(-) diff --git a/ops/metrics/grafana/dashboards/product.json b/ops/metrics/grafana/dashboards/product.json index 025cd467..c67448c9 100644 --- a/ops/metrics/grafana/dashboards/product.json +++ b/ops/metrics/grafana/dashboards/product.json @@ -133,6 +133,29 @@ } }, + { + "type": "timeseries", + "title": "Конверсия оценка → заявка, скользящие 7 суток", + "description": "Доля успешных оценок (outcome=ok), после которых оставили заявку (#1971): лиды за 7 суток / оценки ok за 7 суток. Знаменатель — только ok, потому что форма заявки показывается только при посчитанной оценке (insufficient_data формы не видит). Счёт по событиям, а не по людям: одна заявка после трёх пересчётов того же адреса даёт 33 %. Пусто вместо 0 % — за 7 суток не было ни одной успешной оценки.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 17 }, + "targets": [ + { + "refId": "A", + "expr": "sum(increase(mera_leads_total[7d])) / sum(increase(mera_estimates_total{outcome=\"ok\"}[7d]))", + "legendFormat": "заявки / оценки" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 } + }, + "overrides": [] + } + }, + { "type": "row", "title": "Птица — экспорт отчётов", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } }, { diff --git a/tradein-mvp/backend/app/api/v1/lead.py b/tradein-mvp/backend/app/api/v1/lead.py index f4738c45..5f8ea111 100644 --- a/tradein-mvp/backend/app/api/v1/lead.py +++ b/tradein-mvp/backend/app/api/v1/lead.py @@ -2,9 +2,16 @@ POST /api/v1/trade-in/lead — контактная заявка с результата оценки: телефон + явное согласие на обработку персональных данных. Persist в -trade_in_leads. Notification (Telegram/email) — вне scope: нет существующей -SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только -persist + log; `notified_at` в таблице зарезервирован под будущую доставку. +trade_in_leads. + +Уведомление ответственному (#1971): после ответа клиенту (BackgroundTasks) лид +уходит сообщением в support-топик Telegram — тот же бот и топик, что у веб-чата +поддержки (`app.api.v1.support`). До этого заявки только ложились в таблицу, и +никто о них не узнавал. Успешная отправка проставляет `notified_at`; отказ +Telegram не трогает ни ответ, ни лид — остаётся лог и пустой `notified_at` как +видимый след недоставки. Телефона в сообщении НЕТ: копия в Telegram не стирается +механизмом удаления ПДн (`data_erasure.py`), поэтому туда идут только id — +телефон оператор берёт из trade_in_leads. IDOR-фикс (security-audit): `estimate_id` раньше только проверялся на СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца — любой @@ -25,15 +32,17 @@ from datetime import UTC, datetime, timedelta from typing import Annotated, Any, Literal from uuid import UUID -from fastapi import APIRouter, Depends, Header, HTTPException, Request +from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request from pydantic import BaseModel, Field, field_validator from sqlalchemy import text from sqlalchemy.orm import Session +from app.api.v1.support import _bot_configured from app.api.v1.trade_in import _assert_estimate_access from app.core.config import settings -from app.core.db import get_db +from app.core.db import SessionLocal, get_db from app.observability.metrics import LEADS +from app.services.tgbot.shared import get_telegram_client logger = logging.getLogger(__name__) @@ -99,11 +108,49 @@ class TradeInLeadInput(BaseModel): return value +async def _notify_new_lead(lead_id: str, estimate_id: UUID | None, username: str | None) -> None: + """Сообщает о заявке в support-топик и отмечает `notified_at` (#1971). + + Идёт после ответа клиенту, поэтому ретраи клиента — штатные воркерные, без + интерактивного бюджета (как `app.tasks.glitchtip_alert_retry`). Сессия своя: + сессия запроса к этому моменту уже закрыта. Любой отказ — только в лог. + """ + message = ( + "Новая заявка на трейд-ин\n" + f"id: {lead_id}\n" + f"пользователь: {username or '—'}\n" + f"оценка: {estimate_id or 'без привязки'}\n" + "Телефон — в trade_in_leads по id." + ) + try: + await get_telegram_client().send_message( + chat_id=settings.telegram_support_chat_id, + text=message, + message_thread_id=settings.telegram_support_topic_id or None, + ) + except Exception: + logger.exception("trade_in_lead: уведомление не доставлено id=%s", lead_id) + return + + db = SessionLocal() + try: + db.execute( + text("UPDATE trade_in_leads SET notified_at = now() WHERE id = CAST(:id AS uuid)"), + {"id": lead_id}, + ) + db.commit() + except Exception: + logger.exception("trade_in_lead: уведомление ушло, notified_at не записан id=%s", lead_id) + finally: + db.close() + + @router.post("/lead") async def create_trade_in_lead( payload: TradeInLeadInput, request: Request, db: Annotated[Session, Depends(get_db)], + background_tasks: BackgroundTasks, x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, ) -> dict[str, Any]: """Сохраняет лид (телефон + согласие) в trade_in_leads.""" @@ -185,6 +232,15 @@ async def create_trade_in_lead( _CONSENT_POLICY_VERSION, ) + if _bot_configured(): + background_tasks.add_task( + _notify_new_lead, row["id"], payload.estimate_id, x_authenticated_user + ) + else: + logger.warning( + "trade_in_lead: бот не настроен, уведомление о id=%s не отправлено", row["id"] + ) + return { "id": row["id"], "created_at": row["created_at"].isoformat(), diff --git a/tradein-mvp/backend/tests/test_trade_in_lead.py b/tradein-mvp/backend/tests/test_trade_in_lead.py index 8afd994f..2495efd7 100644 --- a/tradein-mvp/backend/tests/test_trade_in_lead.py +++ b/tradein-mvp/backend/tests/test_trade_in_lead.py @@ -21,7 +21,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: from datetime import UTC, datetime from types import SimpleNamespace from typing import Any -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 import pytest @@ -285,3 +285,88 @@ def test_lead_source_landing_rejected_422(client: TestClient, db: MagicMock) -> ) assert r.status_code == 422, r.text assert not db.execute.called + + +# ── #1971: уведомление ответственному в support-топик ───────────────────────── + +_NOTIFY_LEAD_ID = "00000000-0000-0000-0000-000000000001" + + +@pytest.fixture +def telegram(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: + """Бот настроен, клиент и сессия фоновой задачи подменены.""" + from app.api.v1 import lead as lead_module + + monkeypatch.setattr(lead_module.settings, "telegram_bot_token", "fake-token") + monkeypatch.setattr(lead_module.settings, "telegram_support_chat_id", -100123456789) + monkeypatch.setattr(lead_module.settings, "telegram_support_topic_id", 2) + fake_client = SimpleNamespace(send_message=AsyncMock(return_value={"message_id": 7})) + monkeypatch.setattr(lead_module, "get_telegram_client", lambda: fake_client) + notify_db = MagicMock() + monkeypatch.setattr(lead_module, "SessionLocal", lambda: notify_db) + return SimpleNamespace(client=fake_client, db=notify_db) + + +def test_lead_notifies_topic_without_phone_and_sets_notified_at( + client: TestClient, db: MagicMock, telegram: SimpleNamespace +) -> None: + db.execute.return_value = _insert_result(_NOTIFY_LEAD_ID) + + r = client.post( + "/api/v1/trade-in/lead", + json={"phone": "+7 (912) 345-67-89", "consent": True}, + headers={"X-Authenticated-User": "alice"}, + ) + assert r.status_code == 200, r.text + + telegram.client.send_message.assert_awaited_once() + kwargs = telegram.client.send_message.await_args.kwargs + assert kwargs["chat_id"] == -100123456789 + assert kwargs["message_thread_id"] == 2 + assert _NOTIFY_LEAD_ID in kwargs["text"] + assert "alice" in kwargs["text"] + # Телефон в Telegram не уходит ни в каком написании. + assert "9123456789" not in "".join(ch for ch in kwargs["text"] if ch.isdigit()) + + sql, params = telegram.db.execute.call_args.args + assert "SET notified_at = now()" in str(sql) + assert params == {"id": _NOTIFY_LEAD_ID} + assert telegram.db.commit.called + assert telegram.db.close.called + + +def test_lead_telegram_failure_keeps_200_and_notified_at_empty( + client: TestClient, db: MagicMock, telegram: SimpleNamespace +) -> None: + from app.services.tgbot.client import TelegramError + + db.execute.return_value = _insert_result(_NOTIFY_LEAD_ID) + telegram.client.send_message.side_effect = TelegramError("boom") + + r = client.post( + "/api/v1/trade-in/lead", + json={"phone": "+79123456789", "consent": True}, + headers={"X-Authenticated-User": "alice"}, + ) + assert r.status_code == 200, r.text + assert r.json()["id"] == _NOTIFY_LEAD_ID + assert db.commit.called # лид сохранён до попытки уведомления + telegram.client.send_message.assert_awaited_once() + assert not telegram.db.execute.called + + +def test_lead_bot_not_configured_skips_notification( + client: TestClient, + db: MagicMock, + telegram: SimpleNamespace, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from app.api.v1 import lead as lead_module + + monkeypatch.setattr(lead_module.settings, "telegram_support_chat_id", 0) + db.execute.return_value = _insert_result(_NOTIFY_LEAD_ID) + + r = client.post("/api/v1/trade-in/lead", json={"phone": "+79123456789", "consent": True}) + assert r.status_code == 200, r.text + telegram.client.send_message.assert_not_awaited() + assert not telegram.db.execute.called From 08216e4c435ebb446fcdd2b5ca503f8ec8212201 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:28:55 +0500 Subject: [PATCH 06/47] =?UTF-8?q?feat(mera/estimator):=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=20=D0=B8=20=D0=BE=D0=BA=D0=BD=D0=BE=20=D1=8D?= =?UTF-8?q?=D1=82=D0=B0=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20Tier=20H=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D0=BA=D0=B8,=20=D0=B4=D0=BB=D1=8F=20=D0=B1=D1=8D=D0=BA=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B0=20(#3234)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier H («тот же класс дома») отдаётся только при ≥5 комплах, иначе молча уходит в Tier W без фильтра по классу. Прод, Loki 30 сут (комментарий 12.09): 61 из 86 попыток не доживает, в 31 из них в своей полосе 0 комплов. Какой вариант лучше — порог 5→4, окно этажей ±30%→±50% или оба — решает только MAPE бэктеста на сделках, а оба числа были литералами: сравнить варианты без правки кода было нельзя. estimate_tier_h_min_comps (5) и estimate_tier_h_floors_tol (0.30) в Settings, _fetch_analogs берёт их оттуда. Дефолты дают те же tf_min/tf_max, что литералы (сверено для total_floors 1..199), поведение прода не меняется. Бэктест варианта: docker exec -e ESTIMATE_TIER_H_MIN_COMPS=4 -e ESTIMATE_TIER_H_FLOORS_TOL=0.5 … Дефолт не меняю: бэктест вариантов не прогнан (нужен деплой этих ручек). Refs #3234 Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/core/config.py | 8 ++ tradein-mvp/backend/app/services/estimator.py | 13 ++-- .../tests/test_3234_tier_h_settings.py | 78 +++++++++++++++++++ 3 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3234_tier_h_settings.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index d291f723..cbef4320 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -711,6 +711,14 @@ class Settings(BaseSettings): # ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED. estimate_unknown_attr_penalty_enabled: bool = False estimate_unknown_attr_penalty_min_n: int = 5 + # #3234: Tier H в _fetch_analogs («тот же класс дома»). Порог отдачи и окно + # этажности были литералами; вынесены, чтобы бэктест мог сравнить варианты + # через env без правки кода. Дефолты = прежнее поведение байт-в-байт. + # Прод, Loki 30 сут (12.09): 61 из 86 попыток Tier H уходят в Tier W, в 31 из + # них в своей полосе 0 комплов. Менять дефолт — только по MAPE бэктеста. + # ENV: ESTIMATE_TIER_H_MIN_COMPS, ESTIMATE_TIER_H_FLOORS_TOL. + estimate_tier_h_min_comps: int = 5 + estimate_tier_h_floors_tol: float = 0.30 # total_floors × (1 ± tol) # # is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая # корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 825c5f0f..3f07528a 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -6551,8 +6551,9 @@ def _fetch_analogs( _normalize_building_key + _house_boundary_regex, тот же принцип что и anchor Tier A) + ST_DWithin(radius_m). Если ≥3 результатов → tier='S'. - **Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors ±30%. - Если ≥5 результатов → возвращаем; tier='H'. + **Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors + ±estimate_tier_h_floors_tol (0.30). Если ≥ estimate_tier_h_min_comps (5) + результатов → возвращаем; tier='H'. Пропускается если year_built или total_floors неизвестны. **Tier W (wide / current):** текущая логика без year/floors WHERE фильтра. @@ -6747,12 +6748,12 @@ def _fetch_analogs( ) return _stratify_candidates(tier_s), radius_m > DEFAULT_RADIUS_M, "S" - # ── Tier H: same class (year ±15, total_floors ±30%) ───────────────────── + # ── Tier H: same class (year ±15, total_floors ±tol, #3234: tol/порог в settings) ─ if year_built is not None and total_floors is not None: year_min = year_built - 15 year_max = year_built + 15 - tf_min = math.floor(total_floors * 0.7) - tf_max = math.ceil(total_floors * 1.3) + tf_min = math.floor(total_floors * (1 - settings.estimate_tier_h_floors_tol)) + tf_max = math.ceil(total_floors * (1 + settings.estimate_tier_h_floors_tol)) tier_h_rows = ( db.execute( @@ -6860,7 +6861,7 @@ def _fetch_analogs( min_n=settings.estimate_unknown_attr_penalty_min_n, ) tier_h.sort(key=lambda r: r.get("relevance_score") or 0.0) - if len(tier_h) >= 5: + if len(tier_h) >= settings.estimate_tier_h_min_comps: logger.info( "analogs tier=H year=%d±15 tf=%d-%d → %d results", year_built, diff --git a/tradein-mvp/backend/tests/test_3234_tier_h_settings.py b/tradein-mvp/backend/tests/test_3234_tier_h_settings.py new file mode 100644 index 00000000..b7480b7a --- /dev/null +++ b/tradein-mvp/backend/tests/test_3234_tier_h_settings.py @@ -0,0 +1,78 @@ +"""#3234: порог и окно этажности Tier H берутся из settings, дефолты = прежние литералы. + +Tier H в _fetch_analogs отдаёт «тот же класс дома» только при ≥ N комплов, иначе +уходит в Tier W без фильтра по классу. N и окно total_floors были литералами (5 и +×0.7…×1.3); вынесены, чтобы бэктест сравнил варианты через env. Проверяем значения, +которые реально уходят в SQL, и тир, который реально возвращается. +""" + +from __future__ import annotations + +import os +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest + +import app.services.estimator as est + + +def _run(n_tier_h: int, total_floors: int) -> tuple[str, dict]: + """_fetch_analogs на mock-БД: Tier H отдаёт n_tier_h строк, Tier W — одну.""" + tier_h_params: dict = {} + + def side_effect(stmt, params): # type: ignore[no-untyped-def] + result = MagicMock() + if "tf_min" in params: + tier_h_params.update(params) + rows = [{"source": "avito", "relevance_score": float(i)} for i in range(n_tier_h)] + else: + rows = [{"source": "cian", "relevance_score": 0.0}] + result.mappings.return_value.all.return_value = rows + return result + + db = MagicMock() + db.execute.side_effect = side_effect + _, _, tier = est._fetch_analogs( + db, + lat=56.83, + lon=60.6, + rooms=2, + area=50.0, + radius_m=1000, + year_built=1975, + total_floors=total_floors, + ) + return tier, tier_h_params + + +def test_defaults_keep_old_literals() -> None: + tier, params = _run(n_tier_h=5, total_floors=9) + assert (params["tf_min"], params["tf_max"]) == (6, 12) # floor(6.3), ceil(11.7) + assert tier == "H" + tier, params = _run(n_tier_h=4, total_floors=16) + assert (params["tf_min"], params["tf_max"]) == (11, 21) # floor(11.2), ceil(20.8) + assert tier == "W" + + +def test_min_comps_from_settings(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(est.settings, "estimate_tier_h_min_comps", 4) + assert _run(n_tier_h=4, total_floors=9)[0] == "H" + assert _run(n_tier_h=3, total_floors=9)[0] == "W" + + +def test_floors_tol_from_settings(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(est.settings, "estimate_tier_h_floors_tol", 0.5) + _, params = _run(n_tier_h=5, total_floors=9) + assert (params["tf_min"], params["tf_max"]) == (4, 14) # floor(4.5), ceil(13.5) + + +def test_env_reaches_settings(monkeypatch: pytest.MonkeyPatch) -> None: + # Бэктест вариантов идёт через `docker exec -e ESTIMATE_TIER_H_…` — env обязан доехать. + from app.core.config import Settings + + monkeypatch.setenv("ESTIMATE_TIER_H_MIN_COMPS", "4") + monkeypatch.setenv("ESTIMATE_TIER_H_FLOORS_TOL", "0.5") + s = Settings() + assert (s.estimate_tier_h_min_comps, s.estimate_tier_h_floors_tol) == (4, 0.5) From 45bd47fdd72b1b167c90f14e684bb4c52046a1d5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:29:26 +0500 Subject: [PATCH 07/47] =?UTF-8?q?=D0=9C=D0=95=D0=A0=D0=90:=20=D0=B4=D0=B5?= =?UTF-8?q?=D0=B4=D1=83=D0=BF=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B8=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3=D0=B8=20=D1=80?= =?UTF-8?q?=D1=83=D1=87=D0=BD=D0=BE=D0=B9=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B1=D0=B5=D0=B7=20=D0=B2=D1=8B=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20(#2378)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Флаг estimate_dedup_analogs_enabled снят: кросс-source дедуп работает всегда. На проде 17.09 флаг = True во всех трёх контейнерах (backend/scraper/tgbot), ENV-оверрайда нет; фикстура бэктеста захвачена с True, поэтому пин флага в реплее и monkeypatch в гейте больше не нужны. Числовые пороги estimate_wide_corridor_threshold и три estimate_manual_review_* перенесены в estimator.py константами модуля с прежними значениями (1.2 / 20 000 000 / 1.9 / 250 000). _manual_review больше не принимает settings. Осиротевшие комментарии Settings к уже снятым в #2475 флагам (#1871 P1.2, P2 radius-dedup) удалены. Тесты: OFF-тест дедупа удалён; два теста, пинившие дедуп OFF ради изоляции, получили разные площади у аналогов (разные физлоты). Регрессионный гейт и реплей бэктеста по сделкам (1600, из них 615 радиусных) побитово те же. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/core/config.py | 68 --------- tradein-mvp/backend/app/services/estimator.py | 62 +++++--- .../backend/scripts/backtest_estimator.py | 138 ++++++++---------- .../tests/test_backtest_regression_gate.py | 14 +- ...t_estimator_analogs_display_consistency.py | 1 - .../test_estimator_dedup_cross_source_2087.py | 111 ++++---------- .../tests/test_estimator_manual_review.py | 33 ----- .../tests/test_estimator_n_analogs_priced.py | 22 +-- .../test_estimator_split_corridor_1871.py | 7 +- .../tests/test_same_building_anchor.py | 12 +- 10 files changed, 141 insertions(+), 327 deletions(-) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index d291f723..82bab36f 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -589,60 +589,6 @@ class Settings(BaseSettings): # Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо. estimate_price_trend_max_age_months: int = 6 - # ── #1871 P1.2: ghost-anchor confidence floor ───────────────────────────── - # True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation - # когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'. - # Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation, - # cian_valuation, avito_imv) могут дать median без единого реального рыночного - # аналога → headline выглядит достоверным при нулевой реальной базе. - - # ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─ - # Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка - # человеком. Research: элит/премиум-премия unit-level и под-доверена (зависит - # от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом, - # высокая стоимость, низкая уверенность, слишком широкий диапазон цены. - # Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable). - estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка - estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо - # asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна - # (премия за отделку/вид/класс — unit-level, отсутствует в данных сделок). - estimate_manual_review_elite_ppm2: int = 250000 - - # ── #1871 P2: radius-tier (source, source_id) dedup ─────────────────────── - # Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address - # (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один - # address и выживают на разных rn_addr рангах → раздувают n_analogs (prod - # 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по - # (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире - # (freshest scraped_at на (source, source_id|source_url|ctid)). - - # ── #2087 H4: кросс-source физический дедуп аналогов ────────────────────── - # Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id). - # Один физический лот кросс-постится на avito+cian+domklik (разные source, разные - # source_id) → radius-дедуп его НЕ схлопывает → он считается несколько раз → - # раздувает n_analogs И cv (→ шире коридор), может смещать медиану. Прод-аудит - # #2087: лот 80м²/265000₽/м² = N1+Домклик+Циан (×3); «14 аналогов» → ~6-7 уникальных. - # True схлопывает дубли по ФИЗИЧЕСКОМУ ключу до подсчёта n_analogs/median/cv: - # building (building_cadastral_number | нормализованный address) - # + floor + area_bucket (round(area_m2), ~±0.5 м²) - # + price_bucket (round(price_rub / 100000), ~±0.5% @21М / ~±2% @2.5М). - # Из группы остаётся ОДИН представитель (свежайший scraped_at), НЕ суммируем; - # n_analogs/median/cv/source_counts/sources_used считаются по физическим лотам - # («лот считается один раз»). - # - # Бэктест #1966 (400 ДКП, radius-путь, full spine, OFF vs ON): MAPE 13.89% → - # 13.89%, coverage 83.33% → 83.33%, bias −3.83% → −3.83%, median width 0.743 → - # 0.743, median cv 0.0988 → 0.0988; avg n_analogs 27.64 → 27.57. Дедуп отработал - # 107× на 335 оценках, но снимает лишь identical-price кросс-посты (дубли имеют - # ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс - # ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по - # физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор — - # post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs - # ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%, median width/cv - # без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым - # кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает). - estimate_dedup_analogs_enabled: bool = True - # ── #2012: kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring ── # Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки # estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 / @@ -725,20 +671,6 @@ class Settings(BaseSettings): # ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED. estimate_is_apartments_filter_enabled: bool = False - # ── #1871 P2: split-дома wide-corridor disclosure (default ON, порог 1.2) ── - # Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом - # дробится на несколько house_id). На split-доме разной этажности comp_min..max - # растягивается через несколько ценовых режимов → коридор range_low/high - # 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A + - # corridor_pct > threshold → понижаем confidence на ступень и дописываем - # disclosure в explanation. НЕ трогает point/median/range. - # Порог ширины коридора (range_high-range_low)/median_price для disclosure. - # 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6 - # фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция). - # Genuine split-дома из аудита = 148-170% (1.48-1.70) → 1.2 ловит только - # экстремальный хвост (>p99), не трогая нормальную оценочную неопределённость. - estimate_wide_corridor_threshold: float = 1.2 - # ── Mera-audit fix-1: Cian valuation sanity bounds ──────────────────────── # API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999). # sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется). diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index cd477ac6..9b0ccc95 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -55,7 +55,7 @@ from scraper_kit.proxy_errors import caused_by_no_proxy from sqlalchemy import text from sqlalchemy.orm import Session -from app.core.config import LISTINGS_FRESH_DAYS, Settings, settings +from app.core.config import LISTINGS_FRESH_DAYS, settings from app.core.db import SessionLocal, run_db_thread from app.schemas.trade_in import ( AggregatedEstimate, @@ -416,6 +416,31 @@ PRICE_SEGMENTS_PPM2: tuple[tuple[str, float], ...] = ( ("премиум", float("inf")), ) +# ── #1970: калибровочные константы движка (бывшие поля Settings estimate_*) ── +# Раньше жили в Settings и формально переопределялись через ENV. Ни одно ни разу +# не переопределялось: на проде 17.09.2026 (tradein-backend/scraper/tgbot) живые +# значения совпадают с дефолтами, перенесёнными сюда. Переключателей, которые +# реально переключаются, среди них нет, поэтому это код, а не конфиг: поменять +# число = правка здесь + регрессионный гейт бэктеста +# (tests/test_backtest_regression_gate.py). + +# #1871 P2 (#2378): split-дома, disclosure широкого коридора. Tier A матчит по +# address-regex (намеренно НЕ house_id — дом дробится на несколько house_id), и на +# split-доме разной этажности comp_min..max растягивается через несколько ценовых +# режимов → коридор 148%/170%. Tier A + (range_high-range_low)/median_price > порога +# → confidence на ступень ниже + disclosure в explanation; point/median/range не +# трогает. 1.2: по прод-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6 фаерил +# бы на ~31% оценок; genuine split-дома аудита = 1.48-1.70 → 1.2 ловит только хвост. +WIDE_CORRIDOR_THRESHOLD = 1.2 + +# #2002 #4 (#2378): пороги рекомендации ручной оценки (_manual_review). Чисто +# метаданные — median/expected_sold/ranges не трогают. +MANUAL_REVIEW_HIGH_VALUE_RUB = 20_000_000 # expected_sold ≥ этого — ручная оценка +MANUAL_REVIEW_WIDE_RANGE_RATIO = 1.9 # range_high/range_low ≥ — неопределённо +# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна (премия за +# отделку/вид/класс — unit-level, отсутствует в данных сделок). +MANUAL_REVIEW_ELITE_PPM2 = 250_000 + # #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются # нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади — # которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные @@ -3394,7 +3419,7 @@ def _price_from_inputs( """ # 2b. Кросс-source физический дедуп (#2087 H4) — ДО outlier-фильтра и # агрегации, чтобы n_analogs/median/cv считались по уникальным лотам, а не - # по кросс-постам. No-op при estimate_dedup_analogs_enabled=False. + # по кросс-постам. listings = _dedup_cross_source(listings) # #2265 gap (live QA 2026-07-03): якорный пул (_fetch_anchor_comps, Tier A # same_building / Tier C micro_radius) строится ОТДЕЛЬНЫМ raw SQL и раньше @@ -3726,7 +3751,7 @@ def _price_from_inputs( # #1871 P2: split-дома wide-corridor disclosure. if anchor_tier == "A" and median_price > 0: corridor_pct = (range_high - range_low) / median_price - if corridor_pct > settings.estimate_wide_corridor_threshold: + if corridor_pct > WIDE_CORRIDOR_THRESHOLD: confidence = _downgrade_confidence(confidence) explanation = (explanation or "") + ( " Очень широкий ценовой диапазон по дому (вероятно, дом " @@ -5641,7 +5666,6 @@ async def estimate_quality( confidence, range_low, range_high, - settings, # asking-сторона: тот же headline median_price_per_m2 (=int(median_ppm2)), # что уходит в ответ ниже. НЕ expected_sold_per_m2 — он занижен на элите. asking_ppm2=int(median_ppm2), @@ -5781,8 +5805,8 @@ def _is_premium_building(db: Session, house_id: int | None) -> tuple[bool, int | None) — premium_building флаг не выставится, даже если дом объективно элитный по рынку своего города. Это не тихая потеря сигнала: `_manual_review` (см. ниже, строки ~3944-3993) всё равно ловит такие объекты по АБСОЛЮТНОЙ стоимости — - `estimate_manual_review_high_value_rub` (expected_sold_price ≥ порога) и - `estimate_manual_review_elite_ppm2` (asking ₽/м² ≥ порога) — независимо от + `MANUAL_REVIEW_HIGH_VALUE_RUB` (expected_sold_price ≥ порога) и + `MANUAL_REVIEW_ELITE_PPM2` (asking ₽/м² ≥ порога) — независимо от premium_building. То есть oblast-элита не помечается как «премиальный дом», но почти всегда подхватывается value-based триггером той же функции. """ @@ -5819,7 +5843,6 @@ def _manual_review( confidence: str, range_low: int | None, range_high: int | None, - settings: Settings, asking_ppm2: int | None = None, ) -> tuple[bool, list[str]]: """#2002 #4: рекомендация ручной оценки — производный ФЛАГ, НЕ цена. @@ -5849,15 +5872,13 @@ def _manual_review( reasons: list[str] = [] if premium_building: reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)") - high_value = settings.estimate_manual_review_high_value_rub - if expected_sold_price and expected_sold_price >= high_value: + if expected_sold_price and expected_sold_price >= MANUAL_REVIEW_HIGH_VALUE_RUB: reasons.append("высокая стоимость (≥20 млн ₽)") if confidence == "low": reasons.append("низкая уверенность оценки") - wide_ratio = settings.estimate_manual_review_wide_range_ratio - if range_low and range_high and range_high / range_low >= wide_ratio: + if range_low and range_high and range_high / range_low >= MANUAL_REVIEW_WIDE_RANGE_RATIO: reasons.append("широкий диапазон цены") - if asking_ppm2 and asking_ppm2 >= settings.estimate_manual_review_elite_ppm2: + if asking_ppm2 and asking_ppm2 >= MANUAL_REVIEW_ELITE_PPM2: reasons.append( "элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, " "вид и класс дома не отражена в данных сделок, фактическая цена может быть выше" @@ -7655,8 +7676,7 @@ def _union_find_phys_dedup( не дедупятся — остаются уникальными. include_price=True — статистический ключ (влияет на n_analogs/median/cv, - гейтится флагом estimate_dedup_analogs_enabled со стороны вызывающего - _dedup_cross_source). include_price=False — display-only ключ без + вызывающий — _dedup_cross_source). include_price=False — display-only ключ без price_bucket (см. _dedup_display_lots) — ценовой дрейф между кросс-постами не мешает схлопнуть их в одну UI-карточку; ВЗАМЕН требует РАЗНЫЙ source у пары для merge (require_diff_source) — без цены единственный оставшийся @@ -7777,13 +7797,14 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: """Статистический кросс-source дедуп (#2087 H4 / #2265) — ВЛИЯЕТ на n_analogs/ median/cv/source_counts/sources_used (представитель несёт свой source → лот считается один раз; source кросс-поста, не ставший представителем, выпадает - из выборки — по нему нет независимой ценовой точки). Флаг - estimate_dedup_analogs_enabled OFF → no-op (байт-идентичный проход, нужно - для frozen backtest regression gate). Ключ включает price_bucket + из выборки — по нему нет независимой ценовой точки). Работает всегда: флаг-выключатель + снят (#2378) — бэктест #1966 OFF vs ON был + accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%), дедуп меняет + только честность счёта n_analogs. Ключ включает price_bucket (_lot_dedup_components include_price=True по умолчанию) — см. _union_find_phys_dedup. """ - if not settings.estimate_dedup_analogs_enabled or len(lots) < 2: + if len(lots) < 2: return lots return _union_find_phys_dedup(lots, include_price=True) @@ -7829,9 +7850,8 @@ def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: случай: перевыставили на другой площадке дешевле/дороже) — без такого дубля-выброса, ошибочно попавшего в карточки как «отдельный аналог». - Независим от settings.estimate_dedup_analogs_enabled: тот флаг управляет - статистикой (median/n_analogs), этот — только честностью UI-карточек и - может/должен оставаться активным даже если стат-дедуп выключен. НЕ трогает + Независим от _dedup_cross_source: тот управляет статистикой + (median/n_analogs), этот — только честностью UI-карточек. НЕ трогает n_analogs/median/cv — вызывается ПОСЛЕ того, как эти величины уже вычислены из отдельного (не обязательно совпадающего по итоговой длине) пула. """ diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index 63f1785b..52181f84 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -1975,12 +1975,11 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[ / ``sharpness`` / ``confidence_order`` / ``headline`` and DROPS the volatile ``params`` block. Touches NO DB / network and does NOT consult ``settings_at_capture`` — it prices against the live committed - ``estimator.settings`` defaults (so a settings change is caught as a metric - drift, not silently honoured). ЕДИНСТВЕННОЕ исключение — - ``estimate_dedup_analogs_enabled``, пиннится в False на время реплея: это не - настройка точности, а условие воспроизводимости ЗАХВАЧЕННОГО контрольного - потока (см. комментарий у пина ниже). Deterministic: same fixture → identical - dict. + ``estimator.settings`` defaults and module constants (so a change is caught as a + metric drift, not silently honoured). Кросс-source дедуп (#2087 H4) с #2378 + работает всегда, поэтому реплеится только фикстура, захваченная с дедупом ON + (закоммиченная — такая); более старая упадёт на «control flow diverged from + capture» — её надо перезахватить. Deterministic: same fixture → identical dict. """ est = _import_estimator_full() m = est.m @@ -1991,83 +1990,60 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[ pred_ppm2_all: list[float] = [] unrecorded: list[int] = [0] # #2661: счётчик lookup-вызовов без записи в фикстуре - # #2661: фикстура захвачена с ВЫКЛЮЧЕННЫМ кросс-source дедупом (#2087 H4 был - # no-op по умолчанию на момент захвата), а с #2173 дефолт ON. Реплей обязан идти - # по ЗАХВАЧЕННОМУ контрольному потоку: с активным дедупом _dedup_cross_source - # подрезал бы listings до quarter_indexes_lookup и записанная последовательность - # вызовов разъехалась бы. Пин ЗДЕСЬ, а не только в CI-гейте: гейт монкипатчил флаг - # сам, а документированная регенерация baseline (--from-fixture --update-baseline) - # — нет, и с #2173 писала baseline, который тест не совпал бы НИКОГДА. - # #3251: РАНЬШЕ здесь стоял жёсткий `= False`. Это верно только для фикстуры, - # захваченной ДО #2173 (когда дедуп был выключен по умолчанию), и делает - # НЕРЕПЛЕИБЕЛЬНОЙ любую свежую фикстуру: при dedup=True на захвате часть сделок - # уходит под sufficiency-гейт с median_ppm2<=0 и ratio_resolver для них не - # вызывается, а реплей с dedup=False их оценивает и упирается в - # «control flow diverged from capture». Берём значение ИЗ ФИКСТУРЫ (fallback - # False — для старых фикстур без settings_at_capture поведение прежнее). - _dedup_capture = bool( - (fixture.get("settings_at_capture") or {}).get("estimate_dedup_analogs_enabled", False) - ) - _dedup_saved = m.settings.estimate_dedup_analogs_enabled - m.settings.estimate_dedup_analogs_enabled = _dedup_capture - try: - for rec in deals: - kw = dict(rec["kwargs"]) - sold_ppm2_all.append(float(rec["sold_ppm2"])) - kw["geo"] = est.GeocodeResult(**kw["geo"]) - kw["ratio_resolver"] = _make_call_stub( - rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return - ) - # #2661: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых - # в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт - # Guard-1a на часть сделок, а прод-фикстура захвачена ДО правки. Следствие, - # которое надо знать при чтении метрик: для этих сделок квартальный индекс в - # реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает - # эффект правки — счётчик unrecorded_lookup_calls ниже это число и держит. - # Уйдёт (до нуля) при следующем перезахвате фикстуры с прода. - kw["quarter_index_lookup"] = _make_call_stub( - rec.get("qi_calls") or [], - label="quarter_index_lookup", - coerce=_coerce_qi_return, - on_exhausted=None, - unrecorded_counter=unrecorded, - ) - kw["quarter_indexes_lookup"] = _make_call_stub( - rec.get("qis_calls") or [], - label="quarter_indexes_lookup", - coerce=_coerce_qis_return, - on_exhausted={}, - unrecorded_counter=unrecorded, - ) + for rec in deals: + kw = dict(rec["kwargs"]) + sold_ppm2_all.append(float(rec["sold_ppm2"])) + kw["geo"] = est.GeocodeResult(**kw["geo"]) + kw["ratio_resolver"] = _make_call_stub( + rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return + ) + # #2661: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых + # в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт + # Guard-1a на часть сделок, а прод-фикстура захвачена ДО правки. Следствие, + # которое надо знать при чтении метрик: для этих сделок квартальный индекс в + # реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает + # эффект правки — счётчик unrecorded_lookup_calls ниже это число и держит. + # Уйдёт (до нуля) при следующем перезахвате фикстуры с прода. + kw["quarter_index_lookup"] = _make_call_stub( + rec.get("qi_calls") or [], + label="quarter_index_lookup", + coerce=_coerce_qi_return, + on_exhausted=None, + unrecorded_counter=unrecorded, + ) + kw["quarter_indexes_lookup"] = _make_call_stub( + rec.get("qis_calls") or [], + label="quarter_indexes_lookup", + coerce=_coerce_qis_return, + on_exhausted={}, + unrecorded_counter=unrecorded, + ) - pr = m._price_from_inputs(**kw) + pr = m._price_from_inputs(**kw) - _es_ppm2_raw = pr.expected_sold_per_m2 - _es_price_raw = pr.expected_sold_price - _r_low_raw = pr.expected_sold_range_low - _r_high_raw = pr.expected_sold_range_high - es_ppm2 = float(_es_ppm2_raw) if _es_ppm2_raw is not None else None - es_price = float(_es_price_raw) if _es_price_raw is not None else None - r_low = float(_r_low_raw) if _r_low_raw is not None else None - r_high = float(_r_high_raw) if _r_high_raw is not None else None - prediction = Prediction( - deal_id=int(rec["deal_id"]), - rooms=rec["rooms"], - area_m2=float(rec["area_m2"]), - sold_ppm2=float(rec["sold_ppm2"]), - median_ppm2=float(pr.median_ppm2), - confidence=pr.confidence, - anchor_tier=pr.anchor_tier, - expected_sold_ppm2=es_ppm2, - expected_sold_price=es_price, - range_low=r_low, - range_high=r_high, - ) - predictions.append(prediction) - pred_ppm2_all.append(prediction.median_ppm2) - - finally: - m.settings.estimate_dedup_analogs_enabled = _dedup_saved + _es_ppm2_raw = pr.expected_sold_per_m2 + _es_price_raw = pr.expected_sold_price + _r_low_raw = pr.expected_sold_range_low + _r_high_raw = pr.expected_sold_range_high + es_ppm2 = float(_es_ppm2_raw) if _es_ppm2_raw is not None else None + es_price = float(_es_price_raw) if _es_price_raw is not None else None + r_low = float(_r_low_raw) if _r_low_raw is not None else None + r_high = float(_r_high_raw) if _r_high_raw is not None else None + prediction = Prediction( + deal_id=int(rec["deal_id"]), + rooms=rec["rooms"], + area_m2=float(rec["area_m2"]), + sold_ppm2=float(rec["sold_ppm2"]), + median_ppm2=float(pr.median_ppm2), + confidence=pr.confidence, + anchor_tier=pr.anchor_tier, + expected_sold_ppm2=es_ppm2, + expected_sold_price=es_price, + range_low=r_low, + range_high=r_high, + ) + predictions.append(prediction) + pred_ppm2_all.append(prediction.median_ppm2) # The fixture stores ONLY priced deals, so n_no_prediction is 0 here. metrics = _compute_full_metrics(predictions, n_no_prediction=0, pi_report=pi_report) diff --git a/tradein-mvp/backend/tests/test_backtest_regression_gate.py b/tradein-mvp/backend/tests/test_backtest_regression_gate.py index 0275db49..8f963aa5 100644 --- a/tradein-mvp/backend/tests/test_backtest_regression_gate.py +++ b/tradein-mvp/backend/tests/test_backtest_regression_gate.py @@ -25,11 +25,8 @@ import math import os from pathlib import Path -import pytest - os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") -from app.services import estimator from scripts.backtest_estimator import load_fixture, replay_fixture _FIXTURES = Path(__file__).parent / "fixtures" @@ -76,16 +73,7 @@ def test_fixture_and_baseline_committed() -> None: assert _BASELINE_PATH.exists(), f"frozen baseline missing: {_BASELINE_PATH}" -def test_backtest_regression_gate(monkeypatch: pytest.MonkeyPatch) -> None: - # The frozen fixture records the injected-callback control flow captured with - # cross-source physical dedup OFF (#2087 H4 was a no-op default at capture time). - # Dedup is now default ON (#2173), but this gate is a byte-identical REPLAY of a - # frozen OFF capture — with dedup active _dedup_cross_source would trim listings - # before quarter_indexes_lookup and the recorded call sequence would diverge. Pin - # the flag OFF so the replay follows the captured control flow. (Accuracy-neutral: - # backtest #1966 OFF vs ON is identical; dedup only trims user-visible n_analogs, - # so the OFF baseline stays the valid spine regression reference.) - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False) +def test_backtest_regression_gate() -> None: fixture = load_fixture(_FIXTURE_PATH) baseline = json.loads(_BASELINE_PATH.read_text(encoding="utf-8")) # Round-trip the replay output through JSON before comparing: the committed diff --git a/tradein-mvp/backend/tests/test_estimator_analogs_display_consistency.py b/tradein-mvp/backend/tests/test_estimator_analogs_display_consistency.py index c69cc3cc..ccb82884 100644 --- a/tradein-mvp/backend/tests/test_estimator_analogs_display_consistency.py +++ b/tradein-mvp/backend/tests/test_estimator_analogs_display_consistency.py @@ -172,7 +172,6 @@ def test_non_outlier_cross_source_duplicate_collapses_in_cards_only() -> None: # sanity: стат-дедуп (price_bucket-строгий) ДЕЙСТВИТЕЛЬНО пропускает пару — # иначе тест проверял бы не то, что заявлено в docstring. - assert estimator.settings.estimate_dedup_analogs_enabled is True deduped_upstream = estimator._dedup_cross_source([dup_a, dup_b]) assert len(deduped_upstream) == 2, "price_bucket dedup ошибочно поймал дрейф — тест не то мерит" diff --git a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py index 4564c412..077ae4f6 100644 --- a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py +++ b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py @@ -3,7 +3,7 @@ Проверяет: - композиты физического ключа (_lot_dedup_components) — что образуют / не образуют; - _dedup_cross_source: кросс-source дубли схлопываются в 1 представителя - (свежайший scraped_at), не-дубли не трогаются, флаг OFF = байт-идентично; + (свежайший scraped_at), не-дубли не трогаются; - выбор представителя детерминирован (freshest scraped_at). No DB / network: помощники оперируют plain-dict'ами. DATABASE_URL нужен только @@ -111,45 +111,28 @@ def test_phys_key_area_bucket_tolerates_rounding() -> None: # --------------------------------------------------------------------------- # -# Флаг: default ON (#2173) vs явный OFF (byte-identical) +# Дедуп без выключателя (#2378) # --------------------------------------------------------------------------- # def test_dedup_default_is_on() -> None: - # #2173: default флага теперь ON (в проде дедуп активен без ENV-override). - # Без monkeypatch settings отражает config-default → дедуп схлопывает - # кросс-пост-тройку одного физлота. Явно НЕ монкипатчим — проверяем именно - # поведение по умолчанию. - assert estimator.settings.estimate_dedup_analogs_enabled is True + # #2378: выключателя больше нет — дедуп работает всегда и схлопывает + # кросс-пост-тройку одного физлота. lots = [ _lot(source="avito"), _lot(source="cian"), _lot(source="domklik"), ] out = estimator._dedup_cross_source(lots) - assert len(out) == 1 # дедуп активен по умолчанию - - -def test_dedup_flag_off_is_noop(monkeypatch: pytest.MonkeyPatch) -> None: - # OFF-путь проверяем ЯВНЫМ override (не полагаясь на default, который теперь ON): - # при флаге OFF проход байт-идентичен — возвращается тот же объект. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False) - lots = [ - _lot(source="avito"), - _lot(source="cian"), - _lot(source="domklik"), - ] - out = estimator._dedup_cross_source(lots) - assert out is lots # тот же объект → нулевое влияние на выборку + assert len(out) == 1 # --------------------------------------------------------------------------- # -# _dedup_cross_source — флаг ON +# _dedup_cross_source # --------------------------------------------------------------------------- # -def test_dedup_collapses_cross_source_triple(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) +def test_dedup_collapses_cross_source_triple() -> None: # Один физлот 67м²/21.995М на floor 4 — кросс-пост avito+cian+domklik. lots = [ _lot(source="avito", scraped_at=datetime(2026, 6, 1, tzinfo=UTC)), @@ -162,8 +145,7 @@ def test_dedup_collapses_cross_source_triple(monkeypatch: pytest.MonkeyPatch) -> assert out[0]["source"] == "cian" -def test_dedup_keeps_distinct_units(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) +def test_dedup_keeps_distinct_units() -> None: # Разные физлоты: разный этаж / площадь / цена / здание → не дубли. lots = [ _lot(source="avito", floor=4, area=67.0, price=21_995_000), @@ -176,8 +158,7 @@ def test_dedup_keeps_distinct_units(monkeypatch: pytest.MonkeyPatch) -> None: assert len(out) == 5 # ничего не схлопнулось -def test_dedup_preserves_first_seen_order(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) +def test_dedup_preserves_first_seen_order() -> None: # relevance-порядок из _fetch_analogs должен сохраниться: группа занимает # позицию первого появления. lots = [ @@ -191,8 +172,7 @@ def test_dedup_preserves_first_seen_order(monkeypatch: pytest.MonkeyPatch) -> No assert [o["address"] for o in out] == ["ул. Мира 3", "ул. Ленина 5", "ул. Гагарина 9"] -def test_dedup_lots_without_key_stay_unique(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) +def test_dedup_lots_without_key_stay_unique() -> None: # Нет площади → ключ None → не дедупим, даже если оба «пустые». lots = [ _lot(source="avito", area=0), @@ -225,10 +205,9 @@ def test_extract_street_token_strips_house_and_noise() -> None: assert _street_token(None) == "" -def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_collapses_three_source_formats() -> None: # Прод-дефект (оценка d5fc3841): один физлот 33.6 м²/этаж 3/4.0М показан # как 3 аналога — cadnum пуст у всех троих, адреса в трёх форматах. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="cian", @@ -259,10 +238,9 @@ def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) - assert len(out) == 1 # один физлот, не три -def test_dedup_merges_cadnum_asymmetry_same_street(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_merges_cadnum_asymmetry_same_street() -> None: # У одного cadnum есть, у другого нет, адрес совпадает → сливаются по # street-композиту (#2265: union по любому совпавшему ключу). - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="cian", @@ -285,12 +263,9 @@ def test_dedup_merges_cadnum_asymmetry_same_street(monkeypatch: pytest.MonkeyPat assert len(out) == 1 -def test_dedup_distinct_cadnums_same_metrics_do_not_merge( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_dedup_distinct_cadnums_same_metrics_do_not_merge() -> None: # Две башни на одной улице с одинаковыми floor/area/price, но РАЗНЫМИ # кадастрами — авторитетный сигнал разных зданий → НЕ сливаем. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="cian", @@ -313,12 +288,9 @@ def test_dedup_distinct_cadnums_same_metrics_do_not_merge( assert len(out) == 2 -def test_dedup_same_street_different_metrics_stay_distinct( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_dedup_same_street_different_metrics_stay_distinct() -> None: # Разные лоты на одной улице с разной ценой/площадью/этажом → остаются # разными (защита от over-merge при отсутствии cadnum). - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3), _lot(source="avito", address="улица Сыромолотова, 11", area=45.0, price=6_500_000, floor=8), @@ -350,9 +322,8 @@ def test_extract_street_token_numbered_not_degraded() -> None: assert _street_token("ул. Белинского 86") == "белинского" -def test_dedup_collapses_numbered_street_three_formats(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_collapses_numbered_street_three_formats() -> None: # «8 Марта» в трёх source-форматах, cadnum пуст → один физлот. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="cian", @@ -383,10 +354,9 @@ def test_dedup_collapses_numbered_street_three_formats(monkeypatch: pytest.Monke assert len(out) == 1 -def test_dedup_house_no_guard_blocks_street_union(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_house_no_guard_blocks_street_union() -> None: # Пустой cadnum, ОДНА улица/этаж/area/price, но РАЗНЫЕ извлечённые номера # дома → house-guard не даёт слить (both-present-different). - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3), _lot(source="avito", address="улица Сыромолотова, 13", area=33.6, price=4_000_000, floor=3), @@ -395,10 +365,9 @@ def test_dedup_house_no_guard_blocks_street_union(monkeypatch: pytest.MonkeyPatc assert len(out) == 2 -def test_dedup_house_no_guard_order_stable(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_house_no_guard_order_stable() -> None: # Устойчивость к порядку: домклик без номера сливается с одним из номеров, # два разных номера остаются раздельными независимо от позиции «пустого». - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) base = dict(area=33.6, price=4_000_000, floor=3, cad=None) lots = [ _lot(source="domklik", address="улица Сыромолотова", **base), # house "" @@ -410,10 +379,9 @@ def test_dedup_house_no_guard_order_stable(monkeypatch: pytest.MonkeyPatch) -> N assert len(out) == 2 -def test_dedup_empty_cadnum_and_street_stay_separate(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_empty_cadnum_and_street_stay_separate() -> None: # Анти-катастрофа: пустой cadnum И нераспознанный адрес (нет типа улицы) при # идентичных метриках → НЕ сливаем (нет ни одного ключа → каждый уникален). - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot(source="cian", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None), _lot(source="avito", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None), @@ -485,7 +453,7 @@ def test_extract_street_token_extended_same_token_across_formats() -> None: assert domklik == avito == cian == "рассветная" -def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_collapses_domklik_cian_name_order() -> None: # Живой пример несклейки (оценка 23b8ada1): domklik «Рассветная улица, 6 к1» # + cian «улица Рассветная, 6к1» — тот же физлот (эт.13, ~38.5 м², 6.599М), # cadnum пуст (domklik всегда NULL). Расширенный парсер даёт обоим токен @@ -493,7 +461,6 @@ def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch # адреса используют одну и ту же глухую («к1») family корпуса, и после # #2436 нормализуются к одинаковой «6/1» — house-guard видит равные номера # независимо от пробела перед «к» (см. _normalize_house_no). - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="domklik", @@ -519,7 +486,7 @@ def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch assert out[0]["source"] == "cian" # представитель — свежайший scraped_at -def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_collapses_domklik_cian_area_straddle() -> None: # #2291 класс 3: тот же физлот «Рассветная 6к1», но area округляется в # СОСЕДНИЕ area_bucket банкирским round() — 38.5 → 38 (округление к чётному), # 39.0 → 39. До фикса разные area_bucket → street-owner lookup никогда не @@ -527,7 +494,6 @@ def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPa # должен склеить их в один физлот. Оба адреса — глухая («к1») family # корпуса → после #2436 нормализуются к одинаковой «6/1» (см. # _normalize_house_no), house-guard не блокирует merge независимо от пробела. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="domklik", @@ -552,11 +518,10 @@ def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPa assert len(out) == 1 # склеились несмотря на area straddle 38.5/39.0 -def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_collapses_avito_glued_suffix() -> None: # (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная, # 6к1» → тот же токен «рассветная», house у обоих канонизируется в «6/1» # (см. #2436 _normalize_house_no) → guard видит равные номера → склейка. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot(source="avito", address="Рассветная ул.,6к1", area=38.5, price=6_599_000, floor=13), _lot(source="cian", address="улица Рассветная, 6к1", area=38.5, price=6_599_000, floor=13), @@ -565,11 +530,10 @@ def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) -> assert len(out) == 1 -def test_dedup_mkr_fallback_does_not_cross_with_street(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_mkr_fallback_does_not_cross_with_street() -> None: # Префикс «mkr » изолирует микрорайонный токен от одноимённого уличного: # «Светлый микрорайон» (mkr) и «улица Светлая» (street) — РАЗНЫЕ ключи, не # сливаются даже при совпадении метрик. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot(source="cian", address="Светлый микрорайон, 10", area=40.0, price=5_000_000, floor=6), _lot(source="avito", address="улица Светлая, 10", area=40.0, price=5_000_000, floor=6), @@ -622,11 +586,10 @@ def test_parse_street_house_bare_street_requires_earlier_mkr_segment(addr: str) assert estimator._parse_street_house(addr) == ("", "") -def test_dedup_collapses_domclick_bare_street_mkr_misfire(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_collapses_domclick_bare_street_mkr_misfire() -> None: # Класс 1 (#2291) e2e: DomClick «Заречный м-н, Готвальда, 24» раньше # ключевался по району и никогда не сливался с avito/cian, где «готвальда» # распознаётся явной веткой (тип-слово «улица»). - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="domclick", @@ -660,10 +623,9 @@ def test_parse_street_house_yo_ye_fold() -> None: ) -def test_dedup_collapses_yo_ye_variant(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_collapses_yo_ye_variant() -> None: # Класс 2 (#2291) e2e: один и тот же физлот в двух source-форматах — # «Шевелёва» (avito) и «Шевелева» (cian) — теперь схлопывается в один. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot(source="avito", address="улица Шевелёва, 12", area=42.0, price=5_200_000, floor=4), _lot(source="cian", address="улица Шевелева, 12", area=42.0, price=5_200_000, floor=4), @@ -753,13 +715,10 @@ def test_parse_street_house_ambiguous_glued_digit_runs_limitation() -> None: assert estimator._parse_street_house("ул. Ленина, 7500 м до метро") == ("ленина", "7500") -def test_dedup_collapses_avito_glued_metro_suffix_house_match( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_dedup_collapses_avito_glued_metro_suffix_house_match() -> None: # e2e: avito-лот с приклеенным названием станции (дом «2» до фикса терялся, # извлекалось «15») + чистый cian-адрес того же дома «2» → house-guard # больше не блокирует street-union (оба извлекают «2») → один физлот. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="avito", @@ -782,14 +741,11 @@ def test_dedup_collapses_avito_glued_metro_suffix_house_match( assert len(out) == 1 -def test_dedup_glued_metro_noise_does_not_cause_over_merge( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_dedup_glued_metro_noise_does_not_cause_over_merge() -> None: # Анти-регрессия: фикс не должен сделать guard_house снисходительнее для # адресов с глухим метро-шумом в общем случае — ДЕЙСТВИТЕЛЬНО разные дома # («24» vs «26»), каждый одет в приклеенное название станции разной длины, # ДОЛЖНЫ остаться раздельными (guard всё ещё блокирует both-present-different). - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="avito", @@ -863,15 +819,12 @@ def test_parse_street_house_all_corpus_notations_agree(notation: str) -> None: assert estimator._parse_street_house(f"ул. Ленина, {notation}") == ("ленина", "65/4") -def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus() -> None: # Точный репортированный баг: avito-подобный адрес («...65к4...», глухая # нотация) + domklik-подобный адрес («...65/4...», slash-нотация) — тот же # физлот, без кадастра ни у кого, тот же этаж/площадь/цена-бакет — ДОЛЖНЫ # схлопнуться в один. union() зависит от порядка (кто станет root) — # проверяем ОБА порядка следования лотов. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) def _pair(first_source: str, second_source: str) -> list[dict]: by_source = { @@ -903,14 +856,11 @@ def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus( assert len(out_domklik_first) == 1 -def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus() -> None: # Адверсариальный кейс: нормализация НЕ должна расширять сеть слияний за # пределы genuine corpus-notation эквивалентности. «65к4» (-> «65/4») и # «65г» (литер, не тронут) — РАЗНЫЕ дома на одной улице, слияния быть не # должно. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="avito", @@ -933,13 +883,10 @@ def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus( assert len(out) == 2 -def test_dedup_house_no_guard_blocks_different_corpus_numbers( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_dedup_house_no_guard_blocks_different_corpus_numbers() -> None: # Нормализация — не blanket same-street merge: «65/4» vs «65/5» (или # «65к4» vs «65к5») — genuinely разные здания, guard_house обязан # по-прежнему их разделять после нормализации. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="domklik", diff --git a/tradein-mvp/backend/tests/test_estimator_manual_review.py b/tradein-mvp/backend/tests/test_estimator_manual_review.py index d2c9d916..681c934c 100644 --- a/tradein-mvp/backend/tests/test_estimator_manual_review.py +++ b/tradein-mvp/backend/tests/test_estimator_manual_review.py @@ -15,7 +15,6 @@ from __future__ import annotations import os -from types import SimpleNamespace os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") @@ -33,20 +32,6 @@ ELITE_REASON = ( ) -def _settings( - *, - high_value: int = 20_000_000, - wide_ratio: float = 1.9, - elite_ppm2: int = 250_000, -) -> SimpleNamespace: - """Duck-typed заглушка Settings — helper читает только эти поля.""" - return SimpleNamespace( - estimate_manual_review_high_value_rub=high_value, - estimate_manual_review_wide_range_ratio=wide_ratio, - estimate_manual_review_elite_ppm2=elite_ppm2, - ) - - # ── Каждый триггер в изоляции ──────────────────────────────────────────────── @@ -57,7 +42,6 @@ def test_premium_building_alone() -> None: confidence="high", range_low=7_500_000, range_high=8_500_000, - settings=_settings(), ) assert rec is True assert reasons == [PREMIUM_REASON] @@ -70,7 +54,6 @@ def test_high_value_alone() -> None: confidence="high", range_low=24_000_000, range_high=26_000_000, - settings=_settings(), ) assert rec is True assert reasons == [HIGH_VALUE_REASON] @@ -83,7 +66,6 @@ def test_low_confidence_alone() -> None: confidence="low", range_low=4_500_000, range_high=5_500_000, - settings=_settings(), ) assert rec is True assert reasons == [LOW_CONF_REASON] @@ -96,7 +78,6 @@ def test_wide_range_alone() -> None: confidence="high", range_low=5_000_000, range_high=10_000_000, # ratio 2.0 ≥ 1.9 - settings=_settings(), ) assert rec is True assert reasons == [WIDE_RANGE_REASON] @@ -114,7 +95,6 @@ def test_elite_ppm2_alone() -> None: confidence="high", range_low=11_000_000, range_high=13_000_000, # ratio ~1.18 < 1.9 - settings=_settings(), asking_ppm2=300_000, # ≥ 250_000 ) assert rec is True @@ -129,7 +109,6 @@ def test_elite_ppm2_boundary_inclusive() -> None: confidence="high", range_low=11_000_000, range_high=13_000_000, - settings=_settings(), asking_ppm2=250_000, ) assert rec is True @@ -144,7 +123,6 @@ def test_elite_ppm2_just_below_threshold() -> None: confidence="high", range_low=11_000_000, range_high=13_000_000, - settings=_settings(), asking_ppm2=249_999, ) assert rec is False @@ -159,7 +137,6 @@ def test_elite_ppm2_none_safe() -> None: confidence="high", range_low=11_000_000, range_high=13_000_000, - settings=_settings(), asking_ppm2=None, ) assert rec is False @@ -175,7 +152,6 @@ def test_elite_ppm2_zero_safe() -> None: confidence="high", range_low=11_000_000, range_high=13_000_000, - settings=_settings(), asking_ppm2=0, ) assert rec is False @@ -190,7 +166,6 @@ def test_elite_ppm2_combined_with_other_triggers() -> None: confidence="low", range_low=10_000_000, range_high=25_000_000, # ratio 2.5 ≥ 1.9 - settings=_settings(), asking_ppm2=400_000, ) assert rec is True @@ -214,7 +189,6 @@ def test_multiple_reasons_combined() -> None: confidence="low", range_low=10_000_000, range_high=25_000_000, # ratio 2.5 ≥ 1.9 - settings=_settings(), ) assert rec is True # порядок детерминирован: premium → high_value → low_conf → wide_range @@ -228,7 +202,6 @@ def test_all_clear_no_reasons() -> None: confidence="medium", range_low=9_000_000, range_high=11_000_000, # ratio ~1.22 < 1.9 - settings=_settings(), ) assert rec is False assert reasons == [] @@ -245,7 +218,6 @@ def test_high_value_boundary_inclusive() -> None: confidence="medium", range_low=19_000_000, range_high=21_000_000, - settings=_settings(), ) assert rec is True assert reasons == [HIGH_VALUE_REASON] @@ -258,7 +230,6 @@ def test_high_value_just_below_threshold() -> None: confidence="medium", range_low=19_000_000, range_high=21_000_000, - settings=_settings(), ) assert rec is False assert reasons == [] @@ -272,7 +243,6 @@ def test_wide_range_boundary_inclusive() -> None: confidence="high", range_low=10_000_000, range_high=19_000_000, # ratio == 1.9 - settings=_settings(), ) assert rec is True assert reasons == [WIDE_RANGE_REASON] @@ -285,7 +255,6 @@ def test_wide_range_just_below_threshold() -> None: confidence="high", range_low=10_000_000, range_high=18_900_000, # ratio 1.89 < 1.9 - settings=_settings(), ) assert rec is False assert reasons == [] @@ -311,7 +280,6 @@ def test_wide_range_div_guard(range_low: int | None, range_high: int | None) -> confidence="high", range_low=range_low, range_high=range_high, - settings=_settings(), ) assert rec is False assert WIDE_RANGE_REASON not in reasons @@ -332,7 +300,6 @@ def test_recommended_equals_bool_reasons_invariant() -> None: confidence=conf, range_low=9_000_000, range_high=11_000_000, - settings=_settings(), ) assert rec is bool(reasons) diff --git a/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py b/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py index d73a9364..251d5964 100644 --- a/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py +++ b/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py @@ -26,12 +26,9 @@ from datetime import UTC, datetime from pathlib import Path from typing import Any -import pytest - # Settings требует DATABASE_URL при инициализации (fail-fast, C-3). os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") -from app.services import estimator # Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный # estimate путь со всеми I/O застабленными). anchor_comps=[] + anchor_tier=None @@ -43,16 +40,6 @@ _h = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(_h) -@pytest.fixture(autouse=True) -def _pin_dedup_off(monkeypatch: pytest.MonkeyPatch) -> None: - # Эти тесты проверяют инвариант #2 «n_analogs = число priced-аналогов», - # ортогональный кросс-source физдедупу (#2087 H4, default ON с #2173). - # Синтетические аналоги делят один адрес/этаж/площадь → дедуп схлопнул бы - # близкие по цене как кросс-посты и исказил счётчик. Пиним флаг OFF, чтобы - # мерить именно #2 в изоляции (не полагаясь на прод-default, который теперь ON). - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False) - - def _listing(price_per_m2: float | None, area_m2: float = 60.0) -> dict[str, Any]: """Радиусный аналог; price_per_m2=None моделирует листинг без ₽/м². @@ -103,8 +90,10 @@ def test_n_analogs_counts_only_priced_radius_analogs() -> None: _listing(price_per_m2=210_000.0), _listing(price_per_m2=215_000.0), _listing(price_per_m2=225_000.0), - _listing(price_per_m2=None), - _listing(price_per_m2=None), + # Разная площадь → разные физлоты: кросс-source дедуп (#2087 H4, всегда ON + # с #2378) не схлопывает два безценовых листинга в один. + _listing(price_per_m2=None, area_m2=61.0), + _listing(price_per_m2=None, area_m2=62.0), ] est = _h._run_estimate( anchor_comps=[], @@ -141,7 +130,8 @@ def test_n_analogs_all_priceless_yields_zero_and_low() -> None: def test_n_analogs_all_priced_unchanged() -> None: """Контроль: все аналоги С ценой → n_analogs = их число (поведение не меняется, зеркало test_radius_path_n_analogs_unchanged для priced-only выборки).""" - radius = [_listing(price_per_m2=200_000.0 + i * 1_000) for i in range(5)] + # Разная площадь → 5 разных физлотов, кросс-source дедуп их не схлопывает. + radius = [_listing(price_per_m2=200_000.0 + i * 1_000, area_m2=60.0 + i) for i in range(5)] est = _h._run_estimate( anchor_comps=[], anchor_tier=None, diff --git a/tradein-mvp/backend/tests/test_estimator_split_corridor_1871.py b/tradein-mvp/backend/tests/test_estimator_split_corridor_1871.py index f594deb4..afc2ea1b 100644 --- a/tradein-mvp/backend/tests/test_estimator_split_corridor_1871.py +++ b/tradein-mvp/backend/tests/test_estimator_split_corridor_1871.py @@ -22,8 +22,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/ import pytest -from app.core.config import settings -from app.services.estimator import _downgrade_confidence +from app.services.estimator import WIDE_CORRIDOR_THRESHOLD, _downgrade_confidence # Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный # estimate путь со всеми I/O застабленными). @@ -48,7 +47,7 @@ def _payload(): # type: ignore[no-untyped-def] def _run(*, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def] - with patch.object(settings, "estimate_wide_corridor_threshold", threshold): + with patch("app.services.estimator.WIDE_CORRIDOR_THRESHOLD", threshold): return _h._run_estimate( anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps, anchor_tier=anchor_tier, @@ -67,7 +66,7 @@ _DISCLOSURE_MARKER = "разбит на секции" def test_threshold_default_1_2() -> None: # 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом); # genuine split-дома аудита = 148-170% → 1.2 ловит только экстремальный хвост. - assert settings.estimate_wide_corridor_threshold == 1.2 + assert WIDE_CORRIDOR_THRESHOLD == 1.2 # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index 0f0502ee..2fadaeca 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -24,9 +24,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/ from unittest.mock import AsyncMock, MagicMock, patch import anyio -import pytest -from app.services import estimator from app.services.estimator import ( _compute_same_building_anchor, _geocode_is_coarse, @@ -1040,16 +1038,14 @@ def test_anchor_n_analogs_syncs_to_anchor_count() -> None: assert "по 4 аналогам" in (est.confidence_explanation or "") -def test_radius_path_n_analogs_unchanged(monkeypatch: pytest.MonkeyPatch) -> None: +def test_radius_path_n_analogs_unchanged() -> None: """#695 контроль: без якоря n_analogs = радиусный счёт (поведение не меняется). Проверяет radius-passthrough vs anchor-override — ортогонально кросс-source - дедупу (#2087 H4, default ON с #2173). 5 ИДЕНТИЧНЫХ листингов — это ровно - патологический кросс-пост, который дедуп схлопнул бы в 1; пиним флаг OFF, - чтобы мерить именно #695-инвариант, не полагаясь на прод-default (теперь ON). + дедупу (#2087 H4, всегда ON с #2378). Площади разные, поэтому это 5 разных + физлотов: 5 ИДЕНТИЧНЫХ листингов дедуп честно схлопнул бы в 1. """ - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False) - radius = [_make_listing(price_per_m2=200_000.0) for _ in range(5)] + radius = [_make_listing(price_per_m2=200_000.0, area_m2=60.0 + i) for i in range(5)] est = _run_estimate(anchor_comps=[], anchor_tier=None, radius_analogs=radius) assert est.n_analogs == 5 From 0ff27030322b4400127415a7ccbeb04827b1cb1a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:31:12 +0500 Subject: [PATCH 08/47] =?UTF-8?q?=D0=9C=D0=95=D0=A0=D0=90:=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=B8=20=D0=BA=D0=B2=D0=B0=D1=80=D1=82=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B8=D0=BD=D0=B4=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B0=20=E2=80=94=20=D0=BA=D0=BE=D0=BD=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D1=82=D1=8B=20=D0=B4=D0=B2=D0=B8=D0=B6=D0=BA=D0=B0?= =?UTF-8?q?,=20=D0=BD=D0=B5=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D0=BA=D0=B8=20(#2379)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Единственный переключатель estimate_quarter_index_enabled снят ещё в #2475. Оставшиеся шесть числовых полей Settings (min_n_deals 10, match_skip_ratio 0.6, max_for_small_n 2.0, small_n_threshold 50, factor_min 0.6, factor_max 1.8) перенесены в estimator.py константами QUARTER_* с теми же значениями и комментариями #764/#859; бэктест берёт QUARTER_INDEX_MIN_N_DEALS из модуля. На проде 17.09 все шесть равны дефолтам, ENV-оверрайдов нет. Замороженная фикстура квартальный индекс не применяет ни разу (0 из 1600 сделок), а поведенческие тесты не краснеют при подмене трёх порогов из шести, поэтому добавлен tests/test_1970_estimator_constants.py: боевые значения, снятые с прода, и проверка, что поле не вернулось в Settings. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/core/config.py | 21 +-------- tradein-mvp/backend/app/services/estimator.py | 36 +++++++++++---- .../backend/scripts/backtest_estimator.py | 5 +-- .../tests/test_1970_estimator_constants.py | 45 +++++++++++++++++++ 4 files changed, 75 insertions(+), 32 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_1970_estimator_constants.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 82bab36f..fb3b2d3b 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -695,26 +695,7 @@ class Settings(BaseSettings): # datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER). avito_imv_use_browser_fetcher: bool = False - # ── #764: per-cadastral-quarter price index correction ─────────────────── - # Gap-correction: квартальный индекс применяется ТОЛЬКО в pure-radius пути - # (когда same-building anchor и IMV-blend не сработали). Корректирует РАЗРЫВ - # между квартальным уровнем целевого объекта и усреднённым квартальным уровнем - # аналогов — не дублирует location, уже заложенный в медиану аналогов. - # Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index. - # Минимальное число сделок в квартале (sparse fallback: меньше — no-op). - estimate_quarter_index_min_n_deals: int = 10 - # Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога — - # аналоги уже несут локацию квартала → skip (location in median). - estimate_quarter_match_skip_ratio: float = 0.6 - # Bimodal/nominal guard (backtest 2026-05-31): структурно неоднородные кварталы - # дают индекс > 2.0 при малой выборке → no-op чтобы избежать регрессию. - estimate_quarter_index_max_for_small_n: float = 2.0 - estimate_quarter_index_small_n_threshold: int = 50 - # Sanity-clamp на factor = target_index / avg_analog_index (#859). - # Belt-and-suspenders против патологичных FDW-данных. Нормальные квартальные - # индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал. - estimate_quarter_index_factor_min: float = 0.6 - estimate_quarter_index_factor_max: float = 1.8 + # ── #764: квартальный индекс цены (пороги — константы QUARTER_* в estimator.py) ── # Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local), # когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде. # ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED). diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 9b0ccc95..c4aec71f 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -441,6 +441,24 @@ MANUAL_REVIEW_WIDE_RANGE_RATIO = 1.9 # range_high/range_low ≥ — неопр # отделку/вид/класс — unit-level, отсутствует в данных сделок). MANUAL_REVIEW_ELITE_PPM2 = 250_000 +# #764 (#2379): поправка на квартальный индекс цены. Применяется ТОЛЬКО на +# pure-radius пути (same-building anchor и IMV-blend не сработали) и корректирует +# РАЗРЫВ между квартальным уровнем цели и средним уровнем аналогов: +# adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index. +# Минимум сделок в квартале, чтобы индексу верить (меньше — поправки нет). +QUARTER_INDEX_MIN_N_DEALS = 10 +# Guard-2 (no double-count): доля аналогов ИЗ ТОГО ЖЕ квартала выше порога — +# локация квартала уже в медиане → skip. +QUARTER_MATCH_SKIP_RATIO = 0.6 +# Bimodal/nominal guard (бэктест 2026-05-31): структурно неоднородные кварталы дают +# индекс > 2.0 на малой выборке (< 50 сделок) → no-op, иначе регрессия. +QUARTER_INDEX_MAX_FOR_SMALL_N = 2.0 +QUARTER_INDEX_SMALL_N_THRESHOLD = 50 +# #859: sanity-clamp factor = target_index / avg_analog_index. Нормальные +# квартальные индексы РФ лежат в [0.6, 1.8]; за порогами — артефакт FDW, не сигнал. +QUARTER_INDEX_FACTOR_MIN = 0.6 +QUARTER_INDEX_FACTOR_MAX = 1.8 + # #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются # нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади — # которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные @@ -3936,15 +3954,15 @@ def _price_from_inputs( # Bimodal/nominal guard (Guard-4). if ( - target_qi > settings.estimate_quarter_index_max_for_small_n - and target_n_deals < settings.estimate_quarter_index_small_n_threshold + target_qi > QUARTER_INDEX_MAX_FOR_SMALL_N + and target_n_deals < QUARTER_INDEX_SMALL_N_THRESHOLD ): logger.info( "quarter_index: bimodal guard triggered " "(index=%.3f n=%d < %d) for %s — no-op", target_qi, target_n_deals, - settings.estimate_quarter_index_small_n_threshold, + QUARTER_INDEX_SMALL_N_THRESHOLD, target_quarter, ) else: @@ -3969,11 +3987,11 @@ def _price_from_inputs( if lot_quarters_for_guard2 else 0.0 ) - if same_quarter_ratio > settings.estimate_quarter_match_skip_ratio: + if same_quarter_ratio > QUARTER_MATCH_SKIP_RATIO: logger.info( "quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s", same_quarter_ratio, - settings.estimate_quarter_match_skip_ratio, + QUARTER_MATCH_SKIP_RATIO, target_quarter, ) else: @@ -4026,8 +4044,8 @@ def _price_from_inputs( base_range_high=range_high, target_index=target_qi, avg_analog_index=avg_analog_index, - min_factor=settings.estimate_quarter_index_factor_min, - max_factor=settings.estimate_quarter_index_factor_max, + min_factor=QUARTER_INDEX_FACTOR_MIN, + max_factor=QUARTER_INDEX_FACTOR_MAX, ) analogs_with_qi = sum( 1 for lq, _lp in analog_quarters if lq in analog_index_map @@ -5191,14 +5209,14 @@ async def estimate_quality( return _lookup_quarter_index( db, quarter_cad_number=q, - min_n_deals=settings.estimate_quarter_index_min_n_deals, + min_n_deals=QUARTER_INDEX_MIN_N_DEALS, ) def _qis_lookup(qs: list[str]) -> dict[str, float]: return _lookup_quarter_indexes( db, quarter_cad_numbers=qs, - min_n_deals=settings.estimate_quarter_index_min_n_deals, + min_n_deals=QUARTER_INDEX_MIN_N_DEALS, ) # ── Deterministic pricing orchestration ────────────────────────────────── diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index 52181f84..f77eb0c8 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -2099,7 +2099,6 @@ def _predict_full_spine( whether the house IMV anchor actually resolved a row for this deal. """ m = est.m - settings = est.settings listings, analog_tier, fallback_used, area_widened = _select_analogs_full( db, deal, est, target_house_id=target_house_id @@ -2188,7 +2187,7 @@ def _predict_full_spine( res = m._lookup_quarter_index( db, quarter_cad_number=q, - min_n_deals=settings.estimate_quarter_index_min_n_deals, + min_n_deals=m.QUARTER_INDEX_MIN_N_DEALS, ) if capture is not None: ret = _sanitize_json(list(res) if res is not None else None) @@ -2199,7 +2198,7 @@ def _predict_full_spine( res = m._lookup_quarter_indexes( db, quarter_cad_numbers=qs, - min_n_deals=settings.estimate_quarter_index_min_n_deals, + min_n_deals=m.QUARTER_INDEX_MIN_N_DEALS, ) if capture is not None: qis_calls.append([_sanitize_json(list(qs)), _sanitize_json(dict(res))]) diff --git a/tradein-mvp/backend/tests/test_1970_estimator_constants.py b/tradein-mvp/backend/tests/test_1970_estimator_constants.py new file mode 100644 index 00000000..742e512b --- /dev/null +++ b/tradein-mvp/backend/tests/test_1970_estimator_constants.py @@ -0,0 +1,45 @@ +"""#1970: калибровочные константы движка, вынесенные из Settings, — значения. + +Поля estimate_* жили в Settings и формально переопределялись через ENV. При +переносе в константы estimator.py значения обязаны остаться боевыми. Числа ниже +сняты с прода 17.09.2026 (`settings.<поле>` в tradein-backend, tradein-scraper и +tradein-tgbot — совпали, ENV-оверрайдов нет), а не скопированы из кода. + +Зачем отдельный тест: замороженная фикстура бэктеста часть механизмов не +проходит вовсе (квартальный индекс в реплее применяется 0 раз из 1600 сделок), +а поведенческие тесты ловят не все пороги. Поменять число — осознанно: здесь, +в estimator.py и с прогоном регрессионного гейта. +""" + +from __future__ import annotations + +import os + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.core.config import Settings +from app.services import estimator + +# имя константы в estimator.py → (бывшее поле Settings, боевое значение 17.09.2026) +_PROD_VALUES: dict[str, tuple[str, float]] = { + # #2379, #764 квартальный индекс + "QUARTER_INDEX_MIN_N_DEALS": ("estimate_quarter_index_min_n_deals", 10), + "QUARTER_MATCH_SKIP_RATIO": ("estimate_quarter_match_skip_ratio", 0.6), + "QUARTER_INDEX_MAX_FOR_SMALL_N": ("estimate_quarter_index_max_for_small_n", 2.0), + "QUARTER_INDEX_SMALL_N_THRESHOLD": ("estimate_quarter_index_small_n_threshold", 50), + "QUARTER_INDEX_FACTOR_MIN": ("estimate_quarter_index_factor_min", 0.6), + "QUARTER_INDEX_FACTOR_MAX": ("estimate_quarter_index_factor_max", 1.8), +} + + +@pytest.mark.parametrize("const", sorted(_PROD_VALUES)) +def test_constant_keeps_prod_value(const: str) -> None: + assert getattr(estimator, const) == _PROD_VALUES[const][1] + + +@pytest.mark.parametrize("const", sorted(_PROD_VALUES)) +def test_settings_field_is_gone(const: str) -> None: + # Поле вернулось в Settings → снова два источника одного числа (ENV и код). + assert _PROD_VALUES[const][0] not in Settings.model_fields From 883c741a63309d3dc5c5f7ffb4c7be0fdc81b63a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:33:56 +0500 Subject: [PATCH 09/47] =?UTF-8?q?=D0=9C=D0=95=D0=A0=D0=90:=20=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=BC=D0=BF=20expected=5Fsold=20=E2=89=A4=20asking=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=B2=D1=8B=D0=BA=D0=BB=D1=8E=D1=87=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8F,=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=20#1795=20=E2=80=94=20=D0=BA=D0=BE=D0=BD=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D1=82=D1=8B=20(#2380)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Флаг estimate_expected_sold_le_asking снят: оба клампа (ratio > 1.0 и повторный после хедоники) безусловные. На проде 17.09 флаг = True во всех трёх контейнерах, ENV-оверрайда нет, фикстура бэктеста захвачена с True. Флаги corridor_clamp/radius_floor enabled сняты ещё в #2475. Числовые пороги перенесены с прежними значениями: CORRIDOR_CLAMP_SLACK 0.40, RADIUS_FLOOR_FACTOR 0.8, OUTLIER_SMALL_N_THRESHOLD 15, OUTLIER_TUKEY_K_SMALL 1.0 — в estimator.py; CORRIDOR_CLAMP_MIN_N 10 — в app.core.config рядом с LISTINGS_FRESH_DAYS, потому что его же читает DkpCorridor.advisory_only (#3452), а схема не должна тянуть estimator. Мёртвая проверка `tukey_k_small < 1.5` (константа против константы) убрана. Сегментный множитель (#2255) не тронут, порядок операций прежний. Тесты: OFF-тесты клампа удалены; тест потолка хедоники берёт ratio 0.70, при котором кламп не срабатывает (0.70 × 1.30 = 0.91), вместо выключения клампа. Реплей бэктеста по сделкам побитово тот же (бизнес 169, элит 5, премиум 4). Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/core/config.py | 45 +++----------- tradein-mvp/backend/app/schemas/trade_in.py | 8 +-- tradein-mvp/backend/app/services/estimator.py | 59 ++++++++++++------- .../tests/test_1970_estimator_constants.py | 17 ++++++ .../tests/test_3452_corridor_advisory_zone.py | 21 ++++--- .../test_estimator_expected_sold_clamp.py | 21 +------ .../backend/tests/test_estimator_hedonic.py | 26 +++----- .../tests/test_estimator_radius_floor.py | 10 +--- tradein-mvp/frontend/src/lib/rosreestr.ts | 2 +- tradein-mvp/frontend/src/types/trade-in.ts | 2 +- 10 files changed, 92 insertions(+), 119 deletions(-) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index fb3b2d3b..763c75fd 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -44,6 +44,12 @@ _AUTH_DB_DEFAULT_USER = "auth_app" # никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк). LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней +# #1795 шаг 1 / #3452: минимум ДКП-сделок, с которого коридор держит цену — soft-кламп +# headline сверху и radius-floor снизу (estimator.py). Живёт здесь, а не в estimator.py: +# тот же порог читает схема (DkpCorridor.advisory_only), а schemas остаётся import-light. +# Бывшее поле Settings estimate_corridor_clamp_min_n (#2380), на проде не переопределялось. +CORRIDOR_CLAMP_MIN_N = 10 + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") @@ -521,38 +527,7 @@ class Settings(BaseSettings): estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7% estimate_hedonic_factor_min: float = 0.75 estimate_hedonic_factor_max: float = 1.30 - # ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ────── - # Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП - # (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%. - # Каждый флаг в no-op/OFF положении восстанавливает ТОЧНО старое поведение. - # - # Шаг 1 — soft-кламп headline к коридору ДКП-сделок Росреестра. Когда - # median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A" - # (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline - # к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold. - estimate_corridor_clamp_min_n: int = 10 - # cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов - # (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию - # (tier-C гейт ×1.5 ловит явную контаминацию выше). - estimate_corridor_clamp_slack: float = 0.40 - # Нижний floor для radius-пути: симметрично corridor-clamp сверху, но снизу. - # Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor. - # Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw. - # factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома - # (избегаем ложных подъёмов) и ловит явный undershoot. - # ENV: ESTIMATE_RADIUS_FLOOR_FACTOR. - estimate_radius_floor_factor: float = 0.8 - # Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для - # trade-in (ожидаемая цена сделки не должна превышать цену объявления). - # Диагноз: в high-price tier asking->sold ratio > 1.0 (product artefact, не реальные - # сделки выше прайса) -> expected_sold = headline x ratio > headline. - # При флаге True: если ratio > 1.0 — клампаем до 1.0 и логируем. Применяется - # к point И range (expected_sold_low/high/price) консистентно. - # False -> старое поведение без clamp (backward-compat). - # ENV: ESTIMATE_EXPECTED_SOLD_LE_ASKING. - estimate_expected_sold_le_asking: bool = Field( - default=True, validation_alias="ESTIMATE_EXPECTED_SOLD_LE_ASKING" - ) + # ── #1795: premium headline anti-inflation (шаги 1/4/5 — константы estimator.py) ── # Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor: # при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k # (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх). @@ -563,10 +538,6 @@ class Settings(BaseSettings): # anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану. # Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение). estimate_anchor_tier_c_corridor_mult: float = 1.5 - # Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < threshold - # k уменьшается с 1.5 до tukey_k_small. threshold=0 → выключено (старое поведение). - estimate_outlier_small_n_threshold: int = 15 - estimate_outlier_tukey_k_small: float = 1.0 # #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же # доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где # "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free). @@ -884,7 +855,7 @@ class Settings(BaseSettings): # регион 50 n=35 264 sold_median 162 303 → 179 314 (×1.1048, factor 1.065..1.138) # регион 66 n=33 907 sold_median 94 655 → 99 059 (×1.0465, factor 1.032..1.062) # Ожидаемые ratio после первого пересчёта: 0.729→0.811 (77), 0.811→0.896 (50), - # 0.728→0.762 (66) — все ниже 1.0, клампа estimate_expected_sold_le_asking не задевают. + # 0.728→0.762 (66) — все ниже 1.0, клампа expected_sold ≤ asking не задевают. # NB: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его # bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод # обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025. diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 00d6c074..fd9913e0 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -163,7 +163,7 @@ class DkpCorridor(BaseModel): [low,high]×slack — добавляется текстовая пометка. None / count=0 если по улице нет сопоставимых сделок. - #3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n + #3452: «advisory» здесь НЕ безусловно. При count >= CORRIDOR_CLAMP_MIN_N коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only. """ @@ -192,7 +192,7 @@ class DkpCorridor(BaseModel): def advisory_only(self) -> bool: """#3452: True = сделок меньше порога, ценовые страховки коридора выключены. - Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ + Порог один и тот же (`CORRIDOR_CLAMP_MIN_N`) у обоих СТРАХОВОЧНЫХ путей коридора: soft-кламп headline сверху и radius-floor снизу (estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит цену — зона n=3..9 на экране была неотличима от работающей. @@ -207,9 +207,9 @@ class DkpCorridor(BaseModel): автоматически (POST /estimate и GET-rehydrate) и не дублирует порог вторым числом. """ - from app.core.config import settings # локально: schemas остаётся import-light + from app.core.config import CORRIDOR_CLAMP_MIN_N # локально: schemas import-light - return self.count < settings.estimate_corridor_clamp_min_n + return self.count < CORRIDOR_CLAMP_MIN_N class PriceTrendPoint(BaseModel): diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index c4aec71f..d826ca12 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -55,7 +55,7 @@ from scraper_kit.proxy_errors import caused_by_no_proxy from sqlalchemy import text from sqlalchemy.orm import Session -from app.core.config import LISTINGS_FRESH_DAYS, settings +from app.core.config import CORRIDOR_CLAMP_MIN_N, LISTINGS_FRESH_DAYS, settings from app.core.db import SessionLocal, run_db_thread from app.schemas.trade_in import ( AggregatedEstimate, @@ -316,7 +316,7 @@ def rehydrate_search_radius_m( # Замер 2026-09-10: средневзвешенный по 69 138 московским сделкам за 12 месяцев # time-фактор равен 1.0313 по свердловскому ряду против 1.0917 по московскому — # московский коридор занижен на ~5.9%. Занижен НЕ advisory: при -# n>=estimate_corridor_clamp_min_n коридор участвует в clamp headline, radius-floor +# n>=CORRIDOR_CLAMP_MIN_N коридор участвует в clamp headline, radius-floor # и Tier-C anchor gate, т.е. едет итоговая цена. SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд региона 66 (= DEFAULT_REGION_CODE) @@ -459,6 +459,26 @@ QUARTER_INDEX_SMALL_N_THRESHOLD = 50 QUARTER_INDEX_FACTOR_MIN = 0.6 QUARTER_INDEX_FACTOR_MAX = 1.8 +# #1795 (#2380): premium headline anti-inflation. Диагноз: бизнес/премиум headline +# завышался ~2× против медианы реальных ДКП (Малышева 30 = 296k при медиане сделок +# 138k), эконом/комфорт сходились ±5%. Порог числа сделок CORRIDOR_CLAMP_MIN_N — в +# app.core.config (его же читает DkpCorridor.advisory_only, #3452). +# Шаг 1 — soft-кламп headline к коридору ДКП-сделок: median_ppm2 > high_ppm2×(1+slack) +# И count ≥ CORRIDOR_CLAMP_MIN_N И anchor_tier != "A" (комплы того же дома EXEMPT) → +# headline прижимается к high_ppm2×(1+slack). 0.40 → cap ×1.40 даёт премиум-домам без +# своих листингов (tier-C) воздух над sold-коридором, не возвращая 2× инфляцию. +CORRIDOR_CLAMP_SLACK = 0.40 +# Нижний floor radius-пути (anchor_tier is None, коридор с count ≥ min_n): +# median_ppm2 < dkp_low_ppm2 × factor → поднимаем до floor. 0.8 — 20% зазор ниже P10 +# коридора: достаточно мягко для эконома, ловит явный undershoot. +RADIUS_FLOOR_FACTOR = 0.8 +# Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < порога k = 1.0 вместо +# 1.5, иначе несколько элитных листингов тонкой выборки тянут медиану вверх. +OUTLIER_SMALL_N_THRESHOLD = 15 +OUTLIER_TUKEY_K_SMALL = 1.0 +# Шаг 5 — expected_sold ≤ asking — выключателя не имеет (_price_from_inputs): ratio > 1.0 +# для trade-in физически невозможен, это артефакт high-price бакета. + # #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются # нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади — # которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные @@ -2056,7 +2076,7 @@ def _resolve_target_city(address_text: str | None) -> str | None: Returns None если ни один город не распознан (адрес вне известного списка) — caller (_fetch_dkp_corridor) откатывается на behaviour без city-фильтра. NB: коридор НЕ чисто advisory — при - n>=estimate_corridor_clamp_min_n участвует в clamp headline + radius-floor + + n>=CORRIDOR_CLAMP_MIN_N участвует в clamp headline + radius-floor + Tier-C anchor gate, поэтому city-scope влияет на итоговую цену. """ if not address_text: @@ -2266,7 +2286,7 @@ def _fetch_dkp_corridor( latest_deal = dd ppm2_values = sorted(adjusted) # #3452: чем получен коридор — улицей или расширением до города (widen ниже). - # Нужен логу зоны n < estimate_corridor_clamp_min_n: «мало сделок на улице» и + # Нужен логу зоны n < CORRIDOR_CLAMP_MIN_N: «мало сделок на улице» и # «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит # (pydantic игнорирует лишние kwargs), это служебная метка для логов. scope = "street" @@ -4067,7 +4087,7 @@ def _price_from_inputs( sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"}) # ── #1795 шаг 1: soft-кламп headline к коридору ДКП-сделок ────────────── - slack = settings.estimate_corridor_clamp_slack + slack = CORRIDOR_CLAMP_SLACK if dkp_raw is not None and median_ppm2 > 0: old_ppm2 = median_ppm2 median_ppm2, median_price, range_low, range_high, clamped = _apply_corridor_clamp( @@ -4079,7 +4099,7 @@ def _price_from_inputs( corridor_count=dkp_raw["count"], anchor_tier=anchor_tier, slack=slack, - min_n=settings.estimate_corridor_clamp_min_n, + min_n=CORRIDOR_CLAMP_MIN_N, ) if clamped: logger.info( @@ -4102,9 +4122,9 @@ def _price_from_inputs( and dkp_raw is not None and dkp_raw.get("low_ppm2", 0) > 0 and median_ppm2 > 0 - and dkp_raw.get("count", 0) >= settings.estimate_corridor_clamp_min_n + and dkp_raw.get("count", 0) >= CORRIDOR_CLAMP_MIN_N ): - radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * settings.estimate_radius_floor_factor + radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * RADIUS_FLOOR_FACTOR if median_ppm2 < radius_floor_ppm2: floor_factor = radius_floor_ppm2 / median_ppm2 logger.info( @@ -4112,7 +4132,7 @@ def _price_from_inputs( " → lifting (factor=%.3f)", int(median_ppm2), dkp_raw["low_ppm2"], - settings.estimate_radius_floor_factor, + RADIUS_FLOOR_FACTOR, int(radius_floor_ppm2), floor_factor, ) @@ -4132,7 +4152,7 @@ def _price_from_inputs( if asking_to_sold_ratio is not None and median_price > 0: effective_ratio = asking_to_sold_ratio - if settings.estimate_expected_sold_le_asking and effective_ratio > 1.0: + if effective_ratio > 1.0: logger.info( "expected_sold ratio clamped %.3f->1.0 (rooms=%s)", effective_ratio, @@ -4175,7 +4195,7 @@ def _price_from_inputs( # which is an overpay risk for trade-in. Re-clamp the corrected point back to # the headline so the calibrated PI range below wraps the clamped point. # round(median_ppm2) keeps expected_sold_per_m2 an int (median_ppm2 is float). - if settings.estimate_expected_sold_le_asking and expected_sold_price: + if expected_sold_price: expected_sold_price = min(expected_sold_price, median_price) if expected_sold_per_m2: expected_sold_per_m2 = min(expected_sold_per_m2, round(median_ppm2)) @@ -5354,7 +5374,7 @@ async def estimate_quality( estimate_id = uuid4() # #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок - # меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и + # меньше порога доверия (тот же CORRIDOR_CLAMP_MIN_N гейтит и # soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе). # Зона n=3..9 на экране неотличима от работающего коридора, поэтому # попадание в неё пишется явной строкой: число оценок за сутки — @@ -5365,7 +5385,7 @@ async def estimate_quality( "corridor_advisory_zone #3452: id=%s n=%d min_n=%d scope=%s city=%s", estimate_id, dkp_corridor.count, - settings.estimate_corridor_clamp_min_n, + CORRIDOR_CLAMP_MIN_N, (dkp_raw or {}).get("scope", "unknown"), target_city or "unknown", ) @@ -7881,10 +7901,10 @@ def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: """Tukey IQR rule: исключаем точки вне [Q1 - k×IQR, Q3 + k×IQR]. - #1795 шаг 4: на малых выборках (n < estimate_outlier_small_n_threshold) - ужесточаем k с 1.5 до estimate_outlier_tukey_k_small — несколько элитных + #1795 шаг 4: на малых выборках (n < OUTLIER_SMALL_N_THRESHOLD) + ужесточаем k с 1.5 до OUTLIER_TUKEY_K_SMALL — несколько элитных листингов на тонкой выборке премиум-дома иначе остаются в IQR-границах и - тянут радиусную медиану вверх. threshold=0 → выключено (старое поведение). + тянут радиусную медиану вверх. """ if len(lots) < 5: return lots # на маленькой выборке нечего фильтровать @@ -7894,11 +7914,8 @@ def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: return lots tukey_k = 1.5 - if ( - settings.estimate_outlier_tukey_k_small < 1.5 - and len(prices) < settings.estimate_outlier_small_n_threshold - ): - tukey_k = settings.estimate_outlier_tukey_k_small + if len(prices) < OUTLIER_SMALL_N_THRESHOLD: + tukey_k = OUTLIER_TUKEY_K_SMALL q1 = _percentile(prices, 0.25) q3 = _percentile(prices, 0.75) diff --git a/tradein-mvp/backend/tests/test_1970_estimator_constants.py b/tradein-mvp/backend/tests/test_1970_estimator_constants.py index 742e512b..4be8989f 100644 --- a/tradein-mvp/backend/tests/test_1970_estimator_constants.py +++ b/tradein-mvp/backend/tests/test_1970_estimator_constants.py @@ -31,8 +31,20 @@ _PROD_VALUES: dict[str, tuple[str, float]] = { "QUARTER_INDEX_SMALL_N_THRESHOLD": ("estimate_quarter_index_small_n_threshold", 50), "QUARTER_INDEX_FACTOR_MIN": ("estimate_quarter_index_factor_min", 0.6), "QUARTER_INDEX_FACTOR_MAX": ("estimate_quarter_index_factor_max", 1.8), + # #2380, #1795 anti-inflation (CORRIDOR_CLAMP_MIN_N определён в app.core.config) + "CORRIDOR_CLAMP_MIN_N": ("estimate_corridor_clamp_min_n", 10), + "CORRIDOR_CLAMP_SLACK": ("estimate_corridor_clamp_slack", 0.40), + "RADIUS_FLOOR_FACTOR": ("estimate_radius_floor_factor", 0.8), + "OUTLIER_SMALL_N_THRESHOLD": ("estimate_outlier_small_n_threshold", 15), + "OUTLIER_TUKEY_K_SMALL": ("estimate_outlier_tukey_k_small", 1.0), } +# Булевы выключатели, схлопнутые к боевому True: их OFF-ветки из кода удалены. +_REMOVED_SWITCHES = ( + "estimate_dedup_analogs_enabled", # #2378 + "estimate_expected_sold_le_asking", # #2380 +) + @pytest.mark.parametrize("const", sorted(_PROD_VALUES)) def test_constant_keeps_prod_value(const: str) -> None: @@ -43,3 +55,8 @@ def test_constant_keeps_prod_value(const: str) -> None: def test_settings_field_is_gone(const: str) -> None: # Поле вернулось в Settings → снова два источника одного числа (ENV и код). assert _PROD_VALUES[const][0] not in Settings.model_fields + + +@pytest.mark.parametrize("field", _REMOVED_SWITCHES) +def test_switch_is_gone(field: str) -> None: + assert field not in Settings.model_fields diff --git a/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py b/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py index afcdabf7..08d61769 100644 --- a/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py +++ b/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py @@ -3,7 +3,7 @@ Два порога на одну выборку. Показ коридора открывается с трёх сделок (`DKP_CORRIDOR_CITY_WIDE_MIN_N`, ниже — city-wide widen), а обе ценовые страховки по коридору — soft-кламп headline сверху и radius-floor снизу — гейтятся -`estimate_corridor_clamp_min_n` (10). Между ними лежит зона, где коридор +`CORRIDOR_CLAMP_MIN_N` (10). Между ними лежит зона, где коридор существует, показывается и участвует в fallback-путях, а цену не держит; на экране это неотличимо от работающего коридора. PR #3445 (снятие предиката `d.rooms`) переносит в эту зону реальных клиентов: замерено 248 → 3 и 72 → 8 @@ -34,8 +34,8 @@ import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") -from app.core.config import settings -from app.services.estimator import DKP_CORRIDOR_CITY_WIDE_MIN_N +from app.core.config import CORRIDOR_CLAMP_MIN_N +from app.services.estimator import CORRIDOR_CLAMP_SLACK, DKP_CORRIDOR_CITY_WIDE_MIN_N # Медиана аналогов заведомо выше потолка коридора × (1 + slack) — кламп, если он # включён порогом, ОБЯЗАН сработать и прижать headline к cap. @@ -46,7 +46,7 @@ _MARKER = "corridor_advisory_zone" def _cap_ppm2() -> float: """Потолок клампа: corridor_high × (1 + slack) — см. _apply_corridor_clamp.""" - return _CORRIDOR_HIGH_PPM2 * (1.0 + settings.estimate_corridor_clamp_slack) + return _CORRIDOR_HIGH_PPM2 * (1.0 + CORRIDOR_CLAMP_SLACK) def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, Any]: @@ -157,7 +157,7 @@ def test_zone_exists_at_all() -> None: Если пороги когда-нибудь сведут в один, этот тест скажет об этом прямо, а не оставит два теста ниже молча проверять пустое множество. """ - assert settings.estimate_corridor_clamp_min_n > DKP_CORRIDOR_CITY_WIDE_MIN_N, ( + assert CORRIDOR_CLAMP_MIN_N > DKP_CORRIDOR_CITY_WIDE_MIN_N, ( "зона 3..9 схлопнулась — пороги показа и клампа сравнялись, " "advisory-only решение #3452 больше не описывает реальность" ) @@ -167,15 +167,14 @@ def test_corridor_in_zone_is_flagged_and_does_not_clamp( caplog: pytest.LogCaptureFixture, ) -> None: """n на единицу ниже порога: метка стоит, headline НЕ прижат, лог написан.""" - n = settings.estimate_corridor_clamp_min_n - 1 + n = CORRIDOR_CLAMP_MIN_N - 1 with caplog.at_level(logging.INFO, logger="app.services.estimator"): est = _run_estimate(_corridor(n)) assert est.dkp_corridor is not None, "коридор обязан остаться видимым — порог показа ниже" assert est.dkp_corridor.count == n assert est.dkp_corridor.advisory_only is True, ( - f"n={n} < порога {settings.estimate_corridor_clamp_min_n} — коридор справочный, " - "ответ обязан это называть" + f"n={n} < порога {CORRIDOR_CLAMP_MIN_N} — коридор справочный, ответ обязан это называть" ) # ФАКТ, а не только метка: кламп прижал бы headline к cap, но порог ему не дал. assert est.median_price_per_m2 > _cap_ppm2(), ( @@ -185,7 +184,7 @@ def test_corridor_in_zone_is_flagged_and_does_not_clamp( hits = [r for r in caplog.records if _MARKER in r.getMessage()] assert len(hits) == 1, f"ожидалась ровно одна строка {_MARKER}, получено {len(hits)}" msg = hits[0].getMessage() - assert f"n={n}" in msg and f"min_n={settings.estimate_corridor_clamp_min_n}" in msg, msg + assert f"n={n}" in msg and f"min_n={CORRIDOR_CLAMP_MIN_N}" in msg, msg assert "scope=street" in msg, msg @@ -193,13 +192,13 @@ def test_corridor_above_threshold_is_not_flagged_and_clamps( caplog: pytest.LogCaptureFixture, ) -> None: """n выше порога: метки нет, headline прижат к потолку коридора, лога нет.""" - n = settings.estimate_corridor_clamp_min_n + 2 + n = CORRIDOR_CLAMP_MIN_N + 2 with caplog.at_level(logging.INFO, logger="app.services.estimator"): est = _run_estimate(_corridor(n)) assert est.dkp_corridor is not None assert est.dkp_corridor.advisory_only is False, ( - f"n={n} >= порога {settings.estimate_corridor_clamp_min_n} — коридор в цене участвует, " + f"n={n} >= порога {CORRIDOR_CLAMP_MIN_N} — коридор в цене участвует, " "справочным его называть нельзя" ) assert est.median_price_per_m2 <= round(_cap_ppm2()), ( diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py index ec38f8a5..290f03b7 100644 --- a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py +++ b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py @@ -80,8 +80,6 @@ def _make_payload(rooms: int = 3): def _run_estimate( ratio_tuple: tuple[float | None, str | None], - *, - clamp_enabled: bool = True, ) -> Any: from app.services.estimator import estimate_quality @@ -90,7 +88,6 @@ def _run_estimate( async def _run() -> Any: with ( - patch("app.core.config.settings.estimate_expected_sold_le_asking", new=clamp_enabled), # #2002: these tests assert the clamp/ratio math exactly. Hold the # orthogonal hedonic year+area correction OFF (OFF ⇒ legacy expected_sold). patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False), @@ -131,7 +128,7 @@ def test_expected_sold_clamped_to_headline_when_ratio_above_1( ) -> None: ratio = 1.15 with caplog.at_level(logging.INFO, logger="app.services.estimator"): - est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True) + est = _run_estimate((ratio, "per_rooms")) assert est.median_price_rub > 0, "headline должен быть задан аналогами" assert est.expected_sold_price_rub is not None assert est.expected_sold_price_rub <= est.median_price_rub, ( @@ -167,7 +164,7 @@ def test_expected_sold_clamped_to_headline_when_ratio_above_1( def test_expected_sold_not_clamped_when_ratio_below_1() -> None: ratio = 0.8 - est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True) + est = _run_estimate((ratio, "per_rooms")) assert est.median_price_rub > 0 assert est.expected_sold_price_rub == round(est.median_price_rub * ratio) assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio) @@ -182,22 +179,10 @@ def test_expected_sold_not_clamped_when_ratio_below_1() -> None: def test_expected_sold_none_when_ratio_none() -> None: - est = _run_estimate((None, None), clamp_enabled=True) + est = _run_estimate((None, None)) assert est.expected_sold_price_rub is None assert est.expected_sold_per_m2 is None assert est.expected_sold_range_low_rub is None assert est.expected_sold_range_high_rub is None assert est.asking_to_sold_ratio is None assert est.median_price_rub > 0 - - -def test_expected_sold_exceeds_asking_when_clamp_flag_off() -> None: - ratio = 1.15 - est = _run_estimate((ratio, "per_rooms"), clamp_enabled=False) - assert est.median_price_rub > 0 - assert est.expected_sold_price_rub is not None - assert est.expected_sold_price_rub > est.median_price_rub, ( - f"Ожидалось expected_sold > asking при clamp=False, " - f"но {est.expected_sold_price_rub} <= {est.median_price_rub}" - ) - assert est.expected_sold_price_rub == round(est.median_price_rub * ratio) diff --git a/tradein-mvp/backend/tests/test_estimator_hedonic.py b/tradein-mvp/backend/tests/test_estimator_hedonic.py index d21d3302..1424d934 100644 --- a/tradein-mvp/backend/tests/test_estimator_hedonic.py +++ b/tradein-mvp/backend/tests/test_estimator_hedonic.py @@ -6,7 +6,7 @@ via ``_price_from_inputs`` (hermetic — no DB, no network). Verifies: * both clamp boundaries (≤ factor_min via huge area, ≥ factor_max via small+new); * the neutral year term when ``target_year`` is None (≡ year 2000); * the le_asking invariant — the corrected expected_sold never exceeds the asking - headline (median) when ``estimate_expected_sold_le_asking`` is on. + headline (median) — the expected_sold ≤ asking clamp has no switch (#2380). NOTE: importing app.services.estimator pulls app.core.config.Settings which requires DATABASE_URL. Set it BEFORE importing app modules. @@ -151,8 +151,9 @@ def test_factor_clamps_to_min_for_huge_area(monkeypatch: pytest.MonkeyPatch) -> def test_factor_clamps_to_max_for_small_new_lot(monkeypatch: pytest.MonkeyPatch) -> None: """Small area + new year → raw factor > factor_max → clamped to the ceiling. - le_asking is held OFF so the raw ceiling factor is observable on the point - (otherwise the re-clamp would cap it at the asking headline). + ratio 0.70 keeps 0.70 × factor_max (1.30) = 0.91 < 1.0, so the always-on + expected_sold ≤ asking re-clamp does not bind and the raw ceiling factor is + observable on the point (#2380: the clamp no longer has an OFF switch). #3248: коэффициенты задаются явно — тест про МЕХАНИЗМ потолка, а не про текущую подгонку (после перефита b0 = -0.0140 и потолка сам по себе не @@ -161,11 +162,10 @@ def test_factor_clamps_to_max_for_small_new_lot(monkeypatch: pytest.MonkeyPatch) monkeypatch.setattr(estimator.settings, "estimate_hedonic_b0", 0.6146) monkeypatch.setattr(estimator.settings, "estimate_hedonic_larea_coef", -0.1603) monkeypatch.setattr(estimator.settings, "estimate_hedonic_year_coef", 0.1220) - monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", False) monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False) - off = _price(area_m2=15.0, target_year=2025, ratio=0.85) + off = _price(area_m2=15.0, target_year=2025, ratio=0.70) monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) - on = _price(area_m2=15.0, target_year=2025, ratio=0.85) + on = _price(area_m2=15.0, target_year=2025, ratio=0.70) factor = _expected_factor(15.0, 2025) assert factor == estimator.settings.estimate_hedonic_factor_max @@ -187,9 +187,8 @@ def test_target_year_none_is_neutral(monkeypatch: pytest.MonkeyPatch) -> None: def test_le_asking_invariant_holds_under_hedonic(monkeypatch: pytest.MonkeyPatch) -> None: - """With le_asking on, the hedonic-corrected expected_sold never exceeds asking.""" + """The hedonic-corrected expected_sold never exceeds asking.""" monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) - monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", True) # small area + new year → factor 1.30; ratio 0.95 → 0.95×1.30 ≈ 1.235 > 1 → # uncorrected the point would exceed the asking headline; the re-clamp must bind. res = _price(area_m2=15.0, target_year=2025, ratio=0.95) @@ -202,17 +201,6 @@ def test_le_asking_invariant_holds_under_hedonic(monkeypatch: pytest.MonkeyPatch assert res.expected_sold_price == res.median_price -def test_le_asking_off_allows_hedonic_above_asking(monkeypatch: pytest.MonkeyPatch) -> None: - """Control: with le_asking OFF, the hedonic uplift may exceed asking (no clamp).""" - monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) - monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", False) - res = _price(area_m2=15.0, target_year=2025, ratio=0.95) - - assert res.expected_sold_price is not None - # 0.95 × 1.30 ≈ 1.235 → point is allowed above the asking headline. - assert res.expected_sold_price > res.median_price - - def test_ground_floor_applies_extra_discount(monkeypatch: pytest.MonkeyPatch) -> None: """floor==1 → дополнительная скидка exp(first_floor_coef) против floor=3. diff --git a/tradein-mvp/backend/tests/test_estimator_radius_floor.py b/tradein-mvp/backend/tests/test_estimator_radius_floor.py index aba07b00..52725cbd 100644 --- a/tradein-mvp/backend/tests/test_estimator_radius_floor.py +++ b/tradein-mvp/backend/tests/test_estimator_radius_floor.py @@ -73,10 +73,7 @@ def _make_payload(): def _run_estimate( analogs: list[dict[str, Any]], dkp_raw: dict[str, Any] | None, - *, - radius_floor_factor: float = 0.8, ) -> Any: - from app.core.config import settings from app.services.estimator import estimate_quality db = MagicMock() @@ -107,7 +104,6 @@ def _run_estimate( ), patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw), patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), - patch.object(settings, "estimate_radius_floor_factor", radius_floor_factor), ): return await estimate_quality(payload, db) @@ -132,7 +128,7 @@ def test_radius_median_below_dkp_floor_is_lifted() -> None: "high_ppm2": 220_000, "period_months": 12, } - est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) + est = _run_estimate(analogs, dkp_raw) floor_ppm2 = 150_000 * 0.8 # 120_000 assert est.median_price_per_m2 >= floor_ppm2, ( @@ -157,7 +153,7 @@ def test_radius_median_above_dkp_floor_unchanged() -> None: "high_ppm2": 220_000, "period_months": 12, } - est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) + est = _run_estimate(analogs, dkp_raw) # floor = 150k × 0.8 = 120k; median = 200k > floor → no-op, медиана не изменяется # Медиана должна остаться в диапазоне аналогов (190-210k), а не подняться к floor. @@ -179,7 +175,7 @@ def test_no_dkp_raw_no_floor() -> None: _make_listing(price_per_m2=80_000.0), _make_listing(price_per_m2=85_000.0), ] - est = _run_estimate(analogs, dkp_raw=None, radius_floor_factor=0.8) + est = _run_estimate(analogs, dkp_raw=None) # median ~80k, без dkp_raw floor не поднимает assert est.median_price_per_m2 < 100_000, ( diff --git a/tradein-mvp/frontend/src/lib/rosreestr.ts b/tradein-mvp/frontend/src/lib/rosreestr.ts index 3e92bb8a..59df72ef 100644 --- a/tradein-mvp/frontend/src/lib/rosreestr.ts +++ b/tradein-mvp/frontend/src/lib/rosreestr.ts @@ -41,7 +41,7 @@ export function dealsAsOfLabel( /** * #3452: подпись под коридором ДКП, собранным из малого числа сделок. * - * Порог (`estimate_corridor_clamp_min_n`) считает бэкенд и отдаёт готовый + * Порог (`CORRIDOR_CLAMP_MIN_N`) считает бэкенд и отдаёт готовый * advisory_only: ниже него у коридора выключены ОБЕ его ценовые страховки — * кламп headline сверху и radius-floor снизу. Молчание тут выдавало бы * выключенную страховку за работающую: на экране такой коридор ничем не diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index be0846ec..e324faae 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -168,7 +168,7 @@ export interface DkpCorridor { // посчитанные до деплоя бэкенда, поля не содержат → подпись просто не рисуется. latest_deal_date?: string | null; // #3452: true = сделок меньше порога доверия (бэкенд считает порог сам, - // estimate_corridor_clamp_min_n) → коридор показан, но цену по нему НЕ + // CORRIDOR_CLAMP_MIN_N) → коридор показан, но цену по нему НЕ // поправляли: ни кламп сверху, ни floor снизу. Заголовок «advisory» у всего // коридора не про это — при count >= порога он в цену как раз идёт. // optional: оценки, посчитанные до деплоя бэкенда, поля не содержат. From 26a7f1c1631ebdaebf48c683cd82dd18671a44ab Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:38:14 +0500 Subject: [PATCH 10/47] =?UTF-8?q?fix(metrics):=20=D1=82=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D0=BE=D0=B3=D0=B0,=20=D0=BA=D0=BE=D0=B3=D0=B4=D0=B0=20=D0=B1?= =?UTF-8?q?=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4=20=D0=9C=D0=B5=D1=80=D1=8B=20?= =?UTF-8?q?=D0=BB=D1=91=D0=B3,=20=D0=B0=20=D0=BB=D1=8D=D0=BD=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=20=D0=B5=D1=89=D1=91=20=D0=BE=D1=82=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20(#2214)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мёртвый или зависший tradein-backend не давал ни одного сигнала вне Poincare. GlitchTip-монитор 6 смотрит на лэндинг meraocenka.ru, а его отдаёт фронт из пререндер-кэша (x-nextjs-cache: HIT) — при мёртвом бэкенде там 200. Монитор 2 (gendsgn.ru/health) проверяет бэкенд Птицы. AppHighErrorRate и AppHighLatencyP95 считают метрики самого бэкенда и при его смерти молчат. Новое правило MeraBackendDown: up{job="app",app="mera"} == 0 или серия пропала, 5 минут, severity critical + host apps — маршрут telegram-clients, тема 158. Считается и доставляется на Beget. Порог 5 минут откалиброван по истории up за 26.08–17.09: 140 провалов, самый длинный — 4 нулевые точки (~2 минуты, окна деплоя). Юнит-тесты promtool: мёртвый бэкенд — тревога; окно деплоя и упавшая Птица — тишина; цель пропала из скрейпа — тревога. Co-Authored-By: Claude Opus 5 --- ops/metrics/prometheus/rules/infra.yml | 34 +++++++++++++ ops/metrics/prometheus/tests/infra_test.yml | 55 +++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/ops/metrics/prometheus/rules/infra.yml b/ops/metrics/prometheus/rules/infra.yml index 6f050c92..a78751d0 100644 --- a/ops/metrics/prometheus/rules/infra.yml +++ b/ops/metrics/prometheus/rules/infra.yml @@ -215,6 +215,40 @@ groups: - name: app interval: 60s rules: + # Бэкенд «Меры» лежит или завис (#2214). До этого правила такой отказ не + # давал ни одного сигнала вне Poincare. GlitchTip-монитор 6 смотрит на + # лэндинг meraocenka.ru, а его отдаёт tradein-frontend из пререндер-кэша + # (x-nextjs-cache: HIT) — при мёртвом бэкенде там по-прежнему 200. + # Монитор 2 (gendsgn.ru/health) — это бэкенд «Птицы». Два правила ниже + # считают метрики, которые отдаёт сам бэкенд: мёртвый не отдаёт, и они + # молчат по построению. + # + # Скрейп идёт с Poincare (Alloy), но решение и доставка — здесь, на Beget. + # Зависший event loop даёт таймаут скрейпа (20s), удалённый контейнер — + # ошибку DNS; в обоих случаях up=0. Смерть всего хоста ловят + # HostAgentDown/RemoteWriteStalled; `absent()` — на случай, когда цель + # тихо пропала из alloy-apps.alloy. + # + # for: 5m — по истории up{app="mera"} 26.08–17.09 (61 857 точек): 140 + # провалов, все короткие (окна деплоя), самый длинный — 4 нулевые точки + # подряд, ~2 минуты до восстановления. 5 минут — запас в 2.5 раза. + # + # Чего правило НЕ видит: публичный путь до бэкенда (DNS, TLS, Caddy). + # Для лэндинга его проверяет монитор 6; монитора на публичную ручку + # бэкенда (meraocenka.ru/trade-in/api/public/mera/stats) пока нет — он + # заводится в UI GlitchTip, а не кодом. + - alert: MeraBackendDown + expr: | + up{job="app", app="mera"} == 0 + or absent(up{job="app", app="mera"}) + for: 5m + labels: + severity: critical + host: apps + annotations: + summary: "Бэкенд «Меры» не отвечает" + description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни." + # Гейт по RPS внутри знаменателя — тот же приём, что у # PostgresLowHotUpdateRatio: делит только там, где трафик уже есть, # иначе один упавший запрос при нулевой нагрузке даёт 100% и будит diff --git a/ops/metrics/prometheus/tests/infra_test.yml b/ops/metrics/prometheus/tests/infra_test.yml index 810f18d8..e7d9dfbf 100644 --- a/ops/metrics/prometheus/tests/infra_test.yml +++ b/ops/metrics/prometheus/tests/infra_test.yml @@ -115,3 +115,58 @@ tests: exp_annotations: summary: "Ни одного живого воркера Celery" description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали." + + # #2214: tradein-backend умер, хост и агент живы — агент шлёт up=0. + # До MeraBackendDown этот случай не давал ни одного сигнала вне Poincare. + - interval: 30s + input_series: + - series: 'up{job="app",app="mera",instance="tradein-backend:8000",host="apps"}' + values: '1x9 0x40' + alert_rule_test: + - eval_time: 15m + alertname: MeraBackendDown + exp_alerts: + - exp_labels: + severity: critical + host: apps + job: app + app: mera + instance: tradein-backend:8000 + exp_annotations: + summary: "Бэкенд «Меры» не отвечает" + description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни." + + # Окно деплоя: самый длинный провал за 26.08–17.09 — 4 нулевые точки подряд + # (~2 минуты). Тревоги быть не должно. Заодно упавший бэкенд «Птицы» не должен + # поднимать тревогу про «Меру». + - interval: 30s + input_series: + - series: 'up{job="app",app="mera",instance="tradein-backend:8000",host="apps"}' + values: '1x19 0 0 0 0 1x40' + - series: 'up{job="app",app="sitefinder",instance="gendesign-backend-1:8000",host="apps"}' + values: '0x64' + alert_rule_test: + - eval_time: 11m + alertname: MeraBackendDown + exp_alerts: [] + - eval_time: 30m + alertname: MeraBackendDown + exp_alerts: [] + + # Цель пропала из скрейпа (агент жив, «Птица» снимается), серии «Меры» нет. + - interval: 30s + input_series: + - series: 'up{job="app",app="sitefinder",instance="gendesign-backend-1:8000",host="apps"}' + values: '1x40' + alert_rule_test: + - eval_time: 10m + alertname: MeraBackendDown + exp_alerts: + - exp_labels: + severity: critical + host: apps + job: app + app: mera + exp_annotations: + summary: "Бэкенд «Меры» не отвечает" + description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни." From 1fe383f93583f57a5e17d9c77692d9d157aa355a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:38:35 +0500 Subject: [PATCH 11/47] =?UTF-8?q?fix(tradein):=20=D0=BA=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B4=D0=BE=D1=80=20=D0=94=D0=9A=D0=9F=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D1=8F=D0=BA=D0=BE=D1=80=D0=B5=20=D1=82=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=B6=D0=B5=20=D0=B4=D0=BE=D0=BC=D0=B0=20=D0=BF=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D1=87=D0=B5=D0=BD=20=D0=BA=D0=B0=D0=BA=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B2=D0=BE=D1=88=D0=B5=D0=B4=D1=88=D0=B8=D0=B9=20=D0=B2=20?= =?UTF-8?q?=D1=86=D0=B5=D0=BD=D1=83=20(#3466)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кламп headline к коридору ДКП выключается не только малым числом сделок (advisory_only, #3452), но и якорем Tier A: _apply_corridor_clamp его exempt, radius-floor требует anchor_tier is None. Ревьюер #3462 воспроизвёл n=20, advisory_only=False, headline 202 100 против потолка 140 000 — подписи нет. - Признак — analog_tier == "same_building" (уже структурный в POST). corridorAdvisoryNote принимает тир и при same_building говорит «справочно: цена посчитана по аналогам в этом же доме — коридор её не ограничивает»; оба вызова (v1 HeroSummary, v2 mappers) передают тир. - GET-rehydrate терял analog_tier (колонки нет) — якорный тир теперь восстанавливается из подписи якорного блока в confidence_explanation через общую константу (analog_tier_from_explanation), как радиус в #2632. Разбор по всей фразе: радиусный тир S пишет «(аналоги из того же дома)», на проде таких строк 4. - Тесты floor не доходили до floor: три лота уводили в #oblast-E, headline брался из медианы коридора. На main с полностью выключенным floor файл зелёный. Лотов шесть, ожидания точные, добавлен кейс n = min_n − 1. - Полоса маркера corridor_advisory_zone — n = 1..9, не 3..9: уличный коридор отдаётся с одной сделки. Формулировки поправлены, тест на n=2. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/api/v1/trade_in.py | 5 + tradein-mvp/backend/app/schemas/trade_in.py | 12 +- tradein-mvp/backend/app/services/estimator.py | 43 ++++- .../tests/test_3452_corridor_advisory_zone.py | 7 +- .../tests/test_3466_corridor_tier_a.py | 176 ++++++++++++++++++ .../backend/tests/test_estimate_idor.py | 25 +++ .../backend/tests/test_estimate_revival.py | 2 + .../tests/test_estimator_radius_floor.py | 66 +++++-- .../test_estimator_search_radius_2632.py | 3 + .../src/components/trade-in/HeroSummary.tsx | 6 +- .../v2/__tests__/corridorAdvisoryNote.test.ts | 113 +++++++++++ .../src/components/trade-in/v2/mappers.ts | 2 +- tradein-mvp/frontend/src/lib/rosreestr.ts | 14 +- tradein-mvp/frontend/src/types/trade-in.ts | 3 +- 14 files changed, 443 insertions(+), 34 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3466_corridor_tier_a.py create mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/__tests__/corridorAdvisoryNote.test.ts diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index f16525eb..a6fe69f3 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -718,6 +718,7 @@ def load_estimate( _qc_geo_to_precision, _resolve_target_city, _source_counts, + analog_tier_from_explanation, rehydrate_search_radius_m, ) @@ -896,6 +897,10 @@ def load_estimate( # осознанно НЕ заполняем — payload.radius_m не персистится, и подставить # сюда дефолт значило бы выдать догадку за то, что просил пользователь. search_radius_m=search_radius_m, + # #3466: якорный тир (same_building/micro_radius) восстанавливается из подписи + # якорного блока в confidence_explanation; радиусный — null, колонки нет. + # Без него ссылка ?id= теряла признак «коридор в цену не вошёл» (Tier A). + analog_tier=analog_tier_from_explanation(row.confidence_explanation), ) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 00d6c074..85663a0c 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -166,6 +166,10 @@ class DkpCorridor(BaseModel): #3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only. + #3466: исключение сверх порога — якорь того же дома (AggregatedEstimate.analog_tier + == "same_building"): кламп его не трогает, radius-floor тоже, и коридор в цену не + входит при любом count. Признак живёт в analog_tier, а не здесь: тир не выводится + из коридора, а поле обязано быть верным и на GET-rehydrate. """ count: int # число ДКП-сделок в выборке @@ -195,7 +199,10 @@ class DkpCorridor(BaseModel): Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ путей коридора: soft-кламп headline сверху и radius-floor снизу (estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит - цену — зона n=3..9 на экране была неотличима от работающей. + цену — зона n=1..9 на экране была неотличима от работающей. + + False тоже НЕ значит «коридор в цену вошёл»: при analog_tier="same_building" + обе страховки выключены якорем, а не порогом (#3466). ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C (#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и @@ -344,6 +351,9 @@ class AggregatedEstimate(BaseModel): # "city" — radius Tier W: широкий fallback # null — нет данных / оценка не построена # НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него). + # #3466: на GET-rehydrate колонки нет — якорные тиры (same_building/micro_radius) + # восстанавливаются из подписи якорного блока в confidence_explanation + # (estimator.analog_tier_from_explanation), радиусные остаются null. analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None # search_radius_m — фактический радиус (метры), по которому реально отбирались # listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m: diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index cd477ac6..d3b0540f 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -283,6 +283,32 @@ def radius_from_relaxations(relaxations: list[str] | None) -> int | None: return None +# #3466: тир якоря → подпись в confidence_explanation и enum ответа (analog_tier). +# Колонки под analog_tier нет, поэтому подпись, которую пишет якорный блок +# _price_from_inputs, — единственное место, где тир попадает в persisted-состояние. +# Отсюда же его читает GET-rehydrate (analog_tier_from_explanation): тот же приём, +# что у радиуса (#2632). Нужен витрине: при same_building коридор ДКП в цену не +# входит вовсе (кламп exempt, radius-floor только без якоря), и без тира ссылка +# ?id= показывала бы такой коридор как работающий. +# ponytail: разбор прозы, а не колонка; колонка analog_tier — когда понадобятся +# радиусные тиры на GET или подпись начнут переписывать мимо этой константы. +ANCHOR_TIER_LABELS: dict[str, str] = {"A": "того же дома", "C": "ближайшего окружения (≤500 м)"} +ANCHOR_API_TIERS: dict[str, str] = {"A": "same_building", "C": "micro_radius"} + + +def analog_tier_from_explanation(explanation: str | None) -> str | None: + """Якорный analog_tier сохранённой оценки по подписи якорного блока, иначе None. + + Ищется вся фраза «Оценка построена по N аналогам из <тир>», а не хвост «из того + же дома»: радиусный тир S пишет рядом «(аналоги из того же дома)», и это другой + путь цены — коридор там клампит. None = радиусный путь либо старая формулировка. + """ + for tier, label in ANCHOR_TIER_LABELS.items(): + if re.search(rf"Оценка построена по \d+ аналогам из {re.escape(label)}", explanation or ""): + return ANCHOR_API_TIERS[tier] + return None + + def rehydrate_search_radius_m( relaxations: list[str] | None, analog_distances_m: list[int | None] ) -> int | None: @@ -3699,10 +3725,10 @@ def _price_from_inputs( range_low = new_range_low range_high = new_range_high confidence = anchor["confidence"] - tier_label = "того же дома" if anchor_tier == "A" else "ближайшего окружения (≤500 м)" - # #695: explanation описывает ИМЕННО якорные комплы. + # #695: explanation описывает ИМЕННО якорные комплы. #3466: подпись читает + # GET-rehydrate (analog_tier_from_explanation) — менять только через константу. explanation = ( - f"Оценка построена по {anchor['n']} аналогам из {tier_label}" + f"Оценка построена по {anchor['n']} аналогам из {ANCHOR_TIER_LABELS[anchor_tier]}" f"{' (топ-уровень в доме)' if anchor['used_uplift'] else ''}." ) + repair_note # #695 (QA fixup): n_analogs по anchor-популяции. @@ -5313,8 +5339,9 @@ async def estimate_quality( # #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок # меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и # soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе). - # Зона n=3..9 на экране неотличима от работающего коридора, поэтому - # попадание в неё пишется явной строкой: число оценок за сутки — + # Зона n=1..9 на экране неотличима от работающего коридора, поэтому + # попадание в неё пишется явной строкой (#3466: n=1..2 достижимы — уличный + # коридор отдаётся с одной сделки, widen только вне ЕКБ): число оценок за сутки — # `docker logs tradein-backend --since 24h 2>&1 | grep -c corridor_advisory_zone`. # Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит. if dkp_corridor is not None and dkp_corridor.advisory_only: @@ -5573,10 +5600,8 @@ async def estimate_quality( # radius-путь: analog_tier "W" → "city", остальные → "district". # None только если нет аналогов (median=0, insufficient_data=True). if median_price > 0: - if anchor_tier == "A": - api_analog_tier: str | None = "same_building" - elif anchor_tier == "C": - api_analog_tier = "micro_radius" + if anchor_tier in ANCHOR_API_TIERS: + api_analog_tier: str | None = ANCHOR_API_TIERS[anchor_tier] elif analog_tier == "W": api_analog_tier = "city" else: diff --git a/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py b/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py index afcdabf7..3d0222da 100644 --- a/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py +++ b/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py @@ -1,7 +1,8 @@ -"""#3452: зона n = 3..9 — коридор ДКП виден, но цену по нему не поправляют. +"""#3452: зона n = 1..9 — коридор ДКП виден, но цену по нему не поправляют. -Два порога на одну выборку. Показ коридора открывается с трёх сделок -(`DKP_CORRIDOR_CITY_WIDE_MIN_N`, ниже — city-wide widen), а обе ценовые страховки +Два порога на одну выборку. Показ коридора открывается с одной сделки (ниже +`DKP_CORRIDOR_CITY_WIDE_MIN_N` вне Екатеринбурга пробуется city-wide widen, но +уличная выборка из 1-2 сделок отдаётся и без него, #3466), а обе ценовые страховки по коридору — soft-кламп headline сверху и radius-floor снизу — гейтятся `estimate_corridor_clamp_min_n` (10). Между ними лежит зона, где коридор существует, показывается и участвует в fallback-путях, а цену не держит; на diff --git a/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py b/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py new file mode 100644 index 00000000..4350f621 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py @@ -0,0 +1,176 @@ +"""#3466: при якоре Tier A коридор ДКП в цену не входит — ответ обязан это нести. + +`DkpCorridor.advisory_only` (#3452) считается из размера выборки, а кламп headline +выключается ещё и якорем того же дома (`_apply_corridor_clamp`: Tier A exempt; +radius-floor требует `anchor_tier is None`). Ревьюер PR #3462 воспроизвёл: n=20, +advisory_only=False, headline 202 100 ₽/м² против потолка 140 000 — поле молчит, а +коридор цену не тронул. + +Признак — `analog_tier == "same_building"`, уже структурный в POST-ответе. На +GET-rehydrate колонки под него нет, тир восстанавливается из подписи якорного +блока (`analog_tier_from_explanation`). Тесты ПО ЗНАЧЕНИЮ: + +* цена Tier A не зависит от коридора вовсе — снятие exempt в клампе роняет тест; +* тот же коридор на радиусном пути цену прижимает — иначе первый тест пуст; +* подпись, которую пишет якорный блок, читается обратно в тот же тир, а соседняя + подпись радиусного тира S («аналоги из того же дома») — нет; +* полоса счёта маркера `corridor_advisory_zone` — n = 1..9, а не 3..9: уличный + коридор отдаётся с одной сделки (widen только вне Екатеринбурга). +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.core.config import settings +from app.services import estimator +from app.services.geocoder import GeocodeResult + +# Коридор выше порога доверия, потолок заведомо ниже цены якоря: кламп, если ему +# позволить, ОБЯЗАН прижать headline к cap = 100 000 × (1 + slack). +_CORRIDOR: dict[str, Any] = { + "count": 20, + "low_ppm2": 80_000, + "median_ppm2": 90_000, + "high_ppm2": 100_000, + "period_months": 12, + "latest_deal_date": None, + "scope": "street", +} +_ANCHOR_PPM2 = (190_000, 195_000, 200_000, 205_000, 210_000) + + +def _cap() -> float: + return _CORRIDOR["high_ppm2"] * (1.0 + settings.estimate_corridor_clamp_slack) + + +def _call( + *, + listings_ppm2: tuple[float, ...], + anchor_tier_fetched: str | None, + dkp_raw: dict[str, Any] | None, + analog_tier: str = "W", +) -> estimator.PricingResult: + return estimator._price_from_inputs( + listings=[ + {"price_per_m2": p, "address": f"ул. Тестовая, {i}", "source": "avito"} + for i, p in enumerate(listings_ppm2) + ], + area_m2=50.0, + rooms=2, + repair_state=None, + floor=5, + total_floors=10, + target_year=None, + analog_tier=analog_tier, + fallback_used=False, + area_widened=False, + anchor_comps=( + [{"price_per_m2": p, "area_m2": 50.0, "rooms": 2} for p in _ANCHOR_PPM2] + if anchor_tier_fetched + else [] + ), + anchor_tier_fetched=anchor_tier_fetched, + dkp_raw=dkp_raw, + 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=GeocodeResult( + lat=56.838, lon=60.597, full_address="ул. Тестовая, 1", provider="nominatim" + ), + dadata_qc_geo=None, + ) + + +def _price(pr: estimator.PricingResult) -> tuple[float, int, int, int]: + return (pr.median_ppm2, pr.median_price, pr.range_low, pr.range_high) + + +def test_tier_a_price_does_not_depend_on_corridor() -> None: + """Ядро признака: при same_building коридор не двигает НИ ОДНО ценовое число.""" + listings = (100_000.0,) * 6 + with_corridor = _call(listings_ppm2=listings, anchor_tier_fetched="A", dkp_raw=_CORRIDOR) + without = _call(listings_ppm2=listings, anchor_tier_fetched="A", dkp_raw=None) + + assert with_corridor.anchor_tier == "A", "якорь не сработал — тест проверял бы радиусный путь" + # Предпосылка — на цене БЕЗ коридора: она выше потолка, т.е. клампу было что резать. + assert without.median_ppm2 > _cap(), ( + f"цена якоря {without.median_ppm2:.0f} не выше cap {_cap():.0f} — " + "кламп не понадобился бы и без exempt, случай #3466 не воспроизведён" + ) + assert _price(with_corridor) == _price(without), ( + "коридор из 20 сделок изменил цену Tier A — значит он в цену вошёл, и признак " + f"same_building на витрине врёт: {_price(with_corridor)} против {_price(without)}" + ) + + +def test_same_corridor_clamps_radius_path() -> None: + """Контроль: тот же коридор без якоря прижимает цену — первый тест не пустой.""" + pr = _call( + listings_ppm2=(195_000.0, 200_000.0, 200_000.0, 205_000.0, 210_000.0, 190_000.0), + anchor_tier_fetched=None, + dkp_raw=_CORRIDOR, + ) + + assert pr.anchor_tier is None + assert round(pr.median_ppm2) == round(_cap()), ( + f"без якоря коридор n=20 обязан прижать headline к cap={_cap():.0f}, " + f"получено {pr.median_ppm2:.0f}" + ) + + +def test_anchor_sentence_reads_back_to_tier() -> None: + """Подпись якорного блока → тот же enum, что POST кладёт в analog_tier.""" + tier_a = _call(listings_ppm2=(100_000.0,) * 6, anchor_tier_fetched="A", dkp_raw=None) + tier_c = _call(listings_ppm2=(200_000.0,) * 6, anchor_tier_fetched="C", dkp_raw=None) + assert (tier_a.anchor_tier, tier_c.anchor_tier) == ("A", "C") + + assert estimator.analog_tier_from_explanation(tier_a.explanation) == "same_building" + assert estimator.analog_tier_from_explanation(tier_c.explanation) == "micro_radius" + # #conf-cap ставит свою фразу ПЕРЕД подписью — тир обязан читаться и тогда. + capped = "Оценка построена с расширенными параметрами подбора — точность снижена. " + assert estimator.analog_tier_from_explanation(capped + tier_a.explanation) == "same_building" + + +def test_radius_same_house_note_is_not_anchor() -> None: + """Радиусный тир S пишет «(аналоги из того же дома)», но коридор там клампит.""" + pr = _call( + listings_ppm2=(100_000.0,) * 6, anchor_tier_fetched=None, dkp_raw=None, analog_tier="S" + ) + + assert pr.anchor_tier is None + assert "из того же дома" in pr.explanation, pr.explanation + assert estimator.analog_tier_from_explanation(pr.explanation) is None + assert estimator.analog_tier_from_explanation(None) is None + + +def test_street_corridor_is_returned_below_three_deals() -> None: + """Полоса маркера #3452 — n = 1..9: улица Екатеринбурга не расширяется до города.""" + rows = [ + {"price_per_m2": 150_000, "deal_date": None}, + {"price_per_m2": 160_000, "deal_date": None}, + ] + db = MagicMock() + db.execute.return_value.mappings.return_value.all.return_value = rows + with patch.object(estimator, "_load_sber_index_series", return_value={}): + raw = estimator._fetch_dkp_corridor( + db, + address="Екатеринбург, ул. Учителей, 18", + rooms=2, + area=50.0, + city="екатеринбург", + ) + + assert raw is not None and raw["count"] == 2, raw + assert raw["scope"] == "street" + assert raw["count"] < estimator.DKP_CORRIDOR_CITY_WIDE_MIN_N diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index d2dff0d0..5ed999e1 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -28,6 +28,8 @@ import pytest # noqa: E402 from fastapi import FastAPI # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 +from app.services.estimator import analog_tier_from_explanation # noqa: E402 + _ESTIMATE_ID = "11111111-1111-1111-1111-111111111111" @@ -170,6 +172,9 @@ def _stub_precision_and_pdf(): # фикстур нет ни подписи каскада, ни расстояний → None и есть настоящее # поведение (см. tests/test_estimator_search_radius_2632.py). rehydrate_search_radius_m=lambda *a, **k: None, + # #3466: якорный тир из подписи в confidence_explanation. У фикстуры там + # «ok» → None и есть настоящее поведение; положительный случай — ниже. + analog_tier_from_explanation=lambda _e: None, ) real_estimator = sys.modules.get("app.services.estimator") sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment] @@ -273,6 +278,26 @@ def test_get_estimate_rehydrates_post_only_fields(trade_in_app: FastAPI) -> None assert body["last_scraped_at"] is not None +def test_get_estimate_restores_anchor_tier(trade_in_app: FastAPI) -> None: + """#3466: ссылка ?id= на оценку Tier A отдаёт analog_tier=same_building. + + Без него витрина не отличает коридор, который цену не трогал (якорь того же + дома), от работающего. Разбор — настоящий, из estimator, по подписи, которую + пишет якорный блок. + """ + est = sys.modules["app.services.estimator"] + est.analog_tier_from_explanation = analog_tier_from_explanation # type: ignore[attr-defined] + row = _make_estimate_row(created_by="kopylov") + row.confidence_explanation = "Оценка построена по 20 аналогам из того же дома." + client = _client_with(trade_in_app, _make_db_mock(row), role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json()["analog_tier"] == "same_building" + + def test_get_estimate_requires_authenticated_user(trade_in_app: FastAPI) -> None: """No X-Authenticated-User header → 401.""" db_mock = _make_db_mock(_make_estimate_row(created_by="kopylov")) diff --git a/tradein-mvp/backend/tests/test_estimate_revival.py b/tradein-mvp/backend/tests/test_estimate_revival.py index d41f2e4d..3957f26d 100644 --- a/tradein-mvp/backend/tests/test_estimate_revival.py +++ b/tradein-mvp/backend/tests/test_estimate_revival.py @@ -277,6 +277,8 @@ def _estimator_stub(): # этой фикстуры analogs пусты → None и есть настоящее поведение # (см. tests/test_estimator_search_radius_2632.py). rehydrate_search_radius_m=lambda *a, **k: None, + # #3466: подписи якоря у строк фикстуры нет → None (см. test_estimate_idor.py). + analog_tier_from_explanation=lambda _e: None, estimate_quality=_default_estimate_quality, ) sys.modules["app.services.estimator"] = stub # type: ignore[assignment] diff --git a/tradein-mvp/backend/tests/test_estimator_radius_floor.py b/tradein-mvp/backend/tests/test_estimator_radius_floor.py index aba07b00..aeaaf95d 100644 --- a/tradein-mvp/backend/tests/test_estimator_radius_floor.py +++ b/tradein-mvp/backend/tests/test_estimator_radius_floor.py @@ -8,6 +8,7 @@ - radius median выше dkp_low × factor → no-op (медиана не изменена) - dkp_raw is None → no-op (нет базы для floor) - anchor-путь (anchor_tier != None) → не затронут floor'ом +- коридор ниже estimate_corridor_clamp_min_n → floor не применяется (#3466) """ from __future__ import annotations @@ -114,17 +115,25 @@ def _run_estimate( return anyio.run(_run) +def _six(center: float) -> list[dict[str, Any]]: + """Шесть лотов вокруг center — выше HEADLINE_LISTINGS_MIN_N (5). + + Три лота (как было до #3466) уводили оценку в #oblast-E: headline уступался + медиане коридора (180k), и floor не исполнялся вовсе — тесты 1 и 2 зеленели на + deals-fallback, а снятие гейта min_n у floor ничего не роняло. + """ + return [ + _make_listing(price_per_m2=center + d) + for d in (-5_000.0, -2_500.0, 0.0, 0.0, 2_500.0, 5_000.0) + ] + + # ── тест 1: radius median ниже floor → поднята ─────────────────────────────── def test_radius_median_below_dkp_floor_is_lifted() -> None: """Radius median = 80k, dkp_low = 150k, factor 0.8 → floor = 120k → median поднята.""" - # analogs с median ~80k - analogs = [ - _make_listing(price_per_m2=75_000.0), - _make_listing(price_per_m2=80_000.0), - _make_listing(price_per_m2=85_000.0), - ] + analogs = _six(80_000.0) dkp_raw = { "count": 15, "low_ppm2": 150_000, @@ -135,8 +144,9 @@ def test_radius_median_below_dkp_floor_is_lifted() -> None: est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) floor_ppm2 = 150_000 * 0.8 # 120_000 - assert est.median_price_per_m2 >= floor_ppm2, ( - f"median_ppm2={est.median_price_per_m2} должна быть >= floor={floor_ppm2}" + # Ровно floor, а не «не ниже»: медиана коридора (180k) тоже «не ниже». + assert est.median_price_per_m2 == floor_ppm2, ( + f"median_ppm2={est.median_price_per_m2} должна быть поднята ровно до floor={floor_ppm2}" ) @@ -145,11 +155,7 @@ def test_radius_median_below_dkp_floor_is_lifted() -> None: def test_radius_median_above_dkp_floor_unchanged() -> None: """Radius median = 200k > dkp_low × factor = 150k × 0.8 = 120k → no-op.""" - analogs = [ - _make_listing(price_per_m2=190_000.0), - _make_listing(price_per_m2=200_000.0), - _make_listing(price_per_m2=210_000.0), - ] + analogs = _six(200_000.0) dkp_raw = { "count": 15, "low_ppm2": 150_000, @@ -163,9 +169,9 @@ def test_radius_median_above_dkp_floor_unchanged() -> None: # Медиана должна остаться в диапазоне аналогов (190-210k), а не подняться к floor. floor_ppm2 = 150_000 * 0.8 # 120_000 assert est.median_price_per_m2 > floor_ppm2, "median должна быть выше floor (no-op)" - # Медиана соответствует аналогам (~200k), а не floor - assert 180_000 <= est.median_price_per_m2 <= 220_000, ( - f"median_ppm2={est.median_price_per_m2} должна остаться в диапазоне аналогов (no-op)" + # Медиана — ровно медиана аналогов, а не floor и не медиана коридора (180k). + assert est.median_price_per_m2 == 200_000, ( + f"median_ppm2={est.median_price_per_m2} должна остаться медианой аналогов (no-op)" ) @@ -185,3 +191,31 @@ def test_no_dkp_raw_no_floor() -> None: assert est.median_price_per_m2 < 100_000, ( f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти" ) + + +# ── тест 4: коридор ниже порога доверия → floor не поднимает (#3466) ───────── + + +def test_floor_not_applied_below_clamp_min_n() -> None: + """n = estimate_corridor_clamp_min_n − 1: тот же floor, что в тесте 1, но выключен. + + `DkpCorridor.advisory_only` (#3452) обосновывает себя общим порогом у клампа + headline И у radius-floor. Половина про кламп стережётся test_3452_*, эта — + здесь: без гейта по count медиана 80k поднялась бы до 120k. + """ + from app.core.config import settings + + analogs = _six(80_000.0) + dkp_raw = { + "count": settings.estimate_corridor_clamp_min_n - 1, + "low_ppm2": 150_000, + "median_ppm2": 180_000, + "high_ppm2": 220_000, + "period_months": 12, + } + est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) + + assert est.median_price_per_m2 < 100_000, ( + f"median_ppm2={est.median_price_per_m2}: коридор из " + f"{dkp_raw['count']} сделок ниже порога не должен поднимать цену до floor=120000" + ) diff --git a/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py b/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py index 5b824132..1e65e2b6 100644 --- a/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py +++ b/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py @@ -34,6 +34,7 @@ from fastapi.testclient import TestClient # noqa: E402 from app.services.estimator import ( # noqa: E402 DEFAULT_RADIUS_M, _compute_confidence, + analog_tier_from_explanation, radius_from_relaxations, radius_relaxation_label, rehydrate_search_radius_m, @@ -193,6 +194,8 @@ def _light_estimator_stub(): _source_counts=lambda *a, **k: {}, _canonical_sources=lambda *a, **k: ["cian"], rehydrate_search_radius_m=rehydrate_search_radius_m, + # #3466: радиусная строка («Найдено 42 аналогов») → None, разбор настоящий. + analog_tier_from_explanation=analog_tier_from_explanation, ) sys.modules["app.services.estimator"] = stub # type: ignore[assignment] yield stub diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index db30df9a..d03f2419 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -207,7 +207,11 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals // с числами обязан стоять размер выборки, иначе коридор читается как опора // расчёта. n_analogs === 0 → headline посчитан по этим же сделкам, а не по // объявлениям (та же причина, что у analogsCaption ниже) → подпись другая. - const dkpAdvisory = corridorAdvisoryNote(dkp, estimate.n_analogs === 0); + const dkpAdvisory = corridorAdvisoryNote( + dkp, + estimate.n_analogs === 0, + estimate.analog_tier, + ); // Progressive enrichment state const needsHouseType = estimate.house_type === null; diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/corridorAdvisoryNote.test.ts b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/corridorAdvisoryNote.test.ts new file mode 100644 index 00000000..e72710e1 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/corridorAdvisoryNote.test.ts @@ -0,0 +1,113 @@ +// #3452 / #3466 — подпись под коридором ДКП: когда он цену НЕ держит, экран +// обязан это сказать, а молчание — значить только «держит». +// +// Две разные причины выключенной страховки: +// * сделок меньше порога (advisory_only, бэкенд считает порог сам) — говорим +// про РАЗМЕР выборки: гейт Tier C держит цену и по пяти сделкам; +// * цена посчитана по аналогам того же дома (analog_tier "same_building") — +// кламп этот тир не трогает при любом count, ревьюер #3462 воспроизвёл +// n=20, headline 202 100 против потолка 140 000 и пустую подпись. +// +// Образец — dealsAsOf.test.ts: проверяется и сама функция, и то, что подпись +// доезжает до плитки v2 через mapResultPanel. + +import { describe, expect, it } from "vitest"; + +import { mapResultPanel } from "../mappers"; +import { corridorAdvisoryNote } from "@/lib/rosreestr"; +import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture"; +import type { AggregatedEstimate, DkpCorridor } from "@/types/trade-in"; + +const SAME_BUILDING = + "справочно: цена посчитана по аналогам в этом же доме — коридор её не ограничивает"; + +function corridor(count: number, advisory_only?: boolean): DkpCorridor { + const c: DkpCorridor = { + count, + low_ppm2: 80_000, + median_ppm2: 90_000, + high_ppm2: 100_000, + period_months: 12, + latest_deal_date: "2026-01-01", + }; + if (advisory_only !== undefined) c.advisory_only = advisory_only; + return c; +} + +describe("corridorAdvisoryNote", () => { + it("мало сделок — подпись про размер выборки", () => { + expect(corridorAdvisoryNote(corridor(5, true), false)).toBe( + "справочно: сделок мало (5) — коридор ориентировочный", + ); + }); + + it("мало сделок и цена из них же — подпись прямее", () => { + expect(corridorAdvisoryNote(corridor(5, true), true)).toBe( + "оценка построена на этих сделках — их всего 5", + ); + }); + + it("сделок достаточно, радиусный путь — молчим: коридор цену держит", () => { + expect(corridorAdvisoryNote(corridor(20, false), false, "district")).toBeNull(); + expect(corridorAdvisoryNote(corridor(20, false), false, "micro_radius")).toBeNull(); + }); + + it("#3466: сделок много, но цена по дому — подпись есть", () => { + // Красный на origin/main: advisory_only=false → null, при том что кламп + // Tier A exempt и коридор цену не тронул. + expect(corridorAdvisoryNote(corridor(20, false), false, "same_building")).toBe( + SAME_BUILDING, + ); + }); + + it("#3466: дом и мало сделок — говорим про дом, это причина сильнее", () => { + expect(corridorAdvisoryNote(corridor(5, true), false, "same_building")).toBe( + SAME_BUILDING, + ); + }); + + it("оценка старше деплоя (полей нет) — молчим, как раньше", () => { + expect(corridorAdvisoryNote(corridor(20), false)).toBeNull(); + expect(corridorAdvisoryNote(corridor(20), false, null)).toBeNull(); + }); + + it("коридора нет — подписи нет и у дома", () => { + expect(corridorAdvisoryNote(null, false, "same_building")).toBeNull(); + expect(corridorAdvisoryNote(undefined, false)).toBeNull(); + }); +}); + +/** Подпись плитки «ДКП · РОСРЕЕСТР» — ветка коридора (street-deals нет). */ +function dkpNote(over: Partial): string | undefined { + const e: AggregatedEstimate = { + ...FIXTURE_ESTIMATE, + actual_deals: [], + area_m2: 60, + ...over, + }; + const card = mapResultPanel(e, null).cards.find((c) => + c.title.join(" ").includes("ДКП"), + ); + if (!card) throw new Error("плитка ДКП пропала из панели результата"); + return card.note; +} + +describe("#3466 подпись доезжает до плитки v2", () => { + it("same_building + n=20 → подпись про дом", () => { + expect( + dkpNote({ dkp_corridor: corridor(20, false), analog_tier: "same_building" }), + ).toBe(SAME_BUILDING); + }); + + it("district + n=20 → без подписи", () => { + expect( + dkpNote({ dkp_corridor: corridor(20, false), analog_tier: "district" }), + ).toBeUndefined(); + }); + + it("district + n=9 → подпись про выборку", () => { + expect( + dkpNote({ dkp_corridor: corridor(9, true), analog_tier: "district" }), + ).toBe("справочно: сделок мало (9) — коридор ориентировочный"); + }); +}); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index e907526a..5f33f713 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -696,7 +696,7 @@ function resolveDealTier( asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"), // n_analogs === 0 → headline и есть медиана этого коридора (dealsOnlyPrice // ниже по файлу разбирает тот же случай) — подпись тогда прямее. - advisoryNote: corridorAdvisoryNote(c, e.n_analogs === 0), + advisoryNote: corridorAdvisoryNote(c, e.n_analogs === 0, e.analog_tier), }; } if (e.actual_deals.length > 0) { diff --git a/tradein-mvp/frontend/src/lib/rosreestr.ts b/tradein-mvp/frontend/src/lib/rosreestr.ts index 3e92bb8a..f5cb7b68 100644 --- a/tradein-mvp/frontend/src/lib/rosreestr.ts +++ b/tradein-mvp/frontend/src/lib/rosreestr.ts @@ -1,4 +1,4 @@ -import type { DkpCorridor } from "@/types/trade-in"; +import type { AnalogTier, DkpCorridor } from "@/types/trade-in"; export const ROSREESTR_REQUEST_URL = "https://rosreestr.gov.ru/eservices/request_info_from_egrn/"; @@ -53,13 +53,23 @@ export function dealsAsOfLabel( * min_n — коридор из пяти сделок там способен уронить headline на треть. * Утверждение про размер выборки истинно во всех достижимых состояниях. * + * #3466: сделок может быть и много, а коридор цену всё равно не держит — когда + * она посчитана по аналогам того же дома (analog_tier "same_building"): кламп + * этот тир не трогает, radius-floor тоже. Здесь утверждение про алгоритм уже + * истинно при любом count (гейт Tier C к этому тиру не относится) — его и говорим. + * * null (флага нет — оценка старше деплоя, либо сделок хватает) → подписи нет. */ export function corridorAdvisoryNote( dkp: DkpCorridor | null | undefined, headlineFromDeals: boolean, + analogTier?: AnalogTier | null, ): string | null { - if (dkp?.advisory_only !== true) return null; + if (!dkp) return null; + if (analogTier === "same_building") { + return "справочно: цена посчитана по аналогам в этом же доме — коридор её не ограничивает"; + } + if (dkp.advisory_only !== true) return null; // headline ПОСТРОЕН из этого же коридора (deals-fallback, n_analogs === 0 — // похожих объявлений рядом не нашлось): коридор тут не сбоку от цены, он и // есть цена, и клиент обязан знать, на скольких сделках она стоит. diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index be0846ec..bcec3816 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -170,7 +170,8 @@ export interface DkpCorridor { // #3452: true = сделок меньше порога доверия (бэкенд считает порог сам, // estimate_corridor_clamp_min_n) → коридор показан, но цену по нему НЕ // поправляли: ни кламп сверху, ни floor снизу. Заголовок «advisory» у всего - // коридора не про это — при count >= порога он в цену как раз идёт. + // коридора не про это — при count >= порога он в цену как раз идёт, кроме + // analog_tier === "same_building" (#3466: якорь того же дома, кламп exempt). // optional: оценки, посчитанные до деплоя бэкенда, поля не содержат. advisory_only?: boolean; } From 31c31f8f82fd70d952d12f7018ebbb842f74f380 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:38:44 +0500 Subject: [PATCH 12/47] =?UTF-8?q?=D0=9C=D0=95=D0=A0=D0=90:=20=D1=8F=D0=BA?= =?UTF-8?q?=D0=BE=D1=80=D1=8C=20=C2=AB=D1=82=D0=BE=D1=82=20=D0=B6=D0=B5=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC=C2=BB=20=D0=B8=20IMV-blend=20=E2=80=94=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=82=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=B2=D0=B8=D0=B6=D0=BA=D0=B0,=20=D0=BD=D0=B5=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20(#2381)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пять булевых флагов кластера сняты ещё в #2475. Оставшиеся 14 числовых полей Settings перенесены в estimator.py константами с теми же значениями: IMV_BLEND_WEIGHT 0.5, IMV_BLEND_THRESHOLD 1.15, SB_MIN_COMPS 4, SB_AREA_SIGMA 0.18, SB_ROOMS_MATCH_BOOST 1.6, SB_FLOOR_SIGMA 0.25, SB_GUARDRAIL_TOL 0.05, SB_MAD_K 3.5, SB_MAD_K_SMALL_N 2.5, SB_SMALL_N_THRESHOLD 10, ANCHOR_TIER_C_CORRIDOR_MULT 1.5, FSD_K 1.65, SB_GATE_MIN_N 3, SB_GATE_MAX_FSD 0.20. На проде 17.09 все 14 равны дефолтам, ENV-оверрайдов нет. Мёртвая проверка `tier_c_mult > 0` (константа против нуля) убрана. Тесты подменяют SB_MIN_COMPS на модуле вместо поля settings. Реплей бэктеста по сделкам побитово тот же, срабатываний якоря 985, IMV-blend 3, low-conf гейта 11 — как на main. Шесть порогов (rooms_boost, floor_sigma, guardrail_tol, tier_c_mult, fsd_k, gate_max_fsd) не ловит ни один поведенческий тест и ни гейт, их держит только тест боевых значений. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/core/config.py | 56 ------------ tradein-mvp/backend/app/schemas/trade_in.py | 2 +- tradein-mvp/backend/app/services/estimator.py | 85 ++++++++++++------- .../tests/test_1970_estimator_constants.py | 15 ++++ .../test_estimator_anchor_dedup_gap_2265.py | 10 +-- .../tests/test_estimator_audit_fixes.py | 11 +-- ...imator_deals_headline_fallback_oblast_d.py | 4 +- .../tests/test_estimator_price_spine.py | 4 +- .../tests/test_same_building_anchor.py | 8 +- .../backend/tests/test_tier_a_primary_1774.py | 12 +-- .../frontend/src/app/mera-public/content.ts | 2 +- 11 files changed, 95 insertions(+), 114 deletions(-) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 763c75fd..53841390 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -384,17 +384,6 @@ class Settings(BaseSettings): dadata_api_token: str | None = None dadata_api_secret: str | None = None - # ── #651: IMV / Yandex blend (killer accuracy fix) ────────────────────── - # Радиусная медиана ₽/м² системно недооценивает премиум/видовые квартиры - # (нет class/segment/IMV-коррекции → premium ~2x underestimate, case 50М vs - # факт ~100М). Если внешний якорь (Avito IMV recommended_price из - # house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в - # `threshold` раз — подмешиваем якорь к медиане с весом `weight` и - # расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение). - # При отсутствии IMV/Yandex no-op (медиана не меняется). - estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w - estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15 - # #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на # данные ЭТОГО адреса, а не на среднее по городу. # Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного @@ -414,25 +403,6 @@ class Settings(BaseSettings): # False ⇒ прежнее поведение целиком, без релиза. estimate_require_local_evidence: bool = True - # ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ────────── - # Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка, - # комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A), - # similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита - # дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95) - # и tighter FSD-диапазон. - # Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2. - estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов - estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian) - estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target - # #680-WB within-building heterogeneity refine: floor-similarity Gaussian по - # ОТНОСИТЕЛЬНОЙ вертикальной позиции (floor/total_floors). Прижимает якорь к - # комплам с похожим этажом — мелкокомнатный/нижний юнит во флагман-доме больше - # не наследует цену видового топ-этажа. 0.0 → выключено (точно старое поведение). - # Откалибровано на 55 golden (offline): σ_f=0.25 даёт лучший medAPE без потери - # покрытия; Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%. - estimate_sb_floor_sigma: float = 0.25 - estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol) - estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD # ── #1966: honest calibrated prediction-interval для expected_sold range ───── # Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio): # ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й @@ -527,33 +497,7 @@ class Settings(BaseSettings): estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7% estimate_hedonic_factor_min: float = 0.75 estimate_hedonic_factor_max: float = 1.30 - # ── #1795: premium headline anti-inflation (шаги 1/4/5 — константы estimator.py) ── - # Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor: - # при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k - # (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх). - # mad_k_small >= estimate_sb_mad_k → no-op (старое поведение). - estimate_sb_mad_k_small_n: float = 2.5 - estimate_sb_small_n_threshold: int = 10 - # Шаг 3 — гейт Tier C: micro-radius anchor (НЕ тот же дом) с - # anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану. - # Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение). - estimate_anchor_tier_c_corridor_mult: float = 1.5 - # #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же - # доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где - # "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free). - # Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус / - # ratio — не затрагиваются. asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²) - estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона - - # ── #audit-1: anchor low-confidence gate ───────────────────────────────── - # Якорь с низкой уверенностью (confidence="low" ИЛИ n < min_n И FSD > max_fsd) - # НЕ заменяет headline — fallback на radius-median. Дефолты подобраны так, что - # здоровые якоря (n≥4 с FSD<0.15) проходят без изменений. - # estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает - # estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь - estimate_sb_gate_min_n: int = 3 - estimate_sb_gate_max_fsd: float = 0.20 # ── #audit-3: price_trend freshness filter ──────────────────────────────── # Исключать items старше N месяцев из price_trend (house_placement_history). diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index fd9913e0..2d94c982 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -140,7 +140,7 @@ class AvitoImvSummary(BaseModel): Источник: `house_imv_evaluations` (per house_id, обновляется регулярно). Это РЕАЛЬНАЯ рыночная оценка Avito по дому — служит anchor'ом для blend'а - (см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на + (см. IMV_BLEND_* в estimator.py). Сурфейсится в UI как референсный маркер на ценовой шкале. None если для дома нет свежей IMV-записи. """ diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index d826ca12..e7c57415 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -479,6 +479,44 @@ OUTLIER_TUKEY_K_SMALL = 1.0 # Шаг 5 — expected_sold ≤ asking — выключателя не имеет (_price_from_inputs): ratio > 1.0 # для trade-in физически невозможен, это артефакт high-price бакета. +# #651 (#2381): IMV / Yandex blend. Радиусная медиана ₽/м² системно недооценивает +# премиум/видовые квартиры (premium ~2x underestimate). Если внешний якорь (Avito IMV +# recommended_price из house_imv_evaluations либо Yandex sale) выше медианы более чем +# в IMV_BLEND_THRESHOLD раз — подмешиваем его с весом IMV_BLEND_WEIGHT +# (median×(1-w)+A×w) и расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем. +IMV_BLEND_WEIGHT = 0.5 +IMV_BLEND_THRESHOLD = 1.15 + +# #651/#652 v2 (#2381): same-building anchor (validated, 55 golden cases). Радиусная +# медиана размывает премию дома/ЖК → премиум ~2.5x недооценка, комфорт −15-25%. v2 +# берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A), similarity-weighted по +# площади/комнатам, premium-uplift к ~p70 для топ-юнита, asking→sold haircut (banded +# по ppm²), hard guardrail и tighter FSD-диапазон. +# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2. +SB_MIN_COMPS = 4 # стоп на первом тире с ≥ N активных комплов +SB_AREA_SIGMA = 0.18 # σ log-нормального area-веса (Gaussian) +SB_ROOMS_MATCH_BOOST = 1.6 # ×вес если rooms компла == target +# #680-WB: floor-similarity Gaussian по ОТНОСИТЕЛЬНОЙ вертикальной позиции +# (floor/total_floors) — мелкокомнатный/нижний юнит во флагман-доме не наследует цену +# видового топ-этажа. σ_f=0.25 — лучший medAPE на 55 golden без потери покрытия +# (Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%). +SB_FLOOR_SIGMA = 0.25 +SB_GUARDRAIL_TOL = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol) +SB_MAD_K = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD +# #1795 шаг 2: на малых выборках (n < SB_SMALL_N_THRESHOLD, кроме Tier A) MAD-clip +# жёстче — 3.5 при n=7 не срезает элитные хвосты, weighted mean тянется вверх. +SB_MAD_K_SMALL_N = 2.5 +SB_SMALL_N_THRESHOLD = 10 +# #1795 шаг 3: гейт Tier C — micro-radius якорь (НЕ тот же дом) с +# anchor_ppm2 > corridor_high × mult не заменяет радиусную медиану. +ANCHOR_TIER_C_CORRIDOR_MULT = 1.5 +FSD_K = 1.65 # множитель FSD → полуширина диапазона якоря +# #audit-1: low-confidence гейт якоря — n < SB_GATE_MIN_N И FSD > SB_GATE_MAX_FSD +# (или confidence="low") → fallback на радиусную медиану. Здоровые якоря (n≥4, +# FSD<0.15) проходят без изменений. +SB_GATE_MIN_N = 3 +SB_GATE_MAX_FSD = 0.20 + # #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются # нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади — # которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные @@ -2655,7 +2693,7 @@ def _fetch_anchor_comps( ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None), floor (int|None), total_floors (int|None) — последние два для floor-веса (#680-WB). """ - min_comps = settings.estimate_sb_min_comps + min_comps = SB_MIN_COMPS # ── Tier A: same building ──────────────────────────────────────────────── street, base_no, letter = _normalize_building_key(address) @@ -3038,12 +3076,8 @@ def _compute_same_building_anchor( # легитимен, агрессивный clip там съел бы реальные топ-юниты и обрушил бы якорь # < min_comps → fallback на заниженную радиусную медиану. Tier C/прочие — clip. effective_mad_k = mad_k - if ( - tier != "A" - and settings.estimate_sb_mad_k_small_n < mad_k - and len(raw_ppm2) < settings.estimate_sb_small_n_threshold - ): - effective_mad_k = settings.estimate_sb_mad_k_small_n + if tier != "A" and SB_MAD_K_SMALL_N < mad_k and len(raw_ppm2) < SB_SMALL_N_THRESHOLD: + effective_mad_k = SB_MAD_K_SMALL_N surviving_idx = _mad_clip(raw_ppm2, effective_mad_k) if len(surviving_idx) < min_comps: logger.info( @@ -3654,32 +3688,28 @@ def _price_from_inputs( area_target=area_m2, rooms_target=rooms, tier=anchor_tier or "", - sigma=settings.estimate_sb_area_sigma, - rooms_boost=settings.estimate_sb_rooms_match_boost, + sigma=SB_AREA_SIGMA, + rooms_boost=SB_ROOMS_MATCH_BOOST, floor_target=floor, total_floors_target=total_floors, - floor_sigma=settings.estimate_sb_floor_sigma, - min_comps=settings.estimate_sb_min_comps, - mad_k=settings.estimate_sb_mad_k, + floor_sigma=SB_FLOOR_SIGMA, + min_comps=SB_MIN_COMPS, + mad_k=SB_MAD_K, ) # #1795 шаг 3: гейт Tier C. - if ( - anchor is not None - and anchor_tier == "C" - and settings.estimate_anchor_tier_c_corridor_mult > 0 - ): + if anchor is not None and anchor_tier == "C": if dkp_raw is not None and dkp_raw.get("high_ppm2", 0) > 0: corridor_high_for_gate = float(dkp_raw["high_ppm2"]) else: corridor_high_for_gate = (median_ppm2 / repair_coef) * 1.3 if repair_coef else 0.0 - gate_threshold = corridor_high_for_gate * settings.estimate_anchor_tier_c_corridor_mult + gate_threshold = corridor_high_for_gate * ANCHOR_TIER_C_CORRIDOR_MULT if gate_threshold > 0 and anchor["anchor_ppm2"] > gate_threshold: logger.info( "sb_anchor Tier C gate #1795: anchor_ppm2=%d > corridor_high×%.1f=%d" " → keep radius median (anchor suppressed)", int(anchor["anchor_ppm2"]), - settings.estimate_anchor_tier_c_corridor_mult, + ANCHOR_TIER_C_CORRIDOR_MULT, int(gate_threshold), ) anchor = None @@ -3687,10 +3717,7 @@ def _price_from_inputs( # #audit-1: low-confidence gate. if anchor is not None: gate_low = anchor["confidence"] == "low" - gate_thin = ( - anchor["n"] < settings.estimate_sb_gate_min_n - and anchor["fsd"] > settings.estimate_sb_gate_max_fsd - ) + gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD if gate_low or gate_thin: logger.info( "sb_anchor low-conf gate #audit-1: tier=%s n=%d fsd=%.3f conf=%s" @@ -3730,13 +3757,13 @@ def _price_from_inputs( anchor_comps_used = anchor.get("comps") or anchor_comps est_ppm2 = anchor["anchor_ppm2"] # PREMIUM GUARDRAIL (hard). - floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - settings.estimate_sb_guardrail_tol) + floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - SB_GUARDRAIL_TOL) if est_ppm2 < floor_ppm2: est_ppm2 = floor_ppm2 new_ppm2 = est_ppm2 * repair_coef point = int(new_ppm2 * area_m2) # FSD-диапазон. - half = settings.estimate_fsd_k * anchor["fsd"] + half = FSD_K * anchor["fsd"] new_range_low = int(point * max(0.0, 1.0 - half)) new_range_high = int(point * (1.0 + half)) # Спред комплов. @@ -3893,8 +3920,8 @@ def _price_from_inputs( area=area_m2, anchor_total=anchor_total, anchor_higher=anchor_higher, - weight=settings.estimate_imv_blend_weight, - threshold=settings.estimate_imv_blend_threshold, + weight=IMV_BLEND_WEIGHT, + threshold=IMV_BLEND_THRESHOLD, market_count=( avito_imv_summary.market_count if avito_imv_summary is not None else None ), @@ -3906,7 +3933,7 @@ def _price_from_inputs( median_price, new_median, anchor_used, - settings.estimate_imv_blend_weight, + IMV_BLEND_WEIGHT, range_high, new_range_high, ) @@ -4277,7 +4304,7 @@ def _price_from_inputs( # #oblast-E: guards on `anchor is None` (the actual computed anchor dict), # NOT `anchor_tier is None`. Found via live backtest-fixture regen: when # `_compute_same_building_anchor` rejects a candidate outright (e.g. its - # own MAD-clip drops comps below estimate_sb_min_comps), it returns None + # own MAD-clip drops comps below SB_MIN_COMPS), it returns None # WITHOUT the caller resetting `anchor_tier` back to None (it stays # whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired, # but the stale tier flag falsely reads as "anchor claimed the headline" diff --git a/tradein-mvp/backend/tests/test_1970_estimator_constants.py b/tradein-mvp/backend/tests/test_1970_estimator_constants.py index 4be8989f..a2421aac 100644 --- a/tradein-mvp/backend/tests/test_1970_estimator_constants.py +++ b/tradein-mvp/backend/tests/test_1970_estimator_constants.py @@ -37,6 +37,21 @@ _PROD_VALUES: dict[str, tuple[str, float]] = { "RADIUS_FLOOR_FACTOR": ("estimate_radius_floor_factor", 0.8), "OUTLIER_SMALL_N_THRESHOLD": ("estimate_outlier_small_n_threshold", 15), "OUTLIER_TUKEY_K_SMALL": ("estimate_outlier_tukey_k_small", 1.0), + # #2381, #651/#652 same-building anchor + IMV-blend + "IMV_BLEND_WEIGHT": ("estimate_imv_blend_weight", 0.5), + "IMV_BLEND_THRESHOLD": ("estimate_imv_blend_threshold", 1.15), + "SB_MIN_COMPS": ("estimate_sb_min_comps", 4), + "SB_AREA_SIGMA": ("estimate_sb_area_sigma", 0.18), + "SB_ROOMS_MATCH_BOOST": ("estimate_sb_rooms_match_boost", 1.6), + "SB_FLOOR_SIGMA": ("estimate_sb_floor_sigma", 0.25), + "SB_GUARDRAIL_TOL": ("estimate_sb_guardrail_tol", 0.05), + "SB_MAD_K": ("estimate_sb_mad_k", 3.5), + "SB_MAD_K_SMALL_N": ("estimate_sb_mad_k_small_n", 2.5), + "SB_SMALL_N_THRESHOLD": ("estimate_sb_small_n_threshold", 10), + "ANCHOR_TIER_C_CORRIDOR_MULT": ("estimate_anchor_tier_c_corridor_mult", 1.5), + "FSD_K": ("estimate_fsd_k", 1.65), + "SB_GATE_MIN_N": ("estimate_sb_gate_min_n", 3), + "SB_GATE_MAX_FSD": ("estimate_sb_gate_max_fsd", 0.20), } # Булевы выключатели, схлопнутые к боевому True: их OFF-ветки из кода удалены. diff --git a/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py b/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py index 1a7386f1..9987f3e9 100644 --- a/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py +++ b/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py @@ -101,7 +101,7 @@ _DUP_LOT_DOMKLIK = _comp( # 3 доп. уникальных компла того же дома/подъезда — ppm2 близки к дубль-лоту # (119k-125k), MAD-clip (k=3.5) их не тронет; вместе с дубль-лотом дают -# estimate_sb_min_comps=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает. +# SB_MIN_COMPS=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает. _OTHER_COMPS = [ _comp( source="avito", address="улица Сыромолотова, 13", area=40.0, price_rub=5_000_000, floor=5 @@ -132,10 +132,10 @@ def test_anchor_path_dedups_cross_source_duplicate_lot(monkeypatch: pytest.Monke После фикса: дедуп схлопывает пару в 1 представителя ДО _compute_same_building_ anchor → est.analogs содержит физлот ровно ОДИН раз (n=1). """ - from app.core.config import settings + from app.services import estimator with monkeypatch.context() as m: - m.setattr(settings, "estimate_sb_min_comps", 1) + m.setattr(estimator, "SB_MIN_COMPS", 1) est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload()) assert len(est.analogs) == 1 # НЕ 2 — дубль-лот схлопнут в 1 представителя @@ -151,10 +151,10 @@ def test_anchor_path_n_analogs_matches_shown_count(monkeypatch: pytest.MonkeyPat (постдедуп) analogs, а не сырой недедупленный anchor-пул. До фикса n_analogs=2 при одном реальном физлоте (дубль посчитан дважды) — самосогласованно, но нечестно. После фикса n_analogs=1, синхронно с analogs.""" - from app.core.config import settings + from app.services import estimator with monkeypatch.context() as m: - m.setattr(settings, "estimate_sb_min_comps", 1) + m.setattr(estimator, "SB_MIN_COMPS", 1) est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload()) assert est.n_analogs == len(est.analogs) == 1 diff --git a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py index f6cb2430..83833adc 100644 --- a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py +++ b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py @@ -101,19 +101,16 @@ def test_fix1_thin_n_high_fsd_triggers_gate() -> None: ) if anchor is None: pytest.skip("MAD-clip отсёк — нет якоря, тест не применим") - from app.core.config import settings + from app.services.estimator import SB_GATE_MAX_FSD, SB_GATE_MIN_N # Проверяем условие gate_thin напрямую - gate_thin = ( - anchor["n"] < settings.estimate_sb_gate_min_n - and anchor["fsd"] > settings.estimate_sb_gate_max_fsd - ) + gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD # n=2 < 3 — должен сработать если FSD тоже высокий assert anchor["n"] == 2 # FSD = 0.07 + 0.25*CV + tier_penalty + n_penalty; n_penalty=0.05 при n<3; # tier_penalty=0.05 (C); CV = std/mean для 2 элементов # Ожидаем что n=2 с умеренным разбросом даёт FSD ≈ 0.07+...≥0.20 - if anchor["fsd"] > settings.estimate_sb_gate_max_fsd: + if anchor["fsd"] > SB_GATE_MAX_FSD: assert gate_thin, "gate_thin должен быть True при n=2 и high FSD" @@ -308,8 +305,6 @@ def test_fix4_premium_comp_survives_post_weight_clip() -> None: comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)] with patch("app.services.estimator.settings") as mock_settings: - mock_settings.estimate_sb_mad_k_small_n = 2.5 - mock_settings.estimate_sb_small_n_threshold = 10 mock_settings.avito_imv_thin_market_threshold = 10 mock_settings.sber_index_max_age_days = 35 diff --git a/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py b/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py index 2b8f2a3c..3d7ef194 100644 --- a/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py +++ b/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py @@ -313,7 +313,7 @@ _EKB_LENINA_LISTING = { def _ekb_lenina_pool(prices_per_m2: list[float]) -> list[dict[str, Any]]: - """N distinct EKB "ул. Ленина" comps (>= estimate_sb_min_comps=4 needed for + """N distinct EKB "ул. Ленина" comps (>= SB_MIN_COMPS=4 needed for Tier A to actually FIRE — see _fetch_anchor_comps `len(comps) >= min_comps`). Distinct floor/source_id/price_rub per row so `_dedup_cross_source` (#2265, street+floor+area+price physical key) treats them as distinct units, not @@ -407,7 +407,7 @@ def test_non_ekb_anchor_not_leaked_from_ekb_street_collision() -> None: "period_months": 12, } db = MagicMock() - # >= estimate_sb_min_comps EKB comps — realistic (40 191 of ~40 200 active + # >= SB_MIN_COMPS EKB comps — realistic (40 191 of ~40 200 active # listings are EKB) and necessary for Tier A to actually fire pre-fix. db.execute.side_effect = _fake_anchor_sql_execute( _ekb_lenina_pool([178_000.0, 186_000.0, 190_000.0, 184_000.0]), diff --git a/tradein-mvp/backend/tests/test_estimator_price_spine.py b/tradein-mvp/backend/tests/test_estimator_price_spine.py index 162fa2cc..b0aa665c 100644 --- a/tradein-mvp/backend/tests/test_estimator_price_spine.py +++ b/tradein-mvp/backend/tests/test_estimator_price_spine.py @@ -252,10 +252,10 @@ def test_anchor_tier_reset_when_anchor_not_built() -> None: Третий путь к anchor=None (#2661), отдельный от Tier C гейта и low-conf гейта выше: когда ``_compute_same_building_anchor`` возвращает None САМА (комплов меньше - ``estimate_sb_min_comps``=4), флаг раньше оставался равным ``anchor_tier_fetched`` — + ``SB_MIN_COMPS``=4), флаг раньше оставался равным ``anchor_tier_fetched`` — дальше по коду он читается как «headline построил якорь». """ - comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < estimate_sb_min_comps=4 + comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < SB_MIN_COMPS=4 radius_median_price = int(100_000 * 50.0) pr = _call( listings=_lots(100_000, n=5), diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index 2fadaeca..f2d8eba8 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -393,7 +393,7 @@ _RADIUS_ANALOGS: list[dict[str, Any]] = [ ] # Same-building комплы Хохрякова 48 (флагман 684k внутри). -# 4 комплов — удовлетворяет estimate_sb_min_comps=4 (#755). +# 4 комплов — удовлетворяет SB_MIN_COMPS=4 (#755). _SB_COMPS_PREMIUM: list[dict[str, Any]] = [ {"price_per_m2": 399_478, "area_m2": 153.2, "rooms": 3}, {"price_per_m2": 472_298, "area_m2": 110.1, "rooms": 3}, @@ -527,7 +527,7 @@ def test_estimate_no_anchor_no_radius_stays_insufficient() -> None: def test_estimate_economy_no_regression() -> None: """(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов. - 4 компла — satisfies estimate_sb_min_comps=4 (#755). + 4 компла — satisfies SB_MIN_COMPS=4 (#755). Комплы специально подобраны так, что MAD-clip не удаляет ни один из них (все отклонения << 3.5×MAD при tight spread).""" eco_comps = [ @@ -596,7 +596,7 @@ def test_estimate_tier_d_fallback_keeps_radius() -> None: def test_estimate_range_covers_same_building_comp_spread() -> None: """range_high покрывает RAW max same-building компла (видовой/топ-юнит дома не вылетает за диапазон — зеркало 8 Марта 204Г view-кейса). - 4 компла — satisfies estimate_sb_min_comps=4 (#755).""" + 4 компла — satisfies SB_MIN_COMPS=4 (#755).""" # comp max 255_459 ppm² — самый дорогой лот в доме (видовой). target — рядовой. view_comps = [ {"price_per_m2": 124_309, "area_m2": 54.3, "rooms": 2}, @@ -663,7 +663,7 @@ def test_estimate_analogs_stay_radius_when_no_anchor() -> None: def test_estimate_analogs_pass_through_display_fields() -> None: """#694: комплы С display-полями (address/source/source_url/price_rub) пробрасывают их в AnalogLot напрямую (без вычисления price_rub из ppm²×area). - 4 компла — satisfies estimate_sb_min_comps=4 (#755).""" + 4 компла — satisfies SB_MIN_COMPS=4 (#755).""" comps_with_display = [ { "price_per_m2": 399_478, diff --git a/tradein-mvp/backend/tests/test_tier_a_primary_1774.py b/tradein-mvp/backend/tests/test_tier_a_primary_1774.py index 373ab2b0..abc94f4e 100644 --- a/tradein-mvp/backend/tests/test_tier_a_primary_1774.py +++ b/tradein-mvp/backend/tests/test_tier_a_primary_1774.py @@ -121,7 +121,7 @@ def test_tier_a_includes_novostroyki_when_secondary_present() -> None: _row(source="cian", source_id="c2", listing_segment="novostroyki"), ] db = _db_mock(rows) - with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): + with patch.object(est_mod, "SB_MIN_COMPS", 4): comps, tier = _fetch(db, lat=_LAT, lon=_LON) assert tier == "A" # Все 6 (4 вторички + 2 novostroyki-переуступки) учтены. @@ -146,7 +146,7 @@ def test_tier_a_excludes_novostroyki_when_primary_dominated() -> None: _row(source="cian", source_id="c5", listing_segment="novostroyki"), ] db = _db_mock(rows) - with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): + with patch.object(est_mod, "SB_MIN_COMPS", 4): comps, tier = _fetch(db) # primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip. assert tier is None @@ -168,7 +168,7 @@ def test_tier_a_excludes_novostroyki_when_pure_primary() -> None: _row(source="cian", source_id="c4", listing_segment="novostroyki"), ] db = _db_mock(rows) - with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): + with patch.object(est_mod, "SB_MIN_COMPS", 4): comps, tier = _fetch(db) # Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D). assert tier is None @@ -193,7 +193,7 @@ def test_tier_a_dedup_same_source_id_collapses() -> None: _row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None), ] db = _db_mock(rows) - with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): + with patch.object(est_mod, "SB_MIN_COMPS", 4): comps, tier = _fetch(db, lat=_LAT, lon=_LON) assert tier == "A" # 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5. @@ -229,7 +229,7 @@ def test_tier_a_dedup_same_source_id_different_url_collapses() -> None: ), ] db = _db_mock(rows) - with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): + with patch.object(est_mod, "SB_MIN_COMPS", 4): comps, tier = _fetch(db, lat=_LAT, lon=_LON) assert tier == "A" # source_id-primary схлопывает несмотря на разные url → 5 comps. @@ -248,7 +248,7 @@ def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None: _row(source="avito", source_url=None, source_id="a4", area_m2=80.0), ] db = _db_mock(rows) - with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): + with patch.object(est_mod, "SB_MIN_COMPS", 4): comps, tier = _fetch(db, lat=_LAT, lon=_LON) assert tier == "A" # 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто. diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts index 586e0116..b68988df 100644 --- a/tradein-mvp/frontend/src/app/mera-public/content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/content.ts @@ -370,7 +370,7 @@ export interface SourceGroup { * ПОЧЕМУ ТРЕТЬЯ ГРУППА ЕСТЬ (а не «два типа данных», как было). Оценки площадок * — не украшение экрана: в `backend/app/services/estimator.py` (блок «#651: IMV * / Yandex blend», Tier D — когда якоря по дому/≤500 м нет) медиана - * переписывается на `new_median` с весом `estimate_imv_blend_weight`, + * переписывается на `new_median` с весом `IMV_BLEND_WEIGHT`, * объяснение дополняется «Оценка скорректирована по…», а `sources_used` * пополняется `avito_imv`. Умолчать об этом — значит утверждать на публичной * странице то, чего код не делает. From bc0ef9536ebe5128a2e92f2d5e5e34b5e5884244 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:40:56 +0500 Subject: [PATCH 13/47] =?UTF-8?q?fix(glitchtip):=20=D0=BC=D0=B5=D0=B4?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BE=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=20Telegram=20=D0=BE=D1=82=D0=B2=D0=B5=D1=87=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20502=20=D0=B4=D0=BE=20=D1=82=D0=B0=D0=B9=D0=BC=D0=B0?= =?UTF-8?q?=D1=83=D1=82=D0=B0=20GlitchTip=20(#3157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Синхронная пересылка алерта была ограничена таймаутом одного HTTP-запроса (8 с), а попыток после ретранслятора (#3471) больше одной: relay, прямой путь, пауза 2 с, повтор. 16.09.2026 16:42 UTC отказ шёл медленно, GlitchTip (aiohttp ClientTimeout total=10) оборвал запрос на 10-й секунде, Caddy записал status=0, а хендлер досчитал 200 уже разорванному клиенту: uvicorn такой ответ выбрасывает вместе со строкой access-log. Исход у отправителя и приёмника расходился, 502 и фоновая доставка не срабатывали. Вся синхронная попытка теперь под asyncio.timeout(7 с). TimeoutError идёт тем же путём, что TelegramError: logger.exception, 502, фоновая доставка. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/api/v1/glitchtip.py | 33 ++++++++++++------- .../tests/test_glitchtip_alert_retry.py | 33 +++++++++++++++++-- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/glitchtip.py b/tradein-mvp/backend/app/api/v1/glitchtip.py index 0539698e..7de099de 100644 --- a/tradein-mvp/backend/app/api/v1/glitchtip.py +++ b/tradein-mvp/backend/app/api/v1/glitchtip.py @@ -43,6 +43,7 @@ regex/prefix-веток в ``rbac_guard``). Сам путь — не секре from __future__ import annotations +import asyncio import json import logging import secrets @@ -74,6 +75,14 @@ _TRUNCATE_SUFFIX = "\n… (обрезано)" # timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты. _INTERACTIVE_SEND_TIMEOUT_S = 8.0 _INTERACTIVE_SEND_MAX_RETRIES = 1 +# Потолок на ВСЮ синхронную попытку (#3157). Таймаут выше — на один HTTP-запрос, +# а попыток больше одной: ретранслятор, при его отказе прямой путь, пауза, повтор +# (#3471). 16.09.2026 16:42 UTC отказ шёл медленно (8 с relay + 5 с напрямую + +# 2 с паузы), GlitchTip на 10-й секунде оборвал соединение (Caddy: status=0), +# а хендлер досчитал себе 200 уже в пустоту — uvicorn ответ разорванному +# клиенту молча выбрасывает вместе со строкой access-log. Отправитель и +# приёмник расходились в исходе. Запас 3 с от 10 с GlitchTip — на Caddy и тело. +_INTERACTIVE_SEND_DEADLINE_S = 7.0 class GlitchTipAttachment(BaseModel): @@ -234,19 +243,21 @@ async def glitchtip_webhook( # до api.telegram.org перед каждой отправкой. Живёт в lifespan. client = get_telegram_client() try: - await client.send_message( - chat_id=settings.telegram_alerts_chat_id, - text=text, - message_thread_id=settings.telegram_alerts_topic_id or None, - # review H1-style бюджет (см. support.py) — синхронный HTTP-путь не - # может легально висеть воркерные минуты ретраев. - timeout=_INTERACTIVE_SEND_TIMEOUT_S, - max_retries=_INTERACTIVE_SEND_MAX_RETRIES, - ) - except TelegramError: + async with asyncio.timeout(_INTERACTIVE_SEND_DEADLINE_S): + await client.send_message( + chat_id=settings.telegram_alerts_chat_id, + text=text, + message_thread_id=settings.telegram_alerts_topic_id or None, + # review H1-style бюджет (см. support.py) — синхронный HTTP-путь не + # может легально висеть воркерные минуты ретраев. + timeout=_INTERACTIVE_SEND_TIMEOUT_S, + max_retries=_INTERACTIVE_SEND_MAX_RETRIES, + ) + except (TelegramError, TimeoutError): # Ловим общий предок, а не `TelegramApiError`: недоступность Telegram — # тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не - # 500 из необработанного исключения (#3456). 502 ОСТАЁТСЯ — это честный + # 500 из необработанного исключения (#3456). `TimeoutError` — наш потолок + # `_INTERACTIVE_SEND_DEADLINE_S`, путь тот же (#3157). 502 ОСТАЁТСЯ — это честный # сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого # текст алерта пропал бы бесследно, поэтому доставку ставим в фон # (#3471, см. app.tasks.glitchtip_alert_retry). diff --git a/tradein-mvp/backend/tests/test_glitchtip_alert_retry.py b/tradein-mvp/backend/tests/test_glitchtip_alert_retry.py index 48b3fe3e..92b6d370 100644 --- a/tradein-mvp/backend/tests/test_glitchtip_alert_retry.py +++ b/tradein-mvp/backend/tests/test_glitchtip_alert_retry.py @@ -15,6 +15,7 @@ import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import asyncio +import time from typing import Any, ClassVar import pytest @@ -46,12 +47,13 @@ class _FakeTelegramClient: """Подменяет `TelegramClient` внутри модуля `glitchtip` — никакого httpx/сети. `responses` — очередь: каждый вызов `send_message` берёт следующий элемент - (dict = успех, Exception = отказ), позволяя смоделировать «первая попытка не - удалась, повторная фоном прошла». + (dict = успех, Exception = отказ, float = зависнуть на столько секунд, потом + успех), позволяя смоделировать «первая попытка не удалась, повторная фоном + прошла». """ calls: ClassVar[list[dict[str, Any]]] = [] - responses: ClassVar[list[dict[str, Any] | Exception]] = [] + responses: ClassVar[list[dict[str, Any] | Exception | float]] = [] def __init__(self, _token: str = "fake-token") -> None: pass @@ -61,6 +63,9 @@ class _FakeTelegramClient: outcome = _FakeTelegramClient.responses.pop(0) if isinstance(outcome, Exception): raise outcome + if isinstance(outcome, float): + await asyncio.sleep(outcome) + return {"message_id": 1} return outcome @@ -109,6 +114,28 @@ def test_sync_failure_queues_background_retry_and_still_returns_502( assert _fake_telegram_client.calls[0]["text"] == _fake_telegram_client.calls[1]["text"] +def test_slow_telegram_answers_502_before_sender_gives_up( + client: TestClient, _fake_telegram_client: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """#3157, прод 16.09.2026 16:42 UTC: отказ Telegram был медленным (relay 8 с + + напрямую 5 с + пауза 2 с), GlitchTip оборвал запрос на 10-й секунде, 502 и + фоновой доставки не было. Медленный отказ обязан идти тем же путём, что быстрый.""" + # GlitchTip 6.1.6 apps/alerts/webhooks.py send_webhook: ClientTimeout(total=10). + assert glitchtip_module._INTERACTIVE_SEND_DEADLINE_S <= 8.0 + monkeypatch.setattr(glitchtip_module, "_INTERACTIVE_SEND_DEADLINE_S", 0.2) + _fake_telegram_client.responses = [5.0, {"message_id": 2}] + + started = time.monotonic() + r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD) + elapsed = time.monotonic() - started + + assert r.status_code == 502 + assert elapsed < 2.0 + # Фон отработал: зависшая синхронная попытка + доставка фоном тем же текстом. + assert len(_fake_telegram_client.calls) == 2 + assert _fake_telegram_client.calls[0]["text"] == _fake_telegram_client.calls[1]["text"] + + def test_sync_success_does_not_queue_background_retry( client: TestClient, _fake_telegram_client: Any ) -> None: From e5db8dd80fef4e9db27f09277cdcaa1796b5e759 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:42:35 +0500 Subject: [PATCH 14/47] =?UTF-8?q?fix(metrics):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20prometheus.yml,=20loki-config.yml=20=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=B0=D1=82=D0=B0=D1=81=D0=BE=D1=80=D1=81=D0=BE=D0=B2=20?= =?UTF-8?q?Grafana=20=D0=B4=D0=BE=D0=B5=D0=B7=D0=B6=D0=B0=D1=8E=D1=82=20?= =?UTF-8?q?=D0=B4=D0=BE=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D1=8E=D1=89?= =?UTF-8?q?=D0=B8=D1=85=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=20(#3467)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хвосты #3467, не попавшие в main вместе с #3476: - prometheus.yml смонтирован одним файлом: после git reset --hard reload перечитывал СТАРЫЙ инод с rc=0 и новым lastConfigTime (стенд prom/prometheus:v3.1.0, 17.09). Комментарий в деплое утверждал обратное. Теперь promtool проверяет файлы С ДИСКА одноразовым контейнером, а при расхождении инода контейнер пересоздаётся до reload. - loki-config.yml — тот же пофайловый маунт, перезагрузки у Loki нет: пересоздание при расхождении инода. - Датасорсы Grafana применяются только при старте: POST /api/admin/provisioning/datasources/reload, отказ роняет деплой. Общий шаг — ops/metrics/recreate-stale-mount.sh: пересоздаёт только при расхождении инода и перепроверяет после; тесты исполняют его с подставным docker. Гейт берёт пофайловые маунты из docker-compose.metrics.yml. Co-Authored-By: Claude Opus 5 --- .forgejo/workflows/deploy-metrics.yml | 96 ++++-- .../tests/ops/test_3467_prometheus_reload.py | 82 ++++- .../ops/test_metrics_single_file_mounts.py | 306 ++++++++++++++++++ ops/metrics/recreate-stale-mount.sh | 65 ++++ 4 files changed, 511 insertions(+), 38 deletions(-) create mode 100644 backend/tests/ops/test_metrics_single_file_mounts.py create mode 100755 ops/metrics/recreate-stale-mount.sh diff --git a/.forgejo/workflows/deploy-metrics.yml b/.forgejo/workflows/deploy-metrics.yml index 6d30ecb9..5b77f2ad 100644 --- a/.forgejo/workflows/deploy-metrics.yml +++ b/.forgejo/workflows/deploy-metrics.yml @@ -388,6 +388,15 @@ jobs: echo "Alertmanager: контейнер пересоздан — иначе читал бы конфиг по старому иноду." fi + # ── Loki: конфиг — бинд-маунт ОДНОГО файла (#3467) ───────────── + # Та же ловушка инода, что у Alertmanager выше, а перезагрузки + # основного конфига у Loki нет вовсе: правка loki-config.yml ложилась + # на диск и не вступала в силу до случайного пересоздания. + # Пересоздаём только при расхождении инода. + COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \ + gendesign-metrics docker-compose.metrics.yml \ + loki ops/metrics/loki/loki-config.yml /etc/loki/loki-config.yml + # ── Caddy: СНАЧАЛА проверить, потом применять ────────────────── # На этом хосте тот же Caddy обслуживает git., errors. и obsidian. # Синтаксическая ошибка в infra.caddy положила бы их все, включая @@ -406,15 +415,6 @@ jobs: fi fi - # ── Приёмка ──────────────────────────────────────────────────── - for i in $(seq 1 30); do - if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null; then - break - fi - sleep 3 - done - docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps - # ── Prometheus: конфиг/правила лежат на диске, `up -d` их не # перечитывает ──────────────────────────────────────────────── # Тот же класс бага, что у Caddyfile и alertmanager.yml выше: @@ -424,27 +424,41 @@ jobs: # незамеченными (#3467): lastConfigTime совпадал со startTime # контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**. # - # У Prometheus, в отличие от Alertmanager (см. комментарий выше), - # /-/reload переоткрывает файлы ПО ПУТИ заново, поэтому новый инод - # после `git reset --hard` подхватывается без пересоздания - # контейнера. --web.enable-lifecycle уже включён в compose ради - # этого шага (см. docker-compose.metrics.yml) — просто раньше - # никто не звал сам reload. + # /-/reload перечитывает правила: они подключены КАТАЛОГОМ, и новый + # файл контейнер видит сразу. А prometheus.yml — бинд-маунт ОДНОГО + # файла: после `git reset --hard` за путём в контейнере остаётся + # СТАРЫЙ инод, и reload честно перечитывает старый текст с rc=0 и + # новым lastConfigTime (проверено на prom/prometheus:v3.1.0, 17.09). + # Поэтому при расхождении инода контейнер пересоздаётся (#3467). + # --web.enable-lifecycle включён в compose ради этого шага. # - # promtool проверяет ОБА файла ДО reload: битый конфиг не должен - # положить работающий Prometheus молчаливым откатом на дефолты. - # - # Плюс юнит-тесты правил (#3493): синтаксически верное правило может - # врать по смыслу — `count(x == 1) == 0` от пустого вектора не - # срабатывает никогда, а под текстом «воркер мёртв» горел не поднятый - # экспортёр. Каталог tests/ в контейнер не смонтирован, поэтому - # одноразовый контейнер ТОГО ЖЕ образа поверх файлов с диска. - if docker exec gendesign-prometheus promtool check config /etc/prometheus/prometheus.yml \ - && docker exec gendesign-prometheus sh -c 'promtool check rules /etc/prometheus/rules/*.yml' \ - && docker run --rm --entrypoint promtool \ - -v /opt/gendesign/ops/metrics/prometheus:/work:ro \ - "$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \ - test rules /work/tests/infra_test.yml; then + # promtool проверяет конфиг, правила и их юнит-тесты (#3493) ДО + # любого применения, и именно ФАЙЛЫ С ДИСКА — одноразовым + # контейнером того же образа. `docker exec` в работающий проверял + # бы тот самый старый инод, а не то, что сейчас применится. + # Синтаксически верное правило может врать по смыслу — + # `count(x == 1) == 0` от пустого вектора не срабатывает никогда, — + # отсюда test rules. + if docker run --rm --entrypoint sh \ + -v /opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro \ + "$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \ + -c 'promtool check config /etc/prometheus/prometheus.yml && promtool check rules /etc/prometheus/rules/*.yml && promtool test rules /etc/prometheus/tests/infra_test.yml'; then + COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \ + gendesign-metrics docker-compose.metrics.yml \ + prometheus ops/metrics/prometheus/prometheus.yml /etc/prometheus/prometheus.yml + + # ── Приёмка ──────────────────────────────────────────────── + # После возможного пересоздания выше — ждём оба процесса, + # которые дальше перечитывают конфиг по HTTP. + for i in $(seq 1 30); do + if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null \ + && docker exec gendesign-grafana wget -q --spider http://localhost:3000/api/health 2>/dev/null; then + break + fi + sleep 3 + done + docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps + LAST_CONFIG_BEFORE="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')" docker exec gendesign-prometheus wget -q -O /dev/null --post-data='' http://localhost:9090/-/reload @@ -470,6 +484,30 @@ jobs: exit 1 fi + # ── Grafana: датасорсы применяются только при старте (#3467) ──── + # Каталог provisioning смонтирован целиком, новый datasources.yml + # контейнер видит сразу. Дашборды провайдер пересканирует сам + # (updateIntervalSeconds: 30), а датасорсы — нет: на стенде + # grafana:11.5.1 изменённый url не применился и через 75 с, POST + # на ручку ниже применил сразу (замер в PR #3475). Без этого шага + # правка датасорса лежала бы на диске без эффекта при зелёном деплое. + # + # Пароль раскрывается ВНУТРИ контейнера: в argv хоста и в лог деплоя + # он не попадает. wget отдаёт rc≠0 на 401/5xx; текст ответа сверяем + # отдельно, чтобы «200 не от той ручки» не сошло за успех. + GRAFANA_RELOAD="$(docker exec gendesign-grafana sh -c \ + 'wget -q -O- --post-data="" --header="Authorization: Basic $(printf "%s:%s" "${GF_SECURITY_ADMIN_USER:-admin}" "${GF_SECURITY_ADMIN_PASSWORD}" | base64 -w0)" http://localhost:3000/api/admin/provisioning/datasources/reload' \ + 2>&1)" || GRAFANA_RELOAD="rc=$? $GRAFANA_RELOAD" + case "$GRAFANA_RELOAD" in + *"Datasources config reloaded"*) + echo "Grafana: датасорсы перечитаны." + ;; + *) + echo "ОШИБКА: Grafana не перечитала датасорсы ($GRAFANA_RELOAD) — правка provisioning/datasources осталась бы без эффекта." + exit 1 + ;; + esac + # ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════ agent-apps: runs-on: ubuntu-latest diff --git a/backend/tests/ops/test_3467_prometheus_reload.py b/backend/tests/ops/test_3467_prometheus_reload.py index d27e5673..554cbb22 100644 --- a/backend/tests/ops/test_3467_prometheus_reload.py +++ b/backend/tests/ops/test_3467_prometheus_reload.py @@ -10,28 +10,41 @@ docker-compose.metrics.yml, но эндпоинт `/-/reload` никто не в Тот же класс бага, что уже пойман и починен для Caddy (`caddy reload`) и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py) -в этом же workflow — только для Prometheus починка не пересоздание -контейнера, а именно `POST /-/reload`: он переоткрывает файлы конфига по -пути заново, так что новый инод после `git reset --hard` подхватывается -без даунтайма. +в этом же workflow. `POST /-/reload` лечит правила — они подключены +КАТАЛОГОМ. prometheus.yml же — бинд-маунт ОДНОГО файла: reload перечитывает +за тем же путём СТАРЫЙ инод (prom/prometheus:v3.1.0, 17.09: файл подменён, +reload rc=0, в /api/v1/status/config прежний job). Поэтому при расхождении +инода контейнер пересоздаётся (ops/metrics/recreate-stale-mount.sh, его +поведение — test_metrics_single_file_mounts.py), а promtool проверяет файлы С +ДИСКА: `docker exec` в работающий контейнер проверял бы старый инод. -Проверяется здесь: (1) валидация promtool ЕСТЬ, (2) reload вызывается -ТОЛЬКО после успешной валидации, (3) шаг обязан упасть, если reload не -подтверждён сменой lastConfigTime. +Проверяется здесь: (1) валидация promtool ЕСТЬ и идёт по файлам с диска, +(2) reload и пересоздание — ТОЛЬКО после успешной валидации, (3) шаг обязан +упасть, если reload не подтверждён сменой lastConfigTime, (4) датасорсы +Grafana перечитываются, и отказ этого шага роняет деплой. """ from __future__ import annotations +import re from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[3] WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml" +CHECK_CONFIG = "promtool check config /etc/prometheus/prometheus.yml" def _text() -> str: return WORKFLOW.read_text(encoding="utf-8") +def _guard_start(text: str) -> int: + """Начало `if <проверка promtool>; then` — ближайший `if` перед check config.""" + guard = text.rfind("\n if ", 0, text.index(CHECK_CONFIG)) + assert guard != -1, "проверка promtool не стоит под `if` — reload ничем не защищён" + return guard + + def test_promtool_checks_config_and_rules() -> None: """promtool обязан проверять и конфиг, и правила — не только один файл.""" text = _text() @@ -65,7 +78,7 @@ def test_reload_happens_after_validation_not_before() -> None: def test_reload_is_guarded_by_the_promtool_check() -> None: """Reload обязан быть ВНУТРИ `if promtool ...; then`, а не безусловным.""" text = _text() - guard_start = text.index("if docker exec gendesign-prometheus promtool check config") + guard_start = _guard_start(text) else_pos = text.index("else", guard_start) reload_pos = text.index("localhost:9090/-/reload") assert guard_start < reload_pos < else_pos, ( @@ -77,7 +90,7 @@ def test_reload_is_guarded_by_the_promtool_check() -> None: def test_failed_validation_skips_reload_and_fails_the_step() -> None: """При провале promtool — reload НЕ вызывается, и шаг падает (exit 1).""" text = _text() - guard_start = text.index("if docker exec gendesign-prometheus promtool check config") + guard_start = _guard_start(text) else_pos = text.index("else", guard_start) fi_pos = text.index("fi", else_pos) else_branch = text[else_pos:fi_pos] @@ -110,3 +123,54 @@ def test_acceptance_checks_last_config_time_actually_changed() -> None: assert "exit 1" in verify_block, ( "если lastConfigTime не изменился, шаг обязан падать, а не считаться успешным" ) + + +def test_promtool_checks_files_from_disk_not_the_running_container() -> None: + """Проверяется то, что сейчас применится, а не то, что держит контейнер. + + prometheus.yml смонтирован одним файлом: `docker exec … promtool check config` + в работающем контейнере читает СТАРЫЙ инод — битая правка прошла бы проверку + и уехала бы в пересоздание. + """ + text = _text() + guard = text[_guard_start(text) : text.index(CHECK_CONFIG)] + assert "docker run --rm" in guard, "promtool запускается не одноразовым контейнером" + assert "/opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro" in guard, ( + "одноразовый promtool смотрит не на файлы с диска по боевым путям" + ) + assert not re.search(r"docker exec gendesign-prometheus[^\n]*promtool", text), ( + "promtool всё ещё проверяет работающий контейнер — то есть старый инод" + ) + + +def test_stale_prometheus_yml_is_recreated_between_check_and_reload() -> None: + """Пересоздание по иноду — после проверки promtool и до reload.""" + text = _text() + check = text.index("promtool check config /etc/prometheus/prometheus.yml") + recreate = re.search( + r"recreate-stale-mount\.sh \\\s+gendesign-metrics docker-compose\.metrics\.yml \\\s+" + r"prometheus ops/metrics/prometheus/prometheus\.yml /etc/prometheus/prometheus\.yml", + text, + ) + assert recreate, "prometheus.yml не сверяется по иноду — правка конфига не доедет" + else_pos = text.index("else", _guard_start(text)) + assert check < recreate.start() < text.index("localhost:9090/-/reload") < else_pos, ( + "пересоздание Prometheus стоит не между успешной проверкой и reload" + ) + + +def test_grafana_datasources_are_reloaded_and_failure_is_red() -> None: + """Датасорсы Grafana применяются только при старте — их надо перечитать явно. + + Замер в PR #3475 (grafana:11.5.1): изменённый url не применился и через 75 с, + POST /api/admin/provisioning/datasources/reload применил сразу. Ответ ручки в + v11.5.1 — «Datasources config reloaded» (pkg/api/admin_provisioning.go). + """ + text = _text() + call = text.find("http://localhost:3000/api/admin/provisioning/datasources/reload") + assert call != -1, "деплой не перечитывает датасорсы Grafana" + block = re.search(r'case "\$GRAFANA_RELOAD" in(.+?)\besac\b', text[call:], re.S) + assert block, "результат перезагрузки датасорсов не разбирается" + ok, _, failed = block.group(1).partition(";;") + assert "Datasources config reloaded" in ok, "успех не сверяется по тексту ответа ручки" + assert "exit 1" in failed and "exit 1" not in ok, "отказ перезагрузки не роняет деплой" diff --git a/backend/tests/ops/test_metrics_single_file_mounts.py b/backend/tests/ops/test_metrics_single_file_mounts.py new file mode 100644 index 00000000..0cd8fb3f --- /dev/null +++ b/backend/tests/ops/test_metrics_single_file_mounts.py @@ -0,0 +1,306 @@ +"""Правка пофайлово смонтированного конфига метрик обязана доезжать до контейнера. + +ЧТО НЕ ТАК. `git reset --hard` пишет изменившийся файл НОВЫМ инодом, а bind-маунт +одного файла держит прежний. `up -d` этого не видит (сравнивает описание сервиса), +перезагрузка по API тоже: на prom/prometheus:v3.1.0 (17.09) prometheus.yml подменён, +POST /-/reload → rc=0, lastConfigTime новый, а /api/v1/status/config отдаёт прежний +job. Деплой зелёный, конфиг старый — у отказа нет отрицательного признака (#3467). +Так смонтированы prometheus.yml и loki-config.yml на сервере и queries.yml у трёх +postgres-экспортёров (#3486). + +ЧТО ПРОВЕРЯЕТСЯ. +1. ops/metrics/recreate-stale-mount.sh ИСПОЛНЯЕТСЯ с подставным `docker`: смотрим + на совершённые действия (пересоздал / не тронул / упал), а не на текст. +2. Гейт по compose-файлам метрик: у КАЖДОГО пофайлового маунта есть путь доезда в + той джобе деплоя, что поднимает сервис. Список маунтов берётся ИЗ compose: + добавят новый — тест покраснеет, пока путь не появится. +""" + +from __future__ import annotations + +import os +import re +import shutil +import stat +import subprocess +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[3] +SCRIPT = REPO_ROOT / "ops" / "metrics" / "recreate-stale-mount.sh" +WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml" + +SRC = "ops/metrics/loki/loki-config.yml" +DST = "/etc/loki/loki-config.yml" + +# Подставной `docker`. Каждый вызов пишется в $FAKE_LOG. +# compose … ps -q — id контейнера из $FAKE_CID_FILE (пусто = не запущен); +# код из $FAKE_PS_RC; +# compose … up … — «пересоздание»: новый id cid-new, и контейнер видит +# инод $FAKE_NEW_VIEW (пусто — не видит ничего); +# exec ID stat -c %i … — инод, который видит контейнер ID ($FAKE_VIEW/ID). +FAKE_DOCKER = r"""#!/bin/bash +printf '%s\n' "$*" >> "$FAKE_LOG" +cmd="$1"; shift +case "$cmd" in + exec) + view="$FAKE_VIEW/$1" + [ -f "$view" ] || exit 1 + cat "$view" + ;; + compose) + case " $* " in + *" ps "*) + [ "${FAKE_PS_RC:-0}" = "0" ] || { echo "compose error" >&2; exit "$FAKE_PS_RC"; } + cat "$FAKE_CID_FILE" + ;; + *" up "*) + printf 'cid-new\n' > "$FAKE_CID_FILE" + [ -z "${FAKE_NEW_VIEW:-}" ] || printf '%s\n' "$FAKE_NEW_VIEW" > "$FAKE_VIEW/cid-new" + ;; + esac + ;; +esac +exit 0 +""" + +# На раннере и на хостах stat — GNU/busybox (`-c %i`); на macOS — BSD (`-f %i`). +# Шим ставится только там, где `-c` не понимают, иначе гейт не запускался бы локально. +STAT_SHIM = ( + '#!/bin/sh\n[ "$1" = "-c" ] && [ "$2" = "%i" ] && exec /usr/bin/stat -f %i "$3"\n' + 'exec /usr/bin/stat "$@"\n' +) +NO_SLEEP = "#!/bin/sh\nexit 0\n" + + +def _write_exec(path: Path, text: str) -> None: + path.write_text(text, encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def _gnu_stat() -> bool: + return subprocess.run(["stat", "-c", "%i", "/"], capture_output=True).returncode == 0 + + +@pytest.fixture +def host(tmp_path: Path) -> Path: + """Боевое дерево: скрипт + конфиг; контейнер cid-old видит ТЕКУЩИЙ инод.""" + tree = tmp_path / "opt" / "gendesign" + (tree / "ops" / "metrics" / "loki").mkdir(parents=True) + shutil.copy(SCRIPT, tree / "ops" / "metrics" / SCRIPT.name) + (tree / SRC).write_text("auth_enabled: false\n", encoding="utf-8") + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + _write_exec(bin_dir / "docker", FAKE_DOCKER) + _write_exec(bin_dir / "sleep", NO_SLEEP) + if not _gnu_stat(): + _write_exec(bin_dir / "stat", STAT_SHIM) + + view = tmp_path / "view" + view.mkdir() + (view / "cid-old").write_text(f"{_ino(tree)}\n", encoding="utf-8") + (tmp_path / "cid").write_text("cid-old\n", encoding="utf-8") + (tmp_path / "log").write_text("", encoding="utf-8") + return tree + + +def _ino(tree: Path) -> int: + return os.stat(tree / SRC).st_ino + + +def _stale(tree: Path) -> None: + """Контейнер держит прежний инод — как после `git reset --hard`.""" + (tree.parent.parent / "view" / "cid-old").write_text(f"{_ino(tree) + 1}\n", encoding="utf-8") + + +def _run(tree: Path, **env_extra: str) -> tuple[int, str, list[str]]: + root = tree.parent.parent + env = { + "PATH": f"{root / 'bin'}:/usr/bin:/bin:/usr/sbin:/sbin", + "FAKE_LOG": str(root / "log"), + "FAKE_CID_FILE": str(root / "cid"), + "FAKE_VIEW": str(root / "view"), + **env_extra, + } + proc = subprocess.run( + [ + "sh", + str(tree / "ops" / "metrics" / SCRIPT.name), + "gendesign-metrics", + "docker-compose.metrics.yml", + "loki", + SRC, + DST, + ], + cwd="/", # скрипт обязан сам встать в корень дерева + env=env, + capture_output=True, + text=True, + ) + calls = [c for c in (root / "log").read_text(encoding="utf-8").splitlines() if c] + return proc.returncode, proc.stdout + proc.stderr, calls + + +def _recreates(calls: list[str]) -> list[str]: + return [c for c in calls if "--force-recreate" in c] + + +# ── Что скрипт делает на самом деле ────────────────────────────────────────── + + +def test_fresh_inode_touches_nothing(host: Path) -> None: + """Контейнер видит текущий файл — пересоздавать нельзя: это рвёт работу зря.""" + rc, out, calls = _run(host) + assert rc == 0, out + assert not _recreates(calls), f"пересоздан без расхождения инода: {calls}" + + +def test_stale_inode_recreates_that_service_and_confirms(host: Path) -> None: + """Старый инод → пересоздать ИМЕННО этот сервис и убедиться, что доехало.""" + _stale(host) + rc, out, calls = _run(host, FAKE_NEW_VIEW=str(_ino(host))) + assert rc == 0, out + recreates = _recreates(calls) + assert len(recreates) == 1, f"ожидалось одно пересоздание, было: {calls}" + assert recreates[0].split()[-1] == "loki", f"пересоздан не тот сервис: {recreates[0]}" + assert "--no-deps" in recreates[0], f"пересоздание тянет зависимости: {recreates[0]}" + assert "-p gendesign-metrics -f docker-compose.metrics.yml" in recreates[0], recreates[0] + assert any(c.startswith("exec cid-new ") for c in calls), ( + f"после пересоздания инод НОВОГО контейнера не сверен: {calls}" + ) + + +def test_still_stale_after_recreate_fails_the_deploy(host: Path) -> None: + """Пересоздание не помогло — красный деплой, а не зелёный со старым конфигом.""" + _stale(host) + rc, out, _calls = _run(host, FAKE_NEW_VIEW=str(_ino(host) + 1)) + assert rc != 0, f"контейнер всё ещё на старом иноде, а скрипт вышел с 0:\n{out}" + assert SRC in out, f"в логе не назван файл, который не доехал:\n{out}" + + +def test_unreadable_container_view_counts_as_stale(host: Path) -> None: + """Инод в контейнере не прочитался — это расхождение, а не «доехало».""" + (host.parent.parent / "view" / "cid-old").unlink() + rc, out, calls = _run(host, FAKE_NEW_VIEW=str(_ino(host))) + assert rc == 0, out + assert _recreates(calls), f"непрочитанный инод сочтён доехавшим: {calls}" + + +def test_not_running_container_is_left_alone(host: Path) -> None: + """Контейнера нет — старый инод держать некому; поднимать сервис тут нельзя.""" + (host.parent.parent / "cid").write_text("", encoding="utf-8") + rc, out, calls = _run(host) + assert rc == 0, out + assert not _recreates(calls), f"поднят незапущенный сервис: {calls}" + + +def test_compose_failure_is_not_read_as_not_running(host: Path) -> None: + """Упавший `compose ps` — не «контейнер не запущен»: иначе зелёный выход вслепую.""" + _stale(host) + rc, out, calls = _run(host, FAKE_PS_RC="1") + assert rc != 0, f"ошибка compose проглочена, скрипт вышел с 0:\n{out}" + assert not _recreates(calls), calls + + +def test_missing_host_file_is_not_success(host: Path) -> None: + """Файла на хосте нет — сверять не с чем; молча выйти с 0 нельзя.""" + (host / SRC).unlink() + rc, out, _calls = _run(host) + assert rc != 0, f"отсутствующий файл сочтён доехавшим:\n{out}" + + +# ── Гейт: у каждого пофайлового маунта есть путь доезда ────────────────────── + +# Производные файлы: их рендерит сам деплой, в git их нет. +# alertmanager.yml — rm + рендер, пересоздание по ALERTMANAGER_RERENDERED; +# alertmanager_targets.gen.yml — усечение на месте (`: >`), инод сохраняется. +RENDERED = { + "ops/metrics/alertmanager/alertmanager.yml", + "ops/metrics/prometheus/alertmanager_targets.gen.yml", +} +# Источник маунта: ./путь[:${VAR:?…}]:/куда[:ro]. `${…}` внутри источника — файл, +# выбираемый ролью (alloy-apps.alloy / alloy-infra.alloy). +MOUNT = re.compile(r"^\./(?P(?:\$\{[^}]*\}|[^:])+):(?P/[^:]+)") +COMPOSE_FILES = { + "docker-compose.metrics.yml": "gendesign-metrics", +} + + +def _jobs_for(compose_name: str, profiles: list[str]) -> list[str]: + """Джобы деплоя, поднимающие сервис: сервер — одна; агенты — по профилю.""" + if compose_name == "docker-compose.metrics.yml": + return ["server"] + return [f"agent-{p}" for p in profiles] or ["agent-apps", "agent-infra"] + + +def _commands(job: str) -> str: + """ssh-скрипт джобы: комментарии выкинуты, продолжения строк склеены. + + Комментарии — потому что разбор дефекта содержит его формулировку дословно. + """ + spec = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + steps = [s for s in spec["jobs"][job]["steps"] if "ssh-action" in str(s.get("uses"))] + assert len(steps) == 1, f"в джобе {job} не ровно один ssh-шаг — гейт ослеп" + kept = [ln for ln in steps[0]["with"]["script"].splitlines() if not ln.lstrip().startswith("#")] + return re.sub(r"\\\n\s*", " ", "\n".join(kept)) + + +def _file_mounts(compose_name: str) -> list[tuple[str, str, str, list[str]]]: + spec = yaml.safe_load((REPO_ROOT / compose_name).read_text(encoding="utf-8")) + found = [] + for service, body in spec["services"].items(): + for vol in body.get("volumes") or []: + m = MOUNT.match(vol) if isinstance(vol, str) else None + if not m or (REPO_ROOT / m["src"]).is_dir() or m["src"] in RENDERED: + continue + found.append((service, m["src"], m["dst"], body.get("profiles") or [])) + return found + + +def _arrival(commands: str, project: str, compose_name: str, service: str, src: str, dst: str): + script_call = re.search( + rf"recreate-stale-mount\.sh\s+{re.escape(project)}\s+{re.escape(compose_name)}" + rf"\s+{re.escape(service)}\s+{re.escape(src)}\s+{re.escape(dst)}[^\n]*", + commands, + ) + forced = re.search( + rf"--force-recreate(?:\s+--no-deps)?\s+{re.escape(service)}(?![\w-])[^\n]*", commands + ) + return script_call or forced + + +@pytest.mark.parametrize("compose_name", sorted(COMPOSE_FILES)) +def test_every_single_file_mount_has_a_way_to_arrive(compose_name: str) -> None: + project = COMPOSE_FILES[compose_name] + mounts = _file_mounts(compose_name) + assert mounts, f"в {compose_name} не найдено ни одного пофайлового маунта — маска ослепла" + missing = [] + for service, src, dst, profiles in mounts: + for job in _jobs_for(compose_name, profiles): + hit = _arrival(_commands(job), project, compose_name, service, src, dst) + if not hit: + missing.append(f"{job}: {service} {src} → {dst}") + elif re.search(r"\|\|\s*(true|:)\s*$", hit.group(0)): + missing.append(f"{job}: {service} — отказ проглочен: {hit.group(0).strip()}") + assert not missing, ( + "пофайловые маунты без пути доезда: правка файла ляжет на диск, контейнер " + "останется на старом иноде, деплой зелёный:\n " + "\n ".join(missing) + ) + + +def test_gate_sees_the_known_mounts() -> None: + """Проверка на себя: маска обязана находить маунты, ради которых гейт заведён.""" + server = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics.yml")} + assert ("prometheus", "ops/metrics/prometheus/prometheus.yml") in server, server + assert ("loki", "ops/metrics/loki/loki-config.yml") in server, server + + +def test_gate_would_notice_a_mount_without_arrival() -> None: + """Сервис без пути доезда гейт обязан НЕ найти — иначе он зелёный по построению.""" + commands = _commands("server") + assert not _arrival( + commands, "gendesign-metrics", "docker-compose.metrics.yml", "nosuch", SRC, DST + ), "гейт нашёл путь доезда у несуществующего сервиса — маска слишком широкая" diff --git a/ops/metrics/recreate-stale-mount.sh b/ops/metrics/recreate-stale-mount.sh new file mode 100755 index 00000000..c27c840f --- /dev/null +++ b/ops/metrics/recreate-stale-mount.sh @@ -0,0 +1,65 @@ +#!/bin/sh +# Пересоздать контейнер, если он читает СТАРЫЙ инод пофайлового bind-маунта +# (#3467, #3486). +# +# sh ops/metrics/recreate-stale-mount.sh ПРОЕКТ COMPOSE-ФАЙЛ СЕРВИС ФАЙЛ-НА-ХОСТЕ ПУТЬ-В-КОНТЕЙНЕРЕ +# +# ЗАЧЕМ. `git reset --hard` пишет изменившийся файл НОВЫМ инодом, а bind-маунт +# одного файла держит прежний: контейнер продолжает читать старый текст, `up -d` +# этого не видит (сравнивает описание сервиса, а не содержимое маунта), и +# перезагрузка по API тоже — процесс перечитывает путь, за которым в контейнере +# всё тот же старый инод. Проверено на prom/prometheus:v3.1.0 (17.09): файл +# подменён, POST /-/reload → rc=0, а в /api/v1/status/config остался прежний +# job. Лечит только пересоздание контейнера — его и делаем, но лишь при +# расхождении, чтобы не рвать работу на каждом деплое. +# +# Та же ловушка уже ловилась на Alertmanager (27.08), Alloy (#3380) и Caddy +# (#3443, ops/caddy-apply.sh). Контейнер не запущен — старого инода держать +# некому (при старте маунт берётся с хоста заново), выходим с 0. Сервис +# выключенного профиля сюда передавать нельзя: `up` с явным именем включает +# профиль и поднял бы сервис без окружения — гейт по профилю у вызывающего. +# Сверка не прочиталась — считаем расхождением. После пересоздания сверяем +# снова: не сошлось — exit 1, а не зелёный деплой со старым конфигом. +set -eu + +if [ "$#" -ne 5 ]; then + echo "usage: $0 PROJECT COMPOSE_FILE SERVICE HOST_FILE CONTAINER_FILE" >&2 + exit 2 +fi +project=$1 compose_file=$2 service=$3 src=$4 dst=$5 + +cd "$(dirname "$0")/../.." + +# Ошибку `ps` НЕ глушим: пустой ответ от упавшего compose читался бы как +# «контейнер не запущен» — и зелёный выход со старым конфигом. +cid() { docker compose -p "$project" -f "$compose_file" ps -q "$service"; } +seen() { docker exec "$1" stat -c %i "$dst" 2>/dev/null || true; } + +id=$(cid) +if [ -z "$id" ]; then + echo "$service: контейнер не запущен — старый инод $src держать некому." + exit 0 +fi + +want=$(stat -c %i "$src") +if [ "$(seen "$id")" = "$want" ]; then + echo "$service: $src доехал до контейнера (инод $want)." + exit 0 +fi + +echo "$service: контейнер не видит текущий $src (инод на хосте $want) — пересоздаю." +docker compose -p "$project" -f "$compose_file" up -d --no-deps --force-recreate "$service" + +i=0 +while [ "$i" -lt 15 ]; do + id=$(cid) + if [ -n "$id" ] && [ "$(seen "$id")" = "$want" ]; then + echo "$service: пересоздан, $src доехал (инод $want)." + exit 0 + fi + i=$((i + 1)) + sleep 2 +done + +echo "::error::$service: после пересоздания контейнер всё ещё не видит $src (инод $want)" +exit 1 From ec85ea1861222300c8116041fadf57cbc0868255 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:43:48 +0500 Subject: [PATCH 15/47] =?UTF-8?q?fix(metrics):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0=20queries.yml=20=D0=B4=D0=BE=D0=B5=D0=B7=D0=B6?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D0=B4=D0=BE=20postgres-=D1=8D=D0=BA=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=D1=91=D1=80=D0=BE=D0=B2=20(#3486)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit queries.yml смонтирован трём экспортёрам одним файлом и читается только при старте, а агентские джобы деплоя пересоздавали лишь alloy: правка ложилась на диск новым инодом, экспортёры продолжали отдавать старые запросы при зелёном деплое. Разрыв латентный — 17.09 иноды хоста и контейнеров совпадают (Poincare 5112170, Beget 569352). agent-apps и agent-infra после подъёма сверяют инод queries.yml у своих экспортёров через ops/metrics/recreate-stale-mount.sh и пересоздают только при расхождении, под гейтом профиля. Гейт пофайловых маунтов теперь читает и docker-compose.metrics-agent.yml (сервис → джоба по профилю), а ci.yml запускает backend-тесты на правку этого файла. Co-Authored-By: Claude Opus 5 --- .forgejo/workflows/ci.yml | 4 ++++ .forgejo/workflows/deploy-metrics.yml | 22 +++++++++++++++++++ .../ops/test_metrics_single_file_mounts.py | 4 ++++ 3 files changed, 30 insertions(+) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 178e1246..b599c368 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -233,6 +233,10 @@ jobs: # уезжает в main зелёной. Ровно то, что осуждает комментарий выше. - '.forgejo/workflows/deploy-metrics.yml' - 'docker-compose.metrics.yml' + # #3486: гейт test_metrics_single_file_mounts.py читает и + # compose агентов — правка ТОЛЬКО его (новый пофайловый маунт у + # экспортёра) без этой строки прошла бы мимо гейта. + - 'docker-compose.metrics-agent.yml' # #3443: тот же класс, третий раз. Гейт # backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает # ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть diff --git a/.forgejo/workflows/deploy-metrics.yml b/.forgejo/workflows/deploy-metrics.yml index 5b77f2ad..01407644 100644 --- a/.forgejo/workflows/deploy-metrics.yml +++ b/.forgejo/workflows/deploy-metrics.yml @@ -599,6 +599,21 @@ jobs: [ "$(stat -c %i ops/metrics/alloy/alloy-apps.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \ || { echo "::error::alloy читает старый инод конфига"; exit 1; } + # ── postgres-экспортёры: queries.yml — бинд-маунт ОДНОГО файла (#3486) ── + # PG_EXPORTER_EXTEND_QUERY_PATH читается только при старте, а + # `git reset --hard` пишет правку новым инодом: без пересоздания + # экспортёр продолжает отдавать старые запросы при зелёном деплое. + # Пересоздаём только при расхождении инода. Под гейтом профиля: + # `up` с явным именем сервиса включает его профиль сам. + if [ -n "$EXPORTER_PROFILE" ]; then + METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \ + sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \ + postgres-exporter-gendesign ops/metrics/postgres/queries.yml /etc/pg-queries.yml + METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \ + sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \ + postgres-exporter-tradein ops/metrics/postgres/queries.yml /etc/pg-queries.yml + fi + agent-infra: runs-on: ubuntu-latest needs: server @@ -668,3 +683,10 @@ jobs: [ "$(stat -c %i ops/metrics/alloy/alloy-infra.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \ || { echo "::error::alloy читает старый инод конфига"; exit 1; } + + # queries.yml экспортёра инфраструктурной БД — см. agent-apps (#3486). + if [ -n "$EXPORTER_PROFILE" ]; then + METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \ + sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \ + postgres-exporter-infra ops/metrics/postgres/queries.yml /etc/pg-queries.yml + fi diff --git a/backend/tests/ops/test_metrics_single_file_mounts.py b/backend/tests/ops/test_metrics_single_file_mounts.py index 0cd8fb3f..91511daf 100644 --- a/backend/tests/ops/test_metrics_single_file_mounts.py +++ b/backend/tests/ops/test_metrics_single_file_mounts.py @@ -226,6 +226,7 @@ RENDERED = { MOUNT = re.compile(r"^\./(?P(?:\$\{[^}]*\}|[^:])+):(?P/[^:]+)") COMPOSE_FILES = { "docker-compose.metrics.yml": "gendesign-metrics", + "docker-compose.metrics-agent.yml": "gendesign-metrics-agent", } @@ -296,6 +297,9 @@ def test_gate_sees_the_known_mounts() -> None: server = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics.yml")} assert ("prometheus", "ops/metrics/prometheus/prometheus.yml") in server, server assert ("loki", "ops/metrics/loki/loki-config.yml") in server, server + agent = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics-agent.yml")} + for exporter in ("gendesign", "tradein", "infra"): + assert (f"postgres-exporter-{exporter}", "ops/metrics/postgres/queries.yml") in agent, agent def test_gate_would_notice_a_mount_without_arrival() -> None: From 8e5f097c5eaa929b216f4993ac091c91f401718d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:47:05 +0500 Subject: [PATCH 16/47] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D1=89=D0=B8=D0=BA:=20=D1=83=D0=BF=D0=B0=D0=B2?= =?UTF-8?q?=D1=88=D0=B8=D0=B9=20=D0=B4=D0=BE=20=D1=84=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=82=D0=BE=D1=80=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=D0=BD=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=D1=81?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D1=83,=20=D0=B0=20=D0=BD=D0=B5=20zombie=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=B5=D0=B7=206=20=D1=87=D0=B0=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=20(#1940)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Исключение, вылетевшее из хендлера до его try с mark_failed (прод: пустой пул прокси в BrowserFetcher.__aenter__ через 10 мс после claim), планировщик только логировал. Строка оставалась 'running' с heartbeat_at == started_at, reaper через 6 ч ставил 'zombie' — за 21 сутки так 18 avito-свипов (7349 и 7350 висят сейчас). _dispatch._run теперь финализирует прогон сам: пустой пул прокси в цепочке причин -> banned/ban_kind=infra (как в пайплайнах), остальное -> failed. Уже финализированную хендлером строку не трогает (WHERE status='running'), пустые counters мержатся и чекпоинт не стирают. Co-Authored-By: Claude Opus 5 --- .../test_1940_crashed_run_is_finalized.py | 133 ++++++++++++++++++ .../scraper_kit/orchestration/scheduler.py | 22 ++- 2 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/tests/test_1940_crashed_run_is_finalized.py diff --git a/tradein-mvp/backend/tests/test_1940_crashed_run_is_finalized.py b/tradein-mvp/backend/tests/test_1940_crashed_run_is_finalized.py new file mode 100644 index 00000000..447f6eb1 --- /dev/null +++ b/tradein-mvp/backend/tests/test_1940_crashed_run_is_finalized.py @@ -0,0 +1,133 @@ +"""Упавший хендлер не оставляет строку scrape_runs в 'running' до zombie-reaper'а (#1940). + +Прод 17.09 06:58: avito_city_sweep run 7349 — через 10 мс после claim BrowserFetcher.__aenter__ +бросил NoProxyAvailableError, планировщик записал «crashed run_id=7349» и всё. Строка +осталась 'running' с heartbeat_at == started_at; 21 сутки до этого так ушли в 'zombie' +18 avito-свипов, каждый через 6 ч. Проверяется значение статуса в строке прогона. +""" + +from __future__ import annotations + +import asyncio +import os +from typing import Any +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration.runs import BAN_KIND_INFRA +from scraper_kit.orchestration.scheduler import Handler, SchedulerContext, _dispatch +from scraper_kit.proxy_errors import NoProxyAvailableError + + +class _Db: + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + res = MagicMock() + res.fetchone.return_value = None # «не running» для гейта _claim_run + res.scalar.return_value = True # advisory-lock взят + return res + + def commit(self) -> None: + pass + + def rollback(self) -> None: + pass + + def close(self) -> None: + pass + + +class _Runs: + """ctx.runs с боевым гейтом `WHERE status = 'running'` и мержем counters.""" + + def __init__(self) -> None: + self.rows: dict[int, dict[str, Any]] = {} + + def create_run(self, db: Any, *, source: str, params: dict[str, Any]) -> int: + run_id = 7349 + len(self.rows) + self.rows[run_id] = {"status": "running", "counters": {}, "ban_kind": None} + return run_id + + def _finish(self, run_id: int, status: str, counters: dict[str, Any], **extra: Any) -> None: + row = self.rows[run_id] + if row["status"] != "running": + return + row.update(status=status, **extra) + row["counters"] = {**row["counters"], **counters} + + def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + self._finish(run_id, "done", counters) + + def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + self._finish(run_id, "failed", counters, error=error) + + def mark_banned( + self, db: Any, run_id: int, error: str, counters: dict[str, Any], *, ban_kind: str + ) -> None: + self._finish(run_id, "banned", counters, error=error, ban_kind=ban_kind) + + +async def _dispatch_and_wait(job: Any) -> dict[str, Any]: + runs = _Runs() + ctx = SchedulerContext( + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + session_factory=_Db, + runs=runs, + ) + sched = { + "id": 1, + "source": "avito_city_sweep", + "window_start_hour": 6, + "window_end_hour": 7, + "default_params": {}, + } + run_id = await _dispatch(Handler(job, "avito_city_sweep"), _Db(), sched, ctx) + assert run_id is not None + await asyncio.gather(*ctx._inflight_tasks, return_exceptions=True) + return runs.rows[run_id] + + +async def test_no_proxy_crash_before_try_marks_run_banned_infra() -> None: + async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None: + raise NoProxyAvailableError("avito") + + row = await _dispatch_and_wait(_job) + + assert row["status"] == "banned" + assert row["ban_kind"] == BAN_KIND_INFRA + assert "no proxy available" in row["error"] + + +async def test_wrapped_no_proxy_is_still_infra() -> None: + async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None: + try: + raise NoProxyAvailableError("cian") + except NoProxyAvailableError as exc: + raise RuntimeError("sidecar fetch failed") from exc + + row = await _dispatch_and_wait(_job) + + assert (row["status"], row["ban_kind"]) == ("banned", BAN_KIND_INFRA) + + +async def test_other_crash_marks_run_failed() -> None: + async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None: + raise KeyError("anchors") + + row = await _dispatch_and_wait(_job) + + assert row["status"] == "failed" + assert "KeyError" in row["error"] + + +async def test_handler_own_final_status_and_checkpoint_survive() -> None: + async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None: + c.runs.mark_done(db, run_id, {"done_buckets": ["center"]}) + raise RuntimeError("after mark_done") + + row = await _dispatch_and_wait(_job) + + assert row["status"] == "done" + assert row["counters"] == {"done_buckets": ["center"]} diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py index 7923efbe..47932091 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py @@ -57,6 +57,8 @@ from scraper_kit.orchestration.pipeline import ( run_domclick_city_sweep, run_yandex_city_sweep, ) +from scraper_kit.orchestration.runs import BAN_KIND_INFRA +from scraper_kit.proxy_errors import caused_by_no_proxy if TYPE_CHECKING: from sqlalchemy.orm import Session @@ -1024,8 +1026,26 @@ async def _dispatch( run_db = ctx.session_factory() try: await handler.job(run_db, run_id, params, ctx) - except Exception: + except Exception as exc: logger.exception("scheduler: %s crashed run_id=%d", handler.log_name, run_id) + # #1940: исключение, вылетевшее ДО try-финализатора хендлера (прод: пустой + # пул прокси в BrowserFetcher.__aenter__ через 10 мс после claim), оставляло + # строку 'running' без единого пульса — через 6 ч её снимал reaper как + # 'zombie', источник терял день. Финализируем здесь, в единственной точке, + # через которую идёт любой хендлер. Уже финализированную хендлером строку + # не трогаем: mark_* пишут только `WHERE status = 'running'`, а {} counters + # мержится и чекпоинт не стирает. + try: + if caused_by_no_proxy(exc): + ctx.runs.mark_banned( + run_db, run_id, f"crashed: {exc}", {}, ban_kind=BAN_KIND_INFRA + ) + else: + ctx.runs.mark_failed( + run_db, run_id, f"crashed: {type(exc).__name__}: {exc}", {} + ) + except Exception: + logger.exception("scheduler: не удалось финализировать run_id=%d", run_id) finally: run_db.close() From e42e370ac98f3773f084a790cc2dec6818ca7035 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:48:12 +0500 Subject: [PATCH 17/47] =?UTF-8?q?fix(tradein/avito):=20detail-=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB?= =?UTF-8?q?=D1=87=D0=B0=D0=BD=D0=B8=D1=8E=20=D0=B8=D0=B4=D1=91=D1=82=20?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D1=83=D0=B7=D0=B5=D1=80=D0=BE=D0=BC,=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20curl=20(#3045)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit curl_cffi не проходит QRATOR proof-of-work Авито в принципе (замер 21.08: ~6% обогащений против ~74% на браузере). Шаг 3 задачи был сделан только environment'ом сервиса scraper, дефолт в config.py остался True. Добор при этом исполняет и tradein-backend — ручка админки scrape_avito_detail_backfill вызывает run_avito_detail_backfill в своём процессе, и там printenv даёт true. Ручной прогон через админку шёл curl'ом. Дефолт avito_detail_backfill_use_curl -> False. Комментарий в задаче «true только у tradein-backend, который добор не запускает» был неверен — исправлен, как и два упоминания use_curl=True «прод-дефолтом». Тест по значению: Settings без env и без .env даёт False; контроль — env по-прежнему переключает на curl. Фальсификация: default=True -> «AssertionError: assert True is False». На проде хостовый backend/.env.runtime:37 задаёт true явно, поэтому для tradein-backend нужен ещё environment-оверрайд — он в коммите #3182. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/core/config.py | 28 ++++++++----------- .../app/tasks/avito_detail_backfill.py | 18 ++++++------ .../tests/tasks/test_avito_detail_backfill.py | 2 +- .../tests/test_3045_use_curl_default.py | 28 +++++++++++++++++++ 4 files changed, 48 insertions(+), 28 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3045_use_curl_default.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index d291f723..a6f5a5d8 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -1221,25 +1221,19 @@ class Settings(BaseSettings): cian_login_success_cookie: str = "DMIR_AUTH" cian_login_wait_ms: int = 4000 - # detail_backfill через curl_cffi+backconnect (mproxy) вместо браузера/auv. - # SERP (full_load/city_sweep) и detail_backfill делят один прокси-аккаунт auv - # (~5 параллельных коннектов); browser-фетч в backfill открывает десятки коннектов - # → cap превышается → HTTP 500 / краши. Backconnect (mproxy, авто-ротация, - # 1 коннект/запрос) развязывает прокси-аккаунты. - # True (дефолт) = curl_cffi через settings.scraper_proxy_url (backconnect mproxy). - # False = старое browser-поведение (BrowserFetcher/auv, как scraper_fetch_mode). - # ENV: AVITO_DETAIL_BACKFILL_USE_CURL. + # detail_backfill через curl_cffi вместо браузера. ENV: AVITO_DETAIL_BACKFILL_USE_CURL. + # False (дефолт) = BrowserFetcher (camoufox дожидается QRATOR PoW, #3045/#3046). + # True = curl_cffi через settings.scraper_proxy_url. # - # ⚠️ Обоснование выше УСТАРЕЛО для Авито (замер 2026-08-21). Авито за QRATOR - # отдаёт JS proof-of-work челлендж, который curl_cffi не решает: прогоны - # 4348/4394/4508 — 3-4 обогащённых из 46-53 попыток (~6%) против ~74% на - # браузерном пути. Опасение «browser превышает cap прокси-аккаунта auv» снято: - # браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect. - # Прод переведён на браузер через docker-compose.prod.yml (environment - # перекрывает env_file). Дефолт оставлен True, чтобы не менять поведение - # других окружений вслепую. + # Дефолт False (#3045): Авито за QRATOR отдаёт JS proof-of-work челлендж, который + # curl_cffi не решает в принципе — замер 2026-08-21, прогоны 4348/4394/4508: 3-4 + # обогащённых из 46-53 попыток (~6%) против ~74% на браузере. Старое обоснование + # curl (браузер превышал cap прокси-аккаунта auv) снято: браузер сериализован + # BROWSER_CONCURRENCY=1. Пока дефолт был True, браузер включался только + # environment'ом сервиса scraper, а tradein-backend (ручка админки + # scrape_avito_detail_backfill исполняет добор в своём процессе) шёл curl'ом. avito_detail_backfill_use_curl: bool = Field( - default=True, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL" + default=False, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL" ) # #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index dcbe755a..77332fa6 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -98,9 +98,9 @@ from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig # принимает config: ScraperConfig | None (Strangler-инъекция #2330) и пробрасывает # его в _build_detail_session() для settings.scraper_proxy_url (sticky МГТС-прокси), # зеркаля fetch_detail. Оба call site'а ниже передают config=RealScraperConfig() — -# без него warm-batch curl-путь (use_curl=True, ДЕФОЛТ прод-режима, -# avito_detail_backfill_use_curl=True) молча терял бы backconnect (прямое -# datacenter-подключение вместо sticky-прокси), тот же класс бага что #2322/#2310. +# без него warm-batch curl-путь (use_curl=True, avito_detail_backfill_use_curl) молча +# терял бы backconnect (прямое datacenter-подключение вместо sticky-прокси), тот же +# класс бага что #2322/#2310. logger = logging.getLogger(__name__) @@ -232,12 +232,10 @@ async def _rotate_current_proxy( settings.scraper_proxy_url БЕЗ lease — там ротировать нечего, вызывающий цикл не зовёт эту функцию в том режиме вовсе. - Прод при этом в browser-режиме, а не в curl: у контейнера tradein-scraper (там же - живёт планировщик) проверено AVITO_DETAIL_BACKFILL_USE_CURL=false при - SCRAPER_FETCH_MODE=browser и USE_PROXY_POOL_BROWSER=true, так что ротация - активна. Значение true стоит только у tradein-backend, который добор не запускает. - Комментарий ниже по файлу (~строка 653) называет use_curl=True «прод-дефолтом» — - это предсуществующее заблуждение, а не описание текущего прода. + Прод в browser-режиме, а не в curl: дефолт avito_detail_backfill_use_curl=False + (#3045). Добор исполняют ДВА процесса — планировщик в tradein-scraper и ручка + админки scrape_avito_detail_backfill в tradein-backend, — поэтому режим задаётся + дефолтом кода, а не environment'ом одного сервиса. Отказ провайдера (лимит исчерпан, нет rotate_url, сетевой сбой, неизвестный хост) НЕ должен ронять прогон — логируем и продолжаем на текущем адресе. Эта попытка НЕ @@ -705,7 +703,7 @@ async def run_avito_detail_backfill( # config.scraper_proxy_url=None для backconnect-gate'а # (elif not use_curl own-session path), теряя reconnect- # on-403 поведение legacy (settings.scraper_proxy_url - # напрямую). Не влияет на use_curl=True (прод-дефолт) — + # напрямую). Не влияет на use_curl=True — # там reconnect_on_block=False уже гасит backconnect. config=RealScraperConfig(), origin=serp_origin, diff --git a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py index 9e963679..e2c89336 100644 --- a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py @@ -194,7 +194,7 @@ async def test_backfill_build_warmed_session_receives_config() -> None: """#2397 Part D1 / #2330 regression guard: kit's build_warmed_session() now accepts config=ScraperConfig and silently drops settings.scraper_proxy_url (sticky МГТС-прокси) -- NO crash, just a proxy-less session -- unless config=RealScraperConfig() is passed - at the call site. use_curl=True is the prod-default path (avito_detail_backfill_use_curl), + at the call site. use_curl=True is the curl path (avito_detail_backfill_use_curl), so this is the setup-time build_warmed_session() call (~line 177). assert_awaited_once() alone wouldn't catch someone dropping the kwarg later (mirrors the fetch_detail config= guard above, lines 141-148).""" diff --git a/tradein-mvp/backend/tests/test_3045_use_curl_default.py b/tradein-mvp/backend/tests/test_3045_use_curl_default.py new file mode 100644 index 00000000..893d740b --- /dev/null +++ b/tradein-mvp/backend/tests/test_3045_use_curl_default.py @@ -0,0 +1,28 @@ +"""#3045: дефолт avito_detail_backfill_use_curl — браузер, а не curl. + +curl_cffi не решает QRATOR PoW Авито (замер 21.08: ~6% обогащений против ~74% на +браузере). Пока дефолт был True, браузер включал только environment сервиса scraper, +а tradein-backend (ручка админки scrape_avito_detail_backfill исполняет добор в своём +процессе) шёл curl'ом. Проверка по значению поля Settings без env и без .env-файла. +""" + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest + +from app.core.config import Settings + + +def test_detail_backfill_defaults_to_browser(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("AVITO_DETAIL_BACKFILL_USE_CURL", raising=False) + + assert Settings(_env_file=None).avito_detail_backfill_use_curl is False + + +def test_env_still_switches_to_curl(monkeypatch: pytest.MonkeyPatch) -> None: + """Контроль: переменная окружения по-прежнему переключает режим — поле живое.""" + monkeypatch.setenv("AVITO_DETAIL_BACKFILL_USE_CURL", "true") + + assert Settings(_env_file=None).avito_detail_backfill_use_curl is True From facdbb256626c1a308558b82bbd3658cc7884de4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:48:20 +0500 Subject: [PATCH 18/47] =?UTF-8?q?=D0=A1=D0=B2=D0=B8=D0=BF=D1=8B=20=D0=A6?= =?UTF-8?q?=D0=B8=D0=B0=D0=BD=D0=B0=20=D0=B8=20=D0=90=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D0=BE:=20=D1=8F=D0=BA=D0=BE=D1=80=D1=8C=20=D1=81=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D0=BE=D1=81=D1=82=D1=8C=D1=8E=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=B0=D0=B2=D1=88=D0=B5=D0=B9=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B7=D0=BE=D0=B9=20=D0=BD=D0=B5=20=D0=BF=D0=BE=D0=BF=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D0=B5=D1=82=20=D0=B2=20=D1=87=D0=B5=D0=BA=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D0=BD=D1=82=20(#3415)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cian_city_sweep 6179 (06.09) — failed по «фаза houses отказала полностью, 30 из 30», но в done_buckets все пять якорей. 6276 резюмировался от него, пропустил все якоря и отчитался done с houses_attempted=0, lots_fetched=0. Отметка «якорь пройден» опиралась только на поток управления, а фаза отказывает и без исключения (fetch_newbuilding -> None, растёт счётчик). Гейт _bucket_phase_totally_failed сверяет прирост пар X_attempted/X_failed за якорь; порог одна попытка. Тот же гейт в run_avito_city_sweep (detail). Ветка «houses DB query failed» теперь растит houses_attempted вместе с houses_failed. Форма чекпоинта прежняя (плоский список имён). Перенос ветки fix/3415-bucket-done-only-if-phases-ok (19a335f4) на свежий main, применилась без конфликтов. Co-Authored-By: Claude Opus 5 --- ...test_3415_bucket_done_only_if_phases_ok.py | 271 ++++++++++++++++++ .../src/scraper_kit/orchestration/pipeline.py | 94 +++++- 2 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3415_bucket_done_only_if_phases_ok.py diff --git a/tradein-mvp/backend/tests/test_3415_bucket_done_only_if_phases_ok.py b/tradein-mvp/backend/tests/test_3415_bucket_done_only_if_phases_ok.py new file mode 100644 index 00000000..f74aaaa9 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3415_bucket_done_only_if_phases_ok.py @@ -0,0 +1,271 @@ +"""Бакет уходит в чекпоинт только если ВСЕ его фазы что-то дали (#3415). + +Прод-факт. `cian_city_sweep` 6179 (06.09) — статус `failed` с причиной +«phase-honest-status: фаза 'houses' отказала полностью — 30 из 30 попыток +неудачны, обогащено 0», а `counters.done_buckets` при этом несёт ВСЕ пять якорей +(«Академический», «Пионерский», «Уралмаш», «Центр», «ЮЗ»). Следующий прогон 6276 +(07.09) резюмировался от него (`resume_from: 6179`, `resume_buckets: 5`), +пропустил все якоря и отчитался `done` с `errors_count=0`, `houses_attempted=0`, +`lots_fetched=0` — зелёный по построению: фаза не исполнялась. Из-за этого фикс +#3396 (houses через пул прокси) простоял на проде с 06.09 и ни разу не отработал. + +ИНВАРИАНТ. Отказ ЦЕЛОЙ фазы внутри якоря — это «якорь пройден не был», ровно как +исключение или таймаут (#3074/#3319). Разница только в том, что отказ фазы виден +не потоком управления, а бухгалтерией счётчиков: `houses_attempted` попыток, +`houses_failed` отказов, обогащено ноль. Порог здесь — одна попытка, а не три, +как у run-level `_phase_totally_failed` (#2700): тот СТАВИТ ДИАГНОЗ прогону и +обязан отсеивать шум, а этот решает «собирать ли якорь заново», и цена ошибок +несимметрична — лишний повтор якоря стоит нескольких запросов, а ложная отметка +«пройден» теряет часть города навсегда и молча. +""" + +from __future__ import annotations + +import os + +# Settings собирается автофикстурой conftest'а и требует database_url. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import json +import types +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +ANCHOR_A = (56.83, 60.60, "ekb-center") +ANCHOR_B = (56.79, 60.63, "ekb-south") + +# Два дома на якорь — НИЖЕ порога run-level правила (_PHASE_MIN_ATTEMPTS = 3): +# гейт бакета обязан сработать на своей, более строгой границе. +HOUSE_ROWS = [ + {"id": 101, "cian_zhk_url": "https://cian.ru/zhk/101"}, + {"id": 102, "cian_zhk_url": "https://cian.ru/zhk/102"}, +] +DETAIL_ROWS = [ + {"id": 201, "source_url": "/ekb/kvartiry/201"}, + {"id": 202, "source_url": "/ekb/kvartiry/202"}, +] + + +class _FakeDb: + def __init__(self, prev_counters: dict[str, Any] | None = None) -> None: + self.prev_counters = prev_counters or {} + self.heartbeats: list[dict[str, Any]] = [] + + def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any: + if params and "counters" in params: + self.heartbeats.append(json.loads(params["counters"])) + return MagicMock() + if params and "rid" in params: + return MagicMock(fetchone=lambda: types.SimpleNamespace(counters=self.prev_counters)) + if params and "ids" in params: + # houses-фаза: дома, у которых есть cian_zhk_url. + return MagicMock(mappings=lambda: MagicMock(all=lambda: HOUSE_ROWS)) + if params and "limit" in params: + # detail-фаза avito: карточки-кандидаты на обогащение. + return MagicMock(mappings=lambda: MagicMock(all=lambda: DETAIL_ROWS)) + return MagicMock() + + def commit(self) -> None: ... + def rollback(self) -> None: ... + + +def _lot() -> types.SimpleNamespace: + """Новостроечный лот с привязкой к ЖК — только такой доходит до houses-фазы.""" + return types.SimpleNamespace( + listing_segment="novostroyki", + house_source="cian_newbuilding", + house_ext_id="nb-1", + ) + + +class _FakeScraper: + """Двойник CianScraper: помнит, за какими якорями реально ходили.""" + + visited: list[str] = [] # noqa: RUF012 — тестовый сборник + + def __init__(self, *_a: Any, **_kw: Any) -> None: + self.state_extraction_attempts = 1 + self.state_extraction_failures = 0 + self.request_delay_sec = 0.0 + + async def __aenter__(self) -> _FakeScraper: + return self + + async def __aexit__(self, *_e: Any) -> None: + return None + + async def fetch_around_multi_room( + self, lat: float, lon: float, *_a: Any, **_kw: Any + ) -> list[types.SimpleNamespace]: + _FakeScraper.visited.append(f"{lat},{lon}") + return [_lot()] + + +def _config() -> types.SimpleNamespace: + return types.SimpleNamespace( + scraper_proxy_url=None, + scraper_fetch_mode="cffi", + use_proxy_pool_browser=False, + browser_http_endpoint=None, + environment="test", + ) + + +async def _run( + prev: dict[str, Any] | None, *, houses_ok: bool, run_id: int = 8401 +) -> tuple[_FakeDb, Any]: + from scraper_kit.orchestration import pipeline as pl + + _FakeScraper.visited = [] + db = _FakeDb(prev) + + async def _fake_newbuilding(*_a: Any, **_kw: Any) -> Any: + # None — ровно тот отказ, что был на проде 06.09: исключения нет, + # счётчик houses_failed растёт, фаза возвращается штатно. + return object() if houses_ok else None + + with ( + patch.object(pl, "CianScraper", _FakeScraper), + patch.object(pl, "save_listings", lambda *_a, **_kw: (0, 0)), + patch.object(pl, "fetch_newbuilding", _fake_newbuilding), + patch.object(pl, "save_newbuilding_enrichment", lambda *_a, **_kw: None), + patch.object(pl.runs, "is_cancelled", lambda *_a: False), + ): + counters = await pl.run_cian_city_sweep( + db, # type: ignore[arg-type] + run_id=run_id, + config=_config(), + matcher=MagicMock(), + anchors=[ANCHOR_A, ANCHOR_B], + enrich_houses=True, + detail_top_n=0, + request_delay_sec=0.0, + resume_run_id=(run_id - 1) if prev is not None else None, + ) + return db, counters + + +def _last_checkpoint(db: _FakeDb) -> list[str]: + with_ckpt = [hb for hb in db.heartbeats if "done_buckets" in hb] + assert with_ckpt, "ни один heartbeat не унёс done_buckets — чекпоинт не персистится" + return with_ckpt[-1]["done_buckets"] + + +@pytest.mark.asyncio +async def test_anchor_with_totally_failed_houses_is_not_checkpointed() -> None: + """(а) Якорь, где houses отказала на ВСЕХ домах, не попадает в чекпоинт.""" + db, counters = await _run(None, houses_ok=False) + assert counters.houses_attempted > 0, "houses-фаза вообще не исполнялась — тест не о том" + assert counters.houses_enriched == 0, "houses-фаза что-то обогатила — отказ не полный" + assert _last_checkpoint(db) == [], ( + "якорь с полностью отказавшей houses-фазой помечен пройденным — " + f"чекпоинт {_last_checkpoint(db)}" + ) + + +@pytest.mark.asyncio +async def test_next_run_re_enters_houses() -> None: + """(б) Следующий прогон с этим чекпоинтом снова заходит в houses.""" + db1, _ = await _run(None, houses_ok=False) + _db2, counters2 = await _run( + {"done_buckets": _last_checkpoint(db1)}, houses_ok=True, run_id=8402 + ) + assert counters2.houses_attempted > 0, ( + "прогон-наследник пропустил houses-фазу: чекпоинт предшественника " + "объявил якоря пройденными, хотя фаза в них не дала ничего" + ) + assert counters2.houses_enriched > 0, "houses-фаза исполнилась, но ничего не обогатила" + + +@pytest.mark.asyncio +async def test_healthy_anchor_is_still_checkpointed() -> None: + """(в) Контроль: якорь, где все фазы отработали, помечается как раньше.""" + db, counters = await _run(None, houses_ok=True) + assert counters.houses_enriched > 0 + assert _last_checkpoint(db) == ["ekb-center", "ekb-south"], ( + "перестали помечать пройденные якоря вовсе — резюм (#3074) сломан" + ) + + +class _FakeAvitoScraper: + """Двойник AvitoScraper: SERP отдаёт лоты, дальше работает detail-фаза.""" + + def __init__(self, *_a: Any, **_kw: Any) -> None: + self._browser = None + self._cffi = None + + async def fetch_around(self, *_a: Any, **_kw: Any) -> list[types.SimpleNamespace]: + return [types.SimpleNamespace(house_url=None)] + + +class _FakeAsyncSession: + def __init__(self, *_a: Any, **_kw: Any) -> None: ... + + async def __aenter__(self) -> _FakeAsyncSession: + return self + + async def __aexit__(self, *_e: Any) -> None: + return None + + +@pytest.mark.asyncio +async def test_avito_anchor_with_totally_failed_detail_is_not_checkpointed() -> None: + """Сосед: у avito-свипа тот же гейт — фаза без единой удачи не даёт отметки. + + Кода общего у свипов нет (две отдельные функции), общий только гейт. Прод-следа + у avito за 45 суток нет (0 прогонов против 4 у циана) — тест сторожит вторую + копию проводки, а не найденный в данных дефект. + """ + from scraper_kit.orchestration import pipeline as pl + + db = _FakeDb() + + async def _boom(*_a: Any, **_kw: Any) -> Any: + raise RuntimeError("detail отдал 403") + + with ( + patch.object(pl, "AvitoScraper", _FakeAvitoScraper), + patch.object(pl, "AsyncSession", _FakeAsyncSession), + patch.object(pl, "save_listings", lambda *_a, **_kw: (0, 0)), + patch.object(pl, "fetch_detail", _boom), + patch.object(pl.runs, "is_cancelled", lambda *_a: False), + ): + counters = await pl.run_avito_city_sweep( + db, # type: ignore[arg-type] + run_id=8404, + config=types.SimpleNamespace( + scraper_fetch_mode="cffi", + scraper_proxy_url=None, + use_proxy_pool_browser=False, + browser_http_endpoint=None, + environment="test", + avito_serp_ok_not_banned=True, + ), + matcher=MagicMock(), + enrichment=MagicMock(), + anchors=[ANCHOR_A], + enrich_houses=False, + enrich_imv=False, + detail_top_n=len(DETAIL_ROWS), + request_delay_sec=0.0, + ) + + assert counters.detail_attempted == len(DETAIL_ROWS) + assert counters.detail_enriched == 0 + assert _last_checkpoint(db) == [], ( + f"якорь с полностью отказавшей detail-фазой помечен пройденным: {_last_checkpoint(db)}" + ) + + +@pytest.mark.asyncio +async def test_old_format_checkpoint_still_resumes() -> None: + """(г) Чекпоинт СТАРОГО формата (плоский список имён якорей) читается как раньше.""" + db, _ = await _run({"done_buckets": ["ekb-center"]}, houses_ok=True, run_id=8403) + assert f"{ANCHOR_A[0]},{ANCHOR_A[1]}" not in _FakeScraper.visited, ( + "якорь из унаследованного чекпоинта всё-таки опрашивали" + ) + assert _last_checkpoint(db) == ["ekb-center", "ekb-south"], ( + "унаследованный якорь потерян — формат чекпоинта разъехался со старыми прогонами" + ) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index f9ad6e60..4fae93a3 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -36,7 +36,7 @@ from __future__ import annotations import asyncio import logging import random -from collections.abc import Callable +from collections.abc import Callable, Mapping from contextlib import AsyncExitStack from dataclasses import dataclass, field, fields from datetime import date, timedelta @@ -1346,6 +1346,43 @@ async def run_avito_pipeline( await browser_fetcher.__aexit__(None, None, None) +def _bucket_phase_totally_failed(before: Mapping[str, int], after: Mapping[str, int]) -> str | None: + """Фаза бакета, где отказала КАЖДАЯ попытка (#3415). Имя фазы или None. + + Считает ПРИРОСТ счётчиков за один бакет (якорь): counters у свипа run-level, + так что «фаза этого якоря» существует только как разница до/после. + + Прод-факт, ради которого гейт заведён: `cian_city_sweep` 6179 (06.09) — + `houses_attempted=30, houses_failed=30, houses_enriched=0`, статус `failed` по + run-level правилу (`_phase_totally_failed`, #2700), а в `done_buckets` записаны + все пять якорей. Прогон 6276 (07.09) унаследовал точку, пропустил все якоря и + отчитался `done` с `houses_attempted=0` — зелёный по построению, потому что фаза + не исполнялась. Фикс #3396 (houses через пул прокси) простоял на проде сутки, ни + разу не отработав. + + Пары ищутся В САМИХ counters (ключ `X_attempted` со спутником `X_failed`) — тот же + приём и та же причина, что у `runs._phase_totally_failed`: зашитый список фаз это + ровно то место, куда забывают дописать новую. Фазы без счётчика попыток гейт НЕ + видит — у avito houses роль attempted играет `unique_houses` (нет `houses_attempted`), + и притягивать её сюда значило бы блокировать отметку якоря из-за ОДНОГО упавшего + дома из десяти. + + Порог — одна попытка, а не три, как у run-level правила: тот ставит прогону + диагноз и обязан отсеивать шум, а этот решает «собирать ли якорь заново». Цена + ошибок несимметрична: лишний повтор якоря стоит нескольких запросов, ложная + отметка «пройден» теряет часть города навсегда и молча (прогон-то `done`). + """ + for key in sorted(after): + if not key.endswith("_attempted"): + continue + phase = key[: -len("_attempted")] + attempted = after[key] - before.get(key, 0) + failed = after.get(f"{phase}_failed", 0) - before.get(f"{phase}_failed", 0) + if attempted >= 1 and failed >= attempted: + return phase + return None + + @dataclass class CitySweepCounters: """Aggregate counters для full city sweep run.""" @@ -1629,6 +1666,12 @@ async def run_avito_city_sweep( # #3074: сбрасывается на КАЖДОЙ итерации — иначе один упавший якорь # заразил бы все последующие, и чекпоинт не пополнялся бы вовсе. _anchor_ok = True + # #3415: снимок счётчиков ДО якоря — по разнице видно, дала ли + # каждая фаза ЭТОГО якоря хоть одну удачную попытку (см. + # _bucket_phase_totally_failed). Тот же дефект, что у cian-свипа: + # detail-фаза, отказавшая на всех карточках без исключения, доходила + # до отметки «якорь пройден» наравне с целым якорем. + _before_anchor = counters.to_dict() # Capture loop variables in default args (B023): prevents stale binding # if the coroutine is scheduled after the loop variable changes. @@ -2137,6 +2180,22 @@ async def run_avito_city_sweep( # его как пройденный значило бы, что следующий прогон его пропустит и # объявления оттуда не соберутся НИКОГДА, причём молча: прогон # завершится штатно. Тот же инвариант, что у combo в yandex-свипе. + # + # #3415: флага мало — фаза отказывает и БЕЗ исключения (каждая + # detail-карточка отдала 403, счётчик вырос, корутина завершилась + # штатно). Замер на проде за 45 суток: у avito-свипов такой якорь + # пока не встречался (0 прогонов), у cian — 4; гейт стоит здесь + # потому, что дефект один и тот же, а не по следу в данных. + _failed_phase = _bucket_phase_totally_failed(_before_anchor, counters.to_dict()) + if _failed_phase is not None: + logger.warning( + "city-sweep run_id=%d: якорь %s НЕ помечен пройденным — фаза '%s' " + "не дала ни одной удачной попытки; следующий прогон соберёт его заново", + run_id, + name, + _failed_phase, + ) + _anchor_ok = False if _anchor_ok: _done_anchors.add(name) runs.update_heartbeat(db, run_id, _ckpt()) @@ -3313,6 +3372,10 @@ async def run_cian_city_sweep( ) # ── Per-anchor phases под watchdog-таймаутом ──────────────────── + # #3415: counters у свипа run-level — снимок ДО якоря даёт бухгалтерию + # его собственных фаз (разницей), по которой ниже решается, считать ли + # якорь пройденным. + _before_anchor = counters.to_dict() anchor_lots: list[ScrapedLot] = [] _c_lat, _c_lon, _c_name = lat, lon, name @@ -3529,6 +3592,12 @@ async def run_cian_city_sweep( len(nb_id_list), exc, ) + # #3415: попытки считаем ВМЕСТЕ с отказами. Раньше здесь рос + # только `houses_failed`, и пара получалась несуществующей + # (attempted=0, failed=N): и run-level `_phase_totally_failed` + # (#2700), и гейт бакета сверяют `failed` с `attempted`, так что + # полный отказ фазы на этой ветке не видел ни один из них. + counters.houses_attempted += len(nb_id_list) counters.houses_failed += len(nb_id_list) try: db.rollback() @@ -3720,7 +3789,28 @@ async def run_cian_city_sweep( # Записать упавший якорь пройденным значило бы, что следующий прогон # пропустит его навсегда — молча, потому что прогон завершится # штатно, просто часть города не соберётся. - _done_anchors.add(name) + # + # #3415: потока управления мало. Фаза внутри якоря отказывает БЕЗ + # исключения — `fetch_newbuilding` возвращает None, счётчик растёт, + # `_cian_anchor_phases` завершается штатно, — и такой якорь доходил + # сюда наравне с целым. Дальше ровно тот же исход, что и у записанного + # упавшего: следующий прогон пропускает якорь и рапортует `done` с + # нулевой фазой. Форма отметки прежняя (плоский список имён): чекпоинт + # читают ещё три свипа и `scheduler._resume_decision`, а ключ + # `bucket:phase` потребовал бы новых читателей ради того же решения. + _failed_phase = _bucket_phase_totally_failed(_before_anchor, counters.to_dict()) + if _failed_phase is None: + _done_anchors.add(name) + else: + logger.warning( + "cian-sweep run_id=%d: якорь %s НЕ помечен пройденным — фаза '%s' " + "не дала ни одной удачной попытки; следующий прогон соберёт его заново", + run_id, + name, + _failed_phase, + ) + # Heartbeat пишем в любом случае: без него reap_zombies посчитает живой + # прогон мёртвым, а jsonb-мерж сохранит унаследованную часть точки. runs.update_heartbeat( db, run_id, {**counters.to_dict(), "done_buckets": sorted(_done_anchors)} ) From 7218c2094c0c21c6e42b651d020bb062328b3714 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:49:33 +0500 Subject: [PATCH 19/47] =?UTF-8?q?=D0=91=D1=8D=D0=BA=D0=B0=D0=BF=D1=8B:=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=D1=86=D1=8B=20env=20=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D1=83=D1=82=20=D0=B0=D0=BB=D0=B5=D1=80=D1=82=D1=8B=20?= =?UTF-8?q?=D0=B2=20=D1=82=D0=B5=D0=BC=D1=83=20=C2=ABMetrics=C2=BB,=20?= =?UTF-8?q?=D0=BC=D1=91=D1=80=D1=82=D0=B2=D1=8B=D0=B9=20uptime-=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=BE=D0=B6=20=D1=83=D0=B4=D0=B0=D0=BB=D1=91?= =?UTF-8?q?=D0=BD=20(#3164)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тема форума для уведомлений бэкапов задаётся только env-файлом на хосте, а в образцах её не было вовсе. На проде она задана, но не та: 158 («алерты») в /opt/gendesign/secrets/backup-notify.env и forgejo-backup.env на Beget и в /etc/default/gendesign-backup на Poincare. По решению #3163 инфраструктура идёт в 245 «Metrics». Значение на хостах этот коммит не меняет. - ops/gendesign-backup*.default.example: строка #TELEGRAM_TOPIC_ID=245 с причиной и ловушкой: тема обязана лежать в одном файле с токеном и чатом, иначе notify() её не прочитает. - ops/crontab-beget.cron сверен с живым crontab Beget: сторожа и бэкап волта получают BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env. Без него переустановка crontab из репозитория глушила бы алерты бэкапов на Beget. - ops/uptime-healthcheck.sh и его образец удалены: скрипт не запущен ни на одном хосте (crontab, cron.d, таймеры), доступность сторожат uptime-мониторы GlitchTip на Beget (gendsgn.ru, /health, meraocenka.ru — раз в 60 с). Тест исполняет настоящий check-backup-staleness.sh с образцом, заполненным по инструкции, и проверяет адрес в вызове curl: message_thread_id=245. Co-Authored-By: Claude Opus 5 --- .forgejo/workflows/deploy.yml | 2 +- backend/tests/ops/test_2203_notify_topic.py | 79 ++++++++- backend/tests/ops/test_3059_alert_retry.py | 18 +- ops/check-backup-staleness.sh | 12 +- ops/crontab-beget.cron | 10 +- ops/gendesign-backup-couchdb.default.example | 5 + ops/gendesign-backup-forgejo.default.example | 11 ++ ops/gendesign-backup.default.example | 16 +- ops/gendesign-uptime.default.example | 28 --- ops/lib-backup.sh | 19 +-- ops/selectel-bootstrap.sh | 2 +- ops/uptime-healthcheck.sh | 171 ------------------- 12 files changed, 122 insertions(+), 251 deletions(-) delete mode 100644 ops/gendesign-uptime.default.example delete mode 100755 ops/uptime-healthcheck.sh diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 7e2b4a2e..fb28e90c 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -77,7 +77,7 @@ on: # исполнял бы старую версию — молча и без единого сигнала. # Глоб, а не точечный список (#2203): класс бага — «любой ops-скрипт, # запускаемый по cron с VM», не только docker-prune.sh. Сейчас сюда попадают - # backup.sh, restore-drill.sh, restore.sh, uptime-healthcheck.sh — точечное + # backup.sh, restore-drill.sh, restore.sh — точечное # перечисление пришлось бы дополнять при каждом новом скрипте, и про это # снова забыли бы (см. как этот самый комментарий выше был точечным про # docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком. diff --git a/backend/tests/ops/test_2203_notify_topic.py b/backend/tests/ops/test_2203_notify_topic.py index cad0f108..68557413 100644 --- a/backend/tests/ops/test_2203_notify_topic.py +++ b/backend/tests/ops/test_2203_notify_topic.py @@ -1,4 +1,4 @@ -"""Оповещения уходят в тему «алерты», а не в общую (#2203/#3078). +"""Оповещения уходят в тему форума, а не в общую (#2203/#3078/#3164). Прод-факт, 27.08. Канал доставки включили, `notify()` заработал — и алерты посыпались в ОБЩУЮ тему форума (в чат переговорки), а не в «алерты». Причина: в @@ -16,9 +16,7 @@ реально ушло бы в сеть. Функция извлекается из файла построчно, а не копируется в тест: копия разошлась -бы с оригиналом на первой же правке. Сорсить файл целиком нельзя — у -`uptime-healthcheck.sh` нет guard'а по `BASH_SOURCE`, и сорсинг запустил бы -настоящие сетевые проверки. +бы с оригиналом на первой же правке. """ from __future__ import annotations @@ -34,7 +32,6 @@ import pytest REPO_ROOT = Path(__file__).resolve().parents[3] SENDERS = { "lib-backup.sh": REPO_ROOT / "ops" / "lib-backup.sh", - "uptime-healthcheck.sh": REPO_ROOT / "ops" / "uptime-healthcheck.sh", } FAKE_CURL = '#!/usr/bin/env bash\nprintf "%s\\n" "$@" >> "$ARGS_DUMP"\nexit 0\n' @@ -105,7 +102,7 @@ def _run_notify(tmp_path: Path, script: Path, *, topic: str | None) -> list[str] f'ARGS_DUMP="{_posix(dump)}"', 'TELEGRAM_BOT_TOKEN="123:FAKE"', 'TELEGRAM_CHAT_ID="-1004443088679"', - f"BACKUP_ENV_FILE=\"{_posix(tmp_path / 'missing.env')}\"", + f'BACKUP_ENV_FILE="{_posix(tmp_path / "missing.env")}"', ] if topic is not None: prefix.append(f'TELEGRAM_TOPIC_ID="{topic}"') @@ -153,3 +150,73 @@ def test_adres_i_tekst_na_meste(tmp_path: Path, name: str) -> None: args = _run_notify(tmp_path, SENDERS[name], topic="158") assert "chat_id=-1004443088679" in args, f"{name}: потерялся chat_id" assert any("тестовое сообщение" in a for a in args), f"{name}: потерялся текст" + + +# #3164. Решение владельца в #3163: инфраструктура — в тему «Metrics» (245), +# клиентские инциденты — в «алерты» (158). Бэкапы — инфраструктура. Тема живёт +# только в env-файле на хосте, поэтому образец — единственное место в репозитории, +# откуда следующий, кто заводит хост, узнает правильный адрес. +INFRA_TOPIC = "245" +BACKUP_ENV_EXAMPLES = [ + "gendesign-backup.default.example", + "gendesign-backup-couchdb.default.example", + "gendesign-backup-forgejo.default.example", +] + + +@pytest.mark.parametrize("example", BACKUP_ENV_EXAMPLES) +def test_host_po_obraztsu_shlyot_bekapy_v_metriki(tmp_path: Path, example: str) -> None: + """Хост, заведённый по образцу, шлёт алерт бэкапа в «Metrics», а не мимо. + + Исполняется настоящий сторож `check-backup-staleness.sh` с отсутствующим + маркером, а env-файл — образец, заполненный так, как он велит человеку: + раскомментированы строки `TELEGRAM_*`, подставлены токен и чат. + + Ловит и второе: тема обязана лежать В ТОМ ЖЕ файле, что токен и чат. + `notify()` читает `BACKUP_ENV_FILE`, только если токена или чата нет в + окружении, — тема из другого файла молча не подхватится. + """ + text = (REPO_ROOT / "ops" / example).read_text(encoding="utf-8") + filled = re.sub(r"(?m)^#(TELEGRAM_\w+=)", r"\1", text) + filled = re.sub(r"(?m)^TELEGRAM_BOT_TOKEN=.*$", "TELEGRAM_BOT_TOKEN=123:FAKE", filled) + filled = re.sub(r"(?m)^TELEGRAM_CHAT_ID=.*$", "TELEGRAM_CHAT_ID=-1004443088679", filled) + env_file = tmp_path / "backup.env" + env_file.write_text(filled, encoding="utf-8", newline="\n") + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake = bin_dir / "curl" + fake.write_text(FAKE_CURL, encoding="utf-8", newline="\n") + fake.chmod(0o755) + dump = tmp_path / "args.txt" + + # Пустые TELEGRAM_* префиксом: переменные из окружения разработчика иначе + # перебили бы файл, и тест проверял бы не образец. + prefix = [ + f'PATH="{_posix(bin_dir)}:$PATH"', + f'ARGS_DUMP="{_posix(dump)}"', + 'TELEGRAM_BOT_TOKEN="" TELEGRAM_CHAT_ID="" TELEGRAM_TOPIC_ID=""', + f'BACKUP_ENV_FILE="{_posix(env_file)}"', + f'BACKUP_STALENESS_STATE_FILE="{_posix(tmp_path / "state")}"', + ] + script = REPO_ROOT / "ops" / "check-backup-staleness.sh" + proc = subprocess.run( + [ + "bash", + "-c", + " ".join(prefix) + + f' bash "{_posix(script)}" "{_posix(tmp_path / "no-sentinel")}" 26 "topic probe"', + ], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + timeout=30, + env=dict(os.environ), + ) + args = dump.read_text(encoding="utf-8").splitlines() if dump.exists() else [] + assert args, f"{example}: curl не был вызван.\n{proc.stdout}\n{proc.stderr}" + threads = [a for a in args if a.startswith("message_thread_id=")] + assert threads == [f"message_thread_id={INFRA_TOPIC}"], ( + f"{example}: алерт бэкапа адресован {threads or 'в общую тему'}, " + f"а по #3163 инфраструктура идёт в «Metrics» ({INFRA_TOPIC})" + ) diff --git a/backend/tests/ops/test_3059_alert_retry.py b/backend/tests/ops/test_3059_alert_retry.py index 071080c1..b2390eea 100644 --- a/backend/tests/ops/test_3059_alert_retry.py +++ b/backend/tests/ops/test_3059_alert_retry.py @@ -14,7 +14,9 @@ Poincare, 40 подключений к ЗАКРЕПЛЁННОМУ (#3093) 149.15 Как это ломало алерты: -* `ops/uptime-healthcheck.sh` — ОДИН `curl`, дальше `|| log WARN`. Каждый +* `ops/uptime-healthcheck.sh` (удалён в #3164: ни на одном хосте не был + запущен, доступность сайтов сторожат uptime-мониторы GlitchTip) — ОДИН + `curl`, дальше `|| log WARN`. Каждый отказ терял уведомление целиком. Watchdog, который не может дозваться, — худший вид самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс, что о них не сообщат. При этом сам файл ниже повторяет свои HTTP-ПРОВЕРКИ @@ -51,7 +53,6 @@ import pytest # backend/tests/ops/<этот файл> → корень репозитория REPO_ROOT = Path(__file__).resolve().parents[3] -UPTIME = "ops/uptime-healthcheck.sh" LIB_BACKUP = "ops/lib-backup.sh" # См. подробное обоснование shutil.which в @@ -65,8 +66,7 @@ if BASH is None: # pragma: no cover - окружение без bash не за def _extract_function(script_rel: str, name: str = "notify") -> str: """Достаёт тело одной функции из скрипта — не весь файл. - Весь скрипт source'ить нельзя: uptime-healthcheck.sh ниже функций реально - ходит по прод-URL, а lib-backup.sh рассчитан на вызов из backup.sh. + Весь скрипт source'ить нельзя: lib-backup.sh рассчитан на вызов из backup.sh. Сопоставление точное (`name() {`), иначе `notify` поймал бы `notify_fallback_mail` — соседнюю функцию в том же файле. @@ -140,7 +140,7 @@ def _run_notify(script_rel: str, fail_times: int) -> tuple[int, str, str]: return calls, out, err -@pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP]) +@pytest.mark.parametrize("script_rel", [LIB_BACKUP]) def test_transient_failure_is_retried_not_lost(script_rel: str) -> None: """Один отказ — алерт всё равно доставляется со второй попытки. @@ -153,7 +153,7 @@ def test_transient_failure_is_retried_not_lost(script_rel: str) -> None: assert "НЕ ДОСТАВЛЕН" not in err, f"{script_rel}: доставленный алерт помечен потерянным" -@pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP]) +@pytest.mark.parametrize("script_rel", [LIB_BACKUP]) def test_gives_up_after_three_attempts(script_rel: str) -> None: """Повторы ограничены: три попытки, а не бесконечный цикл. @@ -164,12 +164,6 @@ def test_gives_up_after_three_attempts(script_rel: str) -> None: assert calls == 3, f"{script_rel}: ожидалось ровно 3 попытки, а curl позван {calls} раз" -def test_uptime_reports_undelivered_alert_loudly() -> None: - """Когда все три попытки провалились — это видно в логе, а не молча.""" - _calls, _out, err = _run_notify(UPTIME, fail_times=99) - assert "НЕ ДОСТАВЛЕН" in err, f"недоставленный алерт должен логироваться громко.\n{err}" - - def test_backup_does_not_burn_fallback_on_a_single_timeout() -> None: """Транзиентный таймаут не должен трогать запасной канал. diff --git a/ops/check-backup-staleness.sh b/ops/check-backup-staleness.sh index d21d041a..c3fdec35 100755 --- a/ops/check-backup-staleness.sh +++ b/ops/check-backup-staleness.sh @@ -10,9 +10,8 @@ # script says so loudly instead of the silence that let backups break for # weeks undetected before (#71). # -# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), same -# discipline as ops/uptime-healthcheck.sh, so an hourly cron doesn't spam -# Telegram once a backup is already known to be stale. +# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), so an +# hourly cron doesn't spam Telegram once a backup is already known to be stale. # # Usage (cron — one line per sentinel, run more often than the backup itself # so a stale state is caught promptly; hourly is a reasonable default for a @@ -27,9 +26,10 @@ # /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" \ # >> /var/log/gendesign-backup-staleness.log 2>&1 # -# Alert channel: reuses the Telegram bot from ops/uptime-healthcheck.sh (see -# notify() in ops/lib-backup.sh) — creds in /etc/default/gendesign-backup, -# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID. Without them, logs only. +# Alert channel: notify() in ops/lib-backup.sh — creds in +# ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}, TELEGRAM_BOT_TOKEN/ +# TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID (245 «Metrics», #3164). Without the first +# two, logs only. # # Exit code: 0 = fresh, 1 = stale or sentinel missing (so this can ALSO be # used as a plain healthcheck by anything that just wants the exit code). diff --git a/ops/crontab-beget.cron b/ops/crontab-beget.cron index 16639595..617a00aa 100644 --- a/ops/crontab-beget.cron +++ b/ops/crontab-beget.cron @@ -22,7 +22,11 @@ 15 4 * * * FORGEJO_BACKUP_ENV_FILE=/opt/gendesign/secrets/forgejo-backup.env bash /opt/gendesign/ops/backup-forgejo.sh >> /opt/gendesign/logs/backup-forgejo.log 2>&1 # Сторож пропущенных прогонов для forgejo-бэкапа. Порог 26ч. -0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1 +# Канал оповещения сторожей и бэкапа волта — /opt/gendesign/secrets/backup-notify.env +# (TELEGRAM_BOT_TOKEN/CHAT_ID/TOPIC_ID, тема 245 «Metrics», #3164). Без +# BACKUP_ENV_FILE notify() ищет /etc/default/gendesign-backup, где канала на +# этом хосте нет, — алерты молча не уходят никуда. +0 * * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1 # ── Бэкап волта Obsidian (CouchDB) ────────────────────────────────────────── # Волт остаётся на этом хосте вместе с Forgejo и GlitchTip, поэтому запись @@ -33,11 +37,11 @@ # file-level копия работающего узла может оказаться рассогласованной. Время — # 04:45, между forgejo-бэкапом (04:15) и недельной уборкой докера (04:00 вс), # чтобы три задачи не дрались за диск и сеть одновременно. -45 4 * * * bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1 +45 4 * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1 # Сторож пропущенных прогонов для бэкапа волта. Порог 26ч — как у остальных: # сутки плюс запас на сдвиг прогона. -0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1 +0 * * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1 # ── Уборка докера ─────────────────────────────────────────────────────────── # Остаётся здесь, потому что здесь остаются CI-раннеры (#2881) — именно их diff --git a/ops/gendesign-backup-couchdb.default.example b/ops/gendesign-backup-couchdb.default.example index 81bccbcd..1766b312 100644 --- a/ops/gendesign-backup-couchdb.default.example +++ b/ops/gendesign-backup-couchdb.default.example @@ -51,5 +51,10 @@ COUCHDB_S3_SECRET_KEY= # Переопределяй только если этому бэкапу нужен ОТДЕЛЬНЫЙ адресат: #TELEGRAM_BOT_TOKEN= #TELEGRAM_CHAT_ID= +# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в +# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без +# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и +# чат: notify() читает env-файл, только если их нет в окружении. +#TELEGRAM_TOPIC_ID=245 # Либо укажи другой файл, откуда notify() возьмёт настройки канала: #BACKUP_ENV_FILE=/etc/default/gendesign-backup diff --git a/ops/gendesign-backup-forgejo.default.example b/ops/gendesign-backup-forgejo.default.example index 78e9d4e5..b24dc34f 100644 --- a/ops/gendesign-backup-forgejo.default.example +++ b/ops/gendesign-backup-forgejo.default.example @@ -27,6 +27,17 @@ #FORGEJO_S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY_ONCE_CREATED #FORGEJO_S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY_ONCE_CREATED +# --- оповещения (Telegram) --- +# backup-forgejo.sh читает этот файл сам, поэтому канал задаётся здесь: без +# токена и чата notify() ищет их в /etc/default/gendesign-backup. +#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN +#TELEGRAM_CHAT_ID=123456789 +# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в +# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без +# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и +# чат: notify() читает env-файл, только если их нет в окружении. +#TELEGRAM_TOPIC_ID=245 + # --- optional overrides (defaults are sensible; uncomment only to change) --- #FORGEJO_S3_PREFIX=forgejo/ # key prefix inside the bucket; must match # the policy's Resource path exactly diff --git a/ops/gendesign-backup.default.example b/ops/gendesign-backup.default.example index ee6da0ad..a01c754f 100644 --- a/ops/gendesign-backup.default.example +++ b/ops/gendesign-backup.default.example @@ -24,13 +24,8 @@ # # Missed-run alerting (#2203): ops/check-backup-staleness.sh (separate cron # entry, see its header) alerts when a backup's sentinel goes stale. It reads -# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID from THIS file (not from -# /etc/default/gendesign-uptime — deliberately a separate config so backup -# alerting doesn't depend on the uptime watchdog's env file existing). -# SAME variable names as ops/gendesign-uptime.default.example — point both -# files at the same bot/chat if you want one Telegram destination for -# everything, or use different bots/chats if you'd rather split the noise. -# Without these two set, ops/check-backup-staleness.sh still logs, just +# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID from THIS file. +# Without the first two set, ops/check-backup-staleness.sh still logs, just # doesn't send a Telegram alert. # --- S3 off-site upload (Selectel S3-compatible). All four REQUIRED (#3085) — @@ -41,9 +36,14 @@ #S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY #S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY -# --- missed-run alerting (Telegram, shared bot with uptime watchdog) --- +# --- missed-run alerting (Telegram) --- #TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN #TELEGRAM_CHAT_ID=123456789 +# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в +# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без +# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и +# чат: notify() читает env-файл, только если их нет в окружении. +#TELEGRAM_TOPIC_ID=245 # --- optional overrides (defaults are sensible; uncomment only to change) --- #BACKUP_ALLOW_LOCAL_ONLY=1 # explicit escape hatch: run without S3 creds and diff --git a/ops/gendesign-uptime.default.example b/ops/gendesign-uptime.default.example deleted file mode 100644 index db99c75f..00000000 --- a/ops/gendesign-uptime.default.example +++ /dev/null @@ -1,28 +0,0 @@ -# Environment file for ops/uptime-healthcheck.sh (external uptime watchdog, #75). -# -# Install as a ROOT-OWNED, chmod-600 file that is NOT in git, on whatever host -# runs the cron (ideally a host OTHER than the prod VM, so it survives a full -# VPS outage): -# sudo cp ops/gendesign-uptime.default.example /etc/default/gendesign-uptime -# sudo chmod 600 /etc/default/gendesign-uptime -# sudo $EDITOR /etc/default/gendesign-uptime # fill in real Telegram creds -# -# uptime-healthcheck.sh sources this file if present. With NO Telegram vars set, -# it still logs up/down but sends no alert (useful for a dry run first). -# -# Get a bot token from @BotFather; get your chat_id by messaging the bot then -# GET https://api.telegram.org/bot/getUpdates and reading message.chat.id. - -# --- Telegram alerting (both required to enable alerts) --- -#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN -#TELEGRAM_CHAT_ID=123456789 - -# --- optional overrides (defaults are sensible; uncomment only to change) --- -#BASE_URL=https://gendsgn.ru -#STATE_FILE=/var/tmp/gendesign-uptime-state -#CURL_TIMEOUT=15 -#RETRIES=2 -#RETRY_SLEEP=5 -# Custom check list (newline-separated "label|path|expected_status"): -#CHECKS="health|/health|200 -#market-pulse|/api/v1/analytics/market-pulse|200" diff --git a/ops/lib-backup.sh b/ops/lib-backup.sh index 417bfe3f..2d38960a 100755 --- a/ops/lib-backup.sh +++ b/ops/lib-backup.sh @@ -5,9 +5,7 @@ # SOURCED, not executed directly — no shebang execution of its own. Inherits # the caller's `set -euo pipefail`. Keep this dependency-free: bash builtins + # coreutils (date, stat, mkdir, grep, awk, mktemp) + curl (only used by -# notify() when Telegram vars are actually set — curl is already a hard -# requirement of ops/uptime-healthcheck.sh on the same box, so this adds no -# new dependency). +# notify() when Telegram vars are actually set). # # Load with (script computes its own dir so this works regardless of cron's # CWD or which repo subdir the caller lives in): @@ -18,14 +16,8 @@ log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; } # --- notify ------------------------------------------------------------ -# Reuses the SAME Telegram channel/bot as ops/uptime-healthcheck.sh (#75) — -# this is NOT a second alerting system, just the same TELEGRAM_BOT_TOKEN / -# TELEGRAM_CHAT_ID variable names read from a DIFFERENT env file -# (/etc/default/gendesign-backup, not /etc/default/gendesign-uptime) so -# backup alerting doesn't depend on the uptime watchdog's config being -# present, and vice versa. Point both files at the same bot/chat if you want -# one Telegram destination for everything — that's an ops choice, not this -# script's concern. +# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID / TELEGRAM_TOPIC_ID from the env +# (or ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}). Topic: #3164. # # No-op (logs only) when unset — this is the extension point: to wire a # different channel later, edit ONLY this function; every caller in this repo @@ -153,12 +145,9 @@ sentinel_age_hours() { } # --- transition-tracked alert state -------------------------------------- -# Same idiom as ops/uptime-healthcheck.sh's prev_status()/set_status(): a -# flat "