fix(site-finder): honest POI-score label + drop duplicate MOI column (audit #1871 P2) #1877

Merged
bot-backend merged 1 commit from audit/1871-p2-invest-moi into main 2026-06-23 07:39:48 +00:00
7 changed files with 43 additions and 25 deletions

View file

@ -2966,8 +2966,9 @@ def analyze_parcel(
"score_label": _score_label(score_final),
"score_max_reference": SCORE_MAX_REFERENCE,
"score_explanation": (
"Сумма close-distance POI (школы/сады/парки +, трамваи -) + center_bonus. "
">40 = редко, типичный город. центр 15-30."
"POI-скор (040, оценочный) — взвешенная сумма близких POI с учётом "
"расстояния + бонус за центр. Не инвест-рейтинг: школы/сады/парки "
"повышают, трамвайные пути снижают. Типичный город 1530, >40 редко."
),
"score_breakdown": by_category,
# X1 (#47): per-factor контрибуции с verbal explain + top-3 / by-group.

View file

@ -245,8 +245,8 @@ function SiteFinderContent() {
? `${data.score.toFixed(2)} / ${scoreMax.toFixed(0)}`
: "—";
const scoreLabel = data?.score_label
? `Score · ${data.score_label}`
: "Score";
? `POI-скор · ${data.score_label} · оценочный`
: "POI-скор · оценочный";
const scoreCardColor: KpiColor = data
? scoreKpiColor(data.score, scoreMax)
: "neutral";

View file

@ -74,9 +74,9 @@ const COMPARISON_ROWS: ComparisonRow[] = [
gendesign: "DOM.РФ + Objective, еженедельно",
},
{
feature: "Инвест-балл участка и объяснение",
feature: "POI-скор участка и объяснение",
nspd: null,
gendesign: "Score 0100 с разбором факторов",
gendesign: "POI-скор 040 с разбором факторов",
},
{
feature: "Прогноз цены и горизонты выхода",

View file

@ -22,9 +22,14 @@ interface Props {
value: string;
label: string;
color?: KpiColor;
/**
* Опциональная подпись под значением (caption-токен 12/16, --fg-tertiary).
* Видимая (не hover) для честных квалификаторов вроде «оценочный».
*/
caption?: string;
}
export function KpiCard({ value, label, color = "neutral" }: Props) {
export function KpiCard({ value, label, color = "neutral", caption }: Props) {
return (
<div
style={{
@ -47,6 +52,18 @@ export function KpiCard({ value, label, color = "neutral" }: Props) {
>
{value}
</div>
{caption ? (
<div
style={{
fontSize: 12,
lineHeight: "16px",
color: "var(--fg-tertiary)",
marginTop: 2,
}}
>
{caption}
</div>
) : null}
<div
style={{
fontSize: 11,

View file

@ -3,9 +3,13 @@
/**
* 6.1 Прогноз по горизонтам compact table over future_market.forecasts_by_horizon.
*
* Columns: горизонт (мес) · индекс дефицита (color-coded) · месяцы предложения ·
* Columns: горизонт (мес) · индекс дефицита (color-coded) ·
* прогноз спроса/предложения · ставка (% годовых) · фраза чувствительности к ставке.
*
* MOI (months_of_inventory) НЕ выводим по горизонтам: по определению он не зависит
* от горизонта проекции, поэтому колонка дублировала одно и то же число 4 раза
* (выглядело сломанным). MOI показан один раз в Section6Forecast key_numbers.
*
* Deficit semantics (HARD, see frontend rules):
* deficit_index > 0 недонасыщенность (повод строить) success/green
* deficit_index < 0 затоварка (предложения > спроса) warn/danger
@ -15,7 +19,6 @@
import { Badge } from "@/components/ui/Badge";
import type { DemandSupplyForecast } from "@/types/forecast";
import {
DEFICIT_BALANCE_EPS,
deficitVariant,
deficitWord,
fmtNum,
@ -77,12 +80,6 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
<tr>
<th style={TH_STYLE}>Горизонт</th>
<th style={TH_STYLE}>Индекс дефицита</th>
<th
style={{ ...TH_STYLE, textAlign: "right" }}
title="Во сколько месяцев распродаётся текущий объём при базовом темпе"
>
Мес. предложения
</th>
<th style={{ ...TH_STYLE, textAlign: "right" }}>
Прогноз спроса, квартир
</th>
@ -95,7 +92,6 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
<tbody>
{rows.map((f) => {
const di = f.deficit_index;
const balanced = Math.abs(di) < DEFICIT_BALANCE_EPS;
const isTarget = f.horizon_months === targetHorizon;
return (
<tr
@ -142,9 +138,6 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
{deficitWord(di)}
</span>
</td>
<td style={NUM_TD}>
{balanced ? "—" : fmtNum(f.months_of_inventory, 1)}
</td>
<td style={NUM_TD}>
{Math.round(f.projected_demand_units).toLocaleString("ru")}
</td>

View file

@ -202,13 +202,15 @@ export function Section1ParcelInfo({ cad }: Props) {
: "—";
const scoreStr = `${data.score}`;
// Score color
// Score color — пороги выровнены под калиброванную 040 шкалу _score_label
// (#1871 P2: было 0100, давало red на хорошем ~30/40 участке).
// <5 плохо · 515 средне · 1525 хорошо · ≥25 отлично.
const scoreColor =
data.score >= 80
data.score >= 25
? "green"
: data.score >= 65
: data.score >= 15
? "blue"
: data.score >= 45
: data.score >= 5
? "amber"
: "red";
@ -276,7 +278,12 @@ export function Section1ParcelInfo({ cad }: Props) {
<KpiCard value={areaHa} label="Площадь" color="neutral" />
<KpiCard value={district} label="Район" color="neutral" />
<KpiCard value={medianPrice} label="Медиана рынка" color="blue" />
<KpiCard value={scoreStr} label="Инвест-балл" color={scoreColor} />
<KpiCard
value={scoreStr}
label="POI-скор · 040"
caption="оценочный"
color={scoreColor}
/>
</div>
{/* 2-column grid: map (left) + EGRN + POI (right) */}

View file

@ -102,7 +102,7 @@ const METRIC_ROWS: MetricRow[] = [
},
{
key: "score",
label: "Инвест-балл",
label: "POI-скор · 040",
winner: "high",
value: (c) => c.score ?? null,
render: (c) => (