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:
@@ -0,0 +1,85 @@
|
||||
"""Email-activity proposal review over Matrix — the CRM→Matrix leg of the email-capture flow.
|
||||
|
||||
The CRM (on the box) drafts a proposed grid note per newly-matched email (local model, no Claude)
|
||||
and queues it for human review. The CRM is stdlib-only and can't post to Matrix itself, so this
|
||||
bot PULLS the pending proposals (crm_client.list_email_proposals), posts a review card to the
|
||||
dedicated review room, and relays the human's in-thread reply back to the CRM. Same draft→approve
|
||||
discipline as the intake bot: nothing is appended to the grid until a human approves — here OR on
|
||||
the web Email Capture panel, the two surfaces kept in sync via the CRM's email_proposal_matrix row.
|
||||
|
||||
This module is the PURE logic (card rendering, reply grammar, note revision) so it's unit-tested
|
||||
offline; the async poll/post/reply wiring lives in bot.py (network + Matrix, live-smoke only).
|
||||
"""
|
||||
import spark
|
||||
|
||||
_YES = {"yes", "y", "approve", "approved", "ok", "confirm", "go", "add", "👍", "✅"}
|
||||
_NO = {"no", "n", "cancel", "discard", "reject", "skip", "stop", "👎", "❌"}
|
||||
|
||||
_SNIPPET_MAX = 400 # email snippet shown on the card; the full body is in the web popup
|
||||
|
||||
|
||||
def _truncate(s, n):
|
||||
s = (s or "").strip()
|
||||
return s if len(s) <= n else s[:n].rstrip() + "…"
|
||||
|
||||
|
||||
def render_card(item):
|
||||
"""The review card posted to the Matrix review room: who/when + a short email snippet + the
|
||||
drafted note. Deliberately compact for mobile — the full scrollable body is in the web Email
|
||||
Capture popup (this is the metadata+snippet+note choice)."""
|
||||
name = item.get("investor_name") or "Unknown investor"
|
||||
direction = "Sent" if item.get("direction") == "sent" else "Received"
|
||||
frm = item.get("from_name") or item.get("from_email") or "?"
|
||||
lines = [f"📧 Proposed **grid note** for **{name}** ({direction})"]
|
||||
if item.get("email_subject"):
|
||||
lines.append(f"· Subject: {item['email_subject']}")
|
||||
if item.get("email_date"):
|
||||
lines.append(f"· Date: {item['email_date']}")
|
||||
lines.append(f"· From: {frm}")
|
||||
snippet = _truncate(item.get("snippet"), _SNIPPET_MAX)
|
||||
if snippet:
|
||||
lines.append(f"· Email: {snippet}")
|
||||
lines.append("")
|
||||
lines.append(f"📝 Draft note: {item.get('proposed_note') or '(empty)'}")
|
||||
lines.append("")
|
||||
lines.append("Reply **yes** to add it to the grid, **no** to dismiss, or just tell me how to "
|
||||
"change the note (e.g. *say we discussed the Q3 raise*).")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def closure_line(status):
|
||||
"""Posted in-thread when a proposal was decided on the WEB while its Matrix thread was open."""
|
||||
verb = "approved ✅ and added to the grid" if status == "approved" else "dismissed 🗑️"
|
||||
return f"This was {verb} on the web — nothing more to do here. Thread closed."
|
||||
|
||||
|
||||
def interpret(text):
|
||||
"""Classify an in-thread reply: 'approve' | 'reject' | 'revise' (anything else → revise the note)."""
|
||||
t = (text or "").strip().lower()
|
||||
if t in _YES:
|
||||
return "approve"
|
||||
if t in _NO:
|
||||
return "reject"
|
||||
return "revise"
|
||||
|
||||
|
||||
REVISE_SYSTEM = (
|
||||
"You revise a single CRM note from a short instruction a venture-fund team member typed. "
|
||||
"You are given the CURRENT note and an INSTRUCTION. Apply the instruction and reply with "
|
||||
"ONLY a JSON object of the form {\"note\": \"<the full revised note>\"}. Keep it to one or two "
|
||||
"factual sentences, no preamble. Output JSON only."
|
||||
)
|
||||
|
||||
|
||||
def revise_note(note, instruction, parse_fn=spark.parse_json):
|
||||
"""Re-draft the note via local Qwen from a free-form instruction (no Claude, no scrub — same
|
||||
local-only basis as the intake parse). Returns the new note text, or None if the model gave
|
||||
nothing usable / unchanged, in which case the caller re-prompts. `parse_fn` is injectable for
|
||||
tests."""
|
||||
prompt = "CURRENT:\n" + (note or "") + "\n\nINSTRUCTION:\n" + (instruction or "").strip()
|
||||
raw = parse_fn(prompt, system=REVISE_SYSTEM, max_tokens=400) or {}
|
||||
new = raw.get("note") if isinstance(raw, dict) else None
|
||||
new = (new or "").strip()
|
||||
if not new or new == (note or "").strip():
|
||||
return None
|
||||
return new
|
||||
Reference in New Issue
Block a user