fix(site-finder): normalize POI weighted score to 0..100 on backend (#1486)
Some checks failed
CI / changes (push) Successful in 10s
CI / changes (pull_request) Successful in 12s
CI / frontend-tests (push) Successful in 49s
CI / openapi-codegen-check (push) Failing after 1m45s
CI / backend-tests (push) Failing after 8m57s
CI / frontend-tests (pull_request) Successful in 45s
CI / openapi-codegen-check (pull_request) Failing after 1m37s
CI / backend-tests (pull_request) Failing after 8m53s

Backend now computes poi_weighted_score and per-POI score_contribution in
0..100 range. Normalization denominator = sum of top-7 category weights / 100
(straight-line mode) or sum of top-7 category weights (routing-decay mode).
Frontend stops reconstructing score from raw weight × 100.
This commit is contained in:
bot-backend 2026-06-17 20:26:35 +03:00
parent 14f3ef2019
commit 68eb6ded89
5 changed files with 177 additions and 51 deletions

View file

@ -44,6 +44,21 @@ CATEGORY_WEIGHTS: dict[str, float] = {
"default": 1.0,
}
# ── Нормировочные константы (для вывода score_contribution / poi_weighted_score в 0..100) ──
#
# Теоретический максимум суммы весов top-7 POI при идеальном расположении:
# straight-line: w_i = cat_weight_i / (0+100) → max_sum = Σ(top7 cat_weights) / 100
# routing-decay: w_i = cat_weight_i * decay(0) = cat_weight_i → max_sum = Σ(top7 cat_weights)
#
# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5
_TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
# straight-line mode: каждый POI берётся с множителем 1/(d+100); при d=0 → /100
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / 100.0 # ≈ 0.315
# routing-decay mode: decay ∈ [0,1], при t=0 decay=1.0 → max = cat_weight
_MAX_ROUTING_SCORE: float = _TOP7_WEIGHT_SUM # = 31.5
class PoiScoreItem(BaseModel):
"""Один POI в ranked-ответе."""
@ -52,12 +67,17 @@ class PoiScoreItem(BaseModel):
category: str
distance_m: float
weight: float
# Вклад данного POI в суммарный балл, в процентах от 0..100.
# Позволяет фронтенду рендерить «долю» без знания формулы нормировки.
score_contribution: float
address: str | None
class PoiScoreResponse(BaseModel):
cad_num: str
radius_m: int
# Суммарный взвешенный балл инфраструктуры, нормированный в диапазон 0..100.
poi_weighted_score: float
top_poi: list[PoiScoreItem]
@ -155,6 +175,7 @@ def compute_poi_weighted_top7(
category=category,
distance_m=round(distance_m, 1),
weight=round(weight, 6),
score_contribution=0.0, # заполним после нормировки
address=address,
)
)
@ -163,9 +184,18 @@ def compute_poi_weighted_top7(
items.sort(key=lambda x: x.weight, reverse=True)
top_items = items[:top_n]
# Нормировка: poi_weighted_score = (Σ weight_i / _MAX_STRAIGHT_SCORE) * 100, клэмп [0, 100]
raw_sum = sum(i.weight for i in top_items)
poi_weighted_score = round(min(100.0, (raw_sum / _MAX_STRAIGHT_SCORE) * 100.0), 1)
# score_contribution — доля данного POI в общем балле (0..100)
for item in top_items:
item.score_contribution = round((item.weight / _MAX_STRAIGHT_SCORE) * 100.0, 1)
return PoiScoreResponse(
cad_num=cad_num,
radius_m=radius_m,
poi_weighted_score=poi_weighted_score,
top_poi=top_items,
)
@ -303,7 +333,9 @@ def compute_poi_routing_decay(
)
if not rows:
return PoiScoreResponse(cad_num=cad_num, radius_m=radius_m, top_poi=[])
return PoiScoreResponse(
cad_num=cad_num, radius_m=radius_m, poi_weighted_score=0.0, top_poi=[]
)
destinations = [(float(r["lon"]), float(r["lat"])) for r in rows]
@ -311,9 +343,7 @@ def compute_poi_routing_decay(
times_min: list[float | None]
routing_used = False
try:
times_min = ors_client.matrix_durations_min(
lon, lat, destinations, profile=profile
)
times_min = ors_client.matrix_durations_min(lon, lat, destinations, profile=profile)
routing_used = True
except ors_client.OrsUnavailableError as exc:
logger.info(
@ -355,13 +385,25 @@ def compute_poi_routing_decay(
category=category,
distance_m=round(distance_m, 1),
weight=round(weight, 6),
score_contribution=0.0, # заполним после нормировки
address=address,
)
)
items.sort(key=lambda x: x.weight, reverse=True)
top_items = items[:top_n]
# Нормировка: poi_weighted_score = (Σ weight_i / _MAX_ROUTING_SCORE) * 100, клэмп [0, 100]
raw_sum = sum(i.weight for i in top_items)
poi_weighted_score = round(min(100.0, (raw_sum / _MAX_ROUTING_SCORE) * 100.0), 1)
# score_contribution — доля данного POI в общем балле (0..100)
for item in top_items:
item.score_contribution = round((item.weight / _MAX_ROUTING_SCORE) * 100.0, 1)
return PoiScoreResponse(
cad_num=cad_num,
radius_m=radius_m,
top_poi=items[:top_n],
poi_weighted_score=poi_weighted_score,
top_poi=top_items,
)

View file

@ -7,6 +7,8 @@ import pytest
from app.services.site_finder import ors_client, poi_score
from app.services.site_finder.poi_score import (
_MAX_ROUTING_SCORE,
_MAX_STRAIGHT_SCORE,
CATEGORY_WEIGHTS,
PoiScoreResponse,
_category_radius_min,
@ -153,6 +155,76 @@ def test_empty_db_returns_empty_top_poi():
assert result.top_poi == []
assert result.cad_num == "66:41:0204016:10"
assert result.radius_m == 2000
assert result.poi_weighted_score == 0.0
# ── #1486: normalization 0..100 ────────────────────────────────────────────────
def test_max_straight_score_constant():
"""_MAX_STRAIGHT_SCORE = Σ(top-7 category_weights) / 100 ≈ 0.315."""
top7_sum = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
assert _MAX_STRAIGHT_SCORE == pytest.approx(top7_sum / 100.0)
def test_max_routing_score_constant():
"""_MAX_ROUTING_SCORE = Σ(top-7 category_weights) = 31.5."""
top7_sum = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
assert _MAX_ROUTING_SCORE == pytest.approx(top7_sum)
def test_poi_weighted_score_in_range():
"""poi_weighted_score должен быть в диапазоне 0..100 для реалистичных данных."""
rows = [
_make_row("Метро", "metro_stop", 400.0),
_make_row("Школа", "school", 500.0),
_make_row("Детсад", "kindergarten", 300.0),
]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
assert 0.0 <= result.poi_weighted_score <= 100.0
def test_score_contribution_in_range():
"""Каждый score_contribution должен быть в 0..100."""
rows = [
_make_row("Метро", "metro_stop", 200.0),
_make_row("Школа", "school", 800.0),
]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
for item in result.top_poi:
assert (
0.0 <= item.score_contribution <= 100.0
), f"{item.category} score_contribution={item.score_contribution} вне 0..100"
def test_metro_at_zero_distance_scores_high():
"""Метро в 0м должно дать poi_weighted_score близко к max (≥19.0/100)."""
# metro weight = 6.0 / (0 + 100) = 0.06; normalized = 0.06 / _MAX_STRAIGHT_SCORE * 100
rows = [_make_row("Метро у дома", "metro_stop", 0.0)]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
assert (
result.poi_weighted_score >= 19.0
), f"Метро у дома (d=0) должно давать ≥19/100, получили {result.poi_weighted_score}"
def test_score_contribution_sum_equals_total():
"""Сумма score_contribution по top_poi должна совпадать с poi_weighted_score.
Допуск 0.5 оба значения округлены независимо до 1 знака, накопленная
ошибка при N POI N * 0.05. Для top-7 это 0.35 допуск 0.5 достаточен.
"""
rows = [
_make_row("Метро", "metro_stop", 300.0),
_make_row("Школа", "school", 600.0),
_make_row("Парк", "park", 200.0),
]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
total_from_contributions = sum(i.score_contribution for i in result.top_poi)
assert total_from_contributions == pytest.approx(result.poi_weighted_score, abs=0.5)
def test_address_built_from_tags():
@ -313,9 +385,7 @@ def test_routing_decay_score_spread_wider_than_straight_line(monkeypatch):
def total(rows, times):
db = _MockDb(rows)
monkeypatch.setattr(ors_client, "is_configured", lambda: True)
monkeypatch.setattr(
ors_client, "matrix_durations_min", lambda *_a, **_k: list(times)
)
monkeypatch.setattr(ors_client, "matrix_durations_min", lambda *_a, **_k: list(times))
res = compute_poi_routing_decay(db, "cad", 56.8, 60.6)
return sum(i.weight for i in res.top_poi)

View file

@ -51,12 +51,14 @@ const CATEGORY_LABELS: Record<string, string> = {
bank: "Банк",
};
// score_contribution is now 0..100 (normalized on backend, #1486).
// Thresholds: ≥20 → "отличная инфраструктура", ≥12 → "хорошая", ≥8 → "средняя", <8 → "слабая".
function weightBadgeVariant(
weight: number,
score_contribution: number,
): "success" | "info" | "neutral" | "warning" {
if (weight >= 0.2) return "success";
if (weight >= 0.12) return "info";
if (weight >= 0.08) return "neutral";
if (score_contribution >= 20) return "success";
if (score_contribution >= 12) return "info";
if (score_contribution >= 8) return "neutral";
return "warning";
}
@ -215,9 +217,12 @@ export function PoiList2Gis({ items, totalScore }: Props) {
: `${(item.distance_m / 1000).toFixed(1)} км`}
</span>
{/* Weight badge */}
<Badge variant={weightBadgeVariant(item.weight)} size="sm">
×{item.weight.toFixed(2)}
{/* Score contribution badge (normalized 0..100, #1486) */}
<Badge
variant={weightBadgeVariant(item.score_contribution)}
size="sm"
>
{item.score_contribution.toFixed(1)}
</Badge>
</li>
);

View file

@ -1,55 +1,63 @@
{
"cad_num": "66:41:0701045:42",
"poi_weighted_score": 76,
"items": [
"radius_m": 2000,
"poi_weighted_score": 72,
"top_poi": [
{
"category": "metro_stop",
"name": "Площадь 1905 года",
"distance_m": 340,
"weight": 0.25,
"score_contribution": 22
},
{
"category": "park",
"name": "Сквер Попова",
"distance_m": 150,
"weight": 0.1,
"score_contribution": 9
"weight": 0.013559,
"score_contribution": 22.3,
"address": null
},
{
"category": "school",
"name": "Школа № 32",
"distance_m": 480,
"weight": 0.15,
"score_contribution": 11
"weight": 0.00885,
"score_contribution": 14.5,
"address": null
},
{
"category": "kindergarten",
"name": "Детский сад № 111",
"distance_m": 260,
"weight": 0.12,
"score_contribution": 10
"weight": 0.0125,
"score_contribution": 12.7,
"address": null
},
{
"category": "shop_mall",
"name": "МЕГА Екатеринбург",
"distance_m": 1200,
"weight": 0.1,
"score_contribution": 7
"category": "park",
"name": "Сквер Попова",
"distance_m": 150,
"weight": 0.014,
"score_contribution": 10.8,
"address": null
},
{
"category": "hospital",
"name": "Городская больница № 7",
"distance_m": 650,
"weight": 0.1,
"score_contribution": 8
"weight": 0.005333,
"score_contribution": 8.8,
"address": null
},
{
"category": "shop_mall",
"name": "МЕГА Екатеринбург",
"distance_m": 1200,
"weight": 0.003077,
"score_contribution": 5.0,
"address": null
},
{
"category": "school",
"name": "Гимназия № 9",
"distance_m": 820,
"weight": 0.08,
"score_contribution": 5
"weight": 0.005435,
"score_contribution": 4.4,
"address": null
}
]
}

View file

@ -556,18 +556,20 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
// ── Hook: useParcelPoiScoreQuery (B6) ────────────────────────────────────────
/**
* Raw shape from backend /api/v1/parcels/{cad}/poi-score.
* Backend returns top_poi array with address field (not score_contribution).
* We adapt to PoiScoreResponse so downstream components are stable.
* Raw shape from backend /api/v1/parcels/{cad}/poi-score (#1486).
* Backend now returns poi_weighted_score (0..100) and per-POI score_contribution (0..100)
* normalized on the server side no client-side reconstruction needed.
*/
interface PoiScoreRaw {
cad_num: string;
radius_m: number;
poi_weighted_score: number;
top_poi: Array<{
name: string;
category: string;
distance_m: number;
weight: number;
score_contribution: number;
address?: string | null;
}>;
}
@ -579,8 +581,8 @@ export function useParcelPoiScoreQuery(cad: string) {
if (MOCK_POI_SCORE) {
return fixturePoiScore as PoiScoreResponse;
}
// Backend returns {cad_num, radius_m, top_poi: [{name,category,distance_m,weight,address}]}
// Adapt to PoiScoreResponse {cad_num, poi_weighted_score, items} for stable consumer API.
// Backend returns normalized poi_weighted_score (0..100) and score_contribution per POI.
// Pass through directly — no client-side weight × 100 reconstruction (#1486).
const raw = await apiFetch<PoiScoreRaw>(
`/api/v1/parcels/${encodeURIComponent(cad)}/poi-score`,
);
@ -589,14 +591,13 @@ export function useParcelPoiScoreQuery(cad: string) {
name: p.name,
distance_m: p.distance_m,
weight: p.weight,
// score_contribution not in backend response — derive from weight (0..1) × 100
score_contribution: Math.round(p.weight * 100),
score_contribution: p.score_contribution,
}));
const poi_weighted_score = items.reduce(
(s, p) => s + p.score_contribution,
0,
);
return { cad_num: raw.cad_num, poi_weighted_score, items };
return {
cad_num: raw.cad_num,
poi_weighted_score: raw.poi_weighted_score,
items,
};
},
staleTime: 5 * 60_000,
retry: 1,