feat(site-finder): X1 score breakdown + verbal explain (#47) #92

Merged
lekss361 merged 1 commit from feat/site-finder-x1-clean into main 2026-05-12 05:17:00 +00:00
4 changed files with 578 additions and 9 deletions

View file

@ -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),

View file

@ -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) {
)}
</div>
{/* X1 (#47): per-factor score breakdown с verbal explain */}
{data.score_breakdown_detailed &&
data.score_breakdown_detailed.length > 0 && (
<ScoreBreakdownPanel
topPositives={data.score_top_3_positives ?? []}
topNegatives={data.score_top_3_negatives ?? []}
byGroup={data.score_by_group ?? []}
detailed={data.score_breakdown_detailed}
/>
)}
{/* POI breakdown */}
<div
style={{

View file

@ -0,0 +1,391 @@
"use client";
import { useState } from "react";
import type { FactorContribution, ScoreGroupTotal } from "@/types/site-finder";
interface Props {
topPositives: FactorContribution[];
topNegatives: FactorContribution[];
byGroup: ScoreGroupTotal[];
detailed: FactorContribution[];
}
// Цвета stacked bar — соответствуют тематическим эшелонам
const GROUP_COLORS: Record<string, string> = {
Социалка: "#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 (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
background: "#fff",
display: "flex",
flexDirection: "column",
gap: 14,
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Почему такой балл
</div>
{/* Stacked bar — % contribution по группам */}
{positiveGroups.length > 0 && (
<div>
<div
style={{
display: "flex",
height: 24,
borderRadius: 4,
overflow: "hidden",
border: "1px solid #e5e7eb",
}}
role="img"
aria-label="Состав положительного вклада по группам"
>
{positiveGroups.map((g) => {
const widthPct = (g.contribution / totalPositive) * 100;
return (
<div
key={g.group}
style={{
width: `${widthPct}%`,
background: GROUP_COLORS[g.group] ?? GROUP_COLORS.Прочее,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
color: "#fff",
fontWeight: 500,
overflow: "hidden",
whiteSpace: "nowrap",
}}
title={`${g.group}: ${fmtContribution(g.contribution)} (${g.contribution_pct}%)`}
>
{widthPct >= 10 ? `${Math.round(widthPct)}%` : ""}
</div>
);
})}
</div>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 10,
marginTop: 8,
fontSize: 12,
color: "#6b7280",
}}
>
{/* Positive groups — visible в баре */}
{positiveGroups.map((g) => (
<div
key={g.group}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: 2,
background: GROUP_COLORS[g.group] ?? GROUP_COLORS.Прочее,
}}
/>
<span>
{g.group}:{" "}
<strong style={{ color: "#374151" }}>
{fmtContribution(g.contribution)}
</strong>
</span>
</div>
))}
</div>
{/* Negative groups отдельной "drag" линией под баром (legend для bar
использует только positive, чтобы не было orphan swatches без сегмента) */}
{byGroup
.filter((g) => g.contribution < 0)
.map((g) => (
<div
key={`neg-${g.group}`}
style={{
display: "flex",
alignItems: "center",
gap: 4,
marginTop: 6,
fontSize: 12,
color: "#6b7280",
}}
>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: 2,
background: GROUP_COLORS[g.group] ?? GROUP_COLORS.Прочее,
}}
/>
<span>
Снижают балл {g.group}:{" "}
<strong style={{ color: "#dc2626" }}>
{fmtContribution(g.contribution)}
</strong>
</span>
</div>
))}
</div>
)}
{/* Top-3 positive */}
{topPositives.length > 0 && (
<div>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "#16a34a",
textTransform: "uppercase",
letterSpacing: "0.04em",
marginBottom: 6,
}}
>
Топ-3 плюса
</div>
<ul
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
{topPositives.map((f) => (
<li
key={f.factor}
style={{ fontSize: 13, color: "#374151", lineHeight: 1.4 }}
>
<span
style={{
color: "#16a34a",
fontWeight: 600,
marginRight: 6,
}}
>
</span>
{f.verbal}
</li>
))}
</ul>
</div>
)}
{/* Top-3 negative */}
{topNegatives.length > 0 && (
<div>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "#dc2626",
textTransform: "uppercase",
letterSpacing: "0.04em",
marginBottom: 6,
}}
>
Топ-3 минуса
</div>
<ul
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
{topNegatives.map((f) => (
<li
key={f.factor}
style={{ fontSize: 13, color: "#374151", lineHeight: 1.4 }}
>
<span
style={{
color: "#dc2626",
fontWeight: 600,
marginRight: 6,
}}
>
</span>
{f.verbal}
</li>
))}
</ul>
</div>
)}
{/* Toggle full breakdown */}
{detailed.length > 0 && (
<div>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
aria-expanded={expanded}
style={{
background: "none",
border: "none",
padding: 0,
color: "#1d4ed8",
fontSize: 13,
cursor: "pointer",
fontWeight: 500,
}}
>
{expanded
? "Скрыть все факторы"
: `Показать все факторы (${detailed.length})`}
</button>
{expanded && (
<div
style={{
marginTop: 10,
maxHeight: 280,
overflowY: "auto",
border: "1px solid #f3f4f6",
borderRadius: 6,
}}
>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 12,
}}
>
<thead
style={{
background: "#f9fafb",
position: "sticky",
top: 0,
zIndex: 1,
}}
>
<tr>
<th
style={{
padding: "6px 10px",
textAlign: "left",
color: "#6b7280",
fontWeight: 500,
}}
>
Фактор
</th>
<th
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontWeight: 500,
}}
>
Вклад
</th>
<th
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontWeight: 500,
}}
>
%
</th>
</tr>
</thead>
<tbody>
{detailed.map((f) => (
<tr
key={f.factor}
style={{ borderTop: "1px solid #f3f4f6" }}
>
<td
style={{
padding: "6px 10px",
color: "#374151",
}}
title={f.verbal}
>
{f.verbal}
</td>
<td
style={{
padding: "6px 10px",
textAlign: "right",
color: f.contribution >= 0 ? "#16a34a" : "#dc2626",
fontVariantNumeric: "tabular-nums",
fontWeight: 600,
}}
>
{fmtContribution(f.contribution)}
</td>
<td
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontVariantNumeric: "tabular-nums",
}}
>
{f.contribution_pct.toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}

View file

@ -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<string, ParcelAnalysisPoi[]>;
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;