"""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", {})}