fix(site-finder): invest-score advisory flag + velocity data-honesty + h1 a11y (#1926) #2094
6 changed files with 141 additions and 42 deletions
|
|
@ -3458,6 +3458,16 @@ def analyze_parcel(
|
|||
"расстояния + бонус за центр. Не инвест-рейтинг: школы/сады/парки "
|
||||
"повышают, трамвайные пути снижают. Типичный город 15–30, >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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/** Словесный диапазон темпа: 0–33 «медленно» / 33–66 «средне» / 66–100 «быстро». */
|
||||
|
|
@ -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 к медиане ЕКБ — выше рынка» (краткое словесное «быстрее/
|
||||
// медленнее рынка» по соотношению, отдельно от шкалы 0–100%).
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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) ───────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue