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,112 @@
|
||||
"""Tests for the NL-query Matrix surface: trigger detection + answer rendering (pure, no network)."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import query # noqa: E402
|
||||
|
||||
|
||||
# ── parse_trigger ───────────────────────────────────────────────────────────────────────
|
||||
def test_trigger_question_mark():
|
||||
assert query.parse_trigger("?who are our top investors") == "who are our top investors"
|
||||
assert query.parse_trigger(" ? spaced out ") == "spaced out"
|
||||
|
||||
|
||||
def test_trigger_at_bot():
|
||||
assert query.parse_trigger("@bot top 10 investors") == "top 10 investors"
|
||||
assert query.parse_trigger("@bot: top 10 investors") == "top 10 investors" # pill-style colon
|
||||
assert query.parse_trigger("@BOT spaced") == "spaced" # case-insensitive
|
||||
|
||||
|
||||
def test_trigger_slash_forms():
|
||||
assert query.parse_trigger("/ask when did we last email Acme?") == "when did we last email Acme?"
|
||||
assert query.parse_trigger("/query top investors") == "top investors"
|
||||
assert query.parse_trigger("/q top investors") == "top investors"
|
||||
|
||||
|
||||
def test_trigger_bare_returns_empty_string():
|
||||
# A bare trigger is matched (so we show help) but carries no question.
|
||||
assert query.parse_trigger("@bot") == ""
|
||||
assert query.parse_trigger("?") == ""
|
||||
|
||||
|
||||
def test_non_trigger_routes_to_intake():
|
||||
assert query.parse_trigger("New investor: Acme — Jane <jane@acme.com>") is None
|
||||
# 'ask' as a note verb must NOT trigger (would collide with real intake notes).
|
||||
assert query.parse_trigger("Ask Jane to send the Q3 deck") is None
|
||||
assert query.parse_trigger("/asking for a friend") is None # needs a separator after /ask
|
||||
assert query.parse_trigger("") is None
|
||||
assert query.parse_trigger(" ") is None
|
||||
|
||||
|
||||
# ── render_answer ───────────────────────────────────────────────────────────────────────
|
||||
def test_render_scalar_rows():
|
||||
out = query.render_answer({
|
||||
"intent": "top_investors_committed", "slots": {"limit": 2},
|
||||
"summary": "Top 2 investor(s) by committed capital.",
|
||||
"columns": ["investor_name", "total_invested", "lead"],
|
||||
"rows": [{"investor_name": "Acme Capital", "total_invested": 5000000, "lead": "Grant"},
|
||||
{"investor_name": "Beta Fund", "total_invested": 2500000, "lead": "Jonathan"}],
|
||||
"truncated": False})
|
||||
assert "Top 2 investor(s)" in out
|
||||
assert "**Acme Capital**" in out
|
||||
assert "$5,000,000" in out # money formatting
|
||||
assert "read as: top_investors_committed" in out # interpretation footer
|
||||
|
||||
|
||||
def test_render_nested_contacts_and_commitments():
|
||||
out = query.render_answer({
|
||||
"intent": "investor_lookup", "slots": {"name": "Acme"},
|
||||
"summary": '1 investor(s) matching "Acme".',
|
||||
"columns": ["investor_name", "lead", "total_invested", "graveyard", "contacts", "commitments"],
|
||||
"rows": [{"investor_name": "Acme Capital", "lead": "Grant", "total_invested": 5000000,
|
||||
"graveyard": 0,
|
||||
"contacts": [{"full_name": "Jane Doe", "email": "jane@acme.com", "title": "GP",
|
||||
"city": "Austin", "state": "TX", "country": ""}],
|
||||
"commitments": [{"fund_name": "Fund I", "amount": 5000000}]}],
|
||||
"truncated": False})
|
||||
assert "Jane Doe <jane@acme.com>" in out
|
||||
assert "Fund I: $5,000,000" in out
|
||||
assert "graveyard" not in out # 0-valued flag column suppressed
|
||||
|
||||
|
||||
def test_render_flag_when_set():
|
||||
out = query.render_answer({
|
||||
"intent": "investors_follow_up", "slots": {},
|
||||
"summary": "1 investor(s) with an open follow-up reminder.",
|
||||
"columns": ["investor_name", "title", "due_date", "status", "overdue"],
|
||||
"rows": [{"investor_name": "Acme", "title": "Send deck", "due_date": "2026-01-01",
|
||||
"status": "open", "overdue": 1}]})
|
||||
assert "⚠️ overdue" in out
|
||||
assert "2026-01-01" in out # date truncated to YYYY-MM-DD
|
||||
|
||||
|
||||
def test_render_no_rows():
|
||||
out = query.render_answer({"intent": "investors_by_city", "slots": {"city": "Nowhere"},
|
||||
"summary": '0 investor contact(s) in "Nowhere".',
|
||||
"columns": [], "rows": []})
|
||||
assert "no matching" in out.lower()
|
||||
|
||||
|
||||
def test_render_overflow_note():
|
||||
rows = [{"investor_name": f"Inv {i}", "total_invested": i}
|
||||
for i in range(query.MAX_DISPLAY_ROWS + 5)]
|
||||
out = query.render_answer({"intent": "top_investors_committed", "slots": {}, "summary": "many",
|
||||
"columns": ["investor_name", "total_invested"], "rows": rows})
|
||||
assert "+5 more not shown" in out
|
||||
|
||||
|
||||
def test_render_errors():
|
||||
assert "couldn't map" in query.render_answer({"error": "no_match", "question": "huh"}).lower()
|
||||
assert "unreachable" in query.render_answer({"error": "model_unavailable"}).lower()
|
||||
assert "failed" in query.render_answer({"error": "query_failed", "detail": "boom"}).lower()
|
||||
assert "bad_slot" in query.render_answer({"error": "bad_slot", "detail": "x"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||||
for fn in fns:
|
||||
fn()
|
||||
print(f"ok {fn.__name__}")
|
||||
print(f"\n{len(fns)} passed")
|
||||
Reference in New Issue
Block a user