quillsql 2.2.8__tar.gz → 2.2.10__tar.gz
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.
- {quillsql-2.2.8 → quillsql-2.2.10}/PKG-INFO +1 -1
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql/core.py +247 -52
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql.egg-info/PKG-INFO +1 -1
- {quillsql-2.2.8 → quillsql-2.2.10}/setup.py +1 -1
- {quillsql-2.2.8 → quillsql-2.2.10}/README.md +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql/__init__.py +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql/assets/__init__.py +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql/assets/pgtypes.py +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql/db/__init__.py +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql/db/bigquery.py +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql/db/cached_connection.py +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql/db/db_helper.py +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql/db/postgres.py +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql/error.py +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql/utils/__init__.py +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql/utils/filters.py +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql/utils/pivot_template.py +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql/utils/post_quill_executor.py +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql/utils/run_query_processes.py +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql/utils/schema_conversion.py +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql/utils/tenants.py +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql.egg-info/SOURCES.txt +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql.egg-info/dependency_links.txt +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql.egg-info/requires.txt +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/quillsql.egg-info/top_level.txt +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/setup.cfg +0 -0
- {quillsql-2.2.8 → quillsql-2.2.10}/tests/test_core.py +0 -0
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import codecs
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from decimal import Decimal
|
|
3
6
|
from dotenv import load_dotenv
|
|
4
7
|
|
|
5
8
|
import requests
|
|
@@ -393,6 +396,8 @@ class Quill:
|
|
|
393
396
|
tenants,
|
|
394
397
|
metadata,
|
|
395
398
|
flags=None,
|
|
399
|
+
filters=None,
|
|
400
|
+
admin_enabled=None,
|
|
396
401
|
):
|
|
397
402
|
if not tenants:
|
|
398
403
|
raise ValueError("You may not pass an empty tenants array.")
|
|
@@ -409,51 +414,26 @@ class Quill:
|
|
|
409
414
|
try:
|
|
410
415
|
# Set tenant IDs in the connection
|
|
411
416
|
self.target_connection.tenant_ids = extract_tenant_ids(tenants)
|
|
417
|
+
if task in ("chat", "agent"):
|
|
418
|
+
for event in self._agentic_chat_loop(
|
|
419
|
+
tenants,
|
|
420
|
+
metadata,
|
|
421
|
+
flags,
|
|
422
|
+
filters,
|
|
423
|
+
admin_enabled,
|
|
424
|
+
):
|
|
425
|
+
yield event
|
|
426
|
+
return
|
|
412
427
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
**metadata,
|
|
423
|
-
"tenants": tenants,
|
|
424
|
-
"flags": tenant_flags,
|
|
425
|
-
}
|
|
426
|
-
# Custom JSON Encoder to handle Enums
|
|
427
|
-
class EnumEncoder(json.JSONEncoder):
|
|
428
|
-
def default(self, obj):
|
|
429
|
-
if isinstance(obj, Enum):
|
|
430
|
-
return obj.value # Convert enum to its value (string in this case)
|
|
431
|
-
return super().default(obj)
|
|
432
|
-
url = f"{self.baseUrl}/sdk/{task}"
|
|
433
|
-
headers = {"Authorization": f"Bearer {self.private_key}", "Content-Type": "application/json","Accept": "text/event-stream"}
|
|
434
|
-
encoded = json.dumps(payload, cls=EnumEncoder)
|
|
435
|
-
|
|
436
|
-
resp = requests.post(url, data=encoded, headers=headers, stream=True)
|
|
437
|
-
decoder = codecs.getincrementaldecoder('utf-8')()
|
|
438
|
-
buf = ""
|
|
439
|
-
for chunk in resp.iter_content(chunk_size=4096):
|
|
440
|
-
buf += decoder.decode(chunk)
|
|
441
|
-
while "\n\n" in buf:
|
|
442
|
-
raw_event, buf = buf.split("\n\n", 1)
|
|
443
|
-
data_lines = []
|
|
444
|
-
for line in raw_event.splitlines():
|
|
445
|
-
if line.startswith("data:"):
|
|
446
|
-
data_lines.append(line[len("data:"):].strip())
|
|
447
|
-
if not data_lines:
|
|
448
|
-
continue
|
|
449
|
-
payload = "\n".join(data_lines)
|
|
450
|
-
if payload == "[DONE]":
|
|
451
|
-
break
|
|
452
|
-
yield json.loads(payload)
|
|
453
|
-
|
|
454
|
-
# flush any partial code points at the end
|
|
455
|
-
buf += decoder.decode(b"", final=True)
|
|
456
|
-
yield buf
|
|
428
|
+
for event in self._stream_sse(
|
|
429
|
+
task,
|
|
430
|
+
tenants,
|
|
431
|
+
metadata,
|
|
432
|
+
flags,
|
|
433
|
+
filters,
|
|
434
|
+
admin_enabled,
|
|
435
|
+
):
|
|
436
|
+
yield event
|
|
457
437
|
return
|
|
458
438
|
except Exception as err:
|
|
459
439
|
yield {
|
|
@@ -462,6 +442,228 @@ class Quill:
|
|
|
462
442
|
}
|
|
463
443
|
return
|
|
464
444
|
|
|
445
|
+
def _normalize_tenant_flags(self, tenants, flags):
|
|
446
|
+
tenant_flags = None
|
|
447
|
+
if tenants and tenants[0] == SINGLE_TENANT and flags:
|
|
448
|
+
if flags and isinstance(flags[0], dict):
|
|
449
|
+
tenant_flags = [{"tenantField": SINGLE_TENANT, "flags": flags}]
|
|
450
|
+
else:
|
|
451
|
+
tenant_flags = flags
|
|
452
|
+
return tenant_flags
|
|
453
|
+
|
|
454
|
+
def _agentic_chat_loop(self, tenants, metadata, flags, filters, admin_enabled):
|
|
455
|
+
messages = list(metadata.get("messages") or [])
|
|
456
|
+
max_iterations = 10
|
|
457
|
+
|
|
458
|
+
for _ in range(max_iterations):
|
|
459
|
+
payload = {
|
|
460
|
+
**metadata,
|
|
461
|
+
"messages": messages,
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
has_tool_calls = False
|
|
465
|
+
assistant_text = ""
|
|
466
|
+
tool_results = []
|
|
467
|
+
tool_calls = []
|
|
468
|
+
|
|
469
|
+
for event in self._stream_sse(
|
|
470
|
+
"agent",
|
|
471
|
+
tenants,
|
|
472
|
+
payload,
|
|
473
|
+
flags,
|
|
474
|
+
filters,
|
|
475
|
+
admin_enabled,
|
|
476
|
+
):
|
|
477
|
+
yield event
|
|
478
|
+
|
|
479
|
+
if event.get("type") == "text-delta":
|
|
480
|
+
assistant_text += event.get("delta", "")
|
|
481
|
+
|
|
482
|
+
if event.get("type") == "tool-input-available":
|
|
483
|
+
tool_name = event.get("toolName")
|
|
484
|
+
tool_call_id = event.get("toolCallId")
|
|
485
|
+
tool_input = event.get("input") or {}
|
|
486
|
+
|
|
487
|
+
if tool_call_id is None:
|
|
488
|
+
yield {
|
|
489
|
+
"type": "error",
|
|
490
|
+
"errorText": "Missing toolCallId for tool-input-available event.",
|
|
491
|
+
}
|
|
492
|
+
continue
|
|
493
|
+
|
|
494
|
+
has_tool_calls = True
|
|
495
|
+
yield {
|
|
496
|
+
"type": "tool-executing",
|
|
497
|
+
"toolCallId": tool_call_id,
|
|
498
|
+
"toolName": tool_name,
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
result = self._execute_tool_locally(tool_name, tool_input)
|
|
502
|
+
tool_results.append(
|
|
503
|
+
{
|
|
504
|
+
"toolCallId": tool_call_id,
|
|
505
|
+
"toolName": tool_name,
|
|
506
|
+
"input": tool_input,
|
|
507
|
+
"result": result,
|
|
508
|
+
}
|
|
509
|
+
)
|
|
510
|
+
tool_calls.append(
|
|
511
|
+
{
|
|
512
|
+
"id": tool_call_id,
|
|
513
|
+
"type": "function",
|
|
514
|
+
"function": {
|
|
515
|
+
"name": tool_name,
|
|
516
|
+
"arguments": json.dumps(tool_input or {}),
|
|
517
|
+
},
|
|
518
|
+
}
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
yield {
|
|
522
|
+
"type": "tool-result",
|
|
523
|
+
"toolCallId": tool_call_id,
|
|
524
|
+
"result": result,
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if event.get("type") in ("finish", "error"):
|
|
528
|
+
break
|
|
529
|
+
|
|
530
|
+
if not has_tool_calls:
|
|
531
|
+
break
|
|
532
|
+
|
|
533
|
+
def _build_tool_result_for_history(tool_result):
|
|
534
|
+
result = tool_result.get("result") or {}
|
|
535
|
+
has_rows = isinstance(result.get("rows"), list)
|
|
536
|
+
has_fields = isinstance(result.get("fields"), list)
|
|
537
|
+
is_query_result = has_rows or has_fields or result.get("dbMismatched")
|
|
538
|
+
if not is_query_result:
|
|
539
|
+
return result
|
|
540
|
+
tool_input = tool_result.get("input") or {}
|
|
541
|
+
error = result.get("error") or tool_input.get("error")
|
|
542
|
+
status = "error" if error or result.get("dbMismatched") else "success"
|
|
543
|
+
payload = {"status": status}
|
|
544
|
+
if tool_input.get("sql"):
|
|
545
|
+
payload["sql"] = tool_input.get("sql")
|
|
546
|
+
if error:
|
|
547
|
+
payload["error"] = error
|
|
548
|
+
if result.get("dbMismatched"):
|
|
549
|
+
payload["meta"] = {
|
|
550
|
+
"dbMismatched": True,
|
|
551
|
+
"backendDatabaseType": result.get("backendDatabaseType"),
|
|
552
|
+
}
|
|
553
|
+
elif not error:
|
|
554
|
+
payload["meta"] = {
|
|
555
|
+
"rowsFetchedSuccessfully": True,
|
|
556
|
+
"rowCount": len(result.get("rows") or []),
|
|
557
|
+
}
|
|
558
|
+
return payload
|
|
559
|
+
|
|
560
|
+
messages.append(
|
|
561
|
+
{
|
|
562
|
+
"role": "assistant",
|
|
563
|
+
"content": assistant_text or None,
|
|
564
|
+
"tool_calls": tool_calls,
|
|
565
|
+
}
|
|
566
|
+
)
|
|
567
|
+
for tool_result in tool_results:
|
|
568
|
+
messages.append(
|
|
569
|
+
{
|
|
570
|
+
"role": "tool",
|
|
571
|
+
"tool_call_id": tool_result["toolCallId"],
|
|
572
|
+
"content": json.dumps(_build_tool_result_for_history(tool_result)),
|
|
573
|
+
}
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
yield {"type": "done"}
|
|
577
|
+
|
|
578
|
+
def _execute_tool_locally(self, tool_name, tool_input):
|
|
579
|
+
if tool_name == "generateReport":
|
|
580
|
+
if tool_input.get("error"):
|
|
581
|
+
return {"error": tool_input.get("error")}
|
|
582
|
+
sql = tool_input.get("sql")
|
|
583
|
+
if not sql:
|
|
584
|
+
return {"error": "No SQL provided"}
|
|
585
|
+
results = self.run_queries(
|
|
586
|
+
[sql],
|
|
587
|
+
self.target_connection.database_type,
|
|
588
|
+
)
|
|
589
|
+
if results.get("dbMismatched"):
|
|
590
|
+
return results
|
|
591
|
+
query_results = results.get("queryResults") or []
|
|
592
|
+
if query_results and isinstance(query_results[0], dict):
|
|
593
|
+
if query_results[0].get("error"):
|
|
594
|
+
return query_results[0]
|
|
595
|
+
return {
|
|
596
|
+
"rows": query_results[0].get("rows", []),
|
|
597
|
+
"fields": query_results[0].get("fields", []),
|
|
598
|
+
}
|
|
599
|
+
return {"rows": [], "fields": []}
|
|
600
|
+
|
|
601
|
+
if tool_name == "createChart":
|
|
602
|
+
return {"chartConfig": tool_input}
|
|
603
|
+
|
|
604
|
+
return {"error": f"Unknown tool: {tool_name}"}
|
|
605
|
+
|
|
606
|
+
def _json_default(self, obj):
|
|
607
|
+
if isinstance(obj, Enum):
|
|
608
|
+
return obj.value
|
|
609
|
+
if isinstance(obj, uuid.UUID):
|
|
610
|
+
return str(obj)
|
|
611
|
+
if isinstance(obj, (datetime, date)):
|
|
612
|
+
return obj.isoformat()
|
|
613
|
+
if isinstance(obj, Decimal):
|
|
614
|
+
return float(obj)
|
|
615
|
+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
|
616
|
+
|
|
617
|
+
def _stream_sse(self, endpoint, tenants, payload, flags, filters, admin_enabled):
|
|
618
|
+
tenant_flags = self._normalize_tenant_flags(tenants, flags)
|
|
619
|
+
request_payload = {
|
|
620
|
+
**payload,
|
|
621
|
+
"tenants": tenants,
|
|
622
|
+
"flags": tenant_flags,
|
|
623
|
+
}
|
|
624
|
+
if filters:
|
|
625
|
+
request_payload["sdkFilters"] = [convert_custom_filter(f) for f in filters]
|
|
626
|
+
if admin_enabled is not None:
|
|
627
|
+
request_payload["adminEnabled"] = admin_enabled
|
|
628
|
+
|
|
629
|
+
url = f"{self.baseUrl}/sdk/{endpoint}"
|
|
630
|
+
headers = {
|
|
631
|
+
"Authorization": f"Bearer {self.private_key}",
|
|
632
|
+
"Content-Type": "application/json",
|
|
633
|
+
"Accept": "text/event-stream",
|
|
634
|
+
}
|
|
635
|
+
encoded = json.dumps(request_payload, default=self._json_default)
|
|
636
|
+
|
|
637
|
+
resp = requests.post(url, data=encoded, headers=headers, stream=True)
|
|
638
|
+
decoder = codecs.getincrementaldecoder("utf-8")()
|
|
639
|
+
buf = ""
|
|
640
|
+
for chunk in resp.iter_content(chunk_size=4096):
|
|
641
|
+
buf += decoder.decode(chunk)
|
|
642
|
+
while "\n\n" in buf:
|
|
643
|
+
raw_event, buf = buf.split("\n\n", 1)
|
|
644
|
+
data_lines = []
|
|
645
|
+
for line in raw_event.splitlines():
|
|
646
|
+
if line.startswith("data:"):
|
|
647
|
+
data_lines.append(line[len("data:"):].strip())
|
|
648
|
+
if not data_lines:
|
|
649
|
+
continue
|
|
650
|
+
payload = "\n".join(data_lines)
|
|
651
|
+
if payload == "[DONE]":
|
|
652
|
+
return
|
|
653
|
+
try:
|
|
654
|
+
parsed = json.loads(payload)
|
|
655
|
+
if isinstance(parsed, str):
|
|
656
|
+
yield {"type": "text-delta", "id": "0", "delta": parsed}
|
|
657
|
+
else:
|
|
658
|
+
yield parsed
|
|
659
|
+
except json.JSONDecodeError:
|
|
660
|
+
continue
|
|
661
|
+
|
|
662
|
+
# flush any partial code points at the end
|
|
663
|
+
buf += decoder.decode(b"", final=True)
|
|
664
|
+
yield buf
|
|
665
|
+
return
|
|
666
|
+
|
|
465
667
|
def apply_limit(self, query, limit):
|
|
466
668
|
# Simple logic: if query already has a limit, don't add another
|
|
467
669
|
if getattr(self.target_connection, 'database_type', '').lower() == 'mssql':
|
|
@@ -633,16 +835,9 @@ class Quill:
|
|
|
633
835
|
return results
|
|
634
836
|
|
|
635
837
|
def post_quill(self, path, payload):
|
|
636
|
-
# Custom JSON Encoder to handle Enums
|
|
637
|
-
class EnumEncoder(json.JSONEncoder):
|
|
638
|
-
def default(self, obj):
|
|
639
|
-
if isinstance(obj, Enum):
|
|
640
|
-
return obj.value # Convert enum to its value (string in this case)
|
|
641
|
-
return super().default(obj)
|
|
642
|
-
|
|
643
838
|
url = f"{self.baseUrl}/sdk/{path}"
|
|
644
839
|
# Set content type to application/json
|
|
645
840
|
headers = {"Authorization": f"Bearer {self.private_key}", "Content-Type": "application/json"}
|
|
646
|
-
encoded = json.dumps(payload,
|
|
841
|
+
encoded = json.dumps(payload, default=self._json_default)
|
|
647
842
|
response = requests.post(url, data=encoded, headers=headers)
|
|
648
843
|
return response.json()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|