Review your strongest leads
Inspect the evidence before deciding whether to contact them.
diff --git a/README.md b/README.md index 7203224..755d21d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api/server.py b/api/server.py index 8bb0498..cd330a5 100644 --- a/api/server.py +++ b/api/server.py @@ -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)}) diff --git a/tests/test_app.py b/tests/test_app.py index 09e0012..4499ce7 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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() diff --git a/web/app.js b/web/app.js index e1ad017..f44c544 100644 --- a/web/app.js +++ b/web/app.js @@ -16,11 +16,11 @@ function empty(text){return `
All shows scores ${HIGH_POTENTIAL_SCORE}+; category tabs keep every result grouped by the search category.
`} -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 `| Prospect | Website | Potential | Review | ||
|---|---|---|---|---|---|
| ${esc(p.name)}${esc(prospectCategory(p))} · ${esc(p.address||'')} | ${esc((p.website_status||'unknown').replaceAll('_',' '))}${p.website?`Open ↗`:'No detected website'} | ${p.emails?`✉ ${esc(p.emails)}`:'Not found'} | ${p.opportunity_score||0}${Number(p.opportunity_score||0)>=85?'Hot':Number(p.opportunity_score||0)>=70?'High':Number(p.opportunity_score||0)>=45?'Medium':'Low'} |
| Prospect | Website | Potential | ||
|---|---|---|---|---|
| ${esc(p.name)}${esc(prospectCategory(p))} · ${esc(p.address||'')} | ${esc((p.website_status||'unknown').replaceAll('_',' '))}${p.website?`Open ↗`:'No detected website'} | ${p.emails?`✉ ${esc(p.emails)}`:'Not found'} | ${p.opportunity_score||0}${Number(p.opportunity_score||0)>=85?'Hot':Number(p.opportunity_score||0)>=70?'High':Number(p.opportunity_score||0)>=45?'Medium':'Low'} |
Inspect the evidence before deciding whether to contact them.
Search Google Maps, deduplicate the catalogue, then enrich each result.
Click any prospect to review evidence, change its status, assess it with AI, or draft a tailored message.
| Run | Status | Results | Started | Source |
|---|---|---|---|---|
| #${r.id}${esc(r.query)} | ${esc(r.status)}${esc(r.stage||'')} | ${r.added||0} added / ${r.found||0} found | ${date(r.created_at)} | ${esc(r.source||'google_maps')} |
${esc(d?.rationale||'The draft uses the prospect evidence and your instructions.')}
${unknowns.length?`Confirm before sending: ${esc(unknowns.join(' · '))}
`:''}${used.length?`Evidence used: ${esc(used.join(', '))}
`:''}${d?.follow_up?`${esc(d.follow_up)}
`:''}`} -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=`${esc(p.emails||'No public email found')}
${esc(p.phone||'No phone listed')}
Start from verified prospect evidence, then edit before sending.
${esc(p.emails||'No public email found')}
${esc(p.phone||'No phone listed')}
${esc(p.ai_reasoning||'No rationale returned.')}
Assessed ${date(p.ai_checked_at)}
`:`Use the collected evidence to estimate whether this company is worth contacting.
`;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); diff --git a/web/style.css b/web/style.css index c7543de..e87265f 100644 --- a/web/style.css +++ b/web/style.css @@ -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}}