From fab0bc84c10d3bb59397be54e47a8db106a0f624 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 21 Aug 2026 01:28:50 +0500 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20=D1=80=D1=83=D1=87=D0=BA=D0=B0?= =?UTF-8?q?=20=C2=AB=D0=B4=D0=BE=D0=BC=D0=B0,=20=D1=83=D0=B5=D1=85=D0=B0?= =?UTF-8?q?=D0=B2=D1=88=D0=B8=D0=B5=20=D0=BE=D1=82=20=D1=81=D0=BE=D0=B1?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=8A=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9=C2=BB=20(#2996?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Находка 3 задачи («23 дома лежат вне области 66, гарда на приёме нет») подтвердилась, и к ней добавились масштаб и причина. Масштаб: к этим домам привязано 591 объявление. Адреса екатеринбургские («Ул. 8 Марта», «Крауля», «Амундсена»), координаты — Варшава, Белград, Таллин, Владивосток, Ижевск. Причина: 22 из 23 несут в raw_payload след разового бэкфилла `full_backfill_2026-05-27`, который звал Yandex-геокодер напрямую, без резолва города. Живой путь при этом ЧИСТ — 0 записей вне региона в geocode_cache из 10 448 и 2 из 88 544 у listings; скрипта в репозитории нет. Это исторический осадок, а не текущая утечка, и гард нужен не столько живому пути, сколько следующему разовому скрипту. 17 из 22 помечены геокодером `precision: "exact"`. Точность отвечает на «нашёлся ли номер дома», а не «в том ли городе», и критерием приёмки быть не может — поэтому проверяется ПРИНАДЛЕЖНОСТЬ. Инвариант нарочно не географический: «дом рядом со своими объявлениями», а не «дом внутри рамки области». Рамка сломалась бы при выходе в Москву — ровно то, ради чего заведена #2996. Он же строго сильнее: ловит 25 домов против 23 у рамки, и оба лишних проверены («Ул. Белинского/Фурманова» за 207 км, 34 объявления). Порог 100 км не подобран на глаз. Замер по 9 052 домам: ближе 1 км — 8 914 (98.5 %), 25-100 км — 9 настоящих пригородов (Сарапулка, Кедровка, Чусовское Озеро, Ревда, Первоуральск; самый дальний 46.7 км), дальше 100 км — 25 (ближайший 119.2, максимум 5 079). Между 46.7 и 119.2 км нет НИ ОДНОГО дома: порог лежит в середине пустого промежутка. Первая редакция клала счётчик полем в /scraper/data-quality. Замер это остановил: запрос стоит ~445 мс на тёплом кэше, а обе существующие выборки той ручки вместе — 27 мс, при опросе фронтом каждые 120 с. То есть 17-кратное удорожание ради числа, которое меняется раз в месяцы. Проверка вынесена в отдельную ручку по требованию, и на возврат в горячий путь поставлен контроль-тест. Ручка отдаёт не только счётчик, но и масштаб (список домов + сколько объявлений привязано) — по одному числу «25» решение об очистке не принять. Двусторонне: против origin/main три теста красные, краснота везде по значению — ни одного ImportError/AttributeError. Контроль test_check_stays_out_of_the_polled_endpoint зелёный с обеих сторон. pytest tradein-mvp/backend — 4642 passed, 23 skipped. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/api/v1/admin.py | 107 +++++++++++++++++ .../test_2996_mislocated_houses_watchdog.py | 112 ++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_2996_mislocated_houses_watchdog.py diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index aa251ffe..b108b205 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -2654,6 +2654,113 @@ async def update_scraper_pacing( ) from exc +# ── Дома, уехавшие от собственных объявлений (GET /scraper/mislocated-houses) ─ +# +# Отдельная ручка ПО ТРЕБОВАНИЮ, а не поле в /scraper/data-quality (#2996). +# Замер прода 21.08.2026: запрос стоит ~445 мс на тёплом кэше, тогда как обе +# существующие выборки data-quality вместе — 27 мс, а фронт опрашивает её каждые +# 120 с. Класть сюда 17-кратное удорожание ради числа, которое меняется раз в +# месяцы, неправильно; поэтому проверка живёт отдельно и вызывается руками. +# +# Что ловим. Прод: 23 дома стоят вне Свердловской области, к ним привязано 591 +# объявление; адреса екатеринбургские, координаты — Варшава, Белград, Таллин, +# Владивосток, Ижевск. 22 из 23 несут в raw_payload след разового бэкфилла +# `full_backfill_2026-05-27`, который звал Yandex-геокодер напрямую, без резолва +# города. Живой путь чист: 0 записей вне региона в geocode_cache из 10 448. +# +# 17 из 22 при этом помечены геокодером `precision: "exact"` — точность отвечает +# на «нашёлся ли номер дома», а не «в том ли городе», и критерием приёмки быть +# не может. Поэтому проверяется ПРИНАДЛЕЖНОСТЬ, а не «точность». +# +# Инвариант нарочно НЕ географический: «дом рядом со своими объявлениями», а не +# «дом внутри рамки области». Рамка сломалась бы при выходе в Москву (#2996) — +# этот инвариант расширение переживёт. Он же строго сильнее: ловит 25 домов +# против 23 у рамки, и оба лишних при проверке оказались настоящими +# («Ул. Белинского/Фурманова» за 207 км, 34 объявления). +# +# Порог не подобран на глаз. Замер по 9 052 домам с объявлениями: +# ближе 1 км 8 914 (98.5 %) · 1-5 км 64 · 5-25 км 40 +# 25-100 км 9 — настоящие пригороды (Сарапулка, Кедровка, Чусовское +# Озеро, Ревда, Первоуральск), самый дальний 46.7 км +# дальше 100 км 25 — ближайший 119.2 км, максимум 5 079 км +# Между 46.7 и 119.2 км нет НИ ОДНОГО дома: 100 лежит в середине пустого +# промежутка, а не на краю распределения. +_MISLOCATED_KM = 100.0 + +_MISLOCATED_SQL = text(""" + WITH pairs AS ( + SELECT h.id AS house_id, + h.address, + h.source, + ST_Distance( + h.geom::geography, + ST_SetSRID(ST_MakePoint( + percentile_disc(0.5) WITHIN GROUP (ORDER BY l.lon), + percentile_disc(0.5) WITHIN GROUP (ORDER BY l.lat) + ), 4326)::geography + ) / 1000.0 AS km, + count(*) AS listings_count + FROM houses h + JOIN listings l ON l.house_id_fk = h.id + WHERE h.geom IS NOT NULL AND l.lat IS NOT NULL AND l.lon IS NOT NULL + GROUP BY h.id, h.geom, h.address, h.source + ) + SELECT house_id, address, source, round(km::numeric, 1) AS km, listings_count + FROM pairs + WHERE km >= CAST(:km AS double precision) + ORDER BY km DESC +""") + + +class MislocatedHouse(BaseModel): + """Дом, стоящий дальше порога от медианы координат своих объявлений.""" + + house_id: int + address: str | None + source: str | None + km: float + listings_count: int + + +class MislocatedHousesResponse(BaseModel): + threshold_km: float + houses_count: int + listings_count: int + houses: list[MislocatedHouse] + + +@router.get("/scraper/mislocated-houses", response_model=MislocatedHousesResponse) +def get_mislocated_houses( + db: Annotated[Session, Depends(get_db)], +) -> MislocatedHousesResponse: + """Дома, уехавшие от собственных объявлений дальше порога (#2996). + + Ноль — ожидаемое значение. Ненулевое означает, что чьи-то координаты пришли + из геокода в чужой город: живой путь резолвит город, но разовые скрипты + ходят в API напрямую и эту защиту обходят. + + Запрос тяжёлый (~445 мс на проде) — поэтому ручка отдельная и вызывается + руками, а не подмешана в опрашиваемую каждые 2 минуты /scraper/data-quality. + """ + rows = db.execute(_MISLOCATED_SQL, {"km": _MISLOCATED_KM}).mappings().all() + houses = [ + MislocatedHouse( + house_id=int(r["house_id"]), + address=r["address"], + source=r["source"], + km=float(r["km"]), + listings_count=int(r["listings_count"]), + ) + for r in rows + ] + return MislocatedHousesResponse( + threshold_km=_MISLOCATED_KM, + houses_count=len(houses), + listings_count=sum(h.listings_count for h in houses), + houses=houses, + ) + + # ── Data-quality coverage (GET /scraper/data-quality) ──────────────────────── diff --git a/tradein-mvp/backend/tests/test_2996_mislocated_houses_watchdog.py b/tradein-mvp/backend/tests/test_2996_mislocated_houses_watchdog.py new file mode 100644 index 00000000..94d57a46 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2996_mislocated_houses_watchdog.py @@ -0,0 +1,112 @@ +"""Сторож на дома, уехавшие от собственных объявлений (#2996). + +Прод 21.08.2026: 23 дома стоят вне Свердловской области, к ним привязано 591 +объявление. Адреса екатеринбургские («Ул. 8 Марта», «Крауля», «Амундсена»), +координаты — Варшава, Белград, Таллин, Владивосток, Ижевск. + +Причина найдена и она НЕ в живом пути: 22 из 23 несут в `raw_payload` след +разового бэкфилла `full_backfill_2026-05-27`, который звал Yandex-геокодер +напрямую, без резолва города. Живой путь чист — 0 записей вне региона в +`geocode_cache` из 10 448 и 2 из 88 544 у `listings`. + +**17 из 22 помечены геокодером `precision: "exact"`.** Точность отвечает на +«нашёлся ли номер дома», а не «в том ли городе», и критерием приёмки быть не +может. Поэтому сторож проверяет ПРИНАДЛЕЖНОСТЬ, а не «точность». + +Инвариант нарочно не географический — «дом рядом со своими объявлениями», а не +«дом внутри рамки области». Рамка сломалась бы при выходе в Москву; этот +инвариант переживёт расширение. + +Порог 100 км не подобран на глаз. Замер по 9 052 домам с объявлениями: + + ближе 1 км 8 914 (98.5 %) + 1–5 км 64 + 5–25 км 40 + 25–100 км 9 ← настоящие пригороды: Сарапулка, Кедровка, + Чусовское Озеро, Ревда, Первоуральск (макс. 46.7) + дальше 100 км 25 ← ближайший 119.2, максимум 5 079 + +Между 46.7 и 119.2 км нет НИ ОДНОГО дома. 100 лежит в середине пустого +промежутка, и ловит на два дома больше, чем рамка по области (оба проверены — +настоящие, «Ул. Белинского/Фурманова» за 207 км с 34 объявлениями). +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import inspect +import re + + +def test_threshold_sits_in_the_measured_gap() -> None: + """Головной: порог задан и лежит между пригородами и патологией. + + 46.7 км — самый дальний настоящий пригород, 119.2 — ближайший дефект. + Порог вне этого промежутка либо ловит пригороды, либо пропускает дефекты. + """ + from app.api.v1 import admin + + порог = getattr(admin, "_MISLOCATED_KM", None) + assert порог is not None, ( + "порога нет вовсе — сторож не задан, дома в Варшаве и Таллине никем " "не считаются" + ) + assert 46.7 < порог < 119.2, ( + f"порог {порог} вне измеренного промежутка (46.7, 119.2): ниже — ловит " + "настоящие пригороды, выше — пропускает дефекты" + ) + + +def test_endpoint_exists_and_reports_scale() -> None: + """Ручка обязана отдавать не только счётчик, но и МАСШТАБ. + + Одно число «25» не даёт принять решение об очистке: нужно, сколько + объявлений привязано к этим домам (на проде 591) и какие это дома. + """ + from app.api.v1 import admin + + ручка = getattr(admin, "get_mislocated_houses", None) + модель = getattr(admin, "MislocatedHousesResponse", None) + assert ручка is not None and модель is not None, ( + "ручки нет — дома в Варшаве и Таллине не перечисляет никто; " + f"есть ли обработчик: {ручка is not None}, есть ли модель: {модель is not None}" + ) + поля = модель.model_fields + for нужное in ("threshold_km", "houses_count", "listings_count", "houses"): + assert нужное in поля, f"нет поля {нужное}; есть: {sorted(поля)}" + + +def test_query_compares_house_to_its_own_listings() -> None: + """Контроль смысла: инвариант «рядом со своими», а не «внутри рамки». + + Рамка по области сломалась бы при выходе в Москву — ровно то, ради чего + заведена #2996. Ловит «починку» через координатный BETWEEN. + """ + from app.api.v1 import admin + + запрос = getattr(admin, "_MISLOCATED_SQL", None) + assert запрос is not None, "запроса нет — проверять принадлежность нечем" + src = str(запрос) + assert "l.house_id_fk = h.id" in src, "дом не сравнивается со СВОИМИ объявлениями" + assert "percentile_disc(0.5)" in src, "берётся не медиана координат объявлений" + assert not re.search( + r"ST_Y\(.*BETWEEN", src + ), "появилась географическая рамка — она не переживёт расширение региона" + + +def test_check_stays_out_of_the_polled_endpoint() -> None: + """Контроль цены: тяжёлая проверка не должна попасть в опрашиваемую ручку. + + Замер прода: этот запрос ~445 мс, обе выборки /scraper/data-quality вместе — + 27 мс, а фронт опрашивает её каждые 120 с. Первая редакция этой правки клала + проверку туда и давала 17-кратное удорожание; замер это и остановил. + """ + from app.api.v1 import admin + + src = inspect.getsource(admin.get_data_quality) + assert ( + "percentile_disc" not in src + ), "тяжёлый агрегат вернулся в опрашиваемую каждые 2 минуты ручку" + assert "mislocated" not in src.lower(), "проверка снова подмешана в data-quality"