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>
This commit is contained in:
@@ -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)."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user