Оценка: при якоре того же дома видно, что коридор сделок цену не ограничивал #3554

Merged
bot-backend merged 4 commits from fix/corridor-advisory-tier-a into main 2026-09-17 09:22:51 +00:00
Collaborator

Closes #3466

Условие мержа (блокирующее, из ревью)

Правка трогает tradein-mvp/backend, поэтому deploy-tradein пересоздаст tradein-scraper. Работающие обходы при этом оборвутся. Слив в самом деплое ждёт не дольше 5 минут, потом пересоздаёт контейнер всё равно (deploy-tradein.yml, «WARNING: активные scrape_runs остались после 5 мин ожидания — recreate продолжится»). Длинный обход он не спасает.

Поэтому мержить только при running = 0, и проверять это в том же блоке, что и мерж, а не заранее:

n=$(ssh -o BindInterface=en0 poincare "docker exec tradein-postgres psql -U tradein -d tradein -tAc \"SELECT count(*) FROM scrape_runs WHERE status='running'\"")
echo "running=$n"
[ "$n" = "0" ] && <мерж PR #3554> || echo "НЕ МЕРЖИТЬ: идут обходы ($n)"

Число меняется за минуты: 17.09 около 07:40 UTC было 3, в 08:45 UTC 0, в 08:48 UTC снова 1. Старый замер для решения не годится. Сам блок проверен вживую (без мержа): 08:48 UTC он напечатал running=1 и «НЕ МЕРЖИТЬ: идут обходы (1)».

Что меняется для клиента

Когда цена посчитана по аналогам из того же дома (якорь Tier A), рядом с коридором сделок Росреестра теперь стоит подпись: «справочно: цена посчитана по аналогам в этом же доме — коридор её не ограничивает». Так бывает и при двадцати сделках на улице. До правки подписи не было: коридор выглядел рабочим, хотя цену не трогал, причём именно в том случае, которому клиент доверяет больше всего.

Подпись остаётся и при повторном открытии оценки по ссылке ?id=.

Что было и почему

