add evidence-grounded ai assistance
This commit is contained in:
@@ -111,3 +111,13 @@ There is no send button, message composer, SMTP probe, validation email, campaig
|
||||
## Remaining limitations
|
||||
|
||||
The static client has no client-side crawler, scanner, contact extractor, enrichment scheduler, outreach integration, availability provider, or SSE delivery. It can display server-provided Phase 9 observations, but production still requires server-side official-site scoping, SSRF/DNS-rebinding/redirect controls, hard extraction/page/byte/time/candidate budgets, durable history/cache isolation and retention/deletion, abuse/rate controls, suppression regression tests, and authenticated provenance/audit coverage. For domain intelligence, display registrable-domain/PSL version and unresolved reasons, DNS status and freshness/TTL (not “available”), independent MX/NS/TXT uncertainty, and association confidence with explainable evidence. Never auto-attach candidate domains or treat `nxdomain`/`no_data` as availability. CSV preview is capped for display and is not an import workflow.
|
||||
|
||||
## Phase 13 optional AI assistance UI contract
|
||||
|
||||
The UI may offer AI drafting only when the authenticated API reports an approved, enabled capability for the current tenant and task. Provider choice, fallback, prompt construction, redaction, budgets, tenant authorization, and suppression checks are server-side; the browser must never receive provider secrets or call an AI vendor directly. Show provider/model/version and `unknown`/`unavailable`, timeout, partial, stale, or policy-blocked states distinctly from an empty or successful result.
|
||||
|
||||
Every suggestion must display its evidence citations, tenant-scoped evidence IDs, exact evidence hash/snapshot identifier, observed time, uncertainty/conflict reasons, and policy/provider/model versions. A citation points to the evidence used; it is not proof that the source is correct, and an AI explanation is not an independently verified fact. Do not render unsupported or invented facts about names, roles, contact details, dates, outcomes, consent, deliverability, ownership, or other claims as facts. Preserve missing and conflicting evidence instead of filling gaps. Suppressed/do-not-contact records remain visible with the safety state and never become actionable because an AI suggestion is confident.
|
||||
|
||||
AI output must be visibly labeled **AI suggestion — human review required** and remain read-only until an authorized human explicitly approves it. Approval must show the proposed change, citations/hash, freshness, tenant scope, and safe reason; rejection and expiry must be available. The UI must require re-review when the evidence hash or policy version changes and must display partial/failed approval rather than implying persistence. Approval does not authorize contact or verification.
|
||||
|
||||
No Phase 13 control may send email/SMS, probe SMTP, create a campaign, schedule follow-up, alter pipeline/interactions/outcomes as if communication occurred, merge records, acquire a domain, or perform autonomous CRM/outreach actions. The browser must not hide or export suppressed data as eligible, and exports/reports must retain safe AI provenance and redaction labels where applicable. Production remains limited until browser/API tests cover citations and hash mismatch, redaction, fallback boundaries, approval/rejection, stale/conflicting evidence, suppression precedence, tenant non-disclosure, and no-autonomy controls; the current Compose stack has no configured AI provider.
|
||||
|
||||
+22
-2
@@ -122,7 +122,27 @@
|
||||
panel.innerHTML=`<div class="score-panel-heading"><div><p class="eyebrow">SCORING</p><h4>Score breakdown</h4></div><button class="button ghost compact" id="recalculateScoreBtn" type="button">↻ Recalculate</button></div><div class="score-breakdown-summary"><div><small>Total score</small><strong class="score-total ${scoreClass(score)}">${esc(score)}<small>/ 100</small></strong></div><div><small>Priority</small><b>${esc(priority)}</b></div><div><small>Eligibility</small><b>${esc(eligible)}</b></div></div><dl class="score-meta"><div><dt>Score version</dt><dd>${esc(version)}</dd></div><div><dt>Signals</dt><dd>${factors.length}</dd></div></dl><div class="score-rules-explanation"><h5>Rule explanations</h5>${factors.length?factors.map(raw=>{const item=scoreExplanation(raw);return `<div class="score-rule-row"><span>${esc(scoreValue(item,['label','name','rule','factor'],'Scoring signal'))}</span><strong>${esc(scoreValue(item,['points','value','weight'],0))} pts</strong><small>${esc(scoreValue(item,['explanation','reason','description'],'Observed evidence contributes to this score.'))}</small></div>`}).join(''):'<p class="muted">No scoring signals returned.</p>'}</div><p class="score-safety">Scores are review signals only. They do not override suppression or authorize outreach.</p><p id="scoreMessage" class="form-message" role="status" aria-live="polite"></p>`;
|
||||
}
|
||||
async function recalculateScore() { const button=$('recalculateScoreBtn'), messageEl=$('scoreMessage'); if(!button||!selectedId)return; button.disabled=true; if(messageEl)messageEl.textContent='Recalculation requested…'; try { const result=await jsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/score/recalculate`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})}); if(messageEl)messageEl.textContent=result?.status==='queued'?'Recalculation queued. Refresh when the job completes.':'Score recalculation accepted. Refresh to see the API result.'; } catch(error) { if(error.message!=='unauthorized'&&messageEl){messageEl.textContent=error.message||'Unable to recalculate score.';messageEl.className='form-message error';} } finally {button.disabled=false;} }
|
||||
function renderDetail(p){const s=scoreFor(p),st=statusOf(p),f=freshness(p),factors=p.score_factors||p.factors||[],blocked=st==='review'||st==='suppressed',stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||[],domains=p.domains||[],websites=p.websites||[],evidence=p.evidence||p.evidence_timeline||[],notes=p.notes||[],review=p.review_status||p.review|| (st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1));$('detailPanel').innerHTML=`<div class="detail-head"><div><p class="eyebrow">PROSPECT DETAIL</p><h3>${esc(p.name)}</h3><p class="detail-domain">${esc(p.website_domain||'no detected website')}</p></div><span class="status ${st}">${esc(review)}</span></div><div class="detail-score"><div><small>Fit score</small><b>${s}<small>/ 100</small></b></div><span class="score ${scoreClass(s)}">${esc(p.confidence||(s>=80?'High':s>=60?'Medium':'Low'))} confidence</span></div><section class="detail-block score-breakdown-panel" id="scoreBreakdownPanel" data-smoke="score-breakdown"><div class="detail-loading">Loading score breakdown…</div></section><div class="detail-block"><h4>Pipeline stage</h4><form id="pipelineForm" class="inline-form"><select name="stage" aria-label="Pipeline stage"><option value="new" ${stage==='new'?'selected':''}>New</option><option value="qualified" ${stage==='qualified'?'selected':''}>Qualified</option><option value="review" ${stage==='review'?'selected':''}>Review</option><option value="suppressed" ${stage==='suppressed'?'selected':''}>Suppressed</option></select><button class="button ghost compact" type="submit">Save stage</button></form><p id="pipelineMessage" class="form-message" role="status"></p></div><div class="detail-block"><h4>Contacts <span class="count">${contacts.length}</span></h4>${listItems(contacts,'No contacts added.','email')}<form id="contactForm" class="compact-form"><input name="name" placeholder="Contact name" aria-label="Contact name"><input name="email" type="email" placeholder="Email" aria-label="Contact email" required><button class="button ghost compact" type="submit">Add contact</button></form><p id="contactMessage" class="form-message" role="status"></p></div><section class="detail-block contact-extraction-panel" id="contactExtractionPanel" data-smoke="contact-extraction"><div class="detail-loading">Loading public contacts…</div></section><section class="detail-block website-scan-panel" id="websiteScanPanel" data-smoke="website-scan"><div class="detail-loading">Loading website scan…</div></section><section class="detail-block domain-intelligence" id="domainIntelligencePanel" data-smoke="domain-intelligence"><div class="detail-loading">Loading domain intelligence…</div></section><div class="detail-block"><h4>Domains & websites</h4>${listItems(domains,'No domains recorded.','domain')}${listItems(websites,'No websites recorded.','url')}</div><div class="detail-block"><h4>Evidence timeline</h4>${listItems(evidence,'No evidence events recorded.','description')}${factors.length?factors.map(x=>`<p class="evidence-line"><span>✓ ${esc(labelFactor(x))}</span><span class="confidence">${esc(p.confidence||'Medium')}</span></p>`).join(''):''}</div><div class="detail-block"><h4>Notes <span class="count">${notes.length}</span></h4>${listItems(notes,'No notes added.','body')}<form id="noteForm" class="compact-form"><textarea name="body" rows="2" placeholder="Add a review note…" required></textarea><button class="button ghost compact" type="submit">Add note</button></form><p id="noteMessage" class="form-message" role="status"></p></div><div class="detail-block"><h4>Review status</h4><p class="review-status">${esc(review)}</p>${st!=='suppressed'?'<button class="button primary compact" id="verifyBtn" type="button">Mark verified</button>':''}<p id="verifyMessage" class="form-message" role="status"></p></div>${blocked?`<button class="button disabled-action" disabled aria-disabled="true">Outreach unavailable</button><p class="disabled-reason">${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}</p>`:''}`;renderScoreBreakdown(p);renderContactExtractionPanel(p);loadContactExtraction(p.id);renderWebsiteScanPanel(p);renderDomainPanel(p);renderDedupPanel();}
|
||||
const aiItems = payload => { const runs=payloadItems(payload, ['items','runs','ai_runs']); return runs.flatMap(run => (run.suggestions||[]).length ? run.suggestions.map(item => ({...item,id:run.id,status:run.approval_state||run.status,provider:run.provider||run.model||'Unknown'})) : [{...run,id:run.id,status:run.status||run.approval_state,provider:run.provider||run.model||'Unknown'}]); };
|
||||
const aiStatus = item => String(item?.status || item?.state || item?.suggestion_status || 'unknown').toLowerCase().replaceAll('_','-');
|
||||
const aiLabel = value => ({'not-configured':'Not configured',unknown:'Unknown',error:'Error',suppressed:'Suppressed',pending:'Pending approval',approved:'Approved',rejected:'Rejected',generated:'Generated',ready:'Ready'})[String(value).toLowerCase()] || String(value || 'Unknown').replaceAll('-',' ').replace(/\b\w/g,c=>c.toUpperCase());
|
||||
const aiCitations = item => item?.citations || item?.sources || item?.evidence || [];
|
||||
const aiCitationText = citation => typeof citation === 'string' ? citation : citation?.title || citation?.label || citation?.source_url || citation?.url || citation?.quote || citation?.text || JSON.stringify(citation);
|
||||
function renderAiSuggestion(item) {
|
||||
const status=aiStatus(item), citations=aiCitations(item), pending=['pending','generated','ready'].includes(status), id=item?.id || item?.suggestion_id;
|
||||
const body=item?.suggestion || item?.text || item?.content || item?.recommendation || item?.message || 'No suggestion was returned.';
|
||||
const provider=item?.provider || item?.provider_name || item?.model || 'Unknown';
|
||||
return `<article class="ai-suggestion ${status}" data-ai-suggestion-id="${esc(id || '')}"><div class="ai-suggestion-head"><div><span class="ai-status ${esc(status)}">${esc(aiLabel(status))}</span><span class="ai-provider">Provider: ${esc(provider)}</span></div>${pending && id ? `<div class="ai-actions"><button class="button ghost compact" type="button" data-ai-action="reject" data-ai-id="${esc(id)}">Reject</button><button class="button primary compact" type="button" data-ai-action="approve" data-ai-id="${esc(id)}">Approve</button></div>` : ''}</div><p class="ai-copy">${esc(body)}</p>${citations.length?`<div class="ai-citations"><h5>Evidence citations <span class="count">${citations.length}</span></h5><ol>${citations.slice(0,10).map(c=>`<li>${esc(aiCitationText(c))}${c?.url||c?.source_url?` <a href="${esc(c.url||c.source_url)}" target="_blank" rel="noreferrer">Open source</a>`:''}</li>`).join('')}</ol></div>`:'<p class="muted">No citations were returned; treat this as unknown.</p>'}</article>`;
|
||||
}
|
||||
function renderAiState(state, detail='') { const el=$('aiAssistanceState'); if(!el)return; const normalized=String(state||'unknown').toLowerCase().replaceAll('_','-'); const copy={
|
||||
'not-configured':['AI assistance is not configured','Ask an administrator to configure an approved provider before generating suggestions.'],
|
||||
unknown:['AI assistance is unknown','The provider did not establish a result. No suggestion is available for approval.'],
|
||||
error:['Unable to load AI assistance',detail||'The provider returned an error. Try again later.'],
|
||||
suppressed:['AI assistance suppressed','This prospect is suppressed. Suggestions are unavailable and no contact action is permitted.']
|
||||
}[normalized] || ['No suggestion yet','Generate an evidence-grounded suggestion when an approved provider is available.']; el.innerHTML=`<div class="ai-state ${esc(normalized)}" role="${normalized==='error'?'alert':'status'}"><strong>${esc(copy[0])}</strong><p>${esc(copy[1])}</p></div>`; }
|
||||
async function loadAiAssistance(id) { const panel=$('aiAssistancePanel'); if(!panel)return; const prospect=selectedDetail||prospects.find(item=>Number(item.id)===Number(id))||{}; if(statusOf(prospect)==='suppressed'){renderAiState('suppressed');return;} panel.querySelector('#aiAssistanceState').innerHTML='<div class="detail-loading" aria-live="polite">Loading AI assistance…</div>'; try { const payload=await jsonRequest(`/api/v1/ai-runs?business_id=${encodeURIComponent(id)}`); const items=aiItems(payload); const status=String(payload?.status||payload?.state||'').toLowerCase().replaceAll('_','-'); if(['not-configured','unknown','error','suppressed'].includes(status)){renderAiState(status,payload?.reason||payload?.message);return;} panel.querySelector('#aiAssistanceState').innerHTML=items.length?items.map(renderAiSuggestion).join(''): '<div class="ai-state" role="status"><strong>No suggestion yet</strong><p>Generate an evidence-grounded suggestion when an approved provider is available.</p></div>'; } catch(error) { if(error.message!=='unauthorized')renderAiState('error',error.message); } }
|
||||
async function generateAiSuggestion() { if(!selectedId)return; const button=$('generateAiSuggestionBtn');if(!button)return;button.disabled=true;const state=$('aiAssistanceState');state.innerHTML='<div class="detail-loading" aria-live="polite">Generating evidence-grounded suggestion…</div>';try{await jsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/ai/suggest`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({evidence_grounded:true,autonomous_action:false})});await loadAiAssistance(selectedId);}catch(error){if(error.message!=='unauthorized')renderAiState(error.message.toLowerCase().includes('config')?'not-configured':'error',error.message);}finally{button.disabled=false;}}
|
||||
async function aiAction(action,id) { if(!id)return; const label=action==='approve'?'approve':'reject'; if(!window.confirm(`Confirm ${label} of this suggestion? This only records a human decision; no outreach will be sent.`))return; const path=action==='approve'?`/api/v1/ai-runs/${encodeURIComponent(id)}/approve`:`/api/v1/ai-runs/${encodeURIComponent(id)}/reject`; try{await jsonRequest(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({human_approval:true,autonomous_action:false})});await loadAiAssistance(selectedId);}catch(error){if(error.message!=='unauthorized')renderAiState('error',error.message);}}
|
||||
function renderDetail(p){const s=scoreFor(p),st=statusOf(p),f=freshness(p),factors=p.score_factors||p.factors||[],blocked=st==='review'||st==='suppressed',stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||[],domains=p.domains||[],websites=p.websites||[],evidence=p.evidence||p.evidence_timeline||[],notes=p.notes||[],review=p.review_status||p.review|| (st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1));$('detailPanel').innerHTML=`<div class="detail-head"><div><p class="eyebrow">PROSPECT DETAIL</p><h3>${esc(p.name)}</h3><p class="detail-domain">${esc(p.website_domain||'no detected website')}</p></div><span class="status ${st}">${esc(review)}</span></div><div class="detail-score"><div><small>Fit score</small><b>${s}<small>/ 100</small></b></div><span class="score ${scoreClass(s)}">${esc(p.confidence||(s>=80?'High':s>=60?'Medium':'Low'))} confidence</span></div><section class="detail-block score-breakdown-panel" id="scoreBreakdownPanel" data-smoke="score-breakdown"><div class="detail-loading">Loading score breakdown…</div></section><div class="detail-block"><h4>Pipeline stage</h4><form id="pipelineForm" class="inline-form"><select name="stage" aria-label="Pipeline stage"><option value="new" ${stage==='new'?'selected':''}>New</option><option value="qualified" ${stage==='qualified'?'selected':''}>Qualified</option><option value="review" ${stage==='review'?'selected':''}>Review</option><option value="suppressed" ${stage==='suppressed'?'selected':''}>Suppressed</option></select><button class="button ghost compact" type="submit">Save stage</button></form><p id="pipelineMessage" class="form-message" role="status"></p></div><div class="detail-block"><h4>Contacts <span class="count">${contacts.length}</span></h4>${listItems(contacts,'No contacts added.','email')}<form id="contactForm" class="compact-form"><input name="name" placeholder="Contact name" aria-label="Contact name"><input name="email" type="email" placeholder="Email" aria-label="Contact email" required><button class="button ghost compact" type="submit">Add contact</button></form><p id="contactMessage" class="form-message" role="status"></p></div><section class="detail-block contact-extraction-panel" id="contactExtractionPanel" data-smoke="contact-extraction"><div class="detail-loading">Loading public contacts…</div></section><section class="detail-block website-scan-panel" id="websiteScanPanel" data-smoke="website-scan"><div class="detail-loading">Loading website scan…</div></section><section class="detail-block domain-intelligence" id="domainIntelligencePanel" data-smoke="domain-intelligence"><div class="detail-loading">Loading domain intelligence…</div></section><section class="detail-block ai-assistance-panel" id="aiAssistancePanel" data-smoke="ai-assistance"><div class="ai-panel-heading"><div><p class="eyebrow">AI ASSISTANCE</p><h4>Evidence-grounded suggestion</h4></div><button class="button primary compact" id="generateAiSuggestionBtn" type="button">Generate suggestion</button></div><p class="ai-safety">AI suggestions summarize workspace evidence and citations only. Review and approve explicitly; No autonomous action or outreach is taken.</p><div id="aiAssistanceState" aria-live="polite"><div class="detail-loading">Loading AI assistance…</div></div></section><div class="detail-block"><h4>Domains & websites</h4>${listItems(domains,'No domains recorded.','domain')}${listItems(websites,'No websites recorded.','url')}</div><div class="detail-block"><h4>Evidence timeline</h4>${listItems(evidence,'No evidence events recorded.','description')}${factors.length?factors.map(x=>`<p class="evidence-line"><span>✓ ${esc(labelFactor(x))}</span><span class="confidence">${esc(p.confidence||'Medium')}</span></p>`).join(''):''}</div><div class="detail-block"><h4>Notes <span class="count">${notes.length}</span></h4>${listItems(notes,'No notes added.','body')}<form id="noteForm" class="compact-form"><textarea name="body" rows="2" placeholder="Add a review note…" required></textarea><button class="button ghost compact" type="submit">Add note</button></form><p id="noteMessage" class="form-message" role="status"></p></div><div class="detail-block"><h4>Review status</h4><p class="review-status">${esc(review)}</p>${st!=='suppressed'?'<button class="button primary compact" id="verifyBtn" type="button">Mark verified</button>':''}<p id="verifyMessage" class="form-message" role="status"></p></div>${blocked?`<button class="button disabled-action" disabled aria-disabled="true">Outreach unavailable</button><p class="disabled-reason">${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}</p>`:''}`;renderScoreBreakdown(p);renderContactExtractionPanel(p);loadContactExtraction(p.id);renderWebsiteScanPanel(p);renderDomainPanel(p);loadAiAssistance(p.id);renderDedupPanel();}
|
||||
let mergeSource = null, mergeTarget = null, mergeBusy = false;
|
||||
const payloadItems = (payload, keys) => { for (const key of keys) if (Array.isArray(payload?.[key])) return payload[key]; return Array.isArray(payload) ? payload : []; };
|
||||
const suggestionId = item => item.target_id ?? item.business_id ?? item.prospect_id ?? item.id;
|
||||
@@ -231,7 +251,7 @@
|
||||
async function logout(){try{await fetch(endpoint('/api/v1/auth/logout'),{method:'POST',credentials:'include'});}finally{showLogin('You have been signed out.');$('loginForm').reset();}}
|
||||
async function bootstrap(){try{const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});if(res.status===401){showLogin();return;}if(!res.ok)throw new Error('Could not verify session.');const user=await res.json();showDashboard(user.user||user);await loadData();await loadJobs();await loadSources();await loadScorePanels();await loadSavedFilters();await loadReviewQueue();await loadCrmPipeline();await loadReports();await loadSuppressions();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}}
|
||||
document.addEventListener('submit',e=>{if(e.target.id==='contactForm')saveContact(e.target);if(e.target.id==='noteForm')saveNote(e.target);if(e.target.id==='pipelineForm')saveStage(e.target);});
|
||||
document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();if(e.target.id==='recalculateScoreBtn')recalculateScore();const scoreEdit=e.target.closest?.('[data-score-edit]');if(scoreEdit)updateScoreRule(scoreEdit.dataset.scoreEdit,scoreEdit.dataset.scorePoints);if(e.target.id==='extractContactsBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='refreshContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='retryContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId);if(e.target.id==='scanWebsiteBtn'&&selectedId)loadWebsiteScan(selectedId,{scan:true});if(e.target.id==='refreshWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='retryWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='runDomainCheckBtn')runDomainCheck();if(e.target.id==='retryDomainBtn'&&selectedId)loadDomainIntelligence(selectedId);const availability=e.target.closest?.('[data-domain-availability]');if(availability)checkDomainAvailability(availability.dataset.domain,availability);if(e.target.id==='retryDetailBtn'&&selectedId)loadDetail(selectedId);if(e.target.id==='retryDedupBtn'&&selectedId)loadMatchSuggestions(selectedId);if(e.target.id==='retryHistoryBtn'&&selectedId)loadMergeHistory(selectedId);if(e.target.id==='cancelMergeBtn'||e.target.id==='cancelMergeBtnSecondary')closeMergeDialog();if(e.target.id==='confirmMergeBtn')confirmMerge();const mergeButton=e.target.closest?.('[data-merge-target]');if(mergeButton)openMergeDialog(mergeButton.dataset.mergeTarget,mergeButton.dataset.mergeTargetName);const reverseButton=e.target.closest?.('[data-reverse-merge]');if(reverseButton)reverseMerge(reverseButton.dataset.reverseMerge);if(e.target.id==='retryJobDetailBtn'&&selectedJobId)loadJobDetail(selectedJobId);if(e.target.id==='cancelJobBtn')jobAction('cancel');if(e.target.id==='retryJobBtn')jobAction('retry');const row=e.target.closest?.('[data-job-id]');if(row)loadJobDetail(row.dataset.jobId);});
|
||||
document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();if(e.target.id==='recalculateScoreBtn')recalculateScore();if(e.target.id==='generateAiSuggestionBtn')generateAiSuggestion();const aiButton=e.target.closest?.('[data-ai-action]');if(aiButton)aiAction(aiButton.dataset.aiAction,aiButton.dataset.aiId);const scoreEdit=e.target.closest?.('[data-score-edit]');if(scoreEdit)updateScoreRule(scoreEdit.dataset.scoreEdit,scoreEdit.dataset.scorePoints);if(e.target.id==='extractContactsBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='refreshContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='retryContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId);if(e.target.id==='scanWebsiteBtn'&&selectedId)loadWebsiteScan(selectedId,{scan:true});if(e.target.id==='refreshWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='retryWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='runDomainCheckBtn')runDomainCheck();if(e.target.id==='retryDomainBtn'&&selectedId)loadDomainIntelligence(selectedId);const availability=e.target.closest?.('[data-domain-availability]');if(availability)checkDomainAvailability(availability.dataset.domain,availability);if(e.target.id==='retryDetailBtn'&&selectedId)loadDetail(selectedId);if(e.target.id==='retryDedupBtn'&&selectedId)loadMatchSuggestions(selectedId);if(e.target.id==='retryHistoryBtn'&&selectedId)loadMergeHistory(selectedId);if(e.target.id==='cancelMergeBtn'||e.target.id==='cancelMergeBtnSecondary')closeMergeDialog();if(e.target.id==='confirmMergeBtn')confirmMerge();const mergeButton=e.target.closest?.('[data-merge-target]');if(mergeButton)openMergeDialog(mergeButton.dataset.mergeTarget,mergeButton.dataset.mergeTargetName);const reverseButton=e.target.closest?.('[data-reverse-merge]');if(reverseButton)reverseMerge(reverseButton.dataset.reverseMerge);if(e.target.id==='retryJobDetailBtn'&&selectedJobId)loadJobDetail(selectedJobId);if(e.target.id==='cancelJobBtn')jobAction('cancel');if(e.target.id==='retryJobBtn')jobAction('retry');const row=e.target.closest?.('[data-job-id]');if(row)loadJobDetail(row.dataset.jobId);});
|
||||
$('loginForm').addEventListener('submit',login);$('logoutBtn').addEventListener('click',logout);$('searchInput').addEventListener('input',()=>{page=1;renderRows();});['scoreFilter','statusFilter','websiteClassFilter','pipelineFilter'].forEach(id=>$(id).addEventListener('change',()=>{page=1;loadData();}));$('pageSize').addEventListener('change',e=>{pageSize=Number(e.target.value);page=1;loadData();});$('nextPageBtn').addEventListener('click',()=>{if(hasNextPage){page+=1;loadData();}});$('refreshBtn').addEventListener('click',loadData);$('jobsRefreshBtn').addEventListener('click',()=>loadJobs());$('startDemoJobBtn').addEventListener('click',startDemoJob);$('sourcesRefreshBtn').addEventListener('click',loadSources);$('sourceForm').addEventListener('submit',saveSource);$('sourceType').addEventListener('change',e=>{$('sourceCsvField').hidden=e.target.value!=='csv';});$('discoveryForm').addEventListener('submit',e=>{e.preventDefault();runDiscovery(true);});$('discoveryRunBtn').addEventListener('click',()=>runDiscovery(false));$('sourcesList').addEventListener('click',e=>{const button=e.target.closest?.('[data-source-action]');if(button)sourceAction(button.dataset.sourceId,button.dataset.sourceAction);});$('addForm').addEventListener('submit',addProspect);$('csvInput').addEventListener('change',e=>{const file=e.target.files[0];if(file){const reader=new FileReader();reader.onload=()=>renderCsv(parseCsv(reader.result));reader.readAsText(file);}});$('menuBtn').addEventListener('click',()=>document.querySelector('.sidebar').classList.toggle('open'));document.querySelectorAll('[data-scroll]').forEach(b=>b.addEventListener('click',()=>document.querySelector(b.dataset.scroll)?.scrollIntoView()));
|
||||
$('savedFilterForm').addEventListener('submit',saveCurrentFilters);$('savedFilterSelect').addEventListener('change',e=>{ $('deleteSavedFilterBtn').disabled=!e.target.value;if(e.target.value)loadSavedFilter(e.target.value);});$('deleteSavedFilterBtn').addEventListener('click',deleteSavedFilter);$('reviewQueueState').addEventListener('change',e=>{const input=e.target.closest?.('[data-review-id]');if(input){if(input.checked)selectedReviewIds.add(String(input.dataset.reviewId));else selectedReviewIds.delete(String(input.dataset.reviewId));updateBulkState();}});$('selectAllReview').addEventListener('change',e=>{reviewQueue.forEach(p=>e.target.checked?selectedReviewIds.add(String(p.id)):selectedReviewIds.delete(String(p.id)));renderReviewQueue({items:reviewQueue,count:$('reviewQueueCount').textContent});});$('bulkVerifyBtn').addEventListener('click',()=>bulkReview('verify'));$('bulkRejectBtn').addEventListener('click',()=>bulkReview('reject'));$('reviewQueueState').addEventListener('click',e=>{if(e.target.id==='retryReviewQueueBtn')loadReviewQueue();});document.querySelectorAll('[data-dashboard-filter]').forEach(card=>card.addEventListener('click',()=>{const kind=card.dataset.dashboardFilter;if(kind==='review'||kind==='suppressed')applyFilters({status:kind});else if(kind==='high')applyFilters({score:'high'});else if(kind==='fresh')applyFilters({});else applyFilters({status:'all',score:'all'});}));
|
||||
$('interactionForm').addEventListener('submit',saveInteraction);$('suppressionForm').addEventListener('submit',addSuppression);$('crmRefreshBtn').addEventListener('click',()=>{loadCrmPipeline();loadInteractions(selectedId);});$('reportsRefreshBtn').addEventListener('click',loadReports);$('suppressionRefreshBtn').addEventListener('click',loadSuppressions);$('pipelineViewToggle').addEventListener('click',()=>{crmListMode=!crmListMode;$('pipelineViewToggle').textContent=crmListMode?'▦ Board view':'☷ List view';$('pipelineViewToggle').setAttribute('aria-pressed',String(crmListMode));renderPipeline();});$('pipelineBoard').addEventListener('click',e=>{const selectButton=e.target.closest?.('[data-crm-select]');if(selectButton){selectedId=Number(selectButton.dataset.crmSelect);selectProspect(selectedId);loadInteractions(selectedId);$('crmActivity').scrollIntoView({behavior:'smooth',block:'start'});}const stageButton=e.target.closest?.('[data-crm-save-stage]');if(stageButton)saveCrmStage(stageButton.dataset.crmSaveStage);if(e.target.id==='retryCrmBtn')loadCrmPipeline();if(e.target.id==='retryInteractionsBtn'&&selectedId)loadInteractions(selectedId);});$('suppressionState').addEventListener('change',e=>{const input=e.target.closest?.('[data-suppression-id]');if(input){if(input.checked)selectedSuppressionIds.add(String(input.dataset.suppressionId));else selectedSuppressionIds.delete(String(input.dataset.suppressionId));updateSuppressionSelection();}});$('suppressionState').addEventListener('click',e=>{const button=e.target.closest?.('[data-remove-suppression]');if(button)removeSuppression(button.dataset.removeSuppression);if(e.target.id==='retrySuppressionsBtn')loadSuppressions();});$('selectAllSuppressions').addEventListener('change',e=>{suppressions.forEach(item=>e.target.checked?selectedSuppressionIds.add(String(item.id)):selectedSuppressionIds.delete(String(item.id)));renderSuppressions();});$('bulkReviewSuppressionsBtn').addEventListener('click',bulkReviewSuppressions);
|
||||
|
||||
@@ -58,5 +58,8 @@ frame.onload=async()=>{const d=frame.contentDocument;const js=await fetch('app.j
|
||||
,['Phase 12 reports panels and API states',()=>!!d.querySelector('[data-smoke="crm-reports"]')&&['pipelineReport','outcomesReport','activityReport'].every(id=>!!d.querySelector('#'+id))&&['/api/v1/reports/pipeline','/api/v1/reports/outcomes','/api/v1/reports/activity'].every(path=>js.includes(path))&&js.includes('No')&&js.includes('Unable to load')]
|
||||
,['Phase 12 suppression center controls and safety',()=>!!d.querySelector('[data-smoke="suppression-center"]')&&!!d.querySelector('#suppressionForm')&&!!d.querySelector('#bulkReviewSuppressionsBtn')&&js.includes('/api/v1/suppressions')&&js.includes('data-remove-suppression')&&js.includes('Suppression always wins')&&js.includes('do-not-contact')]
|
||||
,['Phase 12 CRM responsive styles and authenticated requests',()=>js.includes('credentials:\'include\'')&&js.includes('crm-two-col')&&js.includes('reports-grid')&&js.includes('pipeline-board')&&js.includes('@media')]
|
||||
,['Phase 13 AI assistance panel and safety contract',()=>!!d.querySelector('[data-smoke="ai-assistance"]')&&!!d.querySelector('#generateAiSuggestionBtn')&&js.includes('/api/v1/ai-runs')&&js.includes('/api/v1/businesses/${encodeURIComponent(selectedId)}/ai/suggest')&&js.includes('Evidence-grounded suggestion')&&js.includes('No autonomous action')&&js.includes('citations')]
|
||||
,['Phase 13 AI states, provider, and human decisions',()=>['not-configured','Unknown','Error','Suppressed','Provider:','Pending approval','Approve','Reject','human_approval','no outreach will be sent'].every(x=>js.includes(x))&&js.includes('Loading AI assistance')]
|
||||
,['Phase 13 AI authenticated requests and responsive styles',()=>js.includes('/api/v1/ai-runs')&&js.includes('/ai/suggest')&&js.includes('autonomous_action:false')&&js.includes('ai-assistance-panel')&&js.includes('@media')]
|
||||
];let passed=0;document.querySelector('#checks').innerHTML=checks.map(([name,test])=>{const ok=test();if(ok)passed++;return `<li class="${ok?'pass':'fail'}">${ok?'PASS':'FAIL'} — ${name}</li>`}).join('');document.querySelector('#summary').textContent=`${passed}/${checks.length} checks passed`;};
|
||||
</script>
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
.website-scan-actions .button{min-height:32px}.contact-extraction-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.contact-extraction-heading h4{margin:.1rem 0}.contact-extraction-actions{display:flex;gap:7px;flex-wrap:wrap}.contact-extraction-safety{border:1px solid #dcd8ff;border-radius:8px;background:var(--violet-soft);color:#5145a7;padding:9px 11px;font-size:12px;margin:10px 0}.extracted-contact-list{display:grid;gap:9px}.extracted-contact{border:1px solid var(--line);border-radius:9px;padding:11px;background:#fff}.extracted-contact-head{display:flex;justify-content:space-between;gap:10px;align-items:start}.extracted-contact-head strong{overflow-wrap:anywhere}.contact-confidence{color:var(--violet);font-size:11px;font-weight:700;white-space:nowrap}.extracted-contact-facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px;margin:10px 0 0}.extracted-contact-facts div{border:1px solid var(--line);border-radius:7px;padding:7px}.extracted-contact-facts dt{font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}.extracted-contact-facts dd{margin:3px 0 0;font-size:12px;overflow-wrap:anywhere}.extracted-contact-facts a{color:var(--violet)}.contact-source{grid-column:1 / -1}.suppression-status{color:var(--green)}.contact-extraction-empty{padding:18px 4px}.contact-extraction-empty strong{color:var(--muted)}@media(max-width:700px){.website-scan-heading{flex-direction:column}.website-scan-actions{width:100%}.website-scan-actions .button{flex:1}.website-scan-grid{grid-template-columns:1fr}.signal-list{grid-template-columns:repeat(2,minmax(0,1fr))}.contact-extraction-heading{flex-direction:column}.contact-extraction-actions{width:100%}.contact-extraction-actions .button{flex:1}.extracted-contact-facts{grid-template-columns:repeat(2,minmax(0,1fr))}.contact-source{grid-column:1 / -1}}
|
||||
.metric-link{color:inherit;text-decoration:none}.metric-link:hover{border-color:#c9c3ff;transform:translateY(-1px)}.saved-view-controls{display:grid;grid-template-columns:minmax(220px,1fr) 180px auto;gap:8px;align-items:center;padding:12px 0;border-top:1px solid var(--line)}.saved-view-controls .form-message{grid-column:1 / -1;margin:0}.saved-view-controls input,.saved-view-controls select{border:1px solid var(--line);border-radius:7px;padding:8px;font:inherit;min-width:0}.review-queue{margin:4px 0 14px;padding:14px;background:#fbfbff;border:1px solid var(--line);border-radius:10px}.queue-heading{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.queue-heading h3{margin:.1rem 0}.queue-state{margin-top:8px}.queue-list{display:grid;gap:6px;max-height:300px;overflow:auto}.queue-row{display:flex;align-items:center;gap:10px;padding:8px;border:1px solid var(--line);border-radius:7px;background:#fff;cursor:pointer}.queue-row input{flex:0 0 auto}.queue-row>span:nth-child(2){display:flex;flex-direction:column;min-width:0;flex:1}.queue-row small{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.queue-boundary{font-size:11px;color:var(--muted);margin:8px 0 0}.bulk-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px;padding-top:10px;border-top:1px solid var(--line)}.bulk-actions .checkbox-label{margin:0}.explorer-state{min-height:0;color:var(--green);font-size:12px;padding:4px 0}.explorer-state.error{color:var(--red)}
|
||||
.status.review{background:var(--amber-soft);color:var(--amber)}
|
||||
.ai-assistance-panel{background:#fbfbff;border:1px solid #ddd9ff;border-radius:10px;padding:15px}.ai-panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.ai-panel-heading h4{margin:.1rem 0}.ai-safety{border:1px solid #dcd8ff;border-radius:8px;background:var(--violet-soft);color:#5145a7;padding:9px 11px;font-size:12px;margin:10px 0}.ai-suggestion{border:1px solid var(--line);border-radius:9px;background:#fff;padding:12px}.ai-suggestion-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.ai-suggestion-head>div:first-child{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.ai-status{border-radius:999px;padding:4px 8px;background:var(--amber-soft);color:var(--amber);font-size:11px;font-weight:750}.ai-status.approved{background:var(--green-soft);color:var(--green)}.ai-status.rejected,.ai-status.error,.ai-status.suppressed{background:var(--red-soft);color:var(--red)}.ai-provider{color:var(--muted);font-size:11px}.ai-actions{display:flex;gap:7px;flex-wrap:wrap}.ai-copy{white-space:pre-wrap;margin:12px 0}.ai-citations{border-top:1px solid var(--line);padding-top:9px}.ai-citations h5{margin:0}.ai-citations ol{margin:7px 0 0;padding-left:1.25rem}.ai-citations li{margin:5px 0;font-size:12px}.ai-citations a{color:var(--violet);margin-left:4px}.ai-state{padding:14px 4px;color:var(--muted)}.ai-state strong{color:var(--ink)}.ai-state.error strong{color:var(--red)}.ai-state.suppressed strong{color:var(--red)}
|
||||
@media(max-width:700px){.ai-panel-heading,.ai-suggestion-head{flex-direction:column}.ai-panel-heading .button,.ai-actions{width:100%}.ai-actions .button{flex:1}}
|
||||
.crm-section{margin-top:28px;scroll-margin-top:24px}.crm-header{display:flex;align-items:flex-start;justify-content:space-between;gap:18px}.crm-header h2{margin:.15rem 0 .25rem}.crm-actions{display:flex;gap:8px;flex-wrap:wrap}.crm-safety,.suppression-warning{margin:14px 0;padding:12px 15px;border:1px solid #dcd8ff;border-radius:9px;background:var(--violet-soft);color:#5145a7}.suppression-warning{border-color:#f1d7a5;background:var(--amber-soft);color:#76500d}.crm-message{min-height:22px;color:var(--green);padding:6px 2px}.crm-message.error{color:var(--red)}.pipeline-board{display:grid;grid-template-columns:repeat(4,minmax(180px,1fr));gap:12px;overflow-x:auto;align-items:start}.pipeline-column{background:#f1f2f8;border:1px solid var(--line);border-radius:10px;padding:10px;min-height:180px}.pipeline-column-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}.pipeline-column-head h3{margin:0;font-size:13px}.pipeline-card{background:#fff;border:1px solid var(--line);border-radius:9px;padding:10px;margin:8px 0;box-shadow:var(--shadow)}.pipeline-card.is-suppressed{border-color:#e9b8bd;background:#fffafa}.pipeline-card-link{display:grid;gap:3px;width:100%;border:0;background:none;text-align:left;color:inherit;padding:0;cursor:pointer}.pipeline-card-link small,.pipeline-card-link .score{font-size:11px;color:var(--muted)}.pipeline-card-actions{display:flex;gap:6px;margin-top:9px}.pipeline-card-actions select{min-width:0;flex:1;border:1px solid var(--line);border-radius:6px;padding:6px;font:inherit;font-size:12px}.pipeline-list-view{display:block}.pipeline-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px}.crm-column-empty,.crm-empty{color:var(--muted);font-size:12px;text-align:center;padding:20px 8px}.suppression-inline,.review-inline{font-size:11px;color:var(--red);margin:8px 0 0}.review-inline{color:var(--amber)}.crm-two-col{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(300px,.9fr);gap:18px}.crm-form{display:grid;gap:10px}.crm-form label{display:grid;gap:5px;font-size:12px;font-weight:650}.crm-form input,.crm-form select,.crm-form textarea{border:1px solid var(--line);border-radius:7px;padding:9px;font:inherit;font-weight:400}.crm-form textarea{resize:vertical}.crm-timeline{list-style:none;padding:0;margin:12px 0}.crm-timeline li{display:flex;gap:10px;border-top:1px solid var(--line);padding:12px 0}.crm-timeline li>div{display:grid;gap:4px;min-width:0}.crm-timeline p{margin:0;color:var(--muted)}.crm-timeline small{color:var(--muted);font-size:11px}.timeline-dot{width:9px;height:9px;flex:0 0 9px;margin-top:6px;border-radius:50%;background:var(--violet);box-shadow:0 0 0 4px var(--violet-soft)}.outcome-chip{font-size:11px;color:var(--violet);background:var(--violet-soft);border-radius:999px;padding:2px 7px;width:max-content}.reports-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.report-rows{display:grid;gap:8px}.report-rows div{display:flex;justify-content:space-between;gap:10px;border-top:1px solid var(--line);padding:9px 0}.report-rows strong{color:var(--violet)}.report-note{font-size:11px;color:var(--muted)}.suppression-bulk-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.suppression-bulk-actions .checkbox-label{margin:0}.suppression-list{display:grid}.suppression-row{display:flex;justify-content:space-between;gap:10px;align-items:center;border-top:1px solid var(--line);padding:11px 0}.suppression-row .checkbox-label{margin:0;flex:1}.suppression-row .checkbox-label span{display:grid;gap:2px;min-width:0}.suppression-row small{color:var(--muted);overflow-wrap:anywhere}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:1000px){.pipeline-board{grid-template-columns:repeat(4,minmax(220px,1fr))}.reports-grid{grid-template-columns:1fr 1fr}}@media(max-width:700px){.crm-header{flex-direction:column}.crm-actions,.crm-actions .button{width:100%}.crm-actions .button{flex:1}.crm-two-col,.reports-grid{grid-template-columns:1fr}.pipeline-board{grid-template-columns:repeat(4,minmax(235px,1fr))}.pipeline-list{grid-template-columns:1fr}.suppression-row{align-items:flex-start}.suppression-bulk-actions{width:100%}}
|
||||
@media(max-width:700px){.saved-view-controls{grid-template-columns:1fr}.saved-view-controls .inline-form{display:flex}.saved-view-controls .inline-form input{flex:1}.queue-row{align-items:flex-start}.bulk-actions .button{flex:1}.metric-link{min-width:0}}
|
||||
.score-overview{margin-top:18px}.score-distribution-panel{min-width:0}.score-rules-section{margin-top:18px;scroll-margin-top:24px}.score-breakdown-panel{background:#fbfbff;border-radius:10px;padding:15px}.score-panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.score-panel-heading h4{margin:.1rem 0}.score-breakdown-summary{display:grid;grid-template-columns:1.4fr 1fr 1fr;gap:8px;margin:12px 0}.score-breakdown-summary>div,.score-meta div{border:1px solid var(--line);border-radius:8px;background:#fff;padding:10px}.score-breakdown-summary small,.score-breakdown-summary b{display:block}.score-breakdown-summary small,.score-meta dt{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.05em}.score-breakdown-summary b{margin-top:4px}.score-total{display:block;font-size:28px;line-height:1.1;margin-top:3px}.score-total small{display:inline;font-size:12px;margin-left:3px}.score-meta{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:0}.score-meta dd{margin:3px 0 0}.score-rules-explanation{margin-top:13px}.score-rules-explanation h5{margin:0 0 7px}.score-rule-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 8px;border-top:1px solid var(--line);padding:9px 0}.score-rule-row span{font-weight:650}.score-rule-row strong{color:var(--violet);font-size:12px}.score-rule-row small{grid-column:1 / -1;color:var(--muted)}.score-safety,.score-config-note{border:1px solid #dcd8ff;border-radius:8px;background:var(--violet-soft);color:#5145a7;padding:9px 11px;font-size:12px;margin:12px 0 0}.score-rule-table{border-top:1px solid var(--line);margin-top:12px}.score-config-row{display:grid;grid-template-columns:minmax(140px,1.4fr) .8fr .7fr .7fr auto;gap:10px;align-items:center;padding:11px 0;border-bottom:1px solid var(--line)}.score-config-row span{font-size:12px}.score-config-row span:nth-child(2){color:var(--green);font-weight:700}.score-config-row small{color:var(--muted)}.score-config-row button:disabled{opacity:.7;cursor:not-allowed}.score-distribution-list{display:grid;gap:10px;margin-top:12px}.distribution-row{display:grid;grid-template-columns:minmax(100px,1fr) 42px minmax(100px,2fr);gap:10px;align-items:center}.distribution-row strong{text-align:right}.distribution-track{height:8px;border-radius:99px;background:var(--line);overflow:hidden}.distribution-track i{display:block;height:100%;background:var(--violet);border-radius:inherit}@media(max-width:700px){.score-breakdown-summary{grid-template-columns:1fr 1fr}.score-breakdown-summary>div:first-child{grid-column:1 / -1}.score-config-row{grid-template-columns:1fr 1fr}.score-config-row strong{grid-column:1 / -1}.score-config-row button{justify-self:start}.distribution-row{grid-template-columns:minmax(95px,1fr) 34px minmax(80px,1fr)}}
|
||||
|
||||
Reference in New Issue
Block a user