feat(site-finder): X2 confidence indicator + caveats (#48) #88

Merged
lekss361 merged 1 commit from feat/site-finder-confidence into main 2026-05-11 22:04:25 +00:00
4 changed files with 316 additions and 1 deletions

View file

@ -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"],
}

View 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>
);
}

View file

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

View file

@ -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 =