fix(site-finder): invest-score advisory flag + velocity data-honesty + h1 a11y (#1926) #2094

Merged
bot-backend merged 1 commit from fix/sitefinder-honesty-polish-1926 into main 2026-06-29 06:42:51 +00:00
6 changed files with 141 additions and 42 deletions

View file

@ -3458,6 +3458,16 @@ def analyze_parcel(
"расстояния + бонус за центр. Не инвест-рейтинг: школы/сады/парки "
"повышают, трамвайные пути снижают. Типичный город 1530, >40 редко."
),
# #1926 (остаток аудита #1871): машиночитаемый признак честности score.
# score — это POI-density-эвристика по шкале 0..SCORE_MAX_REFERENCE, а НЕ
# абсолютный инвест-рейтинг. Флаг + структурный объект позволяют фронту /
# API-консьюмерам отличить эвристику от «настоящей» оценки явно (additive).
"score_is_advisory": True,
"score_advisory": {
"kind": "poi_density_heuristic",
"scale_min": 0,
"scale_max": SCORE_MAX_REFERENCE,
},
"score_breakdown": by_category,
# X1 (#47): per-factor контрибуции с verbal explain + top-3 / by-group.
"score_breakdown_detailed": factors_sorted,

View file

@ -623,6 +623,11 @@ class AnalyzeResponse(BaseModel):
score_label: str | None = None
score_max_reference: float | None = None
score_explanation: str | None = None
# #1926: машиночитаемый advisory-флаг — score это POI-density-эвристика
# (0..score_max_reference), а не абсолютный инвест-рейтинг. Optional для
# обратной совместимости (старые моки/202-стаб → null).
score_is_advisory: bool | None = None
score_advisory: dict[str, Any] | None = None
score_breakdown: dict[str, Any] | None = None
score_breakdown_detailed: list[dict[str, Any]] | None = None
score_top_3_positives: list[dict[str, Any]] | None = None

View file

@ -50,6 +50,7 @@ export function AnalysisPageContent({ cad }: Props) {
return (
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>
<Breadcrumb cad={cad} />
<PageHeading cad={cad} />
<div
style={{
marginTop: 24,
@ -76,6 +77,7 @@ export function AnalysisPageContent({ cad }: Props) {
return (
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>
<Breadcrumb cad={cad} />
<PageHeading cad={cad} />
<div
style={{
marginTop: 24,
@ -120,17 +122,7 @@ export function AnalysisPageContent({ cad }: Props) {
{/* Breadcrumb */}
<Breadcrumb cad={cad} districtName={districtName} areaStr={areaStr} />
<h1
style={{
fontSize: 22,
fontWeight: 600,
margin: "0 0 12px",
color: "var(--fg-primary)",
}}
>
Анализ участка {cad}
{districtName && districtName !== "—" ? ` · ${districtName}` : ""}
</h1>
<PageHeading cad={cad} districtName={districtName} />
{/* Controls row — horizon selector drives the analyze + forecast queries */}
<div
@ -232,6 +224,36 @@ export function AnalysisPageContent({ cad }: Props) {
);
}
// ── Page heading (h1 a11y) ─────────────────────────────────────────────────────
//
// Единственный <h1> страницы анализа (#1926 a11y): присутствует во ВСЕХ состояниях
// (loading / error / success), не только в success — иначе SR-пользователь на этапе
// загрузки/ошибки остаётся без заголовка верхнего уровня. district дописывается
// когда известен (success). Стиль = h1-токен (22/28 weight 600, left-align).
function PageHeading({
cad,
districtName,
}: {
cad: string;
districtName?: string;
}) {
const hasDistrict = districtName != null && districtName !== "—";
return (
<h1
style={{
fontSize: 22,
fontWeight: 600,
margin: "12px 0 0",
color: "var(--fg-primary, #111111)",
}}
>
Анализ участка {cad}
{hasDistrict ? ` · ${districtName}` : ""}
</h1>
);
}
// ── Breadcrumb ─────────────────────────────────────────────────────────────────
function Breadcrumb({

View file

@ -39,29 +39,52 @@ function formatPercent(score: number): string {
}
/**
* P2 (#1871): caveat о покрытии/прокси объёма velocity.
* - proxy_used === true полное предложение про прокси-оценку и покрытие.
* - proxy_used !== true только точность покрытия (если backend её дал).
* Возвращает null когда показывать нечего.
* P2 (#1871) / honesty (#1926): провенанс объёма velocity откуда взяты данные.
* Делит на два уровня честности:
* - `caveat` (видимый --warn-soft баннер) когда объём НЕ прямые Objective-продажи:
* rosreestr_fallback (кадастровый квартал) или proxy_used (оценка по м²/сделка).
* - `caption` (приглушённый caption) когда объём грунтуется на прямых Objective-данных,
* показываем долю покрытия без тревоги.
* Любое поле None/undefined опускаем (не выдумываем числа). Если показывать
* нечего оба null.
*/
function coverageCaption(velocity: Velocity): string | null {
if (velocity.proxy_used === true) {
interface VelocityProvenance {
caveat: string | null;
caption: string | null;
}
function velocityProvenance(velocity: Velocity): VelocityProvenance {
const isRosreestrFallback = velocity.velocity_source === "rosreestr_fallback";
const isProxy = velocity.proxy_used === true;
// Не прямые продажи Objective → честный caveat полным предложением.
if (isRosreestrFallback || isProxy) {
const parts: string[] = [];
if (velocity.proxy_sqm_per_deal != null) {
if (isProxy && velocity.proxy_sqm_per_deal != null) {
parts.push(
`прокси ${velocity.proxy_sqm_per_deal.toLocaleString("ru")} м²/сделка`,
);
}
if (velocity.objective_coverage_pct != null) {
parts.push(`покрытие ${Math.round(velocity.objective_coverage_pct)}%`);
parts.push(
`прямые данные Objective: ${Math.round(velocity.objective_coverage_pct)}%`,
);
}
const detail = parts.length > 0 ? ` (${parts.join(", ")})` : "";
return `Объём оценочный${detail}.`;
const detail = parts.length > 0 ? ` (${parts.join("; ")})` : "";
const reason = isRosreestrFallback
? "Объём оценён по сделкам кадастрового квартала, а не по прямым продажам Objective"
: "Объём оценён по прокси-метрике (м² на сделку), а не по прямым продажам Objective";
return { caveat: `${reason}${detail}.`, caption: null };
}
// Прямые Objective-данные → спокойный caption о покрытии.
if (velocity.objective_coverage_pct != null) {
return `Покрытие данными Objective: ${Math.round(velocity.objective_coverage_pct)}%.`;
return {
caveat: null,
caption: `Источник: прямые продажи Objective — покрытие ${Math.round(velocity.objective_coverage_pct)}%.`,
};
}
return null;
return { caveat: null, caption: null };
}
/** Словесный диапазон темпа: 033 «медленно» / 3366 «средне» / 66100 «быстро». */
@ -86,11 +109,12 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
const dataAvailable = velocity.velocity_data_available !== false;
const isRosreestrFallback = velocity.velocity_source === "rosreestr_fallback";
const isObjectiveSource = velocity.velocity_source === "objective";
const confColor = CONFIDENCE_COLOR[velocity.confidence];
const scorePct = formatPercent(velocity.velocity_score);
const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm;
const band = paceBand(velocity.velocity_score);
const coverageNote = coverageCaption(velocity);
const provenance = velocityProvenance(velocity);
// Вердикт: «×1.4 к медиане ЕКБ — выше рынка» (краткое словесное «быстрее/
// медленнее рынка» по соотношению, отдельно от шкалы 0100%).
const aboveMarket = ratio >= 1;
@ -118,8 +142,33 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
нет данных velocity
</span>
)}
{isObjectiveSource && (
<span
style={{
padding: "2px 8px",
background: "var(--accent-soft, #DBEAFE)",
color: "var(--accent, #1D4ED8)",
borderRadius: 4,
fontSize: 11,
fontWeight: 600,
}}
title="Объём посчитан по прямым продажам из базы Objective"
>
Источник: Objective
</span>
)}
{isRosreestrFallback && (
<span className="bg-amber-50 text-amber-800 text-xs px-2 py-0.5 rounded">
<span
style={{
padding: "2px 8px",
background: "var(--warn-soft, #FEF3C7)",
color: "var(--warn, #9A6700)",
borderRadius: 4,
fontSize: 11,
fontWeight: 600,
}}
title="Прямых продаж Objective нет — объём оценён по сделкам кадастрового квартала"
>
Источник: квартальные сделки
</span>
)}
@ -225,18 +274,36 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
(медиана ЕКБ)
</div>
{/* P2 (#1871) — caveat покрытия/прокси приглушённым caption-стилем */}
{coverageNote && (
{/* Честность данных (#1871/#1926) спокойный caption когда объём
грунтуется на прямых продажах Objective. */}
{provenance.caption && (
<div
style={{
fontSize: 11,
color: "var(--fg-tertiary, #73767E)",
marginTop: 4,
fontStyle: "italic",
marginTop: 6,
}}
title="Доля объёма velocity, опирающаяся на прямые данные Objective; остальное оценено по прокси-коэффициенту м²/сделка."
>
{coverageNote}
{provenance.caption}
</div>
)}
{/* Честность данных (#1871/#1926) видимый caveat (паттерн --warn-soft)
когда объём НЕ прямые продажи Objective (прокси / кадастровый квартал). */}
{provenance.caveat && (
<div
role="note"
style={{
marginTop: 8,
padding: "8px 12px",
background: "var(--warn-soft, #FEF3C7)",
borderRadius: 8,
fontSize: 11,
lineHeight: "16px",
color: "var(--warn, #9A6700)",
}}
>
{provenance.caveat}
</div>
)}
</div>

View file

@ -118,17 +118,6 @@ export function confidenceVariant(level: ConfidenceLevel): BadgeVariant {
return "danger";
}
/**
* ConfidenceBadge expects a numeric 0..1 value but the report carries a level
* only. Derive a representative midpoint so the numeric badge reads sensibly
* if/when reused.
*/
export function confidenceLevelToValue(level: ConfidenceLevel): number {
if (level === "high") return 0.85;
if (level === "medium") return 0.55;
return 0.25;
}
// ── Scoring (§13.6 — product scores #985 + special indices #986) ───────────────
/**

View file

@ -2740,6 +2740,12 @@ export interface components {
score_max_reference?: number | null;
/** Score Explanation */
score_explanation?: string | null;
/** Score Is Advisory */
score_is_advisory?: boolean | null;
/** Score Advisory */
score_advisory?: {
[key: string]: unknown;
} | null;
/** Score Breakdown */
score_breakdown?: {
[key: string]: unknown;