Matrix intake: main-timeline nudge, clearer messages, note text in grid

Four bot-side UX fixes surfaced by the live smoke:
- Post a brief pointer in the main timeline (a reply to the user's message)
  alongside the in-thread proposal card, so proposals aren't missed inside a
  thread. Pointer only — approvals still happen in the thread, where the note
  is visible (you can't make an informed yes/no without seeing it).
- A bare yes/no typed in the main timeline while a proposal is pending now
  gets a "reply in the thread" redirect instead of "couldn't tell what to record."
- Clearer commit confirmations: "Created a new grid entry for X" vs
  "Logged a note on X (existing grid entry)."
- Send a blank communication subject when a note is present so the grid's
  one-line note summary shows the note text, not the "(Matrix)" label
  (provenance stays in source="matrix_intake").
This commit is contained in:
Keysat
2026-06-17 17:14:08 -05:00
parent 13326cbdc6
commit aefb2aa119
7 changed files with 115 additions and 3 deletions
+11
View File
@@ -31,10 +31,18 @@ async def main():
client = AsyncClient(mx["homeserver"], mx["user_id"]) client = AsyncClient(mx["homeserver"], mx["user_id"])
client.restore_login(user_id=mx["user_id"], device_id=mx["device_id"], access_token=mx["token"]) client.restore_login(user_id=mx["user_id"], device_id=mx["device_id"], access_token=mx["token"])
say = matrix_io.make_say(client) say = matrix_io.make_say(client)
nudge = matrix_io.make_reply(client)
store = proposals.ProposalStore() store = proposals.ProposalStore()
intake_room = mx["intake_room"] intake_room = mx["intake_room"]
async def handle_intake(room_id, root, text): async def handle_intake(room_id, root, text):
# A bare yes/no/approve typed in the MAIN timeline (not inside a proposal's thread) is
# an easy slip — point the user back to the thread rather than parse it as a new intake.
action, _ = proposals.interpret_reply(text)
if action in ("approve", "reject") and store.any_pending():
await nudge(room_id, "👉 To approve, reject, or edit a proposal, open its **thread** "
"and reply there — the note is in the thread.", root)
return
try: try:
proposal = await asyncio.to_thread(parse.parse_message, text) proposal = await asyncio.to_thread(parse.parse_message, text)
except Exception as exc: # Spark/Qwen unreachable or bad response except Exception as exc: # Spark/Qwen unreachable or bad response
@@ -56,6 +64,9 @@ async def main():
pass pass
store.put(root, proposal) store.put(root, proposal)
await say(room_id, proposals.render(proposal) + hint, root) await say(room_id, proposals.render(proposal) + hint, root)
# Also drop a brief, un-threaded reply in the main timeline so the proposal isn't
# easy to miss inside a thread (the full card + yes/edit/no stay in the thread).
await nudge(room_id, proposals.summary_line(proposal), root)
async def handle_reply(room_id, root, text): async def handle_reply(room_id, root, text):
action, payload = proposals.interpret_reply(text) action, payload = proposals.interpret_reply(text)
+8 -3
View File
@@ -97,11 +97,16 @@ def build_commit_payload(proposal):
"title": proposal.get("contact_title") or "", "title": proposal.get("contact_title") or "",
} }
note = proposal.get("note") or "" note = proposal.get("note") or ""
# The CRM's grid note line uses subject-or-body for its one-line summary, so a non-empty
# subject hides the actual note text. Send a blank subject when there's a note (let the note
# itself show in the grid); fall back to a provenance label only when there's nothing to
# show. Provenance is recorded via source="matrix_intake" either way.
intent_label = "Note (Matrix)" if proposal.get("intent") == "meeting_note" else "Intake (Matrix)"
payload = { payload = {
"contact": contact, "contact": contact,
"type": "note", "type": "note",
"body": note, "body": note,
"subject": "Intake (Matrix)" if proposal.get("intent") != "meeting_note" else "Note (Matrix)", "subject": "" if note.strip() else intent_label,
"append_note": True, "append_note": True,
"source": "matrix_intake", "source": "matrix_intake",
} }
@@ -123,5 +128,5 @@ def commit(proposal):
row = (data.get("data") or {}).get("row") or {} row = (data.get("data") or {}).get("row") or {}
name = row.get("investor_name") or payload.get("investor_name") or "investor" name = row.get("investor_name") or payload.get("investor_name") or "investor"
if proposal.get("_match_id"): if proposal.get("_match_id"):
return f"Logged note to **{name}**." return f"Logged a note on **{name}** (existing grid entry)."
return f"Added **{name}** to the grid" + (" with a note." if payload.get("body") else ".") return f"Created a new grid entry for **{name}**" + (" and logged a note." if payload.get("body") else ".")
+18
View File
@@ -53,3 +53,21 @@ def make_say(client):
for chunk in split_message(text): for chunk in split_message(text):
await client.room_send(room_id, "m.room.message", thread_content(chunk, thread_root)) await client.room_send(room_id, "m.room.message", thread_content(chunk, thread_root))
return say return say
def reply_content(text, reply_to_event_id):
"""Build a plain (non-threaded) reply: shows in the MAIN timeline as a reply to
reply_to_event_id, unlike thread_content() which lands the message inside a thread."""
content = {"msgtype": "m.text", "body": text}
if reply_to_event_id:
content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_event_id}}
return content
def make_reply(client):
"""Return an async reply(room_id, text, reply_to) that posts a plain main-timeline reply —
the brief 'proposed X — see thread' nudge alongside the in-thread proposal card."""
async def reply(room_id, text, reply_to):
for chunk in split_message(text):
await client.room_send(room_id, "m.room.message", reply_content(chunk, reply_to))
return reply
+11
View File
@@ -36,6 +36,9 @@ class ProposalStore:
def has(self, thread_root): def has(self, thread_root):
return thread_root in self._pending return thread_root in self._pending
def any_pending(self):
return bool(self._pending)
def _parse_edit(text): def _parse_edit(text):
"""Parse 'edit field=value' (also 'field: value'); return (canonical_field, value) or None.""" """Parse 'edit field=value' (also 'field: value'); return (canonical_field, value) or None."""
@@ -101,3 +104,11 @@ def render(proposal):
lines.append("") lines.append("")
lines.append("Reply **yes** to commit, **edit field=value** to change a field, or **no** to discard.") lines.append("Reply **yes** to commit, **edit field=value** to change a field, or **no** to discard.")
return "\n".join(lines) return "\n".join(lines)
def summary_line(proposal):
"""A brief one-liner for the main-timeline nudge; the full card lives in the thread."""
name = proposal.get("investor_name") or proposal.get("contact_name") or "?"
if proposal.get("intent") == "meeting_note":
return f"📝 Proposed a meeting note for **{name}** — see the thread to review & approve."
return f"📇 Proposed a new investor: **{name}** — see the thread to review & approve."
+12
View File
@@ -46,6 +46,18 @@ def test_no_email_sends_empty_string_not_none():
assert out["contact"]["email"] == "" assert out["contact"]["email"] == ""
def test_subject_blank_when_note_present_else_provenance_label():
# The CRM's grid note line uses subject-or-body, so a blank subject lets the note text show.
with_note = crm_client.build_commit_payload(
{"intent": "meeting_note", "investor_name": "Acme", "note": "sent the deck", "_match_id": "r1"})
assert with_note["subject"] == ""
assert with_note["body"] == "sent the deck"
# no note text → fall back to a provenance label so the grid line isn't empty
no_note = crm_client.build_commit_payload(
{"intent": "new_investor", "investor_name": "Beta", "contact_name": "X", "note": None})
assert no_note["subject"] == "Intake (Matrix)"
if __name__ == "__main__": if __name__ == "__main__":
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns: for fn in fns:
+37
View File
@@ -0,0 +1,37 @@
"""Tests for matrix_io content builders — pure dict shaping, no network."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import matrix_io # noqa: E402
def test_reply_content_is_plain_main_timeline_reply():
c = matrix_io.reply_content("hi", "$evt1")
rel = c["m.relates_to"]
assert rel["m.in_reply_to"]["event_id"] == "$evt1"
# a plain reply must NOT carry a thread relation, or it'd land in the thread
# instead of the main timeline (the whole point of the nudge).
assert "rel_type" not in rel
def test_reply_content_without_target_has_no_relation():
c = matrix_io.reply_content("hi", None)
assert "m.relates_to" not in c
assert c["body"] == "hi"
def test_thread_content_stays_threaded():
c = matrix_io.thread_content("hi", "$root1")
rel = c["m.relates_to"]
assert rel["rel_type"] == "m.thread"
assert rel["event_id"] == "$root1"
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")
+18
View File
@@ -22,6 +22,15 @@ def test_store_put_get_pop():
assert s.pop("$missing") is None assert s.pop("$missing") is None
def test_store_any_pending():
s = proposals.ProposalStore()
assert not s.any_pending()
s.put("$r", SAMPLE)
assert s.any_pending()
s.pop("$r")
assert not s.any_pending()
def test_interpret_yes_variants(): def test_interpret_yes_variants():
for t in ("yes", "Y", "approve", " ok ", "👍"): for t in ("yes", "Y", "approve", " ok ", "👍"):
assert proposals.interpret_reply(t)[0] == "approve", t assert proposals.interpret_reply(t)[0] == "approve", t
@@ -87,6 +96,15 @@ def test_render_meeting_note_variant():
assert "meeting note" in proposals.render(note).lower() assert "meeting note" in proposals.render(note).lower()
def test_summary_line_new_vs_note():
new_line = proposals.summary_line(SAMPLE)
assert "Acme Capital" in new_line and "new investor" in new_line.lower()
note_line = proposals.summary_line(dict(SAMPLE, intent="meeting_note"))
assert "Acme Capital" in note_line and "meeting note" in note_line.lower()
# the nudge must point the user to the thread, where the actual action lives
assert "thread" in new_line.lower()
if __name__ == "__main__": if __name__ == "__main__":
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns: for fn in fns: