fix(mera-landing): наличие схемы улицы входит в полноту строки витрины
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m14s

Первой строкой витрины и первым раундом игры «Проверьте себя» стояла
единственная из двадцати сделка БЕЗ улицы: у неё вместо схемы улиц
рисовался полигон района. Причина — `completeness()` считала район, этаж
и этажность, но не улицу, хотя именно она решает, будет ли у строки карта.

Схема улицы — такое же ВИДИМОЕ поле, как район: строка, которой нечем
нарисовать карту, полнее строки с картой быть не может. Признак берётся
из уже загружаемого индекса улиц (`StreetIndex.lookup`, поиск в памяти);
индекс поднят выше отбора, дорогие пространственные запросы остались в
`_schemes_for` и по-прежнему считаются только для показанных строк.

Отбор по ВЕЛИЧИНЕ ОШИБКИ не введён и введён быть не может: строка
с отклонением +75,7% остаётся в витрине, просто больше не открывает её.
Прежнее правило «наличие схемы на отбор не влияет» в докстринге
`_schemes_for` заменено с разбором, почему оно давало этот дефект.

Тест двусторонний: при прочих равных строка со схемой выше строки без
неё, а строка без улицы остаётся в витрине. Фальсифицирован — снятие
`row.has_street` из `completeness` даёт красное ПО ЗНАЧЕНИЮ ([9, 8]
вместо [8, 9]), не по исключению.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-08-31 11:22:39 +05:00
parent 189ae63e53
commit e45b05845e
2 changed files with 71 additions and 15 deletions

View file

