diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index e7df8544..c03e024e 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -246,7 +246,8 @@ def _clean_address_for_geocode(addr: str) -> str: """Чистим address для геокодера. Cian отдаёт «улица Латвийская, 56/3 · р-н Чкаловский» — суффикс ' · ...' - мешает Nominatim. Берём часть до ' · '. N1 отдаёт «Репина, 75/2 стр.» — ок. + мешает Nominatim. Берём часть до ' · '. Остальные источники такого суффикса + не используют — адрес остаётся без изменений. """ main = addr.split(" · ")[0].strip() return main or addr @@ -260,7 +261,7 @@ async def geocode_missing( ) -> dict: """Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address). - target=listings (по умолч.) — объявления Cian/N1; target=deals — сделки Росреестра. + target=listings (по умолч.) — объявления; target=deals — сделки Росреестра. Чанк-обработка с бюджетом по времени (~240с, заведомо меньше cron `curl -m 320`): за вызов геокодим сколько успеваем, остаток уходит в `remaining`, cron вызывает в цикле пока `remaining` > 0. @@ -269,12 +270,8 @@ async def geocode_missing( адреса не выбираются повторно 7 дней → cron-loop завершается, не зацикливается. geom обновляется автоматически триггером. """ - # Доп. фильтр для listings — у Avito/N1 встречаются плейсхолдер-адреса. - extra_filter = ( - "AND address NOT LIKE '%(Avito)%' AND address NOT LIKE '%(N1)%'" - if target == "listings" - else "" - ) + # Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса. + extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else "" rows = ( db.execute( text( diff --git a/tradein-mvp/backend/tests/test_estimator_source_quota.py b/tradein-mvp/backend/tests/test_estimator_source_quota.py index 0b1e562a..9b02b85c 100644 --- a/tradein-mvp/backend/tests/test_estimator_source_quota.py +++ b/tradein-mvp/backend/tests/test_estimator_source_quota.py @@ -1,9 +1,10 @@ """Tests for _fetch_analogs per-address cap and per-source quota (source starvation fix). Regression: Монтёрская 8/2 — 91 Avito listings с distance=0 выдавливали -Cian/Yandex/N1 из топ-50, т.к. pure-distance sort + LIMIT 50. +Cian/Yandex из топ-50, т.к. pure-distance sort + LIMIT 50. Fix: MAX_ANALOGS_PER_ADDRESS cap в SQL + MIN_ANALOGS_PER_SOURCE quota в Python. """ + import os # Settings requires DATABASE_URL at init time. Set dummy DSN before any app import. @@ -15,6 +16,7 @@ from unittest.mock import MagicMock # ── Helpers ─────────────────────────────────────────────────────────────────── + def _make_listing( *, source: str, @@ -58,6 +60,7 @@ def _make_db_mock(rows: list[dict[str, Any]]) -> MagicMock: # ── Test 1: per-address cap ─────────────────────────────────────────────────── + def test_address_cap_limits_per_address_listings() -> None: """_fetch_analogs caps at MAX_ANALOGS_PER_ADDRESS listings from a single address. @@ -91,6 +94,7 @@ def test_address_cap_limits_per_address_listings() -> None: # ── Test 2: source quota (regression for Cian starvation) ──────────────────── + def test_source_quota_prevents_cian_starvation() -> None: """MIN_ANALOGS_PER_SOURCE guarantees Cian is represented despite Avito dominance. @@ -103,22 +107,28 @@ def test_source_quota_prevents_cian_starvation() -> None: # SQL already applied address cap. Simulate SQL result after cap: # 5 avito (cap applied to large block), 8 cian (different address, 200m away). avito_rows = [ - _make_listing(source="avito", address="ул. Монтёрская, 8/2", distance_m=0.0, - relevance_score=float(i) * 0.01) + _make_listing( + source="avito", + address="ул. Монтёрская, 8/2", + distance_m=0.0, + relevance_score=float(i) * 0.01, + ) for i in range(5) ] cian_rows = [ - _make_listing(source="cian", address="ул. Монтёрская, 1", distance_m=200.0, - relevance_score=0.2 + float(i) * 0.01) + _make_listing( + source="cian", + address="ул. Монтёрская, 1", + distance_m=200.0, + relevance_score=0.2 + float(i) * 0.01, + ) for i in range(8) ] # SQL returns avito first (better relevance), then cian sql_rows = avito_rows + cian_rows db = _make_db_mock(sql_rows) - result, _, _ = _fetch_analogs( - db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000 - ) + result, _, _ = _fetch_analogs(db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000) cian_count = sum(1 for r in result if r["source"] == "cian") expected_min = min(8, MIN_ANALOGS_PER_SOURCE) @@ -130,6 +140,7 @@ def test_source_quota_prevents_cian_starvation() -> None: # ── Test 3: no source starvation when quota > supply ───────────────────────── + def test_source_quota_includes_all_when_supply_below_min() -> None: """When a source has fewer listings than MIN_ANALOGS_PER_SOURCE, all are included. @@ -138,31 +149,38 @@ def test_source_quota_includes_all_when_supply_below_min() -> None: from app.services.estimator import _fetch_analogs avito_rows = [ - _make_listing(source="avito", address="ул. Монтёрская, 8/2", distance_m=0.0, - relevance_score=float(i) * 0.01) + _make_listing( + source="avito", + address="ул. Монтёрская, 8/2", + distance_m=0.0, + relevance_score=float(i) * 0.01, + ) for i in range(5) ] cian_rows = [ - _make_listing(source="cian", address="ул. Монтёрская, 3", distance_m=300.0, - relevance_score=0.3 + float(i) * 0.01) + _make_listing( + source="cian", + address="ул. Монтёрская, 3", + distance_m=300.0, + relevance_score=0.3 + float(i) * 0.01, + ) for i in range(3) ] sql_rows = avito_rows + cian_rows db = _make_db_mock(sql_rows) - result, _, _ = _fetch_analogs( - db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000 - ) + result, _, _ = _fetch_analogs(db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000) cian_count = sum(1 for r in result if r["source"] == "cian") - assert cian_count == 3, ( - f"All 3 Cian listings (below MIN quota) must be included, got {cian_count}" - ) + assert ( + cian_count == 3 + ), f"All 3 Cian listings (below MIN quota) must be included, got {cian_count}" assert len(result) == 8 # 5 avito + 3 cian # ── Test 4: fallback signal preserved ──────────────────────────────────────── + def test_fallback_signal_reflects_radius() -> None: """_fetch_analogs returns correct fallback_used boolean based on radius_m. @@ -172,8 +190,9 @@ def test_fallback_signal_reflects_radius() -> None: from app.services.estimator import DEFAULT_RADIUS_M, FALLBACK_RADIUS_M, _fetch_analogs rows = [ - _make_listing(source="avito", address="ул. Ленина, 1", distance_m=100.0, - relevance_score=0.1), + _make_listing( + source="avito", address="ул. Ленина, 1", distance_m=100.0, relevance_score=0.1 + ), ] db_default = _make_db_mock(rows) diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx index 426aa5a4..fc9df4e3 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx @@ -89,7 +89,6 @@ const SOURCE_COLORS: Record = { cian: "#1baf7a", yandex: "#eda100", domklik: "#008300", - n1: "#4a3aa7", }; const FALLBACK_SOURCE_COLOR = "#6b7280"; // неизвестный/прочий листинговый источник const COLOR_TARGET = "#f59e0b"; // та же семантика, что и MapCard.COLOR_TARGET