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:
@@ -2180,6 +2180,10 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
if path == '/api/reminders':
|
||||
return self.handle_list_reminders(user, params)
|
||||
|
||||
# Natural-language query (W2) — the askable catalog
|
||||
if path == '/api/query/catalog':
|
||||
return self.handle_nl_query_catalog(user)
|
||||
|
||||
# Matrix intake bot — new-vs-existing lookup for its in-thread proposal
|
||||
if path == '/api/intake/match':
|
||||
return self.handle_intake_match(user, params)
|
||||
@@ -2268,6 +2272,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_pipeline_unlink(user, body)
|
||||
if path == '/api/reminders':
|
||||
return self.handle_create_reminder(user, body)
|
||||
if path == '/api/query/nl':
|
||||
return self.handle_nl_query(user, body)
|
||||
if path == '/api/fundraising/collab/heartbeat':
|
||||
return self.handle_fundraising_collab_heartbeat(user, body)
|
||||
if path == '/api/admin/users':
|
||||
@@ -3613,6 +3619,62 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ── Natural-language query (W2) — read-only, the safe nl_query core ──
|
||||
def handle_nl_query(self, user, body):
|
||||
"""Answer a plain-English question about the fundraising database, read-only.
|
||||
|
||||
Bot-or-admin: a query can surface the whole investor book, so it stays off the member
|
||||
tier; the audit row written below (entity_type='nl_query') makes a query made through a
|
||||
leaked or automated credential detectable. Accepts either {question} (mapped to an
|
||||
intent+slots on the LOCAL model — nothing leaves the box) or {intent, slots} (run a
|
||||
curated query directly, e.g. a UI re-run). BOTH go through the same validator and the
|
||||
same fixed parameterized SQL in nl_query; result rows never reach any model.
|
||||
|
||||
Status: a local-model outage -> 503; an unexpected SQL fault -> 500; everything else,
|
||||
including a soft 'no question matched', returns 200 with the structured result, because
|
||||
the UI always wants the interpreted query + summary back rather than a bare HTTP code."""
|
||||
if not require_bot_or_admin(user):
|
||||
return self.send_error_json("Bot or admin required", 403)
|
||||
body = body or {}
|
||||
question = str(body.get('question') or '').strip()
|
||||
intent = str(body.get('intent') or '').strip()
|
||||
if not question and not intent:
|
||||
return self.send_error_json("question or intent is required")
|
||||
source = (str(body.get('source') or 'api').strip()[:20]) or 'api'
|
||||
|
||||
import nl_query # pure-stdlib at import; the local-model leg is lazy inside translate
|
||||
conn = get_db()
|
||||
try:
|
||||
def _audit(p):
|
||||
log_audit(conn, user['user_id'], 'nl_query', p.get('intent') or '-', 'query',
|
||||
{"source": p.get('source'), "slots": p.get('slots'),
|
||||
"row_count": p.get('row_count'), "error": p.get('error'),
|
||||
"question": question or None})
|
||||
if question:
|
||||
result = nl_query.answer(conn, question, audit_fn=_audit,
|
||||
actor=user['user_id'], source=source)
|
||||
else:
|
||||
result = nl_query.run_query(conn, intent, body.get('slots') or {},
|
||||
audit_fn=_audit, actor=user['user_id'], source=source)
|
||||
conn.commit() # persist the audit row(s)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
err = result.get('error')
|
||||
if err == 'model_unavailable':
|
||||
return self.send_json({"data": result}, 503)
|
||||
if err == 'query_failed':
|
||||
return self.send_json({"data": result}, 500)
|
||||
return self.send_json({"data": result})
|
||||
|
||||
def handle_nl_query_catalog(self, user):
|
||||
"""The askable surface: every intent's key, summary, slot specs and an example
|
||||
question — so the UI can show what can be asked. Same gate as the query endpoint."""
|
||||
if not require_bot_or_admin(user):
|
||||
return self.send_error_json("Bot or admin required", 403)
|
||||
import nl_query
|
||||
return self.send_json({"data": nl_query.catalog()})
|
||||
|
||||
def handle_intake_match(self, user, params):
|
||||
"""Read-only: does an investor matching this intake already exist? Used by the
|
||||
Matrix intake bot to label its in-thread proposal new-vs-existing. Returns the
|
||||
|
||||
Reference in New Issue
Block a user