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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user