From 989d4e5b7dfbcb8a9ae370715b5b0f061a02a3a0 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 16:34:52 +0300 Subject: [PATCH] =?UTF-8?q?fix(sf-20):=20adaptive=20district=20threshold?= =?UTF-8?q?=2030=E2=86=9215=20with=20data=5Fconfidence=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - v_bucket_success_score HAVING >= 30 → >= 15 so sparse districts (Кировский etc.) are no longer silently excluded from the block - Backend: SUCCESS_REC_MIN_DEALS=15, SUCCESS_REC_STRONG_DEALS=30; data_confidence='weak' (15-29 deals) or 'strong' (≥30) in response - TS type ParcelSuccessRecommendation: add data_confidence literal - SuccessRecommendationBlock: amber badge for weak, all hex → CSS tokens, emoji star removed (ui-ux.md), empty-state text min 30→15 --- backend/app/api/v1/parcels.py | 19 ++++- data/sql/86_v_bucket_success_score.sql | 4 +- .../SuccessRecommendationBlock.tsx | 73 +++++++++++++------ frontend/src/types/site-finder.ts | 2 + 4 files changed, 70 insertions(+), 28 deletions(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 457746a4..e1809cbc 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -376,6 +376,10 @@ GEOTECH_BY_REGION: dict[int, dict[str, Any]] = { } +# SF-20 — success_recommendation confidence thresholds +SUCCESS_REC_MIN_DEALS = 15 # ниже → не показываем (пусто) +SUCCESS_REC_STRONG_DEALS = 30 # ≥ → data_confidence='strong'; 15-29 → 'weak' + # D4 (#36) — pipeline 24mo constants. Размещены в одном месте для тюнинга # и аудита; severity пороги матчатся с acceptance #36. PIPELINE_RADIUS_M = 5000 @@ -1800,6 +1804,8 @@ def analyze_parcel( logger.warning("zoning query failed for %s: %s", cad_num, e) # 10c) Success recommendation — топ квартирография по district из v_bucket_success_score + # SF-20: адаптивный порог — MIN_DEALS=15 (показываем), STRONG=30 (confidence='strong'). + # Если n_deals всех строк < STRONG → data_confidence='weak' + UI badge «слабые данные». success_recommendation: dict[str, Any] | None = None if district_row: try: @@ -1819,9 +1825,14 @@ def analyze_parcel( .mappings() .all() ) - if success_rows: + # Фильтруем строки ниже абсолютного минимума (< SUCCESS_REC_MIN_DEALS) + valid_rows = [r for r in success_rows if int(r["n_deals"]) >= SUCCESS_REC_MIN_DEALS] + if valid_rows: + max_deals = max(int(r["n_deals"]) for r in valid_rows) + data_confidence = "strong" if max_deals >= SUCCESS_REC_STRONG_DEALS else "weak" success_recommendation = { "district": district_row["district_name"], + "data_confidence": data_confidence, "ranking": [ { "bucket": r["bucket"], @@ -1834,12 +1845,12 @@ def analyze_parcel( round(float(r["avg_area_m2"]), 1) if r["avg_area_m2"] else None ), } - for r in success_rows + for r in valid_rows ], - "top_bucket": dict(success_rows[0]) if success_rows else None, + "top_bucket": dict(valid_rows[0]), "note": ( "Топ комнатность по 'успешности' = z-scores: velocity×0.5 + price×0.3 " - "- area×0.2. Min 30 сделок в группе за 24 мес. " + "- area×0.2. Min 15 сделок в группе за 24 мес. " "Используй для квартирографии проекта." ), } diff --git a/data/sql/86_v_bucket_success_score.sql b/data/sql/86_v_bucket_success_score.sql index 2d9aaded..ea5a2567 100644 --- a/data/sql/86_v_bucket_success_score.sql +++ b/data/sql/86_v_bucket_success_score.sql @@ -53,7 +53,7 @@ bucket_aggs AS ( AND o.region_cd = 66 AND f.snapshot_date > CURRENT_DATE - INTERVAL '24 months' GROUP BY 1, 2, 3 - HAVING COUNT(*) >= 30 -- статистически значимая выборка + HAVING COUNT(*) >= 15 -- мин 15: weak-confidence (15-29), strong (≥30) ), z_scores AS ( @@ -96,7 +96,7 @@ ORDER BY district_name, obj_class, success_score DESC; COMMENT ON VIEW v_bucket_success_score IS 'Success-driven bucket ranking per (district, class). ' 'Z-scores: velocity (+) приоритет, price (+) приоритет, area (-) приоритет. ' - 'Min 30 сделок в группе. ' + 'Min 15 сделок в группе (15-29 = weak confidence, ≥30 = strong). ' 'Используется recommend_mix для смещения рекомендации (issue #25).'; COMMIT; diff --git a/frontend/src/components/site-finder/SuccessRecommendationBlock.tsx b/frontend/src/components/site-finder/SuccessRecommendationBlock.tsx index 1314bda3..df952f47 100644 --- a/frontend/src/components/site-finder/SuccessRecommendationBlock.tsx +++ b/frontend/src/components/site-finder/SuccessRecommendationBlock.tsx @@ -10,15 +10,15 @@ interface Props { } function successColor(score: number): string { - if (score > 0.5) return "#16a34a"; - if (score >= 0) return "#d97706"; - return "#6b7280"; + if (score > 0.5) return "var(--success)"; + if (score >= 0) return "var(--warn)"; + return "var(--fg-tertiary)"; } function successBg(score: number): string { - if (score > 0.5) return "#dcfce7"; - if (score >= 0) return "#fef3c7"; - return "#f3f4f6"; + if (score > 0.5) return "var(--success-soft)"; + if (score >= 0) return "var(--warn-soft)"; + return "var(--bg-card-alt)"; } function formatPrice(v: number | null): string { @@ -43,7 +43,7 @@ function SuccessBadge({ bucket }: { bucket: SuccessRankingBucket }) { borderRadius: 6, padding: "2px 8px", background: bg, - border: `1px solid ${color}44`, + border: "1px solid var(--border-soft)", }} >
- Недостаточно сделок в районе для рейтинга (мин 30) + Недостаточно сделок в районе для рейтинга (мин 15)
); } - const { district, ranking, top_bucket, note } = recommendation; + const { district, ranking, top_bucket, note, data_confidence } = + recommendation; + const isWeak = data_confidence === "weak"; const top5 = ranking.slice(0, 5); return (
-
- Что хорошо продаётся в районе {district} +
+
+ Что хорошо продаётся в районе {district} +
+ {isWeak && ( + + слабые данные, ориентировочно + + )}
@@ -124,13 +153,13 @@ export function SuccessRecommendationBlock({ recommendation }: Props) { textAlign: "left", fontSize: 11, fontWeight: 600, - color: "#6b7280", + color: "var(--fg-tertiary)", textTransform: "uppercase", letterSpacing: "0.04em", paddingBottom: 6, paddingRight: 12, whiteSpace: "nowrap", - borderBottom: "1px solid #e5e7eb", + borderBottom: "1px solid var(--border-soft)", }} > {h} @@ -146,18 +175,18 @@ export function SuccessRecommendationBlock({ recommendation }: Props) { - {isTop ? "⭐ " : ""} + {isTop ? "* " : ""} {row.bucket} {row.n_deals} @@ -165,7 +194,7 @@ export function SuccessRecommendationBlock({ recommendation }: Props) { {formatPrice(row.avg_price_per_m2)} @@ -173,7 +202,7 @@ export function SuccessRecommendationBlock({ recommendation }: Props) { {formatArea(row.avg_area_m2)} @@ -188,7 +217,7 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
-
{note}
+
{note}
); } diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 46bfdf2a..d33ad377 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -315,6 +315,8 @@ export interface SuccessRankingBucket { export interface ParcelSuccessRecommendation { district: string; + /** SF-20: strong = ≥30 сделок в группе; weak = 15-29 сделок (ориентировочно) */ + data_confidence: "strong" | "weak"; ranking: SuccessRankingBucket[]; top_bucket: SuccessRankingBucket; note: string; -- 2.45.3