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 && (
+
| + Фактор + | ++ Вклад + | ++ % + | +
|---|---|---|
| + {f.verbal} + | += 0 ? "#16a34a" : "#dc2626", + fontVariantNumeric: "tabular-nums", + fontWeight: 600, + }} + > + {fmtContribution(f.contribution)} + | ++ {f.contribution_pct.toFixed(1)}% + | +