Compare commits

..

No commits in common. "d6a8a693c5425fe10a8a23ad485d798f28b60fe6" and "24387d0455c6f3e401368d784eea9acfe84936ad" have entirely different histories.

3 changed files with 29 additions and 44 deletions

View file

@ -246,8 +246,7 @@ def _clean_address_for_geocode(addr: str) -> str:
"""Чистим address для геокодера.
Cian отдаёт «улица Латвийская, 56/3 · р-н Чкаловский» суффикс ' · ...'
мешает Nominatim. Берём часть до ' · '. Остальные источники такого суффикса
не используют адрес остаётся без изменений.
мешает Nominatim. Берём часть до ' · '. N1 отдаёт «Репина, 75/2 стр.» ок.
"""
main = addr.split(" · ")[0].strip()
return main or addr
@ -261,7 +260,7 @@ async def geocode_missing(
) -> dict:
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
target=listings (по умолч.) объявления; target=deals сделки Росреестра.
target=listings (по умолч.) объявления Cian/N1; target=deals сделки Росреестра.
Чанк-обработка с бюджетом по времени (~240с, заведомо меньше cron
`curl -m 320`): за вызов геокодим сколько успеваем, остаток уходит в
`remaining`, cron вызывает в цикле пока `remaining` > 0.
@ -270,8 +269,12 @@ async def geocode_missing(
адреса не выбираются повторно 7 дней cron-loop завершается, не зацикливается.
geom обновляется автоматически триггером.
"""
# Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса.
extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else ""
# Доп. фильтр для listings — у Avito/N1 встречаются плейсхолдер-адреса.
extra_filter = (
"AND address NOT LIKE '%(Avito)%' AND address NOT LIKE '%(N1)%'"
if target == "listings"
else ""
)
rows = (
db.execute(
text(

View file

@ -1,10 +1,9 @@
"""Tests for _fetch_analogs per-address cap and per-source quota (source starvation fix).
Regression: Монтёрская 8/2 91 Avito listings с distance=0 выдавливали
Cian/Yandex из топ-50, т.к. pure-distance sort + LIMIT 50.
Cian/Yandex/N1 из топ-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.
@ -16,7 +15,6 @@ from unittest.mock import MagicMock
# ── Helpers ───────────────────────────────────────────────────────────────────
def _make_listing(
*,
source: str,
@ -60,7 +58,6 @@ 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.
@ -94,7 +91,6 @@ 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.
@ -107,28 +103,22 @@ 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)
@ -140,7 +130,6 @@ 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.
@ -149,38 +138,31 @@ 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.
@ -190,9 +172,8 @@ 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)

View file

@ -89,6 +89,7 @@ const SOURCE_COLORS: Record<string, string> = {
cian: "#1baf7a",
yandex: "#eda100",
domklik: "#008300",
n1: "#4a3aa7",
};
const FALLBACK_SOURCE_COLOR = "#6b7280"; // неизвестный/прочий листинговый источник
const COLOR_TARGET = "#f59e0b"; // та же семантика, что и MapCard.COLOR_TARGET