Compare commits

...
3 Commits
Author SHA1 Message Date
Marco0300 cb31f2dd04 replace default scoring with opportunity model
CI / compose (push) Successful in 13m53s
2026-09-03 23:21:35 +02:00
Marco0300 00bd49a894 harden dashboard API contracts and proxy errors 2026-09-03 23:20:47 +02:00
Marco0300 594da00240 correct authenticated dashboard and source registry 2026-09-03 23:19:02 +02:00
14 changed files with 309 additions and 72 deletions
+14 -13
View File
@@ -7,12 +7,12 @@ from pathlib import Path
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
if __package__ in (None, ""): if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business, normalize_domain, normalize_phone, match_businesses from app.domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, normalize_domain, normalize_phone, match_businesses
from app.sources import adapter_for, contains_secret, available_adapters, normalize_record, circuit_is_open from app.sources import adapter_for, contains_secret, available_adapters, normalize_record, circuit_is_open
from app.domain_intelligence import normalize_registrable_domain, resolve_domain, generate_candidate_domains from app.domain_intelligence import normalize_registrable_domain, resolve_domain, generate_candidate_domains
from app.website_scanner import scan_website, validate_url from app.website_scanner import scan_website, validate_url
from app.contact_extractor import extract_contacts, MAX_HTML_BYTES, MAX_RESULTS 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 from app.scoring import DEFAULT_RULES, signals_for_business, evaluate_score, score_business_opportunity, SCORE_VERSION
from app.ai_assistance import generate as generate_ai, input_fingerprint, provider_status, MAX_INPUT_ITEMS, MAX_OUTPUT_CHARS from app.ai_assistance import generate as generate_ai, input_fingerprint, provider_status, MAX_INPUT_ITEMS, MAX_OUTPUT_CHARS
from app.discovery import discover as scoped_discover from app.discovery import discover as scoped_discover
from app.ai_research import provider_status as ai_research_provider_status, configure_db as configure_ai_research_db, validate_criteria as validate_ai_research_criteria, AIResearchConfigError from app.ai_research import provider_status as ai_research_provider_status, configure_db as configure_ai_research_db, validate_criteria as validate_ai_research_criteria, AIResearchConfigError
@@ -20,12 +20,12 @@ if __package__ in (None, ""):
from app.config import load_config from app.config import load_config
from app.provider_config import validate_payload as validate_remote_provider, encrypt as encrypt_provider_secret, decrypt as decrypt_provider_secret, safe_status as remote_provider_status, test_connectivity as test_remote_connectivity from app.provider_config import validate_payload as validate_remote_provider, encrypt as encrypt_provider_secret, decrypt as decrypt_provider_secret, safe_status as remote_provider_status, test_connectivity as test_remote_connectivity
else: else:
from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business, normalize_domain, normalize_phone, match_businesses from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, normalize_domain, normalize_phone, match_businesses
from .sources import adapter_for, contains_secret, available_adapters, normalize_record, circuit_is_open from .sources import adapter_for, contains_secret, available_adapters, normalize_record, circuit_is_open
from .domain_intelligence import normalize_registrable_domain, resolve_domain, generate_candidate_domains from .domain_intelligence import normalize_registrable_domain, resolve_domain, generate_candidate_domains
from .website_scanner import scan_website, validate_url from .website_scanner import scan_website, validate_url
from .contact_extractor import extract_contacts, MAX_HTML_BYTES, MAX_RESULTS from .contact_extractor import extract_contacts, MAX_HTML_BYTES, MAX_RESULTS
from .scoring import DEFAULT_RULES, signals_for_business, evaluate_score, SCORE_VERSION from .scoring import DEFAULT_RULES, signals_for_business, evaluate_score, score_business_opportunity, SCORE_VERSION
from .ai_assistance import generate as generate_ai, input_fingerprint, provider_status, MAX_INPUT_ITEMS, MAX_OUTPUT_CHARS from .ai_assistance import generate as generate_ai, input_fingerprint, provider_status, MAX_INPUT_ITEMS, MAX_OUTPUT_CHARS
from .discovery import discover as scoped_discover from .discovery import discover as scoped_discover
from .ai_research import provider_status as ai_research_provider_status, configure_db as configure_ai_research_db, validate_criteria as validate_ai_research_criteria, AIResearchConfigError from .ai_research import provider_status as ai_research_provider_status, configure_db as configure_ai_research_db, validate_criteria as validate_ai_research_criteria, AIResearchConfigError
@@ -839,10 +839,11 @@ class ApiHandler(BaseHTTPRequestHandler):
return self.send_json(200,{"business_id":bid,"status":"unknown","reason":"not_configured","provider_configured":False,"items":[{"domain":d,"status":"unknown","reason":"not_configured"} for d in domains]}) return self.send_json(200,{"business_id":bid,"status":"unknown","reason":"not_configured","provider_configured":False,"items":[{"domain":d,"status":"unknown","reason":"not_configured"} for d in domains]})
def list_jobs(self, db, org, query): def list_jobs(self, db, org, query):
try: limit=max(1,min(int(query.get("page_size",[50])[0]),JOB_PAGE_SIZE)); offset=max(0,int(query.get("offset",[0])[0])) try:
limit=max(1,min(int(query.get("page_size",[50])[0]),JOB_PAGE_SIZE)); page=max(1,int(query.get("page",[1])[0])); offset=max(0,int(query.get("offset",[0])[0]))+(page-1)*limit
except (ValueError, TypeError): return self.send_json(400,{"error":"invalid_pagination"}) except (ValueError, TypeError): return self.send_json(400,{"error":"invalid_pagination"})
rows=db.execute("SELECT * FROM jobs WHERE organization_id=? ORDER BY id DESC LIMIT ? OFFSET ?",(org,limit+1,offset)).fetchall(); more=len(rows)>limit rows=db.execute("SELECT * FROM jobs WHERE organization_id=? ORDER BY id DESC LIMIT ? OFFSET ?",(org,limit+1,offset)).fetchall(); more=len(rows)>limit
return self.send_json(200,{"organization_id":org,"items":[job_json(r) for r in rows[:limit]],"limit":limit,"offset":offset,"has_more":more}) return self.send_json(200,{"organization_id":org,"items":[job_json(r) for r in rows[:limit]],"limit":limit,"offset":offset,"page":page,"page_size":limit,"has_more":more,"has_next":more,"next_page":page+1 if more else None})
def _discovery_run_json(self, row): def _discovery_run_json(self, row):
item = row_json(row) item = row_json(row)
@@ -1038,7 +1039,7 @@ class ApiHandler(BaseHTTPRequestHandler):
if stage: where.append("EXISTS (SELECT 1 FROM pipeline_entries p WHERE p.business_id=b.id AND p.organization_id=b.organization_id AND p.stage=?)"); params.append(stage) if stage: where.append("EXISTS (SELECT 1 FROM pipeline_entries p WHERE p.business_id=b.id AND p.organization_id=b.organization_id AND p.stage=?)"); params.append(stage)
offset=(number("cursor",0) or 0)+(page-1)*size offset=(number("cursor",0) or 0)+(page-1)*size
rows=db.execute("SELECT b.* FROM businesses b WHERE "+" AND ".join(where)+" ORDER BY b.score DESC,b.id LIMIT ? OFFSET ?",params+[size+1,offset]).fetchall(); more=len(rows)>size; rows=rows[:size] rows=db.execute("SELECT b.* FROM businesses b WHERE "+" AND ".join(where)+" ORDER BY b.score DESC,b.id LIMIT ? OFFSET ?",params+[size+1,offset]).fetchall(); more=len(rows)>size; rows=rows[:size]
return self.send_json(200,{"organization_id":org,"items":[row_json(r) for r in rows],"page":page,"page_size":size,"next_cursor":str(offset+size) if more else None}) return self.send_json(200,{"organization_id":org,"items":[row_json(r) for r in rows],"page":page,"page_size":size,"has_next":more,"next_page":page+1 if more else None,"next_cursor":str(offset+size) if more else None})
def bulk_review(self, payload, db, user): def bulk_review(self, payload, db, user):
ids = payload.get("ids", payload.get("business_ids")); action = str(payload.get("action", "")).strip().lower() ids = payload.get("ids", payload.get("business_ids")); action = str(payload.get("action", "")).strip().lower()
if not isinstance(ids, list) or not ids or len(ids) > 100 or any(not isinstance(i, int) or i < 1 for i in ids) or len(set(ids)) != len(ids): return self.send_json(400, {"error": "invalid_bulk_ids"}) if not isinstance(ids, list) or not ids or len(ids) > 100 or any(not isinstance(i, int) or i < 1 for i in ids) or len(set(ids)) != len(ids): return self.send_json(400, {"error": "invalid_bulk_ids"})
@@ -1179,7 +1180,7 @@ class ApiHandler(BaseHTTPRequestHandler):
if is_suppressed(b,suppressions):return self.send_json(409,{"error":"suppressed"}) if is_suppressed(b,suppressions):return self.send_json(409,{"error":"suppressed"})
fields=[(c,b[c]) for c in ("website_domain","email","phone") if b[c]] fields=[(c,b[c]) for c in ("website_domain","email","phone") if b[c]]
if fields and db.execute("SELECT id FROM businesses WHERE organization_id=? AND ("+" OR ".join(f"{c}=?" for c,_ in fields)+")",[org]+[v for _,v in fields]).fetchone():return self.send_json(409,{"error":"duplicate"}) if fields and db.execute("SELECT id FROM businesses WHERE organization_id=? AND ("+" OR ".join(f"{c}=?" for c,_ in fields)+")",[org]+[v for _,v in fields]).fetchone():return self.send_json(409,{"error":"duplicate"})
scored=score_business(b);cur=db.execute("INSERT INTO businesses(organization_id,name,website,website_domain,email,phone,description,province,city,suburb,score,score_version,score_factors,website_class) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)",(org,b["name"],b["website"],b["website_domain"],b["email"],b["phone"],str(b.get("description","")),b["province"],b["city"],b["suburb"],scored["score"],scored["score_version"],json.dumps(scored["factors"]),scored["website_class"])); self.audit(db,user,"business.created",str(cur.lastrowid));db.commit();return self.send_json(201,row_json(db.execute("SELECT * FROM businesses WHERE id=?",(cur.lastrowid,)).fetchone())) scored=score_business_opportunity(b);cur=db.execute("INSERT INTO businesses(organization_id,name,website,website_domain,email,phone,description,province,city,suburb,score,score_version,score_factors,website_class) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)",(org,b["name"],b["website"],b["website_domain"],b["email"],b["phone"],str(b.get("description","")),b["province"],b["city"],b["suburb"],scored["score"],scored["score_version"],json.dumps(scored["explanations"]),scored["website_class"])); self.audit(db,user,"business.created",str(cur.lastrowid));db.commit();return self.send_json(201,row_json(db.execute("SELECT * FROM businesses WHERE id=?",(cur.lastrowid,)).fetchone()))
def create_suppression(self,payload,db,user): def create_suppression(self,payload,db,user):
kind,value=payload.get("kind"),str(payload.get("value","")).strip().lower() 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"}) if kind not in {"email","domain","phone"} or not value:return self.send_json(400,{"error":"invalid_suppression"})
@@ -1469,11 +1470,11 @@ def _run_scoped_discovery(db, job, handler):
existing = db.execute("SELECT id FROM businesses WHERE organization_id=? AND website_domain=?", (org, b["website_domain"])).fetchone() existing = db.execute("SELECT id FROM businesses WHERE organization_id=? AND website_domain=?", (org, b["website_domain"])).fetchone()
if existing: bid = existing["id"] if existing: bid = existing["id"]
else: else:
scored = score_business(b) scored = score_business_opportunity(b, sources=["scoped_discovery"])
cur = db.execute("INSERT INTO businesses(organization_id,name,website,website_domain,email,phone,description,province,city,suburb,score,score_version,score_factors,website_class) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (org,b["name"],b["website"],b["website_domain"],b["email"],b["phone"],b.get("description", ""),b["province"],b["city"],b["suburb"],scored["score"],scored["score_version"],json.dumps(scored["factors"]),scored["website_class"])) cur = db.execute("INSERT INTO businesses(organization_id,name,website,website_domain,email,phone,description,province,city,suburb,score,score_version,score_factors,website_class) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (org,b["name"],b["website"],b["website_domain"],b["email"],b["phone"],b.get("description", ""),b["province"],b["city"],b["suburb"],scored["score"],scored["score_version"],json.dumps(scored["explanations"]),scored["website_class"]))
bid = cur.lastrowid bid = cur.lastrowid
priority = "high" if scored["score"] >= 70 else "medium" if scored["score"] >= 40 else "low" priority = "high" if scored["score"] >= 70 else "medium" if scored["score"] >= 40 else "low"
db.execute("INSERT INTO score_history(organization_id,business_id,score,eligible,priority_band,score_version,explanations_json,signals_json) VALUES(?,?,?,?,?,?,?,?)", (org,bid,scored["score"],1,priority,scored["score_version"],json.dumps(scored["factors"]),json.dumps({"source": "scoped_discovery"}, sort_keys=True))) db.execute("INSERT INTO score_history(organization_id,business_id,score,eligible,priority_band,score_version,explanations_json,signals_json) VALUES(?,?,?,?,?,?,?,?)", (org,bid,scored["score"],int(scored["eligible"]),scored["priority_band"],scored["score_version"],json.dumps(scored["explanations"]),json.dumps(scored["signals"], sort_keys=True)))
scan_ids = {} scan_ids = {}
for page in candidate.get("pages", []): for page in candidate.get("pages", []):
scan_key = hashlib.sha256((org + ":" + page["url"]).encode()).hexdigest() scan_key = hashlib.sha256((org + ":" + page["url"]).encode()).hexdigest()
@@ -1544,8 +1545,8 @@ def _run_source_discovery(db, job, handler):
existing=db.execute("SELECT * FROM businesses WHERE organization_id=? AND ((website_domain<>'' AND website_domain=?) OR (email<>'' AND email=?) OR (phone<>'' AND phone=?) OR (name=? AND city=?)) ORDER BY id LIMIT 1",(org,normalized["website_domain"],normalized["email"],normalized["phone"],normalized["name"],normalized["city"])).fetchone() existing=db.execute("SELECT * FROM businesses WHERE organization_id=? AND ((website_domain<>'' AND website_domain=?) OR (email<>'' AND email=?) OR (phone<>'' AND phone=?) OR (name=? AND city=?)) ORDER BY id LIMIT 1",(org,normalized["website_domain"],normalized["email"],normalized["phone"],normalized["name"],normalized["city"])).fetchone()
if existing: bid=existing["id"]; handler.add_job_event(db,job["id"],org,"business.matched",f"Matched business {bid}",50) if existing: bid=existing["id"]; handler.add_job_event(db,job["id"],org,"business.matched",f"Matched business {bid}",50)
else: else:
scored=score_business(normalized); cur=db.execute("INSERT INTO businesses(organization_id,name,website,website_domain,email,phone,description,province,city,suburb,score,score_version,score_factors,website_class,review_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",(org,normalized["name"],normalized["website"],normalized["website_domain"],normalized["email"],normalized["phone"],normalized.get("description",""),normalized["province"],normalized["city"],normalized["suburb"],scored["score"],scored["score_version"],json.dumps(scored["factors"]),scored["website_class"],"pending")); bid=cur.lastrowid scored=score_business_opportunity(normalized, sources=[source["kind"]]); cur=db.execute("INSERT INTO businesses(organization_id,name,website,website_domain,email,phone,description,province,city,suburb,score,score_version,score_factors,website_class,review_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",(org,normalized["name"],normalized["website"],normalized["website_domain"],normalized["email"],normalized["phone"],normalized.get("description",""),normalized["province"],normalized["city"],normalized["suburb"],scored["score"],scored["score_version"],json.dumps(scored["explanations"]),scored["website_class"],"pending")); bid=cur.lastrowid
db.execute("INSERT INTO score_history(organization_id,business_id,score,eligible,priority_band,score_version,explanations_json,signals_json) VALUES(?,?,?,?,?,?,?,?)",(org,bid,scored["score"],1,"high" if scored["score"]>=70 else "medium" if scored["score"]>=40 else "low",scored["score_version"],json.dumps(scored["factors"]),json.dumps({"source":source["kind"]}))) db.execute("INSERT INTO score_history(organization_id,business_id,score,eligible,priority_band,score_version,explanations_json,signals_json) VALUES(?,?,?,?,?,?,?,?)",(org,bid,scored["score"],int(scored["eligible"]),scored["priority_band"],scored["score_version"],json.dumps(scored["explanations"]),json.dumps(scored["signals"],sort_keys=True)))
handler.add_job_event(db,job["id"],org,"business.created",f"Created business {bid}",60) handler.add_job_event(db,job["id"],org,"business.created",f"Created business {bid}",60)
if normalized["website_domain"] and not db.execute("SELECT 1 FROM domains WHERE organization_id=? AND business_id=? AND domain=?",(org,bid,normalized["website_domain"])).fetchone(): db.execute("INSERT INTO domains(business_id,organization_id,domain,kind) VALUES(?,?,?,?)",(bid,org,normalized["website_domain"],"website")) if normalized["website_domain"] and not db.execute("SELECT 1 FROM domains WHERE organization_id=? AND business_id=? AND domain=?",(org,bid,normalized["website_domain"])).fetchone(): db.execute("INSERT INTO domains(business_id,organization_id,domain,kind) VALUES(?,?,?,?)",(bid,org,normalized["website_domain"],"website"))
if normalized["website"] and not db.execute("SELECT 1 FROM websites WHERE organization_id=? AND business_id=? AND url=?",(org,bid,normalized["website"])).fetchone(): db.execute("INSERT INTO websites(business_id,organization_id,url,website_class) VALUES(?,?,?,?)",(bid,org,normalized["website"],"business_site")) if normalized["website"] and not db.execute("SELECT 1 FROM websites WHERE organization_id=? AND business_id=? AND url=?",(org,bid,normalized["website"])).fetchone(): db.execute("INSERT INTO websites(business_id,organization_id,url,website_class) VALUES(?,?,?,?)",(bid,org,normalized["website"],"business_site"))
+80 -21
View File
@@ -1,20 +1,36 @@
"""Deterministic, explainable qualification scoring.""" """Deterministic, transparent opportunity scoring."""
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime, timezone from urllib.parse import urlparse
SCORE_VERSION = "opportunity-v1"
def _rule(code, name, points, description):
return {"code": code, "name": name, "description": description,
"condition_json": {"signal": f"opportunity.{code}", "operator": "truthy"},
"points": points, "max_applications": 1, "enabled": 1, "version": 1}
SCORE_VERSION = "phase10-1"
DEFAULT_RULES = [ 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}, _rule("no_detected_website", "No detected website", 30, "No website was detected for the business."),
{"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}, _rule("no_official_domain", "No official domain", 25, "No official business domain was corroborated."),
{"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}, _rule("no_functioning_web_service", "Domain but no functioning web service", 25, "A domain exists but no functioning web service was observed."),
{"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}, _rule("broken_website", "Broken website", 25, "The observed website is broken."),
{"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}, _rule("parked_default_placeholder", "Parked/default/placeholder website", 20, "The website is parked, default, or a placeholder."),
{"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}, _rule("public_free_mail", "Public free-mail address", 15, "A public business contact uses a free-mail provider."),
{"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}, _rule("human_reviewed_outdated", "Human-reviewed outdated website", 15, "A human reviewer marked the website outdated."),
{"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}, _rule("no_working_https", "No working HTTPS", 10, "No working HTTPS service was verified."),
_rule("severe_performance", "Severe performance issue", 10, "The website has a severe performance issue."),
_rule("active_social", "Active social presence", 10, "An active social presence was detected."),
_rule("valid_public_business_phone", "Valid public business phone", 5, "A valid public business phone is available."),
_rule("multiple_corroborating_sources", "Multiple corroborating sources", 5, "Multiple independent sources corroborate the business."),
_rule("possibly_closed", "Possibly closed", -30, "Evidence suggests the business may be closed."),
_rule("healthy_modern_website", "Healthy modern website", -30, "The website is healthy and modern."),
_rule("stale_or_uncertain", "Stale or uncertain evidence", -15, "The evidence is stale or uncertain."),
] ]
def _get(data, path): def _get(data, path):
value = data value = data
for part in str(path).split("."): for part in str(path).split("."):
@@ -22,14 +38,18 @@ def _get(data, path):
value = value.get(part) value = value.get(part)
return value return value
def _match(condition, signals): def _match(condition, signals):
if not isinstance(condition, dict): return False if not isinstance(condition, dict): return False
if "all" in condition: return all(_match(c, signals) for c in condition["all"]) 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 "any" in condition: return any(_match(c, signals) for c in condition["any"])
if "not" in condition: return not _match(condition["not"], signals) 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") path = str(condition.get("signal", "")); value = _get(signals, path)
section = signals.get(str(condition.get("signal", "")).split(".")[0], {}) op = condition.get("operator", "truthy"); expected = condition.get("value")
if isinstance(section, dict) and (section.get("stale") or section.get("uncertain")): return False section = signals.get(path.split(".")[0], {}) if isinstance(signals, dict) else {}
# Positive evidence is suppressed when its evidence section is stale/uncertain;
# the explicit opportunity.stale_or_uncertain rule remains evaluable.
if path != "opportunity.stale_or_uncertain" and 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 in ("truthy", "present"): return bool(value) if op == "truthy" else value not in (None, "", [], {})
if op == "equals": return value == expected if op == "equals": return value == expected
if op == "in": return value in (expected if isinstance(expected, list) else [expected]) if op == "in": return value in (expected if isinstance(expected, list) else [expected])
@@ -38,6 +58,7 @@ def _match(condition, signals):
except (TypeError, ValueError): return False except (TypeError, ValueError): return False
return False return False
def evaluate_score(signals, rules): def evaluate_score(signals, rules):
total = 0; explanations = [] 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))) ordered = sorted((dict(r) for r in rules), key=lambda r: (str(r.get("code", "")), int(r.get("id", 0) or 0)))
@@ -48,12 +69,10 @@ def evaluate_score(signals, rules):
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)")}) 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 {} 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" eligible = not bool(state.get("suppressed")) and str(state.get("merge_status", "active")) == "active"
if not eligible: band = "ineligible" band = "ineligible" if not eligible else "high" if total >= 70 else "medium" if total >= 40 else "low"
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} return {"score": total, "score_version": SCORE_VERSION, "eligible": eligible, "priority_band": band, "explanations": explanations}
def _condition(rule): def _condition(rule):
raw = rule.get("condition_json", {}) raw = rule.get("condition_json", {})
if isinstance(raw, str): if isinstance(raw, str):
@@ -61,7 +80,47 @@ def _condition(rule):
except (TypeError, ValueError): return {} except (TypeError, ValueError): return {}
return raw 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 {} def _has_working_https(website):
url = website.get("final_url") or website.get("input_url") or ""
return urlparse(str(url)).scheme.lower() == "https" and website.get("tls") is not False and website.get("certificate_status", "valid") not in {"invalid", "error"}
def signals_for_business(business, website=None, contacts=None, domain=None, suppressed=False, sources=None):
b = dict(business); website = dict(website or {}); contacts = contacts or []; domain = dict(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")] 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"}} free_mail = any(str(c.get("classification", "")).lower() == "free_mail" for c in public)
phone = str(b.get("phone", "") or "")
valid_phone = sum(ch.isdigit() for ch in phone) >= 7 or any(c.get("kind") == "phone" for c in public)
classification = str(website.get("classification") or b.get("website_class") or "").lower()
has_domain = bool(b.get("website_domain") or b.get("website") or domain.get("domain"))
stale = any(isinstance(x, dict) and (x.get("stale") or x.get("uncertain")) for x in (b, website, domain)) or bool(b.get("stale") or b.get("uncertain"))
closed = str(b.get("status", "")).lower() in {"closed", "possibly_closed"} or bool(b.get("possibly_closed"))
source_count = len(sources or b.get("sources", []) or [])
opportunity = {
"no_detected_website": not has_domain,
"no_official_domain": not bool(domain.get("official", domain.get("status") in {"resolved", "ok", "healthy"}) and has_domain),
"no_functioning_web_service": has_domain and classification not in {"healthy", "modern", "healthy_modern"},
"broken_website": classification == "broken",
"parked_default_placeholder": classification in {"parked", "placeholder", "default", "under_construction"},
"public_free_mail": free_mail or str(b.get("email", "")).lower().split("@")[-1] in {"gmail.com", "yahoo.com", "hotmail.com", "outlook.com", "icloud.com"},
"human_reviewed_outdated": bool(b.get("human_reviewed_outdated") or website.get("human_reviewed_outdated")),
"no_working_https": has_domain and not _has_working_https(website),
"severe_performance": str(website.get("performance", website.get("performance_severity", ""))).lower() == "severe" or bool(website.get("severe_performance")),
"active_social": bool(website.get("social_signal") or b.get("active_social")),
"valid_public_business_phone": valid_phone,
"multiple_corroborating_sources": source_count >= 2,
"possibly_closed": closed,
"healthy_modern_website": classification in {"healthy_modern", "modern"} or (classification == "healthy" and bool(website.get("modern") or website.get("modern_signal"))),
"stale_or_uncertain": stale,
}
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, "opportunity": opportunity, "state": {"verified": bool(b.get("verified")), "suppressed": bool(suppressed), "merge_status": b.get("merge_status", "active"), "merged": b.get("merge_status") == "merged"}}
def score_business_opportunity(business, website=None, contacts=None, domain=None, suppressed=False, sources=None):
"""Score a normalized record with the immutable built-in opportunity model."""
signals = signals_for_business(business, website, contacts, domain, suppressed, sources)
result = evaluate_score(signals, DEFAULT_RULES)
result["factors"] = [item["code"] for item in result["explanations"] if item["applied"]]
result["website_class"] = str((website or {}).get("classification") or business.get("website_class") or ("business_site" if business.get("website") else ""))
return result | {"signals": signals}
+95
View File
@@ -0,0 +1,95 @@
import json
import os
import threading
import unittest
from http.client import HTTPConnection
from tempfile import TemporaryDirectory
from app.main import create_server
class DashboardBootstrapContractTests(unittest.TestCase):
"""Exercise the exact independent GETs issued by the dashboard bootstrap."""
BOOTSTRAP_GETS = (
("/api/v1/businesses?page=1&page_size=10", ("items", "page", "page_size", "next_cursor")),
("/api/v1/dashboard/summary", ("businesses", "counts", "clickable_filters")),
("/api/v1/jobs", ("items", "has_more", "limit", "offset")),
("/api/v1/sources", ("items",)),
("/api/v1/sources/adapters", ("items",)),
("/api/v1/discovery-runs?page_size=50", ("items",)),
("/api/v1/scoring/summary", ("businesses", "bands")),
("/api/v1/score-rules", ("items",)),
("/api/v1/saved-filters", ("items",)),
("/api/v1/review-queue?page=1&page_size=100", ("items", "has_more", "limit", "offset")),
("/api/v1/pipeline-entries", ("items",)),
("/api/v1/reports/pipeline", ("items",)),
("/api/v1/reports/outcomes", ("items",)),
("/api/v1/reports/activity", ("items",)),
("/api/v1/suppressions", ("items",)),
("/api/v1/outreach/provider-config", ("enabled", "policy")),
("/api/v1/ai/provider-config", ("status",)),
)
def setUp(self):
self.tmp = TemporaryDirectory()
os.environ["BOOTSTRAP_ADMIN_EMAIL"] = "owner@example.test"
os.environ["BOOTSTRAP_ADMIN_PASSWORD"] = "development-password"
self.server = create_server("127.0.0.1", 0, self.tmp.name + "/dashboard.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)
self.cookie = None
status, body = self.request("POST", "/api/v1/auth/login", {"email": "owner@example.test", "password": "development-password"})
self.assertEqual(status, 200, body)
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, authenticated=True):
body = json.dumps(payload).encode() if payload is not None else None
headers = {"Content-Type": "application/json"} if body else {}
if authenticated and self.cookie:
headers["Cookie"] = self.cookie
self.conn.request(method, path, body, headers)
response = self.conn.getresponse()
set_cookie = response.getheader("Set-Cookie")
if set_cookie and "session=" in set_cookie:
self.cookie = set_cookie.split(";", 1)[0]
raw = response.read()
return response.status, json.loads(raw or b"{}")
def test_every_bootstrap_get_is_authenticated_json_and_shape_compatible(self):
failures = []
for path, required in self.BOOTSTRAP_GETS:
status, body = self.request("GET", path)
if status != 200:
failures.append(f"{path}: HTTP {status} {body}")
elif not all(key in body for key in required):
failures.append(f"{path}: missing {sorted(set(required) - set(body))} in {body}")
self.assertEqual(failures, [])
status, body = self.request("GET", "/api/v1/businesses?page=1&page_size=10")
self.assertEqual(status, 200)
self.assertIn("has_next", body)
self.assertIn("next_page", body)
status, body = self.request("GET", "/api/v1/jobs?page=1&page_size=10")
self.assertEqual(status, 200)
self.assertIn("page", body)
self.assertIn("page_size", body)
status, body = self.request("GET", "/api/v1/businesses?page_size=0")
self.assertEqual(status, 400)
self.assertEqual(body["error"], "invalid_pagination")
status, body = self.request("GET", "/api/v1/health/live", authenticated=False)
self.assertEqual(status, 200)
self.assertEqual(body["status"], "ok")
status, body = self.request("GET", "/api/v1/health/ready", authenticated=False)
self.assertEqual(status, 200)
self.assertTrue(body["ready"])
if __name__ == "__main__":
unittest.main()
+45 -5
View File
@@ -7,16 +7,54 @@ from http.client import HTTPConnection
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from app.main import create_server from app.main import create_server
from app.scoring import DEFAULT_RULES, evaluate_score from app.scoring import DEFAULT_RULES, SCORE_VERSION, evaluate_score, signals_for_business
class ScoringEngineTests(unittest.TestCase): class ScoringEngineTests(unittest.TestCase):
def test_opportunity_defaults_are_the_exact_versioned_transparent_model(self):
expected = {
"no_detected_website": 30, "no_official_domain": 25,
"no_functioning_web_service": 25, "broken_website": 25,
"parked_default_placeholder": 20, "public_free_mail": 15,
"human_reviewed_outdated": 15, "no_working_https": 10,
"severe_performance": 10, "active_social": 10,
"valid_public_business_phone": 5, "multiple_corroborating_sources": 5,
"possibly_closed": -30, "healthy_modern_website": -30,
"stale_or_uncertain": -15,
}
self.assertEqual(SCORE_VERSION, "opportunity-v1")
self.assertEqual({r["code"]: r["points"] for r in DEFAULT_RULES}, expected)
self.assertTrue(all(r["version"] == 1 and r["enabled"] == 1 for r in DEFAULT_RULES))
def test_each_opportunity_signal_applies_only_when_present_and_explains_rule_metadata(self):
for rule in DEFAULT_RULES:
signals = {"state": {"suppressed": False, "merge_status": "active"}, "opportunity": {rule["code"]: True}}
result = evaluate_score(signals, [rule])
self.assertEqual(result["score"], max(0, rule["points"]), rule["code"])
explanation = result["explanations"][0]
self.assertEqual({explanation[k] for k in ("code", "name", "points", "version")}, {rule["code"], rule["name"], rule["points"], 1})
def test_signal_extraction_maps_website_contacts_and_provenance_to_opportunity_signals(self):
signals = signals_for_business({"name": "Acme", "website": "http://acme.test", "website_domain": "acme.test", "phone": "+27123456789"},
{"classification": "parked", "social_signal": True, "performance": "severe", "human_reviewed_outdated": True},
[{"public_business": True, "classification": "free_mail", "suppressed": False, "do_not_contact": False}],
{"status": "unknown", "official": False}, False, sources=["directory", "registry"])
self.assertTrue(signals["opportunity"]["parked_default_placeholder"])
self.assertTrue(signals["opportunity"]["public_free_mail"])
self.assertTrue(signals["opportunity"]["active_social"])
self.assertTrue(signals["opportunity"]["multiple_corroborating_sources"])
def test_score_cap_and_negative_signals_are_deterministic(self):
signals = {"opportunity": {r["code"]: True for r in DEFAULT_RULES}, "state": {"suppressed": False, "merge_status": "active"}}
first = evaluate_score(signals, DEFAULT_RULES)
self.assertEqual(first["score"], 100)
self.assertEqual(first, evaluate_score(signals, list(reversed(DEFAULT_RULES))))
def test_defaults_are_deterministic_and_emit_explanations_and_band(self): 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}} signals = {"business": {"name": "Acme", "email": "a@acme.test", "website_domain": "acme.test"}, "website": {"classification": "healthy"}, "state": {"suppressed": False}}
first = evaluate_score(signals, DEFAULT_RULES) first = evaluate_score(signals, DEFAULT_RULES)
self.assertEqual(first, evaluate_score(signals, DEFAULT_RULES)) self.assertEqual(first, evaluate_score(signals, DEFAULT_RULES))
self.assertEqual(0 <= first["score"] <= 100, True) self.assertEqual(0 <= first["score"] <= 100, True)
self.assertEqual(first["priority_band"], "medium") self.assertEqual(first["priority_band"], "low")
self.assertTrue(all("code" in item and "reason" in item for item in first["explanations"])) 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): def test_disabled_and_versioned_rules_change_score_without_nondeterminism(self):
@@ -26,12 +64,14 @@ class ScoringEngineTests(unittest.TestCase):
self.assertEqual(enabled["score"], 30) self.assertEqual(enabled["score"], 30)
self.assertEqual(disabled["score"], 0) self.assertEqual(disabled["score"], 0)
def test_suppression_is_ineligible_and_stale_uncertain_signals_do_not_penalize(self): def test_suppression_is_ineligible_and_stale_uncertain_signal_is_transparent(self):
signals = {"business": {"name": "Acme"}, "website": {"classification": "unknown", "stale": True}, "domain": {"status": "error"}, "state": {"suppressed": True}} signals = {"business": {"name": "Acme"}, "website": {"classification": "unknown", "stale": True}, "domain": {"status": "error"}, "opportunity": {"stale_or_uncertain": True}, "state": {"suppressed": True}}
result = evaluate_score(signals, DEFAULT_RULES) result = evaluate_score(signals, DEFAULT_RULES)
self.assertFalse(result["eligible"]) self.assertFalse(result["eligible"])
self.assertEqual(result["priority_band"], "ineligible") self.assertEqual(result["priority_band"], "ineligible")
self.assertNotIn("negative", json.dumps(result["explanations"]).lower()) stale = next(item for item in result["explanations"] if item["code"] == "stale_or_uncertain")
self.assertTrue(stale["applied"])
self.assertEqual(stale["points"], -15)
class ScoringApiTests(unittest.TestCase): class ScoringApiTests(unittest.TestCase):
+2
View File
@@ -93,6 +93,8 @@ class SourceApiTests(unittest.TestCase):
detail = self.req('GET', f"/api/v1/businesses/{businesses[0]['id']}")[1] detail = self.req('GET', f"/api/v1/businesses/{businesses[0]['id']}")[1]
self.assertTrue(detail['domains']); self.assertTrue(detail['websites']); self.assertTrue(detail['evidence']) self.assertTrue(detail['domains']); self.assertTrue(detail['websites']); self.assertTrue(detail['evidence'])
self.assertTrue(detail['contacts']); self.assertEqual(detail['review_status'], 'pending') self.assertTrue(detail['contacts']); self.assertEqual(detail['review_status'], 'pending')
self.assertEqual(detail['score_version'], 'opportunity-v1')
self.assertTrue(all({'code', 'name', 'points', 'version'} <= set(item) for item in detail['score_factors']))
db = sqlite3.connect(self.tmp.name + '/x.db') db = sqlite3.connect(self.tmp.name + '/x.db')
self.assertEqual(db.execute('SELECT processing_status FROM source_records').fetchone()[0], 'processed') self.assertEqual(db.execute('SELECT processing_status FROM source_records').fetchone()[0], 'processed')
self.assertEqual(db.execute('SELECT status FROM enrichment_queue').fetchone()[0], 'completed') self.assertEqual(db.execute('SELECT status FROM enrichment_queue').fetchone()[0], 'completed')
+3 -3
View File
@@ -12,7 +12,7 @@ window.__PROSPECT_CONFIG__ = Object.freeze({ apiBase: 'https://api.example.inval
If `apiBase` is empty, the UI uses `window.API_BASE`, then `localStorage.prospect_api_base` when present, and otherwise targets the same origin. The dashboard requires an authenticated API session and shows the login screen until `/api/v1/auth/me` succeeds. Do not put tokens, passwords, or private keys in `config.js`. If `apiBase` is empty, the UI uses `window.API_BASE`, then `localStorage.prospect_api_base` when present, and otherwise targets the same origin. The dashboard requires an authenticated API session and shows the login screen until `/api/v1/auth/me` succeeds. Do not put tokens, passwords, or private keys in `config.js`.
`asset-manifest.json` records the public entrypoints, cache-busting version, and SHA-256 digests for release verification. The HTML references the static assets with the `phase-15` version query string; update those references and regenerate the manifest when changing the release version. `asset-manifest.json` records the public entrypoints, cache-busting version, and SHA-256 digests for release verification. The HTML references the static assets with the current release version query string; update those references and regenerate the manifest when changing the release version.
## Deployment readiness checks ## Deployment readiness checks
@@ -51,9 +51,9 @@ The browser must not directly fetch arbitrary target URLs, follow redirects for
## Phase 5 source UI contract ## Phase 5 source UI contract
The web client may display registered source metadata, query mode, approval/terms state, rate-limit status, retention class, health, and circuit state returned by the API. It must label `dry_run` as a plan/validation result and distinguish operator-supplied CSV/manual references from independently verified evidence. It must not offer a live-source control unless the API reports explicit approval and operational enablement; client visibility is never an authorization control. The web client displays registered source metadata, adapter type, approval/terms state, configured/available/enabled status, credential state, rate-limit/quota status, health, and circuit state returned by the API. Optional adapters are shown as unavailable/configuration-gated until the API reports explicit approval and operational enablement; client visibility is never an authorization control. It labels `dry_run` as a plan/validation result and distinguishes operator-supplied CSV/manual references from independently verified evidence.
CSV and manual reference workflows must show source attribution, adapter/version, observed time, and any retention/redaction status. Raw source payloads should be hidden or minimized in the UI and remain tenant-scoped. A circuit-open or rate-limited source must be presented as unavailable/deferred, not as an empty discovery result. The current static client has no network discovery implementation; these are display and contract requirements for a future approved integration. CSV and manual reference workflows must show source attribution, adapter/version, observed time, and any retention/redaction status. Raw source payloads should be hidden or minimized in the UI and remain tenant-scoped. A circuit-open or rate-limited source must be presented as unavailable/deferred, not as an empty discovery result. The current client provides governed CSV/manual setup, source registry status, and authenticated discovery-run controls while keeping optional network adapters fail-closed.
## Phase 4 job/live-log UI contract ## Phase 4 job/live-log UI contract
+10 -6
View File
@@ -35,7 +35,7 @@
async function request(path, options = {}) { const response=await fetch(endpoint(path),{...options,credentials:'include'}); if(response.status===401){showLogin('Your session has expired. Please sign in again.');throw new Error('unauthorized');} if(response.status===403)throw new Error('Tenant/workspace access denied.'); return response; } async function request(path, options = {}) { const response=await fetch(endpoint(path),{...options,credentials:'include'}); if(response.status===401){showLogin('Your session has expired. Please sign in again.');throw new Error('unauthorized');} if(response.status===403)throw new Error('Tenant/workspace access denied.'); return response; }
async function jsonRequest(path, options = {}) { const res=await request(path,options); const body=await res.json().catch(()=>({})); if(!res.ok)throw new Error(body.error||body.message||'Request failed'); return body; } async function jsonRequest(path, options = {}) { const res=await request(path,options); const body=await res.json().catch(()=>({})); if(!res.ok)throw new Error(body.error||body.message||'Request failed'); return body; }
function showLogin(message=''){currentUser=null;$('dashboardShell').hidden=true;$('loginScreen').hidden=false;$('loginMessage').textContent=message;$('loginMessage').className=`form-message${message?' error':''}`;} function showLogin(message=''){currentUser=null;$('dashboardShell').hidden=true;$('loginScreen').hidden=false;$('loginMessage').textContent=message;$('loginMessage').className=`form-message${message?' error':''}`;}
function showDashboard(user){currentUser=user||{};const name=currentUser.name||currentUser.full_name||currentUser.email||'Workspace member';const role=currentUser.role||currentUser.roles?.[0]||'Member';$('userIdentity').textContent=`${name} · ${role}`;$('userAvatar').textContent=name.split(/\s+/).map(x=>x[0]).join('').slice(0,2).toUpperCase();$('loginScreen').hidden=true;$('dashboardShell').hidden=false;updateJobPermissions();} function showDashboard(user){currentUser=user||{};const name=currentUser.display_name||currentUser.name||currentUser.full_name||currentUser.email||'Workspace member';const role=currentUser.role||currentUser.roles?.[0]||'Member';$('userIdentity').textContent=`${name} · ${role}`;$('userGreetingName').textContent=name.split(/\s+/)[0]||'there';$('userAvatar').textContent=name.split(/\s+/).map(x=>x[0]).join('').slice(0,2).toUpperCase();$('loginScreen').hidden=true;$('dashboardShell').hidden=false;updateJobPermissions();}
function renderMetrics(summary){const total=Number(summary?.businesses??summary?.total??prospects.length),high=Number(summary?.high_fit??summary?.high_fit_count??prospects.filter(p=>scoreFor(p)>=80).length),review=Number(summary?.needs_review??summary?.review_count??prospects.filter(p=>statusOf(p)==='review').length),suppressed=Number(summary?.suppressed??summary?.suppressed_count??prospects.filter(p=>statusOf(p)==='suppressed').length),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=review;$('metricHigh').textContent=high;$('metricFresh').textContent=`${summary?.freshness_under_7d??summary?.fresh_count??fresh}%`;if($('metricSuppressed'))$('metricSuppressed').textContent=suppressed;} function renderMetrics(summary){const total=Number(summary?.businesses??summary?.total??prospects.length),high=Number(summary?.high_fit??summary?.high_fit_count??prospects.filter(p=>scoreFor(p)>=80).length),review=Number(summary?.needs_review??summary?.review_count??prospects.filter(p=>statusOf(p)==='review').length),suppressed=Number(summary?.suppressed??summary?.suppressed_count??prospects.filter(p=>statusOf(p)==='suppressed').length),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=review;$('metricHigh').textContent=high;$('metricFresh').textContent=`${summary?.freshness_under_7d??summary?.fresh_count??fresh}%`;if($('metricSuppressed'))$('metricSuppressed').textContent=suppressed;}
function filterValues(){return {q:$('searchInput').value.trim(),score:$('scoreFilter').value,status:$('statusFilter').value,website_class:$('websiteClassFilter').value,pipeline_stage:$('pipelineFilter').value,source:$('sourceFilter')?.value||'all',geography:$('geographyFilter')?.value||'all',category:$('categoryFilter')?.value||'all',contact_status:$('contactStatusFilter')?.value||'all',freshness:$('explorerState')?.dataset?.filter||'all'};} function filterValues(){return {q:$('searchInput').value.trim(),score:$('scoreFilter').value,status:$('statusFilter').value,website_class:$('websiteClassFilter').value,pipeline_stage:$('pipelineFilter').value,source:$('sourceFilter')?.value||'all',geography:$('geographyFilter')?.value||'all',category:$('categoryFilter')?.value||'all',contact_status:$('contactStatusFilter')?.value||'all',freshness:$('explorerState')?.dataset?.filter||'all'};}
function filtered(){const {q,score:sf,status:st,website_class:wc,pipeline_stage:ps,source,geography,category,contact_status,freshness}=filterValues();return prospects.filter(p=>{const s=scoreFor(p),text=`${p.name} ${p.website_domain||p.website||''} ${p.location||''}`.toLowerCase(),stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||p.contact_extractions||[], pSource=String(p.source_name||p.source||p.source_id||'all'), geo=String(p.city||p.province||p.country||p.location||'all'), cat=String(p.category||p.industry||p.categories?.[0]||'all'), contactState=contacts.length||p.email||p.phone?'present':statusOf(p)==='suppressed'?'suppressed':'missing';return(!q||text.includes(q.toLowerCase()))&&(source==='all'||pSource===source||String(p.source_id)===source)&&(geography==='all'||geo.toLowerCase().includes(geography.toLowerCase()))&&(category==='all'||cat===category)&&(contact_status==='all'||contactState===contact_status)&&(sf==='all'||(sf==='high'&&s>=80)||(sf==='medium'&&s>=60&&s<80)||(sf==='low'&&s<60))&&(st==='all'||statusOf(p)===st)&&(wc==='all'||(p.website_class||'missing')===wc)&&(ps==='all'||stage===ps)&&(freshness!=='under_7d'||freshnessValue(p));});} function filtered(){const {q,score:sf,status:st,website_class:wc,pipeline_stage:ps,source,geography,category,contact_status,freshness}=filterValues();return prospects.filter(p=>{const s=scoreFor(p),text=`${p.name} ${p.website_domain||p.website||''} ${p.location||''}`.toLowerCase(),stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||p.contact_extractions||[], pSource=String(p.source_name||p.source||p.source_id||'all'), geo=String(p.city||p.province||p.country||p.location||'all'), cat=String(p.category||p.industry||p.categories?.[0]||'all'), contactState=contacts.length||p.email||p.phone?'present':statusOf(p)==='suppressed'?'suppressed':'missing';return(!q||text.includes(q.toLowerCase()))&&(source==='all'||pSource===source||String(p.source_id)===source)&&(geography==='all'||geo.toLowerCase().includes(geography.toLowerCase()))&&(category==='all'||cat===category)&&(contact_status==='all'||contactState===contact_status)&&(sf==='all'||(sf==='high'&&s>=80)||(sf==='medium'&&s>=60&&s<80)||(sf==='low'&&s<60))&&(st==='all'||statusOf(p)===st)&&(wc==='all'||(p.website_class||'missing')===wc)&&(ps==='all'||stage===ps)&&(freshness!=='under_7d'||freshnessValue(p));});}
@@ -272,15 +272,19 @@
let sources = [], selectedSourceId = null; let sources = [], selectedSourceId = null;
const sourceState = source => Boolean(source?.enabled ?? source?.active); const sourceState = source => Boolean(source?.enabled ?? source?.active);
const sourceStatus = source => sourceState(source) ? 'enabled' : 'disabled'; const sourceStatus = source => source.optional && !source.configured ? 'unavailable' : sourceState(source) ? 'enabled' : 'disabled';
const sourceItems = payload => Array.isArray(payload) ? payload : (payload?.sources || payload?.items || payload?.records || []); const sourceItems = payload => Array.isArray(payload) ? payload : (payload?.sources || payload?.items || payload?.records || []);
const sourceJson = (value, fallback={}) => { if (value && typeof value === 'object') return value; try { return value ? JSON.parse(value) : fallback; } catch { return fallback; } };
const sourceLabel = source => source.display_name || source.name || source.source_code || source.kind || `Source ${source.id}`;
const sourceType = source => source.source_code || source.kind || source.type || 'unknown';
const sourceText = (source, keys, fallback='Not returned') => { for (const key of keys) if (source?.[key] !== undefined && source[key] !== null && source[key] !== '') return source[key]; return fallback; };
function sourceMessage(text, error = false) { const el = $('sourcesMessage'); if (el) { el.textContent = text || ''; el.className = `sources-message${error ? ' error' : ''}`; } } function sourceMessage(text, error = false) { const el = $('sourcesMessage'); if (el) { el.textContent = text || ''; el.className = `sources-message${error ? ' error' : ''}`; } }
function renderSourceSelect() { const select = $('discoverySource'); if (select) select.innerHTML = `<option value="">Select a source</option>${sources.map(s => `<option value="${esc(s.id)}">${esc(s.name || s.label || `Source ${s.id}`)} · ${sourceStatus(s)}</option>`).join('')}`; const multi=$('directDiscoverySources'); if(multi) multi.innerHTML=sources.filter(sourceState).map(s=>`<option value="${esc(s.id)}">${esc(s.name||s.label||`Source ${s.id}`)} · ${esc(s.kind||'source')}</option>`).join('') || '<option value="" disabled>No approved sources enabled</option>'; } function renderSourceSelect() { const select = $('discoverySource'); if (select) select.innerHTML = `<option value="">Select a source</option>${sources.filter(source=>!source.optional).map(s => `<option value="${esc(s.id)}" ${sourceState(s)?'':'disabled'}>${esc(sourceLabel(s))} · ${esc(sourceStatus(s))}</option>`).join('')}`; const multi=$('directDiscoverySources'); if(multi) multi.innerHTML=sources.filter(sourceState).map(s=>`<option value="${esc(s.id)}">${esc(sourceLabel(s))} · ${esc(sourceType(s))}</option>`).join('') || '<option value="" disabled>No approved sources enabled</option>'; }
function renderSources() { renderSourceSelect(); const list = $('sourcesList'); if (!sources.length) { list.innerHTML = '<div class="source-empty">No sources returned by the workspace.</div>'; return; } list.innerHTML = sources.map(source => { const status = sourceStatus(source), health = source.health || source.health_status || 'Not tested', terms = source.terms_reviewed ?? source.terms_status ?? 'Not reviewed', config = source.config || {}; return `<article class="source-row" data-source-id="${esc(source.id)}"><div class="source-row-main"><strong>${esc(source.name || source.label || `Source ${source.id}`)}</strong><small>${esc(source.kind || source.source_type || source.type || 'manual')}${source.url || config.url ? ` · ${esc(source.url || config.url)}` : ''}</small></div><span class="source-status ${status}">${status}</span><dl class="source-facts"><div><dt>Owner</dt><dd>${esc(source.owner || source.owner_name || config.owner || 'Not assigned')}</dd></div><div><dt>Terms</dt><dd>${esc(String(source.terms_reviewed ?? source.terms_status ?? (config.terms_url ? 'Provided' : 'Not reviewed')))}</dd></div><div><dt>Rate limit</dt><dd>${esc(source.rate_limit || source.rate_limit_label || config.rate_limit || 'Not set')}</dd></div><div><dt>Health</dt><dd>${esc(String(health))}</dd></div></dl><div class="source-actions"><button class="button ghost compact" type="button" data-source-action="test" data-source-id="${esc(source.id)}">Test</button><button class="button ${status === 'enabled' ? 'danger' : 'primary'} compact" type="button" data-source-action="toggle" data-source-id="${esc(source.id)}">${status === 'enabled' ? 'Disable' : 'Enable'}</button></div></article>`; }).join(''); } function renderSources() { renderSourceSelect(); const list = $('sourcesList'); if (!sources.length) { list.innerHTML = '<div class="source-empty">No registered or available sources returned by the workspace.</div>'; return; } list.innerHTML = sources.map(source => { const policy=sourceJson(source.policy_json||source.policy), quota=sourceJson(source.quota_json||source.quota), config=sourceJson(source.config_json||source.config), status=sourceStatus(source), configured=source.configured ?? (!source.optional && (source.approved || sourceType(source)==='manual'||sourceType(source)==='csv')), available=source.available ?? (!source.optional || Boolean(source.configured)), health=sourceText(source,['health_status','health'],'Not tested'), failures=sourceText(source,['consecutive_failures'],'0'), circuit=source.circuit_open===true||source.circuit_open===1?'Open':'Closed', credential=sourceText(source,['api_credential_status','credential_status'],source.optional?'Required / not configured':'Not required'), terms=sourceText(source,['terms_status','terms_reviewed'],policy.terms_accepted===true?'Accepted':policy.terms_url||config.terms_url?'Provided':'Not reviewed'), owner=sourceText(source,['owner','owner_name'],policy.owner||config.owner||'Not assigned'), rate=sourceText(source,['rate_limit','rate_limit_label'],policy.rate_limit||config.rate_limit||'Not set'), daily=sourceText(source,['daily_quota','daily_limit'],quota.daily_limit||'Not set'), lastHealth=sourceText(source,['last_health_at','last_checked_at','updated_at'],'Not checked'), success=sourceText(source,['last_success_at'],'No successful run'), error=sourceText(source,['last_error','error'],'None recorded'); return `<article class="source-row ${source.optional?'source-optional':''}" data-source-id="${esc(source.id||sourceType(source))}"><div class="source-row-main"><div class="source-title-line"><strong>${esc(sourceLabel(source))}</strong><span class="source-type">${esc(sourceType(source))}</span></div><small>${source.optional?'Optional adapter · configuration-gated':'Registered workspace source'}</small></div><span class="source-status ${status}">${esc(status==='unavailable'?'Unavailable':status)}</span><dl class="source-facts source-registry-facts"><div><dt>Integration</dt><dd>${esc(source.optional?'Optional adapter':'Registered')}</dd></div><div><dt>Configured</dt><dd>${configured?'Yes':'No'}</dd></div><div><dt>Available</dt><dd>${available?'Yes':'No'}</dd></div><div><dt>Enabled</dt><dd>${sourceState(source)?'Yes':'No'}</dd></div><div><dt>API credential</dt><dd>${esc(credential)}</dd></div><div><dt>Terms</dt><dd>${esc(terms)}</dd></div><div><dt>Owner</dt><dd>${esc(owner)}</dd></div><div><dt>Rate limit</dt><dd>${esc(rate)}</dd></div><div><dt>Daily quota</dt><dd>${esc(daily)}</dd></div><div><dt>Last health</dt><dd>${esc(lastHealth)} · ${esc(health)}</dd></div><div><dt>Success / error</dt><dd>${esc(success)}<br>${esc(error)}</dd></div><div><dt>Circuit</dt><dd>${esc(circuit)} · ${esc(failures)} failures</dd></div></dl><div class="source-actions"><button class="button ghost compact" type="button" data-source-action="review" data-source-id="${esc(source.id||'')}" ${source.optional?'disabled':''}>Review</button><button class="button ghost compact" type="button" data-source-action="test" data-source-id="${esc(source.id||'')}" ${source.optional?'disabled':''}>Test</button><button class="button ${status === 'enabled' ? 'danger' : 'primary'} compact" type="button" data-source-action="toggle" data-source-id="${esc(source.id||'')}" ${source.optional||!available?'disabled':''}>${status === 'enabled' ? 'Disable' : 'Enable'}</button></div></article>`; }).join(''); }
function renderSourceRecords(items) { const list = $('sourceRecordsList'); if (!items.length) { list.innerHTML = '<div class="source-empty">No source records returned by the workspace.</div>'; return; } list.innerHTML = `<div class="source-record-table"><table><thead><tr><th>Record</th><th>Source</th><th>Status</th><th>Observed</th></tr></thead><tbody>${items.slice(0,25).map(record => `<tr><td>${esc(record.name || record.title || record.external_id || record.id || 'Unnamed record')}</td><td>${esc(record.source_name || record.source || 'Unknown source')}</td><td><span class="status">${esc(record.status || 'Pending')}</span></td><td>${esc(record.observed_at || record.created_at || 'Time unavailable')}</td></tr>`).join('')}</tbody></table></div>`; } function renderSourceRecords(items) { const list = $('sourceRecordsList'); if (!items.length) { list.innerHTML = '<div class="source-empty">No source records returned by the workspace.</div>'; return; } list.innerHTML = `<div class="source-record-table"><table><thead><tr><th>Record</th><th>Source</th><th>Status</th><th>Observed</th></tr></thead><tbody>${items.slice(0,25).map(record => `<tr><td>${esc(record.name || record.title || record.external_id || record.id || 'Unnamed record')}</td><td>${esc(record.source_name || record.source || 'Unknown source')}</td><td><span class="status">${esc(record.status || 'Pending')}</span></td><td>${esc(record.observed_at || record.created_at || 'Time unavailable')}</td></tr>`).join('')}</tbody></table></div>`; }
async function loadSources() { sourceMessage('Loading sources…'); $('sourcesList').innerHTML = '<div class="detail-loading" aria-live="polite">Loading source registry…</div>'; $('sourceRecordsList').innerHTML = '<div class="detail-loading" aria-live="polite">Loading source records…</div>'; try { const [sourcePayload, recordPayload] = await Promise.all([jsonRequest('/api/v1/sources'), jsonRequest('/api/v1/source-records?page_size=25')]); sources = sourceItems(sourcePayload); renderSources(); renderSourceRecords(sourceItems(recordPayload)); $('sourcesUpdatedAt').textContent = `Updated ${new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}`; sourceMessage(sources.some(sourceState) ? '' : 'No live source is enabled.'); } catch (error) { sources = []; renderSources(); renderSourceRecords([]); if (error.message !== 'unauthorized') sourceMessage(error.message || 'Unable to load sources.', true); } } async function loadSources() { sourceMessage('Loading sources…'); $('sourcesList').innerHTML = '<div class="detail-loading" aria-live="polite">Loading source registry…</div>'; $('sourceRecordsList').innerHTML = '<div class="detail-loading" aria-live="polite">Loading source records…</div>'; try { const [sourcePayload, adapterPayload, recordPayload] = await Promise.all([jsonRequest('/api/v1/sources'), jsonRequest('/api/v1/sources/adapters'), jsonRequest('/api/v1/source-records?page_size=25')]); const configured=sourceItems(sourcePayload), registeredCodes=new Set(configured.map(sourceType)); const optional=sourceItems(adapterPayload).filter(adapter=>!registeredCodes.has(adapter.source_code)).map(adapter=>({...adapter,source_code:adapter.source_code,display_name:adapter.display_name,kind:adapter.source_code,optional:true,configured:false,available:false,enabled:false})); sources=[...configured,...optional]; renderSources(); renderSourceRecords(sourceItems(recordPayload)); $('sourcesUpdatedAt').textContent = `Updated ${new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}`; sourceMessage(sources.some(sourceState) ? '' : 'No live source is enabled. Optional integrations remain unavailable until configured and approved.'); } catch (error) { sources = []; renderSources(); renderSourceRecords([]); if (error.message !== 'unauthorized') sourceMessage(error.message || 'Unable to load sources.', true); } }
async function saveSource(event) { event.preventDefault(); const form = event.currentTarget, fields = Object.fromEntries(new FormData(form).entries()); if (fields.source_type === 'csv' && !fields.csv_content.trim()) { message('sourceFormMessage', 'CSV content is required for a CSV source.', true); return; } const config = {url:fields.url, terms_url:fields.terms_url, owner:fields.owner, rate_limit:fields.rate_limit}; if (fields.source_type === 'csv') config.csv = fields.csv_content; else config.rows = []; try { await jsonRequest('/api/v1/sources', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name:fields.name, kind:fields.source_type, config, enabled:false})}); message('sourceFormMessage', 'Source saved. It remains disabled until explicitly enabled.'); form.reset(); $('sourceCsvField').hidden = true; await loadSources(); } catch (error) { if (error.message !== 'unauthorized') message('sourceFormMessage', error.message || 'Unable to save source.', true); } } async function saveSource(event) { event.preventDefault(); const form = event.currentTarget, fields = Object.fromEntries(new FormData(form).entries()); if (fields.source_type === 'csv' && !fields.csv_content.trim()) { message('sourceFormMessage', 'CSV content is required for a CSV source.', true); return; } const config = {url:fields.url, terms_url:fields.terms_url, owner:fields.owner, rate_limit:fields.rate_limit}; if (fields.source_type === 'csv') config.csv = fields.csv_content; else config.rows = []; try { await jsonRequest('/api/v1/sources', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name:fields.name, kind:fields.source_type, config, enabled:false})}); message('sourceFormMessage', 'Source saved. It remains disabled until explicitly enabled.'); form.reset(); $('sourceCsvField').hidden = true; await loadSources(); } catch (error) { if (error.message !== 'unauthorized') message('sourceFormMessage', error.message || 'Unable to save source.', true); } }
async function sourceAction(id, action) { const source = sources.find(item => String(item.id) === String(id)); if (!source) return; try { if (action === 'test') { await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}/test`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); sourceMessage('Source test completed.'); } else { const enabled = sourceState(source); await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}`, {method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled:!enabled})}); sourceMessage(`Source ${enabled ? 'disabled' : 'enabled'} by the workspace.`); } await loadSources(); } catch (error) { if (error.message !== 'unauthorized') sourceMessage(error.message || `Unable to ${action} source.`, true); } } async function sourceAction(id, action) { const source = sources.find(item => String(item.id) === String(id)); if (!source || source.optional) return; if (action === 'review') { selectedSourceId=id; document.querySelector(`[data-source-id="${CSS.escape(String(id))}"]`)?.scrollIntoView({behavior:'smooth',block:'center'}); sourceMessage('Source details are shown below. Review terms, owner, limits, health, and circuit state before enabling.'); return; } try { if (action === 'test') { await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}/test`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); sourceMessage('Source test completed.'); } else { const enabled = sourceState(source); await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}`, {method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled:!enabled})}); sourceMessage(`Source ${enabled ? 'disabled' : 'enabled'} by the workspace.`); } await loadSources(); } catch (error) { if (error.message !== 'unauthorized') sourceMessage(error.message || `Unable to ${action} source.`, true); } }
async function runDiscovery(dryRun) { const form = $('discoveryForm'), data = Object.fromEntries(new FormData(form).entries()); data.dry_run = Boolean(dryRun); if (!data.source_id || !data.query.trim()) { message('discoveryMessage', 'Select a source and enter a query.', true); return; } const source = sources.find(item => String(item.id) === String(data.source_id)); if (!dryRun && !sourceState(source)) { message('discoveryMessage', 'This source is disabled. Enable it only after review.', true); return; } const button = dryRun ? $('discoveryDryRunBtn') : $('discoveryRunBtn'); button.disabled = true; message('discoveryMessage', dryRun ? 'Validating query…' : 'Starting discovery…'); try { const query = await jsonRequest('/api/v1/discovery-queries', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({source_id:Number(data.source_id), name:data.query.trim().slice(0,80), query:{text:data.query.trim()}, dry_run:data.dry_run})}); if (!dryRun) await jsonRequest(`/api/v1/discovery-queries/${encodeURIComponent(query.id)}/run`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); message('discoveryMessage', dryRun ? 'Dry run completed; no discovery job was started.' : 'Discovery request accepted. Check Jobs for progress.'); } catch (error) { if (error.message !== 'unauthorized') message('discoveryMessage', error.message || 'Discovery request failed.', true); } finally { button.disabled = false; } } async function runDiscovery(dryRun) { const form = $('discoveryForm'), data = Object.fromEntries(new FormData(form).entries()); data.dry_run = Boolean(dryRun); if (!data.source_id || !data.query.trim()) { message('discoveryMessage', 'Select a source and enter a query.', true); return; } const source = sources.find(item => String(item.id) === String(data.source_id)); if (!dryRun && !sourceState(source)) { message('discoveryMessage', 'This source is disabled. Enable it only after review.', true); return; } const button = dryRun ? $('discoveryDryRunBtn') : $('discoveryRunBtn'); button.disabled = true; message('discoveryMessage', dryRun ? 'Validating query…' : 'Starting discovery…'); try { const query = await jsonRequest('/api/v1/discovery-queries', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({source_id:Number(data.source_id), name:data.query.trim().slice(0,80), query:{text:data.query.trim()}, dry_run:data.dry_run})}); if (!dryRun) await jsonRequest(`/api/v1/discovery-queries/${encodeURIComponent(query.id)}/run`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); message('discoveryMessage', dryRun ? 'Dry run completed; no discovery job was started.' : 'Discovery request accepted. Check Jobs for progress.'); } catch (error) { if (error.message !== 'unauthorized') message('discoveryMessage', error.message || 'Discovery request failed.', true); } finally { button.disabled = false; } }
let discoveryRuns = [], selectedDiscoveryRunId = null, discoveryPollTimer = null; let discoveryRuns = [], selectedDiscoveryRunId = null, discoveryPollTimer = null;
const discoveryStatus = run => String(run?.status || run?.state || 'queued').toLowerCase().replaceAll('_','-'); const discoveryStatus = run => String(run?.status || run?.state || 'queued').toLowerCase().replaceAll('_','-');
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"schema": 1, "schema": 1,
"version": "phase-19", "version": "phase-20",
"entrypoints": [ "entrypoints": [
"config.js", "config.js",
"app.js", "app.js",
@@ -13,10 +13,10 @@
"healthz" "healthz"
], ],
"integrity": { "integrity": {
"config.js": "sha256-f9c7b4db3eab4cf54146bd25891b5103b09ae75da93c57b548cf57ae93e4a3f6", "config.js": "sha256-12a10f772029a5ee6d813ed9fd61dfc7ff877aa356bf90f65987948cc3274f90",
"app.js": "sha256-ab3d9046b53fd87950bedeec66da479da8b935bc070b60490a1aab7b22fff2a8", "app.js": "sha256-de1bc9074e8f41d5a964f199e220ce5b48fabbfd0e0301000d3870db84696a43",
"styles.css": "sha256-423d0f9489061aff6420bea3d854e49aaf1c35d94a8c0e0bca2f82434781a2b7", "styles.css": "sha256-ddbb75572a2e80e19a99834a8fb61540e0d94fffe4658429c99116242034183c",
"index.html": "sha256-1979f265a29009f7bd5380401c2185c6473394eb2412b592c50ca90271cb6d3f", "index.html": "sha256-2cba8095f33d9eaed8d73c33eecb08b82b223448240e9ef0d96de8c836122da8",
"health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81", "health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81",
"error.html": "sha256-f3cc28d2dfc9e8af112d0257c6ee47b9e9146e5da8590a754dd589bf43702aaf", "error.html": "sha256-f3cc28d2dfc9e8af112d0257c6ee47b9e9146e5da8590a754dd589bf43702aaf",
"healthz": "sha256-dc51b8c96c2d745df3bd5590d990230a482fd247123599548e0632fdbf97fc22" "healthz": "sha256-dc51b8c96c2d745df3bd5590d990230a482fd247123599548e0632fdbf97fc22"
+1 -1
View File
@@ -1,5 +1,5 @@
/* Public, non-secret runtime configuration. Replace this file at deploy time if needed. */ /* Public, non-secret runtime configuration. Replace this file at deploy time if needed. */
window.__PROSPECT_CONFIG__ = Object.freeze({ window.__PROSPECT_CONFIG__ = Object.freeze({
apiBase: '', apiBase: '',
assetVersion: 'phase-17' assetVersion: 'phase-20'
}); });
+7 -7
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>ProspectOS · Pipeline intelligence</title> <title>ProspectOS · Pipeline intelligence</title>
<meta name="description" content="Prospect discovery and review dashboard"> <meta name="description" content="Prospect discovery and review dashboard">
<link rel="stylesheet" href="styles.css?v=phase-19"> <link rel="stylesheet" href="styles.css?v=phase-20">
</head> </head>
<body> <body>
<section class="login-screen" id="loginScreen" aria-labelledby="loginTitle"> <section class="login-screen" id="loginScreen" aria-labelledby="loginTitle">
@@ -45,7 +45,7 @@
<main class="main" id="top"> <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">● Connecting…</span><span class="user-identity" id="userIdentity"></span><button class="icon-button" aria-label="Notifications">♢</button><button class="logout-button" id="logoutBtn" type="button">Log out</button><div class="avatar" id="userAvatar">?</div></div></header> <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">● Connecting…</span><span class="user-identity" id="userIdentity"></span><button class="icon-button" aria-label="Notifications">♢</button><button class="logout-button" id="logoutBtn" type="button">Log out</button><div class="avatar" id="userAvatar">?</div></div></header>
<div class="content"> <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="hero" id="dashboard"><div><p class="eyebrow">EVIDENCE-LED PROSPECTING</p><h1>Good morning, <span id="userGreetingName">there</span> <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="direct-discovery-section" id="discoveryWorkspace" aria-labelledby="discoveryWorkspaceTitle" data-smoke="direct-discovery"> <section class="direct-discovery-section" id="discoveryWorkspace" aria-labelledby="discoveryWorkspaceTitle" data-smoke="direct-discovery">
<div class="discovery-hero panel"><div><p class="eyebrow">SOURCE INTELLIGENCE</p><h2 id="discoveryWorkspaceTitle">Discovery runs</h2><p class="muted">Build a criteria-first run across approved sources, monitor it live, and review every result with provenance before it enters your pipeline.</p></div><span class="discovery-badge">Bounded · review first</span></div> <div class="discovery-hero panel"><div><p class="eyebrow">SOURCE INTELLIGENCE</p><h2 id="discoveryWorkspaceTitle">Discovery runs</h2><p class="muted">Build a criteria-first run across approved sources, monitor it live, and review every result with provenance before it enters your pipeline.</p></div><span class="discovery-badge">Bounded · review first</span></div>
<div class="discovery-workspace-grid"> <div class="discovery-workspace-grid">
@@ -90,14 +90,14 @@
</div> </div>
</section> </section>
<section class="sources-section" id="sources" aria-labelledby="sourcesTitle"> <section class="sources-section" id="sources" aria-labelledby="sourcesTitle">
<div class="sources-header panel"><div><p class="eyebrow">GOVERNANCE</p><h2 id="sourcesTitle">Sources</h2><p class="muted">Review source ownership, terms, limits, and health before using discovery.</p></div><button class="button ghost" id="sourcesRefreshBtn" type="button">↻ Refresh</button></div> <div class="sources-header panel"><div><p class="eyebrow">GOVERNANCE · SOURCE REGISTRY</p><h2 id="sourcesTitle">Sources</h2><p class="muted">One operational view of every registered integration. Configure, test, and enable sources only after their terms, owner, limits, and health are reviewable.</p></div><button class="button ghost" id="sourcesRefreshBtn" type="button">↻ Refresh registry</button></div>
<div class="source-safety" role="note"><strong>Discovery is disabled by default.</strong> No live source is enabled in this workspace. Enable a source only after its terms, owner, rate limit, and health have been reviewed.</div> <div class="source-safety" role="note"><strong>Discovery is disabled by default.</strong> No live source is enabled in this workspace. Enable a source only after its terms, owner, rate limit, and health have been reviewed.</div>
<div id="sourcesMessage" class="sources-message" role="status" aria-live="polite"></div> <div id="sourcesMessage" class="sources-message" role="status" aria-live="polite"></div>
<div class="sources-grid"> <div class="sources-grid">
<article class="panel source-config-panel"><div class="panel-heading"><div><p class="eyebrow">CONFIGURATION</p><h3>Add a source</h3></div><span class="small-label">Manual or CSV</span></div><form id="sourceForm"><div class="form-grid"><label>Source name<input name="name" required placeholder="Public business directory"></label><label>Source type<select name="source_type" id="sourceType"><option value="manual">Manual / API</option><option value="csv">CSV upload</option></select></label><label>Source URL <span class="optional">optional</span><input name="url" type="url" placeholder="https://…"></label><label>Terms URL <span class="optional">required for enablement</span><input name="terms_url" type="url" placeholder="https://…/terms"></label><label>Owner<input name="owner" required placeholder="Team or accountable person"></label><label>Rate limit<input name="rate_limit" required placeholder="e.g. 60 requests/hour"></label></div><label class="source-csv-field" id="sourceCsvField" hidden>CSV content<textarea name="csv_content" id="sourceCsvContent" rows="4" placeholder="Paste CSV content; it is sent only when you save this source."></textarea></label><div class="form-footer"><p id="sourceFormMessage" class="form-message" role="status"></p><button class="button primary" type="submit">Save source</button></div></form></article> <article class="panel source-config-panel"><div class="panel-heading"><div><p class="eyebrow">SOURCE SETUP</p><h3>Add a governed source</h3></div><span class="small-label">Manual or CSV only</span></div><p class="source-setup-copy">Bring in operator-controlled records without activating an external integration. New sources start disabled and remain review-only until explicitly enabled.</p><form id="sourceForm"><div class="form-grid"><label>Source name<input name="name" required placeholder="Partner list / manual import"></label><label>Source type<select name="source_type" id="sourceType"><option value="manual">Manual records</option><option value="csv">CSV import</option></select></label><label>Source URL <span class="optional">optional reference</span><input name="url" type="url" placeholder="https://…"></label><label>Terms URL <span class="optional">required for enablement</span><input name="terms_url" type="url" placeholder="https://…/terms"></label><label>Owner<input name="owner" required placeholder="Team or accountable person"></label><label>Rate limit<input name="rate_limit" required placeholder="e.g. 60 records/hour"></label></div><label class="source-csv-field" id="sourceCsvField" hidden>CSV content<textarea name="csv_content" id="sourceCsvContent" rows="4" placeholder="Paste CSV content; it is sent only when you save this source."></textarea></label><div class="form-footer"><p id="sourceFormMessage" class="form-message" role="status"></p><button class="button primary" type="submit">Register source</button></div></form></article>
<article class="panel discovery-panel"><div class="panel-heading"><div><p class="eyebrow">DISCOVERY</p><h3>Query a source</h3></div><span class="small-label">No automatic runs</span></div><form id="discoveryForm"><label>Source<select name="source_id" id="discoverySource" required><option value="">Select a source</option></select></label><label>Query<input name="query" required placeholder="e.g. renewable energy firms in Cape Town"></label><label class="checkbox-label"><input type="checkbox" name="dry_run" id="discoveryDryRun" checked> Dry run (preview only)</label><div class="form-footer"><p id="discoveryMessage" class="form-message" role="status"></p><div class="discovery-actions"><button class="button ghost" id="discoveryDryRunBtn" type="submit">Validate query</button><button class="button primary" id="discoveryRunBtn" type="button">Run discovery</button></div></div></form></article> <article class="panel discovery-panel"><div class="panel-heading"><div><p class="eyebrow">DISCOVERY</p><h3>Query a source</h3></div><span class="small-label">No automatic runs</span></div><form id="discoveryForm"><label>Source<select name="source_id" id="discoverySource" required><option value="">Select a source</option></select></label><label>Query<input name="query" required placeholder="e.g. renewable energy firms in Cape Town"></label><label class="checkbox-label"><input type="checkbox" name="dry_run" id="discoveryDryRun" checked> Dry run (preview only)</label><div class="form-footer"><p id="discoveryMessage" class="form-message" role="status"></p><div class="discovery-actions"><button class="button ghost" id="discoveryDryRunBtn" type="submit">Validate query</button><button class="button primary" id="discoveryRunBtn" type="button">Run discovery</button></div></div></form></article>
</div> </div>
<div class="sources-list panel"><div class="panel-heading"><div><p class="eyebrow">REGISTRY</p><h3>Configured sources</h3></div><span id="sourcesUpdatedAt" class="small-label">Not loaded</span></div><div id="sourcesList" class="sources-list-body"><div class="source-empty">Sign in to load sources from the workspace.</div></div></div> <div class="sources-list panel"><div class="panel-heading"><div><p class="eyebrow">REGISTRY STATUS</p><h3>Configured &amp; available integrations</h3><p class="muted source-registry-caption">Configured sources are shown alongside optional adapters so unavailable capability is never mistaken for an active source.</p></div><span id="sourcesUpdatedAt" class="small-label">Not loaded</span></div><div id="sourcesList" class="sources-list-body"><div class="source-empty">Sign in to load sources from the workspace.</div></div></div>
<div class="source-records panel"><div class="panel-heading"><div><p class="eyebrow">RECENT OUTPUT</p><h3>Recent source records</h3></div></div><div id="sourceRecordsList" class="source-records-body"><div class="source-empty">No records loaded.</div></div></div> <div class="source-records panel"><div class="panel-heading"><div><p class="eyebrow">RECENT OUTPUT</p><h3>Recent source records</h3></div></div><div id="sourceRecordsList" class="source-records-body"><div class="source-empty">No records loaded.</div></div></div>
</section> </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> <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>
@@ -130,7 +130,7 @@
<div class="merge-dialog" id="mergeDialog" hidden role="dialog" aria-modal="true" aria-labelledby="mergeDialogTitle"> <div class="merge-dialog" id="mergeDialog" hidden role="dialog" aria-modal="true" aria-labelledby="mergeDialogTitle">
<div class="merge-dialog-card"><div class="panel-heading"><div><p class="eyebrow">REVIEW REQUIRED</p><h2 id="mergeDialogTitle">Confirm merge</h2></div><button class="icon-button" id="cancelMergeBtn" type="button" aria-label="Close merge confirmation">×</button></div><p id="mergeDialogCopy"></p><div class="merge-warning"><strong>This action is reversible.</strong> The merge will be recorded in history and can be reversed later.</div><p id="mergeDialogMessage" class="form-message" role="alert" aria-live="polite"></p><div class="merge-dialog-actions"><button class="button ghost" id="cancelMergeBtnSecondary" type="button">Cancel</button><button class="button primary" id="confirmMergeBtn" type="button">Confirm merge</button></div></div> <div class="merge-dialog-card"><div class="panel-heading"><div><p class="eyebrow">REVIEW REQUIRED</p><h2 id="mergeDialogTitle">Confirm merge</h2></div><button class="icon-button" id="cancelMergeBtn" type="button" aria-label="Close merge confirmation">×</button></div><p id="mergeDialogCopy"></p><div class="merge-warning"><strong>This action is reversible.</strong> The merge will be recorded in history and can be reversed later.</div><p id="mergeDialogMessage" class="form-message" role="alert" aria-live="polite"></p><div class="merge-dialog-actions"><button class="button ghost" id="cancelMergeBtnSecondary" type="button">Cancel</button><button class="button primary" id="confirmMergeBtn" type="button">Confirm merge</button></div></div>
</div> </div>
<script src="config.js?v=phase-19"></script> <script src="config.js?v=phase-20"></script>
<script src="app.js?v=phase-19"></script> <script src="app.js?v=phase-20"></script>
</body> </body>
</html> </html>
+6 -2
View File
@@ -55,7 +55,7 @@ const config = text('config.js');
const expectedIds = [ const expectedIds = [
'loginScreen', 'loginForm', 'loginEmail', 'loginPassword', 'dashboardShell', 'logoutBtn', 'apiStatus', 'loginScreen', 'loginForm', 'loginEmail', 'loginPassword', 'dashboardShell', 'logoutBtn', 'apiStatus',
'explorer', 'detailPanel', 'reviewQueueCount', 'reviewQueueState', 'savedFilterForm', 'savedFilterSelect', 'explorer', 'detailPanel', 'userGreetingName', 'reviewQueueCount', 'reviewQueueState', 'savedFilterForm', 'savedFilterSelect',
'deleteSavedFilterBtn', 'bulkVerifyBtn', 'bulkRejectBtn', 'nextPageBtn', 'jobs', 'jobsList', 'jobDetailPanel', 'deleteSavedFilterBtn', 'bulkVerifyBtn', 'bulkRejectBtn', 'nextPageBtn', 'jobs', 'jobsList', 'jobDetailPanel',
'sources', 'sourcesList', 'sourceRecordsList', 'discoveryForm', 'discoveryWorkspace', 'directDiscoveryForm', 'directDiscoveryRunsState', 'directDiscoveryResultState', 'directDiscoverySources', 'directDiscoveryDailyLimit', 'directDiscoverySchedule', 'directDiscoveryDryRun', 'sourceFilter', 'geographyFilter', 'categoryFilter', 'contactStatusFilter', 'crmPipeline', 'pipelineBoard', 'crmActivity', 'sources', 'sourcesList', 'sourceRecordsList', 'discoveryForm', 'discoveryWorkspace', 'directDiscoveryForm', 'directDiscoveryRunsState', 'directDiscoveryResultState', 'directDiscoverySources', 'directDiscoveryDailyLimit', 'directDiscoverySchedule', 'directDiscoveryDryRun', 'sourceFilter', 'geographyFilter', 'categoryFilter', 'contactStatusFilter', 'crmPipeline', 'pipelineBoard', 'crmActivity',
'interactionState', 'interactionForm', 'crmReports', 'pipelineReport', 'outcomesReport', 'activityReport', 'interactionState', 'interactionForm', 'crmReports', 'pipelineReport', 'outcomesReport', 'activityReport',
@@ -80,6 +80,10 @@ const routeContracts = [
]; ];
check('routes.contracts', 'all critical API route contracts are referenced by the client', all(routeContracts, route => js.includes(route)), listMissing(routeContracts, route => js.includes(route)).join(', ')); check('routes.contracts', 'all critical API route contracts are referenced by the client', all(routeContracts, route => js.includes(route)), listMissing(routeContracts, route => js.includes(route)).join(', '));
check('routes.authenticated', 'protected API requests include cookie credentials', js.includes("credentials:'include'") && js.includes('jsonRequest')); check('routes.authenticated', 'protected API requests include cookie credentials', js.includes("credentials:'include'") && js.includes('jsonRequest'));
check('auth.display-name-greeting', 'authenticated display name drives the greeting and identity', js.includes('currentUser.display_name') && js.includes("$('userGreetingName').textContent") && !html.includes('Good morning, Alex'));
check('sources.registry-status', 'source registry exposes governed configuration and operational status fields', all(['source_code', 'display_name', 'configured', 'available', 'enabled', 'API credential', 'Terms', 'Owner', 'Rate limit', 'Daily quota', 'Last health', 'Success / error', 'Circuit', 'data-source-action="review"', 'data-source-action="test"'], token => `${html}\n${js}\n${css}`.includes(token)));
check('sources.optional-gated', 'optional adapters are rendered unavailable until configured', all(['/api/v1/sources/adapters', 'configuration-gated', 'Optional adapter', 'source.optional', 'available:false'], token => `${html}\n${js}\n${css}`.includes(token)));
check('sources.setup-affordances', 'manual and CSV setup affordances remain explicit and disabled by default', all(['Manual records', 'CSV import', 'CSV content is required', 'enabled:false', 'remains disabled'], token => `${html}\n${js}\n${css}`.includes(token)));
check('discovery.operator-controls', 'discovery builder and run controls are represented', all(['data-run-action="pause"', 'data-run-action="resume"', 'data-run-action="cancel"', 'live-log', 'source-health', 'daily_limit', 'source_ids', 'schedule'], token => `${html}\n${js}\n${css}`.includes(token))); check('discovery.operator-controls', 'discovery builder and run controls are represented', all(['data-run-action="pause"', 'data-run-action="resume"', 'data-run-action="cancel"', 'live-log', 'source-health', 'daily_limit', 'source_ids', 'schedule'], token => `${html}\n${js}\n${css}`.includes(token)));
check('prospects.filter-contract', 'prospect explorer exposes source, geography, category, and contact filters', all(['sourceFilter', 'geographyFilter', 'categoryFilter', 'contactStatusFilter', 'contact_status'], token => `${html}\n${js}\n${css}`.includes(token))); check('prospects.filter-contract', 'prospect explorer exposes source, geography, category, and contact filters', all(['sourceFilter', 'geographyFilter', 'categoryFilter', 'contactStatusFilter', 'contact_status'], token => `${html}\n${js}\n${css}`.includes(token)));
check('routes.no-arbitrary-target-fetch', 'browser does not fetch arbitrary target URLs directly', !/fetch\s*\(\s*(?:url|target|website|href)\b/i.test(js)); check('routes.no-arbitrary-target-fetch', 'browser does not fetch arbitrary target URLs directly', !/fetch\s*\(\s*(?:url|target|website|href)\b/i.test(js));
@@ -108,7 +112,7 @@ if (manifestAssets) {
const linkedAssets = [...new Set([...html.matchAll(/(?:src|href)=["']([^"'#?]+)(?:\?[^"']*)?["']/gi)].map(m => m[1]).filter(asset => !/^(?:https?:|data:|#)/i.test(asset)).map(asset => asset.replace(/^\.\//, '')) )]; const linkedAssets = [...new Set([...html.matchAll(/(?:src|href)=["']([^"'#?]+)(?:\?[^"']*)?["']/gi)].map(m => m[1]).filter(asset => !/^(?:https?:|data:|#)/i.test(asset)).map(asset => asset.replace(/^\.\//, '')) )];
check('assets.linked-local', 'all local HTML assets exist and are non-empty', all(linkedAssets, asset => files[asset]?.length > 0), listMissing(linkedAssets, asset => files[asset]?.length > 0).join(', ')); check('assets.linked-local', 'all local HTML assets exist and are non-empty', all(linkedAssets, asset => files[asset]?.length > 0), listMissing(linkedAssets, asset => files[asset]?.length > 0).join(', '));
check('css.responsive', 'responsive CSS covers mobile layouts and critical grids', /@media\s*\(\s*max-width\s*:\s*700px\s*\)/.test(css) && all(['.saved-view-controls', '.website-scan-grid', '.crm-two-col', '.reports-grid', '.outreach-panel', '.pipeline-board', '.ai-provider-grid'], selector => css.includes(selector))); check('css.responsive', 'responsive CSS covers mobile layouts and critical grids', /@media\s*\(\s*max-width\s*:\s*700px\s*\)/.test(css) && all(['.saved-view-controls', '.website-scan-grid', '.crm-two-col', '.reports-grid', '.outreach-panel', '.pipeline-board', '.ai-provider-grid'], selector => css.includes(selector)));
check('css.layout-contracts', 'critical desktop layout selectors are defined', all(['.app-shell', '.sidebar', '.workspace-grid', '.table-scroll', '.jobs-grid', '.sources-grid', '.score-config-row', '.provider-policy-row'], selector => css.includes(selector))); check('css.layout-contracts', 'critical desktop layout selectors are defined', all(['.app-shell', '.sidebar', '.workspace-grid', '.table-scroll', '.jobs-grid', '.sources-grid', '.source-registry-facts', '.score-config-row', '.provider-policy-row'], selector => css.includes(selector)));
const combined = `${html}\n${js}\n${css}\n${config}\n${text('README.md')}`; const combined = `${html}\n${js}\n${css}\n${config}\n${text('README.md')}`;
const safetyCopy = ['No outreach from this workspace.', 'Suppression always wins.', 'Sending is disabled by default.', 'Approval does not send a message.', 'no outreach will be sent', 'AUTOMATED_OUTREACH_ENABLED=false']; const safetyCopy = ['No outreach from this workspace.', 'Suppression always wins.', 'Sending is disabled by default.', 'Approval does not send a message.', 'no outreach will be sent', 'AUTOMATED_OUTREACH_ENABLED=false'];
+19 -7
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import http.client import http.client
import json
import os import os
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit from urllib.parse import urlsplit
@@ -15,20 +16,29 @@ UPSTREAM_PORT = int(os.environ.get("API_UPSTREAM_PORT", "8000"))
class ProxyStaticHandler(SimpleHTTPRequestHandler): class ProxyStaticHandler(SimpleHTTPRequestHandler):
proxy_api = True proxy_api = True
def _send_json_error(self, status: int, code: str) -> None:
payload = json.dumps({"error": code}, separators=(",", ":")).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store, private")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def _proxy_request(self) -> None: def _proxy_request(self) -> None:
parsed = urlsplit(self.path) parsed = urlsplit(self.path)
if parsed.path == "/api" or parsed.path.startswith("/api/"): if parsed.path == "/api" or parsed.path.startswith("/api/"):
target = self.path target = self.path
else: else:
self.send_error(404) self._send_json_error(404, "not_found")
return return
try: try:
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))
except ValueError: except ValueError:
self.send_error(400, "invalid content length") self._send_json_error(400, "invalid_content_length")
return return
if length > MAX_PROXY_BODY: if length > MAX_PROXY_BODY:
self.send_error(413, "request body too large") self._send_json_error(413, "request_body_too_large")
return return
body = self.rfile.read(length) if length else None body = self.rfile.read(length) if length else None
headers = { headers = {
@@ -37,13 +47,14 @@ class ProxyStaticHandler(SimpleHTTPRequestHandler):
if key.lower() in {"accept", "content-type", "cookie", "user-agent", "x-request-id"} if key.lower() in {"accept", "content-type", "cookie", "user-agent", "x-request-id"}
} }
headers["Host"] = f"{UPSTREAM_HOST}:{UPSTREAM_PORT}" headers["Host"] = f"{UPSTREAM_HOST}:{UPSTREAM_PORT}"
connection = http.client.HTTPConnection(UPSTREAM_HOST, UPSTREAM_PORT, timeout=15) connection = None
try: try:
connection = http.client.HTTPConnection(UPSTREAM_HOST, UPSTREAM_PORT, timeout=15)
connection.request(self.command, target, body=body, headers=headers) connection.request(self.command, target, body=body, headers=headers)
response = connection.getresponse() response = connection.getresponse()
payload = response.read(MAX_PROXY_BODY + 1) payload = response.read(MAX_PROXY_BODY + 1)
if len(payload) > MAX_PROXY_BODY: if len(payload) > MAX_PROXY_BODY:
self.send_error(502, "upstream response too large") self._send_json_error(502, "upstream_response_too_large")
return return
self.send_response(response.status, response.reason) self.send_response(response.status, response.reason)
for key, value in response.getheaders(): for key, value in response.getheaders():
@@ -52,9 +63,10 @@ class ProxyStaticHandler(SimpleHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(payload) self.wfile.write(payload)
except (OSError, http.client.HTTPException) as exc: except (OSError, http.client.HTTPException) as exc:
self.send_error(502, f"api upstream unavailable: {exc}") self._send_json_error(502, "api_upstream_unavailable")
finally: finally:
connection.close() if connection is not None:
connection.close()
def do_GET(self): def do_GET(self):
if self.path.startswith("/api"): if self.path.startswith("/api"):
+2 -2
View File
@@ -103,7 +103,7 @@ h4 { font-size: 14px; }
.hero h1 span { color: var(--accent); font-size: .7em; } .hero h1 span { color: var(--accent); font-size: .7em; }
.hero-sub { margin: 11px 0 0; color: var(--muted); font-size: 15px; } .hero-sub { margin: 11px 0 0; color: var(--muted); font-size: 15px; }
.hero-sub strong { color: var(--ink); } .hero-sub strong { color: var(--ink); }
.metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; } .metrics { display: grid; grid-template-columns: 1.25fr 1fr 1fr 1fr 1fr; gap: 12px; }.metric-card:first-child { border-color: #b8ccd7; background: linear-gradient(135deg, #fffdf9, #f3f9f8); }.metric-card:first-child h2 { font-size: 32px; }.metric-card:first-child .metric-icon { color: #fff; background: var(--navy-2); }
.metric-card { min-width: 0; display: flex; gap: 13px; align-items: flex-start; padding: 17px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow-sm); transition: .18s ease; } .metric-card { min-width: 0; display: flex; gap: 13px; align-items: flex-start; padding: 17px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow-sm); transition: .18s ease; }
.metric-card:hover { transform: translateY(-2px); border-color: #b5c9d5; box-shadow: var(--shadow); } .metric-card:hover { transform: translateY(-2px); border-color: #b5c9d5; box-shadow: var(--shadow); }
.metric-icon { width: 35px; height: 35px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 10px; color: var(--navy); background: var(--blue-soft); font-size: 19px; } .metric-icon { width: 35px; height: 35px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 10px; color: var(--navy); background: var(--blue-soft); font-size: 19px; }
@@ -230,7 +230,7 @@ td:first-child { color: var(--ink-strong); font-weight: 750; }
.job-empty, .source-empty, .crm-empty { padding: 34px 16px; color: var(--muted); text-align: center; } .job-empty, .source-empty, .crm-empty { padding: 34px 16px; color: var(--muted); text-align: center; }
.job-detail { min-height: 320px; }.job-progress { margin: 20px 0; }.job-progress-meta { display: flex; justify-content: space-between; gap: 10px; margin-bottom: 8px; color: var(--muted); font-size: 11px; }.job-progress-meta strong { color: var(--ink); } .job-detail { min-height: 320px; }.job-progress { margin: 20px 0; }.job-progress-meta { display: flex; justify-content: space-between; gap: 10px; margin-bottom: 8px; color: var(--muted); font-size: 11px; }.job-progress-meta strong { color: var(--ink); }
.event-timeline, .crm-timeline { margin-top: 20px; }.event-timeline h4 { margin-bottom: 10px; }.event-timeline ol, .crm-timeline { display: grid; gap: 15px; margin: 0; padding: 0; list-style: none; }.event-timeline li, .crm-timeline li { display: flex; gap: 10px; }.timeline-dot { width: 8px; height: 8px; flex: 0 0 auto; margin-top: 6px; border-radius: 50%; background: var(--teal); box-shadow: 0 0 0 4px var(--teal-soft); }.event-timeline small, .crm-timeline small { display: block; color: var(--muted); font-size: 11px; } .event-timeline, .crm-timeline { margin-top: 20px; }.event-timeline h4 { margin-bottom: 10px; }.event-timeline ol, .crm-timeline { display: grid; gap: 15px; margin: 0; padding: 0; list-style: none; }.event-timeline li, .crm-timeline li { display: flex; gap: 10px; }.timeline-dot { width: 8px; height: 8px; flex: 0 0 auto; margin-top: 6px; border-radius: 50%; background: var(--teal); box-shadow: 0 0 0 4px var(--teal-soft); }.event-timeline small, .crm-timeline small { display: block; color: var(--muted); font-size: 11px; }
.source-safety, .suppression-warning { margin: 14px 0; padding: 12px 15px; border: 1px solid #ead6a9; border-radius: 9px; color: #77500c; background: var(--amber-soft); font-size: 12px; }.source-row { display: grid; grid-template-columns: minmax(170px,1fr) auto; gap: 12px 18px; padding: 16px 0; border-top: 1px solid var(--line); }.source-row-main { display: flex; min-width: 0; flex-direction: column; gap: 3px; }.source-row-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.source-row-main small { color: var(--muted); font-size: 11px; }.source-facts { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 0; }.source-actions { grid-column: 1 / -1; display: flex; gap: 7px; }.csv-panel .panel-heading { align-items: center; }.upload-label { cursor: pointer; }.csv-empty { display: grid; place-items: center; min-height: 160px; margin-top: 17px; border: 1px dashed var(--line-strong); border-radius: 10px; color: var(--muted); text-align: center; }.csv-empty span { font-size: 28px; color: var(--teal); }.csv-empty p { margin: 6px 0; }.csv-empty small { font-size: 11px; }.csv-table { margin-top: 17px; overflow: auto; }.csv-table table { min-width: 480px; } .source-safety, .suppression-warning { margin: 14px 0; padding: 12px 15px; border: 1px solid #ead6a9; border-radius: 9px; color: #77500c; background: var(--amber-soft); font-size: 12px; }.source-row { display: grid; grid-template-columns: minmax(170px,1fr) auto; gap: 12px 18px; padding: 16px 0; border-top: 1px solid var(--line); }.source-row-main { display: flex; min-width: 0; flex-direction: column; gap: 3px; }.source-row-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.source-row-main small { color: var(--muted); font-size: 11px; }.source-title-line { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }.source-type { padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: var(--surface-alt); font-size: 10px; font-weight: 750; }.source-optional { opacity: .78; background: linear-gradient(90deg, rgba(247,245,239,.65), transparent); }.source-optional .source-row-main strong { color: var(--muted); }.source-registry-caption, .source-setup-copy { max-width: 720px; margin: 7px 0 0; font-size: 12px; }.source-facts { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 0; }.source-registry-facts { grid-template-columns: repeat(6, minmax(0, 1fr)); }.source-facts div { padding: 9px; border: 1px solid var(--line); border-radius: 8px; background: #fff; }.source-facts dt { color: var(--muted); font-size: 10px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }.source-facts dd { margin: 4px 0 0; overflow-wrap: anywhere; }.source-actions { grid-column: 1 / -1; display: flex; gap: 7px; }.source-status.unavailable { color: var(--muted); background: var(--surface-alt); }.source-status.disabled { color: var(--amber); background: var(--amber-soft); }.csv-panel .panel-heading { align-items: center; }.upload-label { cursor: pointer; }.csv-empty { display: grid; place-items: center; min-height: 160px; margin-top: 17px; border: 1px dashed var(--line-strong); border-radius: 10px; color: var(--muted); text-align: center; }.csv-empty span { font-size: 28px; color: var(--teal); }.csv-empty p { margin: 6px 0; }.csv-empty small { font-size: 11px; }.csv-table { margin-top: 17px; overflow: auto; }.csv-table table { min-width: 480px; }
/* CRM and evidence modules */ /* CRM and evidence modules */
.pipeline-board { display: grid; grid-template-columns: repeat(4, minmax(190px, 1fr)); gap: 11px; overflow-x: auto; align-items: start; }.pipeline-column { min-height: 180px; padding: 11px; border: 1px solid var(--line); border-radius: 11px; background: #e9eeed; }.pipeline-column-head { display: flex; align-items: center; justify-content: space-between; }.pipeline-column-head h3 { margin: 0; font-size: 13px; }.pipeline-card { margin: 8px 0; padding: 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); box-shadow: var(--shadow-sm); }.pipeline-card.is-suppressed { border-color: #e2a9b0; background: #fff7f7; }.pipeline-card-link { display: grid; width: 100%; gap: 4px; padding: 0; border: 0; color: inherit; background: none; text-align: left; }.pipeline-card-link small, .pipeline-card-link .score { font-size: 11px; color: var(--muted); }.pipeline-card-actions { display: flex; gap: 6px; margin-top: 9px; }.pipeline-card-actions select { min-width: 0; padding: 6px; font-size: 11px; }.pipeline-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; }.pipeline-list-view { display: block; }.crm-column-empty { padding: 20px 8px; color: var(--muted); font-size: 11px; text-align: center; }.crm-timeline .outcome-chip { margin-left: 7px; color: var(--navy-2); background: var(--blue-soft); }.crm-timeline p { margin: 5px 0; }.reports-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }.report-panel { min-height: 190px; }.report-rows { display: grid; gap: 7px; margin-top: 15px; }.report-rows > div { display: flex; justify-content: space-between; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); }.report-rows strong { color: var(--teal); }.report-note { color: var(--muted); font-size: 11px; }.suppression-list { display: grid; gap: 8px; }.suppression-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px; border: 1px solid var(--line); border-radius: 8px; }.suppression-row .checkbox-label { flex: 1; margin: 0; }.suppression-row .checkbox-label > span { display: flex; min-width: 0; flex-direction: column; }.suppression-row small { color: var(--muted); }.provider-policy-panel { display: grid; gap: 10px; }.provider-policy-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }.provider-policy-row strong { display: block; }.provider-policy-row small { color: var(--muted); }.provider-policy-row dl { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 0; }.provider-policy-row dd { margin: 3px 0 0; }.provider-state { padding: 20px 4px; color: var(--muted); }.provider-state strong { color: var(--ink); }.provider-state.error strong { color: var(--red); } .pipeline-board { display: grid; grid-template-columns: repeat(4, minmax(190px, 1fr)); gap: 11px; overflow-x: auto; align-items: start; }.pipeline-column { min-height: 180px; padding: 11px; border: 1px solid var(--line); border-radius: 11px; background: #e9eeed; }.pipeline-column-head { display: flex; align-items: center; justify-content: space-between; }.pipeline-column-head h3 { margin: 0; font-size: 13px; }.pipeline-card { margin: 8px 0; padding: 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); box-shadow: var(--shadow-sm); }.pipeline-card.is-suppressed { border-color: #e2a9b0; background: #fff7f7; }.pipeline-card-link { display: grid; width: 100%; gap: 4px; padding: 0; border: 0; color: inherit; background: none; text-align: left; }.pipeline-card-link small, .pipeline-card-link .score { font-size: 11px; color: var(--muted); }.pipeline-card-actions { display: flex; gap: 6px; margin-top: 9px; }.pipeline-card-actions select { min-width: 0; padding: 6px; font-size: 11px; }.pipeline-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; }.pipeline-list-view { display: block; }.crm-column-empty { padding: 20px 8px; color: var(--muted); font-size: 11px; text-align: center; }.crm-timeline .outcome-chip { margin-left: 7px; color: var(--navy-2); background: var(--blue-soft); }.crm-timeline p { margin: 5px 0; }.reports-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }.report-panel { min-height: 190px; }.report-rows { display: grid; gap: 7px; margin-top: 15px; }.report-rows > div { display: flex; justify-content: space-between; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); }.report-rows strong { color: var(--teal); }.report-note { color: var(--muted); font-size: 11px; }.suppression-list { display: grid; gap: 8px; }.suppression-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px; border: 1px solid var(--line); border-radius: 8px; }.suppression-row .checkbox-label { flex: 1; margin: 0; }.suppression-row .checkbox-label > span { display: flex; min-width: 0; flex-direction: column; }.suppression-row small { color: var(--muted); }.provider-policy-panel { display: grid; gap: 10px; }.provider-policy-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }.provider-policy-row strong { display: block; }.provider-policy-row small { color: var(--muted); }.provider-policy-row dl { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 0; }.provider-policy-row dd { margin: 3px 0 0; }.provider-state { padding: 20px 4px; color: var(--muted); }.provider-state strong { color: var(--ink); }.provider-state.error strong { color: var(--red); }
+20
View File
@@ -1,10 +1,14 @@
import http.client import http.client
import json
import os import os
import threading import threading
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from server import ProxyStaticHandler from server import ProxyStaticHandler
from http.server import ThreadingHTTPServer
RealHTTPConnection = http.client.HTTPConnection
class FakeUpstream: class FakeUpstream:
@@ -30,6 +34,22 @@ class ProxyServerTests(unittest.TestCase):
with patch('server.http.client.HTTPConnection', FakeUpstream): with patch('server.http.client.HTTPConnection', FakeUpstream):
self.assertTrue(server.proxy_api) self.assertTrue(server.proxy_api)
def test_upstream_failure_is_structured_json(self):
httpd = ThreadingHTTPServer(("127.0.0.1", 0), ProxyStaticHandler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
with patch('server.http.client.HTTPConnection', side_effect=OSError('connection refused')):
conn = RealHTTPConnection('127.0.0.1', httpd.server_port, timeout=3)
conn.request('GET', '/api/v1/dashboard/summary')
response = conn.getresponse()
body = response.read()
self.assertEqual(response.status, 502)
self.assertEqual(response.getheader('Content-Type'), 'application/json; charset=utf-8')
self.assertEqual(json.loads(body), {'error': 'api_upstream_unavailable'})
finally:
httpd.shutdown(); httpd.server_close(); thread.join(timeout=2)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()