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
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:
parent
189ae63e53
commit
e45b05845e
2 changed files with 71 additions and 15 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue