- {/* P2 (#46) — Соседи + overlap warning */}
+ {/* P2 (#46) — Соседи + overlap warning (hard warn если overlap) */}
{data.neighbors_summary && (
)}
+ {/* P1 (#45) — Geometry suitability */}
+ {data.geometry_suitability !== undefined && (
+
+ )}
+
{/* Zoning note */}
"
+export type GeometrySuitabilityBaseLabel =
+ | "микро"
+ | "подходящий"
+ | "сложная форма"
+ | "слабо подходит";
+
+export interface GeometrySuitability {
+ data_available: boolean;
+ area_ha?: number;
+ area_m2?: number;
+ perimeter_m?: number;
+ aspect_ratio?: number;
+ convex_hull_ratio?: number;
+ min_inscribed_rect_dim_m?: number;
+ suitability_score?: number;
+ // string — допускаем combo-label "микро, узкий"; см. GeometrySuitabilityBaseLabel
+ label?: string;
+ penalties?: string[];
+ recommendation?: string;
+ note: string;
+}
+
export interface SuccessRankingBucket {
bucket: string;
success_score: number;
@@ -218,6 +242,8 @@ export interface ParcelAnalysis {
score_without_center?: number;
location?: ParcelLocation;
success_recommendation?: ParcelSuccessRecommendation | null;
+ // P1 (#45) — physical suitability участка
+ geometry_suitability?: GeometrySuitability;
// P2 (#46) — cad_buildings соседи + overlap check
neighbors_summary?: NeighborsSummary;
// X2 (#48) — confidence indicator
--
2.45.3
From d4c9930154cd83ef2cdee7b53fd362036c665f6e Mon Sep 17 00:00:00 2001
From: lekss361
Date: Tue, 12 May 2026 08:10:36 +0300
Subject: [PATCH 2/2] feat(site-finder): X1 score breakdown + verbal explain
(#47)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Backend (parcels.py):
- POI scoring loop teper строит score_breakdown_detailed: per-factor list с
verbal explain (через _verbal_for_poi helper) и группировкой
(_POI_GROUP: Социалка / Торговля / Парки / Транспорт / Шум/трамвай / Локация).
- center_bonus добавлен как synthetic factor группы "Локация" с weight=1.0
(decay не применяется — bonus IS the value).
- factor key включает enumerate idx — prevents React key collision когда
два POI одной категории совпадают по округлённому расстоянию.
- Skip факторов с |contribution| < 0.01 (POI > 1км) — UI шуму не нужен.
- abs_total fallback на 1.0 — защита от division-by-zero для empty factors.
- Top-3 positives/negatives: explicit ascending sort для негативов
("most-negative first" очевидно из кода).
- score_by_group: stacked-bar данные с count + contribution_pct.
- group_totals type: dict[str, dict[str, float | int]] (count это int).
Frontend:
- Новый ScoreBreakdownPanel.tsx: stacked-bar по группам с tooltip + legend,
топ-3 плюса (▲ зелёный) / топ-3 минуса (▼ красный) с verbal, отдельная
строка "Снижают балл — Шум/трамвай: ..." для negative groups, разворачиваемая
таблица всех факторов (sticky thead, scrollable).
- Интегрирован в OverviewTab под секцией "Балл".
- TS типы: FactorContribution, ScoreGroupTotal.
Closes #47.
NB: branch создан заново из-за rebase mess (см. PR #87 comments). Логически
эквивалентно но history clean.
---
backend/app/api/v1/parcels.py | 158 ++++++-
.../components/site-finder/OverviewTab.tsx | 12 +
.../site-finder/ScoreBreakdownPanel.tsx | 391 ++++++++++++++++++
frontend/src/types/site-finder.ts | 26 ++
4 files changed, 578 insertions(+), 9 deletions(-)
create mode 100644 frontend/src/components/site-finder/ScoreBreakdownPanel.tsx
diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py
index 13bac11e..7743f5cb 100644
--- a/backend/app/api/v1/parcels.py
+++ b/backend/app/api/v1/parcels.py
@@ -1,3 +1,4 @@
+import datetime as _dt
import json
import logging
import math
@@ -270,6 +271,55 @@ _POI_WEIGHTS: dict[str, float] = {
"tram_stop": -0.5, # негативный вес — шум / вибрация
}
+# Человеко-читаемые имена категорий для verbal breakdown (X1).
+_POI_CATEGORY_RU: dict[str, str] = {
+ "school": "Школа",
+ "kindergarten": "Детсад",
+ "pharmacy": "Аптека",
+ "hospital": "Больница",
+ "shop_mall": "ТЦ",
+ "shop_supermarket": "Супермаркет",
+ "shop_small": "Магазин",
+ "park": "Парк",
+ "bus_stop": "Автобус",
+ "metro_stop": "Метро",
+ "tram_stop": "Трамвай",
+}
+
+# Группировка POI по тематическим эшелонам — для stacked-bar % contribution
+# (X1 score breakdown). Расширяй по мере добавления новых категорий.
+_POI_GROUP: dict[str, str] = {
+ "school": "Социалка",
+ "kindergarten": "Социалка",
+ "pharmacy": "Социалка",
+ "hospital": "Социалка",
+ "shop_mall": "Торговля",
+ "shop_supermarket": "Торговля",
+ "shop_small": "Торговля",
+ "park": "Парки",
+ "bus_stop": "Транспорт",
+ "metro_stop": "Транспорт",
+ "tram_stop": "Шум/трамвай",
+}
+
+
+def _verbal_for_poi(
+ cat: str,
+ name: str | None,
+ distance_m: float,
+ contribution: float,
+) -> str:
+ """Сгенерировать verbal explain для одного POI-вклада.
+
+ Пример: "Школа №125 в 400м — +0.90 баллов".
+ Для отрицательного вклада (трамваи): "Трамвай Ленина в 80м — −0.46 баллов".
+ """
+ label = _POI_CATEGORY_RU.get(cat, cat)
+ safe_name = (name or "").strip()
+ name_part = f" «{safe_name}»" if safe_name and safe_name != "—" else ""
+ sign = "+" if contribution >= 0 else "−"
+ return f"{label}{name_part} в {round(distance_m)}м — {sign}{abs(contribution):.2f} баллов"
+
# Сейсмика по ОСР-2016 карта B (среднее повторяемое за 500 лет).
# Добавляй регионы по мере расширения географии продукта.
@@ -530,8 +580,6 @@ def _compute_confidence(
доступны на main. Композитный балл = avg of subscore'ов; caveats — list
конкретных проблем для UI ("Нет данных N, score K ненадёжен").
"""
- import datetime as _dt
-
caveats: list[str] = []
subscores: dict[str, float] = {}
@@ -567,9 +615,15 @@ def _compute_confidence(
if not district_row:
caveats.append("Район не определён (вне границ ЕКБ?) — медианные цены недоступны")
- # 4) Market trend — есть ли rosreestr_deals
- if market_trend and market_trend.get("recent_deals_count"):
- n_recent = int(market_trend["recent_deals_count"])
+ # 4) Market trend — есть ли rosreestr_deals.
+ # Guard `int(... or 0)` — recent_deals_count иногда приходит как non-numeric
+ # из external/legacy paths; без guard int() крашнет 500.
+ n_recent_raw = (market_trend or {}).get("recent_deals_count")
+ try:
+ n_recent = int(n_recent_raw) if n_recent_raw is not None else 0
+ except (ValueError, TypeError):
+ n_recent = 0
+ if n_recent > 0:
# порог 5 сделок за 6 мес — достаточно для тренда
subscores["market_trend"] = min(1.0, n_recent / 10.0)
if n_recent < 5:
@@ -751,16 +805,20 @@ def analyze_parcel(
# 4) Scoring: weighted sum с distance decay
score = 0.0
by_category: dict[str, list[dict[str, Any]]] = {}
- for p in poi_rows:
+ # X1 (#47): per-POI breakdown с verbal explain для UI
+ factors_detailed: list[dict[str, Any]] = []
+ for idx, p in enumerate(poi_rows):
cat: str = p["category"]
w = _POI_WEIGHTS.get(cat, 0.0)
# distance decay: 1.0 на 0м, 0.5 на ~500м, ~0 на 1000м
- decay = max(0.0, 1.0 - float(p["distance_m"]) / 1000.0)
- score += w * decay
+ distance_m = float(p["distance_m"])
+ decay = max(0.0, 1.0 - distance_m / 1000.0)
+ contribution = w * decay
+ score += contribution
by_category.setdefault(cat, []).append(
{
"name": p["name"],
- "distance_m": round(float(p["distance_m"])),
+ "distance_m": round(distance_m),
"lat": float(p["lat"]) if p["lat"] is not None else None,
"lon": float(p["lon"]) if p["lon"] is not None else None,
"last_edit": (
@@ -768,6 +826,26 @@ def analyze_parcel(
),
}
)
+ # Skip факторы с нулевым вкладом (POI дальше 1км) — UI шуму не нужен.
+ if abs(contribution) < 0.01:
+ continue
+ factors_detailed.append(
+ {
+ # Include idx чтобы избежать React key collision: два POI одной
+ # категории на одинаково округлённом расстоянии иначе дали бы
+ # дубль (например, two аптеки 450м в плотном районе).
+ "factor": f"{cat}_{round(distance_m)}m_{idx}",
+ "category": cat,
+ "category_ru": _POI_CATEGORY_RU.get(cat, cat),
+ "group": _POI_GROUP.get(cat, "Прочее"),
+ "value": round(distance_m, 1),
+ "weight": w,
+ "contribution": round(contribution, 2),
+ "verbal": _verbal_for_poi(cat, p["name"], distance_m, contribution),
+ "lat": float(p["lat"]) if p["lat"] is not None else None,
+ "lon": float(p["lon"]) if p["lon"] is not None else None,
+ }
+ )
# 5) Конкуренты в радиусе 3 км из DOM.РФ.
# NB: domrf_kn_objects имеет ~3 snapshot per obj_id → DISTINCT ON по
@@ -832,6 +910,28 @@ def analyze_parcel(
else:
center_bonus = 0.0
+ # X1 (#47): centrality как отдельный synthetic factor в breakdown.
+ # NB: для centrality decay не применяется (bonus IS the value), поэтому
+ # weight=1.0 семантически — "no decay multiplier"; contribution = center_bonus.
+ if center_bonus > 0:
+ factors_detailed.append(
+ {
+ "factor": f"center_bonus_{round(dist_to_center_km)}km",
+ "category": "centrality",
+ "category_ru": "Центральность",
+ "group": "Локация",
+ "value": round(dist_to_center_km, 2),
+ "weight": 1.0,
+ "contribution": round(center_bonus, 2),
+ "verbal": (
+ f"Близость к центру ЕКБ ({dist_to_center_km:.1f}км) — "
+ f"+{center_bonus:.2f} баллов"
+ ),
+ "lat": None,
+ "lon": None,
+ }
+ )
+
# 7) Noise score — шумовые источники в радиусе 2 км
noise_rows = (
db.execute(
@@ -1196,6 +1296,41 @@ def analyze_parcel(
score_final = score + center_bonus
+ # X1 (#47): расчёт contribution_pct + top-3 / by-group для UI.
+ # Базис для процентов — сумма абсолютных значений всех факторов; это даёт
+ # стабильное соотношение независимо от знака и не делится на 0.
+ abs_total = sum(abs(f["contribution"]) for f in factors_detailed) or 1.0
+ for f in factors_detailed:
+ f["contribution_pct"] = round(100.0 * abs(f["contribution"]) / abs_total, 1)
+
+ factors_sorted = sorted(factors_detailed, key=lambda x: x["contribution"], reverse=True)
+ # Convention: оба top-list'а отсортированы "dominant first":
+ # positives → most-positive first (factors_sorted desc → [:3])
+ # negatives → most-negative first (sort negatives asc → [:3])
+ # Раньше использовался trick [-3:][::-1] на desc-sorted — это давало тот же
+ # результат для N>=3 negatives, но был неинтуитивно читать; explicit sort asc
+ # короче и не зависит от тонкостей slicing.
+ score_top_3_positives = [f for f in factors_sorted if f["contribution"] > 0][:3]
+ negatives_only = [f for f in factors_sorted if f["contribution"] < 0]
+ score_top_3_negatives = sorted(negatives_only, key=lambda x: x["contribution"])[:3]
+
+ # By-group totals — для stacked-bar в UI. count это int, contribution* — float.
+ group_totals: dict[str, dict[str, float | int]] = {}
+ for f in factors_detailed:
+ g = group_totals.setdefault(
+ f["group"], {"contribution": 0.0, "count": 0, "contribution_pct": 0.0}
+ )
+ g["contribution"] += f["contribution"]
+ g["count"] += 1
+ group_abs_total = sum(abs(g["contribution"]) for g in group_totals.values()) or 1.0
+ for g_val in group_totals.values():
+ g_val["contribution"] = round(g_val["contribution"], 2)
+ g_val["contribution_pct"] = round(100.0 * abs(g_val["contribution"]) / group_abs_total, 1)
+ score_by_group = [
+ {"group": k, **v}
+ for k, v in sorted(group_totals.items(), key=lambda kv: -abs(kv[1]["contribution"]))
+ ]
+
# X2 (#48): composite confidence + caveats
confidence_info = _compute_confidence(
source=source,
@@ -1223,6 +1358,11 @@ def analyze_parcel(
">40 = редко, типичный город. центр 15-30."
),
"score_breakdown": by_category,
+ # X1 (#47): per-factor контрибуции с verbal explain + top-3 / by-group.
+ "score_breakdown_detailed": factors_sorted,
+ "score_top_3_positives": score_top_3_positives,
+ "score_top_3_negatives": score_top_3_negatives,
+ "score_by_group": score_by_group,
"poi_count": len(poi_rows),
"location": {
"distance_to_center_km": round(dist_to_center_km, 2),
diff --git a/frontend/src/components/site-finder/OverviewTab.tsx b/frontend/src/components/site-finder/OverviewTab.tsx
index 70e47687..eaffd4c4 100644
--- a/frontend/src/components/site-finder/OverviewTab.tsx
+++ b/frontend/src/components/site-finder/OverviewTab.tsx
@@ -4,6 +4,7 @@ import type { FeatureCollection } from "geojson";
import type { ParcelAnalysis } from "@/types/site-finder";
import { ConfidenceBadge } from "./ConfidenceBadge";
import { IsochronesPanel } from "./IsochronesPanel";
+import { ScoreBreakdownPanel } from "./ScoreBreakdownPanel";
interface Props {
data: ParcelAnalysis;
@@ -165,6 +166,17 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
)}
+ {/* X1 (#47): per-factor score breakdown с verbal explain */}
+ {data.score_breakdown_detailed &&
+ data.score_breakdown_detailed.length > 0 && (
+
+ )}
+
{/* POI breakdown */}
= {
+ Социалка: "#0ea5e9",
+ Торговля: "#a855f7",
+ Парки: "#16a34a",
+ Транспорт: "#eab308",
+ "Шум/трамвай": "#dc2626",
+ Локация: "#1d4ed8",
+ Прочее: "#94a3b8",
+};
+
+function fmtContribution(v: number): string {
+ const sign = v >= 0 ? "+" : "−";
+ return `${sign}${Math.abs(v).toFixed(2)}`;
+}
+
+export function ScoreBreakdownPanel({
+ topPositives,
+ topNegatives,
+ byGroup,
+ detailed,
+}: Props) {
+ const [expanded, setExpanded] = useState(false);
+
+ // Stacked bar — только positive groups (для визуальной шкалы вклада)
+ const positiveGroups = byGroup.filter((g) => g.contribution > 0);
+ const totalPositive =
+ positiveGroups.reduce((s, g) => s + g.contribution, 0) || 1;
+
+ return (
+
+
+ Почему такой балл
+
+
+ {/* Stacked bar — % contribution по группам */}
+ {positiveGroups.length > 0 && (
+
+
+ {positiveGroups.map((g) => {
+ const widthPct = (g.contribution / totalPositive) * 100;
+ return (
+
+ {widthPct >= 10 ? `${Math.round(widthPct)}%` : ""}
+
+ );
+ })}
+
+
+ {/* Positive groups — visible в баре */}
+ {positiveGroups.map((g) => (
+
+
+
+ {g.group}:{" "}
+
+ {fmtContribution(g.contribution)}
+
+
+
+ ))}
+
+ {/* Negative groups — отдельной "drag" линией под баром (legend для bar
+ использует только positive, чтобы не было orphan swatches без сегмента) */}
+ {byGroup
+ .filter((g) => g.contribution < 0)
+ .map((g) => (
+
+
+
+ Снижают балл — {g.group}:{" "}
+
+ {fmtContribution(g.contribution)}
+
+
+
+ ))}
+
+ )}
+
+ {/* Top-3 positive */}
+ {topPositives.length > 0 && (
+
+
+ Топ-3 плюса
+
+
+ {topPositives.map((f) => (
+
+
+ ▲
+
+ {f.verbal}
+
+ ))}
+
+
+ )}
+
+ {/* Top-3 negative */}
+ {topNegatives.length > 0 && (
+
+
+ Топ-3 минуса
+
+
+ {topNegatives.map((f) => (
+
+
+ ▼
+
+ {f.verbal}
+
+ ))}
+
+
+ )}
+
+ {/* Toggle full breakdown */}
+ {detailed.length > 0 && (
+
+
setExpanded((e) => !e)}
+ aria-expanded={expanded}
+ style={{
+ background: "none",
+ border: "none",
+ padding: 0,
+ color: "#1d4ed8",
+ fontSize: 13,
+ cursor: "pointer",
+ fontWeight: 500,
+ }}
+ >
+ {expanded
+ ? "Скрыть все факторы"
+ : `Показать все факторы (${detailed.length})`}
+
+ {expanded && (
+
+
+
+
+
+ Фактор
+
+
+ Вклад
+
+
+ %
+
+
+
+
+ {detailed.map((f) => (
+
+
+ {f.verbal}
+
+ = 0 ? "#16a34a" : "#dc2626",
+ fontVariantNumeric: "tabular-nums",
+ fontWeight: 600,
+ }}
+ >
+ {fmtContribution(f.contribution)}
+
+
+ {f.contribution_pct.toFixed(1)}%
+
+
+ ))}
+
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts
index a9310812..85e684ed 100644
--- a/frontend/src/types/site-finder.ts
+++ b/frontend/src/types/site-finder.ts
@@ -193,6 +193,28 @@ export interface ParcelSuccessRecommendation {
note: string;
}
+// X1 (#47) — per-factor breakdown с verbal explain
+export interface FactorContribution {
+ factor: string;
+ category: string;
+ category_ru: string;
+ group: string;
+ value: number;
+ weight: number;
+ contribution: number;
+ contribution_pct: number;
+ verbal: string;
+ lat: number | null;
+ lon: number | null;
+}
+
+export interface ScoreGroupTotal {
+ group: string;
+ contribution: number;
+ count: number;
+ contribution_pct: number;
+}
+
export interface ParcelAnalysis {
cad_num: string;
source: "cad_quarter" | "cad_building";
@@ -204,6 +226,10 @@ export interface ParcelAnalysis {
score_explanation?: string;
market_trend?: MarketTrend | null;
score_breakdown: Record
;
+ score_breakdown_detailed?: FactorContribution[];
+ score_top_3_positives?: FactorContribution[];
+ score_top_3_negatives?: FactorContribution[];
+ score_by_group?: ScoreGroupTotal[];
poi_count: number;
competitors: ParcelAnalysisCompetitor[];
noise: ParcelAnalysisNoise | null;
--
2.45.3