Files
ten31-database/backend/matrix_intake/proposals.py
T
Keysat aefb2aa119 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").
2026-06-17 17:14:08 -05:00

115 lines
4.2 KiB
Python

"""Pending-proposal store + the in-thread approval state machine.
The one piece of state in the bot: a proposal awaiting a human's yes/edit/no, keyed by the
Matrix thread root (the bot's proposal lives in a thread rooted at the user's message, and
the user replies inside that thread). In-memory and ephemeral by design — a restart drops
pending proposals (the user just re-sends), matching matrix-bridge's stateless-by-default
ethos. Nothing here writes to the CRM; the bot calls the CRM client only after `approve`.
"""
# field aliases accepted in `edit <field>=<value>`
_EDIT_ALIASES = {
"name": "investor_name", "investor": "investor_name", "firm": "investor_name", "org": "investor_name",
"contact": "contact_name", "person": "contact_name",
"email": "contact_email",
"title": "contact_title", "role": "contact_title",
"note": "note",
}
_YES = {"yes", "y", "approve", "approved", "ok", "confirm", "go", "👍", ""}
_NO = {"no", "n", "cancel", "discard", "reject", "stop", "👎", ""}
class ProposalStore:
def __init__(self):
self._pending = {} # thread_root -> proposal dict
def put(self, thread_root, proposal):
self._pending[thread_root] = proposal
def get(self, thread_root):
return self._pending.get(thread_root)
def pop(self, thread_root):
return self._pending.pop(thread_root, None)
def has(self, thread_root):
return thread_root in self._pending
def any_pending(self):
return bool(self._pending)
def _parse_edit(text):
"""Parse 'edit field=value' (also 'field: value'); return (canonical_field, value) or None."""
body = text.strip()
if body.lower().startswith("edit "):
body = body[5:].strip()
for sep in ("=", ":"):
if sep in body:
field, value = body.split(sep, 1)
field = field.strip().lower()
canon = _EDIT_ALIASES.get(field)
value = value.strip()
if canon and value:
return canon, value
# Not a known field on this separator — try the next one rather than bail,
# so e.g. "note: see deck=v2" still parses (split on ':' not the inner '=').
continue
return None
def interpret_reply(text):
"""Classify a threaded reply to a pending proposal.
Returns one of:
("approve", None) | ("reject", None) | ("edit", (field, value)) | ("unknown", None)
"""
t = (text or "").strip()
low = t.lower()
if low in _YES:
return ("approve", None)
if low in _NO:
return ("reject", None)
edit = _parse_edit(t)
if edit:
return ("edit", edit)
return ("unknown", None)
def apply_edit(proposal, field, value):
"""Return a copy of the proposal with one field changed."""
updated = dict(proposal)
updated[field] = value
return updated
def render(proposal):
"""Render a proposal as the in-thread message a human approves."""
if proposal.get("intent") == "meeting_note":
head = f"📝 Proposed **meeting note** for **{proposal.get('investor_name') or proposal.get('contact_name') or '?'}**"
else:
head = f"📇 Proposed **new investor**: **{proposal.get('investor_name') or proposal.get('contact_name') or '?'}**"
lines = [head]
fields = [
("Investor", proposal.get("investor_name")),
("Contact", proposal.get("contact_name")),
("Email", proposal.get("contact_email")),
("Title", proposal.get("contact_title")),
("Note", proposal.get("note")),
]
for label, val in fields:
if val:
lines.append(f"· {label}: {val}")
lines.append("")
lines.append("Reply **yes** to commit, **edit field=value** to change a field, or **no** to discard.")
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."