feat(site-finder): X2 confidence indicator + caveats (#48) #88
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 лет)",
|
||||
"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)
|
||||
|
|
@ -240,6 +240,21 @@ def _score_label(s: float) -> str:
|
|||
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_WEIGHTS: dict[str, float] = {
|
||||
"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)
|
||||
async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse:
|
||||
"""Search parcels by filters + scoring.
|
||||
|
|
@ -906,6 +1020,19 @@ def analyze_parcel(
|
|||
|
||||
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 {
|
||||
"cad_num": cad_num,
|
||||
"source": source,
|
||||
|
|
@ -946,6 +1073,11 @@ def analyze_parcel(
|
|||
"zoning": zoning,
|
||||
"success_recommendation": success_recommendation,
|
||||
"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 { ParcelAnalysis } from "@/types/site-finder";
|
||||
import { ConfidenceBadge } from "./ConfidenceBadge";
|
||||
import { IsochronesPanel } from "./IsochronesPanel";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -37,6 +38,16 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
|
|||
|
||||
return (
|
||||
<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 */}
|
||||
{data.district && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -184,6 +184,11 @@ export interface ParcelAnalysis {
|
|||
score_without_center?: number;
|
||||
location?: ParcelLocation;
|
||||
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 =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue