68 lines
5.7 KiB
Python
68 lines
5.7 KiB
Python
"""Deterministic, explainable qualification scoring."""
|
|||
|
|
from __future__ import annotations
|
||
|
|
import json
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
|
||
|
|
SCORE_VERSION = "phase10-1"
|
||
|
|
DEFAULT_RULES = [
|
||
|
|
{"code": "business_name", "name": "Named business", "description": "Business has a usable name", "condition_json": {"signal": "business.name", "operator": "present"}, "points": 15, "max_applications": 1, "enabled": 1, "version": 1},
|
||
|
|
{"code": "business_site", "name": "Business website", "description": "Business has a non-social website", "condition_json": {"signal": "business.website_class", "operator": "equals", "value": "business_site"}, "points": 20, "max_applications": 1, "enabled": 1, "version": 1},
|
||
|
|
{"code": "website_healthy", "name": "Healthy website", "description": "Latest website scan is healthy", "condition_json": {"signal": "website.classification", "operator": "equals", "value": "healthy"}, "points": 15, "max_applications": 1, "enabled": 1, "version": 1},
|
||
|
|
{"code": "contact_email", "name": "Email contact", "description": "A direct business email is available", "condition_json": {"signal": "business.email", "operator": "present"}, "points": 15, "max_applications": 1, "enabled": 1, "version": 1},
|
||
|
|
{"code": "contact_phone", "name": "Phone contact", "description": "A business phone is available", "condition_json": {"signal": "business.phone", "operator": "present"}, "points": 10, "max_applications": 1, "enabled": 1, "version": 1},
|
||
|
|
{"code": "extracted_contact", "name": "Extracted contact", "description": "A public, non-suppressed contact was extracted", "condition_json": {"signal": "contacts.public_count", "operator": "gte", "value": 1}, "points": 10, "max_applications": 1, "enabled": 1, "version": 1},
|
||
|
|
{"code": "domain_verified", "name": "Domain check", "description": "Domain check resolved successfully", "condition_json": {"signal": "domain.status", "operator": "in", "value": ["resolved", "ok", "healthy"]}, "points": 5, "max_applications": 1, "enabled": 1, "version": 1},
|
||
|
|
{"code": "verified_business", "name": "Verified business", "description": "Business has been verified", "condition_json": {"signal": "state.verified", "operator": "truthy"}, "points": 10, "max_applications": 1, "enabled": 1, "version": 1},
|
||
|
|
]
|
||
|
|
|
||
|
|
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)
|
||
|
|
value = _get(signals, condition.get("signal", "")); op = condition.get("operator", "truthy"); expected = condition.get("value")
|
||
|
|
section = signals.get(str(condition.get("signal", "")).split(".")[0], {})
|
||
|
|
if 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"
|
||
|
|
if not eligible: band = "ineligible"
|
||
|
|
elif total >= 70: band = "high"
|
||
|
|
elif total >= 40: band = "medium"
|
||
|
|
else: band = "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 signals_for_business(business, website=None, contacts=None, domain=None, suppressed=False):
|
||
|
|
b = dict(business); website = website or {}; contacts = contacts or []; domain = 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")]
|
||
|
|
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, "state": {"verified": bool(b.get("verified")), "suppressed": bool(suppressed), "merge_status": b.get("merge_status", "active"), "merged": b.get("merge_status") == "merged"}}
|