Add Matrix NL-query Q&A surface (W2 step 5)

Read-only natural-language query over the curated nl_query endpoint, answered
in-thread. Two entry points (room-per-purpose model): a dedicated Q&A room
(MATRIX_QUERY_ROOM) where every top-level message is a question, plus the
?/@bot trigger in the intake room as a cross-room convenience. Both routes hit
the same handle_query -> crm_client.nl_query -> POST /api/query/nl; translation
runs on the box's local model, nothing leaves the box, and there is no write
path so no approval gate applies.

Pure logic (trigger parsing, answer rendering) in query.py with offline tests;
async room wiring in bot.py (live-smoke only, per the bot's convention).

Bot-side only, ships on the Spark via git pull + restart. Depends on the
box-side /api/query/nl endpoint, which lands with the v93 s9pk (reminders + W2):
until v93 is installed the Q&A surface 404s, so the bot deploy is staged to
follow that install.
This commit is contained in:
Keysat
2026-06-18 19:46:54 -05:00
parent 6c29c22601
commit 68106d7a5a
10 changed files with 458 additions and 5 deletions
+44
View File
@@ -145,6 +145,43 @@ the web panel. The CRM (box, stdlib, no matrix-nio) can't post to Matrix, so the
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).
## NL query — read-only Q&A (W2 step 5)
A read-only "ask the database in plain English" flow, answered in-thread. **No write path, no
approval gate** — it only runs the curated, parameterized queries behind the CRM's NL-query
endpoint, so it's exempt from the draft→approve dance the write flows need. Two entry points,
same `handle_query` → `crm_client.nl_query` underneath:
- **Dedicated Q&A room** (`MATRIX_QUERY_ROOM`, recommended) — **every** top-level message is a
question; no trigger needed. This is the room-per-purpose model (intake / email-review / Q&A,
with a future reminders-push room): the trigger grammar below exists *only* to disambiguate
question-vs-note when Q&A shares the intake room, which a dedicated room makes unnecessary. The
simplest room of the three — read-only, no approval, no redaction, **no special power level**.
- **`@bot`/`?` trigger in the intake room** (cross-room convenience) — fire a quick question
without switching rooms. `query.parse_trigger` (pure/tested) matches a top-level message starting
with `?`, `@bot`, `/ask`, `/query`, or `/q`. The trigger is **required** there, so plain intake
notes still route to intake. A bare leading `ask` is deliberately **not** a trigger — it would
collide with notes like *"Ask Jane to send the deck"*. A bare trigger (`@bot` alone) posts help.
- **One endpoint call** (`crm_client.nl_query` → `POST /api/query/nl {question, source:"matrix"}`):
translation runs on the box's **local Qwen** (nothing leaves the box; **no Claude, no scrub** —
same basis as intake) and only the fixed `nl_query` catalog can run. The bot is a thin client —
see `docs/guides/nl-query.md` for the trust model.
- **Rendering** (`query.render_answer`, pure/tested): a deterministic Matrix-markdown answer
(summary + interpreted intent + compact rows, money/date formatting, nested contacts/commitments
for `investor_lookup`). **Results never go back to any model.** Mobile soft-cap `MAX_DISPLAY_ROWS`
(30) with an explicit "+N more" note — never a silent cut.
- **Status passthrough:** the endpoint returns its structured body on a hit *and* on the soft
503 (model down) / 500 (query fault) codes, so `nl_query` hands those to the renderer; only an
auth/shape failure (403/400) raises → a brief ⚠️ in-thread.
- **Ships on the Spark** (bot-side, `query.py` + `crm_client.nl_query` + `bot.py` wiring) via
`git pull` + restart — **no s9pk for the bot**. **But it depends on the box-side `/api/query/nl`
endpoint**, which ships in the s9pk and is **not live until v93** (reminders + W2). Deploying the
bot before that = a Q&A room that 404s every question (same server-side/bot split as the v83→v84
`/api/intake/match` 404). **Sequence: install v93 first, then** set `MATRIX_QUERY_ROOM` + invite
the bot + restart. Pure logic tested in `test_query.py` (+ `nl_query` cases in
`test_crm_client.py`); the in-room smoke (a bare message in the Q&A room, or `?…` in the intake
room) is live-only.
## Rules / gotchas
- **Module-name collision:** the intake config module is `settings.py`, **not** `config.py`,
@@ -236,6 +273,13 @@ 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.
- **`MATRIX_QUERY_ROOM`** (optional) — the dedicated read-only Q&A room (NL query section above).
In it, every top-level message is answered as a query (no `?`/`@bot` trigger). Unset/empty just
means no dedicated room — questions still work via the trigger in the intake room. The bot must be
invited to + joined in this room (`settings.query_room()`, read once at startup). No poll loop and
no power level needed (read-only). Needs the server side in the s9pk (`POST /api/query/nl`, ≥ the
W2 backend) and the bot's CRM user set to role `bot`.
- **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