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:
Keysat
2026-06-08 21:31:52 -05:00
parent b5619d61e1
commit 787d580550
7 changed files with 181 additions and 7 deletions
+37
View File
@@ -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: