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:
@@ -0,0 +1,189 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user