158 lines
8.1 KiB
Python
158 lines
8.1 KiB
Python
"""Strict, evidence-grounded, review-only opportunity assessment normalization."""
|
|||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from typing import Any, Callable
|
||
|
|
|
||
|
|
ASSESSMENT_SCHEMA_VERSION = "opportunity-assessment-v3"
|
||
|
|
DETERMINISTIC_ASSESSMENT_THRESHOLD = 70
|
||
|
|
RECOMMENDATIONS = frozenset({"contact", "review", "low_priority", "do_not_contact", "insufficient_evidence"})
|
||
|
|
PRIORITIES = frozenset({"high", "medium", "low"})
|
||
|
|
WEBSITE_STATUSES = frozenset({"healthy", "outdated", "broken", "missing", "parked", "unknown"})
|
||
|
|
DOMAIN_STATUSES = frozenset({"registered", "missing", "likely_available", "unknown"})
|
||
|
|
CONTACT_TYPES = frozenset({"none", "general_business", "named_business", "free_mail", "unknown"})
|
||
|
|
_ALLOWED_FIELDS = frozenset({
|
||
|
|
"opportunity_score", "confidence_score", "recommendation", "priority", "reasons", "missing_evidence",
|
||
|
|
"website_assessment", "domain_assessment", "contactability", "recommended_services",
|
||
|
|
"human_review_required", "evidence_references",
|
||
|
|
})
|
||
|
|
|
||
|
|
|
||
|
|
def _score(value: Any, *, confidence: bool = False) -> int:
|
||
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||
|
|
return 0
|
||
|
|
numeric = float(value)
|
||
|
|
if numeric != numeric or numeric in (float("inf"), float("-inf")):
|
||
|
|
return 0
|
||
|
|
if confidence and 0 <= numeric <= 1:
|
||
|
|
numeric *= 100
|
||
|
|
return max(0, min(100, int(round(numeric))))
|
||
|
|
|
||
|
|
|
||
|
|
def _enum(value: Any, allowed: frozenset[str], default: str) -> str:
|
||
|
|
item = value.strip().lower() if isinstance(value, str) else ""
|
||
|
|
return item if item in allowed else default
|
||
|
|
|
||
|
|
|
||
|
|
def _text_list(value: Any) -> list[str]:
|
||
|
|
if value is None:
|
||
|
|
return []
|
||
|
|
if not isinstance(value, list) or len(value) > 20 or any(not isinstance(item, str) for item in value):
|
||
|
|
raise ValueError("invalid_assessment_list")
|
||
|
|
result: list[str] = []
|
||
|
|
for item in value:
|
||
|
|
item = item.strip()
|
||
|
|
if not item or len(item) > 300 or item in result:
|
||
|
|
continue
|
||
|
|
result.append(item)
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def _website(value: Any) -> dict[str, Any]:
|
||
|
|
default = {"status": "unknown", "broken": False, "outdated": False, "mobile_issue": False, "https_issue": False, "performance_issue": False}
|
||
|
|
if value is None:
|
||
|
|
return default
|
||
|
|
if not isinstance(value, dict) or set(value) - set(default):
|
||
|
|
raise ValueError("invalid_assessment_schema")
|
||
|
|
result = dict(default)
|
||
|
|
result["status"] = _enum(value.get("status"), WEBSITE_STATUSES, "unknown")
|
||
|
|
for key in set(default) - {"status"}:
|
||
|
|
if key in value:
|
||
|
|
if not isinstance(value[key], bool):
|
||
|
|
raise ValueError("invalid_assessment_schema")
|
||
|
|
result[key] = value[key]
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def _domain(value: Any) -> dict[str, str]:
|
||
|
|
if value is None:
|
||
|
|
return {"status": "unknown"}
|
||
|
|
if not isinstance(value, dict) or set(value) != {"status"}:
|
||
|
|
raise ValueError("invalid_assessment_schema")
|
||
|
|
return {"status": _enum(value.get("status"), DOMAIN_STATUSES, "unknown")}
|
||
|
|
|
||
|
|
|
||
|
|
def _contactability(value: Any) -> dict[str, Any]:
|
||
|
|
default = {"public_business_contact_found": False, "contact_type": "unknown"}
|
||
|
|
if value is None:
|
||
|
|
return default
|
||
|
|
if not isinstance(value, dict) or set(value) - set(default):
|
||
|
|
raise ValueError("invalid_assessment_schema")
|
||
|
|
result = dict(default)
|
||
|
|
if "public_business_contact_found" in value:
|
||
|
|
if not isinstance(value["public_business_contact_found"], bool):
|
||
|
|
raise ValueError("invalid_assessment_schema")
|
||
|
|
result["public_business_contact_found"] = value["public_business_contact_found"]
|
||
|
|
result["contact_type"] = _enum(value.get("contact_type"), CONTACT_TYPES, "unknown")
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def normalize_assessment(raw: dict[str, Any] | None, known_evidence_ids: set[int], *, suppressed: bool = False) -> dict[str, Any]:
|
||
|
|
"""Return exactly the assessment contract; reject invented evidence IDs."""
|
||
|
|
raw = {} if raw is None else raw
|
||
|
|
if not isinstance(raw, dict) or set(raw) - _ALLOWED_FIELDS:
|
||
|
|
raise ValueError("invalid_assessment_schema")
|
||
|
|
references = raw.get("evidence_references", [])
|
||
|
|
if not isinstance(references, list) or len(references) > 100:
|
||
|
|
raise ValueError("invalid_evidence_references")
|
||
|
|
evidence_references: list[int] = []
|
||
|
|
for reference in references:
|
||
|
|
if isinstance(reference, bool) or not isinstance(reference, int):
|
||
|
|
raise ValueError("invalid_evidence_reference")
|
||
|
|
if reference not in known_evidence_ids:
|
||
|
|
raise ValueError("unknown_evidence_reference")
|
||
|
|
if reference not in evidence_references:
|
||
|
|
evidence_references.append(reference)
|
||
|
|
evidence_references.sort()
|
||
|
|
confidence_score = _score(raw.get("confidence_score"), confidence=True)
|
||
|
|
recommendation = _enum(raw.get("recommendation"), RECOMMENDATIONS, "insufficient_evidence")
|
||
|
|
weak_evidence = len(evidence_references) < 2 or confidence_score < 70 or recommendation == "insufficient_evidence"
|
||
|
|
contactability = _contactability(raw.get("contactability"))
|
||
|
|
if suppressed:
|
||
|
|
recommendation = "do_not_contact"
|
||
|
|
contactability = {"public_business_contact_found": False, "contact_type": "none"}
|
||
|
|
return {
|
||
|
|
"opportunity_score": _score(raw.get("opportunity_score")),
|
||
|
|
"confidence_score": confidence_score,
|
||
|
|
"recommendation": recommendation,
|
||
|
|
"priority": _enum(raw.get("priority"), PRIORITIES, "low"),
|
||
|
|
"reasons": _text_list(raw.get("reasons")),
|
||
|
|
"missing_evidence": _text_list(raw.get("missing_evidence")),
|
||
|
|
"website_assessment": _website(raw.get("website_assessment")),
|
||
|
|
"domain_assessment": _domain(raw.get("domain_assessment")),
|
||
|
|
"contactability": contactability,
|
||
|
|
"recommended_services": _text_list(raw.get("recommended_services")),
|
||
|
|
"human_review_required": bool(suppressed or weak_evidence or raw.get("human_review_required", False)),
|
||
|
|
"evidence_references": evidence_references,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def deterministic_assessment(business: dict[str, Any], evidence: list[dict[str, Any]]) -> dict[str, Any]:
|
||
|
|
try:
|
||
|
|
score = max(0, min(100, int(business.get("score", 0) or 0)))
|
||
|
|
except (TypeError, ValueError):
|
||
|
|
score = 0
|
||
|
|
references = [item["id"] for item in evidence if isinstance(item.get("id"), int)][:100]
|
||
|
|
has_website = bool(business.get("website") or business.get("website_domain"))
|
||
|
|
website_class = str(business.get("website_class", "")).lower()
|
||
|
|
website_status = "missing" if not has_website else website_class if website_class in WEBSITE_STATUSES else "unknown"
|
||
|
|
has_contact = bool(business.get("email") or business.get("phone"))
|
||
|
|
return {
|
||
|
|
"opportunity_score": score,
|
||
|
|
"confidence_score": min(95, 35 + 20 * len(references)),
|
||
|
|
"recommendation": "review" if references and score >= DETERMINISTIC_ASSESSMENT_THRESHOLD else "insufficient_evidence",
|
||
|
|
"priority": "high" if score >= 70 else "medium" if score >= 40 else "low",
|
||
|
|
"reasons": ["Stored evidence requires human review."] if references else [],
|
||
|
|
"missing_evidence": [item for item, present in (("website evidence", has_website), ("corroborating evidence", len(references) >= 2)) if not present],
|
||
|
|
"website_assessment": {"status": website_status, "broken": website_status == "broken", "outdated": website_status == "outdated", "mobile_issue": False, "https_issue": has_website and not str(business.get("website", "")).startswith("https://"), "performance_issue": False},
|
||
|
|
"domain_assessment": {"status": "registered" if business.get("website_domain") else "missing"},
|
||
|
|
"contactability": {"public_business_contact_found": has_contact, "contact_type": "general_business" if has_contact else "none"},
|
||
|
|
"recommended_services": ["website" if not has_website else "website_repair"],
|
||
|
|
"human_review_required": True,
|
||
|
|
"evidence_references": references,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def assess_opportunity(business: dict[str, Any], evidence: list[dict[str, Any]], *, suppressed: bool = False, provider: Callable[[dict[str, Any], list[dict[str, Any]],], dict[str, Any]] | None = None) -> dict[str, Any]:
|
||
|
|
raw = provider(business, evidence) if provider else deterministic_assessment(business, evidence)
|
||
|
|
return normalize_assessment(raw, {item["id"] for item in evidence if isinstance(item.get("id"), int)}, suppressed=suppressed)
|