diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index 7b5cd38b..da65bb17 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -51,6 +51,13 @@ jobs: # ОДИН сьют (та же дыра закрыта симметрично в ci.yml) — так на main # уехал красный test_get_role_known_users (2026-07-30 → PR #2587). - 'auth/**' + # Реестр городов — фронтовый файл, но его читает БЭКЕНДОВЫЙ тест + # (tests/test_public_mera_api.py сверяет то, что мы предлагаем + # выбрать, с тем, на что умеет отвечать проба покрытия). Без этой + # строки правка одного лишь дропдауна не гоняла бы сверку — а + # разошлись списки ровно так: город добавили на фронте, в пороги + # покрытия не внесли, и житель Серова получал «вы вне области». + - 'tradein-mvp/frontend/src/lib/city-registry.ts' - '.forgejo/workflows/ci-tradein.yml' frontend: - 'tradein-mvp/frontend/**' diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py index 80ed00e9..3c2bc5f2 100644 --- a/tradein-mvp/backend/app/api/public/mera.py +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -162,6 +162,41 @@ class PublicSuggestInput(BaseModel): city_hint: str | None = Field(default=None, max_length=100) +def _fold(text: str) -> str: + """ёЁ→еЕ + casefold — та же нормализация, что у городов в trade_in.py.""" + return text.translate(str.maketrans("ёЁ", "ее")).casefold() + + +def _query_with_city(query: str, city_hint: str | None) -> str: + """Подставить выбранный город В САМУ СТРОКУ запроса. + + ЗАЧЕМ. `city_hint` доезжает до геокодера, но НА ВЫДАЧУ ПОДСКАЗОК НЕ ВЛИЯЕТ: + его использует только екатеринбургский кадастровый тир (как признак «речь + не про ЕКБ, тир пропускаем»), а DaData-тир ограничен регионом целиком и + хинта не принимает. Замер на проде 16.08.2026: выбран Серов, введено + «Ленина 1» → первой подсказкой «Невьянский р-н, пгт Верх-Нейвинский». + Человек выбирает верхний вариант и считает совсем чужой дом — ровно тот + баг #2576, ради которого город и спрашивают. + + С городом в строке («Серов Ленина 1») выдача становится серовской целиком — + проверено там же. + + Для Екатеринбурга подстановка безвредна: три разных адреса дали + побайтово тот же результат с префиксом и без (кадастровый тир парсит + улицу и дом одинаково). Поэтому правило одно на все города, без + исключения для основного трафика — исключение пришлось бы поддерживать. + + Чинится ЗДЕСЬ, а не в геокодере: там от `city_hint` зависит поведение + закрытого контура (`target_city_ambiguous`), и менять его смысл ради + публичной формы значит трогать чужой контракт. + """ + if not city_hint: + return query + if _fold(city_hint) in _fold(query): + return query + return f"{city_hint}, {query}" + + @router.post("/suggest", response_model=SuggestResponse) async def public_suggest( request: Request, @@ -224,7 +259,10 @@ async def public_suggest( # адрес не попадает в журналы. with public_request_scope(): return await suggest_addresses( - q=payload.q, limit=payload.limit, db=db, city_hint=payload.city_hint + q=_query_with_city(payload.q, payload.city_hint), + limit=payload.limit, + db=db, + city_hint=payload.city_hint, ) finally: _suggest_slots.release() diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 2e4b391e..81301e69 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -2577,8 +2577,19 @@ COVERAGE_MIN_AGE_SAMPLES = 5 COVERAGE_MAX_AGE_DAYS = 365 # Списки городов и пороги — константа РЯДОМ С РУЧКОЙ (issue #2894 требование), не в БД. +# +# ⚠️ Эти списки обязаны совпадать с `OBLAST_CITIES` +# (frontend/src/lib/city-registry.ts) — тем, что человек видит в дропдауне. +# Расхождение поймано на проде 16.08.2026: Серов предлагался к выбору, но +# отсутствовал здесь, и житель Серова получал «этот адрес вне области, по +# которой мы собираем данные» — про город В ТОЙ ЖЕ области, который мы ему сами +# и предложили. Сверка теперь автоматическая, см. +# tests/test_public_mera_api.py::test_offered_cities_match_coverage_cities. COVERAGE_GREEN_CITIES = ("Екатеринбург", "Верхняя Пышма", "Берёзовский", "Среднеуральск") -COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Уральский", "Первоуральск", "Ревда") +# Серов добавлен 16.08.2026: в жёлтый тир, а не в зелёный — в радиусе 15 км от +# центра 363 активных объявления (все свежие), это на порядок меньше городов +# вокруг Екатеринбурга, но заведомо не ноль. +COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Уральский", "Первоуральск", "Ревда", "Серов") COVERAGE_GREEN_MIN_N = 8 COVERAGE_YELLOW_MIN_N = 12 @@ -2624,6 +2635,7 @@ _CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = { "Каменск-Уральский": (56.4110, 61.9243), "Первоуральск": (56.9083, 59.9483), "Ревда": (56.7986, 59.9298), + "Серов": (59.6047, 60.5772), } diff --git a/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py b/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py index 65d85921..266af904 100644 --- a/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py +++ b/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py @@ -109,7 +109,16 @@ _BEREZOVSKY = {"lat": 56.9096, "lon": 60.8034} # Реальные координаты Серова — ближайший поддерживаемый центроид (Нижний Тагил) # в ~190 км, далеко за пределами COVERAGE_CITY_MATCH_RADIUS_KM=25 — гарантированно # "город не определён", без совпадения ни с одним из 8 центроидов. -_FAR_AWAY_CITY = {"lat": 59.6047, "lon": 60.1970} +# Тавда: ближайший из центроидов (Каменск-Уральский) в 271 км, то есть точка +# заведомо вне 25-км радиуса любого поддержанного города — при этом всё ещё +# Свердловская область, как и задумано тестами ниже. +# +# Раньше здесь стояла точка 59.6047/60.1970 — окрестности Серова, в 21 км от +# его центра. Она работала как «далеко от всех» лишь потому, что Серов не был +# поддержан: город предлагался в дропдауне на сайте, но отсутствовал в списках +# покрытия, и его житель получал «этот адрес вне области». Серов добавлен +# 16.08.2026, и фикстура переехала туда, где действительно далеко. +_FAR_AWAY_CITY = {"lat": 58.0424, "lon": 65.2711} # ── Response schema: NO price anywhere (issue #2894 hard rule) ──────────────── @@ -283,7 +292,7 @@ def test_city_resolved_from_coordinates_not_cohort_mode(trade_in_app: FastAPI) - def test_far_from_all_centroids_not_covered(trade_in_app: FastAPI) -> None: - """Точка за пределами 25 км от всех 8 центроидов → not_covered, city="".""" + """Точка за пределами 25 км от всех центроидов → not_covered, city="".""" db = _db_mock_returning(_row(0, None, n_with_age=0)) _override(trade_in_app, db) diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py index 95efc4a5..5b834927 100644 --- a/tradein-mvp/backend/tests/test_public_mera_api.py +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -350,3 +350,105 @@ def test_daily_suggest_budget_protects_shared_geocoder_quota(client: TestClient) "если per-IP лимит сам по себе не может исчерпать суточный бюджет, " "потолок бессмысленен — проверь, что тест сторожит реальный сценарий" ) + + +# ── 7. Город: то, что предлагаем, и то, на что умеем отвечать ──────────────── + + +def test_offered_cities_match_coverage_cities() -> None: + """Дропдаун на сайте и списки покрытия — одно множество, записанное дважды. + + Разошлись на проде 16.08.2026: `Серов` предлагался к выбору, но + отсутствовал в порогах покрытия, поэтому проба резолвила город как + неизвестный и отвечала «этот адрес вне области, по которой мы собираем + данные» — про город В ТОЙ ЖЕ области, который мы сами же и предложили. + В обратную сторону `Берёзовский`, `Среднеуральск` и `Ревда` бэкенд + обслуживал, а выбрать их было нельзя. + + Шапка `city-registry.ts` этот риск прямо предсказывала («перед добавлением + 7-го города сверить оба списка вручную, теста на это пока нет»). Вот тест. + """ + import pathlib + import re + + from app.api.v1.trade_in import COVERAGE_GREEN_CITIES, COVERAGE_YELLOW_CITIES, _fold_city + + registry = ( + pathlib.Path(__file__).resolve().parents[2] + / "frontend" + / "src" + / "lib" + / "city-registry.ts" + ) + text = registry.read_text(encoding="utf-8") + block = re.search(r"OBLAST_CITIES:\s*readonly OblastCity\[\]\s*=\s*\[(.*?)\];", text, re.S) + assert block, "не нашли OBLAST_CITIES — проверка стала бы пустой" + + offered = {_fold_city(m) for m in re.findall(r'label:\s*"([^"]+)"', block.group(1))} + answerable = {_fold_city(c) for c in (*COVERAGE_GREEN_CITIES, *COVERAGE_YELLOW_CITIES)} + + assert offered == answerable, ( + "предлагаем выбрать и умеем отвечать — это должно быть одно множество.\n" + f" предлагаем, но не отвечаем: {sorted(offered - answerable)}\n" + f" отвечаем, но не предлагаем: {sorted(answerable - offered)}" + ) + + +def test_every_coverage_city_has_a_centroid() -> None: + """Порог без центроида недостижим: город резолвится по координатам.""" + from app.api.v1.trade_in import ( + _CITY_CENTROIDS_DEG, + COVERAGE_GREEN_CITIES, + COVERAGE_YELLOW_CITIES, + _fold_city, + ) + + centroids = {_fold_city(c) for c in _CITY_CENTROIDS_DEG} + for city in (*COVERAGE_GREEN_CITIES, *COVERAGE_YELLOW_CITIES): + assert _fold_city(city) in centroids, ( + f"{city} есть в порогах, но нет в центроидах — по координатам он " + "никогда не резолвится, и порог мёртвый" + ) + + +# ── 8. Подсказки слушаются выбранного города ──────────────────────────────── + + +def test_city_goes_into_the_query_itself() -> None: + """`city_hint` на выдачу подсказок не влияет — его смотрит только + екатеринбургский кадастровый тир, а DaData ограничена регионом целиком. + + Замер на проде: выбран Серов, введено «Ленина 1» → первой подсказкой + «Невьянский р-н, пгт Верх-Нейвинский». Человек выбирает верхний вариант и + считает чужой дом. + """ + assert public_mera._query_with_city("Ленина 1", "Серов") == "Серов, Ленина 1" + + # Город уже назван — не дублируем («Серов, Серов Ленина 1»). + assert public_mera._query_with_city("Серов Ленина 1", "Серов") == "Серов Ленина 1" + assert public_mera._query_with_city("серов ленина 1", "Серов") == "серов ленина 1" + + # ёЁ нормализуются: «Березовский» в тексте и «Берёзовский» в списке — один город. + assert public_mera._query_with_city("Березовский, Мира 5", "Берёзовский") == ( + "Березовский, Мира 5" + ) + + # Города нет — строка не трогается. + assert public_mera._query_with_city("Ленина 1", None) == "Ленина 1" + + +def test_suggest_passes_city_prefixed_query_downstream(client: TestClient) -> None: + """Проверяем не текст помощника, а то, что ручка реально отдаёт его вниз.""" + captured: dict[str, object] = {} + + async def _fake(**kwargs): + captured.update(kwargs) + return SuggestResponse(items=[]) + + with patch.object(public_mera, "suggest_addresses", _fake): + resp = client.post(f"{PREFIX}/suggest", json={"q": "Ленина 1", "city_hint": "Серов"}) + + assert resp.status_code == 200, resp.text + assert captured["q"] == "Серов, Ленина 1" + # Сам хинт продолжаем передавать: от него зависит гейт кадастрового тира. + assert captured["city_hint"] == "Серов" diff --git a/tradein-mvp/frontend/src/lib/city-registry.ts b/tradein-mvp/frontend/src/lib/city-registry.ts index bc07b8ab..ed968e4a 100644 --- a/tradein-mvp/frontend/src/lib/city-registry.ts +++ b/tradein-mvp/frontend/src/lib/city-registry.ts @@ -20,13 +20,23 @@ * * Список городов растёт по мере расширения покрытия скрейперами — не хардкодь * города россыпью по компонентам, добавляй сюда (мирроит паттерн - * source-registry.ts). Parity-риск (deep-review R2, дёшево): эти 6 лейблов - * должны оставаться подмножеством `SVERDLOVSK_OBLAST_CITIES` - * (tradein-mvp/backend/app/services/geocoder.py) — та же кириллица в нижнем - * регистре. Расхождение не ломается типами (`city_hint` — свободный текст, - * бэкенд его не валидирует по списку), но подсунет город без реального - * покрытия скрейпером. Перед добавлением 7-го города — сверить оба списка - * вручную (codegen/тест на это пока нет, backend — другой PR). + * source-registry.ts). + * + * ⚠️ PARITY. Этот список — то, что мы ПРЕДЛАГАЕМ человеку выбрать. Отвечать по + * городу умеет `_COVERAGE_CITY_THRESHOLDS` в + * `backend/app/api/v1/trade_in.py`, и это ДОЛЖНО быть одно множество. + * + * Прежняя редакция шапки предупреждала: «перед добавлением 7-го города сверить + * оба списка вручную, теста на это пока нет». Ровно так и разошлось (замер на + * проде 16.08.2026): здесь лежал Серов, которого не было в списках покрытия, и + * житель Серова, выбрав СВОЙ город из нашего же дропдауна, получал ответ «этот + * адрес вне области, по которой мы собираем данные». В обратную сторону + * Берёзовский, Среднеуральск и Ревда бэкенд обслуживал, а выбрать их было + * нельзя. + * + * Теперь сверка автоматическая: `backend/tests/test_public_mera_api.py` + * читает ЭТОТ файл и требует совпадения множеств. Добавляешь город сюда — + * добавь центроид и порог там же, иначе сборка красная. */ export interface OblastCity { @@ -44,10 +54,13 @@ export interface OblastCity { // остальных — по охвату сбора, не алфавитный. export const OBLAST_CITIES: readonly OblastCity[] = [ { id: "ekaterinburg", label: "Екатеринбург" }, + { id: "berezovsky", label: "Берёзовский" }, + { id: "verkhnyaya_pyshma", label: "Верхняя Пышма" }, + { id: "sredneuralsk", label: "Среднеуральск" }, { id: "nizhny_tagil", label: "Нижний Тагил" }, { id: "kamensk_uralsky", label: "Каменск-Уральский" }, { id: "pervouralsk", label: "Первоуральск" }, - { id: "verkhnyaya_pyshma", label: "Верхняя Пышма" }, + { id: "revda", label: "Ревда" }, { id: "serov", label: "Серов" }, ];