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):
|
def _context(conn, investor_id):
|
||||||
"""Assemble the recipient's context: CRM notes + recent matched email with them.
|
"""Assemble the recipient's context: CRM notes + recent matched email with them.
|
||||||
Returns (investor_name, context_text) or (None, None)."""
|
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),
|
check(set(["intro", "follow_up", "fund_update", "meeting_follow_up", "nurture"]) <= set(oa.OUTREACH_TYPES),
|
||||||
"outreach types catalogue present")
|
"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:
|
if FAILS:
|
||||||
print(f"\nFAILED ({len(FAILS)})")
|
print(f"\nFAILED ({len(FAILS)})")
|
||||||
for f in FAILS:
|
for f in FAILS:
|
||||||
|
|||||||
@@ -1808,6 +1808,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
return self.handle_list_activity_proposals(user)
|
return self.handle_list_activity_proposals(user)
|
||||||
if path == '/api/outreach/investors':
|
if path == '/api/outreach/investors':
|
||||||
return self.handle_list_outreach_investors(user)
|
return self.handle_list_outreach_investors(user)
|
||||||
|
if path == '/api/outreach/radar':
|
||||||
|
return self.handle_outreach_radar(user)
|
||||||
|
|
||||||
# Users
|
# Users
|
||||||
if path == '/api/users':
|
if path == '/api/users':
|
||||||
@@ -3920,6 +3922,21 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
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):
|
def handle_outreach_draft(self, user, body):
|
||||||
"""Draft tailored LP outreach through the redaction boundary (draft-only —
|
"""Draft tailored LP outreach through the redaction boundary (draft-only —
|
||||||
a human reviews/edits/sends; guardrails #4, #6)."""
|
a human reviews/edits/sends; guardrails #4, #6)."""
|
||||||
|
|||||||
+37
-3
@@ -9956,6 +9956,7 @@
|
|||||||
const [drafting, setDrafting] = useState(false);
|
const [drafting, setDrafting] = useState(false);
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [draftText, setDraftText] = useState('');
|
const [draftText, setDraftText] = useState('');
|
||||||
|
const [radar, setRadar] = useState([]);
|
||||||
const TYPES = [
|
const TYPES = [
|
||||||
['intro', 'Intro'],
|
['intro', 'Intro'],
|
||||||
['follow_up', 'Warm follow-up'],
|
['follow_up', 'Warm follow-up'],
|
||||||
@@ -9977,19 +9978,25 @@
|
|||||||
const r = await api('/api/outreach/investors', {}, token);
|
const r = await api('/api/outreach/investors', {}, token);
|
||||||
if (!cancelled) setInvestors(Array.isArray(r?.investors) ? r.investors : []);
|
if (!cancelled) setInvestors(Array.isArray(r?.investors) ? r.investors : []);
|
||||||
} catch (_) { /* none */ }
|
} 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; };
|
return () => { cancelled = true; };
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const draft = async () => {
|
const draft = async (ovInvestor, ovType) => {
|
||||||
if (drafting) return;
|
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 {
|
try {
|
||||||
setDrafting(true);
|
setDrafting(true);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
const res = await api('/api/outreach/draft', {
|
const res = await api('/api/outreach/draft', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ investor_id: investorId, outreach_type: type, guidance }),
|
body: JSON.stringify({ investor_id: inv, outreach_type: t, guidance }),
|
||||||
}, token);
|
}, token);
|
||||||
const data = res.data || res;
|
const data = res.data || res;
|
||||||
setResult(data);
|
setResult(data);
|
||||||
@@ -10013,6 +10020,33 @@
|
|||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<h2 className="section-title" style={{ marginBottom: '20px' }}>Outreach</h2>
|
<h2 className="section-title" style={{ marginBottom: '20px' }}>Outreach</h2>
|
||||||
|
|
||||||
|
{radar.length > 0 && (
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
Needs attention
|
||||||
|
<span className="approval-pill">{radar.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="index-action-hint" style={{ marginTop: 0, marginBottom: '12px' }}>
|
||||||
|
Investors who are waiting on a reply or have gone quiet, most urgent first. Every reason is verifiable from your email history — no guesswork. Click Draft to compose in your voice.
|
||||||
|
</div>
|
||||||
|
{radar.map((it) => (
|
||||||
|
<div key={it.investor_id} className="merge-candidate-card"
|
||||||
|
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{it.name}</div>
|
||||||
|
<div className="kpi-subtitle">{it.reason}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setInvestorId(it.investor_id); setType(it.suggested_type); draft(it.investor_id, it.suggested_type); }}
|
||||||
|
disabled={drafting}>
|
||||||
|
{drafting ? '…' : `Draft ${it.suggested_type === 'nurture' ? 'nurture' : 'follow-up'}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="section">
|
<div className="section">
|
||||||
<div className="index-action-hint" style={{ marginTop: 0, marginBottom: '12px' }}>
|
<div className="index-action-hint" style={{ marginTop: 0, marginBottom: '12px' }}>
|
||||||
Drafts a tailored LP email in Ten31's voice, grounded in the thesis and that investor's CRM notes + email history. The investor's details are de-identified before Claude sees them and restored locally, so the LP list never leaves Ten31. Drafts only — you review, edit, and send.
|
Drafts a tailored LP email in Ten31's voice, grounded in the thesis and that investor's CRM notes + email history. The investor's details are de-identified before Claude sees them and restored locally, so the LP list never leaves Ten31. Drafts only — you review, edit, and send.
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
|
|||||||
// * 0.1.0:65 (Email Capture: per-mailbox captured/matched counts)
|
// * 0.1.0:65 (Email Capture: per-mailbox captured/matched counts)
|
||||||
// * 0.1.0:66 (LP Objections page: UI trigger for the Architect grounding pass)
|
// * 0.1.0:66 (LP Objections page: UI trigger for the Architect grounding pass)
|
||||||
// * 0.1.0:67 (remove LP Objections page — generic/unverifiable; pivot to proactive outreach)
|
// * 0.1.0:67 (remove LP Objections page — generic/unverifiable; pivot to proactive outreach)
|
||||||
// * Current: 0.1.0:68 (Outreach Draft Assistant — tailored LP drafts via thesis + redaction boundary)
|
// * 0.1.0:68 (Outreach Draft Assistant — tailored LP drafts via thesis + redaction boundary)
|
||||||
export const PACKAGE_VERSION = '0.1.0:68'
|
// * Current: 0.1.0:69 (follow-up radar — deterministic "needs attention" list + one-click draft)
|
||||||
|
export const PACKAGE_VERSION = '0.1.0:69'
|
||||||
|
|
||||||
export const DATA_MOUNT_PATH = '/data'
|
export const DATA_MOUNT_PATH = '/data'
|
||||||
export const WEB_PORT = 8080
|
export const WEB_PORT = 8080
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ import { v_0_1_0_65 } from './v0.1.0.65'
|
|||||||
import { v_0_1_0_66 } from './v0.1.0.66'
|
import { v_0_1_0_66 } from './v0.1.0.66'
|
||||||
import { v_0_1_0_67 } from './v0.1.0.67'
|
import { v_0_1_0_67 } from './v0.1.0.67'
|
||||||
import { v_0_1_0_68 } from './v0.1.0.68'
|
import { v_0_1_0_68 } from './v0.1.0.68'
|
||||||
|
import { v_0_1_0_69 } from './v0.1.0.69'
|
||||||
|
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_0_1_0_68,
|
current: v_0_1_0_69,
|
||||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67],
|
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
// Follow-up radar on the Outreach page. A deterministic scan surfaces investors who
|
||||||
|
// need attention — "you owe a reply" (their email is unanswered), or a warm lead gone
|
||||||
|
// quiet (no contact in 45+ days) — most urgent first, each with a checkable reason from
|
||||||
|
// the email history (no LLM guesswork in the surfacing). One click drafts the suggested
|
||||||
|
// follow-up/nurture in your voice via the existing outreach drafter. No schema change.
|
||||||
|
export const v_0_1_0_69 = VersionInfo.of({
|
||||||
|
version: '0.1.0:69',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US: [
|
||||||
|
'Outreach now opens with a "Needs attention" list: investors waiting on a reply or gone',
|
||||||
|
'quiet (45+ days), most urgent first, each with a reason you can verify from the email',
|
||||||
|
'history. One click drafts the right follow-up or nurture in your voice.',
|
||||||
|
].join(' '),
|
||||||
|
},
|
||||||
|
migrations: { up: async () => {}, down: async () => {} },
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user