Merge pull request 'fix(sf-20): adaptive district threshold 30→15 with data_confidence flag' (#288) from fix/sf-20-district-threshold-adaptive into main
All checks were successful
Deploy / changes (push) Successful in 4s
Deploy / build-backend (push) Successful in 1m49s
Deploy / deploy (push) Successful in 1m0s
Deploy / build-worker (push) Successful in 2m41s
Deploy / build-frontend (push) Successful in 3m9s

This commit is contained in:
lekss361 2026-05-17 13:39:52 +00:00
commit 9eed55cd30
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. Размещены в одном месте для тюнинга # D4 (#36) — pipeline 24mo constants. Размещены в одном месте для тюнинга
# и аудита; severity пороги матчатся с acceptance #36. # и аудита; severity пороги матчатся с acceptance #36.
PIPELINE_RADIUS_M = 5000 PIPELINE_RADIUS_M = 5000
@ -1800,6 +1804,8 @@ def analyze_parcel(
logger.warning("zoning query failed for %s: %s", cad_num, e) logger.warning("zoning query failed for %s: %s", cad_num, e)
# 10c) Success recommendation — топ квартирография по district из v_bucket_success_score # 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 success_recommendation: dict[str, Any] | None = None
if district_row: if district_row:
try: try:
@ -1819,9 +1825,14 @@ def analyze_parcel(
.mappings() .mappings()
.all() .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 = { success_recommendation = {
"district": district_row["district_name"], "district": district_row["district_name"],
"data_confidence": data_confidence,
"ranking": [ "ranking": [
{ {
"bucket": r["bucket"], "bucket": r["bucket"],
@ -1834,12 +1845,12 @@ def analyze_parcel(
round(float(r["avg_area_m2"]), 1) if r["avg_area_m2"] else None 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": ( "note": (
"Топ комнатность по 'успешности' = z-scores: velocity×0.5 + price×0.3 " "Топ комнатность по 'успешности' = 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 o.region_cd = 66
AND f.snapshot_date > CURRENT_DATE - INTERVAL '24 months' AND f.snapshot_date > CURRENT_DATE - INTERVAL '24 months'
GROUP BY 1, 2, 3 GROUP BY 1, 2, 3
HAVING COUNT(*) >= 30 -- статистически значимая выборка HAVING COUNT(*) >= 15 -- мин 15: weak-confidence (15-29), strong (≥30)
), ),
z_scores AS ( z_scores AS (
@ -96,7 +96,7 @@ ORDER BY district_name, obj_class, success_score DESC;
COMMENT ON VIEW v_bucket_success_score IS COMMENT ON VIEW v_bucket_success_score IS
'Success-driven bucket ranking per (district, class). ' 'Success-driven bucket ranking per (district, class). '
'Z-scores: velocity (+) приоритет, price (+) приоритет, area (-) приоритет. ' 'Z-scores: velocity (+) приоритет, price (+) приоритет, area (-) приоритет. '
'Min 30 сделок в группе. ' 'Min 15 сделок в группе (15-29 = weak confidence, ≥30 = strong). '
'Используется recommend_mix для смещения рекомендации (issue #25).'; 'Используется recommend_mix для смещения рекомендации (issue #25).';
COMMIT; COMMIT;

View file

@ -10,15 +10,15 @@ interface Props {
} }
function successColor(score: number): string { function successColor(score: number): string {
if (score > 0.5) return "#16a34a"; if (score > 0.5) return "var(--success)";
if (score >= 0) return "#d97706"; if (score >= 0) return "var(--warn)";
return "#6b7280"; return "var(--fg-tertiary)";
} }
function successBg(score: number): string { function successBg(score: number): string {
if (score > 0.5) return "#dcfce7"; if (score > 0.5) return "var(--success-soft)";
if (score >= 0) return "#fef3c7"; if (score >= 0) return "var(--warn-soft)";
return "#f3f4f6"; return "var(--bg-card-alt)";
} }
function formatPrice(v: number | null): string { function formatPrice(v: number | null): string {
@ -43,7 +43,7 @@ function SuccessBadge({ bucket }: { bucket: SuccessRankingBucket }) {
borderRadius: 6, borderRadius: 6,
padding: "2px 8px", padding: "2px 8px",
background: bg, background: bg,
border: `1px solid ${color}44`, border: "1px solid var(--border-soft)",
}} }}
> >
<div <div
@ -51,7 +51,7 @@ function SuccessBadge({ bucket }: { bucket: SuccessRankingBucket }) {
width: 36, width: 36,
height: 8, height: 8,
borderRadius: 4, borderRadius: 4,
background: "#e5e7eb", background: "var(--border-strong)",
overflow: "hidden", overflow: "hidden",
position: "relative", position: "relative",
}} }}
@ -82,23 +82,52 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
style={{ style={{
borderRadius: 10, borderRadius: 10,
padding: "14px 16px", padding: "14px 16px",
background: "#f3f4f6", background: "var(--bg-card-alt)",
color: "#6b7280", color: "var(--fg-tertiary)",
fontSize: 13, fontSize: 13,
}} }}
> >
Недостаточно сделок в районе для рейтинга (мин 30) Недостаточно сделок в районе для рейтинга (мин 15)
</div> </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); const top5 = ranking.slice(0, 5);
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: "#111827" }}> <div
Что хорошо продаётся в районе {district} 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>
<div style={{ overflowX: "auto" }}> <div style={{ overflowX: "auto" }}>
@ -124,13 +153,13 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
textAlign: "left", textAlign: "left",
fontSize: 11, fontSize: 11,
fontWeight: 600, fontWeight: 600,
color: "#6b7280", color: "var(--fg-tertiary)",
textTransform: "uppercase", textTransform: "uppercase",
letterSpacing: "0.04em", letterSpacing: "0.04em",
paddingBottom: 6, paddingBottom: 6,
paddingRight: 12, paddingRight: 12,
whiteSpace: "nowrap", whiteSpace: "nowrap",
borderBottom: "1px solid #e5e7eb", borderBottom: "1px solid var(--border-soft)",
}} }}
> >
{h} {h}
@ -146,18 +175,18 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
<td <td
style={{ style={{
padding: "6px 12px 6px 0", padding: "6px 12px 6px 0",
color: "#111827", color: "var(--fg-primary)",
fontWeight: isTop ? 600 : 400, fontWeight: isTop ? 600 : 400,
whiteSpace: "nowrap", whiteSpace: "nowrap",
}} }}
> >
{isTop ? " " : ""} {isTop ? "* " : ""}
{row.bucket} {row.bucket}
</td> </td>
<td <td
style={{ style={{
padding: "6px 12px 6px 0", padding: "6px 12px 6px 0",
color: "#374151", color: "var(--fg-secondary)",
}} }}
> >
{row.n_deals} {row.n_deals}
@ -165,7 +194,7 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
<td <td
style={{ style={{
padding: "6px 12px 6px 0", padding: "6px 12px 6px 0",
color: "#374151", color: "var(--fg-secondary)",
}} }}
> >
{formatPrice(row.avg_price_per_m2)} {formatPrice(row.avg_price_per_m2)}
@ -173,7 +202,7 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
<td <td
style={{ style={{
padding: "6px 12px 6px 0", padding: "6px 12px 6px 0",
color: "#374151", color: "var(--fg-secondary)",
}} }}
> >
{formatArea(row.avg_area_m2)} {formatArea(row.avg_area_m2)}
@ -188,7 +217,7 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
</table> </table>
</div> </div>
<div style={{ fontSize: 11, color: "#9ca3af" }}>{note}</div> <div style={{ fontSize: 11, color: "var(--fg-tertiary)" }}>{note}</div>
</div> </div>
); );
} }

View file

@ -315,6 +315,8 @@ export interface SuccessRankingBucket {
export interface ParcelSuccessRecommendation { export interface ParcelSuccessRecommendation {
district: string; district: string;
/** SF-20: strong = ≥30 сделок в группе; weak = 15-29 сделок (ориентировочно) */
data_confidence: "strong" | "weak";
ranking: SuccessRankingBucket[]; ranking: SuccessRankingBucket[];
top_bucket: SuccessRankingBucket; top_bucket: SuccessRankingBucket;
note: string; note: string;