fix(sf-20): adaptive district threshold 30→15 with data_confidence flag #288
4 changed files with 70 additions and 28 deletions
|
|
@ -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 мес. "
|
||||
"Используй для квартирографии проекта."
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue