From 0d11f426b29eef3abb8239cd89b7e765a4ed68ee Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 14:49:56 +0300 Subject: [PATCH] fix(sf-09): MAX_BUCKET_SHARE 35% cap + frontend warning banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _build_recommendation зеркалило рынок через чистый Hamilton apportionment. Добавлен _cap_and_redistribute: bucket >35% clamp к 35%, surplus пропорционально перераспределяется в остальные bucket'ы (Hamilton финальный pass гарантирует sum=100). Pathological case (все bucket'ы >35%) — warning в лог, cap пропускается. Frontend: warning banner при maxPct>60 в RecommendationCard (Tailwind amber utilities, без inline hex). --- .../app/services/site_finder/best_layouts.py | 56 +++++++++++++++++++ .../site-finder/BestLayoutsBlock.tsx | 11 ++++ 2 files changed, 67 insertions(+) diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index cc67c1e4..04338799 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -48,6 +48,10 @@ logger = logging.getLogger(__name__) LAYOUT_CONFIDENCE_HIGH_PCT = 50.0 LAYOUT_CONFIDENCE_MEDIUM_PCT = 20.0 +# Fix SF-09: cap доминирующего bucket чтобы рекомендация не зеркалила перекос рынка. +# Избыток перераспределяется пропорционально остальным bucket'ам. +MAX_BUCKET_SHARE_PCT = 35 + # Параметры time_window: (PostgreSQL interval string, months divisor для velocity_per_month). # Используются в _INLINE_VELOCITY_SQL — реальный фильтр по report_month. # Fix SF-01: убраны _VELOCITY_DIVISORS, которые делили MV (24 мес) без изменения данных. @@ -219,6 +223,57 @@ def _normalize_pct(buckets: dict[str, float]) -> dict[str, int]: return floors +def _cap_and_redistribute(pct_map: dict[str, int]) -> dict[str, int]: + """Fix SF-09: clamp каждый bucket к MAX_BUCKET_SHARE_PCT, перераспределить излишек. + + Алгоритм: + 1. Найти clamped (> cap) и free bucket'ы. + 2. Если ВСЕ bucket'ы clamped — pathological case (редко, напр. 2 bucket'а по 50%): + вернуть как есть с warning (сумма уже = 100). + 3. Surplus пропорционально добавить к free bucket'ам (взвешено по их original share). + 4. Финальный Hamilton pass чтобы сумма осталась ровно 100. + """ + if not pct_map: + return pct_map + + cap = MAX_BUCKET_SHARE_PCT + clamped = {k: v for k, v in pct_map.items() if v > cap} + if not clamped: + return pct_map # нет доминирующих — ничего не делаем + + free = {k: v for k, v in pct_map.items() if v <= cap} + if not free: + # Все bucket'ы выше cap — pathological (напр. 2 bucket'а по 50%). + # Нельзя перераспределить — возвращаем как есть. + logger.warning( + "MAX_BUCKET_SHARE cap: все %d bucket'ов выше %d%% — пропускаем redistribute", + len(pct_map), + cap, + ) + return pct_map + + surplus = sum(v - cap for v in clamped.values()) + # Новые значения для clamped bucket'ов + result: dict[str, int] = {k: cap for k in clamped} + + # Пропорциональный redistribute в free bucket'ы + free_total = sum(free.values()) + # Дробные проценты после redistribution + free_raw: dict[str, float] = {} + for k, v in free.items(): + free_raw[k] = v + (v / free_total * surplus if free_total > 0 else surplus / len(free)) + + # Hamilton pass чтобы сумма clamped + free = 100 + target_sum = 100 - sum(result.values()) # сколько должно остаться у free + free_floors = {k: int(v) for k, v in free_raw.items()} + free_remainder = target_sum - sum(free_floors.values()) + free_fracs = sorted(free_raw.keys(), key=lambda k: -(free_raw[k] - free_floors[k])) + for k in free_fracs[: max(0, free_remainder)]: + free_floors[k] += 1 + result.update(free_floors) + return result + + # ── Главная функция ─────────────────────────────────────────────────────────── @@ -552,6 +607,7 @@ def _build_recommendation( total_deals = sum(rb_deals.values()) pct_map = _normalize_pct(rb_deals) + pct_map = _cap_and_redistribute(pct_map) mix: list[LayoutTzMixRow] = [] for rb, pct in sorted(pct_map.items(), key=lambda x: -x[1]): diff --git a/frontend/src/components/site-finder/BestLayoutsBlock.tsx b/frontend/src/components/site-finder/BestLayoutsBlock.tsx index 9a1919af..16c3b02f 100644 --- a/frontend/src/components/site-finder/BestLayoutsBlock.tsx +++ b/frontend/src/components/site-finder/BestLayoutsBlock.tsx @@ -345,6 +345,9 @@ function RecommendationCard({ }: { rec: BestLayoutsResponse["recommendation_for_tz"]; }) { + const maxPct = + rec.mix.length > 0 ? Math.max(...rec.mix.map((r) => r.pct)) : 0; + return (
+ {/* SF-09: предупреждение о сильном перекосе unit-mix */} + {maxPct > 60 && ( +
+ Рекомендация имеет сильный перекос ({maxPct}% в одном формате) — + проверьте competitive density и district pipeline. +
+ )} + {/* Rationale text — plain text only, no dangerouslySetInnerHTML */}