DkpCorridor.advisory_only (#3452) считается только из числа сделок. Но кламп headline выключается ещё и якорем того же дома: _apply_corridor_clamp не трогает Tier A, а radius-floor работает только при anchor_tier is None. Ревьюер #3462 воспроизвёл это: n=20, advisory_only=False, headline 202 100 ₽/м² против потолка 140 000, подписи нет.

За 30 дней на проде 16 живых оценок Tier A, у 3 из них цена выше коридора. Это SELECT по confidence_explanation 17.09.

Что сделано

Признак. Отдельного поля нет, признаком служит уже существующее AggregatedEstimate.analog_tier == "same_building" (issue предлагал «пометку на другом уровне ответа»). corridorAdvisoryNote (lib/rosreestr.ts) получила тир, и оба её вызова, v1 HeroSummary и v2 mappers.resolveDealTier, его передают. Порядок проверок такой: сначала тир дома, потом размер выборки. Для Tier A подпись про дом точнее: коридор не входит в цену при любом count, а гейт Tier C к этому тиру не относится.

GET-rehydrate. Раньше analog_tier там всегда был null, потому что колонки под него нет. Миграции для этой группы не выдавали, поэтому якорный тир (same_building/micro_radius) теперь восстанавливается из подписи, которую пишет якорный блок. Это estimator.analog_tier_from_explanation, та же схема, что у радиуса в #2632: писатель и читатель используют общую константу ANCHOR_TIER_LABELS. Ищется вся фраза «Оценка построена по N аналогам из …», а не хвост «из того же дома»: радиусный тир S пишет рядом «(аналоги из того же дома)». На проде таких строк 8, в 4 из них есть и якорная фраза. Все 8 от 29–30.05, ни одна уже не открывается по ?id=, и ни у одной из 16 живых Tier A этой пометки нет. Радиусные тиры на GET по-прежнему null. Ограничение помечено ponytail:: если понадобятся радиусные тиры на GET, нужна колонка.

Побочный эффект на GET (не регрессия). Для 93 живых оценок Tier C GET теперь отдаёт analog_tier = "micro_radius", раньше было null. Это меняет только одно место: лейбл в HeroTransparency.resolveCompsTier. Раньше он брался эвристикой по тексту («по окружению ≤500 м»), теперь берётся из поля («по ближайшему окружению ≤500 м»), смысл тот же. Опасный случай, когда эвристика читала бы у Tier C текст ghost-anchor «аналоги из того же дома» и писала «того же дома», на проде не встречается: 0 из 93. На подпись коридора micro_radius не влияет, для него corridorAdvisoryNote ведёт себя как для null. Других потребителей analog_tier нет ни на фронте, ни в бэкенде (grep).

Хвосты ревью #3462.

  • Фронт-тест на corridorAdvisoryNote: v2/__tests__/corridorAdvisoryNote.test.ts, 10 тестов по образцу dealsAsOf.test.ts. Сама функция проверена на комбинациях advisory_only true/false/undefined, headlineFromDeals и тира; ещё отдельно проверено, что подпись доезжает до плитки через mapResultPanel. Вызов в v1 HeroSummary проверяется рендером в __tests__/HeroSummaryCorridorNote.test.tsx, см. «Правки по ревью #3554».
  • Порог у radius-floor не стерёгся. Всё оказалось хуже, чем в ревью: тесты test_estimator_radius_floor.py вообще не доходили до floor. Три лота меньше HEADLINE_LISTINGS_MIN_N=5, поэтому срабатывал #oblast-E и headline брался из медианы коридора (180k), которая тоже «≥ floor». На origin/main файл зелёный даже с полностью выключенным floor (прогон ниже). Теперь в тестах шесть лотов, ожидания точные (== 120000, == 200000), есть кейсы count = min_n − 1 и count = min_n.
  • Полоса счёта. Маркер corridor_advisory_zone считает n = 1..9, а не 3..9. Уличный коридор отдаётся уже с одной сделки: для Екатеринбурга widen не срабатывает вовсе, а вне ЕКБ city-выборка берётся только если она больше уличной. «Пол» в widen это не закрыл бы, поэтому поправлены формулировки: комментарий у лога, докстринги advisory_only/DkpCorridor, докстринг test_3452. Добавлен тест: _fetch_dkp_corridor для ЕКБ с двумя сделками возвращает count=2. Приёмку #3452 читать как n = 1..9.

Правки по ревью #3554

Каждую находку сначала проверил сам. Опровергнутых нет, все подтвердились.

Исправлено кодом (отдельные коммиты):

  1. Вызов в v1 HeroSummary не был покрыт (803ea221). Проверка: убрал аргумент estimate.analog_tier из вызова в HeroSummary.tsx (мутация F2), vitest 33 файла, 305 passed, rc=0. Подтверждено. Сделано: рендер HeroSummary по образцу thinMarketBadge.test.tsx, n=20, advisory_only=false. При same_building подпись про дом есть в тексте hero, при micro_radius её нет. Та же мутация F2 теперь даёт красный, текст ниже (фальсификация 7).
  2. Граница radius-floor count == min_n не стереглась (49bcfdac). Проверка: >=> в estimator.py, tests/test_estimator_radius_floor.py дал 4 passed, rc=0. Подтверждено. Сделано: тест test_floor_applied_at_exactly_clamp_min_n, в нём n = estimate_corridor_clamp_min_n, медиана 80k поднимается ровно до 120k. Дополнительно в нём проверено, что DkpCorridor на той же границе не справочный: витрина молчит, значит floor обязан работать. Мутант > теперь красный (фальсификация 8).

Исправлено в описании:
3. Пункт 3 приёмки был неточен. Проверка: v2/mappers.resolveDealTier. При streetDeals.count > 0 плитка ДКП строится из street-deals с advisoryNote: null, и коридора в v2 нет совсем. dkp_corridor рисуют только HeroSummary и эта ветка mappers.ts. HeroSummary подключён в src/app/page.tsx, то есть на корневой /trade-in/?id=, а не на /trade-in/v2. Подтверждено, приёмка переписана: открывать обе страницы.
4. «Таких строк 4» было неверно. Проверка на проде 17.09 08:45 UTC: 8 строк с «(аналоги из того же дома)», у 4 из них есть и якорная фраза, 0 открываются по ?id=, даты 29–30.05. Живых Tier A 16, все за последние 30 дней, у 0 из них есть эта пометка. Подтверждено, текст исправлен. На разбор это не влияет.
5. Не упомянут побочный эффект для Tier C на GET. Проверка на проде: 93 живых Tier C. Заодно проверил эвристику, которая раньше давала лейбл: ни одна из 93 не читалась ею как «того же дома». Подтверждено, добавлен абзац выше.

Без изменений:
6. Разбор тира по прозе хрупок. Согласен. Это уже помечено ponytail: у ANCHOR_TIER_LABELS, а ослабление regex до хвоста ловится тестом (фальсификация 2). Колонка понадобится вместе с радиусными тирами на GET.
7. Условие мержа по scrape_runs вынесено наверх описания в виде одного блока «проверить и смержить». В коде менять нечего.

Тесты

  • backend, затронутые файлы: pytest tests/test_3466_corridor_tier_a.py tests/test_estimate_idor.py tests/test_estimate_revival.py tests/test_estimator_radius_floor.py tests/test_estimator_search_radius_2632.py tests/test_3452_corridor_advisory_zone.py: 71 passed.
  • backend, весь сьют после merge origin/main: DATABASE_URL=… pytest tests/ -q -p no:cacheprovider: 6233 passed, 42 skipped, rc=0 (было 6232, +1 тест по ревью).
  • ruff check app tests: rc=0; ruff format --check по изменённым файлам: rc=0.
  • frontend после merge origin/main: tsc --noEmit rc=0, eslint нового теста rc=0, vitest run: 34 файла, 307 тестов, rc=0 (+10 в первом коммите, +2 по ревью).

Фальсификация (исходник копировался в scratchpad, после каждого прогона восстановлен, diff -q чистый)

  1. Снят exempt Tier A в _apply_corridor_clamp (if False and anchor_tier == "A"):
E  AssertionError: коридор из 20 сделок изменил цену Tier A — значит он в цену вошёл, и признак same_building на витрине врёт: (140000.0, 7000000, 6089411, 7910588) против (204000.0, 10200000, 8873142, 11526857)
FAILED tests/test_3466_corridor_tier_a.py::test_tier_a_price_does_not_depend_on_corridor
1 failed, 4 passed
  1. Разбор ослаблен до хвоста из {label}:
E  AssertionError: assert 'same_building' is None
   ... ('Найдено 6 аналогов из 6 разных адресов, разброс цены ±0% от медианы. (аналоги из того же дома)')
FAILED tests/test_3466_corridor_tier_a.py::test_radius_same_house_note_is_not_anchor
  1. Из GET убрана строка analog_tier=analog_tier_from_explanation(...):
E  AssertionError: assert None == 'same_building'
FAILED tests/test_estimate_idor.py::test_get_estimate_restores_anchor_tier
1 failed, 37 passed
  1. Снят гейт count >= estimate_corridor_clamp_min_n у radius-floor:
E  AssertionError: median_ppm2=120000: коридор из 9 сделок ниже порога не должен поднимать цену до floor=120000
FAILED tests/test_estimator_radius_floor.py::test_floor_not_applied_below_clamp_min_n

Тот же мутант с тестовым файлом из origin/main: 3 passed, rc=0.
5. Floor выключен целиком (if False and dkp_raw …): файл из origin/main 3 passed, rc=0, файл ветки median_ppm2=80000 должна быть поднята ровно до floor=120000.0, rc=1.
6. Фронт: ветка same_building в corridorAdvisoryNote выключена:

× corridorAdvisoryNote > #3466: сделок много, но цена по дому — подпись есть
  AssertionError: expected null to be 'справочно: цена посчитана по аналогам…'
× #3466 подпись доезжает до плитки v2 > same_building + n=20 → подпись про дом
  AssertionError: expected undefined to be 'справочно: цена посчитана по аналогам…'
Tests  3 failed | 7 passed (10)
  1. (ревью) Из вызова в HeroSummary.tsx убран аргумент estimate.analog_tier (F2). До нового теста весь vitest был зелёным, 305 passed, rc=0. После него:
× #3466 подпись коридора в v1 HeroSummary > same_building + n=20 → подпись про дом стоит в hero
AssertionError: expected 'Секция 1 · СводкаАнализ рынка и расчё…' to contain 'справочно: цена посчитана по аналогам…'
Test Files  1 failed | 33 passed (34)
Tests  1 failed | 306 passed (307)
rc=1
  1. (ревью) Гейт radius-floor >= заменён на >. До нового теста файл давал 4 passed, rc=0, после:
E  AssertionError: median_ppm2=80000: коридор из 10 сделок (ровно порог) должен поднять цену ровно до floor=120000
E  assert 80000 == (150000 * 0.8)
FAILED tests/test_estimator_radius_floor.py::test_floor_applied_at_exactly_clamp_min_n
1 failed, 4 passed
rc=1

Деплой

Своей миграции нет. Merge origin/main принёс в ветку чужую 308_domclick_msk_disable_until_incremental_save.sql (#3567), но она уже в main и накатится независимо от этого PR. Правки в tradein-mvp/backend и tradein-mvp/frontend, поэтому deploy-tradein пересоздаст tradein-backend, tradein-browser, tradein-tgbot, tradein-frontend (отдельной командой) и tradein-scraper (backend changed → SCRAPER_RECREATE). Гейт по scrape_runs описан в начале. Это условие мержа.

Приёмка на проде (после деплоя)

  1. Маркер кода в контейнере: docker exec tradein-backend grep -c analog_tier_from_explanation /app/app/api/v1/trade_in.py ≥ 1. На 17.09 в контейнере 0.
  2. Взять живую оценку Tier A: SELECT id FROM trade_in_estimates WHERE confidence_explanation ~ 'Оценка построена по [0-9]+ аналогам из того же дома' AND median_price > 0 AND (expires_at > now() OR retain_until > now()) ORDER BY created_at DESC LIMIT 3. GET /api/v1/trade-in/estimate/{id} должен отдать "analog_tier": "same_building", до деплоя там null. Обратное направление: у живой радиусной оценки (якорной фразы нет) остаётся null. Радиусных строк с «(аналоги из того же дома)», которые открываются по ?id=, на проде нет (0 из 8), поэтому этот случай стережёт только тест test_radius_same_house_note_is_not_anchor.
  3. Открыть эту оценку на обеих страницах и сделать скриншоты:
    • корневая /trade-in/?id=… (v1 HeroSummary): подпись про дом стоит под коридором ДКП, и в плашке «ДКП · Росреестр», и в блоке цены;
    • /trade-in/v2?id=…: подпись есть в плитке «ДКП · Росреестр» только когда street-deals пусты. Если street-deals есть, плитка строится из них, коридора в v2 нет вовсе, и отсутствие подписи там правильно, а не провал.
      Обратное направление: у радиусной оценки с n ≥ 10 подписи нет ни на одной странице.
  4. Tier C на GET: у живой оценки с «Оценка построена по N аналогам из ближайшего окружения (≤500 м)» GET отдаёт analog_tier = "micro_radius". На корневой /trade-in/?id=… в «Как рассчитано» → «Сопоставление» написано «по ближайшему окружению ≤500 м», до деплоя было «по окружению ≤500 м».
  5. Срок: первые 24 ч после деплоя. Если за это время не появится ни одной свежей оценки Tier A, пп. 2–3 выполняются на уже существующих (на 17.09 их 16).

Замечено попутно, не чинилось

У ghost-anchor guard (estimator.py, «Оценка опирается только на аналоги из того же дома») текст один и тот же для Tier A и Tier C. Для Tier C (окружение ≤500 м) это неправда. Разбор тира этот текст не читает. На 17.09 у живых Tier C этого текста нет (0 из 93).

🤖 Generated with Claude Code

Closes #3466 ## Условие мержа (блокирующее, из ревью) Правка трогает `tradein-mvp/backend`, поэтому deploy-tradein пересоздаст `tradein-scraper`. Работающие обходы при этом оборвутся. Слив в самом деплое ждёт не дольше 5 минут, потом пересоздаёт контейнер всё равно (`deploy-tradein.yml`, «WARNING: активные scrape_runs остались после 5 мин ожидания — recreate продолжится»). Длинный обход он не спасает. Поэтому **мержить только при `running = 0`, и проверять это в том же блоке, что и мерж**, а не заранее: ```sh n=$(ssh -o BindInterface=en0 poincare "docker exec tradein-postgres psql -U tradein -d tradein -tAc \"SELECT count(*) FROM scrape_runs WHERE status='running'\"") echo "running=$n" [ "$n" = "0" ] && <мерж PR #3554> || echo "НЕ МЕРЖИТЬ: идут обходы ($n)" ``` Число меняется за минуты: 17.09 около 07:40 UTC было 3, в 08:45 UTC 0, в 08:48 UTC снова 1. Старый замер для решения не годится. Сам блок проверен вживую (без мержа): 08:48 UTC он напечатал `running=1` и «НЕ МЕРЖИТЬ: идут обходы (1)». ## Что меняется для клиента Когда цена посчитана по аналогам из того же дома (якорь Tier A), рядом с коридором сделок Росреестра теперь стоит подпись: **«справочно: цена посчитана по аналогам в этом же доме — коридор её не ограничивает»**. Так бывает и при двадцати сделках на улице. До правки подписи не было: коридор выглядел рабочим, хотя цену не трогал, причём именно в том случае, которому клиент доверяет больше всего. Подпись остаётся и при повторном открытии оценки по ссылке `?id=`. ## Что было и почему `DkpCorridor.advisory_only` (#3452) считается только из числа сделок. Но кламп headline выключается ещё и якорем того же дома: `_apply_corridor_clamp` не трогает Tier A, а radius-floor работает только при `anchor_tier is None`. Ревьюер #3462 воспроизвёл это: n=20, `advisory_only=False`, headline 202 100 ₽/м² против потолка 140 000, подписи нет. За 30 дней на проде 16 живых оценок Tier A, у 3 из них цена выше коридора. Это `SELECT` по `confidence_explanation` 17.09. ## Что сделано **Признак.** Отдельного поля нет, признаком служит уже существующее `AggregatedEstimate.analog_tier == "same_building"` (issue предлагал «пометку на другом уровне ответа»). `corridorAdvisoryNote` (`lib/rosreestr.ts`) получила тир, и оба её вызова, v1 `HeroSummary` и v2 `mappers.resolveDealTier`, его передают. Порядок проверок такой: сначала тир дома, потом размер выборки. Для Tier A подпись про дом точнее: коридор не входит в цену при любом count, а гейт Tier C к этому тиру не относится. **GET-rehydrate.** Раньше `analog_tier` там всегда был `null`, потому что колонки под него нет. Миграции для этой группы не выдавали, поэтому якорный тир (`same_building`/`micro_radius`) теперь восстанавливается из подписи, которую пишет якорный блок. Это `estimator.analog_tier_from_explanation`, та же схема, что у радиуса в #2632: писатель и читатель используют общую константу `ANCHOR_TIER_LABELS`. Ищется вся фраза «Оценка построена по N аналогам из …», а не хвост «из того же дома»: радиусный тир S пишет рядом «(аналоги из того же дома)». На проде таких строк **8**, в 4 из них есть и якорная фраза. Все 8 от 29–30.05, ни одна уже не открывается по `?id=`, и ни у одной из 16 живых Tier A этой пометки нет. Радиусные тиры на GET по-прежнему `null`. Ограничение помечено `ponytail:`: если понадобятся радиусные тиры на GET, нужна колонка. **Побочный эффект на GET (не регрессия).** Для 93 живых оценок Tier C GET теперь отдаёт `analog_tier = "micro_radius"`, раньше было `null`. Это меняет только одно место: лейбл в `HeroTransparency.resolveCompsTier`. Раньше он брался эвристикой по тексту («по окружению ≤500 м»), теперь берётся из поля («по ближайшему окружению ≤500 м»), смысл тот же. Опасный случай, когда эвристика читала бы у Tier C текст ghost-anchor «аналоги из того же дома» и писала «того же дома», на проде не встречается: 0 из 93. На подпись коридора `micro_radius` не влияет, для него `corridorAdvisoryNote` ведёт себя как для `null`. Других потребителей `analog_tier` нет ни на фронте, ни в бэкенде (grep). **Хвосты ревью #3462.** - *Фронт-тест на `corridorAdvisoryNote`*: `v2/__tests__/corridorAdvisoryNote.test.ts`, 10 тестов по образцу `dealsAsOf.test.ts`. Сама функция проверена на комбинациях advisory_only true/false/undefined, headlineFromDeals и тира; ещё отдельно проверено, что подпись доезжает до плитки через `mapResultPanel`. Вызов в v1 `HeroSummary` проверяется рендером в `__tests__/HeroSummaryCorridorNote.test.tsx`, см. «Правки по ревью #3554». - *Порог у radius-floor не стерёгся.* Всё оказалось хуже, чем в ревью: тесты `test_estimator_radius_floor.py` вообще не доходили до floor. Три лота меньше `HEADLINE_LISTINGS_MIN_N=5`, поэтому срабатывал #oblast-E и headline брался из медианы коридора (180k), которая тоже «≥ floor». **На origin/main файл зелёный даже с полностью выключенным floor** (прогон ниже). Теперь в тестах шесть лотов, ожидания точные (`== 120000`, `== 200000`), есть кейсы `count = min_n − 1` и `count = min_n`. - *Полоса счёта.* Маркер `corridor_advisory_zone` считает n = **1..9**, а не 3..9. Уличный коридор отдаётся уже с одной сделки: для Екатеринбурга widen не срабатывает вовсе, а вне ЕКБ city-выборка берётся только если она больше уличной. «Пол» в widen это не закрыл бы, поэтому поправлены формулировки: комментарий у лога, докстринги `advisory_only`/`DkpCorridor`, докстринг `test_3452`. Добавлен тест: `_fetch_dkp_corridor` для ЕКБ с двумя сделками возвращает `count=2`. **Приёмку #3452 читать как n = 1..9.** ## Правки по ревью #3554 Каждую находку сначала проверил сам. Опровергнутых нет, все подтвердились. **Исправлено кодом (отдельные коммиты):** 1. *Вызов в v1 `HeroSummary` не был покрыт* (`803ea221`). Проверка: убрал аргумент `estimate.analog_tier` из вызова в `HeroSummary.tsx` (мутация F2), vitest `33 файла, 305 passed, rc=0`. Подтверждено. Сделано: рендер `HeroSummary` по образцу `thinMarketBadge.test.tsx`, n=20, `advisory_only=false`. При `same_building` подпись про дом есть в тексте hero, при `micro_radius` её нет. Та же мутация F2 теперь даёт красный, текст ниже (фальсификация 7). 2. *Граница radius-floor `count == min_n` не стереглась* (`49bcfdac`). Проверка: `>=` → `>` в `estimator.py`, `tests/test_estimator_radius_floor.py` дал `4 passed, rc=0`. Подтверждено. Сделано: тест `test_floor_applied_at_exactly_clamp_min_n`, в нём n = `estimate_corridor_clamp_min_n`, медиана 80k поднимается ровно до 120k. Дополнительно в нём проверено, что `DkpCorridor` на той же границе не справочный: витрина молчит, значит floor обязан работать. Мутант `>` теперь красный (фальсификация 8). **Исправлено в описании:** 3. *Пункт 3 приёмки был неточен.* Проверка: `v2/mappers.resolveDealTier`. При `streetDeals.count > 0` плитка ДКП строится из street-deals с `advisoryNote: null`, и коридора в v2 нет совсем. `dkp_corridor` рисуют только `HeroSummary` и эта ветка `mappers.ts`. `HeroSummary` подключён в `src/app/page.tsx`, то есть на корневой `/trade-in/?id=`, а не на `/trade-in/v2`. Подтверждено, приёмка переписана: открывать обе страницы. 4. *«Таких строк 4»* было неверно. Проверка на проде 17.09 08:45 UTC: 8 строк с «(аналоги из того же дома)», у 4 из них есть и якорная фраза, 0 открываются по `?id=`, даты 29–30.05. Живых Tier A 16, все за последние 30 дней, у 0 из них есть эта пометка. Подтверждено, текст исправлен. На разбор это не влияет. 5. *Не упомянут побочный эффект для Tier C на GET.* Проверка на проде: 93 живых Tier C. Заодно проверил эвристику, которая раньше давала лейбл: ни одна из 93 не читалась ею как «того же дома». Подтверждено, добавлен абзац выше. **Без изменений:** 6. *Разбор тира по прозе хрупок.* Согласен. Это уже помечено `ponytail:` у `ANCHOR_TIER_LABELS`, а ослабление regex до хвоста ловится тестом (фальсификация 2). Колонка понадобится вместе с радиусными тирами на GET. 7. *Условие мержа по `scrape_runs`* вынесено наверх описания в виде одного блока «проверить и смержить». В коде менять нечего. ## Тесты - backend, затронутые файлы: `pytest tests/test_3466_corridor_tier_a.py tests/test_estimate_idor.py tests/test_estimate_revival.py tests/test_estimator_radius_floor.py tests/test_estimator_search_radius_2632.py tests/test_3452_corridor_advisory_zone.py`: 71 passed. - backend, весь сьют после merge origin/main: `DATABASE_URL=… pytest tests/ -q -p no:cacheprovider`: **6233 passed, 42 skipped, rc=0** (было 6232, +1 тест по ревью). - `ruff check app tests`: rc=0; `ruff format --check` по изменённым файлам: rc=0. - frontend после merge origin/main: `tsc --noEmit` rc=0, `eslint` нового теста rc=0, `vitest run`: **34 файла, 307 тестов, rc=0** (+10 в первом коммите, +2 по ревью). ## Фальсификация (исходник копировался в scratchpad, после каждого прогона восстановлен, `diff -q` чистый) 1. Снят exempt Tier A в `_apply_corridor_clamp` (`if False and anchor_tier == "A"`): ``` E AssertionError: коридор из 20 сделок изменил цену Tier A — значит он в цену вошёл, и признак same_building на витрине врёт: (140000.0, 7000000, 6089411, 7910588) против (204000.0, 10200000, 8873142, 11526857) FAILED tests/test_3466_corridor_tier_a.py::test_tier_a_price_does_not_depend_on_corridor 1 failed, 4 passed ``` 2. Разбор ослаблен до хвоста `из {label}`: ``` E AssertionError: assert 'same_building' is None ... ('Найдено 6 аналогов из 6 разных адресов, разброс цены ±0% от медианы. (аналоги из того же дома)') FAILED tests/test_3466_corridor_tier_a.py::test_radius_same_house_note_is_not_anchor ``` 3. Из GET убрана строка `analog_tier=analog_tier_from_explanation(...)`: ``` E AssertionError: assert None == 'same_building' FAILED tests/test_estimate_idor.py::test_get_estimate_restores_anchor_tier 1 failed, 37 passed ``` 4. Снят гейт `count >= estimate_corridor_clamp_min_n` у radius-floor: ``` E AssertionError: median_ppm2=120000: коридор из 9 сделок ниже порога не должен поднимать цену до floor=120000 FAILED tests/test_estimator_radius_floor.py::test_floor_not_applied_below_clamp_min_n ``` Тот же мутант с тестовым файлом из origin/main: `3 passed, rc=0`. 5. Floor выключен целиком (`if False and dkp_raw …`): файл из origin/main `3 passed, rc=0`, файл ветки `median_ppm2=80000 должна быть поднята ровно до floor=120000.0`, rc=1. 6. Фронт: ветка same_building в `corridorAdvisoryNote` выключена: ``` × corridorAdvisoryNote > #3466: сделок много, но цена по дому — подпись есть AssertionError: expected null to be 'справочно: цена посчитана по аналогам…' × #3466 подпись доезжает до плитки v2 > same_building + n=20 → подпись про дом AssertionError: expected undefined to be 'справочно: цена посчитана по аналогам…' Tests 3 failed | 7 passed (10) ``` 7. (ревью) Из вызова в `HeroSummary.tsx` убран аргумент `estimate.analog_tier` (F2). До нового теста весь vitest был зелёным, `305 passed, rc=0`. После него: ``` × #3466 подпись коридора в v1 HeroSummary > same_building + n=20 → подпись про дом стоит в hero AssertionError: expected 'Секция 1 · СводкаАнализ рынка и расчё…' to contain 'справочно: цена посчитана по аналогам…' Test Files 1 failed | 33 passed (34) Tests 1 failed | 306 passed (307) rc=1 ``` 8. (ревью) Гейт radius-floor `>=` заменён на `>`. До нового теста файл давал `4 passed, rc=0`, после: ``` E AssertionError: median_ppm2=80000: коридор из 10 сделок (ровно порог) должен поднять цену ровно до floor=120000 E assert 80000 == (150000 * 0.8) FAILED tests/test_estimator_radius_floor.py::test_floor_applied_at_exactly_clamp_min_n 1 failed, 4 passed rc=1 ``` ## Деплой Своей миграции нет. Merge origin/main принёс в ветку чужую `308_domclick_msk_disable_until_incremental_save.sql` (#3567), но она уже в main и накатится независимо от этого PR. Правки в `tradein-mvp/backend` и `tradein-mvp/frontend`, поэтому deploy-tradein пересоздаст `tradein-backend`, `tradein-browser`, `tradein-tgbot`, `tradein-frontend` (отдельной командой) и `tradein-scraper` (backend changed → `SCRAPER_RECREATE`). **Гейт по `scrape_runs` описан в начале. Это условие мержа.** ## Приёмка на проде (после деплоя) 1. Маркер кода в контейнере: `docker exec tradein-backend grep -c analog_tier_from_explanation /app/app/api/v1/trade_in.py` ≥ 1. На 17.09 в контейнере 0. 2. Взять живую оценку Tier A: `SELECT id FROM trade_in_estimates WHERE confidence_explanation ~ 'Оценка построена по [0-9]+ аналогам из того же дома' AND median_price > 0 AND (expires_at > now() OR retain_until > now()) ORDER BY created_at DESC LIMIT 3`. `GET /api/v1/trade-in/estimate/{id}` должен отдать `"analog_tier": "same_building"`, до деплоя там `null`. Обратное направление: у живой радиусной оценки (якорной фразы нет) остаётся `null`. Радиусных строк с «(аналоги из того же дома)», которые открываются по `?id=`, на проде нет (0 из 8), поэтому этот случай стережёт только тест `test_radius_same_house_note_is_not_anchor`. 3. Открыть эту оценку на **обеих** страницах и сделать скриншоты: - корневая `/trade-in/?id=…` (v1 `HeroSummary`): подпись про дом стоит под коридором ДКП, и в плашке «ДКП · Росреестр», и в блоке цены; - `/trade-in/v2?id=…`: подпись есть в плитке «ДКП · Росреестр» **только когда street-deals пусты**. Если street-deals есть, плитка строится из них, коридора в v2 нет вовсе, и отсутствие подписи там правильно, а не провал. Обратное направление: у радиусной оценки с n ≥ 10 подписи нет ни на одной странице. 4. Tier C на GET: у живой оценки с «Оценка построена по N аналогам из ближайшего окружения (≤500 м)» GET отдаёт `analog_tier = "micro_radius"`. На корневой `/trade-in/?id=…` в «Как рассчитано» → «Сопоставление» написано «по ближайшему окружению ≤500 м», до деплоя было «по окружению ≤500 м». 5. Срок: первые 24 ч после деплоя. Если за это время не появится ни одной свежей оценки Tier A, пп. 2–3 выполняются на уже существующих (на 17.09 их 16). ## Замечено попутно, не чинилось У ghost-anchor guard (`estimator.py`, «Оценка опирается только на аналоги из того же дома») текст один и тот же для Tier A и Tier C. Для Tier C (окружение ≤500 м) это неправда. Разбор тира этот текст не читает. На 17.09 у живых Tier C этого текста нет (0 из 93). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bot-backend added 1 commit 2026-09-17 07:40:05 +00:00
fix(tradein): коридор ДКП при якоре того же дома помечен как не вошедший в цену (#3466)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 20s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 2m25s
CI Trade-In / backend-tests (pull_request) Successful in 7m2s
1fe383f935
Кламп headline к коридору ДКП выключается не только малым числом сделок
(advisory_only, #3452), но и якорем Tier A: _apply_corridor_clamp его exempt,
radius-floor требует anchor_tier is None. Ревьюер #3462 воспроизвёл n=20,
advisory_only=False, headline 202 100 против потолка 140 000 — подписи нет.

- Признак — analog_tier == "same_building" (уже структурный в POST).
  corridorAdvisoryNote принимает тир и при same_building говорит
  «справочно: цена посчитана по аналогам в этом же доме — коридор её не
  ограничивает»; оба вызова (v1 HeroSummary, v2 mappers) передают тир.
- GET-rehydrate терял analog_tier (колонки нет) — якорный тир теперь
  восстанавливается из подписи якорного блока в confidence_explanation
  через общую константу (analog_tier_from_explanation), как радиус в #2632.
  Разбор по всей фразе: радиусный тир S пишет «(аналоги из того же дома)»,
  на проде таких строк 4.
- Тесты floor не доходили до floor: три лота уводили в #oblast-E, headline
  брался из медианы коридора. На main с полностью выключенным floor файл
  зелёный. Лотов шесть, ожидания точные, добавлен кейс n = min_n − 1.
- Полоса маркера corridor_advisory_zone — n = 1..9, не 3..9: уличный коридор
  отдаётся с одной сделки. Формулировки поправлены, тест на n=2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Light1YT added 3 commits 2026-09-17 08:50:10 +00:00
Ревью #3554: замена `>=` на `>` в гейте radius-floor оставляла
test_estimator_radius_floor.py зелёным — в тестах были только count=15 и
count=min_n−1. При n == min_n витрина не пишет «справочно»
(advisory_only=False), значит floor обязан поднять цену.

Новый тест: n = estimate_corridor_clamp_min_n, медиана 80k → ровно 120k,
плюс проверка, что DkpCorridor на той же границе не справочный. Мутант
`>` краснеет: «коридор из 10 сделок (ровно порог) должен поднять цену
ровно до floor=120000».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ревью #3554: вызов corridorAdvisoryNote в HeroSummary (корневая /trade-in)
тестом не покрывался — мутация F2 убирала аргумент estimate.analog_tier,
vitest оставался зелёным (305 passed). Покрыт был только v2 mappers.

Рендер HeroSummary по образцу thinMarketBadge.test.tsx: n=20,
advisory_only=false; same_building → подпись про дом в тексте hero,
micro_radius → подписи нет. Мутант F2 краснеет: «expected 'Секция 1 ·
Сводка…' to contain 'справочно: цена посчитана по аналогам…'».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge origin/main в fix/corridor-advisory-tier-a
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 5m36s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m38s
8bcb025c0d
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Collaborator

Смысловой конфликт с #3556 (fix/estimator-flag-collapse). Git его не покажет: git merge-tree сливает ветки чисто, а CI гоняет голову ветки, не результат слияния.

#3556 удаляет поля Settings.estimate_corridor_clamp_slack и estimate_corridor_clamp_min_n (теперь это estimator.CORRIDOR_CLAMP_SLACK и app.core.config.CORRIDOR_CLAMP_MIN_N, значения прежние, 0.40 и 10). Ещё он удаляет параметр radius_floor_factor у _run_estimate в tests/test_estimator_radius_floor.py.

Пробное слияние f48a510e (#3556) + 8bcb025c (эта ветка), 17.09.2026: test_3466_corridor_tier_a.py test_estimator_radius_floor.py test_3452_corridor_advisory_zone.py test_1970_estimator_constants.pyrc=1, 4 failed, 61 passed:

FAILED tests/test_3466_corridor_tier_a.py::test_same_corridor_clamps_radius_path
FAILED tests/test_3466_corridor_tier_a.py::test_tier_a_price_does_not_depend_on_corridor
FAILED tests/test_estimator_radius_floor.py::test_floor_applied_at_exactly_clamp_min_n
FAILED tests/test_estimator_radius_floor.py::test_floor_not_applied_below_clamp_min_n
E   AttributeError: 'Settings' object has no attribute 'estimate_corridor_clamp_slack'
E   AttributeError: 'Settings' object has no attribute 'estimate_corridor_clamp_min_n'
(после них: TypeError: _run_estimate() got an unexpected keyword argument 'radius_floor_factor')

С патчем ниже те же файлы дают 65 passed, rc=0. Весь сьют на тройном слиянии (#3556 + #3552 3992d754 + эта ветка + патч): 6318 passed, 42 skipped, rc=0.

Порядок. Кто вливается вторым, тот мержит origin/main к себе, накладывает патч (git apply) и перепрогоняет сьют. Патч применяется к результату слияния, а не к голове этой ветки: CORRIDOR_CLAMP_MIN_N появляется только с #3556. Если #3554 вольют первым, адаптацию сделаю в #3556. Три правки фронта в патче касаются только комментариев. Они здесь, потому что у #3554 фронт пересобирается в любом случае.

патч (git apply на результате слияния)
diff --git a/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py b/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py
index 4350f621..72542446 100644
--- a/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py
+++ b/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py
@@ -26,7 +26,6 @@ from unittest.mock import MagicMock, patch
 
 os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
 
-from app.core.config import settings
 from app.services import estimator
 from app.services.geocoder import GeocodeResult
 
@@ -45,7 +44,7 @@ _ANCHOR_PPM2 = (190_000, 195_000, 200_000, 205_000, 210_000)
 
 
 def _cap() -> float:
-    return _CORRIDOR["high_ppm2"] * (1.0 + settings.estimate_corridor_clamp_slack)
+    return _CORRIDOR["high_ppm2"] * (1.0 + estimator.CORRIDOR_CLAMP_SLACK)
 
 
 def _call(
diff --git a/tradein-mvp/backend/tests/test_estimator_radius_floor.py b/tradein-mvp/backend/tests/test_estimator_radius_floor.py
index c532ed95..0f3e8485 100644
--- a/tradein-mvp/backend/tests/test_estimator_radius_floor.py
+++ b/tradein-mvp/backend/tests/test_estimator_radius_floor.py
@@ -8,8 +8,8 @@
 - radius median выше dkp_low × factor → no-op (медиана не изменена)
 - dkp_raw is None → no-op (нет базы для floor)
 - anchor-путь (anchor_tier != None) → не затронут floor'ом
-- коридор ниже estimate_corridor_clamp_min_n → floor не применяется (#3466)
-- коридор ровно estimate_corridor_clamp_min_n → floor применяется (#3466)
+- коридор ниже CORRIDOR_CLAMP_MIN_N → floor не применяется (#3466)
+- коридор ровно CORRIDOR_CLAMP_MIN_N → floor применяется (#3466)
 """
 
 from __future__ import annotations
@@ -194,23 +194,23 @@ def test_no_dkp_raw_no_floor() -> None:
 
 
 def test_floor_not_applied_below_clamp_min_n() -> None:
-    """n = estimate_corridor_clamp_min_n − 1: тот же floor, что в тесте 1, но выключен.
+    """n = CORRIDOR_CLAMP_MIN_N − 1: тот же floor, что в тесте 1, но выключен.
 
     `DkpCorridor.advisory_only` (#3452) обосновывает себя общим порогом у клампа
     headline И у radius-floor. Половина про кламп стережётся test_3452_*, эта —
     здесь: без гейта по count медиана 80k поднялась бы до 120k.
     """
-    from app.core.config import settings
+    from app.core.config import CORRIDOR_CLAMP_MIN_N
 
     analogs = _six(80_000.0)
     dkp_raw = {
-        "count": settings.estimate_corridor_clamp_min_n - 1,
+        "count": CORRIDOR_CLAMP_MIN_N - 1,
         "low_ppm2": 150_000,
         "median_ppm2": 180_000,
         "high_ppm2": 220_000,
         "period_months": 12,
     }
-    est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8)
+    est = _run_estimate(analogs, dkp_raw)
 
     assert est.median_price_per_m2 < 100_000, (
         f"median_ppm2={est.median_price_per_m2}: коридор из "
@@ -222,18 +222,18 @@ def test_floor_not_applied_below_clamp_min_n() -> None:
 
 
 def test_floor_applied_at_exactly_clamp_min_n() -> None:
-    """n = estimate_corridor_clamp_min_n: advisory_only=False → floor обязан поднять.
+    """n = CORRIDOR_CLAMP_MIN_N: advisory_only=False → floor обязан поднять.
 
     Тест 4 стережёт «ниже порога — нет», тест 1 — «15 сделок — да». Граница между
     ними (`>=` против `>`) не стереглась: при n == min_n витрина не пишет подпись
     «справочно», значит коридор обязан войти в цену.
     """
-    from app.core.config import settings
+    from app.core.config import CORRIDOR_CLAMP_MIN_N
     from app.schemas.trade_in import DkpCorridor
 
     analogs = _six(80_000.0)
     dkp_raw = {
-        "count": settings.estimate_corridor_clamp_min_n,
+        "count": CORRIDOR_CLAMP_MIN_N,
         "low_ppm2": 150_000,
         "median_ppm2": 180_000,
         "high_ppm2": 220_000,
@@ -242,7 +242,7 @@ def test_floor_applied_at_exactly_clamp_min_n() -> None:
     # Та же граница с другой стороны: на ней коридор уже не справочный.
     assert DkpCorridor(**dkp_raw).advisory_only is False
 
-    est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8)
+    est = _run_estimate(analogs, dkp_raw)
 
     assert est.median_price_per_m2 == 150_000 * 0.8, (
         f"median_ppm2={est.median_price_per_m2}: коридор из {dkp_raw['count']} сделок "
diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts
index 586e0116..b68988df 100644
--- a/tradein-mvp/frontend/src/app/mera-public/content.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/content.ts
@@ -370,7 +370,7 @@ export interface SourceGroup {
  * ПОЧЕМУ ТРЕТЬЯ ГРУППА ЕСТЬ (а не «два типа данных», как было). Оценки площадок
  * — не украшение экрана: в `backend/app/services/estimator.py` (блок «#651: IMV
  * / Yandex blend», Tier D — когда якоря по дому/≤500 м нет) медиана
- * переписывается на `new_median` с весом `estimate_imv_blend_weight`,
+ * переписывается на `new_median` с весом `IMV_BLEND_WEIGHT`,
  * объяснение дополняется «Оценка скорректирована по…», а `sources_used`
  * пополняется `avito_imv`. Умолчать об этом — значит утверждать на публичной
  * странице то, чего код не делает.
diff --git a/tradein-mvp/frontend/src/lib/rosreestr.ts b/tradein-mvp/frontend/src/lib/rosreestr.ts
index f5cb7b68..9761721b 100644
--- a/tradein-mvp/frontend/src/lib/rosreestr.ts
+++ b/tradein-mvp/frontend/src/lib/rosreestr.ts
@@ -41,7 +41,7 @@ export function dealsAsOfLabel(
 /**
  * #3452: подпись под коридором ДКП, собранным из малого числа сделок.
  *
- * Порог (`estimate_corridor_clamp_min_n`) считает бэкенд и отдаёт готовый
+ * Порог (`CORRIDOR_CLAMP_MIN_N`) считает бэкенд и отдаёт готовый
  * advisory_only: ниже него у коридора выключены ОБЕ его ценовые страховки —
  * кламп headline сверху и radius-floor снизу. Молчание тут выдавало бы
  * выключенную страховку за работающую: на экране такой коридор ничем не
diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts
index bcec3816..319b9d22 100644
--- a/tradein-mvp/frontend/src/types/trade-in.ts
+++ b/tradein-mvp/frontend/src/types/trade-in.ts
@@ -168,7 +168,7 @@ export interface DkpCorridor {
   // посчитанные до деплоя бэкенда, поля не содержат → подпись просто не рисуется.
   latest_deal_date?: string | null;
   // #3452: true = сделок меньше порога доверия (бэкенд считает порог сам,
-  // estimate_corridor_clamp_min_n) → коридор показан, но цену по нему НЕ
+  // CORRIDOR_CLAMP_MIN_N) → коридор показан, но цену по нему НЕ
   // поправляли: ни кламп сверху, ни floor снизу. Заголовок «advisory» у всего
   // коридора не про это — при count >= порога он в цену как раз идёт, кроме
   // analog_tier === "same_building" (#3466: якорь того же дома, кламп exempt).
Смысловой конфликт с #3556 (`fix/estimator-flag-collapse`). Git его не покажет: `git merge-tree` сливает ветки чисто, а CI гоняет голову ветки, не результат слияния. #3556 удаляет поля `Settings.estimate_corridor_clamp_slack` и `estimate_corridor_clamp_min_n` (теперь это `estimator.CORRIDOR_CLAMP_SLACK` и `app.core.config.CORRIDOR_CLAMP_MIN_N`, значения прежние, 0.40 и 10). Ещё он удаляет параметр `radius_floor_factor` у `_run_estimate` в `tests/test_estimator_radius_floor.py`. Пробное слияние `f48a510e` (#3556) + `8bcb025c` (эта ветка), 17.09.2026: `test_3466_corridor_tier_a.py test_estimator_radius_floor.py test_3452_corridor_advisory_zone.py test_1970_estimator_constants.py` → **rc=1, 4 failed, 61 passed**: ``` FAILED tests/test_3466_corridor_tier_a.py::test_same_corridor_clamps_radius_path FAILED tests/test_3466_corridor_tier_a.py::test_tier_a_price_does_not_depend_on_corridor FAILED tests/test_estimator_radius_floor.py::test_floor_applied_at_exactly_clamp_min_n FAILED tests/test_estimator_radius_floor.py::test_floor_not_applied_below_clamp_min_n E AttributeError: 'Settings' object has no attribute 'estimate_corridor_clamp_slack' E AttributeError: 'Settings' object has no attribute 'estimate_corridor_clamp_min_n' (после них: TypeError: _run_estimate() got an unexpected keyword argument 'radius_floor_factor') ``` С патчем ниже те же файлы дают **65 passed, rc=0**. Весь сьют на тройном слиянии (#3556 + #3552 `3992d754` + эта ветка + патч): **6318 passed, 42 skipped, rc=0**. **Порядок.** Кто вливается вторым, тот мержит `origin/main` к себе, накладывает патч (`git apply`) и перепрогоняет сьют. Патч применяется к результату слияния, а не к голове этой ветки: `CORRIDOR_CLAMP_MIN_N` появляется только с #3556. Если #3554 вольют первым, адаптацию сделаю в #3556. Три правки фронта в патче касаются только комментариев. Они здесь, потому что у #3554 фронт пересобирается в любом случае. <details><summary>патч (git apply на результате слияния)</summary> ```diff diff --git a/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py b/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py index 4350f621..72542446 100644 --- a/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py +++ b/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py @@ -26,7 +26,6 @@ from unittest.mock import MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") -from app.core.config import settings from app.services import estimator from app.services.geocoder import GeocodeResult @@ -45,7 +44,7 @@ _ANCHOR_PPM2 = (190_000, 195_000, 200_000, 205_000, 210_000) def _cap() -> float: - return _CORRIDOR["high_ppm2"] * (1.0 + settings.estimate_corridor_clamp_slack) + return _CORRIDOR["high_ppm2"] * (1.0 + estimator.CORRIDOR_CLAMP_SLACK) def _call( diff --git a/tradein-mvp/backend/tests/test_estimator_radius_floor.py b/tradein-mvp/backend/tests/test_estimator_radius_floor.py index c532ed95..0f3e8485 100644 --- a/tradein-mvp/backend/tests/test_estimator_radius_floor.py +++ b/tradein-mvp/backend/tests/test_estimator_radius_floor.py @@ -8,8 +8,8 @@ - radius median выше dkp_low × factor → no-op (медиана не изменена) - dkp_raw is None → no-op (нет базы для floor) - anchor-путь (anchor_tier != None) → не затронут floor'ом -- коридор ниже estimate_corridor_clamp_min_n → floor не применяется (#3466) -- коридор ровно estimate_corridor_clamp_min_n → floor применяется (#3466) +- коридор ниже CORRIDOR_CLAMP_MIN_N → floor не применяется (#3466) +- коридор ровно CORRIDOR_CLAMP_MIN_N → floor применяется (#3466) """ from __future__ import annotations @@ -194,23 +194,23 @@ def test_no_dkp_raw_no_floor() -> None: def test_floor_not_applied_below_clamp_min_n() -> None: - """n = estimate_corridor_clamp_min_n − 1: тот же floor, что в тесте 1, но выключен. + """n = CORRIDOR_CLAMP_MIN_N − 1: тот же floor, что в тесте 1, но выключен. `DkpCorridor.advisory_only` (#3452) обосновывает себя общим порогом у клампа headline И у radius-floor. Половина про кламп стережётся test_3452_*, эта — здесь: без гейта по count медиана 80k поднялась бы до 120k. """ - from app.core.config import settings + from app.core.config import CORRIDOR_CLAMP_MIN_N analogs = _six(80_000.0) dkp_raw = { - "count": settings.estimate_corridor_clamp_min_n - 1, + "count": CORRIDOR_CLAMP_MIN_N - 1, "low_ppm2": 150_000, "median_ppm2": 180_000, "high_ppm2": 220_000, "period_months": 12, } - est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) + est = _run_estimate(analogs, dkp_raw) assert est.median_price_per_m2 < 100_000, ( f"median_ppm2={est.median_price_per_m2}: коридор из " @@ -222,18 +222,18 @@ def test_floor_not_applied_below_clamp_min_n() -> None: def test_floor_applied_at_exactly_clamp_min_n() -> None: - """n = estimate_corridor_clamp_min_n: advisory_only=False → floor обязан поднять. + """n = CORRIDOR_CLAMP_MIN_N: advisory_only=False → floor обязан поднять. Тест 4 стережёт «ниже порога — нет», тест 1 — «15 сделок — да». Граница между ними (`>=` против `>`) не стереглась: при n == min_n витрина не пишет подпись «справочно», значит коридор обязан войти в цену. """ - from app.core.config import settings + from app.core.config import CORRIDOR_CLAMP_MIN_N from app.schemas.trade_in import DkpCorridor analogs = _six(80_000.0) dkp_raw = { - "count": settings.estimate_corridor_clamp_min_n, + "count": CORRIDOR_CLAMP_MIN_N, "low_ppm2": 150_000, "median_ppm2": 180_000, "high_ppm2": 220_000, @@ -242,7 +242,7 @@ def test_floor_applied_at_exactly_clamp_min_n() -> None: # Та же граница с другой стороны: на ней коридор уже не справочный. assert DkpCorridor(**dkp_raw).advisory_only is False - est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) + est = _run_estimate(analogs, dkp_raw) assert est.median_price_per_m2 == 150_000 * 0.8, ( f"median_ppm2={est.median_price_per_m2}: коридор из {dkp_raw['count']} сделок " diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts index 586e0116..b68988df 100644 --- a/tradein-mvp/frontend/src/app/mera-public/content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/content.ts @@ -370,7 +370,7 @@ export interface SourceGroup { * ПОЧЕМУ ТРЕТЬЯ ГРУППА ЕСТЬ (а не «два типа данных», как было). Оценки площадок * — не украшение экрана: в `backend/app/services/estimator.py` (блок «#651: IMV * / Yandex blend», Tier D — когда якоря по дому/≤500 м нет) медиана - * переписывается на `new_median` с весом `estimate_imv_blend_weight`, + * переписывается на `new_median` с весом `IMV_BLEND_WEIGHT`, * объяснение дополняется «Оценка скорректирована по…», а `sources_used` * пополняется `avito_imv`. Умолчать об этом — значит утверждать на публичной * странице то, чего код не делает. diff --git a/tradein-mvp/frontend/src/lib/rosreestr.ts b/tradein-mvp/frontend/src/lib/rosreestr.ts index f5cb7b68..9761721b 100644 --- a/tradein-mvp/frontend/src/lib/rosreestr.ts +++ b/tradein-mvp/frontend/src/lib/rosreestr.ts @@ -41,7 +41,7 @@ export function dealsAsOfLabel( /** * #3452: подпись под коридором ДКП, собранным из малого числа сделок. * - * Порог (`estimate_corridor_clamp_min_n`) считает бэкенд и отдаёт готовый + * Порог (`CORRIDOR_CLAMP_MIN_N`) считает бэкенд и отдаёт готовый * advisory_only: ниже него у коридора выключены ОБЕ его ценовые страховки — * кламп headline сверху и radius-floor снизу. Молчание тут выдавало бы * выключенную страховку за работающую: на экране такой коридор ничем не diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index bcec3816..319b9d22 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -168,7 +168,7 @@ export interface DkpCorridor { // посчитанные до деплоя бэкенда, поля не содержат → подпись просто не рисуется. latest_deal_date?: string | null; // #3452: true = сделок меньше порога доверия (бэкенд считает порог сам, - // estimate_corridor_clamp_min_n) → коридор показан, но цену по нему НЕ + // CORRIDOR_CLAMP_MIN_N) → коридор показан, но цену по нему НЕ // поправляли: ни кламп сверху, ни floor снизу. Заголовок «advisory» у всего // коридора не про это — при count >= порога он в цену как раз идёт, кроме // analog_tier === "same_building" (#3466: якорь того же дома, кламп exempt). ``` </details>
bot-backend merged commit a9e5322b7a into main 2026-09-17 09:22:51 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3554
No description provided.