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:
+153
-7
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user