High-fit prospects
diff --git a/README.md b/README.md index 896f334..92eabb7 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,18 @@ False-positive exclusions must reject or quarantine values from asset URLs, imag Phase 9 does not authorize automated outreach. There is no SMTP probing, SMTP banner/VRFY/EXPN check, email validation message, send endpoint, campaign queue, or follow-up action. An extracted address is an observation requiring human review and explicit policy authorization before any separate future contact workflow. +## Phase 10 configurable scoring boundary + +Phase 10 separates **fit score**, **priority band**, and **contact eligibility**. A score is a deterministic ranking signal; it never authorizes contact. Rules are represented by a named, versioned rule set with explicit weights, thresholds, band definitions, eligibility gates, freshness policy, and suppression behavior. The active rule-set identifier/version is stored with each result so a historical score can be explained without silently applying today's policy. + +A reproducible calculation uses the tenant-scoped business snapshot, normalized values, eligible evidence observations, rule-set/version, algorithm/version, and calculation time/freshness inputs. Explanations must retain the contributing factors, normalized inputs or evidence references, weights/points, exclusions, uncertainty reasons, and the final band. Do not accept a client-submitted score, band, eligibility flag, or rule version as authoritative. + +Priority bands are policy labels (for example, high/medium/low or an explicitly configured equivalent) and must be derived from the versioned thresholds. Eligibility is evaluated separately and fail-closed: suppression/do-not-contact, stale or expired required evidence, unresolved/uncertain required signals, missing policy prerequisites, and authorization/tenant failures can make a prospect ineligible regardless of score. `suppressed` always wins and must remain visible; stale and uncertain observations must not be silently treated as absent or positive. + +Recalculation is an explicit, tenant-scoped operation. It must snapshot the input/rule versions, record before/after score, band, eligibility, explanation, actor/job, timestamp, and reason in the audit trail, and be idempotent or safely repeatable. A policy/rule change must not rewrite history without an auditable recalculation; partial or failed recalculation must report its incomplete state rather than presenting mixed results as current. + +Phase 10 remains a pilot boundary unless the runtime exposes all of the above controls end to end. Production work includes administrative rule-set lifecycle/approval, immutable calculation inputs, deterministic rounding/tie-breaking, scheduled recalculation with leases, retention and export semantics for explanations/audit, and regression tests proving suppression, stale, uncertain, and cross-tenant isolation behavior. See the API, security, and operations contracts for the authoritative safeguards. + ## Verification ```bash diff --git a/apps/api/README.md b/apps/api/README.md index e2469e2..21b0d26 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -138,6 +138,18 @@ Exclude false positives before persistence and response: values in scripts/style Extraction results are suggestions only and do not create a send/contact capability. The API exposes no SMTP-probe, validation-message, outreach, or campaign endpoint. If the feature is disabled, unapproved, over limit, blocked, or uncertain, fail closed with an explicit status/reason rather than an empty successful result. The current MVP remains pilot-only until extraction limits, suppression enforcement, retention/deletion jobs, provenance/audit coverage, and tenant-isolation tests are production hardened. +## Phase 10 scoring contract + +Scoring is a tenant-scoped, deterministic derivation with three separate outputs: `score` (fit/ranking), `priority_band` (versioned threshold policy), and `eligibility` (whether a later, separately authorized workflow may act). A high score or priority band never authorizes contact. The API must calculate these values server-side from a named rule set and immutable rule-set version; weights, thresholds, required evidence, freshness windows, suppression precedence, and uncertainty handling are configuration, not undocumented code defaults. + +Each result should expose the rule-set ID/version and algorithm/version, calculation timestamp, input/evidence snapshot or stable references, contributing factors, points/weights, exclusions, band decision, eligibility decision/reasons, and stale/uncertain state. Explanations are reviewable lineage, not proof of identity, consent, deliverability, or permission. Normalize and round deterministically, define tie-breaking, and reject client-supplied score/band/eligibility/version fields. + +Eligibility is evaluated independently of score. Tenant-scoped suppression/do-not-contact is an unconditional ineligible result. Required evidence that is stale, expired, missing, blocked, partial, or uncertain must produce an explicit reason and fail closed according to the active rule set; it must not be converted into a zero, a positive signal, or an empty successful result. A score may remain visible for triage while eligibility is `ineligible` or `unknown`, and suppression must remain visible after recalculation. + +Recalculation must be an explicit authenticated operation, preferably represented by the existing tenant-scoped job contract for larger sets. It must capture the requested rule-set/version, input snapshot, actor/job/idempotency lineage, started/completed time, counts and failures, and before/after score, band, eligibility, and explanation changes. Every rule change and recalculation is auditable; retries cannot duplicate or erase history, and a partial run must be marked incomplete. Audit and explanation reads use the same organization predicate as business reads, and cross-tenant IDs/jobs/rule sets behave as not found. + +The current MVP's scoring surface is limited compared with the Phase 10 contract: production still needs an authorized rule-set management API, approval/activation and rollback semantics, immutable evidence snapshots, scheduled/durable recalculation, concurrency protection, deterministic migration of old scores, and comprehensive tests for suppression, stale/uncertain evidence, audit completeness, and tenant isolation. + ## Remaining limitations and production migration work SQLite is a pilot store with an MVP job/event schema but no production migration runner, durable queue, scheduler, worker lease/recovery, durable backup command, or tested restore workflow. Authentication currently uses a development password fallback and does not by itself provide production Argon2id, MFA, CSRF protection, rate limiting, or a complete retention-grade audit system. Website scanning remains subject to pilot limits: no production egress proxy/isolation, distributed crawl coordinator, hardened resolver, or compliance-grade scan-history retention. Production work must add SSRF/DNS-rebinding/redirect-chain tests, egress policy, authenticated history/cache isolation, budget/abuse enforcement, durable result retention/deletion, observability, reviewed content/robots/terms policy, and the Phase 7 PSL/DNS/availability controls. Redis/Celery/Postgres remain future options, not implemented dependencies. diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 3e4bd60..6d31cb2 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -12,12 +12,14 @@ if __package__ in (None, ""): from app.domain_intelligence import normalize_registrable_domain, resolve_domain, generate_candidate_domains from app.website_scanner import scan_website, validate_url from app.contact_extractor import extract_contacts, MAX_HTML_BYTES, MAX_RESULTS + from app.scoring import DEFAULT_RULES, signals_for_business, evaluate_score, SCORE_VERSION else: from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business, normalize_domain, normalize_phone, match_businesses from .sources import adapter_for, contains_secret from .domain_intelligence import normalize_registrable_domain, resolve_domain, generate_candidate_domains from .website_scanner import scan_website, validate_url from .contact_extractor import extract_contacts, MAX_HTML_BYTES, MAX_RESULTS + from .scoring import DEFAULT_RULES, signals_for_business, evaluate_score, SCORE_VERSION ORGANIZATION_ID = "demo-tenant" SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql" SESSION_DAYS = 7 @@ -57,6 +59,9 @@ def connect(db_path: str) -> sqlite3.Connection: if col not in cols: db.execute(f"ALTER TABLE businesses ADD COLUMN {col} {definition}") db.execute("UPDATE businesses SET updated_at=COALESCE(updated_at,created_at) WHERE updated_at IS NULL") db.execute("INSERT OR IGNORE INTO organizations (id,name) VALUES (?,?)", (ORGANIZATION_ID, "Demo organization")) + for organization in db.execute("SELECT id FROM organizations").fetchall(): + for rule in DEFAULT_RULES: + db.execute("INSERT OR IGNORE INTO score_rules(organization_id,code,name,description,condition_json,points,max_applications,enabled,version) VALUES(?,?,?,?,?,?,?,?,?)", (organization["id"], rule["code"], rule["name"], rule["description"], json.dumps(rule["condition_json"], sort_keys=True), rule["points"], rule["max_applications"], rule["enabled"], rule["version"])) email, password = os.environ.get("BOOTSTRAP_ADMIN_EMAIL"), os.environ.get("BOOTSTRAP_ADMIN_PASSWORD") if email and password and not db.execute("SELECT id FROM users WHERE email=?", (email.strip().lower(),)).fetchone(): ph, salt = hash_password(password); db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", (ORGANIZATION_ID,email.strip().lower(),ph,salt,"owner")) @@ -120,6 +125,8 @@ class ApiHandler(BaseHTTPRequestHandler): return self.send_json(200,{"items":[dict(r) for r in db.execute("SELECT id,email,role,organization_id,created_at FROM users WHERE organization_id=? ORDER BY id",(org,))]}) if path=="/api/v1/dashboard/summary": row=db.execute("SELECT COUNT(*) businesses,COALESCE(AVG(score),0) average_score FROM businesses WHERE organization_id=?",(org,)).fetchone(); return self.send_json(200,{"organization_id":org,"businesses":row["businesses"],"average_score":round(row["average_score"],2),"suppressed":db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id=?",(org,)).fetchone()[0]}) + if path=="/api/v1/score-rules": return self.list_score_rules(db,org) + if path=="/api/v1/scoring/summary": return self.scoring_summary(db,org) if path=="/api/v1/businesses": return self.list_businesses(db,org,parse_qs(parsed.query)) if path=="/api/v1/merge-history": return self.list_merge_history(db,org) if path=="/api/v1/sources": return self.list_sources(db,org) @@ -356,6 +363,81 @@ class ApiHandler(BaseHTTPRequestHandler): db.execute("UPDATE jobs SET status='queued',error_code=NULL,completed_at=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=?",(jid,));self.add_job_event(db,jid,user["organization_id"],"retry","Job retry queued",job["progress"]);self.audit(db,user,"job.retried",str(jid));db.commit();getattr(self.server,"job_wakeup",threading.Event()).set();return self.send_json(200,job_json(db.execute("SELECT * FROM jobs WHERE id=?",(jid,)).fetchone())) return self.send_json(404,{"error":"not_found"}) + def ensure_score_rules(self, db, org): + for rule in DEFAULT_RULES: + db.execute("INSERT OR IGNORE INTO score_rules(organization_id,code,name,description,condition_json,points,max_applications,enabled,version) VALUES(?,?,?,?,?,?,?,?,?)", (org, rule["code"], rule["name"], rule["description"], json.dumps(rule["condition_json"], sort_keys=True), rule["points"], rule["max_applications"], rule["enabled"], rule["version"])) + + def list_score_rules(self, db, org): + self.ensure_score_rules(db, org); db.commit() + rows = db.execute("SELECT * FROM score_rules WHERE organization_id=? ORDER BY code,id", (org,)).fetchall() + items = [] + for row in rows: + item = row_json(row) + try: item["condition_json"] = json.loads(item["condition_json"]) + except (TypeError, ValueError): item["condition_json"] = {} + item["enabled"] = bool(item["enabled"]); items.append(item) + return self.send_json(200, {"organization_id": org, "items": items}) + + def create_score_rule(self, payload, db, user): + code = str(payload.get("code", "")).strip(); name = str(payload.get("name", "")).strip(); condition = payload.get("condition_json", payload.get("condition", {})) + try: points = int(payload.get("points", 0)); maximum = int(payload.get("max_applications", 1)); version = int(payload.get("version", 1)) + except (TypeError, ValueError): return self.send_json(400, {"error": "invalid_rule"}) + if not code or not name or not isinstance(condition, dict) or maximum < 1 or version < 1 or points < -100 or points > 100: return self.send_json(400, {"error": "invalid_rule"}) + try: + cur = db.execute("INSERT INTO score_rules(organization_id,code,name,description,condition_json,points,max_applications,enabled,version) VALUES(?,?,?,?,?,?,?,?,?)", (user["organization_id"], code, name, str(payload.get("description", "")), json.dumps(condition, sort_keys=True), points, maximum, int(bool(payload.get("enabled", True))), version)) + except sqlite3.IntegrityError: return self.send_json(409, {"error": "duplicate_rule"}) + self.audit(db, user, "score_rule.created", code); db.commit() + row = db.execute("SELECT * FROM score_rules WHERE id=?", (cur.lastrowid,)).fetchone(); item = row_json(row); item["condition_json"] = condition; item["enabled"] = bool(item["enabled"]) + return self.send_json(201, item) + + def update_score_rule(self, rid, payload, db, user): + row = db.execute("SELECT * FROM score_rules WHERE id=? AND organization_id=?", (rid, user["organization_id"])).fetchone() + if not row: return self.send_json(404, {"error": "not_found"}) + allowed = {"name", "description", "condition_json", "points", "max_applications", "enabled", "version"}; values = {k: payload[k] for k in allowed if k in payload} + if not values: return self.send_json(400, {"error": "no_changes"}) + if "condition_json" in values and not isinstance(values["condition_json"], dict): return self.send_json(400, {"error": "invalid_rule"}) + if "points" in values: + try: values["points"] = int(values["points"]) + except (TypeError, ValueError): return self.send_json(400, {"error": "invalid_rule"}) + columns=[]; params=[] + for key, value in values.items(): columns.append(key + "=?"); params.append(json.dumps(value, sort_keys=True) if key == "condition_json" else (int(bool(value)) if key == "enabled" else value)) + params += [rid, user["organization_id"]]; db.execute("UPDATE score_rules SET " + ",".join(columns) + ",updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?", params); self.audit(db, user, "score_rule.updated", str(rid)); db.commit() + item = row_json(db.execute("SELECT * FROM score_rules WHERE id=?", (rid,)).fetchone()) + try: item["condition_json"] = json.loads(item["condition_json"]) + except (TypeError, ValueError): item["condition_json"] = {} + item["enabled"] = bool(item["enabled"]); return self.send_json(200, item) + + def recalculate_score(self, bid, payload, db, user): + org = user["organization_id"]; business = self.business(db, bid, org) + if not business: return self.send_json(404, {"error": "not_found"}) + scans = db.execute("SELECT result_json,classification,scanned_at FROM website_scans WHERE business_id=? AND organization_id=? ORDER BY id DESC LIMIT 1", (bid, org)).fetchone(); website = {"classification": business["website_class"]} + if scans: + try: website.update(json.loads(scans["result_json"] or "{}")) + except (TypeError, ValueError): pass + website["classification"] = scans["classification"] + contacts = [dict(r) for r in db.execute("SELECT public_business,suppressed,do_not_contact FROM contact_extractions WHERE business_id=? AND organization_id=?", (bid, org))] + drow = db.execute("SELECT status,result_json,checked_at FROM domain_checks WHERE business_id=? AND organization_id=? ORDER BY id DESC LIMIT 1", (bid, org)).fetchone(); domain = dict(drow) if drow else {} + if drow: + try: domain.update(json.loads(drow["result_json"] or "{}")) + except (TypeError, ValueError): pass + suppressed = is_suppressed(dict(business), [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=?", (org,))]) + signals = signals_for_business(dict(business), website, contacts, domain, suppressed); self.ensure_score_rules(db, org); rules = [dict(r) for r in db.execute("SELECT * FROM score_rules WHERE organization_id=?", (org,))]; result = evaluate_score(signals, rules) + override_score = payload.get("override_score"); override_eligible = payload.get("override_eligible") + if override_score is not None or override_eligible is not None: + reason = str(payload.get("override_reason", "")).strip() + if not reason: return self.send_json(400, {"error": "override_reason_required"}) + if override_score is not None: result["score"] = max(0, min(100, int(override_score))) + if override_eligible is not None and not suppressed: result["eligible"] = bool(override_eligible) + result["priority_band"] = "ineligible" if not result["eligible"] else ("high" if result["score"] >= 70 else "medium" if result["score"] >= 40 else "low") + db.execute("UPDATE businesses SET score=?,score_version=?,score_factors=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?", (result["score"], SCORE_VERSION, json.dumps(result["explanations"], sort_keys=True), bid, org)) + cur=db.execute("INSERT INTO score_history(organization_id,business_id,score,eligible,priority_band,score_version,explanations_json,signals_json,override_score,override_eligible,override_reason,actor_user_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", (org,bid,result["score"],int(result["eligible"]),result["priority_band"],SCORE_VERSION,json.dumps(result["explanations"],sort_keys=True),json.dumps(signals,sort_keys=True),override_score,override_eligible,payload.get("override_reason"),user["id"])) + self.audit(db,user,"business.score_recalculated",f"{bid}:{result['score']}"); db.commit(); result.update({"business_id": bid, "history_id": cur.lastrowid}); return self.send_json(200, result) + + def scoring_summary(self, db, org): + row=db.execute("SELECT COUNT(*) businesses,COALESCE(AVG(score),0) average_score,SUM(CASE WHEN score>=70 THEN 1 ELSE 0 END) high_priority FROM businesses WHERE organization_id=? AND merge_status='active'",(org,)).fetchone() + bands={r["priority_band"]:r["count"] for r in db.execute("SELECT priority_band,COUNT(*) count FROM score_history WHERE organization_id=? GROUP BY priority_band",(org,))} + return self.send_json(200,{"organization_id":org,"businesses":row["businesses"],"average_score":round(row["average_score"],2),"high_priority":row["high_priority"] or 0,"bands":bands,"history_count":db.execute("SELECT COUNT(*) FROM score_history WHERE organization_id=?",(org,)).fetchone()[0]}) + def list_businesses(self,db,org,query): def number(name, default=None): raw=query.get(name,[None])[0] @@ -390,6 +472,8 @@ class ApiHandler(BaseHTTPRequestHandler): return self.create_job(self.read_json(),db,user) if user["role"] not in MUTATING_ROLES:return self.send_json(403,{"error":"forbidden"}) payload=self.read_json(); org=user["organization_id"] + if path=="/api/v1/score-rules": return self.create_score_rule(payload,db,user) + if len(path.split("/"))==7 and path.split("/")[3:]==["businesses",path.split("/")[4],"score","recalculate"]: return self.recalculate_score(int(path.split("/")[4]) if path.split("/")[4].isdigit() else -1,payload,db,user) if path.startswith("/api/v1/jobs/"): return self.job_action(db,user,path) if path=="/api/v1/businesses":return self.create_business(payload,db,user) @@ -422,6 +506,7 @@ class ApiHandler(BaseHTTPRequestHandler): if not user:return if user["role"] not in MUTATING_ROLES:return self.send_json(403,{"error":"forbidden"}) bits=path.split("/") + if len(bits)==5 and bits[:4]==["","api","v1","score-rules"] and bits[4].isdigit(): return self.update_score_rule(int(bits[4]),self.read_json(),db,user) if len(bits)==5 and bits[:4]==["","api","v1","sources"] and bits[4].isdigit(): return self.update_source(int(bits[4]),self.read_json(),db,user) if len(bits)==6 and bits[:4]==["","api","v1","businesses"] and bits[5]=="pipeline":return self.update_pipeline(int(bits[4]) if bits[4].isdigit() else -1,self.read_json(),db,user) return self.send_json(404,{"error":"not_found"}) diff --git a/apps/api/app/scoring.py b/apps/api/app/scoring.py new file mode 100644 index 0000000..0b71702 --- /dev/null +++ b/apps/api/app/scoring.py @@ -0,0 +1,67 @@ +"""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"}} diff --git a/apps/api/schema.sql b/apps/api/schema.sql index fd42d32..1755863 100644 --- a/apps/api/schema.sql +++ b/apps/api/schema.sql @@ -212,3 +212,22 @@ CREATE TABLE IF NOT EXISTS contact_extractions ( ); CREATE INDEX IF NOT EXISTS idx_contact_extractions_org ON contact_extractions(organization_id,created_at DESC,id DESC); CREATE INDEX IF NOT EXISTS idx_contact_extractions_business ON contact_extractions(organization_id,business_id,id DESC); + +-- Phase 10 deterministic qualification rules and immutable score audit. +CREATE TABLE IF NOT EXISTS score_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + code TEXT NOT NULL, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', condition_json TEXT NOT NULL DEFAULT '{}', + points INTEGER NOT NULL DEFAULT 0, max_applications INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1, + version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(organization_id,code) +); +CREATE INDEX IF NOT EXISTS idx_score_rules_org ON score_rules(organization_id,enabled,code); +CREATE TABLE IF NOT EXISTS score_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, score INTEGER NOT NULL, eligible INTEGER NOT NULL, + priority_band TEXT NOT NULL, score_version TEXT NOT NULL, explanations_json TEXT NOT NULL DEFAULT '[]', signals_json TEXT NOT NULL DEFAULT '{}', + override_score INTEGER, override_eligible INTEGER, override_reason TEXT, actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_score_history_business ON score_history(organization_id,business_id,created_at DESC,id DESC); +CREATE INDEX IF NOT EXISTS idx_score_history_org ON score_history(organization_id,created_at DESC,id DESC); diff --git a/apps/api/tests/test_phase10_scoring.py b/apps/api/tests/test_phase10_scoring.py new file mode 100644 index 0000000..bf3a750 --- /dev/null +++ b/apps/api/tests/test_phase10_scoring.py @@ -0,0 +1,67 @@ +import json +import os +import sqlite3 +import threading +import unittest +from http.client import HTTPConnection +from tempfile import TemporaryDirectory + +from app.main import create_server +from app.scoring import DEFAULT_RULES, evaluate_score + + +class ScoringEngineTests(unittest.TestCase): + def test_defaults_are_deterministic_and_emit_explanations_and_band(self): + signals = {"business": {"name": "Acme", "email": "a@acme.test", "website_domain": "acme.test"}, "website": {"classification": "healthy"}, "state": {"suppressed": False}} + first = evaluate_score(signals, DEFAULT_RULES) + self.assertEqual(first, evaluate_score(signals, DEFAULT_RULES)) + self.assertEqual(0 <= first["score"] <= 100, True) + self.assertEqual(first["priority_band"], "medium") + self.assertTrue(all("code" in item and "reason" in item for item in first["explanations"])) + + def test_disabled_and_versioned_rules_change_score_without_nondeterminism(self): + signals = {"business": {"name": "Acme"}, "state": {"suppressed": False}} + enabled = evaluate_score(signals, [{"code": "x", "name": "X", "condition_json": {"signal": "business.name", "operator": "present"}, "points": 30, "enabled": 1, "version": 1}]) + disabled = evaluate_score(signals, [{"code": "x", "name": "X", "condition_json": {"signal": "business.name", "operator": "present"}, "points": 30, "enabled": 0, "version": 2}]) + self.assertEqual(enabled["score"], 30) + self.assertEqual(disabled["score"], 0) + + def test_suppression_is_ineligible_and_stale_uncertain_signals_do_not_penalize(self): + signals = {"business": {"name": "Acme"}, "website": {"classification": "unknown", "stale": True}, "domain": {"status": "error"}, "state": {"suppressed": True}} + result = evaluate_score(signals, DEFAULT_RULES) + self.assertFalse(result["eligible"]) + self.assertEqual(result["priority_band"], "ineligible") + self.assertNotIn("negative", json.dumps(result["explanations"]).lower()) + + +class ScoringApiTests(unittest.TestCase): + def setUp(self): + self.tmp = TemporaryDirectory(); self.db_path = self.tmp.name + "/db.sqlite" + os.environ["BOOTSTRAP_ADMIN_EMAIL"] = "score-owner@example.test"; os.environ["BOOTSTRAP_ADMIN_PASSWORD"] = "password" + self.server = create_server("127.0.0.1", 0, self.db_path); threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.conn = HTTPConnection("127.0.0.1", self.server.server_port, timeout=4); self.cookie = None + self.request("POST", "/api/v1/auth/login", {"email": "score-owner@example.test", "password": "password"}) + def tearDown(self): + self.server.shutdown(); self.server.server_close(); self.tmp.cleanup() + def request(self, method, path, payload=None): + body = json.dumps(payload).encode() if payload is not None else None; headers = {"Content-Type": "application/json"} if body else {} + if self.cookie: headers["Cookie"] = self.cookie + self.conn.request(method, path, body, headers); response = self.conn.getresponse(); cookie = response.getheader("Set-Cookie") + if cookie: self.cookie = cookie.split(";", 1)[0] + return response.status, json.loads(response.read() or b"{}") + def test_rule_crud_tenant_scope_and_recalculation_audit(self): + status, rules = self.request("GET", "/api/v1/score-rules"); self.assertEqual(status, 200); self.assertEqual(len(rules["items"]), len(DEFAULT_RULES)) + status, rule = self.request("POST", "/api/v1/score-rules", {"code": "custom", "name": "Custom", "condition_json": {"signal": "business.name", "operator": "present"}, "points": 7}); self.assertEqual(status, 201) + self.assertEqual(self.request("PATCH", f"/api/v1/score-rules/{rule['id']}", {"enabled": False})[0], 200) + _, business = self.request("POST", "/api/v1/businesses", {"name": "Acme", "website": "https://acme.test", "email": "a@acme.test"}) + status, result = self.request("POST", f"/api/v1/businesses/{business['id']}/score/recalculate", {"reason": "qa"}); self.assertEqual(status, 200) + self.assertIn("explanations", result); self.assertIn("eligible", result); self.assertEqual(result["business_id"], business["id"]) + status, summary = self.request("GET", "/api/v1/scoring/summary"); self.assertEqual(status, 200); self.assertEqual(summary["businesses"], 1); self.assertGreaterEqual(summary["history_count"], 1) + db = sqlite3.connect(self.db_path); self.assertEqual(db.execute("SELECT COUNT(*) FROM score_history").fetchone()[0], 1); db.close() + def test_suppressed_business_recalculation_is_force_ineligible(self): + _, business = self.request("POST", "/api/v1/businesses", {"name": "Acme", "website": "https://acme.test"}) + self.request("POST", "/api/v1/suppressions", {"kind": "domain", "value": "acme.test"}) + status, result = self.request("POST", f"/api/v1/businesses/{business['id']}/score/recalculate", {}); self.assertEqual(status, 200); self.assertFalse(result["eligible"]) + + +if __name__ == "__main__": unittest.main() diff --git a/apps/web/README.md b/apps/web/README.md index 6ae7ea4..9fb30c6 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -74,6 +74,16 @@ Extraction is not a browser crawler. The browser must not fetch target pages dir The API remains authoritative for official-site scope, tenant isolation, suppression enforcement, limits, retention, provenance, and permissions. A cached extraction must show its observed time and freshness, never “live.” Candidate confidence, role/free-mail labels, syntax, and MX/DNS uncertainty are review metadata only and cannot enable a contact action. +## Phase 10 scoring UI contract + +The UI may display server-provided `score`, `priority_band`, and `eligibility` as separate fields. It should show the active rule-set ID/version, calculated time/freshness, explanation factors with their points/weights, exclusions, and stale/uncertain reasons. A band is a triage label, not permission to contact; never derive or override these values solely in browser code. + +Render eligibility independently and prominently: **Eligible**, **Ineligible**, or **Unknown/review required** must not be collapsed into a score band. Show suppression/do-not-contact as a hard, persistent state that wins over score, verification, pipeline, cached data, or refresh. Keep stale, expired, blocked, partial, and uncertain evidence visibly distinct from missing evidence and never present them as a positive or negative fact. There is no outreach control in this phase. + +If the API exposes recalculation, the UI must show the requested rule-set/version, job/progress/partial state, actor/time, and before/after explanation or band changes; acceptance of a request is not completion. Recalculation history and audit details remain tenant-scoped server capabilities. Rule-set administration, activation, rollback, and eligibility policy are not client-side authorization controls. + +Phase 10 remains a pilot display contract until the API supplies stable versioned rule metadata, reproducible input lineage, complete explanation payloads, explicit eligibility reasons, and audited recalculation results. Browser smoke coverage should include score/band disagreement with eligibility, suppression precedence, stale/uncertain rendering, version changes, partial recalculation, and cross-tenant non-disclosure. + ## Remaining limitations The static client has no client-side crawler, scanner, contact extractor, enrichment scheduler, outreach integration, availability provider, or SSE delivery. It can display server-provided Phase 9 observations, but production still requires server-side official-site scoping, SSRF/DNS-rebinding/redirect controls, hard extraction/page/byte/time/candidate budgets, durable history/cache isolation and retention/deletion, abuse/rate controls, suppression regression tests, and authenticated provenance/audit coverage. For domain intelligence, display registrable-domain/PSL version and unresolved reasons, DNS status and freshness/TTL (not “available”), independent MX/NS/TXT uncertainty, and association confidence with explainable evidence. Never auto-attach candidate domains or treat `nxdomain`/`no_data` as availability. CSV preview is capped for display and is not an import workflow. diff --git a/apps/web/app.js b/apps/web/app.js index 6ecd65e..66897b8 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -87,7 +87,22 @@ function renderContactExtractionPanel(p){const panel=$('contactExtractionPanel');if(!panel)return;panel.innerHTML=`
PUBLIC CONTACT EXTRACTION
Extraction uses approved public business pages only. It does not probe SMTP, verify mailbox access, or send outreach.
No public contacts extracted yet. Start an extraction to review evidence.
'}No public contacts extracted yet. Start an extraction to review evidence.
';return 'Previously extracted contacts are available. Refresh to check approved public business pages again.
';} async function loadContactExtraction(id,{extract=false}={}){const state=$('contactExtractionState'),extractButton=$('extractContactsBtn'),refreshButton=$('refreshContactExtractionBtn');if(!state||!id)return;[extractButton,refreshButton].forEach(button=>{if(button)button.disabled=true;});state.innerHTML='${esc(error.message)}
PROSPECT DETAIL
${esc(p.website_domain||'no detected website')}
✓ ${esc(labelFactor(x))}${esc(p.confidence||'Medium')}
`).join(''):''}${esc(review)}
${st!=='suppressed'?'':''}${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}
`:''}`;renderContactExtractionPanel(p);loadContactExtraction(p.id);renderWebsiteScanPanel(p);renderDomainPanel(p);renderDedupPanel();} + const scoreItems = payload => payloadItems(payload, ['rules','score_rules','items','factors']); + const scoreValue = (item, keys, fallback='Unknown') => { for (const key of keys) if (item?.[key] !== undefined && item[key] !== null && item[key] !== '') return item[key]; return fallback; }; + const scoreRole = () => String(currentUser?.role || currentUser?.roles?.[0] || '').toLowerCase(); + const canEditScores = () => ['owner','admin'].includes(scoreRole()) || Boolean(currentUser?.permissions?.includes?.('score_rules:write')); + const scoreExplanation = factor => typeof factor === 'string' ? {name:labelFactor(factor), points:scoreValue(({named_business:20,business_site:30,email:25,phone:15,description:10}),[factor],0)} : factor || {}; + function renderScoreConfig(payload) { const panel=$('scoreRulesPanel'); if(!panel)return; const rules=scoreItems(payload), version=scoreValue(payload,['version','score_version','scoring_version'],'Unknown'), editable=canEditScores(); panel.innerHTML=`CONFIGURATION
Rules are workspace configuration. ${editable?'Changes require an authorized admin action.':'Your role can view rules but cannot edit them.'}
No score rules returned by the workspace.
'}Configuration version: ${esc(version)}
`; } + function renderScoreDistribution(payload) { const panel=$('scoreDistributionPanel'); if(!panel)return; const buckets=payloadItems(payload,['distribution','buckets','items']); const bandMap=payload?.bands&&typeof payload.bands==='object'&&!Array.isArray(payload.bands)?payload.bands:{}; const summary=payload?.summary||payload||{}; const fallback=[['High priority',summary.high_priority??bandMap.high??0],['Medium priority',bandMap.medium??0],['Low priority',bandMap.low??0],['Ineligible',bandMap.ineligible??0]]; const rows=buckets.length?buckets.map(item=>[scoreValue(item,['label','band','name'],'Score band'),scoreValue(item,['count','total','value'],0)]):fallback; const total=rows.reduce((n,row)=>n+Number(row[1]||0),0); panel.innerHTML=`SCORING SUMMARY
Distribution is tenant-scoped and reflects API results only.
`; } + async function loadScorePanels() { const distribution=$('scoreDistributionPanel'), rules=$('scoreRulesPanel'); if(distribution)distribution.innerHTML='${esc(error.message)}
${esc(error.message)}
SCORING
No scoring signals returned.
'}Scores are review signals only. They do not override suppression or authorize outreach.
`; + } + async function recalculateScore() { const button=$('recalculateScoreBtn'), messageEl=$('scoreMessage'); if(!button||!selectedId)return; button.disabled=true; if(messageEl)messageEl.textContent='Recalculation requested…'; try { const result=await jsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/score/recalculate`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})}); if(messageEl)messageEl.textContent=result?.status==='queued'?'Recalculation queued. Refresh when the job completes.':'Score recalculation accepted. Refresh to see the API result.'; } catch(error) { if(error.message!=='unauthorized'&&messageEl){messageEl.textContent=error.message||'Unable to recalculate score.';messageEl.className='form-message error';} } finally {button.disabled=false;} } + function renderDetail(p){const s=scoreFor(p),st=statusOf(p),f=freshness(p),factors=p.score_factors||p.factors||[],blocked=st==='review'||st==='suppressed',stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||[],domains=p.domains||[],websites=p.websites||[],evidence=p.evidence||p.evidence_timeline||[],notes=p.notes||[],review=p.review_status||p.review|| (st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1));$('detailPanel').innerHTML=`PROSPECT DETAIL
${esc(p.website_domain||'no detected website')}
✓ ${esc(labelFactor(x))}${esc(p.confidence||'Medium')}
`).join(''):''}${esc(review)}
${st!=='suppressed'?'':''}${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}
`:''}`;renderScoreBreakdown(p);renderContactExtractionPanel(p);loadContactExtraction(p.id);renderWebsiteScanPanel(p);renderDomainPanel(p);renderDedupPanel();} let mergeSource = null, mergeTarget = null, mergeBusy = false; const payloadItems = (payload, keys) => { for (const key of keys) if (Array.isArray(payload?.[key])) return payload[key]; return Array.isArray(payload) ? payload : []; }; const suggestionId = item => item.target_id ?? item.business_id ?? item.prospect_id ?? item.id; @@ -172,9 +187,9 @@ function renderCsv(rows){if(!rows.length){$('csvPreview').innerHTML='⊞No data rows found
';return;}const h=Object.keys(rows[0]);$('csvPreview').className='csv-table';$('csvPreview').innerHTML=`| ${esc(x)} | `).join('')}
|---|
| ${esc(r[x])} | `).join('')}
High-fit prospects
Freshness under 7d
PIPELINE