Backend (parcels.py): - _compute_confidence() composite score 0..1 from 7 subscores: poi_freshness, geom_source (parcel vs quarter), district, market_trend (rosreestr_deals depth), competitors, environment (noise/air/weather availability), zoning (placeholder до G1). - confidence_label: high (>0.75) / medium (0.4-0.75) / low (<0.4) - confidence_caveats: list of конкретных проблем для UI - confidence_breakdown: per-subscore 0..1 для прозрачности Это stub-версия (полная — после G1/G2/D1/D2). Использует только текущие сигналы. Frontend: - Новый ConfidenceBadge.tsx — color-coded (green/yellow/red) badge с % - Caveats для low — показываются сразу; для medium/high — под toggle - Toggle "Подробнее" → breakdown per-subscore + полный список caveats - Размещён в начале OverviewTab (выше "Район") - TS типы расширены: confidence, confidence_label, confidence_breakdown, confidence_caveats Closes #48. Co-authored-by: lekss361 <claudestars@proton.me>
This commit is contained in:
parent
485c221c3b
commit
3777a77a42
4 changed files with 316 additions and 1 deletions
|
|
@ -140,7 +140,7 @@ def _fetch_seasonal_weather_sync(lat: float, lon: float) -> dict | None:
|
||||||
"period": "1995-2024 (30 лет)",
|
"period": "1995-2024 (30 лет)",
|
||||||
"model": "MRI-AGCM3-2-S",
|
"model": "MRI-AGCM3-2-S",
|
||||||
"source": "open-meteo-climate",
|
"source": "open-meteo-climate",
|
||||||
"note": ("Климатические нормали. " "Текущая погода — отдельный API."),
|
"note": ("Климатические нормали. Текущая погода — отдельный API."),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("seasonal weather fetch failed: %s", e)
|
logger.warning("seasonal weather fetch failed: %s", e)
|
||||||
|
|
@ -240,6 +240,21 @@ def _score_label(s: float) -> str:
|
||||||
return "хорошо" if s < SCORE_THRESHOLDS["отлично"] else "отлично"
|
return "хорошо" if s < SCORE_THRESHOLDS["отлично"] else "отлично"
|
||||||
|
|
||||||
|
|
||||||
|
def _confidence_label(c: float) -> str:
|
||||||
|
"""Текстовая интерпретация confidence (0..1).
|
||||||
|
|
||||||
|
Пороги:
|
||||||
|
high — c > 0.75 (плотные актуальные данные)
|
||||||
|
medium — 0.4-0.75
|
||||||
|
low — c < 0.4 (caveats обязательны)
|
||||||
|
"""
|
||||||
|
if c >= 0.75:
|
||||||
|
return "high"
|
||||||
|
if c >= 0.4:
|
||||||
|
return "medium"
|
||||||
|
return "low"
|
||||||
|
|
||||||
|
|
||||||
# Веса POI-категорий для scoring (Максим: трамвай = минус)
|
# Веса POI-категорий для scoring (Максим: трамвай = минус)
|
||||||
_POI_WEIGHTS: dict[str, float] = {
|
_POI_WEIGHTS: dict[str, float] = {
|
||||||
"school": 1.5,
|
"school": 1.5,
|
||||||
|
|
@ -321,6 +336,105 @@ def _geotech_risk(region_code: int, db: Session, geom_wkt: str) -> dict[str, Any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_confidence(
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
poi_rows: list[dict[str, Any]],
|
||||||
|
district_row: dict[str, Any] | None,
|
||||||
|
competitor_rows: list[dict[str, Any]],
|
||||||
|
noise_sources_count: int,
|
||||||
|
air_q: dict[str, Any] | None,
|
||||||
|
weather: dict[str, Any] | None,
|
||||||
|
market_trend: dict[str, Any] | None,
|
||||||
|
zoning: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""X2 (#48) — composite confidence score 0..1 + caveats.
|
||||||
|
|
||||||
|
Stub-версия (до реализации G1/G2/D1/D2): использует сигналы которые уже
|
||||||
|
доступны на main. Композитный балл = avg of subscore'ов; caveats — list
|
||||||
|
конкретных проблем для UI ("Нет данных N, score K ненадёжен").
|
||||||
|
"""
|
||||||
|
import datetime as _dt
|
||||||
|
|
||||||
|
caveats: list[str] = []
|
||||||
|
subscores: dict[str, float] = {}
|
||||||
|
|
||||||
|
# 1) POI freshness — % POI с last_osm_edit_date в последние 2 года.
|
||||||
|
# Для участков с малым числом POI (<5) — снижаем confidence как coverage.
|
||||||
|
poi_total = len(poi_rows)
|
||||||
|
if poi_total == 0:
|
||||||
|
subscores["poi_freshness"] = 0.0
|
||||||
|
caveats.append("OSM POI не найдены в радиусе 1км — скоринг неприменим")
|
||||||
|
else:
|
||||||
|
cutoff = _dt.date.today() - _dt.timedelta(days=730)
|
||||||
|
fresh = sum(
|
||||||
|
1 for p in poi_rows if p.get("last_osm_edit_date") and p["last_osm_edit_date"] >= cutoff
|
||||||
|
)
|
||||||
|
ratio = fresh / poi_total
|
||||||
|
# coverage penalty: <5 POI слабая статистика
|
||||||
|
coverage_factor = min(1.0, poi_total / 10.0)
|
||||||
|
subscores["poi_freshness"] = round(ratio * coverage_factor, 2)
|
||||||
|
if poi_total < 5:
|
||||||
|
caveats.append(f"Мало OSM POI в радиусе 1км ({poi_total}) — социалка-фактор ненадёжен")
|
||||||
|
elif ratio < 0.5:
|
||||||
|
caveats.append("Большая часть POI (>50%) старше 2 лет — данные OSM требуют обновления")
|
||||||
|
|
||||||
|
# 2) Geometry source confidence — участок > квартал
|
||||||
|
subscores["geom_source"] = 0.9 if source == "cad_building" else 0.6
|
||||||
|
if source == "cad_quarter":
|
||||||
|
caveats.append(
|
||||||
|
"Геометрия quartal-level (нет parcel shape) — окружение усреднено по кварталу"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3) District context — известен ли район
|
||||||
|
subscores["district"] = 1.0 if district_row else 0.3
|
||||||
|
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"])
|
||||||
|
# порог 5 сделок за 6 мес — достаточно для тренда
|
||||||
|
subscores["market_trend"] = min(1.0, n_recent / 10.0)
|
||||||
|
if n_recent < 5:
|
||||||
|
caveats.append(f"Мало ДДУ за 6 мес ({n_recent}) — тренд рынка статистически слабый")
|
||||||
|
else:
|
||||||
|
subscores["market_trend"] = 0.0
|
||||||
|
caveats.append("Нет ДДУ в 3км — тренд рынка недоступен")
|
||||||
|
|
||||||
|
# 5) Competitors coverage
|
||||||
|
n_competitors = len(competitor_rows)
|
||||||
|
subscores["competitors"] = min(1.0, n_competitors / 5.0)
|
||||||
|
if n_competitors == 0:
|
||||||
|
caveats.append("Нет конкурентов-ЖК в 3км — низкая урбанизация / окраина")
|
||||||
|
|
||||||
|
# 6) Environmental data freshness
|
||||||
|
env_ok = sum([bool(noise_sources_count > 0), bool(air_q), bool(weather)])
|
||||||
|
subscores["environment"] = env_ok / 3.0
|
||||||
|
if noise_sources_count == 0:
|
||||||
|
caveats.append("Шумовая карта не загружена — noise score = stub")
|
||||||
|
if not air_q:
|
||||||
|
caveats.append("Air Quality API недоступен — exposure unknown")
|
||||||
|
|
||||||
|
# 7) ПЗЗ coverage — placeholder до G1
|
||||||
|
has_zoning = bool(zoning.get("data_available")) if zoning else False
|
||||||
|
subscores["zoning"] = 1.0 if has_zoning else 0.2
|
||||||
|
if not has_zoning:
|
||||||
|
caveats.append(
|
||||||
|
"ПЗЗ zone_code не известен — нельзя оценить разрешённое использование (G1 pending)"
|
||||||
|
)
|
||||||
|
|
||||||
|
composite = sum(subscores.values()) / len(subscores)
|
||||||
|
composite = round(max(0.0, min(1.0, composite)), 2)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"value": composite,
|
||||||
|
"label": _confidence_label(composite),
|
||||||
|
"breakdown": subscores,
|
||||||
|
"caveats": caveats,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/search", response_model=ParcelSearchResponse)
|
@router.post("/search", response_model=ParcelSearchResponse)
|
||||||
async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse:
|
async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse:
|
||||||
"""Search parcels by filters + scoring.
|
"""Search parcels by filters + scoring.
|
||||||
|
|
@ -906,6 +1020,19 @@ def analyze_parcel(
|
||||||
|
|
||||||
score_final = score + center_bonus
|
score_final = score + center_bonus
|
||||||
|
|
||||||
|
# X2 (#48): composite confidence + caveats
|
||||||
|
confidence_info = _compute_confidence(
|
||||||
|
source=source,
|
||||||
|
poi_rows=[dict(p) for p in poi_rows],
|
||||||
|
district_row=dict(district_row) if district_row else None,
|
||||||
|
competitor_rows=[dict(c) for c in competitor_rows],
|
||||||
|
noise_sources_count=len(noise_rows),
|
||||||
|
air_q=air_q,
|
||||||
|
weather=weather,
|
||||||
|
market_trend=market_trend,
|
||||||
|
zoning=zoning,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"cad_num": cad_num,
|
"cad_num": cad_num,
|
||||||
"source": source,
|
"source": source,
|
||||||
|
|
@ -946,6 +1073,11 @@ def analyze_parcel(
|
||||||
"zoning": zoning,
|
"zoning": zoning,
|
||||||
"success_recommendation": success_recommendation,
|
"success_recommendation": success_recommendation,
|
||||||
"isochrones_available": bool(settings.openrouteservice_api_key),
|
"isochrones_available": bool(settings.openrouteservice_api_key),
|
||||||
|
# X2 (#48) — confidence indicator
|
||||||
|
"confidence": confidence_info["value"],
|
||||||
|
"confidence_label": confidence_info["label"],
|
||||||
|
"confidence_breakdown": confidence_info["breakdown"],
|
||||||
|
"confidence_caveats": confidence_info["caveats"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
167
frontend/src/components/site-finder/ConfidenceBadge.tsx
Normal file
167
frontend/src/components/site-finder/ConfidenceBadge.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: number;
|
||||||
|
label: "high" | "medium" | "low";
|
||||||
|
breakdown?: Record<string, number>;
|
||||||
|
caveats?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABEL_RU: Record<Props["label"], string> = {
|
||||||
|
high: "высокая",
|
||||||
|
medium: "средняя",
|
||||||
|
low: "низкая",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLOR: Record<
|
||||||
|
Props["label"],
|
||||||
|
{ bg: string; fg: string; border: string }
|
||||||
|
> = {
|
||||||
|
high: { bg: "#dcfce7", fg: "#15803d", border: "#86efac" },
|
||||||
|
medium: { bg: "#fef9c3", fg: "#a16207", border: "#fde68a" },
|
||||||
|
low: { bg: "#fee2e2", fg: "#b91c1c", border: "#fca5a5" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const BREAKDOWN_RU: Record<string, string> = {
|
||||||
|
poi_freshness: "Свежесть OSM POI",
|
||||||
|
geom_source: "Точность геометрии",
|
||||||
|
district: "Известность района",
|
||||||
|
market_trend: "Глубина ДДУ",
|
||||||
|
competitors: "Покрытие конкурентами",
|
||||||
|
environment: "Экологические данные",
|
||||||
|
zoning: "ПЗЗ / зонирование",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConfidenceBadge({ value, label, breakdown, caveats }: Props) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const c = COLOR[label];
|
||||||
|
const pct = Math.round(value * 100);
|
||||||
|
const hasDetails =
|
||||||
|
(caveats && caveats.length > 0) ||
|
||||||
|
(breakdown && Object.keys(breakdown).length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${c.border}`,
|
||||||
|
background: c.bg,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "10px 14px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: c.fg,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Достоверность
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: c.fg,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
title="Composite confidence: средневзвешенная по 7 подскорам"
|
||||||
|
>
|
||||||
|
{pct}% · {LABEL_RU[label]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{hasDetails && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((e) => !e)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
color: c.fg,
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? "Скрыть" : "Подробнее"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Caveats — показываем сразу для low, под toggle для medium/high */}
|
||||||
|
{caveats && caveats.length > 0 && (label === "low" || expanded) && (
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
paddingLeft: 18,
|
||||||
|
fontSize: 12,
|
||||||
|
color: c.fg,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{caveats.map((cv, i) => (
|
||||||
|
<li key={i}>{cv}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Breakdown — под toggle */}
|
||||||
|
{expanded && breakdown && Object.keys(breakdown).length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
color: c.fg,
|
||||||
|
paddingTop: 6,
|
||||||
|
borderTop: `1px solid ${c.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.entries(breakdown).map(([k, v]) => (
|
||||||
|
<div
|
||||||
|
key={k}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{BREAKDOWN_RU[k] ?? k}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(v * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import type { FeatureCollection } from "geojson";
|
import type { FeatureCollection } from "geojson";
|
||||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
|
import { ConfidenceBadge } from "./ConfidenceBadge";
|
||||||
import { IsochronesPanel } from "./IsochronesPanel";
|
import { IsochronesPanel } from "./IsochronesPanel";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -37,6 +38,16 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||||
|
{/* X2 (#48): confidence indicator на самом верху Overview */}
|
||||||
|
{data.confidence !== undefined && data.confidence_label && (
|
||||||
|
<ConfidenceBadge
|
||||||
|
value={data.confidence}
|
||||||
|
label={data.confidence_label}
|
||||||
|
breakdown={data.confidence_breakdown}
|
||||||
|
caveats={data.confidence_caveats}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* District info */}
|
{/* District info */}
|
||||||
{data.district && (
|
{data.district && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,11 @@ export interface ParcelAnalysis {
|
||||||
score_without_center?: number;
|
score_without_center?: number;
|
||||||
location?: ParcelLocation;
|
location?: ParcelLocation;
|
||||||
success_recommendation?: ParcelSuccessRecommendation | null;
|
success_recommendation?: ParcelSuccessRecommendation | null;
|
||||||
|
// X2 (#48) — confidence indicator
|
||||||
|
confidence?: number;
|
||||||
|
confidence_label?: "high" | "medium" | "low";
|
||||||
|
confidence_breakdown?: Record<string, number>;
|
||||||
|
confidence_caveats?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PoiCategory =
|
export type PoiCategory =
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue