build prospect intelligence platform MVP

This commit is contained in:
Marco0300
2026-09-02 17:38:50 +02:00
commit 44be4efc82
29 changed files with 973 additions and 0 deletions
+10
View File
@@ -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"]
+27
View File
@@ -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.
View File
+95
View File
@@ -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())
+178
View File
@@ -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()
+28
View File
@@ -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)
);
+34
View File
@@ -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()
View File
+55
View File
@@ -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()
+46
View File
@@ -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()