Refine email-proposal review UX (v0.1.0:91)

Three post-smoke refinements to the Matrix email-proposal review:

1. Dash separators (bot): every card/reply is framed with a dash rule top and
   bottom so threads stop bleeding together vertically on mobile.

2. Remove decided threads (bot): on a conclusive approve/dismiss from either
   surface, the bot redacts the card (client.room_redact) so the room clears
   down to only undecided items. Redacting the bot's own card needs no power;
   the web->Matrix path now redacts instead of posting a closure note.

3. Clearer note wording (server v91 + bot): the proposed grid note now names who
   emailed whom -- "{teammate} emailed {investor}" (outbound) / "{sender} emailed
   the team" (inbound) -- instead of an ambiguous "Sent"/"Received". Outbound
   detection also matches our corporate domain (public providers excluded), so a
   teammate's mail from a non-enrolled @ten31.xyz address no longer reads as
   "Received". Going-forward only; no schema change. The card drops its bare
   direction label since the note now carries the relationship.

Tests updated; 30/30 green, render-smoke green.
This commit is contained in:
Keysat
2026-06-18 11:59:38 -05:00
parent 48bd29aaa3
commit a10889b10b
9 changed files with 136 additions and 51 deletions
+26 -13
View File
@@ -162,10 +162,21 @@ async def main():
store.put(root, proposal)
await say(room_id, "I didn't catch that.\n\n" + proposals.render_disambiguation(proposal), root)
async def redact_card(event_id):
"""Remove a decided card from the room so only undecided ones remain. Redacting our OWN
card needs no special power; in Element a redacted message drops out of the timeline. (To
also wipe the human's yes/no reply for a fully-empty thread, give the bot a redact/mod
power level — not required for this.)"""
try:
await client.room_redact(review_room, event_id, reason="proposal resolved")
except Exception as exc:
print(f"matrix-intake: could not redact card {event_id}: {exc}", flush=True)
async def handle_email_reply(room_id, root, text):
"""An in-thread reply to a CRM-drafted email-proposal card: yes commits, no dismisses, and
anything else is a natural-language revision of the note (re-drafted by local Qwen; the
human still approves the revised note, so the draft→approve gate holds)."""
human still approves the revised note, so the draft→approve gate holds). On a conclusive
decision the card is redacted so the room clears down to only what still needs handling."""
item = email_threads.get(root)
if item is None:
return # a threaded reply we don't own (or already resolved)
@@ -177,33 +188,35 @@ async def main():
await asyncio.to_thread(crm_client.decide_email_proposal, item["id"], "approve", item.get("note"))
except Exception as exc:
email_threads[root] = item # restore for retry
await say(room_id, f"⚠️ couldn't add it ({str(exc)[:200]}). Reply **yes** to retry, **no** to dismiss.", root)
await say(room_id, email_proposals.frame(f"⚠️ couldn't add it ({str(exc)[:200]}). Reply **yes** to retry, **no** to dismiss."), root)
return
await say(room_id, f"✅ Added to the grid for **{item.get('investor_name') or 'the investor'}**.", root)
await say(room_id, email_proposals.frame(f"✅ Added to the grid for **{item.get('investor_name') or 'the investor'}**."), root)
await redact_card(root)
elif decision == "reject":
email_threads.pop(root, None)
try:
await asyncio.to_thread(crm_client.decide_email_proposal, item["id"], "dismiss")
except Exception as exc:
email_threads[root] = item
await say(room_id, f"⚠️ couldn't dismiss it ({str(exc)[:200]}). Try again.", root)
await say(room_id, email_proposals.frame(f"⚠️ couldn't dismiss it ({str(exc)[:200]}). Try again."), root)
return
await say(room_id, "🗑️ Dismissed — nothing added to the grid.", root)
await say(room_id, email_proposals.frame("🗑️ Dismissed — nothing added to the grid."), root)
await redact_card(root)
else:
try:
new_note = await asyncio.to_thread(email_proposals.revise_note, item.get("note") or "", text)
except Exception as exc:
await say(room_id, f"⚠️ couldn't revise that ({str(exc)[:200]}). Reply **yes** to add as-is, "
"**no** to dismiss, or rephrase.", root)
await say(room_id, email_proposals.frame(f"⚠️ couldn't revise that ({str(exc)[:200]}). Reply **yes** to add as-is, "
"**no** to dismiss, or rephrase."), root)
return
if not new_note:
await say(room_id, "I didn't catch a change. Reply **yes** to add the note as-is, **no** to "
"dismiss, or tell me how to change it.", root)
await say(room_id, email_proposals.frame("I didn't catch a change. Reply **yes** to add the note as-is, **no** to "
"dismiss, or tell me how to change it."), root)
return
item["note"] = new_note
email_threads[root] = item
await say(room_id, f"✏️ Updated draft note:\n\n{new_note}\n\nReply **yes** to add it, **no** to "
"dismiss, or refine again.", root)
await say(room_id, email_proposals.frame(f"✏️ Updated draft note:\n\n{new_note}\n\nReply **yes** to add it, **no** to "
"dismiss, or refine again."), root)
async def poll_email_proposals():
"""Poll the CRM for email-activity proposals: post a review card for each new one, rebuild
@@ -231,12 +244,12 @@ async def main():
"note": it.get("proposed_note") or ""}
except Exception as exc:
print(f"matrix-intake: failed to post email proposal {it.get('id')}: {exc}", flush=True)
for it in lists["to_close"]: # decided on the web → announce in-thread, then close
for it in lists["to_close"]: # decided on the web → remove the card, then close
ev = it.get("event_id")
if not ev:
continue
try:
await say(review_room, email_proposals.closure_line(it.get("status")), ev)
await redact_card(ev)
await asyncio.to_thread(crm_client.mark_email_proposal_closed, it["id"])
email_threads.pop(ev, None)
except Exception as exc:
+9 -10
View File
@@ -16,6 +16,12 @@ _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
RULE = "-----------------------" # top/bottom rule so threads don't bleed together on mobile
def frame(text):
"""Wrap a message in dash rules so each card/reply is visually bounded in the room."""
return f"{RULE}\n{text}\n{RULE}"
def _truncate(s, n):
@@ -26,11 +32,10 @@ def _truncate(s, n):
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)."""
Capture popup. Direction isn't a bare label anymore — the note itself names who emailed whom."""
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})"]
lines = [f"📧 Proposed **grid note** for **{name}**"]
if item.get("email_subject"):
lines.append(f"· Subject: {item['email_subject']}")
if item.get("email_date"):
@@ -44,13 +49,7 @@ def render_card(item):
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."
return frame("\n".join(lines))
def interpret(text):
+18 -12
View File
@@ -1,5 +1,5 @@
"""Offline tests for the email-proposal review logic (card render, reply grammar, note revision).
The network/Matrix wiring lives in bot.py (live-smoke only); this covers the pure functions."""
"""Offline tests for the email-proposal review logic (card render, framing, reply grammar, note
revision). The network/Matrix wiring lives in bot.py (live-smoke only); this covers pure functions."""
import os
import sys
@@ -11,7 +11,8 @@ ITEM = {
"id": "p1", "investor_name": "Acme Capital", "direction": "received",
"from_name": "Jane Doe", "from_email": "jane@acme.com",
"email_subject": "Re: Fund III", "email_date": "2026-06-02",
"snippet": "thanks for the deck — one question on terms", "proposed_note": "✉ Received: asked about terms",
"snippet": "thanks for the deck — one question on terms",
"proposed_note": "✉ Jane Doe emailed the team: asked about terms",
}
@@ -25,18 +26,28 @@ def test_interpret_yes_no_else():
assert email_proposals.interpret("say we discussed the Q3 raise") == "revise"
def test_frame_wraps_with_rules():
out = email_proposals.frame("hello")
lines = out.split("\n")
assert lines[0] == email_proposals.RULE and lines[-1] == email_proposals.RULE
assert "hello" in out
def test_render_card_has_context_note_and_actions():
card = email_proposals.render_card(ITEM)
assert "Acme Capital" in card and "Received" in card
assert "Acme Capital" in card
assert "Jane Doe" in card
assert "Re: Fund III" in card and "2026-06-02" in card
assert "thanks for the deck" in card
assert "✉ Received: asked about terms" in card
assert "Jane Doe emailed the team: asked about terms" in card # the clear, named note
assert "yes" in card.lower() and "no" in card.lower()
def test_render_card_sent_direction():
assert "(Sent)" in email_proposals.render_card(dict(ITEM, direction="sent"))
def test_render_card_is_framed_and_dropless_direction():
card = email_proposals.render_card(ITEM)
assert card.startswith(email_proposals.RULE) and card.rstrip().endswith(email_proposals.RULE)
# the bare Sent/Received label is gone — the note itself names who emailed whom
assert "(Received)" not in card and "(Sent)" not in card
def test_render_card_truncates_long_snippet():
@@ -59,11 +70,6 @@ def test_revise_note_noop_or_empty_returns_none():
assert email_proposals.revise_note("n", "y", parse_fn=lambda *a, **k: None) is None
def test_closure_line_reflects_status():
assert "approved" in email_proposals.closure_line("approved").lower()
assert "dismiss" in email_proposals.closure_line("dismissed").lower()
if __name__ == "__main__":
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns: