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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user