deploy Nous v16 update
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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=>`<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…';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?`<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)}
|
||||
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();
|
||||
|
||||
+1
-1
@@ -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=ui-v14"></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">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Use the evidence 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 subject and email</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=ui-v10"></script></body></html>
|
||||
<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">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Use the evidence 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 subject and email</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=ui-v15"></script></body></html>
|
||||
|
||||
Reference in New Issue
Block a user