Files
ten31-database/backend/mcp/outreach_agent.py
T
Keysat 787d580550 outreach: follow-up radar — deterministic "needs attention" + one-click draft (v0.1.0:69)
The Outreach page now opens with a "Needs attention" list. A deterministic scan
(outreach_agent.follow_up_radar) surfaces investors per the email history: tier 0 "you
owe a reply" (their email is the most recent, unanswered, >=3d), tier 1 flagged + quiet,
tier 2 warm lead gone quiet (no contact in >=45d). Most urgent first; every reason is
verifiable from the data (no LLM in the surfacing — the deliberate fix for the trust
problem that sank objection-grounding). Excludes graveyard; needs email history. One
click sets the investor + suggested type (follow-up/nurture) and runs the existing
outreach drafter. Route GET /api/outreach/radar. Test mcp/test_outreach.py extended
(owe-reply/warm-quiet/recent/graveyard/order). Verified live in preview.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:31:52 -05:00

176 lines
8.3 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 _days_between(then_iso, now_iso):
from datetime import datetime
try:
a = datetime.strptime(str(then_iso)[:10], "%Y-%m-%d")
b = datetime.strptime(str(now_iso)[:10], "%Y-%m-%d")
return (b - a).days
except Exception:
return None
def follow_up_radar(conn, our_addresses, now_iso, warm_days=45, limit=60):
"""Deterministic scan: surface investors who need attention, each with a concrete,
checkable reason (no LLM guesswork in the *surfacing*). Tiers, most urgent first:
0 you owe a reply (their email is the most recent, unanswered)
1 flagged for follow-up and quiet
2 warm lead gone quiet (no contact in >= warm_days)
"""
own = {(a or "").lower() for a in (our_addresses or [])}
try:
rows = conn.execute("SELECT * FROM fundraising_investors").fetchall()
except Exception:
return []
items = []
for r in rows:
d = dict(r)
inv_id, name = d.get("id"), d.get("investor_name")
if not inv_id:
continue
gv = d.get("graveyard")
if gv and str(gv).strip().lower() not in ("", "0", "false", "no"):
continue # buried leads are out of scope
try:
erows = conn.execute(
"SELECT e.from_email, 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 50", (inv_id,)).fetchall()
except Exception:
erows = []
if not erows:
continue # no email history -> nothing to base a nudge on
last = erows[0]
days = _days_between(last["sent_at"], now_iso)
if days is None:
continue
inbound_last = (last["from_email"] or "").lower() not in own # they emailed last
ff = d.get("follow_up")
flagged = bool(ff) and str(ff).strip().lower() not in ("", "0", "false", "no")
reason, tier, suggested = None, None, "follow_up"
if inbound_last and days >= 3:
reason, tier, suggested = f"You owe a reply — they emailed {days} days ago", 0, "follow_up"
elif flagged and days >= 14:
reason, tier, suggested = f"Flagged for follow-up, quiet {days} days", 1, "follow_up"
elif days >= warm_days and len(erows) >= 2:
reason, tier, suggested = f"No contact in {days} days", 2, "nurture"
if reason is None:
continue
if flagged and tier != 1:
reason += " · flagged"
items.append({"investor_id": inv_id, "name": name, "reason": reason,
"days_since": days, "suggested_type": suggested, "tier": tier})
items.sort(key=lambda x: (x["tier"], -x["days_since"]))
return items[:limit]
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", {})}