"""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\": \"\"}. 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