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:
Keysat
2026-06-06 15:55:26 -05:00
parent 3893a4fb9f
commit 069e60053b
9 changed files with 462 additions and 7 deletions
+225 -1
View File
@@ -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}")