Files
MarketingTool/apps/api/app/scoring.py
T
2026-09-03 23:21:35 +02:00

127 lines
9.0 KiB
Python

"""Deterministic, transparent opportunity scoring."""
from __future__ import annotations
import json
from urllib.parse import urlparse
SCORE_VERSION = "opportunity-v1"
def _rule(code, name, points, description):
return {"code": code, "name": name, "description": description,
"condition_json": {"signal": f"opportunity.{code}", "operator": "truthy"},
"points": points, "max_applications": 1, "enabled": 1, "version": 1}
DEFAULT_RULES = [
_rule("no_detected_website", "No detected website", 30, "No website was detected for the business."),
_rule("no_official_domain", "No official domain", 25, "No official business domain was corroborated."),
_rule("no_functioning_web_service", "Domain but no functioning web service", 25, "A domain exists but no functioning web service was observed."),
_rule("broken_website", "Broken website", 25, "The observed website is broken."),
_rule("parked_default_placeholder", "Parked/default/placeholder website", 20, "The website is parked, default, or a placeholder."),
_rule("public_free_mail", "Public free-mail address", 15, "A public business contact uses a free-mail provider."),
_rule("human_reviewed_outdated", "Human-reviewed outdated website", 15, "A human reviewer marked the website outdated."),
_rule("no_working_https", "No working HTTPS", 10, "No working HTTPS service was verified."),
_rule("severe_performance", "Severe performance issue", 10, "The website has a severe performance issue."),
_rule("active_social", "Active social presence", 10, "An active social presence was detected."),
_rule("valid_public_business_phone", "Valid public business phone", 5, "A valid public business phone is available."),
_rule("multiple_corroborating_sources", "Multiple corroborating sources", 5, "Multiple independent sources corroborate the business."),
_rule("possibly_closed", "Possibly closed", -30, "Evidence suggests the business may be closed."),
_rule("healthy_modern_website", "Healthy modern website", -30, "The website is healthy and modern."),
_rule("stale_or_uncertain", "Stale or uncertain evidence", -15, "The evidence is stale or uncertain."),
]
def _get(data, path):
value = data
for part in str(path).split("."):
if not isinstance(value, dict): return None
value = value.get(part)
return value
def _match(condition, signals):
if not isinstance(condition, dict): return False
if "all" in condition: return all(_match(c, signals) for c in condition["all"])
if "any" in condition: return any(_match(c, signals) for c in condition["any"])
if "not" in condition: return not _match(condition["not"], signals)
path = str(condition.get("signal", "")); value = _get(signals, path)
op = condition.get("operator", "truthy"); expected = condition.get("value")
section = signals.get(path.split(".")[0], {}) if isinstance(signals, dict) else {}
# Positive evidence is suppressed when its evidence section is stale/uncertain;
# the explicit opportunity.stale_or_uncertain rule remains evaluable.
if path != "opportunity.stale_or_uncertain" and isinstance(section, dict) and (section.get("stale") or section.get("uncertain")): return False
if op in ("truthy", "present"): return bool(value) if op == "truthy" else value not in (None, "", [], {})
if op == "equals": return value == expected
if op == "in": return value in (expected if isinstance(expected, list) else [expected])
if op in ("gte", "lte", "gt", "lt"):
try: return {"gte": value >= expected, "lte": value <= expected, "gt": value > expected, "lt": value < expected}[op]
except (TypeError, ValueError): return False
return False
def evaluate_score(signals, rules):
total = 0; explanations = []
ordered = sorted((dict(r) for r in rules), key=lambda r: (str(r.get("code", "")), int(r.get("id", 0) or 0)))
for rule in ordered:
enabled = bool(rule.get("enabled", 1)); applied = enabled and _match(_condition(rule), signals)
points = int(rule.get("points", 0) or 0) if applied else 0
total += points
explanations.append({"code": rule.get("code", ""), "name": rule.get("name", rule.get("code", "")), "version": int(rule.get("version", 1) or 1), "enabled": enabled, "applied": applied, "points": points, "reason": (rule.get("description") or rule.get("name") or rule.get("code") or "Rule") + (" (matched)" if applied else " (not matched)")})
total = max(0, min(100, total)); state = signals.get("state", {}) if isinstance(signals, dict) else {}
eligible = not bool(state.get("suppressed")) and str(state.get("merge_status", "active")) == "active"
band = "ineligible" if not eligible else "high" if total >= 70 else "medium" if total >= 40 else "low"
return {"score": total, "score_version": SCORE_VERSION, "eligible": eligible, "priority_band": band, "explanations": explanations}
def _condition(rule):
raw = rule.get("condition_json", {})
if isinstance(raw, str):
try: return json.loads(raw)
except (TypeError, ValueError): return {}
return raw
def _has_working_https(website):
url = website.get("final_url") or website.get("input_url") or ""
return urlparse(str(url)).scheme.lower() == "https" and website.get("tls") is not False and website.get("certificate_status", "valid") not in {"invalid", "error"}
def signals_for_business(business, website=None, contacts=None, domain=None, suppressed=False, sources=None):
b = dict(business); website = dict(website or {}); contacts = contacts or []; domain = dict(domain or {})
public = [c for c in contacts if c.get("public_business") and not c.get("suppressed") and not c.get("do_not_contact")]
free_mail = any(str(c.get("classification", "")).lower() == "free_mail" for c in public)
phone = str(b.get("phone", "") or "")
valid_phone = sum(ch.isdigit() for ch in phone) >= 7 or any(c.get("kind") == "phone" for c in public)
classification = str(website.get("classification") or b.get("website_class") or "").lower()
has_domain = bool(b.get("website_domain") or b.get("website") or domain.get("domain"))
stale = any(isinstance(x, dict) and (x.get("stale") or x.get("uncertain")) for x in (b, website, domain)) or bool(b.get("stale") or b.get("uncertain"))
closed = str(b.get("status", "")).lower() in {"closed", "possibly_closed"} or bool(b.get("possibly_closed"))
source_count = len(sources or b.get("sources", []) or [])
opportunity = {
"no_detected_website": not has_domain,
"no_official_domain": not bool(domain.get("official", domain.get("status") in {"resolved", "ok", "healthy"}) and has_domain),
"no_functioning_web_service": has_domain and classification not in {"healthy", "modern", "healthy_modern"},
"broken_website": classification == "broken",
"parked_default_placeholder": classification in {"parked", "placeholder", "default", "under_construction"},
"public_free_mail": free_mail or str(b.get("email", "")).lower().split("@")[-1] in {"gmail.com", "yahoo.com", "hotmail.com", "outlook.com", "icloud.com"},
"human_reviewed_outdated": bool(b.get("human_reviewed_outdated") or website.get("human_reviewed_outdated")),
"no_working_https": has_domain and not _has_working_https(website),
"severe_performance": str(website.get("performance", website.get("performance_severity", ""))).lower() == "severe" or bool(website.get("severe_performance")),
"active_social": bool(website.get("social_signal") or b.get("active_social")),
"valid_public_business_phone": valid_phone,
"multiple_corroborating_sources": source_count >= 2,
"possibly_closed": closed,
"healthy_modern_website": classification in {"healthy_modern", "modern"} or (classification == "healthy" and bool(website.get("modern") or website.get("modern_signal"))),
"stale_or_uncertain": stale,
}
return {"business": {"name": b.get("name", ""), "email": b.get("email", ""), "phone": b.get("phone", ""), "description": b.get("description", ""), "website_domain": b.get("website_domain", ""), "website_class": b.get("website_class", "")}, "website": website, "contacts": {"count": len(contacts), "public_count": len(public)}, "domain": domain, "opportunity": opportunity, "state": {"verified": bool(b.get("verified")), "suppressed": bool(suppressed), "merge_status": b.get("merge_status", "active"), "merged": b.get("merge_status") == "merged"}}
def score_business_opportunity(business, website=None, contacts=None, domain=None, suppressed=False, sources=None):
"""Score a normalized record with the immutable built-in opportunity model."""
signals = signals_for_business(business, website, contacts, domain, suppressed, sources)
result = evaluate_score(signals, DEFAULT_RULES)
result["factors"] = [item["code"] for item in result["explanations"] if item["applied"]]
result["website_class"] = str((website or {}).get("classification") or business.get("website_class") or ("business_site" if business.get("website") else ""))
return result | {"signals": signals}