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
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:
commit
b474a5f44e
6 changed files with 193 additions and 12 deletions
|
|
@ -51,6 +51,13 @@ jobs:
|
||||||
# ОДИН сьют (та же дыра закрыта симметрично в ci.yml) — так на main
|
# ОДИН сьют (та же дыра закрыта симметрично в ci.yml) — так на main
|
||||||
# уехал красный test_get_role_known_users (2026-07-30 → PR #2587).
|
# уехал красный test_get_role_known_users (2026-07-30 → PR #2587).
|
||||||
- 'auth/**'
|
- 'auth/**'
|
||||||
|
# Реестр городов — фронтовый файл, но его читает БЭКЕНДОВЫЙ тест
|
||||||
|
# (tests/test_public_mera_api.py сверяет то, что мы предлагаем
|
||||||
|
# выбрать, с тем, на что умеет отвечать проба покрытия). Без этой
|
||||||
|
# строки правка одного лишь дропдауна не гоняла бы сверку — а
|
||||||
|
# разошлись списки ровно так: город добавили на фронте, в пороги
|
||||||
|
# покрытия не внесли, и житель Серова получал «вы вне области».
|
||||||
|
- 'tradein-mvp/frontend/src/lib/city-registry.ts'
|
||||||
- '.forgejo/workflows/ci-tradein.yml'
|
- '.forgejo/workflows/ci-tradein.yml'
|
||||||
frontend:
|
frontend:
|
||||||
- 'tradein-mvp/frontend/**'
|
- 'tradein-mvp/frontend/**'
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,41 @@ class PublicSuggestInput(BaseModel):
|
||||||
city_hint: str | None = Field(default=None, max_length=100)
|
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)
|
@router.post("/suggest", response_model=SuggestResponse)
|
||||||
async def public_suggest(
|
async def public_suggest(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -224,7 +259,10 @@ async def public_suggest(
|
||||||
# адрес не попадает в журналы.
|
# адрес не попадает в журналы.
|
||||||
with public_request_scope():
|
with public_request_scope():
|
||||||
return await suggest_addresses(
|
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:
|
finally:
|
||||||
_suggest_slots.release()
|
_suggest_slots.release()
|
||||||
|
|
|
||||||
|
|
@ -2577,8 +2577,19 @@ COVERAGE_MIN_AGE_SAMPLES = 5
|
||||||
COVERAGE_MAX_AGE_DAYS = 365
|
COVERAGE_MAX_AGE_DAYS = 365
|
||||||
|
|
||||||
# Списки городов и пороги — константа РЯДОМ С РУЧКОЙ (issue #2894 требование), не в БД.
|
# Списки городов и пороги — константа РЯДОМ С РУЧКОЙ (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_GREEN_CITIES = ("Екатеринбург", "Верхняя Пышма", "Берёзовский", "Среднеуральск")
|
||||||
COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Уральский", "Первоуральск", "Ревда")
|
# Серов добавлен 16.08.2026: в жёлтый тир, а не в зелёный — в радиусе 15 км от
|
||||||
|
# центра 363 активных объявления (все свежие), это на порядок меньше городов
|
||||||
|
# вокруг Екатеринбурга, но заведомо не ноль.
|
||||||
|
COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Уральский", "Первоуральск", "Ревда", "Серов")
|
||||||
COVERAGE_GREEN_MIN_N = 8
|
COVERAGE_GREEN_MIN_N = 8
|
||||||
COVERAGE_YELLOW_MIN_N = 12
|
COVERAGE_YELLOW_MIN_N = 12
|
||||||
|
|
||||||
|
|
@ -2624,6 +2635,7 @@ _CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
||||||
"Каменск-Уральский": (56.4110, 61.9243),
|
"Каменск-Уральский": (56.4110, 61.9243),
|
||||||
"Первоуральск": (56.9083, 59.9483),
|
"Первоуральск": (56.9083, 59.9483),
|
||||||
"Ревда": (56.7986, 59.9298),
|
"Ревда": (56.7986, 59.9298),
|
||||||
|
"Серов": (59.6047, 60.5772),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,16 @@ _BEREZOVSKY = {"lat": 56.9096, "lon": 60.8034}
|
||||||
# Реальные координаты Серова — ближайший поддерживаемый центроид (Нижний Тагил)
|
# Реальные координаты Серова — ближайший поддерживаемый центроид (Нижний Тагил)
|
||||||
# в ~190 км, далеко за пределами COVERAGE_CITY_MATCH_RADIUS_KM=25 — гарантированно
|
# в ~190 км, далеко за пределами COVERAGE_CITY_MATCH_RADIUS_KM=25 — гарантированно
|
||||||
# "город не определён", без совпадения ни с одним из 8 центроидов.
|
# "город не определён", без совпадения ни с одним из 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) ────────────────
|
# ── 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:
|
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))
|
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||||||
_override(trade_in_app, db)
|
_override(trade_in_app, db)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -350,3 +350,105 @@ def test_daily_suggest_budget_protects_shared_geocoder_quota(client: TestClient)
|
||||||
"если per-IP лимит сам по себе не может исчерпать суточный бюджет, "
|
"если 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"] == "Серов"
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,23 @@
|
||||||
*
|
*
|
||||||
* Список городов растёт по мере расширения покрытия скрейперами — не хардкодь
|
* Список городов растёт по мере расширения покрытия скрейперами — не хардкодь
|
||||||
* города россыпью по компонентам, добавляй сюда (мирроит паттерн
|
* города россыпью по компонентам, добавляй сюда (мирроит паттерн
|
||||||
* source-registry.ts). Parity-риск (deep-review R2, дёшево): эти 6 лейблов
|
* source-registry.ts).
|
||||||
* должны оставаться подмножеством `SVERDLOVSK_OBLAST_CITIES`
|
*
|
||||||
* (tradein-mvp/backend/app/services/geocoder.py) — та же кириллица в нижнем
|
* ⚠️ PARITY. Этот список — то, что мы ПРЕДЛАГАЕМ человеку выбрать. Отвечать по
|
||||||
* регистре. Расхождение не ломается типами (`city_hint` — свободный текст,
|
* городу умеет `_COVERAGE_CITY_THRESHOLDS` в
|
||||||
* бэкенд его не валидирует по списку), но подсунет город без реального
|
* `backend/app/api/v1/trade_in.py`, и это ДОЛЖНО быть одно множество.
|
||||||
* покрытия скрейпером. Перед добавлением 7-го города — сверить оба списка
|
*
|
||||||
* вручную (codegen/тест на это пока нет, backend — другой PR).
|
* Прежняя редакция шапки предупреждала: «перед добавлением 7-го города сверить
|
||||||
|
* оба списка вручную, теста на это пока нет». Ровно так и разошлось (замер на
|
||||||
|
* проде 16.08.2026): здесь лежал Серов, которого не было в списках покрытия, и
|
||||||
|
* житель Серова, выбрав СВОЙ город из нашего же дропдауна, получал ответ «этот
|
||||||
|
* адрес вне области, по которой мы собираем данные». В обратную сторону
|
||||||
|
* Берёзовский, Среднеуральск и Ревда бэкенд обслуживал, а выбрать их было
|
||||||
|
* нельзя.
|
||||||
|
*
|
||||||
|
* Теперь сверка автоматическая: `backend/tests/test_public_mera_api.py`
|
||||||
|
* читает ЭТОТ файл и требует совпадения множеств. Добавляешь город сюда —
|
||||||
|
* добавь центроид и порог там же, иначе сборка красная.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface OblastCity {
|
export interface OblastCity {
|
||||||
|
|
@ -44,10 +54,13 @@ export interface OblastCity {
|
||||||
// остальных — по охвату сбора, не алфавитный.
|
// остальных — по охвату сбора, не алфавитный.
|
||||||
export const OBLAST_CITIES: readonly OblastCity[] = [
|
export const OBLAST_CITIES: readonly OblastCity[] = [
|
||||||
{ id: "ekaterinburg", label: "Екатеринбург" },
|
{ id: "ekaterinburg", label: "Екатеринбург" },
|
||||||
|
{ id: "berezovsky", label: "Берёзовский" },
|
||||||
|
{ id: "verkhnyaya_pyshma", label: "Верхняя Пышма" },
|
||||||
|
{ id: "sredneuralsk", label: "Среднеуральск" },
|
||||||
{ id: "nizhny_tagil", label: "Нижний Тагил" },
|
{ id: "nizhny_tagil", label: "Нижний Тагил" },
|
||||||
{ id: "kamensk_uralsky", label: "Каменск-Уральский" },
|
{ id: "kamensk_uralsky", label: "Каменск-Уральский" },
|
||||||
{ id: "pervouralsk", label: "Первоуральск" },
|
{ id: "pervouralsk", label: "Первоуральск" },
|
||||||
{ id: "verkhnyaya_pyshma", label: "Верхняя Пышма" },
|
{ id: "revda", label: "Ревда" },
|
||||||
{ id: "serov", label: "Серов" },
|
{ id: "serov", label: "Серов" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue