Add Matrix intake bot (M1+M2): typed message → approved fundraising-grid write

New backend/matrix_intake/ runs as its own process (matrix-nio isolated from the
stdlib CRM): local-Qwen parse via Spark Control → in-thread human approval
(yes/edit/no) → write through the CRM's own log-communication endpoint, tagged
source=matrix_intake. Adds read-only GET /api/intake/match (returns grid row id,
no-duplicate contract); threads provenance through handle_log_fundraising_communication.
Reviewer-passed: pop-before-commit closes a double-approve race; edit-grammar fix.
Text-only v1; business-card photo (M3) deferred (no Spark vision model).
26/26 tests green; live Matrix smoke pending deploy.
This commit is contained in:
Keysat
2026-06-17 07:51:27 -05:00
parent 172c76553b
commit 7ad0ee7624
20 changed files with 1169 additions and 7 deletions
+62 -1
View File
@@ -1215,6 +1215,45 @@ def sync_fundraising_relational(conn, grid, views, actor_user_id=None):
))
run_fundraising_automations(conn)
def find_intake_match(conn, q, email=None):
"""Find an existing fundraising-grid investor for the intake bot's new-vs-existing hint.
Scans the canonical grid blob (not the derived tables) so the returned `id` is the grid
row id that handle_log_fundraising_communication matches on — keeping the bot's proposal
consistent with where the write actually lands (no duplicate-investor risk). Matches by
normalized investor_name first (the write's own key), then falls back to a contact email.
Deleted investors are absent from the blob; graveyarded ones remain (a note on them is
still valid), so no extra filtering is needed."""
row = conn.execute("SELECT grid_json FROM fundraising_state WHERE id = 'main'").fetchone()
if not row or not row['grid_json']:
return None
try:
grid = json.loads(row['grid_json'])
except Exception:
return None
rows = grid.get('rows', []) if isinstance(grid, dict) else []
wanted_name = _normalize_text(q) if q else ''
wanted_email = (email or '').strip().lower()
email_hit = None
for r in rows:
if not isinstance(r, dict):
continue
rid = str(r.get('id') or '').strip()
if not rid:
continue
name = str(r.get('investor_name') or '').strip()
if wanted_name and _normalize_text(name) == wanted_name:
return {"id": rid, "investor_name": name, "matched_on": "name"}
if wanted_email and email_hit is None:
contacts = r.get('contacts')
if isinstance(contacts, list):
for c in contacts:
if isinstance(c, dict) and str(c.get('email') or '').strip().lower() == wanted_email:
email_hit = {"id": rid, "investor_name": name, "matched_on": "email"}
break
return email_hit
def ensure_fundraising_state_row(conn):
existing = conn.execute("SELECT * FROM fundraising_state WHERE id = 'main'").fetchone()
if not existing:
@@ -1829,6 +1868,10 @@ class CRMHandler(BaseHTTPRequestHandler):
if path == '/api/outreach/radar':
return self.handle_outreach_radar(user)
# Matrix intake bot — new-vs-existing lookup for its in-thread proposal
if path == '/api/intake/match':
return self.handle_intake_match(user, params)
# Users
if path == '/api/users':
return self.handle_list_users(user)
@@ -2747,6 +2790,9 @@ class CRMHandler(BaseHTTPRequestHandler):
contact_in = body.get('contact')
append_note = bool(body.get('append_note', True))
create_investor_if_missing = bool(body.get('create_investor_if_missing', False))
# Provenance: where this logged communication originated (grid UI vs the Matrix
# intake bot). Default preserves prior behavior; callers may override.
comm_source = (str(body.get('source') or 'fundraising_grid').strip() or 'fundraising_grid')[:64]
if not row_id and not investor_name_in:
return self.send_error_json("row_id or investor_name is required")
@@ -2863,7 +2909,7 @@ class CRMHandler(BaseHTTPRequestHandler):
user['user_id']
))
conn.execute("UPDATE contacts SET updated_at = ? WHERE id = ?", (now(), contact_id))
log_audit(conn, user['user_id'], 'communication', comm_id, 'create', {"source": "fundraising_grid"})
log_audit(conn, user['user_id'], 'communication', comm_id, 'create', {"source": comm_source})
iso_day = now()[:10]
target_row['last_communication_date'] = iso_day
@@ -2900,6 +2946,21 @@ class CRMHandler(BaseHTTPRequestHandler):
conn.close()
return self.send_json({"data": {"communication": comm, "row": target_row, "version": next_version}}, 201)
def handle_intake_match(self, user, params):
"""Read-only: does an investor matching this intake already exist? Used by the
Matrix intake bot to label its in-thread proposal new-vs-existing. Returns the
grid row id so an approved note lands on exactly that investor."""
q = str(params.get('q') or '').strip()
email = str(params.get('email') or '').strip()
if not q and not email:
return self.send_error_json("q or email is required")
conn = get_db()
try:
match = find_intake_match(conn, q, email)
finally:
conn.close()
return self.send_json({"data": {"match": match}})
def handle_update_communication(self, user, comm_id, body):
conn = get_db()
existing = conn.execute("SELECT id FROM communications WHERE id = ?", (comm_id,)).fetchone()