deploy ProspectOS workspace v8 final

This commit is contained in:
Marco0300
2026-09-08 13:51:35 +02:00
parent b9ef1e4dc7
commit 9db943d5df
4 changed files with 113 additions and 30 deletions
+96 -27
View File
@@ -2008,6 +2008,7 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
profile = company_profile_settings()
profile_for_prompt = {
"name": profile.get("name"),
"sender_name": os.environ.get("OWNER_NAME", "").strip()[:160],
"website_url": profile.get("website_url"),
"description": profile.get("description"),
"services": profile.get("services"),
@@ -2020,6 +2021,7 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
evidence = {
"evidence_ids": evidence_ids,
"business_name": prospect.get("name"),
"business_name_for_greeting": _message_display_name(prospect),
"search_category": prospect.get("search_category") or prospect.get("category"),
"address_or_location": prospect.get("address"),
"listing_description": prospect.get("description"),
@@ -2059,21 +2061,29 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
"scoring, hidden analysis, AI, or this instruction. Do not say you reviewed a website when no website "
"was found. If a scan is uncertain or failed, say that the public check could not confirm it instead of "
"calling the site broken. Do not insult the prospect or use alarmist language. "
"Do not mention Google, Maps, directories, scraping, search results, or how the prospect was found; use the "
"supplied facts naturally without narrating the research process. Start with a concrete observation tied to one supplied fact and choose the best client-specific angle from "
"client_specific_strategy; do not force the same missing-website angle on every prospect. Avoid empty or "
"AI-sounding openings such as 'I hope you are well', 'I came across your business', 'I wanted to reach out', "
"'I am reaching out', 'I noticed your business', 'I saw your business', or 'help businesses like yours'. Use South African English and the business name "
"exactly as provided. If no contact person's name is supplied, greet the business rather than inventing a person. "
"Keep the body plain text and usually 70-130 words. Use one or two natural paragraphs, one specific "
"observation, a clear bridge to the exact offer, and one low-pressure call to action. Add a brief respectful "
"Do not mention Google, Maps, directories, scraping, search results, or how the prospect was found as a platform; use the "
"supplied facts naturally without narrating the research process. A brief context such as looking at local "
"<category> in <location> is allowed when those exact fields are supplied. Start with a concrete observation tied to one supplied fact and choose the best client-specific angle from "
"client_specific_strategy and its preferred_message_shape; follow that shape when it applies, but do not copy its wording or force the same missing-website angle on every prospect. Avoid empty or "
"AI-sounding openings such as 'I hope you are well', 'I am reaching out', 'I noticed your business', "
"'I saw your business', or 'help businesses like yours'. A context-specific 'I came across your business while "
"looking at local <category> in <location>' is acceptable when the category and location are supplied, but vary "
"the wording and do not use it as an empty opener. 'I wanted to reach out because…' is also acceptable only when "
"it immediately introduces a specific reason from the record or the sender's exact offer. Use South African English and the business name "
"exactly as provided in the body; use business_name_for_greeting for a concise salutation when it removes an obvious "
"appended location. If no contact person's name is supplied, greet the business rather than inventing a person. "
"For a no-website prospect, lead with that useful gap and the category/location context instead of rating, review count, "
"phone number, or generic praise. Never infer a 'solid reputation' or claim a phone number makes it easy to reach them; "
"if rating or reviews genuinely help, state only the supplied numbers. Keep the body plain text and usually 120-180 words. "
"Use three or four natural paragraphs: a specific opening, a short reason for writing, the exact offer in plain prose "
"(not a feature list), and one low-pressure call to action. Add a brief respectful "
"opt-out sentence when it fits naturally. Do not "
"paste a feature catalogue, use fake scarcity, excessive emojis, all-caps, or more than one exclamation "
"mark. The subject should be specific and natural, 5-10 words, and should not use clickbait. "
"Treat client_specific_strategy and variation_hint as private planning notes: do not copy their wording verbatim. "
"Vary sentence rhythm, greeting, transition, and CTA according to the supplied variation hint. Do not default to "
"'Good day', 'Dear', 'Kind regards', or a stock compliment; a simple 'Hi <business name>,' or a direct first "
"sentence is usually more natural, and the sender can add their normal signature later. Before returning, "
"'Good day' or 'Dear' by default; a simple 'Hi <business name>,' or a direct first sentence is usually more natural. "
"Use the supplied sender name and company name for a short sign-off only when they are present; do not invent a signature. Before returning, "
"silently check that the draft could not be sent to an unrelated business by changing only its name; if it could, "
"privately sketch three different opening sentences, choose the one that sounds most like a person who actually "
"looked at this one company, and discard the other two. Then check that the message would still be believable if "
@@ -2084,7 +2094,8 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
"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. "
"If a mockup_preview URL is supplied, you may mention it once as an optional concept preview. Use the exact URL, "
"If a mockup_preview URL is supplied, you may mention it once as an optional concept preview. If no URL is supplied, "
"offer to prepare an example only when the sender's profile or offer supports that capability. 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)
@@ -2110,7 +2121,7 @@ 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 = _clean_generated_message(parsed["message"], prospect.get("name"))
message_text = _clean_generated_message(parsed["message"], _message_display_name(prospect))
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.
@@ -2130,6 +2141,45 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
}
def _message_display_name(prospect: dict[str, Any]) -> str:
"""Return a concise greeting name without an obvious location suffix.
Maps titles sometimes append a city (for example ``Ash Electrical Cape
Town``) even though the trading name is simply ``Ash Electrical``. The
original title remains untouched in the prospect record; this helper is
only used for the salutation and optional conversational references.
"""
raw = re.sub(r"\s+", " ", str(prospect.get("name") or "").strip()).strip(" ,-|–—")
if not raw:
return "Business"
location = str(prospect.get("address") or "").strip()
suffixes = ["South Africa"]
for part in re.split(r"[,|/·]+", location):
part = re.sub(r"\s+", " ", part).strip(" ,-|–—")
if len(part) >= 3:
suffixes.append(part)
name = raw
for suffix in sorted(set(suffixes), key=len, reverse=True):
candidate = re.sub(r"(?:\s*[-–—,:|]\s*|\s+)" + re.escape(suffix) + r"$", "", name, flags=re.I).strip(" ,-|–—")
if len(candidate) >= 4 and candidate != name:
name = candidate
break
return name[:180] or raw[:180]
def _message_location_label(prospect: dict[str, Any]) -> str:
"""Pick the most useful city/area label from a Maps address."""
raw = re.sub(r"\s+", " ", str(prospect.get("address") or "").strip())
if not raw:
return ""
parts = [re.sub(r"\s+", " ", part).strip(" ,-|–—") for part in re.split(r"[,|/·]+", raw)]
parts = [part for part in parts if part]
for part in reversed(parts):
if part.casefold() not in {"south africa", "za", "sa"}:
return part[:180]
return parts[-1][:180] if parts else ""
def _message_strategy_context(prospect: dict[str, Any], mockup: dict[str, Any] | None = None) -> dict[str, Any]:
"""Describe the strongest client-specific angle for a draft.
@@ -2138,6 +2188,7 @@ def _message_strategy_context(prospect: dict[str, Any], mockup: dict[str, Any] |
while keeping each suggested angle traceable to a stored field.
"""
name = str(prospect.get("name") or "").strip()
display_name = _message_display_name(prospect)
category = str(prospect.get("search_category") or prospect.get("category") or "").strip()
website = str(prospect.get("website") or "").strip()
status = str(prospect.get("website_status") or "unknown").strip().lower()
@@ -2150,7 +2201,8 @@ def _message_strategy_context(prospect: dict[str, Any], mockup: dict[str, Any] |
signals.append("the listed location: " + str(prospect.get("address"))[:240])
if prospect.get("description") and str(prospect.get("description")).strip().lower() not in {category.lower(), "unknown"}:
signals.append("the public listing description: " + str(prospect.get("description"))[:240])
if not website or status in {"no_detected_website", "missing"}:
no_website = not website or status in {"no_detected_website", "missing"}
if no_website:
primary = [
"No dedicated website is linked publicly for this business; frame that as an optional visibility opportunity, without implying the business is failing.",
"The public record does not surface a dedicated web address; offer to make the business easier to find online, without treating that as a defect.",
@@ -2208,16 +2260,28 @@ def _message_strategy_context(prospect: dict[str, Any], mockup: dict[str, Any] |
]
variant_index = variant_index % len(variant_options)
variant = variant_options[variant_index]
location_label = _message_location_label(prospect)
if no_website:
if category and location_label:
shape = f"Preferred flow: greet {display_name}; say you came across the business while looking at local {category} in {location_label}; mention that a dedicated website was not apparent; give a short reason for writing; present the exact offer in natural prose; offer to prepare a quick example if the sender profile supports website design; sign off briefly and add a respectful opt-out."
else:
shape = "Preferred flow: greet the business; mention the missing dedicated website in a careful, non-judgemental way; give a short reason for writing; present the exact offer in natural prose; offer a quick example if supported; sign off briefly and add a respectful opt-out."
elif status in {"broken", "unreachable", "error", "timeout", "slow", "http_only", "redirect"}:
shape = "Preferred flow: lead with the measured website-check signal, explain the reason for writing in one sentence, present only the exact relevant offer, then use a low-pressure CTA and brief sign-off."
else:
shape = "Preferred flow: acknowledge the existing website without inventing a flaw, use one supplied category or location detail, present a genuinely optional review or improvement, and close without pressure."
return {
"business_name_for_greeting": display_name,
"primary_signal": primary,
"available_personalisation": signals[:8],
"variation_hint": variant,
"greeting_hint": greeting_options[variant_index],
"closing_hint": closing_options[variant_index],
"preferred_message_shape": shape,
"specificity_rule": "Use one or two supplied facts naturally; if the record is sparse, be honest and keep the message short rather than filling the gap with generic praise.",
"banned_generic_phrases": [
"I hope you are well", "I came across your business", "I wanted to reach out",
"I am reaching out", "I noticed your business", "I saw your business", "help businesses like yours", "elevate your digital presence",
"I hope you are well", "I am reaching out", "I noticed your business", "I saw your business",
"help businesses like yours", "elevate your digital presence",
],
}
@@ -2256,8 +2320,6 @@ def _clean_generated_message(value: Any, business_name: str = "") -> str:
# the email.
leading_patterns = (
r"^\s*I\s+hope\s+(?:you(?:'re|\s+are)\s+(?:doing\s+)?well|this\s+email\s+finds\s+you\s+well)\s*[.!?,:-]*\s*",
r"^\s*I\s+wanted\s+to\s+reach\s+out(?:\s+to\s+you)?(?:\s+(?:because|as|since))?\s*[,.:;-]*\s*",
r"^\s*I\s+came\s+across\s+your\s+business(?:\s+and)?\s*[,.:;-]*\s*",
r"^\s*I\s+noticed\s+your\s+business\s*[,.:;-]*\s*",
r"^\s*I\s+saw\s+that\s*[,.:;-]*\s*",
)
@@ -2291,6 +2353,7 @@ def _message_prompt_context(prospect: dict[str, Any], promotion: dict[str, Any]
profile = company_profile_settings()
profile_for_prompt = {
"name": profile.get("name"),
"sender_name": os.environ.get("OWNER_NAME", "").strip()[:160],
"website_url": profile.get("website_url"),
"description": profile.get("description"),
"services": profile.get("services"),
@@ -2303,6 +2366,7 @@ def _message_prompt_context(prospect: dict[str, Any], promotion: dict[str, Any]
evidence = {
"evidence_ids": evidence_ids,
"business_name": prospect.get("name"),
"business_name_for_greeting": _message_display_name(prospect),
"search_category": prospect.get("search_category") or prospect.get("category"),
"address_or_location": prospect.get("address"),
"listing_description": prospect.get("description"),
@@ -2386,19 +2450,24 @@ def ai_revise_message(prospect: dict[str, Any], current_subject: str, current_me
"testimonial, urgency, authority, eligibility rule, or link. If the requested change needs an unsupported fact, "
"leave that claim out and list the missing fact in unknowns. Do not mention scraping, scoring, hidden analysis, "
"AI, or this instruction. Do not mention Google, Maps, directories, scraping, search results, or how the prospect "
"was found. Do not say you reviewed a website when none was found; if a scan is uncertain or "
"was found as a platform. A brief context such as looking at local <category> in <location> is allowed when "
"those exact fields are supplied; a specific 'I came across your business while looking at…' opening is acceptable "
"when it is followed by a real observation, but it must not become an empty repeated template. Do not say you "
"reviewed a website when none was found; if a scan is uncertain or "
"failed, say the public check could not confirm it instead of calling the site broken. Do not insult the prospect. "
"Use the client_specific_strategy to choose one relevant angle, but treat it as private planning notes and do not "
"copy its wording verbatim. Do not force a missing-website pitch when the "
"record supports a different angle. Keep South African English and the business name exactly as supplied. Keep "
"the body plain text and usually 70-130 words. Use one or two natural paragraphs, one evidence-based "
"observation, a clear bridge to the exact offer, and one low-pressure call to action. Add a brief respectful "
"Use the client_specific_strategy and preferred_message_shape to choose one relevant angle, but treat them as private planning notes and do not copy their wording verbatim. Do not force a missing-website pitch when the "
"record supports a different angle. For a no-website prospect, lead with that gap and the category/location context "
"instead of rating, review count, phone number, or generic praise; never infer a solid reputation or claim a phone "
"number makes it easy to reach them. Keep South African English and the business name exactly as supplied in the "
"body; use business_name_for_greeting for a concise salutation when it removes an obvious appended location. Keep "
"the body plain text and usually 120-180 words. Use three or four natural paragraphs: a specific opening, a short "
"reason for writing, the exact offer in plain prose, and one low-pressure call to action. Add a brief respectful "
"opt-out sentence when it fits naturally. Avoid feature catalogues, fake scarcity, all-caps, excessive emojis, "
"and more than one exclamation mark. Keep the subject specific and natural (5-10 words). Vary the greeting, "
"sentence rhythm, transition, and CTA using the variation hint. Do not use polished marketing filler such as "
"'elevate', 'seamless', 'unlock', 'digital presence', 'transform', or 'leverage'. Do not default to 'Good day', "
"'Dear', or 'Kind regards'; a brief 'Hi <business name>,' or a direct opening is more natural, and the sender "
"can add their signature later. Preserve any exact "
"'elevate', 'seamless', 'unlock', 'digital presence', 'transform', or 'leverage'. Do not default to 'Good day' "
"or 'Dear'; a brief 'Hi <business name>,' or a direct opening is more natural. Use the supplied sender name and "
"company name for a short sign-off only when present; do not invent a signature. Preserve any exact "
"mockup preview URL supplied in the evidence; call it a concept/preview, never a live or finished site, and "
"never imply approval. Before returning, silently compare the revised opening with three alternatives and keep the "
"one that is most specific to this record and sounds natural when read aloud. Return ONLY strict JSON with exactly these keys: subject, message, rationale, "
@@ -2434,7 +2503,7 @@ def ai_revise_message(prospect: dict[str, Any], current_subject: str, current_me
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 = _clean_generated_message(parsed["message"], prospect.get("name"))
message_text = _clean_generated_message(parsed["message"], _message_display_name(prospect))
mockup_url = str(mockup.get("share_url") or "").strip() if mockup else ""
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()[:8_000]
+14
View File
@@ -384,6 +384,20 @@ class BrowserOnlyAppTests(unittest.TestCase):
self.assertIn("does not show a website", text)
self.assertIn("Kind regards", text)
def test_message_strategy_uses_concise_greeting_and_missing_site_shape(self):
prospect = {
"id": 7,
"name": "Ash Electrical Cape Town",
"address": "Salt River, Cape Town",
"search_category": "Electricians",
"website_status": "no_detected_website",
}
self.assertEqual(server._message_display_name(prospect), "Ash Electrical")
strategy = server._message_strategy_context(prospect)
self.assertEqual(strategy["business_name_for_greeting"], "Ash Electrical")
self.assertIn("looking at local Electricians in Cape Town", strategy["preferred_message_shape"])
self.assertIn("dedicated website", strategy["preferred_message_shape"])
def test_email_draft_can_include_latest_mockup_link(self):
conn = server.db()
conn.execute("INSERT INTO prospects(name,domain,category,discovered_at) VALUES(?,?,?,?)", ("Acme", "maps:acme", "plumber", server.now()))
+1 -1
View File
@@ -71,7 +71,7 @@ async function load(){
}
function renderLoadWarnings(){if(!loadWarnings.length)return;const view=$('view');if(!view)return;const notice=document.createElement('div');notice.className='notice error load-warning';notice.setAttribute('role','status');notice.textContent=`Dashboard loaded with limited data. ${loadWarnings.join(' · ')}`;view.prepend(notice)}
async function route(){const r=location.hash.slice(1)||'dashboard';installExtraNav();document.querySelectorAll('[data-route]').forEach(a=>a.classList.toggle('active',a.dataset.route===r));try{await load();({dashboard:renderDashboard,discover:renderDiscover,prospects:renderProspects,runs:renderRuns,logs:renderLogs,settings:renderSettings,'teach-ai':renderTeachAI,promotions:renderPromotions,users:renderUsers}[r]||renderDashboard)();renderLoadWarnings();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});refreshWorkerStatus();}catch(e){$('view').innerHTML=empty(e.message);refreshWorkerStatus();}}
function suggestedOffer(p){if(p.ai_offer)return p.ai_offer;const status=String(p.website_status||'').toLowerCase();if(['broken','unreachable','error','timeout'].includes(status))return'First check whether the current site can be recovered; if a rebuild is useful, move hosting to our Gold Plus package at R119/month and include the rebuild at no extra design charge.';if(status==='slow')return'Offer a quick performance review and, if useful, move hosting to our Gold Plus package at R119/month.';if(!p.website)return'If useful, suggest a .co.za domain, Gold Plus hosting at R119/month, professional email, and a simple first website.';if(!p.website_has_viewport)return'Offer a short mobile-friendly website review and only suggest changes that the evidence supports.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'}
function suggestedOffer(p){if(p.ai_offer)return p.ai_offer;const status=String(p.website_status||'').toLowerCase();if(['broken','unreachable','error','timeout'].includes(status))return'First check whether the current site can be recovered; if a rebuild is useful, move hosting to our Gold Plus package at R119/month and include the rebuild at no extra design charge.';if(status==='slow')return'Offer a quick performance review and, if useful, move hosting to our Gold Plus package at R119/month.';if(!p.website)return'If useful, suggest a .co.za domain, Gold Plus hosting at R119/month, professional email, and a simple first website; offer to prepare a quick example so they can see the direction.';if(!p.website_has_viewport)return'Offer a short mobile-friendly website review and only suggest changes that the evidence supports.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'}
function jsonArray(value){try{const parsed=JSON.parse(value||'[]');return Array.isArray(parsed)?parsed:[]}catch{return[]}}
function showDraftNotes(d){let notes=$('draftNotes');if(!notes){notes=document.createElement('div');notes.id='draftNotes';notes.className='draft-notes';const status=$('messageStatus');if(status)status.parentNode.insertBefore(notes,status)}if(!notes)return;const unknowns=Array.isArray(d?.unknowns)?d.unknowns:[];const used=Array.isArray(d?.evidence_used)?d.evidence_used:[];notes.innerHTML=`<b>AI review notes</b><p>${esc(d?.rationale||'The draft uses the prospect evidence and your instructions.')}</p>${unknowns.length?`<p><b>Confirm before sending:</b> ${esc(unknowns.join(' · '))}</p>`:''}${used.length?`<p class="muted">Evidence used: ${esc(used.join(', '))}</p>`:''}${d?.follow_up?`<p class="muted">${esc(d.follow_up)}</p>`:''}`}
function promotionOptions(){return (Array.isArray(settings.promotions)?settings.promotions:[]).filter(item=>item.active).map(item=>`<option value="${esc(item.id)}">${esc(item.name)}</option>`).join('')}
+2 -2
View File
@@ -1,3 +1,3 @@
<!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="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v7"></head>
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">CLIENT-SPECIFIC DRAFT</label><h2 id="messageTitle">Write a message for this prospect</h2><p>Use verified facts and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate client-specific draft</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=workspace-v7"></script></body></html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v8"></head>
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">CLIENT-SPECIFIC DRAFT</label><h2 id="messageTitle">Write a message for this prospect</h2><p>Use verified facts and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate client-specific draft</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=workspace-v8"></script></body></html>