fix(mera-public): честный текст покрытия и разные квартиры в раундах игры
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m8s

Остаток «100 − уверенные» в подписи города был однородным только на словах:
внутрь него входят пустые пробы, на которые describeCoverage отвечает «рядом
не продаётся ничего похожего» и расчёт не предлагает. Текст обещал расчёт и
на них — две наши же строки спорили друг с другом. Остаток разложен на долю
«посчитаем, но разброс шире» и долю «считать не станем».

Возраст объявления бэкенд считает percentile_cont(0.5) — это медиана, а
«в среднем» под скошенным распределением называет другую величину.

Раунды игры брались первыми тремя строками витрины, а она отсортирована по
полноте и свежести: на проде это давало три студии с одной улицы. Выбор
жадный по комнатности и улице, детерминированный — страница серверная.
This commit is contained in:
bot-backend 2026-09-02 17:37:13 +05:00
parent d8b7de2cf6
commit c093212eae
5 changed files with 168 additions and 14 deletions

View file

@ -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("неизвестный город не выдумывает величину", () => {

View file

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

View file

@ -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<number>();
const streets = new Set<string>();
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;
}
/**
* Разброс расхождений ПОКАЗАННЫХ строк.
*

View file

@ -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}.`,
};
}

View file

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