fix(mera-public): честный текст покрытия и разные квартиры в раундах игры #3337

Merged
bot-backend merged 2 commits from fix/3323-coverage-copy-honesty into main 2026-09-02 12:46:06 +00:00
5 changed files with 216 additions and 17 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,49 @@ 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);
// Инвариант ЗАМЕРА, а не арифметики: уверенные и пустые обязаны
// оставлять место третьей доле, иначе рендер печатает «Ещё в 0».
expect(
measure.confidentPct + measure.emptyPct,
`${measure.city}: средней доле не осталось места`,
).toBeLessThan(100);
// Про пустые когорты сказано, что мы НЕ считаем, — иначе текст обещает
// расчёт там, где следующий же экран в нём откажет.
expect(said?.text, `${measure.city}: обещан расчёт на пустой когорте`).toMatch(
/считать не станем/,
);
}
});
it("когда средней доли не осталось — предложения про неё нет вовсе", () => {
// Перезамер вида 85 уверенных + 15 пустых законен, и «Ещё в 0 данных
// меньше» на нём было бы сообщением о доле, которой не существует.
const said = describeCityExpectation("Ревда", {
city: "Ревда",
confidentPct: 85,
emptyPct: 15,
sampleN: 100,
});
expect(said?.text).not.toMatch(/Ещё в 0|в 0 /);
expect((said?.text.match(/\d+/g) ?? []).map(Number)).toEqual([85, 100, 15]);
expect(said?.text).toMatch(/считать не станем/);
});
it("неизвестный город не выдумывает величину", () => {

View file

@ -0,0 +1,85 @@
/**
* Какие сделки попадают в игру «Угадай цену».
*
* Витрина отсортирована по полноте и свежести, а не по разнообразию, поэтому
* первые три её строки на проде оказывались тремя студиями с одной улицы: игра
* трижды спрашивала цену одной и той же квартиры. Проверяется ЗНАЧЕНИЕ выбора
* какие именно строки взяты, а не факт вызова функции.
*/
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("пустая строка в street_name — не ключ улицы, а её отсутствие", () => {
// `!== null` считал "" настоящим названием, и вторая такая строка
// вылетала как «повтор улицы» — повтора здесь нет, есть два молчания.
// Третья строка — та, которую при этом брали ВМЕСТО второй.
const blank = [deal(1, "", 38), deal(2, "", 58), deal(2, "улица Мира", 60)];
expect(pickVariedDeals(blank, 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,42 @@ 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% строк — такая строка не «повторяет улицу», она
// про неё молчит, и держать её из-за этого вне игры не за что. Проверка
// именно на пустоту, а не на `!== null`: пустая строка — то же самое
// молчание, и второй такой строке нельзя вылетать как «повтору улицы».
if (deal.street_name && streets.has(deal.street_name)) continue;
picked.push(deal);
rooms.add(deal.rooms);
if (deal.street_name) 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,12 +17,14 @@
* объявления, а не срок продажи: выборка цензурирована, эти квартиры ещё
* продаются, реальная экспозиция проданных всегда меньше. Написать под
* цифрой «за столько продаются» соврать; поэтому подпись здесь одна и
* тестом запинена. И при `median === null` (бэкенд занулил её, когда
* тестом запинена. Величина при этом МЕДИАННАЯ (`percentile_cont(0.5)` на
* бэкенде), а не средняя «в среднем» под ней было такой же неправдой. И при `median === null` (бэкенд занулил её, когда
* возраст известен меньше чем по пяти объявлениям) плитки просто нет
* ноль вместо неизвестного значения был бы худшей из ошибок.
*/
import { CITY_COVERAGE, CITY_COVERAGE_SOURCE } from "./landing-facts";
import type { CityCoverageMeasure } from "./landing-facts";
import type { CoverageProbe } from "./public-api";
export interface CoverageTile {
@ -73,10 +75,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}`
: "столько и дольше висит каждое второе объявление",
});
}
@ -99,17 +105,36 @@ export interface CityExpectation {
* что выбрасывать города из списка не за что. Разница между ними
* количественная её и показываем числом, а не отсутствием опции.
*/
export function describeCityExpectation(city: string): CityExpectation | null {
const measure = CITY_COVERAGE.find((m) => m.city === city);
export function describeCityExpectation(
city: string,
// Замер вторым аргументом — чтобы ветку «средней доли не осталось» можно
// было проверить, не подделывая CITY_COVERAGE: сегодня такого города в
// замере нет, а появиться он может при первом же перезамере.
measure: CityCoverageMeasure | undefined = CITY_COVERAGE.find((m) => m.city === city),
): CityExpectation | null {
if (!measure) return null;
const rest = 100 - measure.confidentPct;
// Остаток от уверенных — НЕ однородный: в него входят и пустые пробы, где
// считать нечего. Пока он назывался одним числом, текст обещал расчёт и на
// них тоже, а `describeCoverage` в той же ситуации отвечает «не считаем» —
// две наши же строки спорили друг с другом. Делим остаток надвое.
const thin = 100 - measure.confidentPct - measure.emptyPct;
const head =
`В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} ` +
"проверках из 100. ";
// Средней доли может не остаться вовсе (перезамер вида 85 уверенных + 15
// пустых) — тогда предложение про неё выпадает целиком. «Ещё в 0 данных
// меньше» было бы не округлением, а сообщением о доле, которой нет.
const empty = thin > 0 ? measure.emptyPct : 100 - measure.confidentPct;
const tail =
`В оставшихся ${empty} рядом не находится ни одной похожей квартиры — ` +
"так и скажем, а считать не станем.";
return {
text:
`В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} проверках ` +
`из 100; в остальных ${rest} данных меньше — расчёт мы всё равно сделаем, но разброс ` +
`будет шире, и в ответе это будет написано. В ${measure.emptyPct} случаях из 100 рядом ` +
"не находится ни одной похожей квартиры.",
thin > 0
? `${head}Ещё в ${thin} данных меньше — расчёт мы сделаем, но разброс будет шире, и в ` +
`ответе это будет написано. ${tail}`
: head + tail,
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 (
<>