From c093212eaeaff8d2aadc3d4d4bdcecf2fca41800 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:37:13 +0500 Subject: [PATCH] =?UTF-8?q?fix(mera-public):=20=D1=87=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=B2=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D1=8B=20=D0=B2=20=D1=80=D0=B0=D1=83=D0=BD=D0=B4?= =?UTF-8?q?=D0=B0=D1=85=20=D0=B8=D0=B3=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Остаток «100 − уверенные» в подписи города был однородным только на словах: внутрь него входят пустые пробы, на которые describeCoverage отвечает «рядом не продаётся ничего похожего» и расчёт не предлагает. Текст обещал расчёт и на них — две наши же строки спорили друг с другом. Остаток разложен на долю «посчитаем, но разброс шире» и долю «считать не станем». Возраст объявления бэкенд считает percentile_cont(0.5) — это медиана, а «в среднем» под скошенным распределением называет другую величину. Раунды игры брались первыми тремя строками витрины, а она отсортирована по полноте и свежести: на проде это давало три студии с одной улицы. Выбор жадный по комнатности и улице, детерминированный — страница серверная. --- .../__tests__/coverage-copy.test.ts | 40 +++++++++- .../__tests__/varied-rounds.test.ts | 77 +++++++++++++++++++ .../mera-public/_components/v3/deal-view.ts | 34 ++++++++ .../src/app/mera-public/coverage-copy.ts | 25 ++++-- .../frontend/src/app/mera-public/page.tsx | 6 +- 5 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts index 3d03e999..57cb0f5f 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts @@ -63,7 +63,18 @@ describe("describeCoverage", () => { it("когда дата известна у всех — доля не упоминается вовсе", () => { // «дата известна у 30 из 30» — шум: доля есть, но она ничего не сообщает. const [, ageTile] = describeCoverage(probe({ n_listings: 30, n_with_age: 30 })).tiles; - expect(ageTile.label).toBe("столько в среднем висит объявление"); + expect(ageTile.label).toBe("столько и дольше висит каждое второе объявление"); + }); + + it("возраст подписан как медиана — «в среднем» под percentile_cont(0.5) неправда", () => { + // Бэкенд считает `percentile_cont(0.5)` (trade_in.py::_coverage_probe). + // На возрасте объявлений распределение скошено вправо: среднее заметно + // больше медианы, и подпись «в среднем» называла бы другую величину. + for (const over of [{ n_with_age: 6 }, { n_with_age: 34 }]) { + const [, ageTile] = describeCoverage(probe(over)).tiles; + expect(ageTile.label).not.toMatch(/в среднем/i); + expect(ageTile.label).toMatch(/каждое второе|половин|медиан/i); + } }); it("город вне покрытия — говорим прямо и не предлагаем платный шаг", () => { @@ -142,9 +153,30 @@ describe("describeCityExpectation", () => { const said = describeCityExpectation("Ревда"); expect(said?.text).toContain("проверках"); expect(said?.text).not.toMatch(/точност/i); - // Доли складываются в целое: 16 уверенных + 84 остальных — иначе текст - // утверждает больше, чем измерено. - expect(said?.text).toContain("84"); + }); + + it("остаток разложен: где считаем с широким разбросом, а где не считаем вовсе", () => { + // Прежний текст называл остаток одним числом (100 − уверенные) и обещал + // расчёт на всём нём. Но пустые пробы сидят внутри этого остатка, а + // `describeCoverage` отвечает на них «рядом не продаётся ничего похожего» + // и платный шаг не предлагает. Числа берём из замера, а не из строки: + // подстрокой «3» нашлось бы и внутри «83». + for (const measure of CITY_COVERAGE) { + const thin = 100 - measure.confidentPct - measure.emptyPct; + const said = describeCityExpectation(measure.city); + const nums = (said?.text.match(/\d+/g) ?? []).map(Number); + + expect(measure.emptyPct, `${measure.city}: нечего разделять`).toBeGreaterThan(0); + expect(nums, `${measure.city}: нет доли уверенных`).toContain(measure.confidentPct); + expect(nums, `${measure.city}: нет доли «посчитаем, но шире»`).toContain(thin); + expect(nums, `${measure.city}: нет доли пустых когорт`).toContain(measure.emptyPct); + expect(measure.confidentPct + thin + measure.emptyPct).toBe(100); + // Про пустые когорты сказано, что мы НЕ считаем, — иначе текст обещает + // расчёт там, где следующий же экран в нём откажет. + expect(said?.text, `${measure.city}: обещан расчёт на пустой когорте`).toMatch( + /считать не станем/, + ); + } }); it("неизвестный город не выдумывает величину", () => { diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts new file mode 100644 index 00000000..88aa458a --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts @@ -0,0 +1,77 @@ +/** + * Какие сделки попадают в игру «Угадай цену». + * + * Витрина отсортирована по полноте и свежести, а не по разнообразию, поэтому + * первые три её строки на проде оказывались тремя студиями с одной улицы: игра + * трижды спрашивала цену одной и той же квартиры. Проверяется ЗНАЧЕНИЕ выбора + * — какие именно строки взяты, — а не факт вызова функции. + */ +import { describe, expect, it } from "vitest"; + +import { pickVariedDeals } from "../_components/v3/deal-view"; +import type { ShowcaseDeal } from "../public-api"; + +const deal = (rooms: number, street: string | null, area_m2 = 40): ShowcaseDeal => + ({ + district: "Кировский", + rooms, + area_m2, + floor: 3, + total_floors: 9, + deal_quarter: "II квартал 2026", + predicted_rub: 5_000_000, + fact_rub: 5_000_000, + err_pct: 0, + n_analogs: 8, + note: "", + street_name: street, + street_scheme: null, + lat: null, + lon: null, + }) as ShowcaseDeal; + +/** Порядок — как на витрине: студии одной улицы стоят первыми. */ +const showcase: readonly ShowcaseDeal[] = [ + deal(0, "улица Ленина", 25), + deal(0, "улица Ленина", 27), + deal(0, "улица Ленина", 29), + deal(2, "улица Ленина", 62), + deal(1, "улица Мира", 38), + deal(2, "улица Щорса", 58), +]; + +describe("pickVariedDeals", () => { + it("три раунда — три разные комнатности и три разные улицы", () => { + const picked = pickVariedDeals(showcase, 3); + expect(picked).toHaveLength(3); + expect(picked.map((d) => d.rooms)).toEqual([0, 1, 2]); + expect(picked.map((d) => d.street_name)).toEqual([ + "улица Ленина", + "улица Мира", + "улица Щорса", + ]); + // Прежний slice(0, 3) взял бы три студии с Ленина — вот они, и их нет. + expect(picked.map((d) => d.area_m2)).toEqual([25, 38, 58]); + }); + + it("когда разнообразия нет — берём сколько есть, а не показываем меньше раундов", () => { + // Игра не рендерится, пока раундов меньше трёх. Отдать два «ради чистоты + // выбора» значило бы погасить секцию из-за однородной витрины. + const same = [deal(0, "улица Ленина", 25), deal(0, "улица Ленина", 27), deal(0, "улица Ленина", 29)]; + expect(pickVariedDeals(same, 3)).toHaveLength(3); + }); + + it("строки без улицы не считаются повтором улицы", () => { + // `street_name` пуст примерно у 8% строк — такая строка про улицу молчит. + const noStreet = [deal(1, null, 38), deal(1, "улица Мира", 40), deal(2, null, 58)]; + expect(pickVariedDeals(noStreet, 2).map((d) => d.area_m2)).toEqual([38, 58]); + }); + + it("выбор детерминирован — страница рендерится на сервере", () => { + // Разный набор на сервере и в браузере — это разъехавшаяся гидрация, + // поэтому никакого случайного выбора здесь быть не может. + const first = pickVariedDeals(showcase, 3); + const second = pickVariedDeals(showcase, 3); + expect(first.map((d) => d.area_m2)).toEqual(second.map((d) => d.area_m2)); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts index b8751743..0a5bdcda 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts @@ -130,6 +130,40 @@ export function toGameRound(deal: ShowcaseDeal, index: number): GameRound { }; } +/** + * Строки витрины для игры — РАЗНЫЕ по типу квартиры и по улице. + * + * `slice(0, n)` брал первые в порядке витрины, а он отсортирован по полноте и + * свежести (`_sort_key` в `landing_showcase_deals.py`), а не по разнообразию: + * на проде это давало три студии с одной улицы, и игра выглядела как одна и та + * же квартира трижды. Жадно берём первую строку, у которой И комнатность, И + * улица ещё не встречались; когда таких не осталось — добираем первыми + * попавшимися, потому что раундов должно быть столько, сколько просили. + * + * Порядок строго по входу: страница рендерится на сервере, и любой `random` + * здесь означал бы разный набор на сервере и в браузере. + */ +export function pickVariedDeals(deals: readonly ShowcaseDeal[], n: number): ShowcaseDeal[] { + const picked: ShowcaseDeal[] = []; + const rooms = new Set(); + const streets = new Set(); + for (const deal of deals) { + if (picked.length >= n) break; + if (rooms.has(deal.rooms)) continue; + // Улицы нет примерно у 8% строк — такая строка не «повторяет улицу», она + // про неё молчит, и держать её из-за этого вне игры не за что. + if (deal.street_name !== null && streets.has(deal.street_name)) continue; + picked.push(deal); + rooms.add(deal.rooms); + if (deal.street_name !== null) streets.add(deal.street_name); + } + for (const deal of deals) { + if (picked.length >= n) break; + if (!picked.includes(deal)) picked.push(deal); + } + return picked; +} + /** * Разброс расхождений ПОКАЗАННЫХ строк. * diff --git a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts index 42246c37..ffa884a0 100644 --- a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts +++ b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts @@ -17,7 +17,8 @@ * объявления, а не срок продажи: выборка цензурирована, эти квартиры ещё * продаются, реальная экспозиция проданных всегда меньше. Написать под * цифрой «за столько продаются» — соврать; поэтому подпись здесь одна и - * тестом запинена. И при `median === null` (бэкенд занулил её, когда + * тестом запинена. Величина при этом МЕДИАННАЯ (`percentile_cont(0.5)` на + * бэкенде), а не средняя — «в среднем» под ней было такой же неправдой. И при `median === null` (бэкенд занулил её, когда * возраст известен меньше чем по пяти объявлениям) плитки просто нет — * ноль вместо неизвестного значения был бы худшей из ошибок. */ @@ -73,10 +74,14 @@ function tilesFor(probe: CoverageProbe): CoverageTile[] { const partial = probe.n_with_age < probe.n_listings; tiles.push({ value: `${probe.median_listing_age_days} дн.`, - // Формулировка запинена тестом — см. шапку модуля. + // Формулировка запинена тестом — см. шапку модуля. «В среднем» отсюда + // убрано 02.09: бэкенд считает `percentile_cont(0.5)` + // (`trade_in.py::_coverage_probe`) — это МЕДИАНА, и на скошенном вправо + // возрасте объявлений она заметно меньше среднего. «Каждое второе» — + // то же самое утверждение словами, которые не надо расшифровывать. label: partial - ? `столько в среднем висит объявление; дата известна у ${probe.n_with_age} из ${probe.n_listings}` - : "столько в среднем висит объявление", + ? `столько и дольше висит каждое второе объявление; дата известна у ${probe.n_with_age} из ${probe.n_listings}` + : "столько и дольше висит каждое второе объявление", }); } @@ -103,13 +108,17 @@ export function describeCityExpectation(city: string): CityExpectation | null { const measure = CITY_COVERAGE.find((m) => m.city === city); if (!measure) return null; - const rest = 100 - measure.confidentPct; + // Остаток от уверенных — НЕ однородный: в него входят и пустые пробы, где + // считать нечего. Пока он назывался одним числом, текст обещал расчёт и на + // них тоже, а `describeCoverage` в той же ситуации отвечает «не считаем» — + // две наши же строки спорили друг с другом. Делим остаток надвое. + const thin = 100 - measure.confidentPct - measure.emptyPct; return { text: `В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} проверках ` + - `из 100; в остальных ${rest} данных меньше — расчёт мы всё равно сделаем, но разброс ` + - `будет шире, и в ответе это будет написано. В ${measure.emptyPct} случаях из 100 рядом ` + - "не находится ни одной похожей квартиры.", + `из 100. Ещё в ${thin} данных меньше — расчёт мы сделаем, но разброс будет шире, и в ` + + `ответе это будет написано. В оставшихся ${measure.emptyPct} рядом не находится ни одной ` + + "похожей квартиры — так и скажем, а считать не станем.", source: `${CITY_COVERAGE_SOURCE}. Прогнано адресов: ${measure.sampleN}.`, }; } diff --git a/tradein-mvp/frontend/src/app/mera-public/page.tsx b/tradein-mvp/frontend/src/app/mera-public/page.tsx index ace48006..f3a753d6 100644 --- a/tradein-mvp/frontend/src/app/mera-public/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/page.tsx @@ -12,7 +12,7 @@ import { StepsV3 } from "./_components/v3/StepsV3"; import { StickyCtaV3 } from "./_components/v3/StickyCtaV3"; import { TwoPathsV3 } from "./_components/v3/TwoPathsV3"; import { WhatYouGetV3 } from "./_components/v3/WhatYouGetV3"; -import { toGameRound } from "./_components/v3/deal-view"; +import { pickVariedDeals, toGameRound } from "./_components/v3/deal-view"; import { fetchLandingStats, fetchShowcase } from "./public-api"; import styles from "./landing.module.css"; @@ -81,7 +81,9 @@ const MIN_ROUNDS = 3; export default async function MeraPublicPage() { const [stats, showcase] = await Promise.all([fetchLandingStats(), fetchShowcase()]); const deals = showcase?.deals ?? []; - const rounds = deals.slice(0, MIN_ROUNDS).map(toGameRound); + // Не первые три, а три РАЗНЫЕ — иначе игра предлагает угадать одну и ту же + // студию с одной улицы трижды (см. `pickVariedDeals`). + const rounds = pickVariedDeals(deals, MIN_ROUNDS).map(toGameRound); return ( <>