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,72 @@
|
||||
"""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."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import email_proposals # noqa: E402
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
def test_interpret_yes_no_else():
|
||||
assert email_proposals.interpret("yes") == "approve"
|
||||
assert email_proposals.interpret(" Y ") == "approve"
|
||||
assert email_proposals.interpret("✅") == "approve"
|
||||
assert email_proposals.interpret("no") == "reject"
|
||||
assert email_proposals.interpret("skip") == "reject"
|
||||
# anything that isn't a clear yes/no is treated as a revision instruction
|
||||
assert email_proposals.interpret("say we discussed the Q3 raise") == "revise"
|
||||
|
||||
|
||||
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 "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 "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_truncates_long_snippet():
|
||||
card = email_proposals.render_card(dict(ITEM, snippet="x" * 1000))
|
||||
assert "…" in card and len(card) < 1000
|
||||
|
||||
|
||||
def test_revise_note_applies_model_output():
|
||||
out = email_proposals.revise_note(
|
||||
"old note", "make it about the Q3 raise",
|
||||
parse_fn=lambda prompt, system=None, max_tokens=400: {"note": "Discussed the Q3 raise."})
|
||||
assert out == "Discussed the Q3 raise."
|
||||
|
||||
|
||||
def test_revise_note_noop_or_empty_returns_none():
|
||||
# model echoes the same note unchanged -> None so the caller re-prompts (not "Updated")
|
||||
assert email_proposals.revise_note("same", "x", parse_fn=lambda *a, **k: {"note": "same"}) is None
|
||||
# model returns nothing usable -> None
|
||||
assert email_proposals.revise_note("n", "y", parse_fn=lambda *a, **k: {}) is 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:
|
||||
fn()
|
||||
print(f"ok {fn.__name__}")
|
||||
print(f"\n{len(fns)} passed")
|
||||
Reference in New Issue
Block a user