diff --git a/backend/mcp/outreach_agent.py b/backend/mcp/outreach_agent.py index fb44d01..ad7862a 100644 --- a/backend/mcp/outreach_agent.py +++ b/backend/mcp/outreach_agent.py @@ -22,6 +22,72 @@ OUTREACH_TYPES = { } +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).""" diff --git a/backend/mcp/test_outreach.py b/backend/mcp/test_outreach.py index 4c510f3..efdaa38 100644 --- a/backend/mcp/test_outreach.py +++ b/backend/mcp/test_outreach.py @@ -55,6 +55,43 @@ def main(): check(set(["intro", "follow_up", "fund_update", "meeting_follow_up", "nurture"]) <= set(oa.OUTREACH_TYPES), "outreach types catalogue present") + # ── follow-up radar ── + rc = sqlite3.connect(os.path.join(tempfile.mkdtemp(), "radar.db")) + rc.row_factory = sqlite3.Row + rc.executescript(""" + CREATE TABLE fundraising_investors (id TEXT PRIMARY KEY, investor_name TEXT, follow_up TEXT, graveyard TEXT); + CREATE TABLE emails (id TEXT PRIMARY KEY, from_email TEXT, sent_at TEXT, is_matched INT); + CREATE TABLE email_investor_links (id TEXT, email_id TEXT, fundraising_investor_id TEXT); + """) + rc.executemany("INSERT INTO fundraising_investors (id,investor_name,follow_up,graveyard) VALUES (?,?,?,?)", [ + ("owe", "Owe Reply LP", None, None), # they emailed last, 5d ago -> tier 0 + ("warm", "Warm Quiet LP", None, None), # we emailed last, 60d ago -> tier 2 + ("fresh", "Fresh LP", None, None), # we emailed 4d ago -> not surfaced + ("buried", "Buried LP", None, "1"), # graveyard -> excluded + ]) + OURS = "grant@ten31.xyz" + em = [ + ("o1", "lp@owe.example", "2026-06-04T10:00:00", "owe"), # inbound, 5 days before 06-09 + ("w1", OURS, "2026-04-10T10:00:00", "warm"), # outbound, ~60 days + ("w0", "lp@warm.example", "2026-04-01T10:00:00", "warm"), # 2nd email for history + ("f1", OURS, "2026-06-05T10:00:00", "fresh"), # outbound, 4 days -> too recent + ("b1", "lp@buried.example", "2026-01-01T10:00:00", "buried"), + ] + for eid, frm, sent, inv in em: + rc.execute("INSERT INTO emails (id,from_email,sent_at,is_matched) VALUES (?,?,?,1)", (eid, frm, sent)) + rc.execute("INSERT INTO email_investor_links (id,email_id,fundraising_investor_id) VALUES (?,?,?)", (eid + "l", eid, inv)) + rc.commit() + radar = oa.follow_up_radar(rc, [OURS], "2026-06-09T00:00:00", warm_days=45) + names = [x["name"] for x in radar] + check("Owe Reply LP" in names and "Warm Quiet LP" in names, f"surfaces owe-reply + warm-quiet (got {names})") + check("Fresh LP" not in names, "recent contact not surfaced") + check("Buried LP" not in names, "graveyard excluded") + check(radar[0]["name"] == "Owe Reply LP" and radar[0]["tier"] == 0, "owe-a-reply ranked first (tier 0)") + owe = next(x for x in radar if x["name"] == "Owe Reply LP") + check("owe a reply" in owe["reason"] and owe["suggested_type"] == "follow_up", "owe-reply reason + suggested type") + warm = next(x for x in radar if x["name"] == "Warm Quiet LP") + check(warm["tier"] == 2 and warm["suggested_type"] == "nurture", "warm-quiet is tier 2, suggests nurture") + if FAILS: print(f"\nFAILED ({len(FAILS)})") for f in FAILS: diff --git a/backend/server.py b/backend/server.py index 2ad5faa..7a1781c 100644 --- a/backend/server.py +++ b/backend/server.py @@ -1808,6 +1808,8 @@ class CRMHandler(BaseHTTPRequestHandler): return self.handle_list_activity_proposals(user) if path == '/api/outreach/investors': return self.handle_list_outreach_investors(user) + if path == '/api/outreach/radar': + return self.handle_outreach_radar(user) # Users if path == '/api/users': @@ -3920,6 +3922,21 @@ class CRMHandler(BaseHTTPRequestHandler): finally: conn.close() + def handle_outreach_radar(self, user): + """Deterministic 'who needs attention' scan (reasons are checkable, not LLM guesses).""" + if _outreach_agent is None: + return self.send_error_json("Outreach agent unavailable", 503) + conn = get_db() + try: + try: + own = [r[0] for r in conn.execute("SELECT email_address FROM email_accounts")] + except Exception: + own = [] + items = _outreach_agent.follow_up_radar(conn, own, now()) + return self.send_json({"items": items}) + 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).""" diff --git a/frontend/index.html b/frontend/index.html index d440d76..a3dc4fb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9956,6 +9956,7 @@ const [drafting, setDrafting] = useState(false); const [result, setResult] = useState(null); const [draftText, setDraftText] = useState(''); + const [radar, setRadar] = useState([]); const TYPES = [ ['intro', 'Intro'], ['follow_up', 'Warm follow-up'], @@ -9977,19 +9978,25 @@ const r = await api('/api/outreach/investors', {}, token); if (!cancelled) setInvestors(Array.isArray(r?.investors) ? r.investors : []); } catch (_) { /* none */ } + try { + const rr = await api('/api/outreach/radar', {}, token); + if (!cancelled) setRadar(Array.isArray(rr?.items) ? rr.items : []); + } catch (_) { /* none */ } })(); return () => { cancelled = true; }; }, [token]); - const draft = async () => { + const draft = async (ovInvestor, ovType) => { if (drafting) return; - if (!investorId) { onShowToast('Pick an investor first', 'error'); return; } + const inv = ovInvestor || investorId; + const t = ovType || type; + if (!inv) { onShowToast('Pick an investor first', 'error'); return; } try { setDrafting(true); setResult(null); const res = await api('/api/outreach/draft', { method: 'POST', - body: JSON.stringify({ investor_id: investorId, outreach_type: type, guidance }), + body: JSON.stringify({ investor_id: inv, outreach_type: t, guidance }), }, token); const data = res.data || res; setResult(data); @@ -10013,6 +10020,33 @@ return (