diff --git a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py index ec69b54a..881f3728 100644 --- a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py +++ b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py @@ -30,8 +30,8 @@ самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет квартала, нет площади) — «число некрасивое» причиной не является. -Полнота — сколько из полей, которые видит посетитель (район, этаж, этажность), -у строки заполнено. Свежесть — порядок квартала сделки. +Полнота — сколько из полей, которые видит посетитель (район, этаж, этажность, +схема улицы), у строки заполнено. Свежесть — порядок квартала сделки. ЧЕСТНОСТЬ ВИТРИНЫ (нарушение любого пункта = витрина врёт) ---------------------------------------------------------- @@ -69,7 +69,10 @@ нет ни координат окна, ни констант проекции: точку дома по ней нельзя поставить даже случайно. Название сматчилось с OSM у 550 из 654 названий — 92.3% сделок; остальным `street_scheme` = NULL, и это штатно: фронт - показывает район. Наличие схемы НА ОТБОР НЕ ВЛИЯЕТ (см. `_schemes_for`). + показывает район. Наличие схемы ВХОДИТ В ПОЛНОТУ (см. `completeness`): + строка, которой нечем нарисовать карту, полнее строки с картой быть не + может. Это признак «поле заполнено», как район и этаж, а не величина + ошибки, — правило отбора выше не нарушено. * СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных строк» неотличимо от «столько и было», пока рядом не написано, сколько сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог @@ -97,7 +100,7 @@ from typing import Any from sqlalchemy import text 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__) @@ -158,15 +161,26 @@ class ShowcaseRow: # None штатно: у части сделок координаты нет, подставлять туда нечего. lat: float | None = None lon: float | None = None + # Есть ли чем нарисовать схему улицы (название сделки нашлось в OSM). + # Саму схему строим только для показанных строк — см. `_schemes_for`. + has_street: bool = False 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]: @@ -197,6 +211,7 @@ def build_row( n_analogs: int, lat: float | None = None, lon: float | None = None, + has_street: bool = False, ) -> ShowcaseRow | None: """Кандидат → строка витрины, либо None если считать не из чего. @@ -251,6 +266,7 @@ def build_row( n_analogs=n_analogs, lat=lat, 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 сделки → схема. Считаем после отбора, а не до: схема — это два пространственных запроса на сделку, и на двухстах кандидатах ради двадцати показанных это четыреста лишних запросов в чужую базу. - НА ОТБОР НАЛИЧИЕ СХЕМЫ НЕ ВЛИЯЕТ — и это то же правило, что запрещает отбор - по величине ошибки. Ранжируй мы строки со сматчившейся улицей выше, витрина - показывала бы не работу оценщика, а те 92% адресов, которые удобно легли на - OSM. Схема — украшение строки, а не причина её показать. + НА ОТБОР ВЛИЯЕТ НЕ ЭТОТ ШАГ, А ПОЛНОТА (`completeness`), куда наличие улицы + входит наравне с районом и этажом. До 2026-08-31 здесь было записано + обратное — «схема на отбор не влияет», — и первой строкой витрины и первым + раундом игры стояла единственная из двадцати сделка БЕЗ улицы: у неё вместо + схемы улиц рисовался полигон района. Отсутствие видимого поля не может + делать строку самой полной. Запрет отбора по ВЕЛИЧИНЕ ОШИБКИ этим не + затронут: строка с большим отклонением, но со схемой, стоит в витрине как + есть. """ - index = load_street_index(db) out = {} for row in chosen: 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) districts = _fetch_districts(db, [d.id for d in deals]) + # Индекс улиц нужен ДО отбора: сматчился ли адрес с OSM — это признак + # полноты строки. Поиск по индексу идёт в памяти, запрос ровно один на + # прогон, дорогие пространственные запросы остались в `_schemes_for`. + street_index = load_street_index(db) candidates: list[ShowcaseRow] = [] n_priced = 0 @@ -408,6 +436,7 @@ def refresh_landing_showcase_deals( # тип этого не поймает: обе величины float. lat=deal.lat, lon=deal.lon, + has_street=street_index.lookup(deal.address) is not None, ) if row is None: n_incomplete += 1 @@ -415,7 +444,7 @@ def refresh_landing_showcase_deals( candidates.append(row) 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_RUNS_SQL) diff --git a/tradein-mvp/backend/tests/test_landing_showcase_deals.py b/tradein-mvp/backend/tests/test_landing_showcase_deals.py index 702e42f7..80bd3920 100644 --- a/tradein-mvp/backend/tests/test_landing_showcase_deals.py +++ b/tradein-mvp/backend/tests/test_landing_showcase_deals.py @@ -31,6 +31,7 @@ def _row( total_floors: int | None = 9, deal_date: date = date(2026, 1, 1), err_pct: float = 10.0, + has_street: bool = True, ) -> ShowcaseRow: return ShowcaseRow( deal_id=deal_id, @@ -45,6 +46,7 @@ def _row( fact_rub=5_500_000, err_pct=err_pct, 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] +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: """Полные совпадения ключа разводятся id — иначе витрина «мерцает».""" rows = [_row(7), _row(9), _row(8)]