deploy ProspectOS workspace v2 final

This commit is contained in:
Marco0300
2026-09-06 20:17:29 +02:00
parent 7250f6d521
commit 05984c9ae6
8 changed files with 351 additions and 13 deletions
+265 -7
View File
@@ -24,11 +24,12 @@ import urllib.error
import urllib.request
from datetime import datetime, timezone
from html.parser import HTMLParser
from html import escape as html_escape
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from http.cookies import SimpleCookie
from pathlib import Path
from typing import Any, Callable
from urllib.parse import parse_qs, quote_plus, unquote, urlparse
from urllib.parse import parse_qs, quote, quote_plus, unquote, urlparse
try:
from intelligence import domain_candidates, scan_website, _safe_url
@@ -65,6 +66,7 @@ AUTH_COOKIE_NAME = "prospect_session"
AUTH_SESSION_TTL_SECONDS = max(3600, min(60 * 60 * 24 * 30, int(os.environ.get("AUTH_SESSION_TTL_SECONDS", str(60 * 60 * 24 * 7)))))
AUTH_COOKIE_SECURE = os.environ.get("AUTH_COOKIE_SECURE", "false").strip().lower() not in {"0", "false", "no", "off"}
WORKSPACE_NAME = os.environ.get("WORKSPACE_NAME", "ProspectOS workspace").strip() or "ProspectOS workspace"
PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "").strip().rstrip("/")
BACKUP_DIR = os.environ.get("BACKUP_DIR", "").strip()
try:
BACKUP_RETENTION = max(1, min(100, int(os.environ.get("BACKUP_RETENTION", "14"))))
@@ -462,6 +464,7 @@ def db() -> sqlite3.Connection:
ai_unknowns TEXT NOT NULL DEFAULT '[]',
ai_evidence_ids TEXT NOT NULL DEFAULT '[]',
ai_follow_up TEXT NOT NULL DEFAULT '',
ai_mockup_id INTEGER,
opportunity_score INTEGER NOT NULL DEFAULT 0,
raw_json TEXT NOT NULL DEFAULT '{}'
)""")
@@ -598,6 +601,22 @@ def db() -> sqlite3.Connection:
model TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)""")
conn.execute("""CREATE TABLE IF NOT EXISTS mockups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id INTEGER NOT NULL DEFAULT 1,
prospect_id INTEGER NOT NULL,
created_by INTEGER,
token TEXT NOT NULL UNIQUE,
title TEXT NOT NULL DEFAULT '',
source_url TEXT NOT NULL DEFAULT '',
html TEXT NOT NULL,
ai_model TEXT NOT NULL DEFAULT '',
generation_source TEXT NOT NULL DEFAULT 'fallback',
instructions TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'published',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)""")
conn.execute("""CREATE TABLE IF NOT EXISTS audit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id INTEGER NOT NULL DEFAULT 1,
@@ -666,6 +685,7 @@ def db() -> sqlite3.Connection:
"ai_evidence_ids": "TEXT NOT NULL DEFAULT '[]'",
"ai_follow_up": "TEXT NOT NULL DEFAULT ''",
"ai_promotion_id": "INTEGER",
"ai_mockup_id": "INTEGER",
"opportunity_score": "INTEGER NOT NULL DEFAULT 0",
"raw_json": "TEXT NOT NULL DEFAULT '{}'",
}
@@ -705,12 +725,13 @@ def db() -> sqlite3.Connection:
"ai_assessment_history": {"organization_id": "INTEGER NOT NULL DEFAULT 1"},
"audit_events": {"organization_id": "INTEGER NOT NULL DEFAULT 1"},
"run_events": {"organization_id": "INTEGER NOT NULL DEFAULT 1"},
"mockups": {"organization_id": "INTEGER NOT NULL DEFAULT 1"},
}.items():
existing_fields = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
for name, ddl in fields.items():
if name not in existing_fields:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}")
for table in ("prospects", "runs", "suppressions", "schedules", "website_candidates", "logs", "promotions", "prospect_notes", "prospect_tasks", "score_history", "ai_assessment_history", "audit_events", "run_events"):
for table in ("prospects", "runs", "suppressions", "schedules", "website_candidates", "logs", "promotions", "prospect_notes", "prospect_tasks", "score_history", "ai_assessment_history", "audit_events", "run_events", "mockups"):
conn.execute(f"UPDATE {table} SET organization_id=1 WHERE organization_id IS NULL OR organization_id=0")
conn.execute("CREATE INDEX IF NOT EXISTS idx_prospects_review ON prospects(review_status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_prospects_score ON prospects(opportunity_score DESC)")
@@ -729,6 +750,8 @@ def db() -> sqlite3.Connection:
conn.execute("CREATE INDEX IF NOT EXISTS idx_ai_history_prospect ON ai_assessment_history(organization_id, prospect_id, id DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_org_created ON audit_events(organization_id, id DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_run_events_run ON run_events(organization_id, run_id, id DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_mockups_prospect ON mockups(organization_id, prospect_id, id DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_mockups_token ON mockups(token)")
conn.commit()
_schema_path = DB_PATH
_schema_lock.release()
@@ -1921,7 +1944,7 @@ def scheduler_loop() -> None:
time.sleep(30)
def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm", goal: str = "conversation", promotion: dict[str, Any] | None = None) -> dict[str, Any]:
def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm", goal: str = "conversation", promotion: dict[str, Any] | None = None, mockup: dict[str, Any] | None = None) -> dict[str, Any]:
"""Generate an evidence-grounded, human-editable outreach draft.
``tone`` and ``goal`` are deliberately small, validated controls rather
@@ -1945,6 +1968,8 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
evidence_ids.append("public_email_listing")
if prospect.get("phone"):
evidence_ids.append("public_phone_listing")
if mockup and mockup.get("share_url"):
evidence_ids.append("mockup_preview")
tone_options = {"warm": "warm and approachable", "direct": "direct and concise", "consultative": "consultative and helpful"}
goal_options = {"conversation": "start a low-pressure conversation", "review": "offer a short website/hosting review", "quote": "invite them to request pricing or a quote"}
tone_key = str(tone or "warm").strip().lower()
@@ -1990,6 +2015,10 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
"name": promotion.get("name"), "description": promotion.get("description"),
"terms": promotion.get("terms"), "starts_at": promotion.get("starts_at"), "ends_at": promotion.get("ends_at"),
} if promotion else None,
"mockup_preview": {
"title": mockup.get("title"), "url": mockup.get("share_url"),
"source": mockup.get("generation_source"),
} if mockup and mockup.get("share_url") else None,
}
system = (
"You are an expert B2B copywriter for a small South African web-hosting and website-design business. "
@@ -2012,7 +2041,9 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
"unknowns, evidence_used, follow_up. subject and message are plain text. rationale is concise. "
"recommended_offer is a short summary of the user's offer. unknowns is an array of facts to confirm. "
"evidence_used is an array containing only supplied evidence IDs. follow_up is a short human review note, "
"not part of the email. If a selected promotion is supplied, mention it naturally only when it is active and its exact terms are supplied; never invent an expiry, discount, price, or eligibility rule."
"not part of the email. If a selected promotion is supplied, mention it naturally only when it is active and its exact terms are supplied; never invent an expiry, discount, price, or eligibility rule. "
"If a mockup_preview URL is supplied, you may mention it once as an optional concept preview. Use the exact URL, "
"call it a concept/preview rather than a live or finished site, and never imply that the prospect approved it."
)
prompt = json.dumps({"evidence": evidence, "user_offer_or_instruction": offer[:2000], "tone": tone_key, "goal": goal_key}, ensure_ascii=False)
headers = {"Authorization": "Bearer " + ai_api_key} if ai_api_key else {}
@@ -2037,14 +2068,23 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
return []
return [str(item).strip()[:size] for item in value if str(item).strip()][:limit]
used = [item for item in text_list(parsed.get("evidence_used"), limit=10, size=80) if item in evidence_ids]
message_text = str(parsed["message"]).strip()[:8000]
mockup_url = str(mockup.get("share_url") or "").strip() if mockup else ""
# The link is an explicit part of the requested workflow. If the model
# omits it despite receiving the evidence, append a clear editable line.
if mockup_url and mockup_url not in message_text:
message_text = (message_text.rstrip() + "\n\nI also prepared a concept preview for you: " + mockup_url).strip()[:8000]
if mockup_url and "mockup_preview" not in used:
used.append("mockup_preview")
return {
"subject": str(parsed["subject"]).strip()[:200],
"message": str(parsed["message"]).strip()[:8000],
"message": message_text,
"rationale": str(parsed.get("rationale") or "").strip()[:2000],
"recommended_offer": str(parsed.get("recommended_offer") or offer).strip()[:2000],
"unknowns": text_list(parsed.get("unknowns")),
"evidence_used": used,
"follow_up": str(parsed.get("follow_up") or "Review the draft and confirm the offer before sending.").strip()[:1000],
"mockup_url": mockup_url,
}
@@ -2131,6 +2171,101 @@ def ai_assess_prospect(prospect: dict[str, Any]) -> dict[str, Any]:
}
def _mockup_text(value: Any, limit: int = 6000) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()[:limit]
def sanitize_mockup_html(value: Any) -> str:
"""Remove active or unsafe markup before a generated preview is published."""
text = str(value or "").strip()
if text.startswith("```"):
text = re.sub(r"^```(?:html)?\s*|\s*```$", "", text, flags=re.I | re.S).strip()
text = re.sub(r"<script\b[^>]*>.*?</script\s*>", "", text, flags=re.I | re.S)
text = re.sub(r"<iframe\b[^>]*>.*?</iframe\s*>", "", text, flags=re.I | re.S)
text = re.sub(r"<form\b[^>]*>.*?</form\s*>", "", text, flags=re.I | re.S)
text = re.sub(r"<link\b[^>]*>", "", text, flags=re.I | re.S)
text = re.sub(r"<img\b[^>]*>", "", text, flags=re.I | re.S)
text = re.sub(r"<meta\b[^>]*(?:http-equiv\s*=\s*['\"]?refresh|content\s*=\s*['\"]?\d+\s*;\s*url)[^>]*>", "", text, flags=re.I | re.S)
text = re.sub(r"<(?:object|embed|video|audio|base)\b[^>]*>.*?</(?:object|embed|video|audio|base)\s*>", "", text, flags=re.I | re.S)
text = re.sub(r"\s+on[a-z]+\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s>]+)", "", text, flags=re.I)
text = re.sub(r"\s+(?:href|src)\s*=\s*(['\"])\s*javascript:[^'\"]*\1", "", text, flags=re.I)
text = re.sub(r"url\s*\(\s*['\"]?\s*javascript:[^)]+\)", "", text, flags=re.I)
if not re.search(r"<html\b|<main\b|<body\b", text, flags=re.I):
return ""
return text[:250_000]
def fallback_mockup_html(prospect: dict[str, Any], snapshot: dict[str, Any] | None = None) -> str:
"""Produce a polished no-AI fallback so the button always gives a preview."""
snapshot = snapshot or {}
name = html_escape(_mockup_text(prospect.get("name"), 160) or "Your business")
category = html_escape(_mockup_text(prospect.get("category") or prospect.get("search_category"), 120) or "Local business")
address = html_escape(_mockup_text(prospect.get("address"), 240))
phone = _mockup_text(prospect.get("phone"), 80)
email = _mockup_text(prospect.get("emails"), 160).split(",")[0].strip()
website = html_escape(_mockup_text(prospect.get("website") or snapshot.get("website_url"), 400))
title = html_escape(_mockup_text(snapshot.get("website_title"), 160) or name)
description = html_escape(_mockup_text(prospect.get("description"), 500) or f"A clearer, faster online home for {name}.")
phone_link = html_escape(re.sub(r"[^0-9+]+", "", phone))
email_link = html_escape(email)
contact = " · ".join(item for item in [address, html_escape(phone), html_escape(email)] if item)
return f'''<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#101a2d"><title>{name} · Website concept</title>
<style>
:root{{--ink:#17233a;--muted:#66738a;--brand:#635bff;--brand2:#16a77a;--surface:#fff;--soft:#f4f7fb;--line:#e6ebf2;--max:1120px;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:var(--soft);line-height:1.5}}*{{box-sizing:border-box}}body{{margin:0}}a{{color:inherit}}.preview{{position:sticky;top:0;z-index:3;text-align:center;padding:8px 14px;background:#111d2e;color:#dfe6f5;font:700 11px/1.2 system-ui;letter-spacing:.03em}}.nav{{display:flex;align-items:center;justify-content:space-between;gap:20px;max-width:var(--max);margin:auto;padding:20px 24px}}.brand{{font-size:18px;font-weight:850;letter-spacing:-.03em}}.brand i{{display:inline-block;width:10px;height:10px;margin-right:8px;border-radius:50%;background:var(--brand)}}.navlinks{{display:flex;gap:22px;color:var(--muted);font-size:13px;font-weight:700}}.navlinks a{{text-decoration:none}}.btn{{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 17px;border-radius:10px;background:var(--brand);color:#fff;text-decoration:none;font-size:13px;font-weight:800;box-shadow:0 8px 18px #635bff36}}.hero{{background:linear-gradient(135deg,#f1f2ff 0,#fff 62%);border-top:1px solid var(--line);border-bottom:1px solid var(--line)}}.hero-inner{{display:grid;grid-template-columns:1.05fr .95fr;align-items:center;gap:50px;max-width:var(--max);margin:auto;padding:88px 24px}}.eyebrow{{color:var(--brand);font-size:11px;font-weight:900;letter-spacing:.17em;text-transform:uppercase}}h1{{max-width:650px;margin:12px 0;font-size:clamp(37px,5vw,66px);line-height:1.03;letter-spacing:-.06em}}.lead{{max-width:590px;color:var(--muted);font-size:18px}}.hero-actions{{display:flex;flex-wrap:wrap;gap:10px;margin-top:28px}}.ghost{{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 17px;border:1px solid #ccd5e3;border-radius:10px;color:var(--ink);text-decoration:none;font-size:13px;font-weight:800;background:#fff}}.hero-card{{min-height:320px;padding:20px;border:1px solid #e0e4ff;border-radius:24px;background:linear-gradient(145deg,#e9e8ff,#fff 60%);box-shadow:0 25px 60px #2a386014;display:flex;flex-direction:column;justify-content:flex-end}}.hero-card-top{{width:60%;height:14px;border-radius:20px;background:#b9b4ff;margin-bottom:auto}}.hero-card-panel{{padding:18px;border-radius:15px;background:#ffffffbd;border:1px solid #fff}}.hero-card-panel b{{display:block;font-size:22px}}.hero-card-panel span{{display:block;color:var(--muted);font-size:12px;margin-top:4px}}.section{{max-width:var(--max);margin:auto;padding:72px 24px}}.section h2{{margin:8px 0 12px;font-size:34px;letter-spacing:-.05em}}.section-intro{{max-width:620px;color:var(--muted)}}.cards{{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-top:28px}}.card{{padding:24px;border:1px solid var(--line);border-radius:16px;background:var(--surface);box-shadow:0 9px 28px #27365308}}.icon{{width:38px;height:38px;display:grid;place-items:center;border-radius:12px;background:#ebeaff;color:var(--brand);font-weight:900}}.card h3{{margin:18px 0 5px;font-size:16px}}.card p{{margin:0;color:var(--muted);font-size:13px}}.contact{{background:#111d2e;color:#fff}}.contact .section-intro{{color:#aeb9ce}}.contact-grid{{display:grid;grid-template-columns:1fr auto;gap:30px;align-items:center}}.contact-links{{display:flex;flex-wrap:wrap;gap:10px}}.contact-links a{{color:#fff;text-decoration:none;border:1px solid #ffffff3a;border-radius:9px;padding:10px 13px;font-size:12px}}footer{{max-width:var(--max);margin:auto;padding:24px;color:#8a96a8;font-size:11px;text-align:center}}@media(max-width:760px){{.navlinks{{display:none}}.hero-inner{{grid-template-columns:1fr;padding:58px 20px 50px;gap:32px}}h1{{font-size:44px}}.lead{{font-size:16px}}.hero-card{{min-height:230px}}.section{{padding:52px 20px}}.cards{{grid-template-columns:1fr}}.contact-grid{{grid-template-columns:1fr}}.contact-links{{display:grid}}.contact-links a{{display:block}}}}
</style></head><body><div class="preview">WEBSITE CONCEPT PREVIEW · prepared for review</div><header><nav class="nav"><div class="brand"><i></i>{name}</div><div class="navlinks"><a href="#services">Services</a><a href="#about">About</a><a href="#contact">Contact</a></div><a class="btn" href="#contact">Get in touch</a></nav></header><main><section class="hero"><div class="hero-inner"><div><span class="eyebrow">{category}</span><h1>{name}, made easier to find.</h1><p class="lead">{description}</p><div class="hero-actions"><a class="btn" href="#contact">Start a conversation</a><a class="ghost" href="#services">Explore services</a></div></div><div class="hero-card"><div class="hero-card-top"></div><div class="hero-card-panel"><b>{title}</b><span>A modern, mobile-first way to present what you do.</span></div></div></div></section><section class="section" id="services"><span class="eyebrow">WHAT WE DO</span><h2>Clear services. Better first impressions.</h2><p class="section-intro">Visitors can quickly understand how {name} helps, then take the next step from any device.</p><div class="cards"><article class="card"><div class="icon">01</div><h3>Simple navigation</h3><p>Important information is easy to find on mobile and desktop.</p></article><article class="card"><div class="icon">02</div><h3>Trust at a glance</h3><p>Strong hierarchy and useful contact details make the business feel established.</p></article><article class="card"><div class="icon">03</div><h3>Built for action</h3><p>Clear calls to action help interested visitors make contact.</p></article></div></section><section class="section" id="about"><span class="eyebrow">ABOUT {category.upper()}</span><h2>A stronger home for your business.</h2><p class="section-intro">This concept uses the public information currently available and leaves space for your exact services, photos, proof, and preferred contact flow.</p></section><section class="contact"><div class="section contact-grid" id="contact"><div><span class="eyebrow" style="color:#a9a1ff">LET'S TALK</span><h2>Ready to make it easier for customers to choose you?</h2><p class="section-intro">A focused website can turn interest into a call, message, or enquiry.</p></div><div class="contact-links">{f'<a href="tel:{phone_link}">{html_escape(phone)}</a>' if phone else ''}{f'<a href="mailto:{email_link}">{html_escape(email)}</a>' if email else ''}{f'<a href="{website}" target="_blank" rel="noopener">Current website ↗</a>' if website else ''}</div></div></section></main><footer>{contact or name} · website concept preview</footer></body></html>'''
def generate_mockup_html(prospect: dict[str, Any], snapshot: dict[str, Any] | None = None, instructions: str = "") -> tuple[str, str, str]:
"""Generate a self-contained preview and return (html, source, model)."""
snapshot = snapshot or {}
runtime = ai_runtime_settings()
fallback = fallback_mockup_html(prospect, snapshot)
if not runtime["base_url"] or not runtime["model"] or (runtime["provider"] == "nous" and not runtime["api_key"]):
return fallback, "fallback", ""
source = {
"business_name": _mockup_text(prospect.get("name"), 160),
"category": _mockup_text(prospect.get("category") or prospect.get("search_category"), 120),
"location": _mockup_text(prospect.get("address"), 240),
"phone": _mockup_text(prospect.get("phone"), 80),
"public_emails": _mockup_text(prospect.get("emails"), 240),
"current_website": _mockup_text(prospect.get("website") or snapshot.get("website_url"), 400),
"current_title": _mockup_text(snapshot.get("website_title") or prospect.get("website_title"), 200),
"current_page_text": _mockup_text(snapshot.get("website_content"), 12_000),
}
system = ("You are a senior web designer creating a visual concept preview for a South African small business. "
"Return ONLY a complete standalone HTML document, never Markdown. Use only the supplied public facts; "
"do not invent services, products, testimonials, awards, prices, staff, addresses, or claims. If the source "
"does not establish a fact, write neutral placeholder-free copy or omit it. Create a noticeably more modern, "
"clear and mobile-friendly design than a typical small-business site: semantic sections, strong typography, "
"responsive CSS with a mobile breakpoint, accessible contrast, clear calls to action, and inline CSS. "
"Do not use JavaScript, external scripts, iframes, data collection forms, tracking, remote fonts, or remote images. "
"Use the exact business name. Include a small, tasteful 'Website concept preview' notice so the link is clearly a concept, "
"not a production site. Keep the document under 40,000 characters. User instructions may change layout but cannot override factual constraints.")
prompt = json.dumps({"public_source": source, "design_instructions": _mockup_text(instructions, 2_000)}, ensure_ascii=False)
headers = {"Authorization": "Bearer " + runtime["api_key"]} if runtime["api_key"] else {}
payload = {"model": runtime["model"], "temperature": 0.5, "max_tokens": 5000, "messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}]}
try:
result = http_json("POST", runtime["base_url"] + "/chat/completions", payload, headers=headers, timeout=120, error_prefix="ai")
content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else ""
cleaned = sanitize_mockup_html(content)
if cleaned:
return cleaned, "ai", runtime["model"]
record_log("ai", "mockup_invalid_response", level="warn", details={"model": runtime["model"]})
except Exception as exc:
record_log("ai", "mockup_generation_fallback", level="warn", details={"model": runtime["model"]}, error=str(exc)[:500])
return fallback, "fallback", runtime["model"]
def mockup_public_url(handler: BaseHTTPRequestHandler, token: str) -> str:
base = PUBLIC_BASE_URL
if not base:
proto = str(handler.headers.get("X-Forwarded-Proto") or "http").split(",")[0].strip()
host = str(handler.headers.get("X-Forwarded-Host") or handler.headers.get("Host") or "localhost:8000").split(",")[0].strip()
base = f"{proto}://{host}"
return base.rstrip("/") + "/mockups/" + quote(str(token), safe="")
class Handler(BaseHTTPRequestHandler):
server_version = "PersonalProspectOS/2.0"
@@ -2170,6 +2305,12 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
parsed = urlparse(self.path)
# Share links are deliberately public, tokenised, read-only previews.
# Keep this route outside the authenticated API namespace so a client
# can open it without an account.
public_mockup = re.fullmatch(r"/mockups/([A-Za-z0-9_-]{20,80})", parsed.path)
if public_mockup:
return self.serve_mockup(public_mockup.group(1))
if not parsed.path.startswith("/api/"):
self.serve_static(parsed.path)
return
@@ -2339,6 +2480,21 @@ class Handler(BaseHTTPRequestHandler):
return self.send_json(404, {"error": "not_found"})
rows = conn.execute("SELECT * FROM website_candidates WHERE prospect_id=? AND organization_id=? ORDER BY confidence DESC, id DESC", (prospect_id, getattr(self, "_organization_id", 1))).fetchall()
return self.send_json(200, {"items": [dict(row) for row in rows]})
match = re.fullmatch(r"/api/prospects/(\d+)/mockups", parsed.path)
if match:
prospect_id = int(match.group(1))
organization_id = getattr(self, "_organization_id", 1)
if not conn.execute("SELECT 1 FROM prospects WHERE id=? AND organization_id=?", (prospect_id, organization_id)).fetchone():
return self.send_json(404, {"error": "not_found"})
rows = conn.execute("""SELECT id,prospect_id,token,title,source_url,ai_model,generation_source,
instructions,status,created_at,updated_at FROM mockups
WHERE prospect_id=? AND organization_id=? ORDER BY id DESC""", (prospect_id, organization_id)).fetchall()
items = []
for row in rows:
item = dict(row)
item["share_url"] = mockup_public_url(self, item["token"])
items.append(item)
return self.send_json(200, {"items": items})
match = re.fullmatch(r"/api/prospects/(\d+)/(notes|tasks|history)", parsed.path)
if match:
prospect_id, resource, organization_id = int(match.group(1)), match.group(2), getattr(self, "_organization_id", 1)
@@ -2414,6 +2570,32 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(content)
def serve_mockup(self, token: str) -> None:
"""Serve a published, sanitised concept preview by opaque token."""
conn = db()
try:
row = conn.execute("SELECT html FROM mockups WHERE token=? AND status='published' LIMIT 1", (token,)).fetchone()
finally:
conn.close()
if not row:
body = b"Preview not found"
self.send_response(404)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
body = str(row["html"] or "").encode("utf-8", "replace")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("X-Robots-Tag", "noindex, nofollow")
self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
self.send_header("Cache-Control", "public, max-age=300")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self) -> None:
path = urlparse(self.path).path
if path == "/api/auth/setup":
@@ -2863,6 +3045,61 @@ class Handler(BaseHTTPRequestHandler):
conn.close()
except Exception:
pass
mockup_match = re.fullmatch(r"/api/prospects/(\d+)/mockup", urlparse(self.path).path)
if mockup_match:
if not role_allows(getattr(self, "_auth_user", None), "reviewer"):
return self.send_json(403, {"error": "permission_denied"})
conn: sqlite3.Connection | None = None
prospect_id = int(mockup_match.group(1))
try:
conn = db()
organization_id = getattr(self, "_organization_id", 1)
row = conn.execute("SELECT * FROM prospects WHERE id=? AND organization_id=?", (prospect_id, organization_id)).fetchone()
if not row:
return self.send_json(404, {"error": "not_found"})
payload = self.read_json()
instructions = str(payload.get("instructions") or "").strip()[:2_000]
prospect = dict(row)
source_url = str(prospect.get("website") or prospect.get("website_final_url") or "").strip()
snapshot: dict[str, Any] = {}
if source_url:
try:
snapshot = fetch_company_page(source_url)
except Exception as exc:
# A preview must still be useful when the current site
# is slow, blocked, or listed as unhealthy.
record_log("search", "mockup_source_fetch_failed", level="warn", prospect_id=prospect_id,
details={"url": _safe_log_url(source_url)}, error=str(exc)[:500])
html, generation_source, model = generate_mockup_html(prospect, snapshot, instructions)
token = secrets.token_urlsafe(24)
stamp = now()
title = (_mockup_text(prospect.get("name"), 140) or "Business") + " · Website concept"
cur = conn.execute("""INSERT INTO mockups
(organization_id,prospect_id,created_by,token,title,source_url,html,ai_model,generation_source,instructions,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", (organization_id, prospect_id, getattr(self, "_user_id", None), token,
title, str(snapshot.get("website_url") or source_url)[:500], html, model, generation_source, instructions, "published", stamp, stamp))
conn.execute("UPDATE prospects SET ai_mockup_id=?,updated_at=? WHERE id=? AND organization_id=?", (int(cur.lastrowid), stamp, prospect_id, organization_id))
conn.commit()
share_url = mockup_public_url(self, token)
record_log("ai", "mockup_generated", prospect_id=prospect_id,
details={"mockup_id": int(cur.lastrowid), "source": generation_source, "model": model,
"source_url": _safe_log_url(str(snapshot.get("website_url") or source_url)), "share_url": share_url})
record_audit("mockup_generated", organization_id=organization_id, user_id=getattr(self, "_user_id", None),
entity_type="mockup", entity_id=int(cur.lastrowid), details={"prospect_id": prospect_id, "source": generation_source, "model": model})
return self.send_json(201, {"id": int(cur.lastrowid), "prospect_id": prospect_id, "token": token,
"title": title, "source_url": str(snapshot.get("website_url") or source_url),
"generation_source": generation_source, "ai_model": model,
"created_at": stamp, "share_url": share_url})
except RuntimeError as exc:
return self.send_json(503, {"error": str(exc)})
except (ValueError, TypeError, json.JSONDecodeError) as exc:
return self.send_json(400, {"error": str(exc)})
except sqlite3.Error as exc:
record_log("ai", "mockup_persist_failed", level="error", prospect_id=prospect_id, error=str(exc)[:500])
return self.send_json(500, {"error": "mockup_persist_failed"})
finally:
if conn is not None:
conn.close()
assess_match = re.fullmatch(r"/api/prospects/(\d+)/assess", urlparse(self.path).path)
if assess_match:
if not role_allows(getattr(self, "_auth_user", None), "reviewer"):
@@ -2917,8 +3154,29 @@ class Handler(BaseHTTPRequestHandler):
promotion = dict(promotion_row)
else:
promotion_id = None
draft = ai_generate_message(dict(row), offer, tone=str(payload.get("tone") or "warm"), goal=str(payload.get("goal") or "conversation"), promotion=promotion)
stamp = now(); conn.execute("UPDATE prospects SET ai_subject=?,ai_message=?,ai_offer=?,ai_promotion_id=?,ai_reasoning=?,ai_unknowns=?,ai_evidence_ids=?,ai_follow_up=?,ai_checked_at=?,updated_at=? WHERE id=? AND organization_id=?", (draft["subject"],draft["message"],offer,promotion_id,draft["rationale"],json.dumps(draft.get("unknowns") or [], ensure_ascii=False),json.dumps(draft.get("evidence_used") or [], ensure_ascii=False),draft.get("follow_up") or "",stamp,stamp,int(message_match.group(1)),getattr(self, "_organization_id", 1))); conn.commit()
include_value = payload.get("include_mockup", True)
include_mockup = not (include_value is False or str(include_value).strip().lower() in {"0", "false", "off", "no"})
mockup = None
mockup_id = payload.get("mockup_id")
if include_mockup:
if mockup_id not in (None, "", 0, "0"):
try:
mockup_id = int(mockup_id)
except (TypeError, ValueError):
return self.send_json(400, {"error": "invalid_mockup_id"})
mockup_row = conn.execute("SELECT * FROM mockups WHERE id=? AND prospect_id=? AND organization_id=? AND status='published'", (mockup_id, int(message_match.group(1)), getattr(self, "_organization_id", 1))).fetchone()
else:
mockup_row = conn.execute("SELECT * FROM mockups WHERE prospect_id=? AND organization_id=? AND status='published' ORDER BY id DESC LIMIT 1", (int(message_match.group(1)), getattr(self, "_organization_id", 1))).fetchone()
if mockup_row:
mockup = dict(mockup_row)
mockup["share_url"] = mockup_public_url(self, mockup["token"])
mockup_id = int(mockup["id"])
else:
mockup_id = None
else:
mockup_id = None
draft = ai_generate_message(dict(row), offer, tone=str(payload.get("tone") or "warm"), goal=str(payload.get("goal") or "conversation"), promotion=promotion, mockup=mockup)
stamp = now(); conn.execute("UPDATE prospects SET ai_subject=?,ai_message=?,ai_offer=?,ai_promotion_id=?,ai_mockup_id=?,ai_reasoning=?,ai_unknowns=?,ai_evidence_ids=?,ai_follow_up=?,ai_checked_at=?,updated_at=? WHERE id=? AND organization_id=?", (draft["subject"],draft["message"],offer,promotion_id,mockup_id,draft["rationale"],json.dumps(draft.get("unknowns") or [], ensure_ascii=False),json.dumps(draft.get("evidence_used") or [], ensure_ascii=False),draft.get("follow_up") or "",stamp,stamp,int(message_match.group(1)),getattr(self, "_organization_id", 1))); conn.commit()
record_audit("prospect_message_generated", organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), entity_type="prospect", entity_id=int(message_match.group(1)), details={"promotion_id": promotion_id})
return self.send_json(200, {**draft,"ai_checked_at":stamp,"prospect_id":int(message_match.group(1))})
except RuntimeError as exc: return self.send_json(503, {"error":str(exc)})