"""Скоринг лидов v5 — «решаемая нами боль». score = pain(решаемая нами боль) × icp_fit(наш ли это размер), шкала 0-10. Принцип (решение 2026-06-01): Score отвечает на ОДИН вопрос — есть ли у компании проблемы, которые закрывают наши продукты, и насколько остро. Не «качество лида», не «дозвонибельность» — только боль под продукты 44AS. • Каждый детектор боли привязан к продукту (P-код) и теме. • Внутри темы — насыщение (max + k·остальные): коррелированные сигналы одной темы (несколько веб-проблем) не складываются линейно. • ICP-гейт множителем топит крупняк/премиум (не наша ЦА). • «Уже автоматизирован» отдельно не гейтим — у такого лида просто нет болевых дыр, severity→0. • Каждый детектор различает present / absent / unknown: «не проверяли» (поле = None) НЕ засчитывается как «нет боли» — уходит в coverage. Веса и пороги — в config (PAIN_WEIGHTS=SCORE_WEIGHTS, ICP_*, BAND_*). calculate_score(lead) -> (score:int, breakdown:dict) — сигнатура сохранена для обратной совместимости с database.update_score / main.run_rescore. """ import config W = config.SCORE_WEIGHTS # CMS, которые считаем «конструктором» (сайт-визитка, легко прокачать). CONSTRUCTOR_CMS = { "tilda", "wix", "squarespace", "webflow", "insales", "1c-bitrix-sites", "readymag", "craftum", # Авто-визитки (Я.Бизнес и пр.) — слабейшее веб-присутствие, тоже конструктор. "yandex_business", "nethouse", "taplink", "ucoz", } def _cat_in(category: str, keywords: set) -> bool: """True если в названии категории встречается хоть одно ключевое слово. Используется для категорийной релевантности детекторов (см. config: APPT_CATEGORIES / SOCIAL_SALES_CATEGORIES). """ return any(k in category for k in keywords) def _rating_key(avg: float): """Рейтинг → ключ детектора репутации (континуум-ступени). None если оценок нет (не известно — не штрафуем) или рейтинг хороший (4.8+). """ if avg <= 0: return None if avg < 3.5: return "rating_very_low" if avg < 4.0: return "rating_low" if avg < 4.5: return "rating_mid" # 4.5+ — здоровая репутация, не наша боль (P3 AI-Reputation тут не нужен) return None def icp_fit(lead: dict) -> float: """Множитель 0..1: прогрессивный штраф за «зрелость» (отзывы × рейтинг). Чем больше отзывов И выше рейтинг — тем сильнее снижение (процветающим мы менее нужны). Отзывы штрафуют всегда (доля ICP_BASE), рейтинг усиливает. Мало отзывов → множитель ≈1 (новый/борющийся бизнес сохраняет балл). """ rc = int(lead.get("reviews_count") or 0) avg = float(lead.get("reviews_avg") or 0) rf = min(1.0, rc / config.ICP_REVIEWS_FULL) if config.ICP_REVIEWS_FULL else 0.0 span = 5.0 - config.ICP_RATING_MIN gf = max(0.0, min(1.0, (avg - config.ICP_RATING_MIN) / span)) if span > 0 else 0.0 penalty = config.ICP_PMAX * rf * (config.ICP_BASE + (1 - config.ICP_BASE) * gf) return 1.0 - penalty def _detect_pain(lead: dict): """Прогнать детекторы боли. Возвращает (themes, products, reasons, covered): themes: theme -> list[severity] products: product(P-код) -> сумма severity (для «с чем заходить») reasons: list[str] человекочитаемых причин covered: theme -> bool (была ли тема диагностирована — для coverage) """ themes: dict[str, list[float]] = {} products: dict[str, float] = {} reasons: list[str] = [] def emit(key: str) -> None: sev = W[key] themes.setdefault(config.PAIN_THEME[key], []).append(sev) prod = config.PAIN_PRODUCT[key] products[prod] = products.get(prod, 0.0) + sev reasons.append(config.PAIN_REASON[key]) hob = lead.get("has_online_booking") hlc = lead.get("has_live_chat") site_alive = lead.get("site_alive") has_analytics = lead.get("has_analytics") cms = (lead.get("cms_type") or "").lower() website = lead.get("website") edt = lead.get("email_domain_type") rc = int(lead.get("reviews_count") or 0) avg = float(lead.get("reviews_avg") or 0) # Полнота диагностики по темам: тема «покрыта», если есть её источник. covered = { "booking": (hob is not None) or (hlc is not None), # Tier 2 прошёл "reputation": rc > 0 or avg > 0, # отзывы из Я.Карт "web": (site_alive is not None) or bool(website) or lead.get("has_website") == 1, "marketing": True, # соцсети известны всегда "infra": edt is not None, } # ── booking / inbound (P4) ─────────────────────────────────────── # Категорийная релевантность (см. config): «нет онлайн-записи» — боль # только для услуг с записью (APPT_CATEGORIES). Розница/B2B (магазин, # бухгалтерия) запись не ведут → не штрафуем. Чат релевантен всем. cat = (lead.get("category") or "").lower() if hob == 0 and _cat_in(cat, config.APPT_CATEGORIES): emit("no_online_booking") if hlc == 0: emit("no_live_chat") # ── reputation (P3) ────────────────────────────────────────────── rk = _rating_key(avg) if rk: emit(rk) if 0 < rc < 10: emit("few_reviews") elif 10 <= rc < 30: emit("some_reviews") # ── web (P10) ──────────────────────────────────────────────────── # «Есть сайт» определяем по совокупности сигналов, НЕ только по URL-полю: # у ~30% лидов website-колонка пустая, хотя сайт реально проверен # (has_website=1 / site_alive=1 / cms заполнен — URL потерялся в пайплайне). # Иначе «нет сайта» ложно срабатывает у тех, у кого сайт есть и работает. has_site = bool(website) or lead.get("has_website") == 1 or site_alive == 1 if site_alive == 0: emit("site_dead") elif not has_site: emit("no_website") elif cms in CONSTRUCTOR_CMS: # Премиум-на-Tilda отсекается не здесь, а ICP-гейтом (×0.2 по отзывам) — # авто-визитка/конструктор это слабый сайт независимо от популярности. emit("site_constructor") # ── marketing (P12) ────────────────────────────────────────────── # «Нет соцсетей» — боль только для B2C, где соцсети = канал продаж # (SOCIAL_SALES_CATEGORIES). Для B2B (бухгалтерия, стройка) — не боль. # Аналитика на сайте — универсальна. # Instagram в РФ забанен — не учитываем. Соцсети = VK / Telegram. no_social = not lead.get("vk_url") and not lead.get("telegram_url") if no_social and _cat_in(cat, config.SOCIAL_SALES_CATEGORIES): emit("no_social") if has_analytics == 0: emit("no_analytics") # ── infra (P2) ─────────────────────────────────────────────────── if edt == "free": emit("free_email") return themes, products, reasons, covered def _saturate(severities: list[float]) -> float: """max + k·(сумма остальных) — насыщение внутри темы.""" if not severities: return 0.0 s = sorted(severities, reverse=True) return s[0] + config.THEME_SATURATION * sum(s[1:]) def band(score: int) -> str: """Бэнд лида по score: hot / warm / cold.""" if score >= config.BAND_HOT: return "hot" if score >= config.BAND_WARM: return "warm" return "cold" def calculate_score(lead: dict) -> tuple[int, dict]: """score = pain × icp, шкала 0-10. Возвращает (score, breakdown:dict). breakdown — богатая структура (JSON-сериализуемая) для CRM и аудита: band, icp_fit, pain_raw, coverage, themes, pain_products, reasons. """ themes, products, reasons, covered = _detect_pain(lead) theme_values = {t: round(_saturate(s), 2) for t, s in themes.items()} raw_pain = sum(theme_values.values()) icp = icp_fit(lead) raw_final = raw_pain * icp score = min(round(raw_final / config.PAIN_NORM * config.SCORE_MAX), config.SCORE_MAX) coverage = round(sum(1 for v in covered.values() if v) / len(covered), 2) # «С чем заходить» — продукты по убыванию суммарной severity. pain_products = { p: round(v, 1) for p, v in sorted(products.items(), key=lambda kv: -kv[1]) } breakdown = { "v": 5, "band": band(score), "icp_fit": round(icp, 2), "pain_raw": round(raw_pain, 2), "coverage": coverage, "themes": theme_values, "pain_products": pain_products, "reasons": reasons, } return score, breakdown def annotate_with_score(lead: dict) -> dict: """Проставить lead['score'] и lead['score_breakdown'] (in-place + возврат).""" score, breakdown = calculate_score(lead) lead["score"] = score lead["score_breakdown"] = breakdown return lead # ─────────────────────────────────────────────────────────────────────── # Smoke-тесты v5 # ─────────────────────────────────────────────────────────────────────── if __name__ == "__main__": # 1. Малый сервисный бизнес с дырами — должен быть hot, с понятным «зайти» small_pain = { "name": "Барбершоп у Лёхи", "category": "барбершоп", "reviews_avg": 4.4, "reviews_count": 120, "website": "https://barber.tilda.ws/", "site_alive": 1, "cms_type": "tilda", "has_live_chat": 0, "has_online_booking": 0, "has_analytics": 0, "vk_url": None, "telegram_url": None, "instagram_url": None, "email_domain_type": "free", "source": "yandex_maps", } s, b = calculate_score(small_pain) print(f"1. Малый с дырами: score={s} band={b['band']} products={b['pain_products']}") assert s >= config.BAND_HOT, "малый бизнес с дырами должен быть hot" # 2. Премиум-ресторан (тысячи отзывов + 5.0) — должен утонуть несмотря на дыры premium = { "name": "White Rabbit", "category": "ресторан", "reviews_avg": 5.0, "reviews_count": 16817, "website": "https://whiterabbit.tilda.ws/", "site_alive": 1, "cms_type": "tilda", "has_live_chat": 0, "has_online_booking": 0, "has_analytics": 1, "telegram_url": "https://t.me/wr", "source": "yandex_maps", } s, b = calculate_score(premium) print(f"2. Премиум: score={s} band={b['band']} icp={b['icp_fit']}") assert s < config.BAND_WARM, "премиум должен проваливаться даже с дырами" # 3. Уже автоматизирован (запись+чат, нормальный сайт, рейтинг ок) — cold equipped = { "name": "Клиника Здоровье", "category": "стоматология", "reviews_avg": 4.9, "reviews_count": 800, "website": "https://clinic.ru/", "site_alive": 1, "cms_type": "bitrix", "has_live_chat": 1, "has_online_booking": 1, "has_analytics": 1, "vk_url": "https://vk.com/clinic", "email_domain_type": "corporate", "source": "yandex_maps", } s, b = calculate_score(equipped) print(f"3. Оснащённый: score={s} band={b['band']} products={b['pain_products']}") assert s < config.BAND_WARM, "оснащённый бизнес = нет боли = cold" # 4. Свежий лид с источника (Tier 2 ещё не прошёл) — coverage низкая fresh = { "name": "Кафе только спарсили", "category": "кафе", "reviews_avg": 4.3, "reviews_count": 40, "website": None, "source": "yandex_maps", } s, b = calculate_score(fresh) print(f"4. Свежий (без Tier2): score={s} coverage={b['coverage']}") assert b["coverage"] < 1.0, "недиагностированный лид должен иметь coverage<1" print( f"\n[OK] scoring.py v5 — smoke-тесты пройдены. " f"BAND_HOT={config.BAND_HOT}, PAIN_NORM={config.PAIN_NORM}, SCORE_MAX={config.SCORE_MAX}" )