Email-proposal review over Matrix + a bot role (v0.1.0:89)

The email-capture "proposed grid notes" gain two review surfaces:

1. Inline source email — each proposed-note card on the Email Capture page
   gets a "View email" toggle that lazily fetches the existing
   GET /api/email/detail and shows from/to/cc/date/subject + scrollable body,
   so a reviewer can judge the note against the email it was drafted from.

2. CRM->Matrix review bridge — the CRM (box, stdlib, no matrix-nio) can't post
   to Matrix, so the intake bot (Spark) PULLS: GET /api/intake/email-proposals
   returns to_post/open/to_close work-lists; the bot posts a review card
   (metadata + snippet + draft note) to a dedicated review room
   (MATRIX_EMAIL_REVIEW_ROOM) and relays in-thread yes / no / NL-edit
   (POST .../{id}/decide, note revised via local Qwen). Decisions sync both
   ways: web decide -> bot announces + closes the thread; Matrix decide -> the
   web panel's ~25s poll clears the card. State lives CRM-side in the new
   email_proposal_matrix side row (email-integration migration 0003, additive
   + idempotent CREATE TABLE IF NOT EXISTS), so it survives a bot restart.

Adds a 'bot' role (authenticated, never admin; require_bot_or_admin) to gate
the email-proposal endpoints rather than handing the bot full admin — the
principled base for the coming agentic capabilities. Role controls reach;
the draft->approve gate still controls autonomy (a human approves every write).

Deploy split: endpoints + migration + role + frontend ship in the s9pk; the
bot poll loop + review-room handling ship on the Spark. The bot's CRM user
must be flipped member->bot and joined to the review room (one-time).

