Add NL-query backend (W2): local translator + safe named-query runner

Read-only "ask the database in plain English" backend. Translation runs on
the local Qwen via Spark Control (question -> {intent, slots}); nothing leaves
the box, no Claude and no redaction boundary (the simplification chosen after
pressure-testing). The safe surface is a curated catalog of ~12 hand-written
parameterized queries; a slot validator is the trust boundary (no generic SQL,
no dynamic identifiers). POST /api/query/nl + GET /api/query/catalog, gated
require_bot_or_admin, read-only, audited. Soft-delete-correct per table.
Local Qwen translated 12/12 real example questions correctly against the live
Spark. Web "Ask" box and Matrix bot still to come (steps 4-5).
This commit is contained in:
Keysat
2026-06-18 18:35:41 -05:00
parent a166b49397
commit 6c29c22601
13 changed files with 1348 additions and 13 deletions
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Tests for the W2 safe NL-query runner (the model-free core).
Boots the REAL schema (server.init_db against a temp DB — exact columns + all migrations),
inserts synthetic fundraising/email/reminder/pipeline data, and exercises every intent plus
the trust-boundary behaviour:
- each intent returns the right rows over the real schema;
- SOFT-DELETE is respected on both recency legs (a tombstoned communication and a tombstoned
email sighting never count), on reminders, and on opportunities; graveyard investors are
excluded from "live" intents;
- the validator rejects bad/unknown/unexpected slots WITHOUT crashing (the `?limit=abc` class);
- LIKE wildcards in a free-text slot are escaped (a city of "%" does NOT return everything);
- limits clamp to their caps; the audit hook fires with the intent + row count.
Synthetic data only — no real LP substance, no network, no model.
Run: cd backend && python3 nl_query/test_nl_query.py
"""
import os
import sys
import tempfile
from datetime import datetime, timedelta
_DATA = tempfile.mkdtemp()
os.environ["CRM_DATA_DIR"] = _DATA
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
os.environ["CRM_GMAIL_INTEGRATION_ENABLED"] = "1"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # backend/
import server # noqa: E402
import nl_query # noqa: E402
FAILS = []
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
def _ago(days):
return (datetime.utcnow() - timedelta(days=days)).isoformat() + "Z"
TODAY = datetime.utcnow().date()
def seed(conn):
c = conn.execute
# users + mailboxes
c("INSERT INTO users (id, username, email, password_hash, full_name, role) VALUES "
"('u_grant','grant','grant@ten31.xyz','x','Grant Smith','admin'),"
"('u_jon','jonathan','jon@ten31.xyz','x','Jonathan Lee','member')")
c("INSERT INTO email_accounts (id, user_id, email_address, auth_method) VALUES "
"('a_grant','u_grant','grant@ten31.xyz','dwd'),"
"('a_jon','u_jon','jon@ten31.xyz','dwd')")
# funds
c("INSERT INTO fundraising_funds (id, column_id, fund_name, display_order) VALUES "
"('f1','c_f1','Fund I',1),('f2','c_f2','Fund II',2)")
# investors (graveyard flag is the live/retired axis; no deleted_at on this table)
def inv(iid, name, lead, total, grave=0):
c("INSERT INTO fundraising_investors (id, investor_name, lead, graveyard, "
"source_row_id, total_invested) VALUES (?,?,?,?,?,?)",
(iid, name, lead, grave, iid, total))
inv("i_acme", "Acme Capital", "Jonathan Lee", 5_000_000)
inv("i_beta", "Beta Partners", "Grant Smith", 2_000_000)
inv("i_cold", "Cold Co", "Grant Smith", 0) # never contacted
inv("i_delta", "Delta LP", "Grant Smith", 1_000_000) # only a (comms) signal
inv("i_ghost", "Graveyard Ghost", "Grant Smith", 9_999_999, grave=1)
# contacts (grid pills) + classic contact rows for the comms leg
c("INSERT INTO fundraising_contacts (id, investor_id, full_name, email, title, city, "
"contact_id, sort_order) VALUES "
"('fc_a','i_acme','Alice Acme','alice@acme.com','GP','Austin','cc_alice',0),"
"('fc_b','i_beta','Bob Beta','bob@beta.com','LP','Denver',NULL,0),"
"('fc_d','i_delta','Dana Delta','dana@delta.com','CFO','Miami','cc_dana',0)")
c("INSERT INTO contacts (id, first_name, last_name, email) VALUES "
"('cc_alice','Alice','Acme','alice@acme.com'),"
"('cc_dana','Dana','Delta','dana@delta.com')")
# commitments — Acme across two funds (3M + 2M = 5M); Beta one fund
c("INSERT INTO fundraising_commitments (id, investor_id, fund_id, amount) VALUES "
"('cm1','i_acme','f1',3_000_000),('cm2','i_acme','f2',2_000_000),"
"('cm3','i_beta','f1',2_000_000)")
# emails: matched + a per-mailbox sighting. is_sent + from_email decide direction.
def email(eid, frm, frm_name, days, inv_id, account, is_sent, deleted=False):
c("INSERT INTO emails (id, rfc_message_id, from_email, from_name, sent_at, subject, "
"is_matched, match_status) VALUES (?,?,?,?,?,?,1,'matched')",
(eid, "rfc_" + eid, frm, frm_name, _ago(days), "Re: " + eid))
c("INSERT INTO email_account_messages (id, email_id, account_id, gmail_message_id, "
"gmail_thread_id, is_sent, deleted_at) VALUES (?,?,?,?,?,?,?)",
("eam_" + eid, eid, account, "g_" + eid, "t_" + eid, is_sent,
_ago(days) if deleted else None))
c("INSERT INTO email_investor_links (id, email_id, fundraising_investor_id, "
"matched_address, match_kind) VALUES (?,?,?,?, 'exact_email')",
("eil_" + eid, eid, inv_id, frm))
email("ea_recent", "grant@ten31.xyz", "Grant Smith", 0, "i_acme", "a_grant", 1) # Acme: today
email("eb_old", "grant@ten31.xyz", "Grant Smith", 40, "i_beta", "a_grant", 1) # Beta: 40d
email("edel", "grant@ten31.xyz", "Grant Smith", 0, "i_beta", "a_grant", 1, deleted=True) # tombstoned
email("ej", "jon@ten31.xyz", "Jonathan Lee", 0, "i_acme", "a_jon", 1) # jonathan today
email("ein", "alice@acme.com", "Alice Acme", 3, "i_acme", "a_grant", 0) # inbound 3d
# communications (the other recency leg) — Delta has ONLY comms: one live (5d), one tombstoned
# (today). If the soft-delete filter broke, Delta would read as contacted today.
c("INSERT INTO communications (id, contact_id, type, communication_date, created_by) VALUES "
"('cmm_live','cc_dana','email',?,'u_grant')", (_ago(5),))
c("INSERT INTO communications (id, contact_id, type, communication_date, created_by, deleted_at) "
"VALUES ('cmm_del','cc_dana','email',?,'u_grant',?)", (_ago(0), _ago(0)))
# reminders — open(overdue) / open(future) / done / deleted / standalone
def rem(rid, inv_id, title, due, status="open", deleted=False):
c("INSERT INTO reminders (id, investor_id, investor_name, title, due_date, status, "
"deleted_at) VALUES (?,?,?,?,?,?,?)",
(rid, inv_id, title, title, due, status, _ago(0) if deleted else None))
rem("r_over", "i_beta", "Send deck", (TODAY - timedelta(days=1)).isoformat()) # overdue
rem("r_future", "i_acme", "Quarterly check-in", (TODAY + timedelta(days=10)).isoformat())
rem("r_done", "i_acme", "Old task", (TODAY - timedelta(days=2)).isoformat(), status="done")
rem("r_del", "i_acme", "Tombstoned", (TODAY - timedelta(days=2)).isoformat(), deleted=True)
rem("r_standalone", None, "Team chore", (TODAY - timedelta(days=1)).isoformat())
# opportunities — committed / meeting (live) / lost (terminal) / deleted
def opp(oid, inv_id, contact, stage, expected, owner, deleted=False):
c("INSERT INTO opportunities (id, name, contact_id, stage, expected_amount, owner_id, "
"fundraising_investor_id, deleted_at) VALUES (?,?,?,?,?,?,?,?)",
(oid, oid, contact, stage, expected, owner, inv_id, _ago(0) if deleted else None))
# opp contact_id must reference a real contacts row (FK on); reuse the two we made
opp("o_acme", "i_acme", "cc_alice", "committed", 4_000_000, "u_jon")
opp("o_beta", "i_beta", "cc_dana", "meeting", 1_000_000, "u_grant")
opp("o_lost", "i_acme", "cc_alice", "lost", 9_000_000, "u_jon")
opp("o_del", "i_beta", "cc_dana", "due_diligence", 7_000_000, "u_grant", deleted=True)
conn.commit()
def names(res):
return [r["investor_name"] for r in res["rows"]]
def main():
server.init_db()
conn = server.get_db()
seed(conn)
run = lambda *a, **k: nl_query.run_query(conn, *a, **k)
print("investors_cold")
r = run("investors_cold", {"days": 30})
check(names(r) == ["Cold Co", "Beta Partners"], f"cold(30) never-first then stale: {names(r)}")
check(run("investors_cold", {"days": 90})["row_count"] == 1, "cold(90): only never-contacted")
check("Graveyard Ghost" not in names(run("investors_cold", {"days": 3650})),
"cold excludes graveyard investors")
check("Delta LP" in names(run("investors_cold", {"days": 3})), "cold(3) sees Delta (comms 5d)")
check("Delta LP" not in names(run("investors_cold", {"days": 7})),
"cold(7): Delta's tombstoned comm (today) did NOT count")
print("investor_lookup")
r = run("investor_lookup", {"name": "acme"})
check(r["row_count"] == 1 and r["rows"][0]["total_invested"] == 5_000_000, "lookup total committed")
check({c["fund_name"] for c in r["rows"][0]["commitments"]} == {"Fund I", "Fund II"},
"lookup per-fund breakdown")
check(r["rows"][0]["contacts"][0]["email"] == "alice@acme.com", "lookup surfaces contact email")
print("investors_by_city / by_lead / top / follow_up")
check(names(run("investors_by_city", {"city": "Austin"})) == ["Acme Capital"], "by_city")
check(set(names(run("investors_by_lead", {"lead": "Grant"}))) == {"Beta Partners", "Cold Co", "Delta LP"},
"by_lead excludes graveyard + other leads")
check(names(run("top_investors_committed", {"limit": 2})) == ["Acme Capital", "Beta Partners"],
"top by committed (graveyard + zero excluded)")
r = run("investors_follow_up")
check(names(r) == ["Beta Partners", "Acme Capital"], f"follow_up overdue-first, open-only: {names(r)}")
check(r["rows"][0]["overdue"] == 1 and r["rows"][1]["overdue"] == 0, "follow_up overdue flag")
print("pipeline")
r = run("pipeline_totals")
stages = {row["stage"]: row for row in r["rows"]}
check(set(stages) == {"committed", "meeting"}, f"pipeline_totals excludes lost+deleted: {set(stages)}")
check(stages["committed"]["expected_total"] == 4_000_000, "pipeline_totals stage sum")
r = run("pipeline_top", {"limit": 10})
check(names(r) == ["Acme Capital", "Beta Partners"], "pipeline_top furthest-stage first")
check(r["rows"][0]["last_activity_at"] is not None, "pipeline_top enriches last activity")
print("emails")
check(run("recent_emails", {"direction": "outbound"})["row_count"] == 3,
"recent_emails(outbound): 3 live (tombstoned sighting excluded)")
check(run("recent_emails", {"direction": "inbound"})["row_count"] == 1, "recent_emails(inbound)")
check(run("recent_emails")["row_count"] == 4, "recent_emails(any): 4 live")
r = run("investor_last_contact", {"name": "beta"})
check(r["rows"][0]["days_since"] >= 39, "investor_last_contact days_since")
check(run("comms_by_user", {"user": "Grant"})["row_count"] == 2,
"comms_by_user: grant's 2 live outbound (tombstoned excluded)")
r = run("email_counts_by_user", {"user": "grant"})
check(r["rows"][0]["this_week"] == 1, "email_counts this_week = 1 live (tombstoned excluded)")
check(r["rows"][0]["ytd"] >= 1, "email_counts ytd")
print("trust boundary")
check(run("investors_cold", {"days": "abc"})["error"] == "bad_slot", "bad int slot -> bad_slot, no crash")
check(run("nope")["error"] == "unknown_intent", "unknown intent rejected")
check(run("pipeline_totals", {"foo": 1})["error"] == "bad_slot", "unexpected slot rejected")
check(run("investor_lookup", {})["error"] == "bad_slot", "missing required slot rejected")
check(run("investors_by_city", {"city": "%"})["row_count"] == 0,
"LIKE wildcard escaped — '%' does not match every row")
check(run("investors_cold", {"days": 0})["slots"]["days"] == 1, "int slot clamps to min")
check(run("top_investors_committed", {"limit": 99999})["slots"]["limit"] == nl_query.INTENTS
["top_investors_committed"]["slots"]["limit"]["max"], "int slot clamps to max")
print("audit hook + catalog")
seen = []
run("pipeline_totals", audit_fn=seen.append, actor="tester", source="test")
check(len(seen) == 1 and seen[0]["intent"] == "pipeline_totals" and seen[0]["error"] is None
and seen[0]["actor"] == "tester", "audit hook fires with intent/actor/no-error")
run("nope", audit_fn=seen.append)
check(seen[-1]["error"] == "unknown_intent", "audit hook fires on rejection too")
check(len(nl_query.catalog()) == len(nl_query.INTENTS), "catalog covers every intent")
conn.close()
print()
if FAILS:
print(f"{len(FAILS)} FAILED")
for f in FAILS:
print(" - " + f)
sys.exit(1)
print("ALL PASS")
if __name__ == "__main__":
main()