outreach: Outreach Draft Assistant — tailored LP drafts (v0.1.0:68)
First proactive-messaging build. New "Outreach" page (all authenticated users): pick an investor + type (intro / follow-up / fund update / meeting follow-up / nurture) + optional guidance; the agent drafts a tailored LP email in Ten31's voice, grounded in the thesis + that investor's CRM notes and matched email history. The draft is editable + copyable; nothing is sent (draft-only — guardrails #4, #6). Sovereignty: the thesis is Ten31's own non-sensitive messaging (to Claude as-is); the LP context is scrubbed through the redaction boundary before Claude, drafted with placeholders, and re-hydrated locally — the LP list never reaches the API. Fails closed (scrub_unavailable / claude_not_configured / rehydrate_failed quarantines a hallucinated-token draft). Backend: mcp/outreach_agent.py (context assembly + scrub + Claude + rehydrate, reusing architect_agent's client/thesis/voice + the Boundary); routes GET /api/outreach/investors, POST /api/outreach/draft; logged. Test mcp/test_outreach.py (context assembly). Verified in preview: page/selector/types/guidance render, fail-closed at the key-less Claude step (scrub ran locally first), success rendering verified with a mocked ok draft. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9948,6 +9948,132 @@
|
||||
);
|
||||
};
|
||||
|
||||
const OutreachPage = ({ token, user, onShowToast }) => {
|
||||
const [investors, setInvestors] = useState([]);
|
||||
const [investorId, setInvestorId] = useState('');
|
||||
const [type, setType] = useState('follow_up');
|
||||
const [guidance, setGuidance] = useState('');
|
||||
const [drafting, setDrafting] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [draftText, setDraftText] = useState('');
|
||||
const TYPES = [
|
||||
['intro', 'Intro'],
|
||||
['follow_up', 'Warm follow-up'],
|
||||
['fund_update', 'Fund update'],
|
||||
['meeting_follow_up', 'Meeting follow-up'],
|
||||
['nurture', 'Nurture / stay in touch'],
|
||||
];
|
||||
const FAIL = {
|
||||
not_found: 'That investor was not found.',
|
||||
scrub_unavailable: 'The redaction boundary could not be prepared, so nothing was sent to Claude.',
|
||||
claude_not_configured: 'The Architect (Claude) is not configured on the server.',
|
||||
rehydrate_failed: 'The draft could not be safely personalized (an unexpected placeholder). Try again.',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await api('/api/outreach/investors', {}, token);
|
||||
if (!cancelled) setInvestors(Array.isArray(r?.investors) ? r.investors : []);
|
||||
} catch (_) { /* none */ }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [token]);
|
||||
|
||||
const draft = async () => {
|
||||
if (drafting) return;
|
||||
if (!investorId) { onShowToast('Pick an investor first', 'error'); return; }
|
||||
try {
|
||||
setDrafting(true);
|
||||
setResult(null);
|
||||
const res = await api('/api/outreach/draft', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ investor_id: investorId, outreach_type: type, guidance }),
|
||||
}, token);
|
||||
const data = res.data || res;
|
||||
setResult(data);
|
||||
if (data.status === 'ok') setDraftText(data.draft || '');
|
||||
} catch (err) {
|
||||
const msg = getErrorMessage(err, 'Drafting failed');
|
||||
setResult({ status: 'error', reason: msg });
|
||||
onShowToast(msg, 'error');
|
||||
} finally {
|
||||
setDrafting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
try { await navigator.clipboard.writeText(draftText); onShowToast('Draft copied', 'success'); }
|
||||
catch (_) { onShowToast('Could not copy', 'error'); }
|
||||
};
|
||||
|
||||
const ok = result && result.status === 'ok';
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<h2 className="section-title" style={{ marginBottom: '20px' }}>Outreach</h2>
|
||||
<div className="section">
|
||||
<div className="index-action-hint" style={{ marginTop: 0, marginBottom: '12px' }}>
|
||||
Drafts a tailored LP email in Ten31's voice, grounded in the thesis and that investor's CRM notes + email history. The investor's details are de-identified before Claude sees them and restored locally, so the LP list never leaves Ten31. Drafts only — you review, edit, and send.
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '12px', maxWidth: '420px' }}>
|
||||
<label className="form-label">Investor</label>
|
||||
<select className="select-input" value={investorId} onChange={(e) => setInvestorId(e.target.value)}>
|
||||
<option value="">Select an investor…</option>
|
||||
{investors.map((iv) => <option key={iv.id} value={iv.id}>{iv.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '12px', maxWidth: '420px' }}>
|
||||
<label className="form-label">Type</label>
|
||||
<select className="select-input" value={type} onChange={(e) => setType(e.target.value)}>
|
||||
{TYPES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '12px' }}>
|
||||
<label className="form-label">Guidance (optional)</label>
|
||||
<textarea className="text-input" style={{ width: '100%', minHeight: '54px' }}
|
||||
placeholder="e.g. mention the new Giga deal; they asked about lock-up terms"
|
||||
value={guidance} onChange={(e) => setGuidance(e.target.value)} />
|
||||
</div>
|
||||
<div className="index-action-buttons">
|
||||
<button onClick={draft} disabled={drafting || !investorId}>
|
||||
{drafting ? 'Drafting… (this can take a moment)' : 'Draft outreach'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{drafting && <div className="section"><SkeletonBlock lines={6} /></div>}
|
||||
|
||||
{result && !drafting && (
|
||||
<div className="section">
|
||||
{ok ? (
|
||||
<>
|
||||
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
Draft for {result.investor_name}
|
||||
{result.scrub_stats && result.scrub_stats.tokens != null
|
||||
? <span className="approval-pill">{result.scrub_stats.tokens} identifiers protected</span> : null}
|
||||
</div>
|
||||
<textarea className="text-input" style={{ width: '100%', minHeight: '260px', lineHeight: 1.5 }}
|
||||
value={draftText} onChange={(e) => setDraftText(e.target.value)} />
|
||||
<div className="index-action-buttons" style={{ marginTop: '10px' }}>
|
||||
<button onClick={copy}>Copy draft</button>
|
||||
</div>
|
||||
<div className="index-action-hint" style={{ marginTop: '10px' }}>
|
||||
Review and edit before sending. Nothing is sent automatically.
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="toast error" style={{ position: 'static' }}>
|
||||
{FAIL[result.status] || result.reason || 'Drafting did not complete.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EmailCapturePage = ({ token, user, onShowToast }) => {
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const [status, setStatus] = useState(null);
|
||||
@@ -10751,6 +10877,9 @@
|
||||
<button className={`nav-item ${page === 'thesis-workshop' ? 'active' : ''}`} onClick={() => setPage('thesis-workshop')}>
|
||||
<span className="nav-item-icon">◆</span> Thesis Workshop
|
||||
</button>
|
||||
<button className={`nav-item ${page === 'outreach' ? 'active' : ''}`} onClick={() => setPage('outreach')}>
|
||||
<span className="nav-item-icon">✎</span> Outreach
|
||||
</button>
|
||||
<button className={`nav-item ${page === 'system-status' ? 'active' : ''}`} onClick={() => setPage('system-status')}>
|
||||
<span className="nav-item-icon">◉</span> System Status
|
||||
</button>
|
||||
@@ -10786,6 +10915,7 @@
|
||||
{page === 'communications' && 'Communications'}
|
||||
{page === 'thesis' && 'Thesis'}
|
||||
{page === 'thesis-workshop' && 'Thesis Workshop'}
|
||||
{page === 'outreach' && 'Outreach'}
|
||||
{page === 'system-status' && 'System Status'}
|
||||
{page === 'email-capture' && 'Email Capture'}
|
||||
{page === 'feature-requests' && 'Feature Requests'}
|
||||
@@ -10818,6 +10948,7 @@
|
||||
{page === 'communications' && <CommunicationsPage token={token} onShowToast={showToast} />}
|
||||
{page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'thesis-workshop' && <ThesisWorkshopPage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'outreach' && <OutreachPage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'system-status' && <SystemStatusPage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'email-capture' && <EmailCapturePage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'feature-requests' && <FeatureRequestsPage token={token} onShowToast={showToast} user={user} />}
|
||||
|
||||
Reference in New Issue
Block a user