build prospect intelligence platform MVP
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
FROM python:3.13-alpine
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
RUN addgroup -S app && adduser -S -G app app
|
||||
WORKDIR /app
|
||||
COPY app /app/app
|
||||
COPY schema.sql /app/schema.sql
|
||||
RUN mkdir -p /data && chown -R app:app /app /data
|
||||
USER app
|
||||
EXPOSE 8000
|
||||
CMD ["python", "/app/app/main.py", "--host", "0.0.0.0", "--port", "8000", "--db", "/data/prospects.db"]
|
||||
@@ -0,0 +1,27 @@
|
||||
# Prospect Platform API MVP
|
||||
|
||||
Dependency-light JSON API for a single demo tenant (`demo-tenant`). Core domain rules use only Python's standard library; persistence is SQLite. This MVP stores prospect evidence and scores, but never sends outreach.
|
||||
|
||||
## Run
|
||||
|
||||
From this directory:
|
||||
|
||||
```bash
|
||||
python3 app/main.py
|
||||
# listens on http://127.0.0.1:8000
|
||||
python3 -m unittest discover
|
||||
```
|
||||
|
||||
Set `PROSPECT_API_PORT` or pass `--port`; set `PROSPECT_API_DB` or pass `--db` to override the default `prospects.db`.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /api/v1/health/live` — liveness.
|
||||
- `GET /api/v1/dashboard/summary` — tenant-scoped counts and score average.
|
||||
- `GET /api/v1/businesses` — list businesses; optional `?q=` filter.
|
||||
- `POST /api/v1/businesses` — create a business from JSON (`name` required; website, email, phone, description optional). Normalization, scoring, deduplication, and suppression are enforced server-side.
|
||||
- `GET /api/v1/businesses/{id}` — retrieve one tenant-scoped business.
|
||||
- `POST /api/v1/suppressions` — add `{kind: email|domain|phone, value: ...}`. Future matching business creation is blocked.
|
||||
- `POST /api/v1/imports/preview` — preview `{rows: [...]}` without writing; reports accepted, duplicates, suppressed, normalized rows.
|
||||
|
||||
All SQL uses parameters and all responses are JSON. Scores include `score_version` and `score_factors` for traceability. There is intentionally no send/outreach endpoint.
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Pure, dependency-free prospect domain rules."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
SCORE_VERSION = "mvp-1"
|
||||
_SOCIAL = {"facebook.com", "instagram.com", "linkedin.com", "twitter.com", "x.com", "youtube.com", "tiktok.com"}
|
||||
|
||||
|
||||
def normalize_domain(value: str | None) -> str:
|
||||
value = (value or "").strip().lower()
|
||||
if not value:
|
||||
return ""
|
||||
parsed = urlparse(value if "://" in value else "//" + value)
|
||||
host = (parsed.hostname or "").strip(".")
|
||||
if host.startswith("www."):
|
||||
host = host[4:]
|
||||
return host
|
||||
|
||||
|
||||
def normalize_phone(value: str | None) -> str:
|
||||
return re.sub(r"[^0-9+]", "", (value or "").strip())
|
||||
|
||||
|
||||
def normalize_business(raw: dict) -> dict:
|
||||
name = " ".join(str(raw.get("name", "")).split())
|
||||
email = str(raw.get("email", "")).strip().lower()
|
||||
website = str(raw.get("website", "")).strip()
|
||||
domain = normalize_domain(raw.get("website_domain") or website)
|
||||
phone = normalize_phone(raw.get("phone"))
|
||||
result = dict(raw)
|
||||
result.update({"name": name, "email": email, "website": website, "website_domain": domain, "phone": phone})
|
||||
return result
|
||||
|
||||
|
||||
def classify_website(website_or_domain: str | None) -> str:
|
||||
domain = normalize_domain(website_or_domain)
|
||||
if not domain:
|
||||
return "missing"
|
||||
if any(domain == item or domain.endswith("." + item) for item in _SOCIAL):
|
||||
return "social_profile"
|
||||
return "business_site"
|
||||
|
||||
|
||||
def score_business(business: dict) -> dict:
|
||||
b = normalize_business(business)
|
||||
score = 0
|
||||
factors = []
|
||||
if b.get("name"):
|
||||
score += 20; factors.append("named_business")
|
||||
site_class = classify_website(b.get("website_domain") or b.get("website"))
|
||||
if site_class == "business_site":
|
||||
score += 30; factors.append("business_site")
|
||||
if b.get("email"):
|
||||
score += 25; factors.append("email")
|
||||
if b.get("phone"):
|
||||
score += 15; factors.append("phone")
|
||||
if b.get("description"):
|
||||
score += 10; factors.append("description")
|
||||
return {"score": min(score, 100), "score_version": SCORE_VERSION, "factors": factors, "website_class": site_class}
|
||||
|
||||
|
||||
def suppression_values(business: dict) -> set[str]:
|
||||
b = normalize_business(business)
|
||||
return {x for x in (b.get("email"), b.get("website_domain"), b.get("phone")) if x}
|
||||
|
||||
|
||||
def is_suppressed(business: dict, suppressions: list[dict]) -> bool:
|
||||
values = suppression_values(business)
|
||||
for item in suppressions:
|
||||
kind, value = item.get("kind", ""), str(item.get("value", "")).strip().lower()
|
||||
if kind == "domain": value = normalize_domain(value)
|
||||
elif kind == "phone": value = normalize_phone(value)
|
||||
if value and value in values:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def deduplication_key(business: dict) -> tuple[str, str]:
|
||||
b = normalize_business(business)
|
||||
if b["website_domain"]: return ("domain", b["website_domain"])
|
||||
if b["email"]: return ("email", b["email"])
|
||||
if b["phone"]: return ("phone", b["phone"])
|
||||
return ("name", re.sub(r"[^a-z0-9]", "", b["name"].lower()))
|
||||
|
||||
|
||||
def deduplicate_businesses(rows: list[dict]) -> list[dict]:
|
||||
chosen: dict[tuple[str, str], dict] = {}
|
||||
for raw in rows:
|
||||
item = normalize_business(raw)
|
||||
key = deduplication_key(item)
|
||||
if key not in chosen or sum(bool(v) for v in item.values()) > sum(bool(v) for v in chosen[key].values()):
|
||||
chosen[key] = item
|
||||
return list(chosen.values())
|
||||
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
if __package__ in (None, ""):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from app.domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business
|
||||
else:
|
||||
from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business
|
||||
|
||||
ORGANIZATION_ID = "demo-tenant"
|
||||
SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql"
|
||||
|
||||
|
||||
def connect(db_path: str) -> sqlite3.Connection:
|
||||
db = sqlite3.connect(db_path)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA foreign_keys = ON")
|
||||
db.executescript(SCHEMA.read_text())
|
||||
return db
|
||||
|
||||
|
||||
def row_json(row: sqlite3.Row) -> dict:
|
||||
result = dict(row)
|
||||
result["score_factors"] = json.loads(result.pop("score_factors", "[]"))
|
||||
return result
|
||||
|
||||
|
||||
def existing_businesses(db: sqlite3.Connection) -> list[dict]:
|
||||
return [row_json(r) for r in db.execute("SELECT * FROM businesses WHERE organization_id = ? ORDER BY id", (ORGANIZATION_ID,))]
|
||||
|
||||
|
||||
def suppression_rows(db: sqlite3.Connection) -> list[dict]:
|
||||
return [dict(r) for r in db.execute("SELECT kind, value FROM suppressions WHERE organization_id = ?", (ORGANIZATION_ID,))]
|
||||
|
||||
|
||||
class ApiHandler(BaseHTTPRequestHandler):
|
||||
server_version = "ProspectPlatform/0.1"
|
||||
|
||||
def send_json(self, status: int, payload: dict | list):
|
||||
body = json.dumps(payload, sort_keys=True).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080"))
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def read_json(self) -> dict:
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
value = json.loads(self.rfile.read(length) or b"{}")
|
||||
return value if isinstance(value, dict) else {}
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
def db(self):
|
||||
return connect(self.server.db_path)
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080"))
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path.rstrip("/")
|
||||
if path == "/api/v1/health/live":
|
||||
return self.send_json(200, {"status": "ok", "organization_id": ORGANIZATION_ID})
|
||||
db = self.db()
|
||||
try:
|
||||
if path == "/api/v1/dashboard/summary":
|
||||
row = db.execute("SELECT COUNT(*) AS businesses, COALESCE(AVG(score), 0) AS average_score FROM businesses WHERE organization_id = ?", (ORGANIZATION_ID,)).fetchone()
|
||||
return self.send_json(200, {"organization_id": ORGANIZATION_ID, "businesses": row["businesses"], "average_score": round(row["average_score"], 2), "suppressed": db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id = ?", (ORGANIZATION_ID,)).fetchone()[0]})
|
||||
if path == "/api/v1/businesses":
|
||||
query = parse_qs(parsed.query).get("q", [""])[0].strip()
|
||||
if query:
|
||||
like = f"%{query}%"
|
||||
rows = db.execute("SELECT * FROM businesses WHERE organization_id = ? AND (name LIKE ? OR website_domain LIKE ? OR email LIKE ?) ORDER BY score DESC, id", (ORGANIZATION_ID, like, like, like)).fetchall()
|
||||
else:
|
||||
rows = db.execute("SELECT * FROM businesses WHERE organization_id = ? ORDER BY score DESC, id", (ORGANIZATION_ID,)).fetchall()
|
||||
return self.send_json(200, {"organization_id": ORGANIZATION_ID, "items": [row_json(r) for r in rows]})
|
||||
if path.startswith("/api/v1/businesses/"):
|
||||
ident = path.rsplit("/", 1)[1]
|
||||
if not ident.isdigit(): return self.send_json(404, {"error": "not_found"})
|
||||
row = db.execute("SELECT * FROM businesses WHERE id = ? AND organization_id = ?", (int(ident), ORGANIZATION_ID)).fetchone()
|
||||
return self.send_json(200, row_json(row)) if row else self.send_json(404, {"error": "not_found"})
|
||||
return self.send_json(404, {"error": "not_found"})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def do_POST(self):
|
||||
path = urlparse(self.path).path.rstrip("/")
|
||||
payload = self.read_json()
|
||||
if path == "/api/v1/businesses": return self.create_business(payload)
|
||||
if path == "/api/v1/suppressions": return self.create_suppression(payload)
|
||||
if path == "/api/v1/imports/preview": return self.preview_import(payload)
|
||||
return self.send_json(404, {"error": "not_found"})
|
||||
|
||||
def create_business(self, payload):
|
||||
if not str(payload.get("name", "")).strip(): return self.send_json(400, {"error": "name_required"})
|
||||
business = normalize_business(payload)
|
||||
db = self.db()
|
||||
try:
|
||||
if is_suppressed(business, suppression_rows(db)): return self.send_json(409, {"error": "suppressed"})
|
||||
identity_fields = [("website_domain", business["website_domain"]), ("email", business["email"]), ("phone", business["phone"])]
|
||||
identity_fields = [(column, value) for column, value in identity_fields if value]
|
||||
if identity_fields:
|
||||
predicates = " OR ".join(f"{column} = ?" for column, _ in identity_fields)
|
||||
values = [value for _, value in identity_fields]
|
||||
if db.execute(f"SELECT id FROM businesses WHERE organization_id = ? AND ({predicates})", [ORGANIZATION_ID, *values]).fetchone():
|
||||
return self.send_json(409, {"error": "duplicate"})
|
||||
scored = score_business(business)
|
||||
cur = db.execute("INSERT INTO businesses (organization_id,name,website,website_domain,email,phone,description,score,score_version,score_factors,website_class) VALUES (?,?,?,?,?,?,?,?,?,?,?)", (ORGANIZATION_ID, business["name"], business["website"], business["website_domain"], business["email"], business["phone"], str(business.get("description", "")), scored["score"], scored["score_version"], json.dumps(scored["factors"]), scored["website_class"]))
|
||||
db.commit()
|
||||
return self.send_json(201, row_json(db.execute("SELECT * FROM businesses WHERE id = ?", (cur.lastrowid,)).fetchone()))
|
||||
finally: db.close()
|
||||
|
||||
def create_suppression(self, payload):
|
||||
kind, value = payload.get("kind"), str(payload.get("value", "")).strip().lower()
|
||||
if kind not in {"email", "domain", "phone"} or not value: return self.send_json(400, {"error": "invalid_suppression"})
|
||||
db = self.db()
|
||||
try:
|
||||
try:
|
||||
db.execute("INSERT INTO suppressions (organization_id,kind,value) VALUES (?,?,?)", (ORGANIZATION_ID, kind, value)); db.commit()
|
||||
except sqlite3.IntegrityError: pass
|
||||
row = db.execute("SELECT * FROM suppressions WHERE organization_id = ? AND kind = ? AND value = ?", (ORGANIZATION_ID, kind, value)).fetchone()
|
||||
return self.send_json(201, dict(row))
|
||||
finally: db.close()
|
||||
|
||||
def preview_import(self, payload):
|
||||
rows = payload.get("rows", [])
|
||||
if not isinstance(rows, list): return self.send_json(400, {"error": "rows_required"})
|
||||
normalized = deduplicate_businesses([r for r in rows if isinstance(r, dict) and str(r.get("name", "")).strip()])
|
||||
db = self.db()
|
||||
try:
|
||||
suppressions = suppression_rows(db); existing = existing_businesses(db); seen = set()
|
||||
accepted, duplicate, suppressed = [], 0, 0
|
||||
for b in normalized:
|
||||
key = deduplication_key(b)
|
||||
if is_suppressed(b, suppressions): suppressed += 1
|
||||
elif (key in {deduplication_key(x) for x in existing} or key in seen): duplicate += 1
|
||||
else: seen.add(key); accepted.append(b)
|
||||
return self.send_json(200, {"accepted": len(accepted), "duplicates": duplicate + len(rows) - len(normalized), "suppressed": suppressed, "rows": accepted})
|
||||
finally: db.close()
|
||||
|
||||
def log_message(self, *_): pass
|
||||
|
||||
|
||||
def create_server(host="127.0.0.1", port=8000, db_path="prospects.db"):
|
||||
server = ThreadingHTTPServer((host, port), ApiHandler)
|
||||
server.db_path = db_path
|
||||
connect(db_path).close()
|
||||
return server
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Prospect Platform API")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=int(os.environ.get("PROSPECT_API_PORT", "8000")))
|
||||
parser.add_argument("--db", default=os.environ.get("PROSPECT_API_DB", "prospects.db"))
|
||||
args = parser.parse_args()
|
||||
server = create_server(args.host, args.port, args.db)
|
||||
print(f"Prospect API listening on http://{args.host}:{args.port}", flush=True)
|
||||
try: server.serve_forever()
|
||||
except KeyboardInterrupt: pass
|
||||
finally: server.server_close()
|
||||
@@ -0,0 +1,28 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE IF NOT EXISTS businesses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
organization_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
website TEXT NOT NULL DEFAULT '',
|
||||
website_domain TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
score INTEGER NOT NULL DEFAULT 0,
|
||||
score_version TEXT NOT NULL DEFAULT 'mvp-1',
|
||||
score_factors TEXT NOT NULL DEFAULT '[]',
|
||||
website_class TEXT NOT NULL DEFAULT 'missing',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_businesses_org ON businesses(organization_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_business_domain ON businesses(organization_id, website_domain) WHERE website_domain <> '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_business_email ON businesses(organization_id, email) WHERE email <> '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_business_phone ON businesses(organization_id, phone) WHERE phone <> '';
|
||||
CREATE TABLE IF NOT EXISTS suppressions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
organization_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('email','domain','phone')),
|
||||
value TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(organization_id, kind, value)
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Small stdlib-only MVP API/container smoke-test service."""
|
||||
import json
|
||||
import os
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "ProspectPlatformMVP/0.1"
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
if self.path == "/healthz":
|
||||
self.respond(200, {"status": "ok", "outreach_enabled": False})
|
||||
elif self.path == "/":
|
||||
self.respond(200, {"service": "api", "status": "ok"})
|
||||
else:
|
||||
self.respond(404, {"error": "not_found"})
|
||||
|
||||
def respond(self, status, payload):
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
if os.environ.get("LOG_LEVEL", "INFO").upper() != "QUIET":
|
||||
super().log_message(format, *args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
host = os.environ.get("API_HOST", "0.0.0.0")
|
||||
port = int(os.environ.get("API_PORT", "8000"))
|
||||
ThreadingHTTPServer((host, port), Handler).serve_forever()
|
||||
@@ -0,0 +1,55 @@
|
||||
import json
|
||||
import threading
|
||||
import unittest
|
||||
from http.client import HTTPConnection
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from app.main import create_server
|
||||
|
||||
|
||||
class ApiSmokeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = TemporaryDirectory()
|
||||
self.server = create_server("127.0.0.1", 0, self.tmp.name + "/test.db")
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.conn = HTTPConnection("127.0.0.1", self.server.server_port, timeout=3)
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=2)
|
||||
self.tmp.cleanup()
|
||||
|
||||
def request(self, method, path, payload=None):
|
||||
body = json.dumps(payload).encode() if payload is not None else None
|
||||
self.conn.request(method, path, body, {"Content-Type": "application/json"} if body else {})
|
||||
response = self.conn.getresponse()
|
||||
return response.status, json.loads(response.read())
|
||||
|
||||
def test_health_create_get_summary_and_import_preview(self):
|
||||
self.assertEqual(self.request("GET", "/api/v1/health/live")[0], 200)
|
||||
status, created = self.request("POST", "/api/v1/businesses", {"name": "Acme", "website": "https://acme.co.za", "email": "a@acme.co.za"})
|
||||
self.assertEqual(status, 201)
|
||||
self.assertEqual(created["organization_id"], "demo-tenant")
|
||||
status, fetched = self.request("GET", "/api/v1/businesses/" + str(created["id"]))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(fetched["name"], "Acme")
|
||||
status, summary = self.request("GET", "/api/v1/dashboard/summary")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(summary["businesses"], 1)
|
||||
status, preview = self.request("POST", "/api/v1/imports/preview", {"rows": [{"name": "Acme", "website": "https://acme.co.za"}, {"name": "New Co"}]})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(preview["accepted"], 1)
|
||||
self.assertEqual(preview["duplicates"], 1)
|
||||
|
||||
def test_suppression_blocks_new_business(self):
|
||||
status, _ = self.request("POST", "/api/v1/suppressions", {"kind": "domain", "value": "blocked.co.za"})
|
||||
self.assertEqual(status, 201)
|
||||
status, response = self.request("POST", "/api/v1/businesses", {"name": "Blocked", "website": "https://blocked.co.za"})
|
||||
self.assertEqual(status, 409)
|
||||
self.assertEqual(response["error"], "suppressed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,46 @@
|
||||
import unittest
|
||||
|
||||
from app.domain import (
|
||||
classify_website,
|
||||
deduplicate_businesses,
|
||||
normalize_business,
|
||||
normalize_domain,
|
||||
score_business,
|
||||
is_suppressed,
|
||||
)
|
||||
|
||||
|
||||
class DomainTests(unittest.TestCase):
|
||||
def test_normalization_canonicalizes_contact_and_domain(self):
|
||||
value = normalize_business({"name": " Acme ", "website": "HTTPS://WWW.Acme.co.za/path", "email": " SALES@ACME.CO.ZA "})
|
||||
self.assertEqual(value["name"], "Acme")
|
||||
self.assertEqual(value["website_domain"], "acme.co.za")
|
||||
self.assertEqual(value["email"], "sales@acme.co.za")
|
||||
|
||||
def test_website_classification_is_conservative(self):
|
||||
self.assertEqual(classify_website("https://acme.co.za"), "business_site")
|
||||
self.assertEqual(classify_website("https://www.facebook.com/acme"), "social_profile")
|
||||
self.assertEqual(classify_website(""), "missing")
|
||||
|
||||
def test_scoring_is_versioned_and_explains_factors(self):
|
||||
result = score_business({"name": "Acme", "website_domain": "acme.co.za", "email": "a@acme.co.za", "phone": "123"})
|
||||
self.assertEqual(result["score_version"], "mvp-1")
|
||||
self.assertGreaterEqual(result["score"], 70)
|
||||
self.assertIn("business_site", result["factors"])
|
||||
|
||||
def test_suppression_matches_email_domain_or_phone(self):
|
||||
business = {"email": "person@example.com", "website_domain": "example.com", "phone": "+27123456789"}
|
||||
self.assertTrue(is_suppressed(business, [{"kind": "domain", "value": "example.com"}]))
|
||||
self.assertTrue(is_suppressed(business, [{"kind": "email", "value": "person@example.com"}]))
|
||||
self.assertTrue(is_suppressed(business, [{"kind": "phone", "value": "+27123456789"}]))
|
||||
self.assertFalse(is_suppressed(business, [{"kind": "email", "value": "other@example.com"}]))
|
||||
|
||||
def test_deduplication_prefers_richer_record(self):
|
||||
rows = [{"name": "Acme", "website": "https://acme.com"}, {"name": " acme ", "website": "https://www.acme.com", "email": "a@acme.com"}]
|
||||
result = deduplicate_businesses(rows)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]["email"], "a@acme.com")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM python:3.13-alpine
|
||||
RUN addgroup -S app && adduser -S -G app app
|
||||
WORKDIR /srv
|
||||
COPY index.html /srv/index.html
|
||||
COPY styles.css /srv/styles.css
|
||||
COPY app.js /srv/app.js
|
||||
COPY healthz /srv/healthz
|
||||
RUN chown -R app:app /srv
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
CMD ["python", "-m", "http.server", "8080", "--bind", "0.0.0.0", "--directory", "/srv"]
|
||||
@@ -0,0 +1,32 @@
|
||||
# ProspectOS web MVP
|
||||
|
||||
Self-contained static frontend for the Prospect Platform API. There is no bundler or runtime dependency: serve this directory with any static HTTP server.
|
||||
|
||||
## Configure and run
|
||||
|
||||
The API base is configurable before `app.js` runs:
|
||||
|
||||
```html
|
||||
<script>window.API_BASE = 'http://127.0.0.1:8000';</script>
|
||||
<script src="app.js"></script>
|
||||
```
|
||||
|
||||
If not set, the UI uses `localStorage.prospect_api_base` when present, then renders clearly-labelled demo data. The API contract used by this page is the current MVP contract:
|
||||
|
||||
- `GET /api/v1/businesses` (optional filtering is performed client-side)
|
||||
- `GET /api/v1/dashboard/summary`
|
||||
- `POST /api/v1/businesses`
|
||||
|
||||
The CSV control is intentionally preview-only. The backend's `POST /api/v1/imports/preview` can be wired to a confirmation flow later; this UI does not claim that import rows have been persisted.
|
||||
|
||||
## Browser verification
|
||||
|
||||
1. Start the API from `apps/api` with `python3 app/main.py`.
|
||||
2. Serve this directory: `python3 -m http.server 8080 --directory apps/web`.
|
||||
3. Open `http://127.0.0.1:8080`, with `window.API_BASE` set to `http://127.0.0.1:8000` using a tiny pre-load edit or browser devtools.
|
||||
4. Confirm the header changes to **API connected**, metrics populate, search and score/status filters update the table, selecting a row opens evidence/confidence/freshness, and adding a prospect POSTs to `/api/v1/businesses`.
|
||||
5. Confirm rows with `status: review` show **Outreach unavailable — Review this prospect before outreach is available**, and suppressed rows show **Outreach unavailable — Suppressed records cannot be contacted**. There is no outreach/send endpoint or button.
|
||||
6. Select a CSV and confirm a local, preview-only table appears without a network request.
|
||||
7. Resize below 700px to verify the collapsible nav, stacked panels, and horizontally scrollable explorer table.
|
||||
|
||||
A zero-dependency static smoke page (`smoke-test.html`) checks the key DOM contract in an iframe and reports pass/fail.
|
||||
@@ -0,0 +1,60 @@
|
||||
/* ProspectOS frontend MVP. Configure before loading with window.API_BASE = 'http://127.0.0.1:8000'; */
|
||||
(() => {
|
||||
'use strict';
|
||||
const API_BASE = (window.API_BASE || localStorage.getItem('prospect_api_base') || '').replace(/\/$/, '');
|
||||
const endpoint = (path) => `${API_BASE}${path}`;
|
||||
const demoProspects = [
|
||||
{id:1,name:'Northstar Creative',website:'https://northstarcreative.co.za',website_domain:'northstarcreative.co.za',location:'Cape Town, ZA',score:92,score_factors:['named_business','business_site','email','phone'],email:'hello@northstarcreative.co.za',phone:'+27215550101',updated_at:'2026-08-31T09:00:00Z',status:'reviewed',confidence:'High'},
|
||||
{id:2,name:'Berg & Bloom',website:'https://bergandbloom.co.za',website_domain:'bergandbloom.co.za',location:'Johannesburg, ZA',score:78,score_factors:['named_business','business_site','description'],description:'Independent retail studio',updated_at:'2026-08-29T09:00:00Z',status:'review',confidence:'Medium'},
|
||||
{id:3,name:'Mosaic Studio',website:'',website_domain:'',location:'Durban, ZA',score:45,score_factors:['named_business'],updated_at:'2026-08-12T09:00:00Z',status:'review',confidence:'Low'},
|
||||
{id:4,name:'Cedar Works',website:'https://cedarworks.co.za',website_domain:'cedarworks.co.za',location:'Pretoria, ZA',score:83,score_factors:['named_business','business_site','phone'],phone:'+27125550102',updated_at:'2026-08-30T09:00:00Z',status:'reviewed',confidence:'High'},
|
||||
{id:5,name:'Studio Lumen',website:'https://instagram.com/studiolumen',website_domain:'instagram.com',location:'Gqeberha, ZA',score:55,score_factors:['named_business'],updated_at:'2026-08-20T09:00:00Z',status:'suppressed',suppressed:true,suppression_reason:'Suppressed by domain match',confidence:'Low'},
|
||||
{id:6,name:'Field Notes Co.',website:'https://fieldnotes.example',website_domain:'fieldnotes.example',location:'Cape Town, ZA',score:67,score_factors:['named_business','business_site'],updated_at:'2026-08-25T09:00:00Z',status:'review',confidence:'Medium'}
|
||||
];
|
||||
let prospects = [];
|
||||
let selectedId = null;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const scoreClass = (score) => score >= 80 ? 'high' : score >= 60 ? 'medium' : 'low';
|
||||
const statusOf = (p) => p.suppressed || p.status === 'suppressed' ? 'suppressed' : (p.reviewed || p.status === 'reviewed' || p.reviewed_at ? 'reviewed' : 'review');
|
||||
const freshness = (p) => {
|
||||
const raw = p.updated_at || p.last_checked_at || p.created_at;
|
||||
if (!raw) return {label:'Unknown', cls:'stale'};
|
||||
const days = Math.max(0, Math.floor((Date.now() - new Date(raw).getTime()) / 86400000));
|
||||
return {label: days === 0 ? 'Today' : `${days}d ago`, cls: days <= 7 ? 'good' : 'stale'};
|
||||
};
|
||||
const scoreFor = (p) => Number.isFinite(Number(p.score)) ? Number(p.score) : (p.score_factors || p.factors || []).reduce((n, f) => n + ({named_business:20,business_site:30,email:25,phone:15,description:10}[f] || 0), 0);
|
||||
const labelFactor = (f) => ({named_business:'Named business',business_site:'Business site',email:'Email found',phone:'Phone found',description:'Description'}[f] || String(f).replaceAll('_',' '));
|
||||
function renderMetrics(summary) {
|
||||
const total = Number(summary?.businesses ?? summary?.total ?? prospects.length);
|
||||
const high = prospects.filter(p => scoreFor(p) >= 80).length;
|
||||
const review = prospects.filter(p => statusOf(p) === 'review').length;
|
||||
const fresh = prospects.length ? Math.round(prospects.filter(p => freshness(p).cls === 'good').length / prospects.length * 100) : 0;
|
||||
$('metricTotal').textContent = total; $('heroCount').textContent = `${total} prospects`;
|
||||
$('metricReview').textContent = summary?.needs_review ?? review; $('metricHigh').textContent = summary?.high_fit ?? high; $('metricFresh').textContent = `${summary?.freshness_under_7d ?? fresh}%`;
|
||||
}
|
||||
function filtered() {
|
||||
const q = $('searchInput').value.trim().toLowerCase(), sf = $('scoreFilter').value, st = $('statusFilter').value;
|
||||
return prospects.filter(p => { const s=scoreFor(p), text=`${p.name} ${p.website_domain||p.website||''} ${p.location||''}`.toLowerCase(); return (!q || text.includes(q)) && (sf==='all' || (sf==='high'&&s>=80) || (sf==='medium'&&s>=60&&s<80) || (sf==='low'&&s<60)) && (st==='all' || statusOf(p)===st); });
|
||||
}
|
||||
function renderRows() {
|
||||
const rows = filtered(); $('resultCount').textContent = `Showing ${rows.length} prospect${rows.length===1?'':'s'}`;
|
||||
$('prospectRows').innerHTML = rows.length ? rows.map(p => { const s=scoreFor(p), f=freshness(p), st=statusOf(p), factors=p.score_factors||p.factors||[]; return `<tr data-id="${esc(p.id)}" class="${p.id===selectedId?'selected':''}"><td>${esc(p.name)}<span class="company-sub">${esc(p.website_domain || 'no detected website')}</span></td><td><span class="score ${scoreClass(s)}">${s} <small>/ 100</small></span></td><td class="evidence"><strong>${factors.length} signal${factors.length===1?'':'s'}</strong>${esc(factors.slice(0,2).map(labelFactor).join(' · ') || 'Limited evidence')}</td><td><span class="fresh ${f.cls}">${f.label}</span></td><td><span class="status ${st}">${st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1)}</span></td><td class="row-arrow">›</td></tr>`; }).join('') : '<tr><td colspan="6" class="muted">No prospects match these filters.</td></tr>';
|
||||
document.querySelectorAll('#prospectRows tr[data-id]').forEach(row => row.addEventListener('click', () => { selectedId = Number(row.dataset.id); renderRows(); renderDetail(); }));
|
||||
}
|
||||
function renderDetail() {
|
||||
const p = prospects.find(x => Number(x.id) === Number(selectedId)); if (!p) return;
|
||||
const s=scoreFor(p), st=statusOf(p), f=freshness(p), factors=p.score_factors||p.factors||[], blocked=st==='review' || st==='suppressed';
|
||||
$('detailPanel').innerHTML = `<div class="detail-head"><div><p class="eyebrow">PROSPECT DETAIL</p><h3>${esc(p.name)}</h3><p class="detail-domain">${esc(p.website_domain || 'no detected website')}</p></div><span class="status ${st}">${st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1)}</span></div><div class="detail-score"><div><small>Fit score</small><b>${s}<small>/ 100</small></b></div><span class="score ${scoreClass(s)}">${esc(p.confidence || (s>=80?'High':s>=60?'Medium':'Low'))} confidence</span></div><div class="detail-block"><h4>Evidence & signals</h4>${factors.length ? factors.map(x=>`<p class="evidence-line"><span>✓ ${esc(labelFactor(x))}</span><span class="confidence">${esc(p.confidence || 'Medium')}</span></p>`).join('') : '<p>Limited evidence available for this record.</p>'}</div><div class="detail-block"><h4>Data quality</h4><p class="evidence-line"><span>Last checked</span><span>${f.label}</span></p><p class="evidence-line"><span>Website</span><span>${p.website_domain?'Detected':'no detected website'}</span></p></div>${blocked?`<button class="button disabled-action" disabled aria-disabled="true">Outreach unavailable</button><p class="disabled-reason">${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}</p>`:''}`;
|
||||
}
|
||||
async function loadData() {
|
||||
$('apiStatus').textContent = API_BASE ? '● Connecting…' : '● Demo data';
|
||||
try { const [listRes, summaryRes] = await Promise.all([fetch(endpoint('/api/v1/businesses')), fetch(endpoint('/api/v1/dashboard/summary'))]); if (!listRes.ok || !summaryRes.ok) throw new Error('API request failed'); const list=await listRes.json(), summary=await summaryRes.json(); prospects=Array.isArray(list)?list:(list.businesses||list.items||[]); renderMetrics(summary); $('apiStatus').textContent='● API connected'; $('apiStatus').classList.add('live'); } catch (error) { prospects=demoProspects; renderMetrics(null); $('apiStatus').textContent=API_BASE?'● API unavailable · demo data':'● Demo data'; }
|
||||
renderRows();
|
||||
}
|
||||
async function addProspect(event) { event.preventDefault(); const data=Object.fromEntries(new FormData(event.currentTarget).entries()); const msg=$('formMessage'); if (!API_BASE) { prospects.unshift({...data,id:`local-${Date.now()}`,score:data.website?50:20,status:'review',confidence:'Low'}); msg.textContent='Added to local preview review queue.'; event.currentTarget.reset(); renderMetrics(null); renderRows(); return; } try { const res=await fetch(endpoint('/api/v1/businesses'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}); const body=await res.json(); if(!res.ok) throw new Error(body.error||'Could not add prospect'); prospects.unshift(body); msg.textContent='Added to review queue.'; event.currentTarget.reset(); renderMetrics(null); renderRows(); } catch(e) { msg.textContent=e.message; msg.className='form-message error'; } }
|
||||
function parseCsv(text) { const lines=text.trim().split(/\r?\n/).filter(Boolean), cells=line=>line.match(/("[^"]*(?:""[^"]*)*"|[^,]+)(?=,|$)/g)?.map(x=>x.replace(/^"|"$/g,'').replaceAll('""','"'))||[]; if(!lines.length)return []; const headers=cells(lines[0]); return lines.slice(1,11).map(l=>Object.fromEntries(cells(l).map((v,i)=>[headers[i]||`column_${i+1}`,v]))); }
|
||||
function renderCsv(rows) { if(!rows.length){$('csvPreview').innerHTML='<span>⊞</span><p>No data rows found</p>';return;} const h=Object.keys(rows[0]); $('csvPreview').className='csv-table'; $('csvPreview').innerHTML=`<table><thead><tr>${h.map(x=>`<th>${esc(x)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${h.map(x=>`<td>${esc(r[x])}</td>`).join('')}</tr>`).join('')}</tbody></table><small class="muted">Showing up to 10 rows · Preview only; nothing added yet.</small>`; }
|
||||
$('searchInput').addEventListener('input',renderRows); $('scoreFilter').addEventListener('change',renderRows); $('statusFilter').addEventListener('change',renderRows); $('refreshBtn').addEventListener('click',loadData); $('addForm').addEventListener('submit',addProspect); $('csvInput').addEventListener('change',e=>{const file=e.target.files[0]; if(file){const reader=new FileReader();reader.onload=()=>renderCsv(parseCsv(reader.result));reader.readAsText(file);}}); $('menuBtn').addEventListener('click',()=>document.querySelector('.sidebar').classList.toggle('open')); document.querySelectorAll('[data-scroll]').forEach(b=>b.addEventListener('click',()=>document.querySelector(b.dataset.scroll)?.scrollIntoView()));
|
||||
loadData();
|
||||
})();
|
||||
@@ -0,0 +1 @@
|
||||
ok
|
||||
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ProspectOS · Pipeline intelligence</title>
|
||||
<meta name="description" content="Prospect discovery and review dashboard">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="#top" aria-label="ProspectOS home"><span class="brand-mark">✦</span><span>Prospect<span class="brand-light">OS</span></span></a>
|
||||
<nav aria-label="Primary navigation">
|
||||
<a class="nav-item active" href="#dashboard"><span>▦</span> Dashboard</a>
|
||||
<a class="nav-item" href="#explorer"><span>⌕</span> Prospect explorer</a>
|
||||
<a class="nav-item" href="#add"><span>+</span> Add prospects</a>
|
||||
</nav>
|
||||
<div class="sidebar-foot"><div class="live-dot"></div><div><strong>Workspace live</strong><small>Data sync is healthy</small></div></div>
|
||||
</aside>
|
||||
<main class="main" id="top">
|
||||
<header class="topbar"><button class="mobile-menu" id="menuBtn" aria-label="Toggle navigation">☰</button><div class="crumb">Workspace <span>/</span> Growth pipeline</div><div class="top-actions"><span class="api-status" id="apiStatus">● Demo data</span><button class="icon-button" aria-label="Notifications">♢</button><div class="avatar">AR</div></div></header>
|
||||
<div class="content">
|
||||
<section class="hero" id="dashboard"><div><p class="eyebrow">EVIDENCE-LED PROSPECTING</p><h1>Good morning, Alex <span>✦</span></h1><p class="hero-sub">Your pipeline has <strong id="heroCount">0 prospects</strong> ready for review.</p></div><button class="button primary" data-scroll="#add">+ Add prospect</button></section>
|
||||
<section class="metrics" aria-label="Dashboard metrics">
|
||||
<article class="metric-card"><div class="metric-icon violet">◎</div><div><p>Total prospects</p><h2 id="metricTotal">0</h2><span class="trend neutral">● Current workspace</span></div></article>
|
||||
<article class="metric-card"><div class="metric-icon amber">◌</div><div><p>Needs review</p><h2 id="metricReview">0</h2><span class="trend neutral">● Human verification</span></div></article>
|
||||
<article class="metric-card"><div class="metric-icon green">◉</div><div><p>High-fit prospects</p><h2 id="metricHigh">0</h2><span class="trend neutral">● Score 80+</span></div></article>
|
||||
<article class="metric-card"><div class="metric-icon blue">◷</div><div><p>Freshness under 7d</p><h2 id="metricFresh">0%</h2><span class="trend neutral">● Evidence coverage</span></div></article>
|
||||
</section>
|
||||
<section class="workspace-grid" id="explorer">
|
||||
<div class="explorer-panel panel"><div class="panel-heading"><div><p class="eyebrow">PIPELINE</p><h2>Prospect explorer</h2></div><button class="button ghost" id="refreshBtn">↻ Refresh</button></div>
|
||||
<div class="filters"><label class="search-wrap"><span>⌕</span><input id="searchInput" type="search" placeholder="Search companies, domains, locations…" autocomplete="off"></label><select id="scoreFilter" aria-label="Filter by score"><option value="all">All scores</option><option value="high">High fit · 80+</option><option value="medium">Medium · 60–79</option><option value="low">Low · under 60</option></select><select id="statusFilter" aria-label="Filter by status"><option value="all">All statuses</option><option value="review">Needs review</option><option value="reviewed">Reviewed</option><option value="suppressed">Suppressed</option></select></div>
|
||||
<div class="table-meta"><span id="resultCount">Showing 0 prospects</span><span class="legend"><span class="legend-dot high-dot"></span> High fit <span class="legend-dot review-dot"></span> Needs review</span></div>
|
||||
<div class="table-scroll"><table><thead><tr><th>Company</th><th>Fit score</th><th>Evidence</th><th>Freshness</th><th>Status</th><th></th></tr></thead><tbody id="prospectRows"></tbody></table></div>
|
||||
</div>
|
||||
<aside class="detail-panel panel" id="detailPanel"><div class="empty-detail"><span class="empty-icon">◒</span><h3>Select a prospect</h3><p>Review evidence, confidence, and eligibility before taking action.</p></div></aside>
|
||||
</section>
|
||||
<section class="lower-grid" id="add"><article class="panel add-panel"><div class="panel-heading"><div><p class="eyebrow">INTAKE</p><h2>Add a prospect</h2></div><span class="small-label">Manual entry</span></div><form id="addForm"><div class="form-grid"><label>Company name<input required name="name" placeholder="Acme Inc."></label><label>Website <span class="optional">optional</span><input name="website" type="url" placeholder="https://acme.com"></label><label>Location<input name="location" placeholder="Cape Town, ZA"></label><label>Notes <span class="optional">optional</span><input name="description" placeholder="Why this is a fit…"></label></div><div class="form-footer"><p id="formMessage" class="form-message" role="status"></p><button class="button primary" type="submit">Add to review queue</button></div></form></article>
|
||||
<article class="panel csv-panel"><div class="panel-heading"><div><p class="eyebrow">BULK INTAKE</p><h2>CSV preview</h2></div><label class="button ghost upload-label" for="csvInput">↑ Choose CSV</label><input id="csvInput" type="file" accept=".csv,text/csv" hidden></div><p class="muted">Preview rows before adding them to your review queue.</p><div id="csvPreview" class="csv-empty"><span>⊞</span><p>No file selected</p><small>CSV stays in your browser until you confirm.</small></div></article></section>
|
||||
<footer><span>ProspectOS</span><span>Evidence-led prospecting · <a href="#explorer">Review queue</a></span></footer>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><meta charset="utf-8"><title>ProspectOS smoke test</title><style>body{font:16px system-ui;padding:2rem;background:#f7f8fb;color:#172033}li{margin:.5rem 0}.pass{color:#16845b}.fail{color:#b84d55}</style><h1>ProspectOS static smoke test</h1><p id="summary">Running…</p><ul id="checks"></ul><iframe id="app" src="index.html" hidden></iframe><script>const checks=[['Dashboard metrics',d=>!!d.querySelector('#metricTotal')],['Explorer table',d=>!!d.querySelector('#prospectRows')],['Add prospect form',d=>!!d.querySelector('#addForm')],['CSV preview control',d=>!!d.querySelector('#csvInput')],['No outreach/send controls',d=>!Array.from(d.querySelectorAll('button')).some(x=>/outreach|send/i.test(x.textContent)&&!x.disabled)]];const frame=document.querySelector('#app');frame.onload=()=>setTimeout(()=>{const d=frame.contentDocument;let passed=0;document.querySelector('#checks').innerHTML=checks.map(([name,test])=>{const ok=test(d);if(ok)passed++;return `<li class="${ok?'pass':'fail'}">${ok?'PASS':'FAIL'} — ${name}</li>`}).join('');document.querySelector('#summary').textContent=`${passed}/${checks.length} checks passed`;},500);</script>
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user