deploy DOM search v6 update

This commit is contained in:
Marco0300
2026-09-06 00:21:17 +02:00
parent 8c90340293
commit e92ffaa807
5 changed files with 68 additions and 30 deletions
+6
View File
@@ -34,6 +34,12 @@ as a fallback when no parseable result links are found. Candidate domains are
shown with evidence and must be accepted by you before they replace the
catalogue record.
Prospect review status is changed inside the detail drawer (In review,
Accepted, Rejected, or Contacted), keeping the explorer focused on discovery
categories and opportunity scores. The AI draft dialog shows the prospect
evidence, lets you choose a voice and call-to-action, and produces an editable
subject and plain-text email using a stricter evidence-grounded prompt.
## Start the stack
```bash
+40 -20
View File
@@ -1322,8 +1322,13 @@ def scheduler_loop() -> None:
time.sleep(30)
def ai_generate_message(prospect: dict[str, Any], offer: str) -> dict[str, Any]:
"""Generate an evidence-grounded, human-editable outreach draft."""
def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm", goal: str = "conversation") -> dict[str, Any]:
"""Generate an evidence-grounded, human-editable outreach draft.
``tone`` and ``goal`` are deliberately small, validated controls rather
than an unrestricted prompt field. This keeps drafts consistent while
still letting the owner choose how direct the first contact should feel.
"""
runtime = ai_runtime_settings()
ai_base_url = runtime["base_url"]
ai_api_key = runtime["api_key"]
@@ -1341,6 +1346,16 @@ def ai_generate_message(prospect: dict[str, Any], offer: str) -> dict[str, Any]:
evidence_ids.append("public_email_listing")
if prospect.get("phone"):
evidence_ids.append("public_phone_listing")
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()
goal_key = str(goal or "conversation").strip().lower()
if tone_key not in tone_options:
tone_key = "warm"
if goal_key not in goal_options:
goal_key = "conversation"
tone_instruction = tone_options[tone_key]
goal_instruction = goal_options[goal_key]
evidence = {
"evidence_ids": evidence_ids,
"business_name": prospect.get("name"),
@@ -1354,30 +1369,35 @@ def ai_generate_message(prospect: dict[str, Any], offer: str) -> dict[str, Any]:
"url": prospect.get("website"), "status": prospect.get("website_status"),
"http_status": prospect.get("website_http_status"), "title": prospect.get("website_title"),
"technology": prospect.get("website_technology"), "https": bool(prospect.get("website_https")),
"latency_ms": prospect.get("website_latency_ms"),
"latency_ms": prospect.get("website_latency_ms"), "error": prospect.get("website_error"),
},
"public_emails": prospect.get("emails"),
"opportunity_score": prospect.get("opportunity_score"),
"score_reasons": _json_list(prospect.get("score_reasons")),
}
system = (
"You are a senior B2B copywriter for a South African web-hosting and website-design business. "
"Create a short, respectful, genuinely personalised first-contact email from the supplied evidence. "
"Use only stored facts and the user's offer; never invent a person's name, company activity, price, "
"service, problem, result, testimonial, urgency, or link. Do not mention scraping, scoring, hidden "
"analysis, or imply certainty where the evidence is uncertain. Do not insult the prospect or call a "
"site bad; use neutral wording such as 'may be having trouble loading' when appropriate. "
"If there is no website, do not say you reviewed their website. If there is no mockup URL in the offer, "
"do not invent one. Keep the email around 90-150 words, with a natural greeting using the business name, "
"one specific evidence-based observation, the exact offer/instructions supplied by the user, one simple "
"call to action, and a polite opt-out sentence. Avoid spammy hype, excessive emojis, all-caps, and fake "
"personal familiarity. Return ONLY strict JSON with exactly these keys: subject, message, rationale, "
"recommended_offer, unknowns, evidence_used, follow_up. subject is 5-10 natural words. message is plain "
"text. rationale is concise. recommended_offer is a short summary of the user's offer. unknowns is an "
"array of facts that should be confirmed. evidence_used is an array containing only the supplied evidence "
"IDs. follow_up is a short human review note, not part of the email."
"You are an expert B2B copywriter for a small South African web-hosting and website-design business. "
"Write one genuinely human first-contact email, not a mass-marketing template. The desired voice is "
f"{tone_instruction}; the goal is to {goal_instruction}. "
"Use only the stored evidence and the user's offer. Never invent a person's name, company activity, "
"price, service, problem, result, testimonial, urgency, authority, or link. Do not mention scraping, "
"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. "
"Start with a concrete observation tied to one supplied fact; avoid empty openings such as 'I hope you "
"are well' or 'I came across your business'. 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 about 90-140 words: greeting, one specific observation, a natural bridge "
"to the exact offer, one low-pressure call to action, and a brief respectful opt-out sentence. 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. "
"Return ONLY strict JSON with exactly these keys: subject, message, rationale, recommended_offer, "
"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."
)
prompt = json.dumps({"evidence": evidence, "user_offer_or_instruction": offer[:2000]}, ensure_ascii=False)
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 {}
request_payload: dict[str, Any] = {
"model": ai_model,
@@ -1880,7 +1900,7 @@ class Handler(BaseHTTPRequestHandler):
if not row: return self.send_json(404, {"error":"not_found"})
payload = self.read_json(); offer = str(payload.get("offer") or "").strip()
if not offer: return self.send_json(400, {"error":"offer_required"})
draft = ai_generate_message(dict(row), offer)
draft = ai_generate_message(dict(row), offer, tone=str(payload.get("tone") or "warm"), goal=str(payload.get("goal") or "conversation"))
stamp = now(); conn.execute("UPDATE prospects SET ai_subject=?,ai_message=?,ai_offer=?,ai_reasoning=?,ai_unknowns=?,ai_evidence_ids=?,ai_follow_up=?,ai_checked_at=? WHERE id=?", (draft["subject"],draft["message"],offer,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,int(message_match.group(1)))); conn.commit()
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)})
+5 -2
View File
@@ -224,9 +224,12 @@ class BrowserOnlyAppTests(unittest.TestCase):
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)", (key, value, server.now()))
conn.commit(); conn.close()
content = json.dumps({"subject": "A website idea for Acme Plumbing", "message": "Good day Acme Plumbing,\n\nI noticed your public listing does not show a dedicated website. We can help with a professional online presence. Would you like me to send a few details?\n\nKind regards", "rationale": "Uses the missing-website signal and the supplied offer.", "recommended_offer": "Gold Plus hosting and a free one-page website", "unknowns": ["Confirm the preferred contact person"], "evidence_used": ["prospect_record", "public_maps_listing", "public_email_listing"], "follow_up": "Confirm the offer and recipient before sending."})
with patch("api.server.http_json", return_value={"choices": [{"message": {"content": content}}]}):
status, body = self.request("POST", "/api/prospects/1/message", {"offer": "Gold Plus hosting at R119/month with a free one-page website."})
with patch("api.server.http_json", return_value={"choices": [{"message": {"content": content}}]}) as http_json:
status, body = self.request("POST", "/api/prospects/1/message", {"offer": "Gold Plus hosting at R119/month with a free one-page website.", "tone": "consultative", "goal": "review"})
self.assertEqual(status, 200)
request_payload = http_json.call_args.args[2]
self.assertIn('"tone": "consultative"', request_payload["messages"][1]["content"])
self.assertIn("consultative and helpful", request_payload["messages"][0]["content"])
self.assertEqual(body["unknowns"], ["Confirm the preferred contact person"])
self.assertEqual(body["evidence_used"], ["prospect_record", "public_maps_listing", "public_email_listing"])
saved = server.db().execute("SELECT ai_unknowns,ai_evidence_ids,ai_follow_up FROM prospects WHERE id=1").fetchone()
+12 -8
View File
@@ -16,11 +16,11 @@ function empty(text){return `<div class="empty"><span>⌕</span><b>${text}</b></
function scoreClass(n){return n>=75?'hot':n>=55?'warm':'cool'}
function prospectCategory(p){const raw=String(p.search_category||p.category||'').trim();return raw?raw.replace(/\s+/g,' ').replace(/^./,c=>c.toUpperCase()):'Other'}
function prospectTabs(){const categories=[...new Set(prospects.map(prospectCategory))].sort((a,b)=>a.localeCompare(b));if(activeProspectCategory!=='all'&&!categories.includes(activeProspectCategory))activeProspectCategory='all';const count=category=>category==='all'?prospects.filter(p=>Number(p.opportunity_score||0)>=HIGH_POTENTIAL_SCORE).length:prospects.filter(p=>prospectCategory(p)===category).length;return `<div class="prospect-tab-list"><button class="prospect-tab ${activeProspectCategory==='all'?'active':''}" data-category-tab="all">All high potential <span>${count('all')}</span></button>${categories.map(category=>`<button class="prospect-tab ${activeProspectCategory===category?'active':''}" data-category-tab="${esc(category)}">${esc(category)} <span>${count(category)}</span></button>`).join('')}</div><p class="tab-hint">All shows scores ${HIGH_POTENTIAL_SCORE}+; category tabs keep every result grouped by the search category.</p>`}
function prospectRows(){const q=($('prospectSearch')?.value||'').toLowerCase();const st=$('prospectStatus')?.value||'';return prospects.filter(p=>{const hay=[p.name,p.domain,p.search_category,p.category,p.address,p.emails].join(' ').toLowerCase();const tabMatch=activeProspectCategory==='all'?Number(p.opportunity_score||0)>=HIGH_POTENTIAL_SCORE:prospectCategory(p)===activeProspectCategory;return tabMatch&&(!q||hay.includes(q))&&(!st||p.review_status===st)}).sort((a,b)=>Number(b.opportunity_score||0)-Number(a.opportunity_score||0)||String(a.name||'').localeCompare(String(b.name||'')))}
function prospectTable(){const rows=prospectRows();return `<div class="table-wrap"><table><thead><tr><th>Prospect</th><th>Website</th><th>Email</th><th>Potential</th><th>Review</th><th></th></tr></thead><tbody>${rows.map(p=>`<tr data-open="${p.id}"><td><b>${esc(p.name)}</b><small>${esc(prospectCategory(p))} · ${esc(p.address||'')}</small></td><td><span class="badge ${p.website_status==='healthy'?'good':'warn'}">${esc((p.website_status||'unknown').replaceAll('_',' '))}</span>${p.website?`<a href="${esc(p.website)}" target="_blank" rel="noopener">Open ↗</a>`:'<small>No detected website</small>'}</td><td>${p.emails?`<span class="email">✉ ${esc(p.emails)}</span>`:'<small>Not found</small>'}</td><td><span class="score ${scoreClass(Number(p.opportunity_score||0))}">${p.opportunity_score||0}</span><small>${Number(p.opportunity_score||0)>=85?'Hot':Number(p.opportunity_score||0)>=70?'High':Number(p.opportunity_score||0)>=45?'Medium':'Low'}</small></td><td><select data-review="${p.id}"><option value="pending" ${p.review_status==='pending'?'selected':''}>Needs review</option><option value="approved" ${p.review_status==='approved'?'selected':''}>Approved</option><option value="rejected" ${p.review_status==='rejected'?'selected':''}>Rejected</option><option value="contacted" ${p.review_status==='contacted'?'selected':''}>Contacted</option></select></td><td><button class="icon-action" data-message="${p.id}" title="Draft AI email">✦</button></td></tr>`).join('')}</tbody></table></div>${rows.length?'':empty('No prospects match this tab and filter')}`}
function prospectRows(){const q=($('prospectSearch')?.value||'').toLowerCase();return prospects.filter(p=>{const hay=[p.name,p.domain,p.search_category,p.category,p.address,p.emails].join(' ').toLowerCase();const tabMatch=activeProspectCategory==='all'?Number(p.opportunity_score||0)>=HIGH_POTENTIAL_SCORE:prospectCategory(p)===activeProspectCategory;return tabMatch&&(!q||hay.includes(q))}).sort((a,b)=>Number(b.opportunity_score||0)-Number(a.opportunity_score||0)||String(a.name||'').localeCompare(String(b.name||'')))}
function prospectTable(){const rows=prospectRows();return `<div class="table-wrap"><table><thead><tr><th>Prospect</th><th>Website</th><th>Email</th><th>Potential</th><th></th></tr></thead><tbody>${rows.map(p=>`<tr data-open="${p.id}"><td><b>${esc(p.name)}</b><small>${esc(prospectCategory(p))} · ${esc(p.address||'')}</small></td><td><span class="badge ${p.website_status==='healthy'?'good':'warn'}">${esc((p.website_status||'unknown').replaceAll('_',' '))}</span>${p.website?`<a href="${esc(p.website)}" target="_blank" rel="noopener">Open ↗</a>`:'<small>No detected website</small>'}</td><td>${p.emails?`<span class="email">✉ ${esc(p.emails)}</span>`:'<small>Not found</small>'}</td><td><span class="score ${scoreClass(Number(p.opportunity_score||0))}">${p.opportunity_score||0}</span><small>${Number(p.opportunity_score||0)>=85?'Hot':Number(p.opportunity_score||0)>=70?'High':Number(p.opportunity_score||0)>=45?'Medium':'Low'}</small></td><td><button class="icon-action" data-message="${p.id}" title="Draft AI email">✦</button></td></tr>`).join('')}</tbody></table></div>${rows.length?'':empty('No prospects match this tab and filter')}`}
function renderDashboard(){const total=prospects.length,pending=prospects.filter(p=>p.review_status==='pending').length,hot=prospects.filter(p=>p.opportunity_score>=75).length,withEmail=prospects.filter(p=>p.emails).length;const latest=runs[0];$('view').innerHTML=layout('Dashboard','A clear view of what was found, what needs review, and where the best opportunities are.',`<div class="metric-grid"><div class="metric"><small>CATALOGUE</small><b>${total}</b><span>saved prospects</span></div><div class="metric"><small>NEEDS REVIEW</small><b>${pending}</b><span>awaiting your decision</span></div><div class="metric"><small>HIGH OPPORTUNITY</small><b>${hot}</b><span>score 75 or higher</span></div><div class="metric"><small>PUBLIC EMAILS</small><b>${withEmail}</b><span>evidence-backed contacts</span></div></div><div class="dashboard-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">NEXT ACTION</span><h2>Review your strongest leads</h2><p>Inspect the evidence before deciding whether to contact them.</p></div><a class="primary" href="#prospects">Open explorer →</a></div><div class="spotlight">${prospects.slice(0,5).map(p=>`<button class="spot" data-open="${p.id}"><span class="score ${scoreClass(p.opportunity_score)}">${p.opportunity_score}</span><span><b>${esc(p.name)}</b><small>${esc(p.website_status?.replaceAll('_',' ')||'unknown')} · ${esc(p.emails||'no public email')}</small></span><span>›</span></button>`).join('')||empty('Run a discovery to build your catalogue')}</div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">RECENT ACTIVITY</span><h2>Runs</h2></div><a href="#runs">View all</a></div><div class="activity">${runs.slice(0,6).map(r=>`<div><span class="run-dot ${r.status}"></span><span><b>#${r.id} · ${esc(r.query)}</b><small>${esc(r.status)} · ${r.added||0} added</small></span><time>${date(r.created_at)}</time></div>`).join('')||empty('No runs yet')}</div></article></div>`)}
function renderDiscover(){ $('view').innerHTML=layout('Discover','Set the criteria for this run. Source configuration and provider settings live in Settings.',`<div class="two-col"><article class="panel form-panel"><div class="panel-title"><div><span class="eyebrow">NEW RUN</span><h2>Find potential customers</h2><p>Search Google Maps, deduplicate the catalogue, then enrich each result.</p></div><span class="pill">Email collection always on</span></div><form id="discoverForm"><label>Keywords<input id="keywords" required placeholder="plumbing services"></label><label>Category<input id="category" required placeholder="plumbers"></label><label>Location<input id="location" value="South Africa" required></label><label>Maximum results<select id="max-results"><option>10</option><option selected>25</option><option>50</option></select></label><label class="checkline"><input type="checkbox" id="extra-reviews"> Collect extra reviews <small>May increase run time</small></label><div class="form-row"><button class="primary" type="submit" id="discoverBtn">Run discovery →</button><span id="discoverMessage" class="notice"></span></div></form></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">HOW IT WORKS</span><h2>Evidence-first pipeline</h2></div></div><ol class="steps"><li><b>Search</b><span>Maps worker finds public business listings.</span></li><li><b>Enrich</b><span>Website, HTTPS, DNS hints and public email checks run safely.</span></li><li><b>Review</b><span>You decide what is relevant before any contact.</span></li></ol></article></div>`)}
function renderProspects(){ $('view').innerHTML=layout('Prospects','Browse the strongest opportunities first, then switch to a search category when you want a focused list.',`<article class="panel"><div class="toolbar"><div class="search"><span>⌕</span><input id="prospectSearch" placeholder="Search name, address, domain or email"></div><select id="prospectStatus"><option value="">All review statuses</option><option value="pending">Needs review</option><option value="approved">Approved</option><option value="rejected">Rejected</option><option value="contacted">Contacted</option></select><button class="secondary" id="exportCsv">Export CSV</button><button class="secondary" id="refreshProspects">Refresh</button></div><div id="prospectTabs" class="prospect-tabs">${prospectTabs()}</div><div id="prospectTable">${prospectTable()}</div></article><aside id="detail" class="detail-panel" hidden></aside>`)}
function renderProspects(){ $('view').innerHTML=layout('Prospects','Browse the strongest opportunities first, then switch to a search category when you want a focused list.',`<article class="panel"><div class="toolbar"><div class="search"><span>⌕</span><input id="prospectSearch" placeholder="Search name, address, domain or email"></div><button class="secondary" id="exportCsv">Export CSV</button><button class="secondary" id="refreshProspects">Refresh</button></div><div id="prospectTabs" class="prospect-tabs">${prospectTabs()}</div><p class="list-hint">Click any prospect to review evidence, change its status, assess it with AI, or draft a tailored message.</p><div id="prospectTable">${prospectTable()}</div></article><aside id="detail" class="detail-panel" hidden></aside>`)}
function renderRuns(){ $('view').innerHTML=layout('Runs','Monitor discovery jobs and inspect errors without leaving the run history.',`<article class="panel"><div class="table-wrap"><table><thead><tr><th>Run</th><th>Status</th><th>Results</th><th>Started</th><th>Source</th></tr></thead><tbody>${runs.map(r=>`<tr><td><b>#${r.id}</b><small>${esc(r.query)}</small></td><td><span class="status ${r.status}">${esc(r.status)}</span><small>${esc(r.stage||'')}</small></td><td>${r.added||0} added / ${r.found||0} found</td><td>${date(r.created_at)}</td><td>${esc(r.source||'google_maps')}</td></tr>`).join('')}</tbody></table></div>${runs.length?'':empty('No discovery runs yet')}</article>`)}
function logTabCount(tab,counts){return tab==='errors'?Number(counts?.errors||0):Number(counts?.[tab]||0)}
function logTabs(counts={}){return `<div class="logs-tab-list">${LOG_TABS.map(([key,label])=>`<button class="logs-tab ${activeLogTab===key?'active':''}" data-log-tab="${key}">${label}<span>${logTabCount(key,counts)}</span></button>`).join('')}</div>`}
@@ -69,10 +69,14 @@ async function route(){const r=location.hash.slice(1)||'dashboard';installLogsNa
function suggestedOffer(p){if(p.ai_offer)return p.ai_offer;if(['broken','unreachable','error','slow'].includes(String(p.website_status||'')))return'Move their hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge.';if(!p.website)return'Offer a professional .co.za domain, reliable hosting from R119/month, professional email, and a free one-page website.';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>`:''}`}
async function openMessage(id){selected=prospects.find(p=>String(p.id)===String(id));if(!selected)return;$('messageTitle').textContent='Draft for '+selected.name;$('offerInput').value=suggestedOffer(selected);$('subjectOutput').value=selected.ai_subject||'';$('bodyOutput').value=selected.ai_message||'';$('messageStatus').textContent='';showDraftNotes({rationale:selected.ai_reasoning,unknowns:jsonArray(selected.ai_unknowns),evidence_used:jsonArray(selected.ai_evidence_ids),follow_up:selected.ai_follow_up});$('messageDialog').showModal()}
function openDrawer(id){const p=prospects.find(x=>String(x.id)===String(id));if(!p)return;selected=p;$('drawerName').textContent=p.name;let reasons=[];try{reasons=JSON.parse(p.score_reasons||'[]')}catch{};$('drawerBody').innerHTML=`<div class="drawer-score"><span class="score ${scoreClass(Number(p.opportunity_score||0))}">${p.opportunity_score||0}</span><div><b>Opportunity score</b><small>Explainable triage signal, not an AI verdict</small></div></div><div class="drawer-section"><h3>Contact</h3><p>${esc(p.emails||'No public email found')}</p><p>${esc(p.phone||'No phone listed')}</p></div><div class="drawer-section"><h3>Website health</h3><div class="detail-grid"><span>Status<b>${esc((p.website_status||'unknown').replaceAll('_',' '))}</b></span><span>HTTPS<b>${p.website_https?'Yes':'No / unknown'}</b></span><span>Latency<b>${p.website_latency_ms?p.website_latency_ms+' ms':'—'}</b></span><span>Checked<b>${date(p.website_checked_at)}</b></span></div></div><div class="drawer-section"><h3>Why this scored</h3><ul>${reasons.map(r=>`<li>${esc(r)}</li>`).join('')||'<li>No score reasons recorded</li>'}</ul></div><div class="drawer-actions"><button class="primary" data-message="${p.id}">Draft AI email</button>${p.website?`<a class="secondary" href="${esc(p.website)}" target="_blank" rel="noopener">Open website ↗</a>`:''}</div>`;$('detailDrawer').classList.add('open');$('detailDrawer').setAttribute('aria-hidden','false')}
function ensureMessageDialog(){const dialog=$('messageDialog');const form=$('messageForm');if(dialog)dialog.classList.add('message-dialog');if(!form||$('messageTone'))return;form.innerHTML=`<div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Start from verified prospect evidence, then edit before sending.</p></div><button class="icon-btn" value="cancel" aria-label="Close draft">×</button></div><div id="messageContext" class="message-context"></div><div class="message-controls"><label for="messageTone">Voice<select id="messageTone"><option value="warm">Warm and approachable</option><option value="direct">Direct and concise</option><option value="consultative">Consultative and helpful</option></select></label><label for="messageGoal">Call to action<select id="messageGoal"><option value="conversation">Start a conversation</option><option value="review">Offer a short review</option><option value="quote">Invite a quote request</option></select></label></div><label for="offerInput">Your offer or instruction <small class="field-help">Prices and promises are copied only from this field.</small></label><textarea id="offerInput" rows="4" placeholder="Move hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge."></textarea><div id="messageStatus" class="notice"></div><button class="primary draft-generate" id="generateMessage" type="button">Generate tailored draft</button><div class="draft-output"><label for="subjectOutput">Subject <small>Editable</small></label><input id="subjectOutput" placeholder="A specific, natural subject line"><label for="bodyOutput">Email draft <small>Editable plain text</small></label><textarea id="bodyOutput" rows="12" placeholder="Your generated email will appear here."></textarea></div><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy subject + email</button><button class="secondary" value="cancel">Close</button></div>`}
async function openMessage(id){ensureMessageDialog();selected=prospects.find(p=>String(p.id)===String(id));if(!selected)return;$('messageTitle').textContent='Draft for '+selected.name;const context=$('messageContext');if(context)context.innerHTML=`<div class="message-context-title"><span class="score ${scoreClass(Number(selected.opportunity_score||0))}">${selected.opportunity_score||0}</span><div><b>${esc(selected.name)}</b><small>${esc(prospectCategory(selected))} · ${esc(selected.address||'Location not listed')}</small></div></div><div class="message-context-facts"><span>${selected.website?`Website: ${esc((selected.website_status||'detected').replaceAll('_',' '))}`:'No website detected'}</span><span>${selected.emails?`Public email: ${esc(selected.emails)}`:'No public email found'}</span></div>`;$('offerInput').value=suggestedOffer(selected);if($('messageTone'))$('messageTone').value='warm';if($('messageGoal'))$('messageGoal').value='conversation';$('subjectOutput').value=selected.ai_subject||'';$('bodyOutput').value=selected.ai_message||'';$('messageStatus').textContent='';$('messageStatus').className='notice';showDraftNotes({rationale:selected.ai_reasoning,unknowns:jsonArray(selected.ai_unknowns),evidence_used:jsonArray(selected.ai_evidence_ids),follow_up:selected.ai_follow_up});$('messageDialog').showModal()}
function reviewStatusLabel(status){return status==='approved'?'Accepted':status==='rejected'?'Rejected':status==='contacted'?'Contacted':'In review'}
function reviewStatusTabs(p){const options=[['pending','In review'],['approved','Accepted'],['rejected','Rejected'],['contacted','Contacted']];return `<div class="drawer-section review-section"><div class="section-heading"><h3>Review status</h3><span class="muted">Saved to this prospect</span></div><div class="drawer-review-tabs">${options.map(([value,label])=>`<button type="button" class="drawer-review-tab ${p.review_status===value?'active':''}" data-drawer-review="${p.id}" data-status="${value}">${label}</button>`).join('')}</div></div>`}
function openDrawer(id){const p=prospects.find(x=>String(x.id)===String(id));if(!p)return;selected=p;$('drawerName').textContent=p.name;let reasons=[];try{reasons=JSON.parse(p.score_reasons||'[]')}catch{};$('drawerBody').innerHTML=`<div class="drawer-score"><span class="score ${scoreClass(Number(p.opportunity_score||0))}">${p.opportunity_score||0}</span><div><b>Opportunity score</b><small>Explainable triage signal, not an AI verdict</small></div></div>${reviewStatusTabs(p)}<div class="drawer-section"><h3>Contact</h3><p>${esc(p.emails||'No public email found')}</p><p>${esc(p.phone||'No phone listed')}</p></div><div class="drawer-section"><h3>Website health</h3><div class="detail-grid"><span>Status<b>${esc((p.website_status||'unknown').replaceAll('_',' '))}</b></span><span>HTTPS<b>${p.website_https?'Yes':'No / unknown'}</b></span><span>Latency<b>${p.website_latency_ms?p.website_latency_ms+' ms':'—'}</b></span><span>Checked<b>${date(p.website_checked_at)}</b></span></div></div><div class="drawer-section"><h3>Why this scored</h3><ul>${reasons.map(r=>`<li>${esc(r)}</li>`).join('')||'<li>No score reasons recorded</li>'}</ul></div><div class="drawer-actions"><button class="primary" data-message="${p.id}">Draft AI email</button>${p.website?`<a class="secondary" href="${esc(p.website)}" target="_blank" rel="noopener">Open website ↗</a>`:''}</div>`;$('detailDrawer').classList.add('open');$('detailDrawer').setAttribute('aria-hidden','false')}
document.addEventListener('click',async e=>{const open=e.target.closest('[data-open]');if(open)openDrawer(open.dataset.open);const msg=e.target.closest('[data-message]');if(msg)openMessage(msg.dataset.message);if(e.target.id==='exportCsv')location.href='/api/export.csv';if(e.target.id==='refreshProspects')route();if(e.target.id==='closeDrawer'||!e.target.closest('#detailDrawer')&&$('detailDrawer').classList.contains('open')&&!e.target.closest('[data-open]')){$('detailDrawer').classList.remove('open');$('detailDrawer').setAttribute('aria-hidden','true')}});
document.addEventListener('change',async e=>{if(e.target.dataset.review){try{const p=await api(`/api/prospects/${e.target.dataset.review}/review`,{method:'PATCH',body:JSON.stringify({status:e.target.value})});const i=prospects.findIndex(x=>x.id===p.id);if(i>=0)prospects[i]=p}catch(err){alert(err.message)}}if(e.target.id==='prospectSearch'||e.target.id==='prospectStatus')$('prospectTable').innerHTML=prospectTable();if(e.target.id==='aiProvider'){const nous=e.target.value==='nous';$('aiBase').readOnly=nous;$('aiBase').value=nous?'https://inference-api.nousresearch.com/v1':$('aiBase').value;const note=$('aiProviderNote');if(note)note.textContent=nous?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':'Use any OpenAI-compatible /v1 endpoint.';if(nous){try{const data=await api('/api/ai/models?provider=nous');const model=$('aiModel');const list=$('nousModelList');const models=data.models||[];if(list)list.innerHTML=models.map(m=>`<option value="${esc(m.id)}" label="${esc(m.label||m.id)}"></option>`).join('');if(model&&models.length&&!models.some(m=>m.id===model.value))model.value=models[0].id;}catch(err){}}}});
document.addEventListener('click',async e=>{const button=e.target.closest('[data-drawer-review]');if(!button)return;e.preventDefault();e.stopPropagation();const id=button.dataset.drawerReview,status=button.dataset.status;const panel=button.closest('.review-section');const buttons=panel?panel.querySelectorAll('[data-drawer-review]'):[];buttons.forEach(item=>item.disabled=true);try{const updated=await api(`/api/prospects/${id}/review`,{method:'PATCH',body:JSON.stringify({status})});const index=prospects.findIndex(p=>String(p.id)===String(id));if(index>=0)prospects[index]=updated;selected=updated;openDrawer(id);if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){buttons.forEach(item=>item.disabled=false);if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=`Could not save status: ${error.message}`;panel.append(notice)}}});
document.addEventListener('change',async e=>{if(e.target.id==='prospectSearch')$('prospectTable').innerHTML=prospectTable();if(e.target.id==='aiProvider'){const nous=e.target.value==='nous';$('aiBase').readOnly=nous;$('aiBase').value=nous?'https://inference-api.nousresearch.com/v1':$('aiBase').value;const note=$('aiProviderNote');if(note)note.textContent=nous?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':'Use any OpenAI-compatible /v1 endpoint.';if(nous){try{const data=await api('/api/ai/models?provider=nous');const model=$('aiModel');const list=$('nousModelList');const models=data.models||[];if(list)list.innerHTML=models.map(m=>`<option value="${esc(m.id)}" label="${esc(m.label||m.id)}"></option>`).join('');if(model&&models.length&&!models.some(m=>m.id===model.value))model.value=models[0].id;}catch(err){}}}});
document.addEventListener('submit',async e=>{
if(e.target.id==='discoverForm'){
e.preventDefault();const m=$('discoverMessage');$('discoverBtn').disabled=true;m.textContent='Queueing…';
@@ -92,7 +96,7 @@ document.addEventListener('submit',async e=>{
if(e.target.id==='scheduleForm'){e.preventDefault();try{await api('/api/schedules',{method:'POST',body:JSON.stringify({category:$('sCat').value,keywords:$('sKey').value,location:$('sLoc').value,interval_hours:Number($('sHours').value)})});route()}catch(x){alert(x.message)}}
if(e.target.id==='suppressionForm'){e.preventDefault();try{await api('/api/suppressions',{method:'POST',body:JSON.stringify({kind:$('supKind').value,value:$('supValue').value,reason:$('supReason').value})});route()}catch(x){alert(x.message)}}
});
document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');const button=e.target;button.disabled=true;button.textContent='Generating…';s.textContent='The AI is comparing the evidence with your offer…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;selected={...selected,ai_subject:d.subject,ai_message:d.message,ai_offer:$('offerInput').value,ai_reasoning:d.rationale||'',ai_unknowns:JSON.stringify(d.unknowns||[]),ai_evidence_ids:JSON.stringify(d.evidence_used||[]),ai_follow_up:d.follow_up||''};const i=prospects.findIndex(p=>String(p.id)===String(selected.id));if(i>=0)prospects[i]=selected;showDraftNotes(d);s.textContent='Draft ready. Review, edit, and confirm the facts before sending.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}finally{button.disabled=false;button.textContent='Regenerate draft'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}if(e.target.id==='testAi'){const s=$('settingsMessage');s.textContent='Testing…';s.className='notice';try{const d=await api('/api/ai/test',{method:'POST',body:'{}'});s.textContent=`Connection works · ${d.provider} · ${d.model}`;s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}});
document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');const button=e.target;button.disabled=true;button.textContent='Writing…';s.textContent='The AI is comparing the evidence with your offer…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value,tone:$('messageTone')?.value||'warm',goal:$('messageGoal')?.value||'conversation'})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;selected={...selected,ai_subject:d.subject,ai_message:d.message,ai_offer:$('offerInput').value,ai_reasoning:d.rationale||'',ai_unknowns:JSON.stringify(d.unknowns||[]),ai_evidence_ids:JSON.stringify(d.evidence_used||[]),ai_follow_up:d.follow_up||''};const i=prospects.findIndex(p=>String(p.id)===String(selected.id));if(i>=0)prospects[i]=selected;showDraftNotes(d);s.textContent='Draft ready. Edit it, verify the facts, then copy it to your mail client.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}finally{button.disabled=false;button.textContent='Regenerate draft'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}if(e.target.id==='testAi'){const s=$('settingsMessage');s.textContent='Testing…';s.className='notice';try{const d=await api('/api/ai/test',{method:'POST',body:'{}'});s.textContent=`Connection works · ${d.provider} · ${d.model}`;s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}});
function aiRecommendationLabel(value){return value==='contact'?'Contact recommended':value==='review'?'Review first':'Do not contact yet'}
function addAssessmentPanel(id){const p=prospects.find(x=>String(x.id)===String(id));const body=$('drawerBody');if(!p||!body)return;const has=Number.isFinite(Number(p.ai_probability))&&p.ai_checked_at;const section=document.createElement('section');section.className='drawer-section';section.id='aiAssessmentPanel';section.innerHTML=has?`<h3>AI assessment</h3><div class="detail-grid"><span>Contact probability<b>${Math.round(Number(p.ai_probability))}%</b></span><span>Recommendation<b>${esc(aiRecommendationLabel(p.ai_recommendation))}</b></span></div><p>${esc(p.ai_reasoning||'No rationale returned.')}</p><p class="muted">Assessed ${date(p.ai_checked_at)}</p><button class="secondary" data-assess="${p.id}">Reassess with AI</button>`:`<h3>AI assessment</h3><p>Use the collected evidence to estimate whether this company is worth contacting.</p><button class="secondary" data-assess="${p.id}">Assess with AI</button>`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section)}
function candidateEvidence(candidate){try{const value=JSON.parse(candidate.evidence||'[]');return Array.isArray(value)?value.filter(Boolean).map(String):[]}catch{return[]}}
@@ -103,7 +107,7 @@ async function assessProspect(id,button){if(button){button.disabled=true;button.
document.addEventListener('click',async e=>{const button=e.target.closest('[data-assess]');if(button){e.preventDefault();e.stopPropagation();await assessProspect(button.dataset.assess,button)}});
async function findWebsiteCandidates(id,button){if(button){button.disabled=true;button.textContent='Searching…'}const section=$('websiteCandidatesPanel');try{const d=await api(`/api/prospects/${id}/website-candidates`,{method:'POST',body:'{}'});renderWebsiteCandidates(d.items||[],id)}catch(error){if(section){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;section.append(notice)}}finally{if(button){button.disabled=false;button.textContent='Search again'}}}
document.addEventListener('click',async e=>{const find=e.target.closest('[data-find-websites]');if(find){e.preventDefault();e.stopPropagation();await findWebsiteCandidates(find.dataset.findWebsites,find);return}const accept=e.target.closest('[data-accept-website]');if(accept){e.preventDefault();e.stopPropagation();accept.disabled=true;accept.textContent='Checking…';try{const d=await api(`/api/prospects/${accept.dataset.prospectId}/website-candidates/${accept.dataset.acceptWebsite}`,{method:'PATCH',body:JSON.stringify({status:'accepted'})});if(d.prospect){const i=prospects.findIndex(p=>String(p.id)===String(d.prospect.id));if(i>=0)prospects[i]={...prospects[i],...d.prospect};selected=d.prospect}openDrawer(accept.dataset.prospectId);if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){accept.disabled=false;accept.textContent='Accept domain';const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}return}const reject=e.target.closest('[data-reject-website]');if(reject){e.preventDefault();e.stopPropagation();reject.disabled=true;try{const d=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates/${reject.dataset.rejectWebsite}`,{method:'PATCH',body:JSON.stringify({status:'rejected'})});const current=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates`);renderWebsiteCandidates(current.items||[],reject.dataset.prospectId)}catch(error){reject.disabled=false;const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}}});
window.addEventListener('hashchange',route);$('refreshAll').addEventListener('click',route);refreshWorkerStatus();setInterval(refreshWorkerStatus,30000);route();
window.addEventListener('hashchange',route);$('refreshAll').addEventListener('click',route);ensureMessageDialog();refreshWorkerStatus();setInterval(refreshWorkerStatus,30000);route();
document.addEventListener('click',e=>{const tab=e.target.closest('[data-category-tab]');if(!tab)return;e.preventDefault();e.stopPropagation();activeProspectCategory=tab.dataset.categoryTab||'all';if($('prospectTabs'))$('prospectTabs').innerHTML=prospectTabs();if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()});
document.addEventListener('click',e=>{const tab=e.target.closest('[data-log-tab]');if(tab){e.preventDefault();activeLogTab=tab.dataset.logTab||'all';refreshLogs();return}if(e.target.id==='refreshLogs')refreshLogs()});
document.addEventListener('click',e=>{const target=e.target.closest('button,a,select,input,textarea,tr,[role="button"]');if(!target)return;const details={tag:target.tagName.toLowerCase(),id:target.id||'',label:target.getAttribute('aria-label')||target.getAttribute('title')||'',route:location.hash.slice(1)||'dashboard'};for(const name of ['open','message','assess','findWebsites','acceptWebsite','rejectWebsite','categoryTab','logTab','review'])if(target.dataset[name])details[name]=target.dataset[name];logAction('ui_click',details)},true);
+5
View File
@@ -134,3 +134,8 @@ input::placeholder { color: #aeb6c5; } input:focus, select:focus { border-color:
background: #fff5f6;
color: #a33e4d;
}
.list-hint{margin:-9px 0 17px;color:#8a96a8;font-size:11px}
.section-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.section-heading h3{margin:0}.section-heading .muted{font-size:10px}
.drawer-review-tabs{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.drawer-review-tab{min-height:36px;padding:7px 8px;border:1px solid #dfe4ed;border-radius:8px;background:#fff;color:#59677e;font:inherit;font-size:11px;font-weight:800;cursor:pointer}.drawer-review-tab:hover{border-color:#b9b5ff;background:#f7f6ff;color:#4f47df}.drawer-review-tab.active{border-color:#635bff;background:#635bff;color:#fff;box-shadow:0 5px 12px #635bff2b}.drawer-review-tab:disabled{cursor:wait;opacity:.6}
.message-context{padding:14px;border:1px solid #e5e9f2;border-radius:11px;background:#fafbff}.message-context-title{display:flex;align-items:center;gap:10px}.message-context-title .score{flex:0 0 auto}.message-context-title b,.message-context-title small{display:block}.message-context-title b{font-size:14px;color:#27344b}.message-context-title small{margin-top:3px;color:#8995a8;font-size:11px}.message-context-facts{display:flex;flex-wrap:wrap;gap:7px;margin-top:11px}.message-context-facts span{padding:5px 8px;border-radius:999px;background:#eef1f7;color:#66748a;font-size:10px;font-weight:700}.message-controls{display:grid;grid-template-columns:1fr 1fr;gap:12px}.message-controls label{display:grid;gap:6px;font-size:11px;font-weight:800;color:#526078}.message-controls select{width:100%;margin:0}.message-dialog .field-help{display:block;margin-top:5px;font-size:10px;font-weight:500}.draft-generate{width:100%;min-height:42px}.draft-output{display:grid;gap:8px;padding-top:4px;border-top:1px solid #edf0f5}.draft-output label{display:flex;justify-content:space-between;align-items:center;color:#526078;font-size:11px;font-weight:800}.draft-output label small{color:#9aa4b5;font-size:10px;font-weight:500}.draft-output input,.draft-output textarea{margin:0}.draft-output textarea{min-height:235px;resize:vertical;line-height:1.55}
@media(max-width:560px){.drawer-review-tabs{grid-template-columns:repeat(2,minmax(0,1fr))}.message-controls{grid-template-columns:1fr}}