From e92ffaa807c6042d04b2ebedecd66597c401172e Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Sun, 6 Sep 2026 00:21:17 +0200 Subject: [PATCH] deploy DOM search v6 update --- README.md | 6 +++++ api/server.py | 60 +++++++++++++++++++++++++++++++---------------- tests/test_app.py | 7 ++++-- web/app.js | 20 +++++++++------- web/style.css | 5 ++++ 5 files changed, 68 insertions(+), 30 deletions(-) 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 `
⌕${text}=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 `
${categories.map(category=>``).join('')}

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 `
${rows.map(p=>``).join('')}
ProspectWebsiteEmailPotentialReview
${esc(p.name)}${esc(prospectCategory(p))} · ${esc(p.address||'')}${esc((p.website_status||'unknown').replaceAll('_',' '))}${p.website?`Open ↗`:'No detected website'}${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'}
${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 `
${rows.map(p=>``).join('')}
ProspectWebsiteEmailPotential
${esc(p.name)}${esc(prospectCategory(p))} · ${esc(p.address||'')}${esc((p.website_status||'unknown').replaceAll('_',' '))}${p.website?`Open ↗`:'No detected website'}${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'}
${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.',`
CATALOGUE${total}saved prospects
NEEDS REVIEW${pending}awaiting your decision
HIGH OPPORTUNITY${hot}score 75 or higher
PUBLIC EMAILS${withEmail}evidence-backed contacts
NEXT ACTION

Review your strongest leads

Inspect the evidence before deciding whether to contact them.

Open explorer →
${prospects.slice(0,5).map(p=>``).join('')||empty('Run a discovery to build your catalogue')}
RECENT ACTIVITY

Runs

View all
${runs.slice(0,6).map(r=>`
#${r.id} · ${esc(r.query)}${esc(r.status)} · ${r.added||0} added
`).join('')||empty('No runs yet')}
`)} function renderDiscover(){ $('view').innerHTML=layout('Discover','Set the criteria for this run. Source configuration and provider settings live in Settings.',`
NEW RUN

Find potential customers

Search Google Maps, deduplicate the catalogue, then enrich each result.

Email collection always on
HOW IT WORKS

Evidence-first pipeline

  1. SearchMaps worker finds public business listings.
  2. EnrichWebsite, HTTPS, DNS hints and public email checks run safely.
  3. ReviewYou decide what is relevant before any contact.
`)} -function renderProspects(){ $('view').innerHTML=layout('Prospects','Browse the strongest opportunities first, then switch to a search category when you want a focused list.',`
${prospectTabs()}
${prospectTable()}
`)} +function renderProspects(){ $('view').innerHTML=layout('Prospects','Browse the strongest opportunities first, then switch to a search category when you want a focused list.',`
${prospectTabs()}

Click any prospect to review evidence, change its status, assess it with AI, or draft a tailored message.

${prospectTable()}
`)} function renderRuns(){ $('view').innerHTML=layout('Runs','Monitor discovery jobs and inspect errors without leaving the run history.',`
${runs.map(r=>``).join('')}
RunStatusResultsStartedSource
#${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')}
${runs.length?'':empty('No discovery runs yet')}
`)} function logTabCount(tab,counts){return tab==='errors'?Number(counts?.errors||0):Number(counts?.[tab]||0)} function logTabs(counts={}){return `
${LOG_TABS.map(([key,label])=>``).join('')}
`} @@ -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=`AI review notes

${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=`
${p.opportunity_score||0}
Opportunity scoreExplainable triage signal, not an AI verdict

Contact

${esc(p.emails||'No public email found')}

${esc(p.phone||'No phone listed')}

Website health

Status${esc((p.website_status||'unknown').replaceAll('_',' '))}HTTPS${p.website_https?'Yes':'No / unknown'}Latency${p.website_latency_ms?p.website_latency_ms+' ms':'—'}Checked${date(p.website_checked_at)}

Why this scored

    ${reasons.map(r=>`
  • ${esc(r)}
  • `).join('')||'
  • No score reasons recorded
  • '}
${p.website?`Open website ↗`:''}
`;$('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=`

Tailor a message

Start from verified prospect evidence, then edit before sending.

`} +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=`
${selected.opportunity_score||0}
${esc(selected.name)}${esc(prospectCategory(selected))} · ${esc(selected.address||'Location not listed')}
${selected.website?`Website: ${esc((selected.website_status||'detected').replaceAll('_',' '))}`:'No website detected'}${selected.emails?`Public email: ${esc(selected.emails)}`:'No public email found'}
`;$('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 `

Review status

Saved to this prospect
${options.map(([value,label])=>``).join('')}
`} +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=`
${p.opportunity_score||0}
Opportunity scoreExplainable triage signal, not an AI verdict
${reviewStatusTabs(p)}

Contact

${esc(p.emails||'No public email found')}

${esc(p.phone||'No phone listed')}

Website health

Status${esc((p.website_status||'unknown').replaceAll('_',' '))}HTTPS${p.website_https?'Yes':'No / unknown'}Latency${p.website_latency_ms?p.website_latency_ms+' ms':'—'}Checked${date(p.website_checked_at)}

Why this scored

    ${reasons.map(r=>`
  • ${esc(r)}
  • `).join('')||'
  • No score reasons recorded
  • '}
${p.website?`Open website ↗`:''}
`;$('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=>``).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=>``).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?`

AI assessment

Contact probability${Math.round(Number(p.ai_probability))}%Recommendation${esc(aiRecommendationLabel(p.ai_recommendation))}

${esc(p.ai_reasoning||'No rationale returned.')}

Assessed ${date(p.ai_checked_at)}

`:`

AI assessment

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}}