From 68eb6ded89cc4f524b2ba54f1892f2ad91a06149 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 17 Jun 2026 20:26:35 +0300 Subject: [PATCH] fix(site-finder): normalize POI weighted score to 0..100 on backend (#1486) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/services/site_finder/poi_score.py | 52 +++++++++++-- backend/tests/test_poi_score.py | 76 ++++++++++++++++++- .../site-finder/analysis/PoiList2Gis.tsx | 19 +++-- frontend/src/lib/mocks/poi-score.json | 56 ++++++++------ frontend/src/lib/site-finder-api.ts | 25 +++--- 5 files changed, 177 insertions(+), 51 deletions(-) diff --git a/backend/app/services/site_finder/poi_score.py b/backend/app/services/site_finder/poi_score.py index deeb4b52..85744882 100644 --- a/backend/app/services/site_finder/poi_score.py +++ b/backend/app/services/site_finder/poi_score.py @@ -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, ) diff --git a/backend/tests/test_poi_score.py b/backend/tests/test_poi_score.py index 43753ac0..711db6c0 100644 --- a/backend/tests/test_poi_score.py +++ b/backend/tests/test_poi_score.py @@ -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) diff --git a/frontend/src/components/site-finder/analysis/PoiList2Gis.tsx b/frontend/src/components/site-finder/analysis/PoiList2Gis.tsx index c90c2d8b..fc5f042d 100644 --- a/frontend/src/components/site-finder/analysis/PoiList2Gis.tsx +++ b/frontend/src/components/site-finder/analysis/PoiList2Gis.tsx @@ -51,12 +51,14 @@ const CATEGORY_LABELS: Record = { 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)} км`} - {/* Weight badge */} - - ×{item.weight.toFixed(2)} + {/* Score contribution badge (normalized 0..100, #1486) */} + + {item.score_contribution.toFixed(1)} ); diff --git a/frontend/src/lib/mocks/poi-score.json b/frontend/src/lib/mocks/poi-score.json index f0b910f7..bf71df77 100644 --- a/frontend/src/lib/mocks/poi-score.json +++ b/frontend/src/lib/mocks/poi-score.json @@ -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 } ] } diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts index ec80dfb8..6ba833a5 100644 --- a/frontend/src/lib/site-finder-api.ts +++ b/frontend/src/lib/site-finder-api.ts @@ -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( `/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,