fix(sf-20): adaptive district threshold 30→15 with data_confidence flag

- 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
This commit is contained in:
lekss361 2026-05-17 16:34:52 +03:00
parent eb865a08bc
commit 989d4e5b7d
4 changed files with 70 additions and 28 deletions

View file

@ -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 мес. "
"Используй для квартирографии проекта."
),
}

View file

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

View file

@ -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)",
}}
>
<div
@ -51,7 +51,7 @@ function SuccessBadge({ bucket }: { bucket: SuccessRankingBucket }) {
width: 36,
height: 8,
borderRadius: 4,
background: "#e5e7eb",
background: "var(--border-strong)",
overflow: "hidden",
position: "relative",
}}
@ -82,23 +82,52 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
style={{
borderRadius: 10,
padding: "14px 16px",
background: "#f3f4f6",
color: "#6b7280",
background: "var(--bg-card-alt)",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
Недостаточно сделок в районе для рейтинга (мин 30)
Недостаточно сделок в районе для рейтинга (мин 15)
</div>
);
}
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 (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: "#111827" }}>
Что хорошо продаётся в районе {district}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flexWrap: "wrap",
}}
>
<div
style={{ fontSize: 14, fontWeight: 700, color: "var(--fg-primary)" }}
>
Что хорошо продаётся в районе {district}
</div>
{isWeak && (
<span
style={{
fontSize: 11,
fontWeight: 600,
padding: "2px 8px",
borderRadius: 6,
background: "var(--warn-soft)",
color: "var(--warn)",
border: "1px solid var(--warn-soft)",
whiteSpace: "nowrap",
}}
>
слабые данные, ориентировочно
</span>
)}
</div>
<div style={{ overflowX: "auto" }}>
@ -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) {
<td
style={{
padding: "6px 12px 6px 0",
color: "#111827",
color: "var(--fg-primary)",
fontWeight: isTop ? 600 : 400,
whiteSpace: "nowrap",
}}
>
{isTop ? " " : ""}
{isTop ? "* " : ""}
{row.bucket}
</td>
<td
style={{
padding: "6px 12px 6px 0",
color: "#374151",
color: "var(--fg-secondary)",
}}
>
{row.n_deals}
@ -165,7 +194,7 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
<td
style={{
padding: "6px 12px 6px 0",
color: "#374151",
color: "var(--fg-secondary)",
}}
>
{formatPrice(row.avg_price_per_m2)}
@ -173,7 +202,7 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
<td
style={{
padding: "6px 12px 6px 0",
color: "#374151",
color: "var(--fg-secondary)",
}}
>
{formatArea(row.avg_area_m2)}
@ -188,7 +217,7 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
</table>
</div>
<div style={{ fontSize: 11, color: "#9ca3af" }}>{note}</div>
<div style={{ fontSize: 11, color: "var(--fg-tertiary)" }}>{note}</div>
</div>
);
}

View file

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