Files
ten31-database/docs/guides/matrix-intake.md
T
Keysat ee6a4e52d2 Handoff: email-proposal Matrix review live (v0.1.0:91); bot role + whole-thread redaction
Durable updates after the email-proposal review session:
- AGENTS.md: roles admin/member -> admin/member/bot; add a Conventions entry on
  the bot role and the reach(role)-vs-autonomy(approval gate) principle.
- matrix-intake guide: rewrite the bridge section to final behavior (redact_thread
  whole-thread redaction, the Element 'show deleted' client-setting dependency for
  full clearing, the redact_resolved.py backfill tool, deploy gotchas).
- Current state rewritten lean (14->8 bullets); test count 27->30.
2026-06-18 12:51:46 -05:00

254 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
paths:
- backend/matrix_intake/**
---
# Matrix intake bot
Read this before editing `backend/matrix_intake/`. The bot turns a typed message in a
dedicated Matrix room into a proposed fundraising-grid add/edit, gated on **in-thread human
approval** before any write. Phase status: **M1 + M2 deployed & live** (text intake + approval + write; bot on the Spark,
CRM endpoints on the box at **v0.1.0:86**; live-smoked 2026-06-17). **M3 (business-card photo)
deferred** — Spark Control has no vision model yet.
**Post-deploy UX pass — DEPLOYED & LIVE 2026-06-17:** fuzzy investor matching (server-side,
**v0.1.0:86**, installed to the box — `candidates` endpoint verified live) + in-thread
disambiguation and conversational natural-language edits (bot-side, pulled + restarted on the
Spark). See *Fuzzy matching* below. Tests green (27/27 backend + the offline bot suite); the
**Matrix live-smoke** of the disambiguation grammar and the Qwen `revise` leg is still pending.
## What it is (and isn't)
- A **separate process**, not part of the CRM. Its only third-party dep, `matrix-nio`, lives
in `backend/matrix_intake/requirements.txt` and **must never** be added to the stdlib CRM
(`backend/server.py`). Runs on the Spark (placement per `standards/guides/placement.md`).
- It **drafts; a human approves.** Nothing is written autonomously — every CRM write follows a
`yes` reply in the proposal thread. This is exempt from "agents draft, humans send" the same
way the digest is: it's internal data entry to our own CRM, not outward LP contact.
- It is **not** a parallel write path. It reuses the CRM's own canonical endpoint
`POST /api/fundraising/log-communication` (create-if-missing + contact upsert + note +
relational sync + audit) for both new-investor and existing-note cases. Don't reimplement
grid mutation in the bot.
## Flow
1. Top-level message in the intake room → `parse.parse_message` → local **Qwen via Spark
Control** (`spark.py` reuses `backend/ingest/llm.py`; temp 0, JSON only) extracts
`{intent, investor_name, contact_name, contact_email, contact_title, note}`. The original
message text is stashed on the proposal as `_source_text` (needed later for `revise`'s
email-integrity check). The system prompt is built by `parse.build_system(roster)`, which —
when a **team roster** is configured (`INTAKE_TEAM_ROSTER`, see *Config*) — appends an
**outreach frame**: those names are our own team members *doing* the outreach, so a teammate's
name is never extracted as the investor/contact and the *other* party is the prospect. Fixes
the live-smoke gripe where *"jonathan is chatting with wyoming"* picked the teammate, not the
prospect. `revise` gets the same framing. Roster unset → prior behavior (no frame).
2. `crm_client.match` (`GET /api/intake/match`) resolves new-vs-existing. It returns **both** an
exact `match` (returns the **grid row id** so an approved note lands on exactly that investor,
no duplicate) **and**, when there's no exact match, a ranked list of fuzzy `candidates` (see
*Fuzzy matching* below).
3. Three outcomes drive what gets posted, all **in a thread** rooted at the user's message, plus a
brief **main-timeline nudge** (a plain reply — `matrix_io.make_reply`) so it isn't missed:
- **Exact match** → auto-attach: proposal flips to `meeting_note` with `_match_id` set, rendered
as the normal approval card.
- **Fuzzy candidates, no exact** → a **disambiguation** card (`proposals.render_disambiguation`):
the proposal is held at `_stage="disambiguate"` with `_candidates`, and the human must pick a
**number** / `new` / `no` before it becomes an approval-stage proposal.
- **Neither** → the new-investor approval card.
The nudge is a **pointer only, not a reply target** — you need the thread to act. The pending
proposal is held in memory keyed by the thread root (`proposals.ProposalStore`).
4. User replies **in the thread**. `handle_reply` branches on `_stage`:
- **disambiguate** (`handle_disambiguation`): a number attaches to that candidate (→ `meeting_note`
+ `_match_id`, re-rendered for approval); `new` proceeds as a new investor; `no` discards.
- **approval**: `yes` commits; `no` discards; `edit field=value` is the deterministic fast-path
edit; **anything else is treated as a natural-language revision**`parse.revise` sends
`{current proposal + instruction}` back through local Qwen and re-renders the revised card (a
no-op revision is detected via `proposals.same_fields` and re-prompts instead of saying
"Updated"). On `yes`, `crm_client.commit` POSTs to `log-communication` tagged
`source="matrix_intake"` (provenance in the audit log).
A bare `yes`/`no` typed **top-level** (not in the thread) while a proposal is pending gets a
"reply in the thread" redirect (`store.any_pending()` guard in `handle_intake`), not a
misparsed new intake.
## Fuzzy matching (server-side, ships in the s9pk)
`GET /api/intake/match` returns `{match, candidates}`. `find_intake_match` is unchanged —
**exact-after-normalization**, and an exact match still auto-attaches without disambiguation.
`find_intake_candidates` (new) is the fuzzy layer, **deterministic, no LLM**: it scans the same
canonical grid blob and scores each row by `max(`name similarity`, `email near-match`)`, keeping
rows ≥ `min_score` (0.62), ranked, capped at 5:
- **Name** (`_name_similarity`): max of stdlib `difflib` sequence ratio (near-spellings —
"Charlie"/"Charles") and token-set Jaccard (word-order). **Legal-entity suffixes**
(LLC/LP/Inc/… via `_strip_legal_suffix`) are stripped first, so "Acme Capital" ~ "Acme Capital
LLC" scores 1.0 (a near-certain duplicate `find_intake_match` misses because it compares the
full string) — and is surfaced as a candidate, **never auto-attached** (the human still confirms).
- **Email** (`_email_edit_distance`): Levenshtein ≤ 2 against each contact email (dist 1→0.9,
2→0.8). Distance 0 is an exact email — that's `find_intake_match`'s job, skipped here.
- **Recall-favoring by design:** a shared common name-word ("… Capital") can lift an unrelated firm
into the 0.60.8 band. Acceptable — it's a *ranked, human-confirmed* shortlist, and the cost of an
occasional stray suggestion is far lower than missing a real near-duplicate. **Semantic pruning of
the shortlist (the "Charlie really is Charles" judgment) is a deferred LLM-judge re-rank** — fed
only the shortlist, never the whole LP list — intentionally NOT built in this pass, because the
deterministic filter already surfaces every duplicate the human then resolves.
## Email-activity proposal review (the CRM→Matrix bridge, v0.1.0:89)
A second, separate flow runs alongside intake: reviewing the **proposed grid notes** the CRM
drafts from newly-matched email (`server.propose_email_activity_notes`, surfaced on the web Email
Capture panel). The bot lets the team approve/dismiss/edit those on mobile, kept **in sync** with
the web panel. The CRM (box, stdlib, no matrix-nio) can't post to Matrix, so the bot **pulls**.
- **Dedicated room** (`MATRIX_EMAIL_REVIEW_ROOM`, see *Config*) — separate from the intake room
so high-volume email proposals don't drown the conversational intake. Unset → the whole leg is
off (the bot just does intake). The bot must be a **member** of this room.
- **Poll loop** (`bot.poll_email_proposals`, every `EMAIL_POLL_SEC`=20s) calls `crm_client.
list_email_proposals` → `GET /api/intake/email-proposals`, which returns three work-lists:
- **to_post** — pending, not yet posted → the bot posts a review card (metadata + a short email
**snippet** + the drafted note; the full body is the web popup's job, kept compact for mobile),
then records the thread-root event id via `POST .../{id}/matrix {event_id}`.
- **open** — pending, posted, not closed → the bot rebuilds its `event_id → proposal` routing map
from these on **every poll**, so replies still route **after a bot restart** (unlike intake's
in-memory-only store — the state lives CRM-side in `email_proposal_matrix`).
- **to_close** — decided on the **web** while a thread was open → the bot clears it (see redaction
below) and `POST .../{id}/matrix {closed:true}`.
- **In-thread replies** (`bot.handle_email_reply`, `email_proposals.interpret`): `yes` →
`POST .../{id}/decide {decision:"approve", note}` (appends the note to the grid, source='matrix',
closes the thread atomically); `no` → dismiss; **anything else → NL revision of the note** via
local Qwen (`email_proposals.revise_note`, no Claude/scrub) — re-rendered for re-approval, so the
draft→approve gate holds. A no-op/empty revision re-prompts instead of saying "Updated".
- **Card formatting:** `email_proposals.render_card` frames every card/reply with a `RULE` dash line
top and bottom (`frame()`) so threads don't bleed together on mobile, and the note **names who
emailed whom** ("{teammate} emailed {investor}" / "{sender} emailed the team") rather than a bare
Sent/Received — the wording is built server-side in `propose_email_activity_notes`.
- **Decided threads are redacted, not just closed.** On any conclusive decision (Matrix or web) the
bot calls `redact_thread(root)`: redact the card, then scan recent history (`room_messages`,
`MessageDirection.back`) for that root's `m.thread` replies and redact those too — so a resolved
thread clears from the **threads view**, not only the timeline. **No confirmation is posted on
success** (the thread vanishing is the ack; a confirmation reply would keep the thread alive).
- **Needs the bot to hold a `redact`/moderator power level** in the review room — required to
redact the *human's* yes/no reply (its own card needs no power). Without it, the reply lingers.
- **Full clearing depends on a client setting:** redaction removes the events, but Element shows a
"Message deleted" placeholder by default — turn OFF "show removed/deleted messages" in Element and
both the main chat and the threads view clear completely. (Verified the intended UX 2026-06-18.)
- **One-time backfill:** `backend/matrix_intake/redact_resolved.py` (dry-run default; `--apply`)
clears threads decided *before* this shipped (already `closed`, so the poll's to_close never
touches them). Run on the Spark: `docker compose run --rm intake python -u
backend/matrix_intake/redact_resolved.py [--apply]`. It keeps cards still pending (CRM `open`)
and redacts every other card + its replies.
- **Two surfaces, one source of truth.** Decide on the web → the bot redacts + closes the thread;
decide on Matrix → the web panel polls `/api/activity/proposals` (~25s) and the card clears.
`email_proposal_matrix` (1:1 side row, migration `0003`) carries `event_id`/`posted_at`/`closed_at`;
a matrix decision sets `closed_at` in the same txn so it's never re-processed via `to_close`.
- **Pure logic is `email_proposals.py`** (card render, reply grammar, note revision) — unit-tested
offline in `test_email_proposals.py`; the async poll/post wiring is in `bot.py` (live-smoke only).
- **Known minors (low-likelihood, ~5-person team):** if the CRM is unreachable *between* posting a
card and recording its event id, the next poll re-posts a duplicate card (the orphan's replies
won't route — re-send/decide the recorded one). A mid-revise bot restart loses the in-memory
revised note (rebuilt from `open` = the original `proposed_note`; still a valid proposal).
## Rules / gotchas
- **Module-name collision:** the intake config module is `settings.py`, **not** `config.py`,
because `backend/ingest/config.py` is imported (as bare `config`) through `spark → llm`. A
second `config` module would shadow it in `sys.modules` and break `llm` (`CHAT_MODEL`).
Keep intake module names from colliding with ingest's (`config`, `http_util`, `llm`).
- **Email integrity:** `parse.normalize` only keeps an address that literally appears in the
source message — the model must never mint one (a wrong email is worse than none). It takes
the **first** address in the text, so a two-person message ("Alice a@x.com and Bob b@y.com")
could attach the wrong one; the human sees it in the proposal and can `edit email=…` before
approving. Cross-referencing multiple addresses to the named contact is a deliberate non-goal
for v1.
- **Conversational revise keeps the email rule:** `parse.revise` re-runs a free-form correction
through Qwen but **never trusts the model's email field**. A changed address is accepted only if
it literally appears in the *instruction text* (searched first), else the existing
integrity-checked address is kept (`_apply_revision`). The model can edit name/contact/title/note
freely but cannot mint an email. A revision that nulls both investor and contact is rejected (the
proposal can't be emptied to something unactionable). Revise edits fields on the current proposal;
it does **not** re-run the matcher if you rename the firm mid-thread (a known v1 limit — the human
still approves).
- **Deploy is split across two surfaces** (mind which one carries a change): the fuzzy
**`candidates`** come from `server.py` → ship in the **s9pk** (build + install, version-bumped).
The bot's **disambiguation flow + `revise`** live in `backend/matrix_intake/` → ship on the
**Spark** via `git pull` + restart. A bot restart alone won't deliver `candidates` (the box would
return an empty list and the bot just proposes new — safe, but no fuzzy surfacing until the s9pk
is installed). Same lesson as the v83→v84 `/api/intake/match` 404.
- **Double-approve guard:** `handle_reply` pops the pending proposal from the store *before*
awaiting the commit, so a second `yes` arriving mid-write is a no-op (asyncio is cooperative;
the pop is atomic w.r.t. other events). On commit failure the proposal is restored for retry.
*Known minor:* in the **disambiguate** stage the pick re-stores an approval-stage proposal
before its `await say`, so a rapidly-repeated `1` can have the second one fall through to the
NL-revise path (a wasted Spark round-trip that re-prompts) — harmless, nothing commits, not
guarded (low likelihood on a ~5-person team).
- **Local-only parse:** intake text is real LP substance but goes ONLY to local Qwen via Spark
Control, never Claude — so no scrub boundary applies (same basis as the digest). Never call a
Spark directly; always go through `SPARK_CONTROL_URL`.
- **Auth:** the CRM has no service-key path; the bot logs in as a dedicated CRM user
(`CRM_BOT_USERNAME`/`CRM_BOT_PASSWORD`) → Bearer JWT, re-login once on 401.
- **Tests** are offline: `test_parse.py` / `test_proposals.py` / `test_crm_client.py` stub the
network; `backend/test_intake_endpoints.py` boots the real server against a temp DB and
covers `/api/intake/match` + the create→match (no-duplicate) contract + provenance. A **live
Matrix smoke** needs creds + `matrix-nio` installed on the Spark — it can't run in CI.
- **Grid note line:** the bot sends a **blank `subject`** when there's a note so the CRM's
one-line note summary shows the note text (the CRM renders subject-or-body); a provenance
label is sent only when there's no note. v0.1.0:85 also dropped the redundant `[note]` type
tag from that server-side line (informative types like `[call]` keep theirs).
## Deployment & ops
- **Runs on the Spark as a docker container** (`matrix-intake`), since 2026-06-17 — SSH alias
`modelo32`, host `spark-32d0`, repo clone at `/home/modelo/ten31-database`. Defined by
`docker-compose.yml` at the repo root + `backend/matrix_intake/Dockerfile`. The image bundles
`backend/matrix_intake/` **and** `backend/ingest/` (spark.py reaches into the latter's stdlib
Spark client via sys.path); `.env` is mounted read-only at `/app/.env`. `network_mode: host`
so it reaches Matrix, the CRM, and Spark Control. Startup logs `listening as … in room …`.
- **Survives a Spark reboot** via `restart: unless-stopped` — the durability fix that retired the
old bare `nohup` launch. (The previous nohup method + `/tmp/intake-bot.pid` are gone.)
- **Deploy / update after a `git pull`:** `cd /home/modelo/ten31-database && git pull && docker
compose up -d --build`. **Logs:** `docker logs -f matrix-intake`. **Restart:**
`docker restart matrix-intake`. **Stop:** `docker compose down`. A restart still **drops
in-memory pending proposals** (re-send to recover).
- **Not yet a spark-control dashboard card.** The container is managed via `docker`/SSH today; a
managed card (Update/Restart/Stop/Logs tile, like `matrix-bridge`) is a separate spark-control
task — see `docs/handoffs/add-intake-bot-to-spark-control.md`.
- **Gotcha — the repo-root `.dockerignore` is SHARED** with the s9pk build (`start9/0.4/Dockerfile`,
same repo-root context). Don't add bot-only exclusions (e.g. `frontend/`, `docs/`) to it — you'd
break the CRM image build, which needs them. It already excludes the security-critical bits
(`data/`, `.env`), which is all the bot build needs.
- **Server-side endpoints ship in the s9pk, not the bot.** `GET /api/intake/match` and the
`source` provenance on `log-communication` live in `backend/server.py`, so they reach the box
only via an **s9pk build + install** — a bot restart won't deliver them. (Missed in v83: the
box 404'd `/api/intake/match` until **v0.1.0:84**.) **Same split for the email-review bridge
(v0.1.0:89):** the `/api/intake/email-proposals*` endpoints + the `email_proposal_matrix`
migration (`0003`) + the `bot` role ship in the **s9pk**; the poll loop + review-room handling
ship on the **Spark** (git pull + restart). A bot restart against a pre-v89 box returns nothing
useful (404/empty), so install the s9pk first, then set the bot user's role + the review room.
- **`CRM_API_BASE` is the box over the LAN, not localhost** (bot on the Spark, CRM on the box).
`https://immense-voyage.local` (443) is the **StartOS dashboard**, not the CRM — the CRM has
its own interface address (the URL you open in a browser); container port 8080 isn't
LAN-reachable.
## Config
All in `.env` (names in `.env.example`): `MATRIX_HOMESERVER`, `MATRIX_USER`,
`MATRIX_ACCESS_TOKEN`, `MATRIX_DEVICE_ID`, `MATRIX_INTAKE_ROOM`; `CRM_API_BASE`,
`CRM_BOT_USERNAME`, `CRM_BOT_PASSWORD`, `CRM_API_VERIFY_TLS`. Spark settings are inherited from
the ingest client (`SPARK_CONTROL_URL`, `CRM_CHAT_MODEL`).
- **`MATRIX_EMAIL_REVIEW_ROOM`** (optional) — the dedicated room for the email-activity proposal
review leg (above). Unset/empty disables that leg entirely (the bot does intake only). The bot
must be invited to + joined in this room. Read once at startup, like the room/roster.
- **Bot CRM user needs role `bot`.** The email-proposal endpoints (`/api/intake/email-proposals*`)
are gated to `require_bot_or_admin` because they expose LP email content (the proposals are
admin-only on the web). The `bot` role is **authenticated-but-not-admin** — it passes these
endpoints + the auth-only ones the bot already uses (login, `/api/intake/match`,
`log-communication`), but **never** `require_admin` (no user-management/settings/security reach).
One-time flip of the existing service account (kept out of the invite UI's member/admin dropdown
— provision deliberately): an admin `PATCH /api/users/<id> {"role":"bot"}`, or on the box
`UPDATE users SET role='bot' WHERE username='<CRM_BOT_USERNAME>';`. Role controls *reach*; the
draft→approve gate (a human still approves every write) controls *autonomy* — two separate axes.
- **`INTAKE_TEAM_ROSTER`** (optional, comma-separated) — Ten31 team-member names that frame the
parse (see *Flow* step 1). Use the **first names as actually typed in the room** ("Grant,
Jonathan, …"). Read once at startup by `settings.team_roster()`, so **a roster change needs a
bot restart**. It lives only in the Spark's `.env` (bot-side) — no s9pk change. Empty/unset
disables the framing.