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