feat(tradein): ручка «дома, уехавшие от собственных объявлений» (#2996)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m27s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 2m10s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s

Часть Находки 3 из #2996.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-08-21 05:54:33 +00:00
commit a003fdd37c
2 changed files with 219 additions and 0 deletions

View file

@ -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) ────────────────────────

View file

@ -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 %)
15 км 64
525 км 40
25100 км 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"