From 1d1c169365959237362c4b3690ad85797202c0bd Mon Sep 17 00:00:00 2001 From: lekss361 Date: Tue, 12 May 2026 00:43:34 +0300 Subject: [PATCH] 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: - POST /parcels/{cad}/analyze returns score_breakdown_detailed (per-POI FactorContribution with verbal explain), score_top_3_positives / score_top_3_negatives, score_by_group (для stacked bar). - Каждый фактор: factor slug, category, category_ru, group, value, weight, contribution, contribution_pct, verbal. - center_bonus добавлен как synthetic factor группы "Локация". - Skip факторов с |contribution| < 0.01 (POI > 1км) — UI шуму не нужен. Frontend: - Новый ScoreBreakdownPanel.tsx: stacked-bar по группам с легендой, топ-3 плюса / топ-3 минуса с verbal, разворачиваемая таблица всех факторов. - Интегрирован в OverviewTab под секцией "Балл". - TS типы расширены: FactorContribution, ScoreGroupTotal. Closes #47. --- backend/app/api/v1/parcels.py | 131 ++++++- .../components/site-finder/OverviewTab.tsx | 12 + .../site-finder/ScoreBreakdownPanel.tsx | 357 ++++++++++++++++++ frontend/src/types/site-finder.ts | 26 ++ 4 files changed, 522 insertions(+), 4 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 173e1af8..d65b412c 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -140,7 +140,7 @@ def _fetch_seasonal_weather_sync(lat: float, lon: float) -> dict | None: "period": "1995-2024 (30 лет)", "model": "MRI-AGCM3-2-S", "source": "open-meteo-climate", - "note": ("Климатические нормали. " "Текущая погода — отдельный API."), + "note": ("Климатические нормали. Текущая погода — отдельный API."), } except Exception as e: logger.warning("seasonal weather fetch failed: %s", e) @@ -255,6 +255,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 лет). # Добавляй регионы по мере расширения географии продукта. @@ -461,16 +510,20 @@ def analyze_parcel( # 4) Scoring: weighted sum с distance decay score = 0.0 by_category: dict[str, list[dict[str, Any]]] = {} + # X1 (#47): per-POI breakdown с verbal explain для UI + factors_detailed: list[dict[str, Any]] = [] for p in 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": ( @@ -478,6 +531,23 @@ def analyze_parcel( ), } ) + # Skip факторы с нулевым вкладом (POI дальше 1км) — UI шуму не нужен. + if abs(contribution) < 0.01: + continue + factors_detailed.append( + { + "factor": f"{cat}_{round(distance_m)}m", + "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 по @@ -542,6 +612,26 @@ def analyze_parcel( else: center_bonus = 0.0 + # X1 (#47): centrality как отдельный synthetic factor в breakdown + 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": center_bonus, + "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( @@ -906,6 +996,34 @@ 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) + score_top_3_positives = [f for f in factors_sorted if f["contribution"] > 0][:3] + score_top_3_negatives = [f for f in factors_sorted if f["contribution"] < 0][-3:][::-1] + + # By-group totals — для stacked-bar в UI + group_totals: dict[str, dict[str, float]] = {} + 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"])) + ] + return { "cad_num": cad_num, "source": source, @@ -920,6 +1038,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 d5631f7b..9c6cadeb 100644 --- a/frontend/src/components/site-finder/OverviewTab.tsx +++ b/frontend/src/components/site-finder/OverviewTab.tsx @@ -3,6 +3,7 @@ import type { FeatureCollection } from "geojson"; import type { ParcelAnalysis } from "@/types/site-finder"; import { IsochronesPanel } from "./IsochronesPanel"; +import { ScoreBreakdownPanel } from "./ScoreBreakdownPanel"; interface Props { data: ParcelAnalysis; @@ -154,6 +155,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)}%` : ""} +
+ ); + })} +
+
+ {byGroup.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 && ( +
+ + {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 4fe5dee8..a2982f4b 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -159,6 +159,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"; @@ -170,6 +192,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;