databricks-explore 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,157 @@
1
+ """Demo mode's invented query history, table lineage and column lineage, as the warehouse's system tables return them.
2
+
3
+ Everything is invented and deterministic (the statements, users and their order never change; the timestamps are
4
+ anchored to the start of the current UTC day). `system_query` answers the SQL that mining.py and
5
+ Explorer.column_lineage send, filtering on the same parameters the real tables would.
6
+
7
+ Set EXPLORER_DEMO_REDACTED=1 (databricks-explore --demo --redacted) to make every statement_text read <REDACTED>, as it does
8
+ for a reader who is neither an account admin nor in databricks_pii_access. Without it, three rows are redacted, so
9
+ a run reports them as skipped.
10
+ """
11
+ import os
12
+ import random
13
+ import re
14
+ from datetime import datetime, timedelta, timezone
15
+
16
+ USERS = ["ana.k@example.invalid", "ben.o@example.invalid", "chloe.m@example.invalid", "dev.p@example.invalid",
17
+ "elif.s@example.invalid", "farid.n@example.invalid"]
18
+ REDACTED = "<REDACTED>"
19
+ LINEAGE_SEEN = "2026-11-10T08:00:00.000Z"
20
+
21
+ O, C, I, P = "demo.sales.orders", "demo.sales.customers", "demo.sales.order_items", "demo.sales.products"
22
+ R, S, E, D = "demo.sales.returns", "demo.sales.shipments", "demo.hr.employees", "demo.hr.departments"
23
+ T, V = "samples.nyctaxi.trips", "demo.sales.v_daily_revenue"
24
+ SYS = ["system.access.table_lineage", "system.query.history"]
25
+
26
+ # source table, source column, target table, target column
27
+ COLUMN_LINEAGE = [
28
+ (O, "order_date", V, "order_date"), (O, "amount", V, "revenue"), (O, "order_id", V, "orders"),
29
+ (T, "trip_id", "demo.metrics.wide_metrics", "id"), (T, "pickup_ts", "demo.metrics.wide_metrics", "event_ts"),
30
+ (T, "distance_mi", "demo.metrics.wide_metrics", "metric_01_throughput_per_sec"),
31
+ (T, "fare", "demo.metrics.wide_metrics", "metric_02_error_rate"),
32
+ ]
33
+
34
+ # (count, user indexes, statement type, status, lineage tables or None for no lineage row, SQL with {date}/{email})
35
+ _SPECS = [
36
+ (70, [0, 1, 2, 3, 4, 5], "SELECT", "FINISHED", [O, C],
37
+ "SELECT o.order_id, c.name, o.amount FROM demo.sales.orders o JOIN demo.sales.customers c "
38
+ "ON o.customer_id = c.customer_id WHERE c.email = '{email}' AND o.order_date >= '{date}'"),
39
+ (60, [0, 1, 3], "SELECT", "FINISHED", [I, P],
40
+ "SELECT i.order_id, p.category, SUM(i.qty * i.unit_price) AS revenue FROM demo.sales.order_items i "
41
+ "INNER JOIN demo.sales.products p ON i.sku = p.sku WHERE p.list_price > 10 GROUP BY 1, 2"),
42
+ (35, [1, 2, 4], "SELECT", "FINISHED", [O, R],
43
+ "SELECT o.order_id, r.reason FROM demo.sales.orders o, demo.sales.returns r "
44
+ "WHERE o.order_id = r.order_id AND r.returned_at > '{date}'"),
45
+ (30, [0, 2, 5], "SELECT", "FINISHED", [O, C, S],
46
+ "WITH recent AS (SELECT order_id AS oid, customer_id AS cid, amount FROM demo.sales.orders "
47
+ "WHERE order_date >= '{date}'), shipped AS (SELECT * FROM demo.sales.shipments) "
48
+ "SELECT c.name, recent.amount, s.carrier FROM recent JOIN demo.sales.customers c ON c.customer_id = recent.cid "
49
+ "LEFT JOIN shipped s ON s.order_id = recent.oid"),
50
+ (25, [1, 3], "SELECT", "FINISHED", [I, R],
51
+ "SELECT * FROM demo.sales.order_items i LEFT JOIN demo.sales.returns r USING (order_id, line_no)"),
52
+ (20, [0, 4], "SELECT", "FINISHED", [I, S],
53
+ "SELECT i.order_id, i.units, s.carrier FROM (SELECT order_id, SUM(qty) AS units FROM demo.sales.order_items "
54
+ "GROUP BY order_id) i JOIN demo.sales.shipments s ON s.order_id = i.order_id"),
55
+ (22, [5, 2], "SELECT", "FINISHED", [E, D],
56
+ "SELECT e.name, d.budget FROM demo.hr.employees e JOIN demo.hr.departments d ON e.dept = d.dept"),
57
+ (8, [5], "SELECT", "FINISHED", [E, D],
58
+ "SELECT d.dept, e.name AS manager FROM demo.hr.departments d JOIN demo.hr.employees e ON e.emp_id = d.manager_id"),
59
+ (25, [1, 2, 3, 4], "SELECT", "FINISHED", [O, S], # short names: the lineage rows say which tables they are
60
+ "SELECT * FROM orders o JOIN shipments s ON s.order_id = o.order_id WHERE s.shipped_at >= '{date}'"),
61
+ (6, [2], "SELECT", "FINISHED", None, # a notebook run: no lineage row, short names cannot be resolved
62
+ "SELECT * FROM orders o JOIN shipments s ON s.order_id = o.order_id"),
63
+ (5, [3], "MERGE", "FINISHED", [I],
64
+ "MERGE INTO demo.sales.returns t USING (SELECT order_id, line_no FROM demo.sales.order_items) s "
65
+ "ON t.order_id = s.order_id AND t.line_no = s.line_no WHEN MATCHED THEN UPDATE SET t.reason = 'merged'"),
66
+ (10, [0, 1], "SELECT", "FINISHED", [O, R],
67
+ "SELECT * FROM demo.sales.orders o WHERE EXISTS (SELECT 1 FROM demo.sales.returns r WHERE r.order_id = o.order_id)"),
68
+ (60, [0, 1, 2, 3, 4, 5], "SELECT", "FINISHED", [O],
69
+ "SELECT count(*) FROM demo.sales.orders WHERE status = 'shipped' AND order_date >= '{date}'"),
70
+ (25, [4, 5], "SELECT", "FINISHED", [T], "SELECT zone, avg(fare) FROM samples.nyctaxi.trips GROUP BY zone"),
71
+ (20, [0, 2], "SELECT", "FINISHED", [V], "SELECT * FROM demo.sales.v_daily_revenue ORDER BY order_date DESC"),
72
+ (4, [1], "SELECT", "FINISHED", [O, C], # does not parse
73
+ "SELECT o.order_id FROM demo.sales.orders o JOIN demo.sales.customers c ON (o.customer_id = c.customer_id"),
74
+ (3, [3], "SELECT", "FINISHED", [O], "SELECT * FROM (SELECT * FROM demo.sales.orders"),
75
+ (3, [2], "SELECT", "FINISHED", [O, C], "SELECT o.order_id FROM demo.sales.orders o LEFT JOIN demo.sales.customers c ON c.customer_id = o.customer_id WHERE"),
76
+ (5, [4], "SELECT", "FINISHED", [O, C], # unqualified columns, two tables: no way to know which side each is on
77
+ "SELECT * FROM demo.sales.orders JOIN demo.sales.customers ON customer_id = lifetime_value"),
78
+ (6, [1], "SELECT", "FAILED", [O, S], # failed runs are not read
79
+ "SELECT * FROM demo.sales.orders o JOIN demo.sales.shipments s ON s.shipped_at = o.order_ts"),
80
+ (8, [0, 2], "SHOW", "FINISHED", None, "SHOW TABLES IN demo.sales"),
81
+ (5, [0], "SELECT", "FINISHED", SYS, # Databricks' own tables are not the user's data model
82
+ "SELECT * FROM system.access.table_lineage l JOIN system.query.history h ON l.statement_id = h.statement_id"),
83
+ ]
84
+ _REDACTED_ROWS = 3 # in an ordinary demo, this many rows read <REDACTED>
85
+ _DATES = ["2026-09-01", "2026-09-15", "2026-10-01", "2026-10-20"]
86
+ _EMAILS = ["jane.doe@example.invalid", "li.wei@example.invalid", "sam.o@example.invalid"]
87
+
88
+ _cache = {}
89
+
90
+
91
+ def redacted_mode():
92
+ """True when every statement_text should read <REDACTED> (EXPLORER_DEMO_REDACTED=1)."""
93
+ return os.environ.get("EXPLORER_DEMO_REDACTED") == "1"
94
+
95
+
96
+ def _base():
97
+ return datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
98
+
99
+
100
+ def _fmt(dt):
101
+ return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
102
+
103
+
104
+ def history():
105
+ """Every invented statement, oldest first: dicts with the columns of system.query.history and a `tables` list."""
106
+ base = _base()
107
+ if base in _cache:
108
+ return _cache[base]
109
+ r = random.Random(20261113)
110
+ rows = []
111
+ for count, users, kind, status, tables, sql in _SPECS:
112
+ for _ in range(count):
113
+ when = base - timedelta(hours=r.uniform(2, 38 * 24))
114
+ rows.append({"executed_by": USERS[r.choice(users)], "start_time": when, "statement_type": kind,
115
+ "execution_status": status, "tables": tables,
116
+ "statement_text": sql.format(date=r.choice(_DATES), email=r.choice(_EMAILS))})
117
+ rows.sort(key=lambda x: x["start_time"])
118
+ for n, row in enumerate(rows, 1):
119
+ row["statement_id"] = f"demo-hist-{n:04d}"
120
+ for k in range(1, _REDACTED_ROWS + 1): # a few, spread out
121
+ rows[len(rows) * k // (_REDACTED_ROWS + 1)]["redacted"] = True
122
+ _cache.clear()
123
+ _cache[base] = rows
124
+ return rows
125
+
126
+
127
+ def _param_time(params, name):
128
+ return datetime.fromisoformat(str(params[name]).replace("Z", "+00:00"))
129
+
130
+
131
+ def _selected(low, params, cap):
132
+ """The statements the history query's WHERE and LIMIT would return."""
133
+ since, until = _param_time(params, "since"), _param_time(params, "until")
134
+ types = re.search(r"statement_type in \(([^)]*)\)", low)
135
+ wanted = set(re.findall(r"'(\w+)'", types.group(1))) if types else None
136
+ rows = [x for x in history()
137
+ if since <= x["start_time"] < until and x["execution_status"] == "FINISHED"
138
+ and (wanted is None or x["statement_type"].lower() in {w.lower() for w in wanted})]
139
+ return rows[:cap]
140
+
141
+
142
+ def system_query(low, params, cap):
143
+ """(columns, rows) for a statement on system.query.history, system.access.table_lineage or column_lineage."""
144
+ strs = lambda *names: [{"name": n, "type": "STRING"} for n in names] # noqa: E731
145
+ if "column_lineage" in low:
146
+ t = (params or {}).get("t")
147
+ hits = [c for c in COLUMN_LINEAGE if t in (c[0], c[2])]
148
+ return (strs("source_table_full_name", "source_column_name", "target_table_full_name", "target_column_name",
149
+ "last_seen"), [[a, b, c, d, LINEAGE_SEEN] for a, b, c, d in hits])
150
+ rows = _selected(low, params, cap)
151
+ if "table_lineage" in low:
152
+ return (strs("statement_id", "source_table_full_name"),
153
+ [[x["statement_id"], t] for x in rows if x["tables"] for t in x["tables"]])
154
+ redact_all = redacted_mode()
155
+ return (strs("statement_id", "executed_by", "start_time", "statement_text"),
156
+ [[x["statement_id"], x["executed_by"], _fmt(x["start_time"]),
157
+ REDACTED if redact_all or x.get("redacted") else x["statement_text"]] for x in rows])
@@ -0,0 +1,281 @@
1
+ """Genie One over MCP: a small JSON-RPC client for ask, poll, query result and cancel, plus the response shaping."""
2
+ import json
3
+ import re
4
+ import threading
5
+ import time
6
+ from urllib.parse import unquote
7
+
8
+ import requests
9
+
10
+ PROTOCOL = "2025-06-18"
11
+ SERVER_PATH = "/ai-gateway/mcp-services/system.ai.genie_one_mcp"
12
+ CLIENT_INFO = {"name": "databricks-explore", "version": "0"}
13
+ TIMEOUT = 60 # seconds for one HTTP call; every tool call returns quickly (the browser polls)
14
+ MAX_QUESTION = 4000
15
+ POLL_RETRY_PAUSE = 0.5 # seconds before the one retry of a poll that failed in transit
16
+ MAX_ERROR = 300 # characters of an upstream error body kept in a message
17
+
18
+ _PERMISSION = re.compile(r"permission|EXECUTE|denied|forbidden|not authorized|unauthorized", re.I)
19
+ _SESSION = re.compile(r"session", re.I)
20
+ # https://<host>/explore/data/<catalog>/<schema>/<table>[?query][#fragment], as Genie writes them in its answers
21
+ _TABLE_URL = re.compile(r"https?://[^/\s)\]>\"']+/explore/data/([^/\s?#)\]>\"']+)/([^/\s?#)\]>\"']+)/([^/\s?#)\]>\"']+)[^\s)\]>\"']*")
22
+ _AGENT_COMMENT = re.compile(r"<!--.*?-->", re.S)
23
+
24
+
25
+ class GenieError(Exception):
26
+ """Genie could not be reached or refused. `status` is the HTTP status the route returns (403, 429 or 502).
27
+ `transient` marks a failure in transit (HTTP 5xx, no connection, timeout) that may work on a second try."""
28
+
29
+ def __init__(self, message, status=502, transient=False):
30
+ super().__init__(message)
31
+ self.status = status
32
+ self.transient = transient
33
+
34
+
35
+ class GenieInvalid(ValueError):
36
+ """A request body or query the ask routes cannot use (route returns HTTP 400)."""
37
+
38
+
39
+ # ---------------------------------------------------------------- validation
40
+
41
+ def _text(value, name, limit=None):
42
+ if not isinstance(value, str) or not value.strip():
43
+ raise GenieInvalid(f"{name}: must be a non-empty string")
44
+ if limit and len(value) > limit:
45
+ raise GenieInvalid(f"{name}: at most {limit} characters")
46
+ return value.strip()
47
+
48
+
49
+ def parse_ask(body):
50
+ """(question, conversation_id or None) from a POST /api/ask body."""
51
+ if not isinstance(body, dict):
52
+ raise GenieInvalid("(root): body must be a JSON object")
53
+ cid = body.get("conversation_id")
54
+ return _text(body.get("question"), "question", MAX_QUESTION), (_text(cid, "conversation_id") if cid else None)
55
+
56
+
57
+ def parse_ids(source, *names):
58
+ """The named non-empty string ids from a query string or body."""
59
+ if not hasattr(source, "get"):
60
+ raise GenieInvalid("(root): body must be a JSON object")
61
+ return tuple(_text(source.get(n), n) for n in names)
62
+
63
+
64
+ # ---------------------------------------------------------------- shaping
65
+
66
+ def tables_from_markdown(markdown):
67
+ """Tables an answer links to, as [{"name": "cat.sch.tbl", "url": ...}], first mention first, no repeats."""
68
+ seen, out = set(), []
69
+ for m in _TABLE_URL.finditer(markdown or ""):
70
+ parts = [unquote(g).strip("`") for g in m.groups()]
71
+ name = ".".join(parts)
72
+ if all(parts) and name.lower() not in seen:
73
+ seen.add(name.lower())
74
+ out.append({"name": name, "url": m.group(0)})
75
+ return out
76
+
77
+
78
+ def shape_ask(sc):
79
+ """The ask response: ids and the first status."""
80
+ return {"conversation_id": sc.get("conversation_id"), "response_id": sc.get("response_id"),
81
+ "status": sc.get("status") or "in_progress"}
82
+
83
+
84
+ def shape_poll(sc):
85
+ """One poll, in the shape the page reads. `tables` is parsed from the answer's links."""
86
+ status = sc.get("status") or "in_progress"
87
+ answer = sc.get("final_answer")
88
+ items = [{"item_id": q.get("item_id"), "sql": q.get("sql") or ""} for q in (sc.get("query_items") or []) if q.get("item_id")]
89
+ return {"conversation_id": sc.get("conversation_id"), "response_id": sc.get("response_id"), "status": status,
90
+ "deep_link": sc.get("deep_link"), "final_answer": answer,
91
+ "progress_steps": [str(s) for s in (sc.get("progress_steps") or [])],
92
+ "query_items": items, "tables": tables_from_markdown(answer),
93
+ "error": str(sc.get("error") or sc.get("message")) if status in ("failed", "incomplete") and (sc.get("error") or sc.get("message")) else None}
94
+
95
+
96
+ def shape_result(sc):
97
+ """A query result in the Result shape of POST /api/query (contracts/api.md section 3), so the results grid reads it."""
98
+ cols = []
99
+ for c in sc.get("columns") or []:
100
+ text = c.get("type_text")
101
+ base = re.split(r"[(<\s]", (text or "").strip())[0].upper() or None
102
+ cols.append({"name": c.get("name"), "type": c.get("type") or base, "type_text": text})
103
+ rows = [list(r) for r in (sc.get("rows") or [])]
104
+ return {"statement_id": sc.get("item_id"), "state": sc.get("statement_state"), "ready": bool(sc.get("ready")),
105
+ "columns": cols, "rows": rows, "row_count": len(rows), "total_row_count": sc.get("total_row_count"),
106
+ "truncated": bool(sc.get("truncated")), "ms": None}
107
+
108
+
109
+ # ---------------------------------------------------------------- transport
110
+
111
+ def default_post(url, headers, body, timeout):
112
+ """POST a JSON body. Returns (status, response headers with lower-case keys, body text). Network failures are GenieError."""
113
+ try:
114
+ r = requests.post(url, headers=headers, data=json.dumps(body), timeout=timeout)
115
+ except requests.Timeout:
116
+ raise GenieError("The Genie One MCP server did not answer in time. Try again.", 502, transient=True) from None
117
+ except requests.RequestException as e:
118
+ raise GenieError(f"Could not reach the Genie One MCP server: {type(e).__name__}. Check the connection and the profile.", 502, transient=True) from None
119
+ return r.status_code, {k.lower(): v for k, v in r.headers.items()}, r.text
120
+
121
+
122
+ def _snippet(text):
123
+ return " ".join(str(text).split())[:MAX_ERROR]
124
+
125
+
126
+ def _permission_message(detail=""):
127
+ base = ("Genie One refused the request. Your user needs EXECUTE on system.ai.genie_one_mcp, "
128
+ "and the workspace needs Genie One turned on.")
129
+ return f"{base} ({_snippet(detail)})" if detail else base
130
+
131
+
132
+ def _classify(message):
133
+ """GenieError for an error message from the server: 403 when it reads like a permission problem, else 502."""
134
+ return GenieError(_permission_message(message), 403) if _PERMISSION.search(message) else GenieError(_snippet(message) or "Genie failed.", 502)
135
+
136
+
137
+ def _parse_body(text, rpc_id):
138
+ """The JSON-RPC message in a response body: plain JSON, or an event stream with the message in a data line."""
139
+ text = (text or "").strip()
140
+ if not text:
141
+ return None
142
+ if text.startswith(("{", "[")):
143
+ data = json.loads(text)
144
+ return data if isinstance(data, dict) else next((m for m in data if m.get("id") == rpc_id), None)
145
+ found = None
146
+ for line in text.splitlines():
147
+ if line.startswith("data:"):
148
+ try:
149
+ msg = json.loads(line[5:].strip())
150
+ except ValueError:
151
+ continue
152
+ if isinstance(msg, dict) and (msg.get("id") == rpc_id or found is None):
153
+ found = msg
154
+ return found
155
+
156
+
157
+ class GenieClient:
158
+ """Talks to the Genie One MCP server as the connected user. One MCP session is reused until the server drops it."""
159
+
160
+ def __init__(self, w, warehouse_id=None, post=default_post, timeout=TIMEOUT):
161
+ self.w = w
162
+ self.warehouse_id = warehouse_id # sent in _meta with every tool call; the page's selected warehouse
163
+ self._post = post
164
+ self._timeout = timeout
165
+ self._session = None
166
+ self._next_id = 0
167
+ self._lock = threading.Lock()
168
+
169
+ # ---- plumbing
170
+ def url(self):
171
+ """The MCP endpoint of the connected workspace."""
172
+ try:
173
+ wid = self.w.get_workspace_id()
174
+ except Exception as e:
175
+ raise GenieError(f"Could not read the workspace id: {_snippet(e)}", 502) from None
176
+ return f"{self.w.config.host.rstrip('/')}{SERVER_PATH}?o={wid}"
177
+
178
+ def _headers(self):
179
+ h = dict(self.w.config.authenticate()) # a fresh bearer token each call; never logged or returned
180
+ h.update({"Content-Type": "application/json", "Accept": "application/json, text/event-stream",
181
+ "MCP-Protocol-Version": PROTOCOL})
182
+ if self._session:
183
+ h["Mcp-Session-Id"] = self._session
184
+ return h
185
+
186
+ def _send(self, body):
187
+ """(status, headers, text) of one POST; an HTTP error is turned into a GenieError except 404, which the caller handles."""
188
+ status, headers, text = self._post(self.url(), self._headers(), body, self._timeout)
189
+ if status in (401, 403):
190
+ raise GenieError(_permission_message(), 403)
191
+ if status == 429:
192
+ raise GenieError("Genie is rate limited right now. Wait a moment and try again.", 429)
193
+ if status >= 400 and status != 404:
194
+ raise GenieError(f"The Genie One MCP server answered HTTP {status}: {_snippet(text)}", 502, transient=status >= 500)
195
+ return status, headers, text
196
+
197
+ def _rpc_id(self):
198
+ self._next_id += 1
199
+ return self._next_id
200
+
201
+ def _initialize(self):
202
+ self._session = None
203
+ rid = self._rpc_id()
204
+ status, headers, text = self._send({"jsonrpc": "2.0", "id": rid, "method": "initialize",
205
+ "params": {"protocolVersion": PROTOCOL, "capabilities": {}, "clientInfo": CLIENT_INFO}})
206
+ if status == 404:
207
+ raise GenieError("This workspace has no Genie One MCP server (system.ai.genie_one_mcp). It may not be enabled here.", 502)
208
+ msg = _parse_body(text, rid)
209
+ if msg and msg.get("error"):
210
+ raise _classify(str(msg["error"].get("message", msg["error"])))
211
+ self._session = headers.get("mcp-session-id")
212
+ self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
213
+
214
+ def _call(self, name, args, retry=True):
215
+ with self._lock:
216
+ if not self._session:
217
+ self._initialize()
218
+ rid = self._rpc_id()
219
+ params = {"name": name, "arguments": args}
220
+ if self.warehouse_id:
221
+ params["_meta"] = {"warehouse_id": self.warehouse_id}
222
+ status, _, text = self._send({"jsonrpc": "2.0", "id": rid, "method": "tools/call", "params": params})
223
+ msg = None
224
+ try:
225
+ msg = _parse_body(text, rid) if status != 404 else None
226
+ except ValueError:
227
+ raise GenieError("The Genie One MCP server sent a reply that is not JSON.", 502) from None
228
+ expired = status == 404 or (msg and msg.get("error") and _SESSION.search(str(msg["error"].get("message", ""))))
229
+ if expired and retry: # the server forgot the session: start a new one and send the call once more
230
+ with self._lock:
231
+ self._session = None
232
+ return self._call(name, args, retry=False)
233
+ if status == 404:
234
+ raise GenieError("The Genie One MCP server lost the session. Ask again.", 502)
235
+ if msg is None:
236
+ raise GenieError("The Genie One MCP server sent an empty reply.", 502)
237
+ if msg.get("error"):
238
+ raise _classify(str(msg["error"].get("message", msg["error"])))
239
+ result = msg.get("result") or {}
240
+ sc = result.get("structuredContent")
241
+ if sc is None:
242
+ content = _AGENT_COMMENT.sub("", "".join(c.get("text", "") for c in result.get("content") or []))
243
+ try:
244
+ sc = json.loads(content)
245
+ except ValueError:
246
+ sc = {"message": content.strip()}
247
+ if result.get("isError"):
248
+ raise _classify(str(sc.get("message") or sc.get("error") or json.dumps(sc)))
249
+ return sc
250
+
251
+ # ---- the four tools
252
+ def ask(self, question, conversation_id=None):
253
+ """Start a turn (or a follow-up in `conversation_id`). Returns ids and the status."""
254
+ args = {"question": question}
255
+ if conversation_id:
256
+ args["conversation_id"] = conversation_id
257
+ return shape_ask(self._call("genie_ask", args))
258
+
259
+ def poll(self, conversation_id, response_id):
260
+ """One upstream poll, shaped by shape_poll. A transient failure (HTTP 5xx, no connection, timeout) is tried
261
+ once more after a short pause; only poll does this, because the page polls again anyway and a repeated ask,
262
+ result or cancel is not harmless."""
263
+ args = {"conversation_id": conversation_id, "response_id": response_id}
264
+ try:
265
+ sc = self._call("genie_poll_response", args)
266
+ except GenieError as e:
267
+ if not e.transient:
268
+ raise
269
+ time.sleep(POLL_RETRY_PAUSE)
270
+ sc = self._call("genie_poll_response", args)
271
+ return shape_poll(sc)
272
+
273
+ def query_result(self, conversation_id, response_id, item_id):
274
+ """The rows of one SQL item, in the Result shape."""
275
+ return shape_result(self._call("genie_get_query_result", {"conversation_id": conversation_id,
276
+ "response_id": response_id, "item_id": item_id}))
277
+
278
+ def cancel(self, conversation_id):
279
+ """Stop the in-flight turn of a conversation."""
280
+ self._call("genie_cancel_response", {"conversation_id": conversation_id})
281
+ return {"conversation_id": conversation_id, "cancel_requested": True}