diff --git a/backend/mcp/outreach_agent.py b/backend/mcp/outreach_agent.py new file mode 100644 index 0000000..fb44d01 --- /dev/null +++ b/backend/mcp/outreach_agent.py @@ -0,0 +1,109 @@ +"""Outreach drafting agent — tailored LP outreach in Ten31's voice, grounded in the +thesis + the LP's DE-IDENTIFIED context, through the redaction boundary. + +Draft-only: a human reviews, edits, and sends (guardrails #4 and #6 — no auto-send, +no cold/outbound automation until counsel defines the solicitation posture). Sovereignty: +the thesis is Ten31's own non-sensitive messaging and goes to Claude as-is; the LP's +context (CRM notes + email history) is scrubbed first, so the LP list never reaches the +API in the clear, and the draft is re-hydrated locally for the human. +""" +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) + +# outreach_type -> human description woven into the prompt +OUTREACH_TYPES = { + "intro": "a first introduction to Ten31 and the fund", + "follow_up": "a warm follow-up that moves the conversation forward", + "fund_update": "a fund update / progress note", + "meeting_follow_up": "a follow-up after a recent meeting or call", + "nurture": "a light-touch note to stay in contact", +} + + +def _context(conn, investor_id): + """Assemble the recipient's context: CRM notes + recent matched email with them. + Returns (investor_name, context_text) or (None, None).""" + row = conn.execute("SELECT investor_name, notes FROM fundraising_investors WHERE id=?", + (investor_id,)).fetchone() + if not row: + return None, None + name = row["investor_name"] + parts = [f"Investor: {name}"] + notes = (row["notes"] or "").strip() + if notes: + parts.append("CRM notes:\n" + notes) + try: + rows = conn.execute( + "SELECT e.subject, e.body_text, e.snippet, e.sent_at FROM emails e " + "JOIN email_investor_links l ON l.email_id = e.id " + "WHERE l.fundraising_investor_id = ? AND e.is_matched = 1 " + "ORDER BY e.sent_at DESC LIMIT 6", (investor_id,)).fetchall() + for em in rows: + body = (em["body_text"] or em["snippet"] or "")[:1500].strip() + if body or em["subject"]: + parts.append(f"Email ({(em['sent_at'] or '')[:10]}) — {em['subject'] or '(no subject)'}\n{body}") + except Exception: + pass # email tables may be absent / not yet captured + return name, "\n\n---\n\n".join(parts) + + +def _draft_with_claude(aa, thesis, type_desc, deident_context, guidance): + system = ( + "You are Ten31's outreach copilot. Draft ONE ready-to-send LP outreach email in Ten31's voice. " + f"VOICE RULES (follow exactly): {aa.VOICE}\n\n" + "Ten31 invests in critical infrastructure across bitcoin, AI, energy, and freedom technologies, " + "with scarcity as the connecting idea. Current working thesis:\n" + aa._render_thesis(thesis) + "\n\n" + "The recipient's context below is DE-IDENTIFIED: people, firms, and amounts appear as placeholders " + "like [PERSON_1], [ORG_1], [AMOUNT_1]. Keep every placeholder EXACTLY as written and NEVER invent new " + "ones — they are swapped back to real values after you reply. Output a subject line, then the email body. " + "Ground it in the actual context; do NOT fabricate facts, numbers, returns, or commitments that are not " + "present in the context or the thesis.") + user = (f"Outreach type: {type_desc}\n\n" + f"Recipient context (de-identified):\n{deident_context}\n\n" + + (f"Additional guidance from the sender: {guidance}\n\n" if (guidance or "").strip() else "") + + "Draft the email now.") + resp = aa._client().messages.create( + model=aa.MODEL, max_tokens=1200, + system=[{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}], + messages=[{"role": "user", "content": user}]) + return "".join(b.text for b in resp.content if getattr(b, "type", None) == "text") + + +def draft_outreach(conn, investor_id, outreach_type, guidance, db_path): + """Draft tailored outreach for one investor. FAILS CLOSED: if the scrub can't be + prepared or Claude hallucinates a placeholder, no de-anonymized draft is returned.""" + name, context = _context(conn, investor_id) + if not name: + return {"status": "not_found"} + type_desc = OUTREACH_TYPES.get(outreach_type, OUTREACH_TYPES["follow_up"]) + + # 1) Scrub the LP context — the LP list / identifiers never reach Claude in the clear. + try: + sys.path.insert(0, os.path.dirname(_HERE)) # backend/ for the redaction package + from redaction.client import Boundary + boundary = Boundary(db_path=db_path, actor="closer") + scrubbed = boundary.scrub([context], bucket=False, conn=conn) + except Exception as exc: + return {"status": "scrub_unavailable", "reason": str(exc)} + deident = scrubbed["items"][0] + handle = scrubbed["handle"] + + # 2) Claude drafts over the de-identified context + (non-sensitive) thesis. + try: + sys.path.insert(0, _HERE) + import architect_agent as aa + thesis = aa.at.get_thesis("core", db=db_path) + raw = _draft_with_claude(aa, thesis, type_desc, deident, guidance) + except Exception as exc: + boundary.forget(handle) + return {"status": "claude_not_configured", "reason": str(exc)} + + # 3) Re-hydrate locally (strict: a hallucinated placeholder quarantines the draft). + rehy = boundary.rehydrate(raw, handle, strict=True, conn=conn) + boundary.forget(handle) + if rehy.get("error"): + return {"status": "rehydrate_failed"} + return {"status": "ok", "draft": rehy["text"], "investor_name": name, + "scrub_stats": scrubbed.get("stats", {})} diff --git a/backend/mcp/test_outreach.py b/backend/mcp/test_outreach.py new file mode 100644 index 0000000..4c510f3 --- /dev/null +++ b/backend/mcp/test_outreach.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Test the outreach agent's context assembly: it pulls the investor's CRM notes + +recent matched email into the de-identifiable context block. Synthetic data only +(guardrail #9). The scrub/Claude/rehydrate round-trip is exercised live in the preview. +Run: cd backend && python3 mcp/test_outreach.py +""" +import os +import sqlite3 +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import outreach_agent as oa # noqa: E402 + +FAILS = [] + + +def check(cond, msg): + print((" PASS " if cond else " FAIL ") + msg) + if not cond: + FAILS.append(msg) + + +def main(): + db = os.path.join(tempfile.mkdtemp(), "t.db") + c = sqlite3.connect(db) + c.row_factory = sqlite3.Row + c.executescript(""" + CREATE TABLE fundraising_investors (id TEXT PRIMARY KEY, investor_name TEXT, notes TEXT); + CREATE TABLE emails (id TEXT PRIMARY KEY, subject TEXT, body_text TEXT, snippet TEXT, sent_at TEXT, is_matched INT); + CREATE TABLE email_investor_links (id TEXT, email_id TEXT, fundraising_investor_id TEXT); + """) + c.execute("INSERT INTO fundraising_investors VALUES ('inv1','Harbor & Vine','Met at the conference; interested in Fund III.')") + c.executemany("INSERT INTO emails (id,subject,body_text,sent_at,is_matched) VALUES (?,?,?,?,1)", [ + ("e1", "Re: Fund III", "Thanks for the call. We are still weighing the lock-up terms.", "2026-06-02T10:00:00"), + ("e2", "Intro", "Good to meet you at the dinner.", "2026-05-01T10:00:00"), + ("e3", "Spam", "ignore me", "2026-04-01T10:00:00"), # not linked -> excluded + ]) + c.executemany("INSERT INTO email_investor_links (id,email_id,fundraising_investor_id) VALUES (?,?, 'inv1')", + [("l1", "e1"), ("l2", "e2")]) + c.commit() + + name, ctx = oa._context(c, "inv1") + check(name == "Harbor & Vine", f"resolves investor name (got {name!r})") + check("Met at the conference" in ctx, "includes CRM notes") + check("lock-up terms" in ctx, "includes matched email body") + check("Good to meet you" in ctx, "includes a second matched email") + check("ignore me" not in ctx, "excludes email not linked to this investor") + check(ctx.index("lock-up terms") < ctx.index("Good to meet you"), "newest email first") + + n2, c2 = oa._context(c, "missing") + check(n2 is None and c2 is None, "unknown investor -> (None, None)") + + # type catalogue is intact + check(set(["intro", "follow_up", "fund_update", "meeting_follow_up", "nurture"]) <= set(oa.OUTREACH_TYPES), + "outreach types catalogue present") + + if FAILS: + print(f"\nFAILED ({len(FAILS)})") + for f in FAILS: + print(" - " + f) + sys.exit(1) + print("\nALL PASS (outreach context assembly)") + + +if __name__ == "__main__": + main() diff --git a/backend/server.py b/backend/server.py index 1eb72c7..2ad5faa 100644 --- a/backend/server.py +++ b/backend/server.py @@ -60,10 +60,12 @@ try: import architect_tools as _architect_tools # type: ignore import architect_agent as _architect_agent # type: ignore import architect_grounding as _architect_grounding # type: ignore + import outreach_agent as _outreach_agent # type: ignore except Exception: _architect_tools = None _architect_agent = None _architect_grounding = None + _outreach_agent = None # ─── Configuration ──────────────────────────────────────────────────────────── @@ -1804,6 +1806,8 @@ class CRMHandler(BaseHTTPRequestHandler): return self.handle_system_status(user) if path == '/api/activity/proposals': return self.handle_list_activity_proposals(user) + if path == '/api/outreach/investors': + return self.handle_list_outreach_investors(user) # Users if path == '/api/users': @@ -1907,6 +1911,8 @@ class CRMHandler(BaseHTTPRequestHandler): return self.handle_node_feedback(user, path.split('/')[-2], body) if path == '/api/architect/ground': return self.handle_architect_ground(user, body) + if path == '/api/outreach/draft': + return self.handle_outreach_draft(user, body) if re.match(r'^/api/activity/proposals/[^/]+/approve$', path): return self.handle_decide_activity_proposal(user, path.split('/')[-2], 'approve', body) if re.match(r'^/api/activity/proposals/[^/]+/dismiss$', path): @@ -3905,6 +3911,44 @@ class CRMHandler(BaseHTTPRequestHandler): conn.close() return self.send_json({"data": res}) + def handle_list_outreach_investors(self, user): + conn = get_db() + try: + rows = conn.execute("SELECT id, investor_name FROM fundraising_investors " + "ORDER BY investor_name LIMIT 2000").fetchall() + return self.send_json({"investors": [{"id": r["id"], "name": r["investor_name"]} for r in rows]}) + finally: + conn.close() + + def handle_outreach_draft(self, user, body): + """Draft tailored LP outreach through the redaction boundary (draft-only — + a human reviews/edits/sends; guardrails #4, #6).""" + if _outreach_agent is None: + return self.send_error_json("Outreach agent unavailable", 503) + body = body or {} + inv = body.get('investor_id') + if not inv: + return self.send_error_json("investor_id required", 400) + conn = get_db() + try: + res = _outreach_agent.draft_outreach(conn, inv, body.get('outreach_type', 'follow_up'), + body.get('guidance', '') or '', DB_PATH) + try: + conn.execute( + "INSERT INTO interaction_log (id, ts, actor_type, actor_id, action, target_type, target_id, payload, source, created_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + (generate_id(), now(), "human", user.get('user_id'), "outreach.drafted", + "fundraising_investor", inv, + json.dumps({"type": body.get('outreach_type'), "status": res.get('status')}), "crm_ui", now())) + conn.commit() + except Exception: + pass + except Exception as exc: + return self.send_error_json(str(exc), 502) + finally: + conn.close() + return self.send_json({"data": res}) + # ─── Architect thesis (Phase 1) ─── def handle_list_thesis_lines(self, user): if thesis_review is None: diff --git a/frontend/index.html b/frontend/index.html index c4e41b9..d440d76 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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 ( +