Merge pull request 'fix(mera/b2c): житель Серова получал «вы вне области», а подсказки игнорировали выбранный город' (#2919) from fix/mera-city-parity into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m34s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Successful in 1m50s
Deploy Trade-In / deploy-status (push) Successful in 1s

This commit is contained in:
lekss361 2026-08-16 10:09:13 +00:00
commit b474a5f44e
6 changed files with 193 additions and 12 deletions

View file

@ -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/**'

View file

@ -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()

View file

@ -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),
}

View file

@ -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)

View file

@ -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"] == "Серов"

View file

@ -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: "Серов" },
];