Files
ten31-database/backend/matrix_intake/query.py
T
Keysat 68106d7a5a 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.
2026-06-18 19:46:54 -05:00

190 lines
7.3 KiB
Python
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.
"""NL-query Matrix surface (W2 step 5) — turn an '@bot <question>' message into a read-only
answer from the CRM's curated NL-query endpoint, and render that answer for the chat room.
This module is PURE (no network, no matrix-nio) so it's unit-testable offline; the async wiring
(call the endpoint, post in a thread) lives in bot.py. The endpoint does the real work:
translation runs on the box's LOCAL model (nothing leaves the box) and only the curated,
parameterized queries can run — there is no write path here, so no approval gate applies.
Trigger: a top-level message starting with '?' / '@bot' / '/ask' (see parse_trigger). We
deliberately do NOT accept a bare leading 'ask', which would collide with intake notes like
"Ask Jane to send the Q3 deck".
"""
# Markers a human wouldn't start an intake note with. '?' is handled separately (single char).
QUERY_PREFIXES = ("@bot", "/ask", "/query", "/q")
# Soft cap on rows rendered into a single chat answer. The endpoint already caps the SQL result
# (server MAX_ROWS), but 500 rows is unreadable on mobile — show the first N and say how many
# more there are (never a silent cut). Refine the question or use the web Ask box for the rest.
MAX_DISPLAY_ROWS = 30
# Column-name hints used only for nicer formatting (money / dates). Cosmetic — never affects
# what's queried (that's fixed in intents.py).
_MONEY_HINTS = ("amount", "invested", "total", "expected", "committed")
# 0/1 flag columns: suppress when 0 (noise), show a label when 1.
_FLAG_LABELS = {"graveyard": "retired", "overdue": "⚠️ overdue"}
def parse_trigger(text):
"""If `text` is addressed to the query bot, return the question (the remainder after the
trigger, possibly an empty string when the trigger is bare). Return None if it isn't a query,
so the caller routes it to intake instead."""
s = (text or "").strip()
if not s:
return None
if s[0] == "?":
return s[1:].strip()
low = s.lower()
for p in QUERY_PREFIXES:
if low.startswith(p):
rest = s[len(p):]
# Require a separator so '/asking …' isn't read as the '/ask' trigger.
if rest == "" or rest[0] in " \t\n:,":
return rest.lstrip(" \t\n:,").strip()
return None
def _examples():
return ("Try things like:\n"
"• `?which investors haven't we contacted in 90 days?`\n"
"• `?top 10 investors by committed capital`\n"
"• `?when did we last reach out to Acme Capital?`\n"
"• `?how many emails has Grant sent this month?`")
HELP = ("💬 Ask me about the fundraising database — start your message with `?` (or `@bot`).\n\n"
+ _examples())
def _is_money_col(name):
n = name.lower()
return any(h in n for h in _MONEY_HINTS)
def _fmt_value(col, val):
"""Format one scalar cell for chat: dates -> YYYY-MM-DD, money columns -> $1,234, else str."""
if val is None:
return ""
name = col.lower()
if name.endswith("_at") or name.endswith("date"):
return str(val)[:10]
if isinstance(val, (int, float)) and _is_money_col(col):
return f"${val:,.0f}"
return str(val)
def _render_contacts(contacts):
"""investor_lookup's nested contact dicts -> 'Name <email> (title · city, state)' lines."""
out = []
for c in contacts:
bits = c.get("full_name") or "?"
if c.get("email"):
bits += f" <{c['email']}>"
loc = ", ".join(x for x in (c.get("city"), c.get("state"), c.get("country")) if x)
extra = " · ".join(x for x in (c.get("title"), loc) if x)
if extra:
bits += f" ({extra})"
out.append(bits)
return out
def _render_commitments(commitments):
"""investor_lookup's nested per-fund commitments -> 'Fund: $amount' lines."""
out = []
for c in commitments:
fund = c.get("fund_name") or "?"
amt = c.get("amount")
out.append(f"{fund}: ${amt:,.0f}" if isinstance(amt, (int, float)) else f"{fund}: {amt}")
return out
def _render_row(i, row, columns):
cols = columns or list(row.keys())
lead = None
scalars = []
sublines = []
for col in cols:
val = row.get(col)
if isinstance(val, list):
if not val:
continue
if col == "contacts":
sublines += [f" {x}" for x in _render_contacts(val)]
elif col == "commitments":
sublines += [f" {x}" for x in _render_commitments(val)]
else: # generic list-of-dicts fallback (no intent uses this yet)
sublines += [f" {', '.join(f'{k}={v}' for k, v in d.items())}"
for d in val if isinstance(d, dict)]
continue
if col in _FLAG_LABELS:
if val:
scalars.append(_FLAG_LABELS[col])
continue
s = _fmt_value(col, val)
if s == "":
continue
if lead is None: # first non-empty column is the bold identifier for the row
lead = s
else:
scalars.append(f"{col}: {s}")
head = f"{i}. **{lead}**" if lead else f"{i}."
if scalars:
head += "" + " · ".join(scalars)
return "\n".join([head] + sublines)
def _render_interpretation(intent, slots):
if not intent:
return ""
if slots:
return f"read as: {intent} ({', '.join(f'{k}={v}' for k, v in slots.items())})"
return f"read as: {intent}"
def _render_error(err, result):
detail = (result.get("detail") or "").strip()
if err == "no_match":
return "🤷 I couldn't map that to one of my saved queries.\n\n" + _examples()
if err == "model_unavailable":
return "⚠️ The local query model is unreachable right now — try again in a moment."
if err == "query_failed":
return f"⚠️ That query failed to run{(': ' + detail) if detail else ''}."
# unknown_intent / bad_slot / anything unexpected
return (f"⚠️ I couldn't run that ({err}){(': ' + detail) if detail else ''}.\n\n" + _examples())
def render_answer(result):
"""Render the NL-query endpoint's structured result into a Matrix markdown answer.
`result` is the endpoint body: a hit {intent, slots, columns, rows, summary, truncated} or
an error {error, detail}. Results never go back to any model — this is a deterministic format."""
result = result or {}
err = result.get("error")
if err:
return _render_error(err, result)
summary = (result.get("summary") or "").strip()
rows = result.get("rows") or []
columns = result.get("columns") or []
header = f"📊 {summary}" if summary else "📊 Done."
interp = _render_interpretation(result.get("intent"), result.get("slots") or {})
if interp:
header += f"\n_{interp}_"
if not rows:
return header + "\n\n(no matching records)"
shown = rows[:MAX_DISPLAY_ROWS]
blocks = [_render_row(i + 1, r, columns) for i, r in enumerate(shown)]
out = header + "\n\n" + "\n".join(blocks)
notes = []
extra = len(rows) - len(shown)
if extra > 0:
notes.append(f"+{extra} more not shown")
if result.get("truncated"):
notes.append("results hit the server cap")
if notes:
out += "\n\n_" + "; ".join(notes) + " — refine your question or use the web Ask box._"
return out