email-activity agent: propose -> review -> approve grid notes (v0.1.0:64)
When a sent/received email is matched to an investor, a local-model agent drafts a
one-line dated note and queues it as a PENDING proposal (it never writes the grid
itself). On the Email Capture page a partner sees "Proposed grid notes", can edit the
text, and Approve (appends to that investor's grid notes cell, newest at bottom,
stamped with the approver) or Dismiss. Going-forward only: a cutoff (app_settings
email_activity_since, set on first run) means email dated before the feature was
enabled is never summarized, so the historical backfill makes no noise. Sovereign:
summaries run entirely on the local model (no redaction needed). Gmail sync interval
tightened 180 -> 15 min so outgoing email surfaces quickly.
Backend: migration 0002 (email_activity_proposals); propose_email_activity_notes()
runs via a new scheduler post_sync hook; list/decide functions + routes
GET /api/activity/proposals, POST .../{id}/approve|dismiss. Grid append stamps the
approving user (fundraising_state.updated_by has a FK to users). Test
test_email_activity.py (propose cutoff/idempotency, approve appends + edited note,
dismiss, already-decided guard) under FK enforcement.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+225
-1
@@ -1802,6 +1802,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_security_status(user)
|
||||
if path == '/api/system/status':
|
||||
return self.handle_system_status(user)
|
||||
if path == '/api/activity/proposals':
|
||||
return self.handle_list_activity_proposals(user)
|
||||
|
||||
# Users
|
||||
if path == '/api/users':
|
||||
@@ -1905,6 +1907,10 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_node_feedback(user, path.split('/')[-2], body)
|
||||
if path == '/api/architect/ground':
|
||||
return self.handle_architect_ground(user, body)
|
||||
if re.match(r'^/api/activity/proposals/[^/]+/approve$', path):
|
||||
return self.handle_decide_activity_proposal(user, path.split('/')[-2], 'approve', body)
|
||||
if re.match(r'^/api/activity/proposals/[^/]+/dismiss$', path):
|
||||
return self.handle_decide_activity_proposal(user, path.split('/')[-2], 'dismiss', body)
|
||||
if re.match(r'^/api/thesis/nodes/[^/]+/choose$', path):
|
||||
return self.handle_choose_variant(user, path.split('/')[-2])
|
||||
if re.match(r'^/api/thesis/lines/[^/]+/approve$', path):
|
||||
@@ -3636,6 +3642,29 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
conn.close()
|
||||
self.send_json({"data": out})
|
||||
|
||||
def handle_list_activity_proposals(self, user):
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin required", 403)
|
||||
conn = get_db()
|
||||
try:
|
||||
return self.send_json({"proposals": list_email_activity_proposals(conn, status="pending")})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def handle_decide_activity_proposal(self, user, proposal_id, decision, body):
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin required", 403)
|
||||
conn = get_db()
|
||||
try:
|
||||
res = decide_email_activity_proposal(conn, proposal_id, decision,
|
||||
user['user_id'], (body or {}).get('note'))
|
||||
finally:
|
||||
conn.close()
|
||||
if res.get("error"):
|
||||
code = {"not_found": 404, "already_decided": 409}.get(res["error"], 400)
|
||||
return self.send_error_json(res["error"], code)
|
||||
return self.send_json({"data": res})
|
||||
|
||||
# ─── UI-triggered index jobs + entity-merge review (Phase 1) ───
|
||||
def handle_index_job(self, user, kind):
|
||||
if not require_admin(user):
|
||||
@@ -4993,6 +5022,200 @@ def seed_demo_data():
|
||||
print("Demo data seeded successfully.")
|
||||
|
||||
|
||||
# ─── Email-activity summary agent ────────────────────────────────────────────
|
||||
# When a sent/received email is matched to an investor, summarize it to ONE dated,
|
||||
# marked note on the LOCAL model (sovereign — nothing leaves Ten31, so no redaction
|
||||
# boundary is needed) and append it to that investor's notes in the fundraising grid.
|
||||
# Going-forward only: we never summarize email dated before the feature was switched
|
||||
# on, so the historical backfill does not generate noise.
|
||||
|
||||
_ACTIVITY_SINCE_KEY = "email_activity_since"
|
||||
_ACTIVITY_MARKER = "✉" # marks an email-derived note (kept editable before approval)
|
||||
|
||||
|
||||
def _fmt_activity_date(sent_at):
|
||||
"""ISO-ish timestamp -> 'Jun 25, 2026' (falls back to the raw date part)."""
|
||||
from datetime import datetime
|
||||
datepart = str(sent_at or "")[:10] # YYYY-MM-DD
|
||||
try:
|
||||
return datetime.strptime(datepart, "%Y-%m-%d").strftime("%b %-d, %Y")
|
||||
except Exception:
|
||||
return datepart
|
||||
|
||||
|
||||
def _activity_investor(conn, email_id):
|
||||
"""Resolve (grid_row_id, investor_name) for a matched email via its highest-
|
||||
confidence investor link. Either may be None."""
|
||||
link = conn.execute(
|
||||
"SELECT fundraising_investor_id, organization_id, contact_id FROM email_investor_links "
|
||||
"WHERE email_id=? ORDER BY match_confidence DESC LIMIT 1", (email_id,)).fetchone()
|
||||
if not link:
|
||||
return None, None
|
||||
inv_id = link["fundraising_investor_id"]
|
||||
name = None
|
||||
if inv_id:
|
||||
r = conn.execute("SELECT investor_name FROM fundraising_investors WHERE id=?", (inv_id,)).fetchone()
|
||||
name = r["investor_name"] if r else None
|
||||
if not name and link["organization_id"]:
|
||||
r = conn.execute("SELECT name FROM organizations WHERE id=?", (link["organization_id"],)).fetchone()
|
||||
name = r["name"] if r else None
|
||||
if not name and link["contact_id"]:
|
||||
r = conn.execute("SELECT first_name, last_name FROM contacts WHERE id=?", (link["contact_id"],)).fetchone()
|
||||
if r:
|
||||
name = f"{r['first_name'] or ''} {r['last_name'] or ''}".strip()
|
||||
return inv_id, name
|
||||
|
||||
|
||||
def _summarize_email_gist(subject, body):
|
||||
"""One short clause describing the email's substance, from the LOCAL model."""
|
||||
try:
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "ingest"))
|
||||
import llm # noqa: E402
|
||||
except Exception:
|
||||
return None
|
||||
text = (body or "")[:4000]
|
||||
if not (subject or text).strip():
|
||||
return None
|
||||
out = llm.chat(
|
||||
f"Subject: {subject or '(none)'}\n\n{text}",
|
||||
system=("You summarize an email into a brief CRM note. Reply with ONE clause under 14 words "
|
||||
"describing what the email is about. No greeting, no names, no quotes, no trailing period."),
|
||||
max_tokens=40, temperature=0.0)
|
||||
gist = " ".join((out or "").split()).strip().rstrip(".")
|
||||
return gist or None
|
||||
|
||||
|
||||
def _append_grid_note(conn, inv_id, inv_name, note, updated_by=None):
|
||||
"""Append a note to the matched investor's notes cell in the live grid (newest at
|
||||
bottom), bump the grid version, and refresh the relational projection. Best-effort."""
|
||||
row = conn.execute("SELECT grid_json, views_json, version FROM fundraising_state WHERE id='main'").fetchone()
|
||||
if not row or not row["grid_json"]:
|
||||
return False
|
||||
try:
|
||||
grid = json.loads(row["grid_json"])
|
||||
except Exception:
|
||||
return False
|
||||
rows = grid.get("rows", []) if isinstance(grid, dict) else []
|
||||
target = None
|
||||
if inv_id:
|
||||
target = next((r for r in rows if isinstance(r, dict) and str(r.get("id")) == str(inv_id)), None)
|
||||
if target is None and inv_name:
|
||||
nn = _normalize_text(inv_name)
|
||||
target = next((r for r in rows if isinstance(r, dict)
|
||||
and _normalize_text(str(r.get("investor_name") or "")) == nn), None)
|
||||
if target is None:
|
||||
return False
|
||||
existing = str(target.get("notes") or "").rstrip()
|
||||
target["notes"] = (existing + "\n" + note) if existing else note
|
||||
try:
|
||||
views = json.loads(row["views_json"]) if row["views_json"] else []
|
||||
except Exception:
|
||||
views = []
|
||||
# updated_by has a FK to users(id); stamp the approving user (None -> NULL is fine).
|
||||
conn.execute("UPDATE fundraising_state SET grid_json=?, version=?, updated_by=?, updated_at=? WHERE id='main'",
|
||||
(json.dumps(grid), (row["version"] or 0) + 1, updated_by, now()))
|
||||
try:
|
||||
sync_fundraising_relational(conn, grid, views, actor_user_id=updated_by)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _activity_note_text(sent_at, direction, gist):
|
||||
return f"{_ACTIVITY_MARKER} {_fmt_activity_date(sent_at)} — {direction}: {gist}"
|
||||
|
||||
|
||||
def propose_email_activity_notes(limit=50):
|
||||
"""Draft a PROPOSED grid note per newly-matched email and queue it for human
|
||||
review (status 'pending'). Does NOT touch the grid — approval does that. Idempotent
|
||||
(one proposal per email), going-forward only. Safe to call after each Gmail sync."""
|
||||
conn = get_db()
|
||||
try:
|
||||
try:
|
||||
conn.execute("SELECT 1 FROM email_activity_proposals LIMIT 1")
|
||||
except sqlite3.OperationalError:
|
||||
return {"proposed": 0, "skipped": "tables_absent"}
|
||||
since = get_app_setting(conn, _ACTIVITY_SINCE_KEY)
|
||||
if not since:
|
||||
since = now()
|
||||
set_app_setting(conn, _ACTIVITY_SINCE_KEY, since)
|
||||
conn.commit()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, subject, body_text, snippet, from_email, sent_at FROM emails "
|
||||
"WHERE is_matched=1 AND sent_at >= ? "
|
||||
"AND id NOT IN (SELECT email_id FROM email_activity_proposals) "
|
||||
"ORDER BY sent_at ASC LIMIT ?", (since, limit)).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
return {"proposed": 0, "skipped": "emails_absent"}
|
||||
if not rows:
|
||||
return {"proposed": 0}
|
||||
own = set()
|
||||
try:
|
||||
own = {(r[0] or "").lower() for r in conn.execute("SELECT email_address FROM email_accounts")}
|
||||
except Exception:
|
||||
pass
|
||||
done = 0
|
||||
for r in rows:
|
||||
inv_id, inv_name = _activity_investor(conn, r["id"])
|
||||
direction = "Sent" if (r["from_email"] or "").lower() in own else "Received"
|
||||
gist = _summarize_email_gist(r["subject"], r["body_text"] or r["snippet"] or "")
|
||||
if not gist:
|
||||
continue # leave unproposed; a later pass retries once the model answers
|
||||
note = _activity_note_text(r["sent_at"], direction, gist)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO email_activity_proposals "
|
||||
"(id,email_id,investor_id,investor_name,direction,summary,proposed_note,"
|
||||
" email_subject,email_date,status,created_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,'pending',?)",
|
||||
(generate_id(), r["id"], inv_id, inv_name, direction.lower(), gist, note,
|
||||
r["subject"], r["sent_at"], now()))
|
||||
conn.commit()
|
||||
done += 1
|
||||
return {"proposed": done}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_email_activity_proposals(conn, status="pending", limit=200):
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, email_id, investor_id, investor_name, direction, summary, proposed_note, "
|
||||
"email_subject, email_date, status, created_at FROM email_activity_proposals "
|
||||
"WHERE status=? ORDER BY email_date ASC, created_at ASC LIMIT ?", (status, limit)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
except sqlite3.OperationalError:
|
||||
return []
|
||||
|
||||
|
||||
def decide_email_activity_proposal(conn, proposal_id, decision, user_id, edited_note=None):
|
||||
"""Approve (optionally with an edited note -> append to grid) or dismiss a proposal."""
|
||||
p = conn.execute("SELECT * FROM email_activity_proposals WHERE id=?", (proposal_id,)).fetchone()
|
||||
if not p:
|
||||
return {"error": "not_found"}
|
||||
if p["status"] != "pending":
|
||||
return {"error": "already_decided", "status": p["status"]}
|
||||
if decision == "approve":
|
||||
note = (edited_note or "").strip() or p["proposed_note"]
|
||||
placed = _append_grid_note(conn, p["investor_id"], p["investor_name"], note, updated_by=user_id)
|
||||
conn.execute("UPDATE email_activity_proposals SET status='approved', final_note=?, decided_by=?, decided_at=? WHERE id=?",
|
||||
(note, user_id, now(), proposal_id))
|
||||
action, result = "email.activity_approved", {"status": "approved", "placed_in_grid": placed}
|
||||
elif decision == "dismiss":
|
||||
conn.execute("UPDATE email_activity_proposals SET status='dismissed', decided_by=?, decided_at=? WHERE id=?",
|
||||
(user_id, now(), proposal_id))
|
||||
action, result = "email.activity_dismissed", {"status": "dismissed"}
|
||||
else:
|
||||
return {"error": "bad_decision"}
|
||||
conn.execute(
|
||||
"INSERT INTO interaction_log (id, ts, actor_type, actor_id, action, target_type, target_id, payload, source, created_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
(generate_id(), now(), "human", user_id, action, "fundraising_investor", p["investor_id"],
|
||||
json.dumps({"proposal_id": proposal_id}), "crm_ui", now()))
|
||||
conn.commit()
|
||||
return result
|
||||
|
||||
|
||||
# ─── Main Entry Point ────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
@@ -5010,7 +5233,8 @@ def main():
|
||||
if os.environ.get("CRM_GMAIL_INTEGRATION_ENABLED", "").lower() in ("1", "true", "yes", "on"):
|
||||
try:
|
||||
from email_integration.scheduler import start_sync_scheduler
|
||||
start_sync_scheduler()
|
||||
# After each Gmail sync, draft proposed activity notes for human review.
|
||||
start_sync_scheduler(post_sync=lambda: propose_email_activity_notes())
|
||||
print("[email_integration] Gmail sync scheduler started")
|
||||
except Exception as _e:
|
||||
print(f"[email_integration] failed to start scheduler: {_e}")
|
||||
|
||||
Reference in New Issue
Block a user