68106d7a5a
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.
157 lines
5.7 KiB
Python
157 lines
5.7 KiB
Python
"""Tests for the CRM client's payload builder (pure logic, no network)."""
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
import crm_client # noqa: E402
|
|
|
|
|
|
def test_new_investor_payload():
|
|
p = {"intent": "new_investor", "investor_name": "Acme Capital",
|
|
"contact_name": "Jane Doe", "contact_email": "jane@acme.com",
|
|
"contact_title": "GP", "note": "met at conf"}
|
|
out = crm_client.build_commit_payload(p)
|
|
assert out["investor_name"] == "Acme Capital"
|
|
assert out["create_investor_if_missing"] is True
|
|
assert "row_id" not in out
|
|
assert out["contact"] == {"name": "Jane Doe", "email": "jane@acme.com", "title": "GP"}
|
|
assert out["body"] == "met at conf"
|
|
assert out["source"] == "matrix_intake"
|
|
|
|
|
|
def test_existing_investor_uses_row_id_not_create():
|
|
p = {"intent": "meeting_note", "investor_name": "Acme Capital",
|
|
"contact_name": "Jane Doe", "contact_email": None, "note": "wants Q3 deck",
|
|
"_match_id": "rowAcme"}
|
|
out = crm_client.build_commit_payload(p)
|
|
assert out["row_id"] == "rowAcme"
|
|
assert "create_investor_if_missing" not in out
|
|
assert "investor_name" not in out # targeted by row id, never re-matched by name
|
|
assert out["body"] == "wants Q3 deck"
|
|
|
|
|
|
def test_contact_falls_back_to_investor_name_when_no_person():
|
|
p = {"intent": "new_investor", "investor_name": "Delta Fund",
|
|
"contact_name": None, "contact_email": None, "note": None}
|
|
out = crm_client.build_commit_payload(p)
|
|
assert out["contact"]["name"] == "Delta Fund"
|
|
assert out["body"] == ""
|
|
|
|
|
|
def test_no_email_sends_empty_string_not_none():
|
|
p = {"intent": "new_investor", "investor_name": "Gamma", "contact_name": "Bob",
|
|
"contact_email": None, "note": "x"}
|
|
out = crm_client.build_commit_payload(p)
|
|
assert out["contact"]["email"] == ""
|
|
|
|
|
|
def test_subject_blank_when_note_present_else_provenance_label():
|
|
# The CRM's grid note line uses subject-or-body, so a blank subject lets the note text show.
|
|
with_note = crm_client.build_commit_payload(
|
|
{"intent": "meeting_note", "investor_name": "Acme", "note": "sent the deck", "_match_id": "r1"})
|
|
assert with_note["subject"] == ""
|
|
assert with_note["body"] == "sent the deck"
|
|
# no note text → fall back to a provenance label so the grid line isn't empty
|
|
no_note = crm_client.build_commit_payload(
|
|
{"intent": "new_investor", "investor_name": "Beta", "contact_name": "X", "note": None})
|
|
assert no_note["subject"] == "Intake (Matrix)"
|
|
|
|
|
|
def _with_stub_authed(reply, capture=None):
|
|
"""Swap crm_client._authed for a canned (status, data); return a restorer."""
|
|
orig = crm_client._authed
|
|
|
|
def fake(method, path, body=None):
|
|
if capture is not None:
|
|
capture["path"] = path
|
|
return reply
|
|
|
|
crm_client._authed = fake
|
|
return orig
|
|
|
|
|
|
def test_match_parses_exact_match():
|
|
cap = {}
|
|
orig = _with_stub_authed((200, {"data": {
|
|
"match": {"id": "rowAcme", "investor_name": "Acme Capital", "matched_on": "name"},
|
|
"candidates": [],
|
|
}}), cap)
|
|
try:
|
|
res = crm_client.match({"investor_name": "Acme Capital", "contact_email": ""})
|
|
finally:
|
|
crm_client._authed = orig
|
|
assert res["match"] == {"id": "rowAcme", "name": "Acme Capital"}
|
|
assert res["candidates"] == []
|
|
assert "q=Acme" in cap["path"] # the query was forwarded
|
|
|
|
|
|
def test_match_returns_ranked_candidates_when_no_exact():
|
|
orig = _with_stub_authed((200, {"data": {"match": None, "candidates": [
|
|
{"id": "rowCharlie", "investor_name": "Charlie Brown", "score": 0.92, "matched_on": "name"},
|
|
{"id": "rowBeta", "investor_name": "Beta Capital LLC", "score": 0.86, "matched_on": "name"},
|
|
]}}))
|
|
try:
|
|
res = crm_client.match({"investor_name": "Charles Brown"})
|
|
finally:
|
|
crm_client._authed = orig
|
|
assert res["match"] is None
|
|
assert [c["id"] for c in res["candidates"]] == ["rowCharlie", "rowBeta"]
|
|
assert res["candidates"][0]["name"] == "Charlie Brown"
|
|
assert res["candidates"][0]["matched_on"] == "name"
|
|
|
|
|
|
def test_match_no_query_skips_network():
|
|
def boom(*a, **k):
|
|
raise AssertionError("should not hit the network when there's nothing to match on")
|
|
orig = crm_client._authed
|
|
crm_client._authed = boom
|
|
try:
|
|
res = crm_client.match({"investor_name": None, "contact_name": None, "contact_email": None})
|
|
finally:
|
|
crm_client._authed = orig
|
|
assert res == {"match": None, "candidates": []}
|
|
|
|
|
|
def test_nl_query_returns_endpoint_data():
|
|
cap = {}
|
|
orig = _with_stub_authed(
|
|
(200, {"data": {"intent": "top_investors_committed", "rows": [], "summary": "ok"}}), cap)
|
|
try:
|
|
res = crm_client.nl_query("top investors")
|
|
finally:
|
|
crm_client._authed = orig
|
|
assert res["intent"] == "top_investors_committed"
|
|
assert cap["path"] == "/api/query/nl"
|
|
|
|
|
|
def test_nl_query_passes_through_soft_503():
|
|
# Model-down still carries a structured body (the endpoint 503s with the error in `data`) —
|
|
# return it for the renderer to surface, don't raise.
|
|
orig = _with_stub_authed((503, {"data": {"error": "model_unavailable"}}))
|
|
try:
|
|
res = crm_client.nl_query("anything")
|
|
finally:
|
|
crm_client._authed = orig
|
|
assert res["error"] == "model_unavailable"
|
|
|
|
|
|
def test_nl_query_raises_on_auth_failure():
|
|
orig = _with_stub_authed((403, {"error": "Bot or admin required"}))
|
|
raised = False
|
|
try:
|
|
crm_client.nl_query("x")
|
|
except RuntimeError:
|
|
raised = True
|
|
finally:
|
|
crm_client._authed = orig
|
|
assert raised
|
|
|
|
|
|
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")
|