Compare commits
No commits in common. "b474a5f44e9f4cfb9d1cc6625767db2c3ceebe47" and "d38e83df8296130f53342abe67cfd824c0fea098" have entirely different histories.
b474a5f44e
...
d38e83df82
6 changed files with 12 additions and 193 deletions
|
|
@ -51,13 +51,6 @@ 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,41 +162,6 @@ 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,
|
||||||
|
|
@ -259,10 +224,7 @@ async def public_suggest(
|
||||||
# адрес не попадает в журналы.
|
# адрес не попадает в журналы.
|
||||||
with public_request_scope():
|
with public_request_scope():
|
||||||
return await suggest_addresses(
|
return await suggest_addresses(
|
||||||
q=_query_with_city(payload.q, payload.city_hint),
|
q=payload.q, limit=payload.limit, db=db, city_hint=payload.city_hint
|
||||||
limit=payload.limit,
|
|
||||||
db=db,
|
|
||||||
city_hint=payload.city_hint,
|
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
_suggest_slots.release()
|
_suggest_slots.release()
|
||||||
|
|
|
||||||
|
|
@ -2577,19 +2577,8 @@ 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 = ("Екатеринбург", "Верхняя Пышма", "Берёзовский", "Среднеуральск")
|
||||||
# Серов добавлен 16.08.2026: в жёлтый тир, а не в зелёный — в радиусе 15 км от
|
COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Уральский", "Первоуральск", "Ревда")
|
||||||
# центра 363 активных объявления (все свежие), это на порядок меньше городов
|
|
||||||
# вокруг Екатеринбурга, но заведомо не ноль.
|
|
||||||
COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Уральский", "Первоуральск", "Ревда", "Серов")
|
|
||||||
COVERAGE_GREEN_MIN_N = 8
|
COVERAGE_GREEN_MIN_N = 8
|
||||||
COVERAGE_YELLOW_MIN_N = 12
|
COVERAGE_YELLOW_MIN_N = 12
|
||||||
|
|
||||||
|
|
@ -2635,7 +2624,6 @@ _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,16 +109,7 @@ _BEREZOVSKY = {"lat": 56.9096, "lon": 60.8034}
|
||||||
# Реальные координаты Серова — ближайший поддерживаемый центроид (Нижний Тагил)
|
# Реальные координаты Серова — ближайший поддерживаемый центроид (Нижний Тагил)
|
||||||
# в ~190 км, далеко за пределами COVERAGE_CITY_MATCH_RADIUS_KM=25 — гарантированно
|
# в ~190 км, далеко за пределами COVERAGE_CITY_MATCH_RADIUS_KM=25 — гарантированно
|
||||||
# "город не определён", без совпадения ни с одним из 8 центроидов.
|
# "город не определён", без совпадения ни с одним из 8 центроидов.
|
||||||
# Тавда: ближайший из центроидов (Каменск-Уральский) в 271 км, то есть точка
|
_FAR_AWAY_CITY = {"lat": 59.6047, "lon": 60.1970}
|
||||||
# заведомо вне 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) ────────────────
|
||||||
|
|
@ -292,7 +283,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 км от всех центроидов → not_covered, city=""."""
|
"""Точка за пределами 25 км от всех 8 центроидов → 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,105 +350,3 @@ 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,23 +20,13 @@
|
||||||
*
|
*
|
||||||
* Список городов растёт по мере расширения покрытия скрейперами — не хардкодь
|
* Список городов растёт по мере расширения покрытия скрейперами — не хардкодь
|
||||||
* города россыпью по компонентам, добавляй сюда (мирроит паттерн
|
* города россыпью по компонентам, добавляй сюда (мирроит паттерн
|
||||||
* source-registry.ts).
|
* source-registry.ts). Parity-риск (deep-review R2, дёшево): эти 6 лейблов
|
||||||
*
|
* должны оставаться подмножеством `SVERDLOVSK_OBLAST_CITIES`
|
||||||
* ⚠️ PARITY. Этот список — то, что мы ПРЕДЛАГАЕМ человеку выбрать. Отвечать по
|
* (tradein-mvp/backend/app/services/geocoder.py) — та же кириллица в нижнем
|
||||||
* городу умеет `_COVERAGE_CITY_THRESHOLDS` в
|
* регистре. Расхождение не ломается типами (`city_hint` — свободный текст,
|
||||||
* `backend/app/api/v1/trade_in.py`, и это ДОЛЖНО быть одно множество.
|
* бэкенд его не валидирует по списку), но подсунет город без реального
|
||||||
*
|
* покрытия скрейпером. Перед добавлением 7-го города — сверить оба списка
|
||||||
* Прежняя редакция шапки предупреждала: «перед добавлением 7-го города сверить
|
* вручную (codegen/тест на это пока нет, backend — другой PR).
|
||||||
* оба списка вручную, теста на это пока нет». Ровно так и разошлось (замер на
|
|
||||||
* проде 16.08.2026): здесь лежал Серов, которого не было в списках покрытия, и
|
|
||||||
* житель Серова, выбрав СВОЙ город из нашего же дропдауна, получал ответ «этот
|
|
||||||
* адрес вне области, по которой мы собираем данные». В обратную сторону
|
|
||||||
* Берёзовский, Среднеуральск и Ревда бэкенд обслуживал, а выбрать их было
|
|
||||||
* нельзя.
|
|
||||||
*
|
|
||||||
* Теперь сверка автоматическая: `backend/tests/test_public_mera_api.py`
|
|
||||||
* читает ЭТОТ файл и требует совпадения множеств. Добавляешь город сюда —
|
|
||||||
* добавь центроид и порог там же, иначе сборка красная.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface OblastCity {
|
export interface OblastCity {
|
||||||
|
|
@ -54,13 +44,10 @@ 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: "revda", label: "Ревда" },
|
{ id: "verkhnyaya_pyshma", label: "Верхняя Пышма" },
|
||||||
{ id: "serov", label: "Серов" },
|
{ id: "serov", label: "Серов" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue