diff --git a/README.md b/README.md index 641866d..30c0985 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ views. It includes website health and email evidence, explainable scoring, domain ideas, suppression, CSV export, scheduled runs, rescans, and human review. It never sends outreach automatically. Optional AI can analyse a prospect and draft a subject/email from supplied evidence and your offer; you edit and approve the draft yourself. +From a prospect's detail drawer, **Assess with AI** stores a contact probability, +recommendation, and short evidence-grounded rationale for that specific company. ## Start the stack @@ -118,7 +120,8 @@ none are published. Website scans are bounded and SSRF-safe. A scan records HTTP status, final URL, latency, HTTPS/TLS, title, technology hints, contact-page emails, and errors. Use `/api/prospects/{id}/scan` for a manual rescan. The explorer's “Draft AI -email” action calls `/api/prospects/{id}/message`; it requires the optional AI +email” action calls `/api/prospects/{id}/message`, and **Assess with AI** calls +`/api/prospects/{id}/assess`; both require the optional AI variables above and accepts your exact offer/instructions (for example, the Gold Plus R119/month plan and a free rebuild). Suppressions are enforced before new records are saved and can be managed through `/api/suppressions`. diff --git a/api/server.py b/api/server.py index c5abc17..0974bf3 100644 --- a/api/server.py +++ b/api/server.py @@ -1043,6 +1043,82 @@ def ai_generate_message(prospect: dict[str, Any], offer: str) -> dict[str, Any]: return {"subject": str(parsed["subject"])[:200], "message": str(parsed["message"])[:8000], "rationale": str(parsed.get("rationale") or "")[:2000]} +def _parse_ai_object(content: Any) -> dict[str, Any]: + """Parse a JSON object from models that occasionally add markdown fences.""" + text = str(content or "").strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.I | re.S).strip() + try: + value = json.loads(text) + except (TypeError, ValueError, json.JSONDecodeError): + start, end = text.find("{"), text.rfind("}") + if start < 0 or end <= start: + raise RuntimeError("ai_invalid_json_response") + try: + value = json.loads(text[start:end + 1]) + except (TypeError, ValueError, json.JSONDecodeError) as exc: + raise RuntimeError("ai_invalid_json_response") from exc + if not isinstance(value, dict): + raise RuntimeError("ai_invalid_json_response") + return value + + +def ai_assess_prospect(prospect: dict[str, Any]) -> dict[str, Any]: + """Assess whether a prospect is worth human review/contact.""" + runtime = ai_runtime_settings() + if runtime["provider"] == "nous" and not runtime["api_key"]: + raise RuntimeError("nous_api_key_required_for_standalone_app") + if not runtime["base_url"] or not runtime["model"]: + raise RuntimeError("ai_not_configured_set_AI_BASE_URL_and_AI_MODEL") + evidence = { + "business": prospect.get("name"), "category": prospect.get("category"), + "address": prospect.get("address"), "phone": prospect.get("phone"), + "website": prospect.get("website"), "website_status": prospect.get("website_status"), + "website_http_status": prospect.get("website_http_status"), + "website_title": prospect.get("website_title"), "technology": prospect.get("website_technology"), + "emails": prospect.get("emails"), "rating": prospect.get("rating"), + "review_count": prospect.get("review_count"), "opportunity_score": prospect.get("opportunity_score"), + "score_reasons": _json_list(prospect.get("score_reasons")), + } + system = ( + "You qualify prospects for a South African web hosting and web design business. " + "Assess only the supplied public evidence. A high probability means the business appears active " + "and has a plausible hosting, domain, website repair, or redesign need. Do not invent facts, " + "intent, budget, decision-maker names, or contact details. Return strict JSON with exactly these " + "keys: probability (integer 0-100), recommendation (one of contact, review, do_not_contact), " + "reasoning (one concise paragraph)." + ) + prompt = json.dumps({"prospect": evidence}, ensure_ascii=False) + headers = {"Authorization": "Bearer " + runtime["api_key"]} if runtime["api_key"] else {} + request_payload: dict[str, Any] = { + "model": runtime["model"], "temperature": 0.1, "max_tokens": 450, + "messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}], + } + if runtime["provider"] != "nous": + request_payload["response_format"] = {"type": "json_object"} + result = http_json("POST", runtime["base_url"] + "/chat/completions", request_payload, headers=headers, timeout=90, error_prefix="ai") + content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else "" + parsed = _parse_ai_object(content) + try: + probability = max(0, min(100, int(round(float(parsed.get("probability", 0)))))) + except (TypeError, ValueError): + raise RuntimeError("ai_invalid_probability") + recommendation = str(parsed.get("recommendation") or "").strip().lower().replace(" ", "_") + aliases = { + "yes": "contact", "contact_now": "contact", "high": "contact", + "maybe": "review", "needs_review": "review", "medium": "review", + "no": "do_not_contact", "do_not_contact_now": "do_not_contact", "low": "do_not_contact", + } + recommendation = aliases.get(recommendation, recommendation) + if recommendation not in {"contact", "review", "do_not_contact"}: + recommendation = "contact" if probability >= 70 else "review" if probability >= 45 else "do_not_contact" + return { + "ai_probability": probability, + "ai_recommendation": recommendation, + "ai_reasoning": str(parsed.get("reasoning") or "").strip()[:3000], + } + + class Handler(BaseHTTPRequestHandler): server_version = "PersonalProspectOS/2.0" @@ -1207,6 +1283,29 @@ class Handler(BaseHTTPRequestHandler): return self.send_json(502, {"error": str(exc)}) except Exception as exc: return self.send_json(502, {"error": "ai_test_failed:" + str(exc)[:200]}) + assess_match = re.fullmatch(r"/api/prospects/(\d+)/assess", urlparse(self.path).path) + if assess_match: + conn = None + try: + conn = db(); prospect_id = int(assess_match.group(1)) + row = conn.execute("SELECT * FROM prospects WHERE id=?", (prospect_id,)).fetchone() + if not row: + return self.send_json(404, {"error": "not_found"}) + assessment = ai_assess_prospect(dict(row)) + stamp = now() + conn.execute( + "UPDATE prospects SET ai_probability=?,ai_recommendation=?,ai_reasoning=?,ai_checked_at=? WHERE id=?", + (assessment["ai_probability"], assessment["ai_recommendation"], assessment["ai_reasoning"], stamp, prospect_id), + ) + conn.commit() + return self.send_json(200, {**assessment, "ai_checked_at": stamp, "prospect_id": prospect_id}) + except RuntimeError as exc: + return self.send_json(503, {"error": str(exc)}) + except (ValueError, TypeError, json.JSONDecodeError) as exc: + return self.send_json(400, {"error": str(exc)}) + finally: + if conn is not None: + conn.close() message_match = re.fullmatch(r"/api/prospects/(\d+)/message", urlparse(self.path).path) if message_match: conn = None diff --git a/tests/test_app.py b/tests/test_app.py index eea1d5c..988e76f 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -137,6 +137,22 @@ class BrowserOnlyAppTests(unittest.TestCase): server.http_json("GET", "https://example.test/v1/models", error_prefix="ai") self.assertEqual(open_url.call_args.args[0].headers.get("User-agent"), server.OUTBOUND_USER_AGENT) + def test_ai_assessment_is_saved_for_a_prospect(self): + conn = server.db() + conn.execute("INSERT INTO prospects(name,domain,category,website_status,opportunity_score,discovered_at) VALUES(?,?,?,?,?,?)", ("Acme Plumbing", "acme.co.za", "plumber", "no_detected_website", 82, server.now())) + conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)", ("AI_PROVIDER", "nous", server.now())) + conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)", ("AI_MODEL", "meituan/longcat-2.0:free", server.now())) + conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)", ("AI_API_KEY", "test-key", server.now())) + conn.commit(); conn.close() + response = {"choices": [{"message": {"content": '{"probability":84,"recommendation":"contact","reasoning":"Active business with a clear website opportunity."}'}}]} + with patch("api.server.http_json", return_value=response): + status, body = self.request("POST", "/api/prospects/1/assess", {}) + self.assertEqual(status, 200) + self.assertEqual(body["ai_probability"], 84) + self.assertEqual(body["ai_recommendation"], "contact") + saved = server.db().execute("SELECT ai_probability,ai_recommendation,ai_reasoning FROM prospects WHERE id=1").fetchone() + self.assertEqual(tuple(saved), (84.0, "contact", "Active business with a clear website opportunity.")) + if __name__ == "__main__": unittest.main() diff --git a/web/app.js b/web/app.js index 674e1ab..7ec22b8 100644 --- a/web/app.js +++ b/web/app.js @@ -20,4 +20,9 @@ document.addEventListener('click',async e=>{const open=e.target.closest('[data-o 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=>``).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…';try{const r=await api('/api/discover',{method:'POST',body:JSON.stringify({keywords:$('keywords').value,category:$('category').value,location:$('location').value,max_results:Number($('max-results').value),extra_reviews:$('extra-reviews').checked,include_email:true})});m.textContent=`Run #${r.id} started. View it in Runs.`;setTimeout(route,500)}catch(x){m.textContent=x.message}finally{$('discoverBtn').disabled=false}}if(e.target.id==='settingsForm'){e.preventDefault();const m=$('settingsMessage');try{const ai={AI_PROVIDER:$('aiProvider').value,AI_BASE_URL:$('aiBase').value,AI_MODEL:$('aiModel').value};if($('aiKey').value)ai.AI_API_KEY=$('aiKey').value;await api('/api/settings',{method:'POST',body:JSON.stringify({ai})});m.textContent='Settings saved.';m.className='notice ok';}catch(x){m.textContent=x.message}}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');s.textContent='Generating…';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;s.textContent='Draft ready. Review and edit before sending.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}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?`
${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)} +const _openDrawerWithAssessment=openDrawer;openDrawer=function(id){_openDrawerWithAssessment(id);addAssessmentPanel(id)} +async function assessProspect(id,button){if(button){button.disabled=true;button.textContent='Assessing…'}try{const d=await api(`/api/prospects/${id}/assess`,{method:'POST',body:'{}'});const index=prospects.findIndex(p=>String(p.id)===String(id));if(index>=0)prospects[index]={...prospects[index],...d};selected=prospects[index]||selected;openDrawer(id)}catch(error){const panel=$('aiAssessmentPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}if(button){button.disabled=false;button.textContent='Assess with AI'}}} +document.addEventListener('click',async e=>{const button=e.target.closest('[data-assess]');if(button){e.preventDefault();e.stopPropagation();await assessProspect(button.dataset.assess,button)}}); window.addEventListener('hashchange',route);$('refreshAll').addEventListener('click',route);route(); diff --git a/web/index.html b/web/index.html index 504e858..018cd87 100644 --- a/web/index.html +++ b/web/index.html @@ -1,3 +1,3 @@