Tests: backend/test_email_proposal_matrix.py + matrix_intake/test_email_proposals.py
(30/30 suite green, render-smoke green, migration verified twice on a DB copy).
This commit is contained in:
Keysat
2026-06-18 09:51:41 -05:00
parent 41def0f014
commit 5faa5ae4d6
16 changed files with 783 additions and 17 deletions
+153 -7
View File
@@ -619,6 +619,14 @@ def parse_iso_utc(ts):
def require_admin(user):
return bool(user and user.get('role') == 'admin')
def require_bot_or_admin(user):
"""Gate for agent/bot-facing endpoints: a dedicated 'bot' service account OR an admin
(admins keep parity for debugging/curl). The 'bot' role is authenticated-but-not-admin
it never passes require_admin, so a bot credential cannot reach user-management, security,
or settings. Reach (which endpoints) is controlled here; autonomy (acting without a human)
stays governed by the per-feature draft->approve gate, independent of role."""
return bool(user and user.get('role') in ('admin', 'bot'))
def log_audit(conn, user_id, entity_type, entity_id, action, changes=None):
conn.execute(
"INSERT INTO audit_log (id, user_id, entity_type, entity_id, action, changes) VALUES (?, ?, ?, ?, ?, ?)",
@@ -2068,6 +2076,9 @@ class CRMHandler(BaseHTTPRequestHandler):
# Matrix intake bot — new-vs-existing lookup for its in-thread proposal
if path == '/api/intake/match':
return self.handle_intake_match(user, params)
# Matrix review bot — email-activity proposal work-lists (to_post/open/to_close)
if path == '/api/intake/email-proposals':
return self.handle_list_bot_email_proposals(user)
# Users
if path == '/api/users':
@@ -2187,6 +2198,10 @@ class CRMHandler(BaseHTTPRequestHandler):
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/intake/email-proposals/[^/]+/matrix$', path):
return self.handle_mark_email_proposal_matrix(user, path.split('/')[-2], body)
if re.match(r'^/api/intake/email-proposals/[^/]+/decide$', path):
return self.handle_decide_email_proposal_matrix(user, path.split('/')[-2], 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):
@@ -3964,6 +3979,59 @@ class CRMHandler(BaseHTTPRequestHandler):
return self.send_error_json(res["error"], code)
return self.send_json({"data": res})
# ─── Matrix review-bot bridge for email-activity proposals (bot-or-admin) ───
def handle_list_bot_email_proposals(self, user):
"""The bot's poll endpoint: {to_post, open, to_close}. Bot-or-admin (the proposals
carry LP email content, so this stays off the member tier)."""
if not require_bot_or_admin(user):
return self.send_error_json("Bot or admin required", 403)
conn = get_db()
try:
return self.send_json({"data": list_bot_email_proposals(conn)})
finally:
conn.close()
def handle_mark_email_proposal_matrix(self, user, proposal_id, body):
"""Record Matrix thread state: {event_id} marks the card posted; {closed:true} marks
the thread resolved after the bot announced a web-side decision."""
if not require_bot_or_admin(user):
return self.send_error_json("Bot or admin required", 403)
body = body or {}
conn = get_db()
try:
if body.get("closed"):
res = mark_proposal_matrix_closed(conn, proposal_id)
else:
event_id = str(body.get("event_id") or "").strip()
if not event_id:
return self.send_error_json("event_id or closed is required")
res = mark_proposal_matrix_posted(conn, proposal_id, event_id)
finally:
conn.close()
if res.get("error"):
return self.send_error_json(res["error"], 404 if res["error"] == "not_found" else 400)
return self.send_json({"data": res})
def handle_decide_email_proposal_matrix(self, user, proposal_id, body):
"""In-thread Matrix decision relayed by the bot: approve/dismiss (+ optional edited note),
tagged source='matrix' and closing the thread in the same transaction."""
if not require_bot_or_admin(user):
return self.send_error_json("Bot or admin required", 403)
body = body or {}
decision = str(body.get("decision") or "").strip()
if decision not in ("approve", "dismiss"):
return self.send_error_json("decision must be approve or dismiss")
conn = get_db()
try:
res = decide_email_activity_proposal(conn, proposal_id, decision, user['user_id'],
body.get('note'), source="matrix", close_matrix=True)
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):
@@ -4357,8 +4425,10 @@ class CRMHandler(BaseHTTPRequestHandler):
return self.send_error_json("password must be at least 8 characters")
role = body.get('role', 'member')
if role not in ('admin', 'member'):
return self.send_error_json("role must be admin or member")
# 'bot' is a deliberately-provisioned agent service account (kept out of the invite UI's
# member/admin dropdown) — authenticated but never an admin. See require_bot_or_admin.
if role not in ('admin', 'member', 'bot'):
return self.send_error_json("role must be admin, member, or bot")
conn = get_db()
existing = conn.execute(
@@ -4417,9 +4487,9 @@ class CRMHandler(BaseHTTPRequestHandler):
if 'role' in body:
role = str(body.get('role'))
if role not in ('admin', 'member'):
if role not in ('admin', 'member', 'bot'):
conn.close()
return self.send_error_json("role must be admin or member")
return self.send_error_json("role must be admin, member, or bot")
sets.append("role = ?")
args.append(role)
@@ -5728,8 +5798,14 @@ def list_email_activity_proposals(conn, status="pending", limit=200):
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."""
def decide_email_activity_proposal(conn, proposal_id, decision, user_id, edited_note=None,
source="crm_ui", close_matrix=False):
"""Approve (optionally with an edited note -> append to grid) or dismiss a proposal.
`source` records the channel in the audit log ('crm_ui' for the web panel, 'matrix' for an
in-thread approval relayed by the review bot). `close_matrix` is set by the bot when the
decision was made in-thread: it stamps the Matrix side row closed in the same transaction so
the web->Matrix close path (list_bot_email_proposals.to_close) won't re-announce it."""
p = conn.execute("SELECT * FROM email_activity_proposals WHERE id=?", (proposal_id,)).fetchone()
if not p:
return {"error": "not_found"}
@@ -5747,15 +5823,85 @@ def decide_email_activity_proposal(conn, proposal_id, decision, user_id, edited_
action, result = "email.activity_dismissed", {"status": "dismissed"}
else:
return {"error": "bad_decision"}
if close_matrix:
_mark_proposal_matrix(conn, proposal_id, closed_at=now())
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()))
json.dumps({"proposal_id": proposal_id}), source, now()))
conn.commit()
return result
# ─── Matrix review-bot bridge for email-activity proposals (Features 2/3) ──────
# The CRM (box, stdlib, no matrix-nio) can't post to Matrix, so the intake bot (Spark) PULLS
# pending proposals via list_bot_email_proposals, posts a review card to the dedicated review
# room, and writes the thread-root event_id back here. State lives CRM-side (email_proposal_matrix)
# so both surfaces stay in sync and it survives a bot restart. All queries degrade to empty when
# the email integration tables are absent (OperationalError), mirroring list_email_activity_proposals.
_BOT_PROPOSAL_COLS = (
"SELECT p.id, p.investor_name, p.direction, p.summary, p.proposed_note, "
"p.email_subject, p.email_date, e.from_name, e.from_email, e.snippet, "
"m.event_id AS event_id, p.status AS status "
"FROM email_activity_proposals p "
"LEFT JOIN email_proposal_matrix m ON m.proposal_id = p.id "
"LEFT JOIN emails e ON e.id = p.email_id ")
def list_bot_email_proposals(conn, limit=100):
"""The three work-lists the Matrix review bot polls:
to_post pending, not yet posted to Matrix -> bot posts a review card.
open pending, posted, not closed -> live threads; the bot rebuilds its
event_id->proposal routing map from these after a restart.
to_close decided on the WEB while a thread is open -> bot announces it in-thread, closes.
Each item carries the card content (investor, direction, subject, date, from, snippet, note)."""
try:
to_post = [dict(r) for r in conn.execute(
_BOT_PROPOSAL_COLS + "WHERE p.status='pending' AND (m.proposal_id IS NULL OR m.posted_at IS NULL) "
"ORDER BY p.email_date ASC, p.created_at ASC LIMIT ?", (limit,))]
open_threads = [dict(r) for r in conn.execute(
_BOT_PROPOSAL_COLS + "WHERE p.status='pending' AND m.posted_at IS NOT NULL AND m.closed_at IS NULL "
"ORDER BY p.email_date ASC, p.created_at ASC LIMIT ?", (limit,))]
to_close = [dict(r) for r in conn.execute(
_BOT_PROPOSAL_COLS + "WHERE p.status!='pending' AND m.posted_at IS NOT NULL AND m.closed_at IS NULL "
"ORDER BY p.decided_at ASC LIMIT ?", (limit,))]
except sqlite3.OperationalError:
return {"to_post": [], "open": [], "to_close": []}
return {"to_post": to_post, "open": open_threads, "to_close": to_close}
def _mark_proposal_matrix(conn, proposal_id, *, event_id=None, posted_at=None, closed_at=None):
"""Idempotent upsert of the 1:1 Matrix side row. Only the passed fields are written."""
cols, vals, sets = ["proposal_id"], [proposal_id], []
for name, val in (("event_id", event_id), ("posted_at", posted_at), ("closed_at", closed_at)):
if val is not None:
cols.append(name); vals.append(val); sets.append(f"{name}=excluded.{name}")
placeholders = ",".join("?" for _ in cols)
sql = f"INSERT INTO email_proposal_matrix ({','.join(cols)}) VALUES ({placeholders})"
if sets:
sql += " ON CONFLICT(proposal_id) DO UPDATE SET " + ",".join(sets)
conn.execute(sql, vals)
def mark_proposal_matrix_posted(conn, proposal_id, event_id):
"""Record that the bot posted a review card (thread root = event_id)."""
if not conn.execute("SELECT 1 FROM email_activity_proposals WHERE id=?", (proposal_id,)).fetchone():
return {"error": "not_found"}
_mark_proposal_matrix(conn, proposal_id, event_id=event_id, posted_at=now())
conn.commit()
return {"ok": True}
def mark_proposal_matrix_closed(conn, proposal_id):
"""Mark the Matrix review thread resolved (the bot announced a web-side decision)."""
if not conn.execute("SELECT 1 FROM email_activity_proposals WHERE id=?", (proposal_id,)).fetchone():
return {"error": "not_found"}
_mark_proposal_matrix(conn, proposal_id, closed_at=now())
conn.commit()
return {"ok": True}
# ─── Main Entry Point ────────────────────────────────────────────────────────
def main():