b5619d61e1
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>
110 lines
5.5 KiB
Python
110 lines
5.5 KiB
Python
"""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", {})}
|