@ -30,8 +30,8 @@
самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет
квартала, нет площади) «число некрасивое» причиной не является. квартала, нет площади) «число некрасивое» причиной не является.
Полнота сколько из полей, которые видит посетитель (район, этаж, этажность), Полнота сколько из полей, которые видит посетитель (район, этаж, этажность,
у строки заполнено. Свежесть порядок квартала сделки. схема улицы), у строки заполнено. Свежесть порядок квартала сделки.
ЧЕСТНОСТЬ ВИТРИНЫ (нарушение любого пункта = витрина врёт) ЧЕСТНОСТЬ ВИТРИНЫ (нарушение любого пункта = витрина врёт)
---------------------------------------------------------- ----------------------------------------------------------
@ -69,7 +69,10 @@
нет ни координат окна, ни констант проекции: точку дома по ней нельзя нет ни координат окна, ни констант проекции: точку дома по ней нельзя
поставить даже случайно. Название сматчилось с OSM у 550 из 654 названий поставить даже случайно. Название сматчилось с OSM у 550 из 654 названий
92.3% сделок; остальным `street_scheme` = NULL, и это штатно: фронт 92.3% сделок; остальным `street_scheme` = NULL, и это штатно: фронт
показывает район. Наличие схемы НА ОТБОР НЕ ВЛИЯЕТ (см. `_schemes_for`). показывает район. Наличие схемы ВХОДИТ В ПОЛНОТУ (см. `completeness`):
строка, которой нечем нарисовать карту, полнее строки с картой быть не
может. Это признак «поле заполнено», как район и этаж, а не величина
ошибки, правило отбора выше не нарушено.
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных * СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
строк» неотличимо от «столько и было», пока рядом не написано, сколько строк» неотличимо от «столько и было», пока рядом не написано, сколько
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
@ -97,7 +100,7 @@ from typing import Any
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.services.street_scheme import build_street_scheme, load_street_index from app.services.street_scheme import StreetIndex, build_street_scheme, load_street_index
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -158,15 +161,26 @@ class ShowcaseRow:
# None штатно: у части сделок координаты нет, подставлять туда нечего. # None штатно: у части сделок координаты нет, подставлять туда нечего.
lat: float | None = None lat: float | None = None
lon: float | None = None lon: float | None = None
# Есть ли чем нарисовать схему улицы (название сделки нашлось в OSM).
# Саму схему строим только для показанных строк — см. `_schemes_for`.
has_street: bool = False
def completeness(row: ShowcaseRow) -> int: def completeness(row: ShowcaseRow) -> int:
"""Сколько ВИДИМЫХ посетителю полей заполнено (0..3). """Сколько ВИДИМЫХ посетителю полей заполнено (0..4).
Считаем район/этаж/этажность: комнаты и площадь есть у всех кандидатов по Считаем район/этаж/этажность и схему улицы: комнаты и площадь есть у всех
построению выборки, поэтому в оценке полноты они бесполезны. кандидатов по построению выборки, поэтому в оценке полноты они бесполезны.
СХЕМА УЛИЦЫ ТАКОЕ ЖЕ ВИДИМОЕ ПОЛЕ, как район. Строка без улицы рисует на
фронте полигон РАЙОНА вместо схемы улиц, то есть этого поля у неё просто
нет, и полнее строки с картой она быть не может. Это признак наличия
данных, а не величина ошибки: строка с большим отклонением, но со схемой,
показывается как есть.
""" """
return sum(x is not None for x in (row.district, row.floor, row.total_floors)) return int(row.has_street) + sum(
x is not None for x in (row.district, row.floor, row.total_floors)
)
def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]: def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
@ -197,6 +211,7 @@ def build_row(
n_analogs: int, n_analogs: int,
lat: float | None = None, lat: float | None = None,
lon: float | None = None, lon: float | None = None,
has_street: bool = False,
) -> ShowcaseRow | None: ) -> ShowcaseRow | None:
"""Кандидат → строка витрины, либо None если считать не из чего. """Кандидат → строка витрины, либо None если считать не из чего.
@ -251,6 +266,7 @@ def build_row(
n_analogs=n_analogs, n_analogs=n_analogs,
lat=lat, lat=lat,
lon=lon, lon=lon,
has_street=has_street,
) )
@ -320,19 +336,27 @@ _INSERT_SQL = text(
) )
def _schemes_for(db: Session, chosen: list[ShowcaseRow], addresses: dict[int, str | None]) -> dict: def _schemes_for(
db: Session,
index: StreetIndex,
chosen: list[ShowcaseRow],
addresses: dict[int, str | None],
) -> dict:
"""Схемы улиц ТОЛЬКО для показанных строк: id сделки → схема. """Схемы улиц ТОЛЬКО для показанных строк: id сделки → схема.
Считаем после отбора, а не до: схема это два пространственных запроса на Считаем после отбора, а не до: схема это два пространственных запроса на
сделку, и на двухстах кандидатах ради двадцати показанных это четыреста сделку, и на двухстах кандидатах ради двадцати показанных это четыреста
лишних запросов в чужую базу. лишних запросов в чужую базу.
НА ОТБОР НАЛИЧИЕ СХЕМЫ НЕ ВЛИЯЕТ и это то же правило, что запрещает отбор НА ОТБОР ВЛИЯЕТ НЕ ЭТОТ ШАГ, А ПОЛНОТА (`completeness`), куда наличие улицы
по величине ошибки. Ранжируй мы строки со сматчившейся улицей выше, витрина входит наравне с районом и этажом. До 2026-08-31 здесь было записано
показывала бы не работу оценщика, а те 92% адресов, которые удобно легли на обратное «схема на отбор не влияет», и первой строкой витрины и первым
OSM. Схема украшение строки, а не причина её показать. раундом игры стояла единственная из двадцати сделка БЕЗ улицы: у неё вместо
схемы улиц рисовался полигон района. Отсутствие видимого поля не может
делать строку самой полной. Запрет отбора по ВЕЛИЧИНЕ ОШИБКИ этим не
затронут: строка с большим отклонением, но со схемой, стоит в витрине как
есть.
""" """
index = load_street_index(db)
out = {} out = {}
for row in chosen: for row in chosen:
scheme = build_street_scheme(db, index, addresses.get(row.deal_id)) scheme = build_street_scheme(db, index, addresses.get(row.deal_id))
@ -377,6 +401,10 @@ def refresh_landing_showcase_deals(
logger.info("витрина: загружено %d ДКП-сделок (city=%s, since=%s)", len(deals), city, since) logger.info("витрина: загружено %d ДКП-сделок (city=%s, since=%s)", len(deals), city, since)
districts = _fetch_districts(db, [d.id for d in deals]) districts = _fetch_districts(db, [d.id for d in deals])
# Индекс улиц нужен ДО отбора: сматчился ли адрес с OSM — это признак
# полноты строки. Поиск по индексу идёт в памяти, запрос ровно один на
# прогон, дорогие пространственные запросы остались в `_schemes_for`.
street_index = load_street_index(db)
candidates: list[ShowcaseRow] = [] candidates: list[ShowcaseRow] = []
n_priced = 0 n_priced = 0
@ -408,6 +436,7 @@ def refresh_landing_showcase_deals(
# тип этого не поймает: обе величины float. # тип этого не поймает: обе величины float.
lat=deal.lat, lat=deal.lat,
lon=deal.lon, lon=deal.lon,
has_street=street_index.lookup(deal.address) is not None,
) )
if row is None: if row is None:
n_incomplete += 1 n_incomplete += 1
@ -415,7 +444,7 @@ def refresh_landing_showcase_deals(
candidates.append(row) candidates.append(row)
chosen = select_rows(candidates, limit) chosen = select_rows(candidates, limit)
schemes = _schemes_for(db, chosen, {d.id: d.address for d in deals}) schemes = _schemes_for(db, street_index, chosen, {d.id: d.address for d in deals})
db.execute(_DELETE_SQL) db.execute(_DELETE_SQL)
db.execute(_DELETE_RUNS_SQL) db.execute(_DELETE_RUNS_SQL)

View file

@ -31,6 +31,7 @@ def _row(
total_floors: int | None = 9, total_floors: int | None = 9,
deal_date: date = date(2026, 1, 1), deal_date: date = date(2026, 1, 1),
err_pct: float = 10.0, err_pct: float = 10.0,
has_street: bool = True,
) -> ShowcaseRow: ) -> ShowcaseRow:
return ShowcaseRow( return ShowcaseRow(
deal_id=deal_id, deal_id=deal_id,
@ -45,6 +46,7 @@ def _row(
fact_rub=5_500_000, fact_rub=5_500_000,
err_pct=err_pct, err_pct=err_pct,
n_analogs=40, n_analogs=40,
has_street=has_street,
) )
@ -72,6 +74,31 @@ def test_selection_prefers_fresher_quarter_at_equal_completeness() -> None:
assert [r.deal_id for r in select_rows([older, fresher], limit=1)] == [2] assert [r.deal_id for r in select_rows([older, fresher], limit=1)] == [2]
def test_selection_prefers_row_with_street_scheme() -> None:
"""Строка без улицы не может быть самой полной: ей нечем нарисовать карту.
Так дефект и выглядел в проде: первой строкой витрины и первым раундом игры
«Проверьте себя» стояла единственная из двадцати сделка БЕЗ улицы, и вместо
схемы улиц у неё рисовался полигон РАЙОНА.
Всё остальное здесь одинаково, а id подобраны так, что без учёта улицы
выиграл бы разрыв ключа по id. Ломать так: убрать `row.has_street` из
`completeness` тест покраснеет ПО ЗНАЧЕНИЮ, порядком [9, 8].
Вторая сторона проверки строка без улицы ОСТАЁТСЯ в витрине: она не
первая, но и не выброшена. Прятать промахи по-прежнему нельзя.
"""
no_street = _row(9, has_street=False, err_pct=75.7)
with_street = _row(8, err_pct=3.0)
chosen = select_rows([no_street, with_street], limit=2)
assert [r.deal_id for r in chosen] == [8, 9], (
"строка, которой нечем нарисовать карту, оказалась полнее строки со "
"схемой улиц — витрина открывается полигоном района"
)
def test_selection_is_deterministic_on_full_ties() -> None: def test_selection_is_deterministic_on_full_ties() -> None:
"""Полные совпадения ключа разводятся id — иначе витрина «мерцает».""" """Полные совпадения ключа разводятся id — иначе витрина «мерцает»."""
rows = [_row(7), _row(9), _row(8)] rows = [_row(7), _row(9), _row(8)]