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
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:
commit
a003fdd37c
2 changed files with 219 additions and 0 deletions
|
|
@ -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) ────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
Loading…
Add table
Reference in a new issue