diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 557106e7..cfb63fd3 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -2664,6 +2664,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"