a10889b10b
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.
85 lines
3.9 KiB
Python
85 lines
3.9 KiB
Python
"""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
|
|
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):
|
|
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. Direction isn't a bare label anymore — the note itself names who emailed whom."""
|
|
name = item.get("investor_name") or "Unknown investor"
|
|
frm = item.get("from_name") or item.get("from_email") or "?"
|
|
lines = [f"📧 Proposed **grid note** for **{name}**"]
|
|
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 frame("\n".join(lines))
|
|
|
|
|
|
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
|