From 5d6895127baff4e685f649fefdf6c2936ae3ddd3 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Mon, 29 Jun 2026 11:27:02 +0500 Subject: [PATCH] fix(site-finder): invest-score advisory flag + velocity data-honesty + h1 a11y (#1926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Остаток аудита #1871 (#1926), тема data-honesty #1953. - invest_score: machine-readable score_is_advisory + score_advisory {kind=poi_density_heuristic, scale_min/scale_max=SCORE_MAX_REFERENCE} в analyze-payload + AnalyzeResponse (optional, backward-compat) + регенерён api-types.ts. Явно помечает score как POI-эвристику, не абсолютный рейтинг. - velocity (Section3 VelocityBlock): явный провенанс объёма — бейдж «Источник: Objective» + видимый --warn-soft caveat когда объём по прокси/ кад.кварталу, а не прямые продажи Objective. Поля провенанса уже доходили. - a11y:

во всех состояниях страницы анализа (был только в success). - cleanup: удалён неиспользуемый export confidenceLevelToValue (latent risk). units_sold-пункт #1926 закрыт ранее (#2054 inline _SOLD_COUNT_SQL). --- backend/app/api/v1/parcels.py | 10 ++ backend/app/schemas/parcel.py | 5 + .../analysis/[cad]/AnalysisPageContent.tsx | 44 +++++-- .../components/site-finder/VelocityBlock.tsx | 107 ++++++++++++++---- .../site-finder/analysis/forecast-helpers.ts | 11 -- frontend/src/lib/api-types.ts | 6 + 6 files changed, 141 insertions(+), 42 deletions(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 28f06766..a57b09ec 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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, diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 72ace881..ca78cb7e 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -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 diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index 491d257b..4f6ac20c 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -50,6 +50,7 @@ export function AnalysisPageContent({ cad }: Props) { return (
+
+
-

- Анализ участка {cad} - {districtName && districtName !== "—" ? ` · ${districtName}` : ""} -

+ {/* Controls row — horizon selector drives the analyze + forecast queries */}
страницы анализа (#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 ( +

+ Анализ участка {cad} + {hasDistrict ? ` · ${districtName}` : ""} +

+ ); +} + // ── Breadcrumb ───────────────────────────────────────────────────────────────── function Breadcrumb({ diff --git a/frontend/src/components/site-finder/VelocityBlock.tsx b/frontend/src/components/site-finder/VelocityBlock.tsx index de2a19d4..149bd55b 100644 --- a/frontend/src/components/site-finder/VelocityBlock.tsx +++ b/frontend/src/components/site-finder/VelocityBlock.tsx @@ -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 )} + {isObjectiveSource && ( + + Источник: Objective + + )} {isRosreestrFallback && ( - + Источник: квартальные сделки )} @@ -225,18 +274,36 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) { (медиана ЕКБ)
- {/* P2 (#1871) — caveat покрытия/прокси приглушённым caption-стилем */} - {coverageNote && ( + {/* Честность данных (#1871/#1926) — спокойный caption когда объём + грунтуется на прямых продажах Objective. */} + {provenance.caption && (
- {coverageNote} + {provenance.caption} +
+ )} + + {/* Честность данных (#1871/#1926) — видимый caveat (паттерн --warn-soft) + когда объём НЕ прямые продажи Objective (прокси / кадастровый квартал). */} + {provenance.caveat && ( +
+ {provenance.caveat}
)}
diff --git a/frontend/src/components/site-finder/analysis/forecast-helpers.ts b/frontend/src/components/site-finder/analysis/forecast-helpers.ts index 5ba46171..1eefc03f 100644 --- a/frontend/src/components/site-finder/analysis/forecast-helpers.ts +++ b/frontend/src/components/site-finder/analysis/forecast-helpers.ts @@ -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) ─────────────── /** diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index ea7dae0e..18b99fb6 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -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; -- 2.45.3