"""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}