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 && ( +
+ + {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;