sageql 0.2.1__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.
- sageql/__init__.py +108 -0
- sageql/catalog_input.py +138 -0
- sageql/chat_cli.py +229 -0
- sageql/chat_provider.py +527 -0
- sageql/cli.py +43 -0
- sageql/context.py +136 -0
- sageql/conversation.py +92 -0
- sageql/core.py +47 -0
- sageql/discovery.py +188 -0
- sageql/errors.py +9 -0
- sageql/execution.py +139 -0
- sageql/localization.py +98 -0
- sageql/odbc_catalog.py +172 -0
- sageql/openai_provider.py +79 -0
- sageql/planning.py +415 -0
- sageql/report_validation.py +233 -0
- sageql/schema.py +86 -0
- sageql/sdk/__init__.py +36 -0
- sageql/sdk/adapters.py +709 -0
- sageql/sdk/agents.py +1000 -0
- sageql/sdk/business.py +158 -0
- sageql/sdk/calendars.py +133 -0
- sageql/sdk/comparisons.py +185 -0
- sageql/sdk/consistency.py +268 -0
- sageql/sdk/day_counts.py +88 -0
- sageql/sdk/decreases.py +88 -0
- sageql/sdk/engine.py +781 -0
- sageql/sdk/evidence.py +1098 -0
- sageql/sdk/grounding.py +568 -0
- sageql/sdk/intent.py +570 -0
- sageql/sdk/labels.py +77 -0
- sageql/sdk/lookups.py +130 -0
- sageql/sdk/models.py +492 -0
- sageql/sdk/names.py +10 -0
- sageql/sdk/periods.py +115 -0
- sageql/sdk/phrases.py +372 -0
- sageql/sdk/provider.py +835 -0
- sageql/sdk/relationships.py +238 -0
- sageql/sdk/saved.py +133 -0
- sageql/sdk/semantics.py +751 -0
- sageql/sdk/sessions.py +240 -0
- sageql/sdk/vocabulary.py +171 -0
- sageql/sql_generation.py +220 -0
- sageql/tsql.py +301 -0
- sageql/understanding.py +74 -0
- sageql/validation.py +50 -0
- sageql-0.2.1.dist-info/METADATA +415 -0
- sageql-0.2.1.dist-info/RECORD +51 -0
- sageql-0.2.1.dist-info/WHEEL +4 -0
- sageql-0.2.1.dist-info/entry_points.txt +2 -0
- sageql-0.2.1.dist-info/licenses/LICENSE +21 -0
sageql/__init__.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Public API for SageQL."""
|
|
2
|
+
|
|
3
|
+
from sageql.core import Candidate, GeneratedQuery, SQLProvider, generate_sql
|
|
4
|
+
from sageql.conversation import (
|
|
5
|
+
ChatConfig,
|
|
6
|
+
ChatError,
|
|
7
|
+
ChatProvider,
|
|
8
|
+
DatabaseConfig,
|
|
9
|
+
LLMConfig,
|
|
10
|
+
Message,
|
|
11
|
+
ReportConversation,
|
|
12
|
+
)
|
|
13
|
+
from sageql.context import ContextProvider, ContextResolution, ContextResolutionSession, ResolvedContext
|
|
14
|
+
from sageql.catalog_input import catalog_from_dict, load_catalog_file
|
|
15
|
+
from sageql.discovery import (
|
|
16
|
+
CatalogSource, DiscoveryCandidates, DiscoveryError, DiscoveryProvider,
|
|
17
|
+
DiscoverySelection, QuerySpace, discover_query_space,
|
|
18
|
+
)
|
|
19
|
+
from sageql.odbc_catalog import ODBCCatalogSource
|
|
20
|
+
from sageql.planning import (
|
|
21
|
+
PlanCandidates, PlanProposal, PlanQuality, PlanningError, PlanningProvider,
|
|
22
|
+
ProposedFilter, ProposedJoin, ProposedMeasure, QueryPlan, create_query_plan,
|
|
23
|
+
review_query_plan,
|
|
24
|
+
)
|
|
25
|
+
from sageql.schema import Column, Definition, Relation, SchemaCatalog, Table
|
|
26
|
+
from sageql.sql_generation import SQLGenerationError, SQLQuery, generate_sql_from_plan
|
|
27
|
+
from sageql.report_validation import (
|
|
28
|
+
PreparedQuery, QueryValidationError, RequiredFilter, ValidationResult,
|
|
29
|
+
prepare_report_query, validate_report_query,
|
|
30
|
+
)
|
|
31
|
+
from sageql.execution import QueryExecutionError, QueryResult, execute_sqlite_report
|
|
32
|
+
from sageql.errors import GenerationError, InvalidSQL
|
|
33
|
+
from sageql.validation import validate_sql
|
|
34
|
+
from sageql.tsql import (
|
|
35
|
+
TSQLQuery, TSQLResult, TSQLReportError, render_tsql_report,
|
|
36
|
+
validate_tsql_report, execute_tsql_report,
|
|
37
|
+
)
|
|
38
|
+
from sageql.understanding import RequestAssessment, RequestUnderstandingSession, UnderstandingProvider
|
|
39
|
+
from sageql.sdk import SageQL
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"Candidate",
|
|
43
|
+
"SageQL",
|
|
44
|
+
"ChatConfig",
|
|
45
|
+
"ChatError",
|
|
46
|
+
"ChatProvider",
|
|
47
|
+
"ContextProvider",
|
|
48
|
+
"ContextResolution",
|
|
49
|
+
"ContextResolutionSession",
|
|
50
|
+
"CatalogSource",
|
|
51
|
+
"catalog_from_dict",
|
|
52
|
+
"Column",
|
|
53
|
+
"DatabaseConfig",
|
|
54
|
+
"Definition",
|
|
55
|
+
"DiscoveryCandidates",
|
|
56
|
+
"DiscoveryError",
|
|
57
|
+
"DiscoveryProvider",
|
|
58
|
+
"DiscoverySelection",
|
|
59
|
+
"GeneratedQuery",
|
|
60
|
+
"GenerationError",
|
|
61
|
+
"InvalidSQL",
|
|
62
|
+
"LLMConfig",
|
|
63
|
+
"load_catalog_file",
|
|
64
|
+
"Message",
|
|
65
|
+
"ODBCCatalogSource",
|
|
66
|
+
"PlanCandidates",
|
|
67
|
+
"PlanProposal",
|
|
68
|
+
"PlanQuality",
|
|
69
|
+
"PlanningError",
|
|
70
|
+
"PlanningProvider",
|
|
71
|
+
"ProposedFilter",
|
|
72
|
+
"ProposedJoin",
|
|
73
|
+
"ProposedMeasure",
|
|
74
|
+
"QueryPlan",
|
|
75
|
+
"QueryExecutionError",
|
|
76
|
+
"QueryResult",
|
|
77
|
+
"QueryValidationError",
|
|
78
|
+
"QuerySpace",
|
|
79
|
+
"Relation",
|
|
80
|
+
"ReportConversation",
|
|
81
|
+
"ResolvedContext",
|
|
82
|
+
"RequestAssessment",
|
|
83
|
+
"RequestUnderstandingSession",
|
|
84
|
+
"RequiredFilter",
|
|
85
|
+
"SQLProvider",
|
|
86
|
+
"SQLGenerationError",
|
|
87
|
+
"SQLQuery",
|
|
88
|
+
"PreparedQuery",
|
|
89
|
+
"ValidationResult",
|
|
90
|
+
"SchemaCatalog",
|
|
91
|
+
"Table",
|
|
92
|
+
"UnderstandingProvider",
|
|
93
|
+
"generate_sql",
|
|
94
|
+
"generate_sql_from_plan",
|
|
95
|
+
"prepare_report_query",
|
|
96
|
+
"validate_report_query",
|
|
97
|
+
"execute_sqlite_report",
|
|
98
|
+
"discover_query_space",
|
|
99
|
+
"create_query_plan",
|
|
100
|
+
"review_query_plan",
|
|
101
|
+
"validate_sql",
|
|
102
|
+
"TSQLQuery",
|
|
103
|
+
"TSQLResult",
|
|
104
|
+
"TSQLReportError",
|
|
105
|
+
"render_tsql_report",
|
|
106
|
+
"validate_tsql_report",
|
|
107
|
+
"execute_tsql_report",
|
|
108
|
+
]
|
sageql/catalog_input.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Parse schema metadata supplied by the package user."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
from sageql.schema import Column, Definition, Relation, SchemaCatalog, Table
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_MAX_FILE_BYTES = 1_000_000
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _object(value: Any, required: set[str], optional: set[str], label: str) -> Mapping[str, Any]:
|
|
14
|
+
if not isinstance(value, dict) or not required <= value.keys() or value.keys() - required - optional:
|
|
15
|
+
raise ValueError(f"{label} has missing or unexpected fields")
|
|
16
|
+
return value
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _array(value: Any, label: str, *, maximum: int, nonempty: bool = False) -> list[Any]:
|
|
20
|
+
if not isinstance(value, list) or len(value) > maximum or (nonempty and not value):
|
|
21
|
+
raise ValueError(f"{label} must be a JSON array with 1 to {maximum} items" if nonempty
|
|
22
|
+
else f"{label} must be a JSON array with at most {maximum} items")
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _text(value: Any, label: str, *, allow_empty: bool = False) -> str:
|
|
27
|
+
if not isinstance(value, str) or len(value) > 2000 or (not allow_empty and not value.strip()):
|
|
28
|
+
raise ValueError(f"{label} must be text")
|
|
29
|
+
if any(ord(character) < 32 for character in value if character not in "\n\t"):
|
|
30
|
+
raise ValueError(f"{label} contains invalid control characters")
|
|
31
|
+
return value.strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _identifier(value: Any, label: str, *, allow_empty: bool = False) -> str:
|
|
35
|
+
result = _text(value, label, allow_empty=allow_empty)
|
|
36
|
+
if len(result) > 256 or "\n" in result or "\t" in result:
|
|
37
|
+
raise ValueError(f"{label} must be a single-line identifier")
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def catalog_from_dict(data: Mapping[str, Any]) -> SchemaCatalog:
|
|
42
|
+
"""Validate a JSON-shaped catalog with tables, columns, relations, and definitions."""
|
|
43
|
+
root = _object(data, {"tables"}, {"relations", "definitions"}, "catalog")
|
|
44
|
+
tables: list[Table] = []
|
|
45
|
+
columns: list[Column] = []
|
|
46
|
+
definitions: list[Definition] = []
|
|
47
|
+
for index, raw_table in enumerate(_array(root["tables"], "tables", maximum=200, nonempty=True)):
|
|
48
|
+
label = f"tables[{index}]"
|
|
49
|
+
item = _object(raw_table, {"name", "columns"}, {"schema", "kind", "description"}, label)
|
|
50
|
+
table = Table(
|
|
51
|
+
name=_identifier(item["name"], label + ".name"),
|
|
52
|
+
schema=_identifier(item.get("schema", ""), label + ".schema", allow_empty=True),
|
|
53
|
+
kind=_identifier(item.get("kind", "TABLE"), label + ".kind").upper(),
|
|
54
|
+
)
|
|
55
|
+
tables.append(table)
|
|
56
|
+
if "description" in item:
|
|
57
|
+
definitions.append(Definition(
|
|
58
|
+
table.key, _text(item["description"], label + ".description"),
|
|
59
|
+
"user table description",
|
|
60
|
+
))
|
|
61
|
+
for col_index, raw_column in enumerate(
|
|
62
|
+
_array(item["columns"], label + ".columns", maximum=500, nonempty=True)
|
|
63
|
+
):
|
|
64
|
+
col_label = f"{label}.columns[{col_index}]"
|
|
65
|
+
col = _object(raw_column, {"name"}, {"data_type", "nullable", "description"}, col_label)
|
|
66
|
+
nullable = col.get("nullable", True)
|
|
67
|
+
if type(nullable) is not bool:
|
|
68
|
+
raise ValueError(f"{col_label}.nullable must be a boolean")
|
|
69
|
+
column = Column(
|
|
70
|
+
table=table.key,
|
|
71
|
+
name=_identifier(col["name"], col_label + ".name"),
|
|
72
|
+
data_type=_identifier(col.get("data_type", "unknown"), col_label + ".data_type"),
|
|
73
|
+
nullable=nullable,
|
|
74
|
+
)
|
|
75
|
+
columns.append(column)
|
|
76
|
+
if "description" in col:
|
|
77
|
+
definitions.append(Definition(
|
|
78
|
+
column.key, _text(col["description"], col_label + ".description"),
|
|
79
|
+
"user column description",
|
|
80
|
+
))
|
|
81
|
+
if len(columns) > 5000:
|
|
82
|
+
raise ValueError("catalog has too many columns")
|
|
83
|
+
|
|
84
|
+
table_names = {table.key.casefold(): table.key for table in tables}
|
|
85
|
+
column_names = {(column.table.casefold(), column.name.casefold()): column.name
|
|
86
|
+
for column in columns}
|
|
87
|
+
relations: list[Relation] = []
|
|
88
|
+
for index, raw_relation in enumerate(_array(root.get("relations", []), "relations", maximum=500)):
|
|
89
|
+
label = f"relations[{index}]"
|
|
90
|
+
item = _object(raw_relation,
|
|
91
|
+
{"name", "child_table", "child_columns", "parent_table", "parent_columns"},
|
|
92
|
+
set(), label)
|
|
93
|
+
child_raw = _identifier(item["child_table"], label + ".child_table")
|
|
94
|
+
parent_raw = _identifier(item["parent_table"], label + ".parent_table")
|
|
95
|
+
child = table_names.get(child_raw.casefold(), child_raw)
|
|
96
|
+
parent = table_names.get(parent_raw.casefold(), parent_raw)
|
|
97
|
+
def relation_columns(field: str, table: str) -> tuple[str, ...]:
|
|
98
|
+
values = _array(item[field], label + "." + field, maximum=32, nonempty=True)
|
|
99
|
+
return tuple(
|
|
100
|
+
column_names.get((table.casefold(), name.casefold()), name)
|
|
101
|
+
for name in (_identifier(value, label + "." + field) for value in values)
|
|
102
|
+
)
|
|
103
|
+
relations.append(Relation(
|
|
104
|
+
name=_identifier(item["name"], label + ".name"),
|
|
105
|
+
child_table=child,
|
|
106
|
+
child_columns=relation_columns("child_columns", child),
|
|
107
|
+
parent_table=parent,
|
|
108
|
+
parent_columns=relation_columns("parent_columns", parent),
|
|
109
|
+
))
|
|
110
|
+
|
|
111
|
+
for index, raw_definition in enumerate(_array(root.get("definitions", []), "definitions", maximum=500)):
|
|
112
|
+
label = f"definitions[{index}]"
|
|
113
|
+
item = _object(raw_definition, {"term", "meaning"}, set(), label)
|
|
114
|
+
definitions.append(Definition(
|
|
115
|
+
_identifier(item["term"], label + ".term"),
|
|
116
|
+
_text(item["meaning"], label + ".meaning"),
|
|
117
|
+
"user glossary",
|
|
118
|
+
))
|
|
119
|
+
try:
|
|
120
|
+
return SchemaCatalog(tuple(tables), tuple(columns), tuple(relations), tuple(definitions))
|
|
121
|
+
except ValueError as exc:
|
|
122
|
+
raise ValueError(f"invalid catalog structure: {exc}") from exc
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def load_catalog_file(path: str | Path) -> SchemaCatalog:
|
|
126
|
+
"""Load a UTF-8 JSON catalog without echoing file contents in errors."""
|
|
127
|
+
try:
|
|
128
|
+
with Path(path).open("rb") as handle:
|
|
129
|
+
content = handle.read(_MAX_FILE_BYTES + 1)
|
|
130
|
+
except OSError as exc:
|
|
131
|
+
raise ValueError("could not read schema catalog file") from exc
|
|
132
|
+
if len(content) > _MAX_FILE_BYTES:
|
|
133
|
+
raise ValueError("schema catalog file is too large")
|
|
134
|
+
try:
|
|
135
|
+
data = json.loads(content.decode("utf-8"))
|
|
136
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
137
|
+
raise ValueError("schema catalog must be valid UTF-8 JSON") from exc
|
|
138
|
+
return catalog_from_dict(data)
|
sageql/chat_cli.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Interactive report-planning chat CLI."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import getpass
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from sageql.chat_provider import OpenAIChatProvider
|
|
10
|
+
from sageql.catalog_input import load_catalog_file
|
|
11
|
+
from sageql.conversation import ChatConfig, ChatError, DatabaseConfig, LLMConfig
|
|
12
|
+
from sageql.context import ContextResolutionSession, ResolvedContext
|
|
13
|
+
from sageql.discovery import DiscoveryError, QuerySpace, discover_query_space
|
|
14
|
+
from sageql.planning import PlanQuality, PlanningError, QueryPlan, create_query_plan, review_query_plan
|
|
15
|
+
from sageql.report_validation import QueryValidationError, validate_report_query
|
|
16
|
+
from sageql.sql_generation import SQLGenerationError, SQLQuery, generate_sql_from_plan
|
|
17
|
+
from sageql.execution import QueryExecutionError, execute_sqlite_report
|
|
18
|
+
from sageql.understanding import RequestUnderstandingSession
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _required_input(label: str) -> str:
|
|
22
|
+
while True:
|
|
23
|
+
value = input(label).strip()
|
|
24
|
+
if value:
|
|
25
|
+
return value
|
|
26
|
+
print("Please enter a value.", file=sys.stderr)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _configured_or_input(name: str, label: str) -> str:
|
|
30
|
+
return os.environ.get(name, "").strip() or _required_input(label)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _next_message() -> str:
|
|
34
|
+
return _required_input("You> ")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _print_context(context: ResolvedContext) -> None:
|
|
38
|
+
print("Resolved context:")
|
|
39
|
+
print(f" Time period: {context.time_period or 'Not specified'}")
|
|
40
|
+
print(f" Entities: {', '.join(context.entities) or 'Not specified'}")
|
|
41
|
+
print(f" Metrics: {', '.join(context.metrics) or 'Not specified'}")
|
|
42
|
+
print(f" Filters: {', '.join(context.filters) or 'Not specified'}")
|
|
43
|
+
print(f" Comparison period: {context.comparison_period or 'Not specified'}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _print_query_space(space: QuerySpace) -> None:
|
|
47
|
+
print("Query space discovery:")
|
|
48
|
+
print(" Tables/views:")
|
|
49
|
+
for table in space.tables:
|
|
50
|
+
print(f" {table.key} ({table.kind.lower()})")
|
|
51
|
+
print(" Columns:")
|
|
52
|
+
for column in space.columns:
|
|
53
|
+
print(f" {column.key} ({column.data_type})")
|
|
54
|
+
print(" Relations:")
|
|
55
|
+
for relation in space.relations:
|
|
56
|
+
child = ", ".join(f"{relation.child_table}.{item}" for item in relation.child_columns)
|
|
57
|
+
parent = ", ".join(f"{relation.parent_table}.{item}" for item in relation.parent_columns)
|
|
58
|
+
print(f" {child} -> {parent} [{relation.name}]")
|
|
59
|
+
print(" Definitions:")
|
|
60
|
+
for definition in space.definitions:
|
|
61
|
+
print(f" {definition.term}: {definition.meaning} ({definition.source})")
|
|
62
|
+
if not space.relations:
|
|
63
|
+
print(" No relevant supplied relations selected.")
|
|
64
|
+
if not space.definitions:
|
|
65
|
+
print(" No relevant definitions selected or available.")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _print_plan(plan: QueryPlan, quality: PlanQuality) -> None:
|
|
69
|
+
print("\nQuery plan (logical operations, no SQL):")
|
|
70
|
+
print(json.dumps(plan.operations(), indent=2, ensure_ascii=False))
|
|
71
|
+
print("Plan quality:")
|
|
72
|
+
for check in quality.checks_passed:
|
|
73
|
+
print(f" PASS: {check}")
|
|
74
|
+
for item in quality.review_items:
|
|
75
|
+
print(f" REVIEW: {item}")
|
|
76
|
+
print(" Execution: Not performed.")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _print_sql(query: SQLQuery) -> None:
|
|
80
|
+
print("\nGenerated SQLite SQL (review before execution):")
|
|
81
|
+
print(query.sql)
|
|
82
|
+
print("Parameters:", dict(query.parameters))
|
|
83
|
+
print("Required date bindings:", ", ".join(query.required_parameters) or "None")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _schema_paths(values: list[str]) -> dict[str, str]:
|
|
87
|
+
paths = {}
|
|
88
|
+
for value in values:
|
|
89
|
+
name, separator, path = value.partition("=")
|
|
90
|
+
if not separator or not name or not path or name in paths:
|
|
91
|
+
raise ValueError("--sqlite-schema must be a unique NAME=PATH entry")
|
|
92
|
+
paths[name] = path
|
|
93
|
+
return paths
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main(argv: list[str] | None = None) -> int:
|
|
97
|
+
parser = argparse.ArgumentParser(
|
|
98
|
+
prog="sageql chat", description="Discuss a report request with an AI assistant"
|
|
99
|
+
)
|
|
100
|
+
parser.add_argument("--no-discovery", action="store_true", help="stop after context resolution")
|
|
101
|
+
parser.add_argument("--no-planning", action="store_true", help="stop after query-space discovery")
|
|
102
|
+
parser.add_argument("--no-sql", action="store_true", help="stop after logical query planning")
|
|
103
|
+
parser.add_argument("--catalog", help="path to a user-supplied schema catalog JSON file")
|
|
104
|
+
parser.add_argument("--period-start", help="inclusive ISO date for the report period")
|
|
105
|
+
parser.add_argument("--period-end", help="exclusive ISO date for the report period")
|
|
106
|
+
parser.add_argument("--comparison-start", help="inclusive ISO date for the comparison period")
|
|
107
|
+
parser.add_argument("--comparison-end", help="exclusive ISO date for the comparison period")
|
|
108
|
+
parser.add_argument("--execute-sqlite", metavar="PATH", help="execute on this SQLite file read-only")
|
|
109
|
+
parser.add_argument("--sqlite-schema", action="append", default=[], metavar="NAME=PATH",
|
|
110
|
+
help="attach a named SQLite schema file read-only; repeat as needed")
|
|
111
|
+
parser.add_argument("--max-rows", type=int, default=50, help="maximum rows returned when executing")
|
|
112
|
+
args = parser.parse_args(argv)
|
|
113
|
+
if args.execute_sqlite and (args.no_discovery or args.no_planning or args.no_sql):
|
|
114
|
+
parser.error("--execute-sqlite requires discovery, planning, and SQL generation")
|
|
115
|
+
if bool(args.period_start) != bool(args.period_end):
|
|
116
|
+
parser.error("provide both --period-start and --period-end")
|
|
117
|
+
if bool(args.comparison_start) != bool(args.comparison_end):
|
|
118
|
+
parser.error("provide both --comparison-start and --comparison-end")
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
first_question = _required_input("What report would you like to create? ")
|
|
122
|
+
print("\nDatabase configuration")
|
|
123
|
+
database = DatabaseConfig(
|
|
124
|
+
server_host=_configured_or_input("DB_SERVER_HOST", "Server / Host: "),
|
|
125
|
+
database=_configured_or_input("DB_NAME", "Database: "),
|
|
126
|
+
authentication=_configured_or_input("DB_AUTHENTICATION", "Authentication method: "),
|
|
127
|
+
odbc_driver=_configured_or_input("DB_ODBC_DRIVER", "ODBC Driver: "),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
print("\nLLM configuration")
|
|
131
|
+
api_key = (
|
|
132
|
+
os.environ.get("LLM_API_KEY")
|
|
133
|
+
or os.environ.get("OPENAI_API_KEY")
|
|
134
|
+
or getpass.getpass("API Key: ")
|
|
135
|
+
)
|
|
136
|
+
base_url = os.environ.get("LLM_BASE_URL", "").strip()
|
|
137
|
+
model = os.environ.get("LLM_MODEL", "").strip()
|
|
138
|
+
if not base_url:
|
|
139
|
+
base_url = input("Base URL [https://api.openai.com/v1]: ").strip()
|
|
140
|
+
if not model:
|
|
141
|
+
model = input("Model [gpt-5-nano]: ").strip()
|
|
142
|
+
llm = LLMConfig(
|
|
143
|
+
api_key=api_key,
|
|
144
|
+
base_url=base_url or "https://api.openai.com/v1",
|
|
145
|
+
model=model or "gpt-5-nano",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
config = ChatConfig(database=database, llm=llm)
|
|
149
|
+
provider = OpenAIChatProvider(llm)
|
|
150
|
+
conversation = RequestUnderstandingSession(config, provider)
|
|
151
|
+
print("\nRequest understanding started. Type /exit to finish.\n")
|
|
152
|
+
pending = first_question
|
|
153
|
+
while True:
|
|
154
|
+
try:
|
|
155
|
+
assessment = conversation.submit(pending)
|
|
156
|
+
if assessment.enough_information:
|
|
157
|
+
print(f"Request understanding:\n{assessment.request_understanding.strip()}")
|
|
158
|
+
break
|
|
159
|
+
print(f"AI> {assessment.clarification_question.strip()}\n")
|
|
160
|
+
except ChatError as exc:
|
|
161
|
+
print(f"sageql: {exc}", file=sys.stderr)
|
|
162
|
+
|
|
163
|
+
pending = _next_message()
|
|
164
|
+
if pending.lower() in {"/exit", "/quit"}:
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
context_session = ContextResolutionSession(
|
|
168
|
+
config, provider, conversation.history, assessment.request_understanding
|
|
169
|
+
)
|
|
170
|
+
resolution = context_session.start()
|
|
171
|
+
while not resolution.ready:
|
|
172
|
+
print(f"AI> {resolution.clarification_question.strip()}\n")
|
|
173
|
+
answer = _next_message()
|
|
174
|
+
if answer.lower() in {"/exit", "/quit"}:
|
|
175
|
+
return 0
|
|
176
|
+
resolution = context_session.submit(answer)
|
|
177
|
+
_print_context(resolution.context)
|
|
178
|
+
if not args.no_discovery:
|
|
179
|
+
catalog_path = args.catalog or os.getenv("SCHEMA_CATALOG_PATH", "")
|
|
180
|
+
if not catalog_path:
|
|
181
|
+
catalog_path = _required_input("Schema catalog JSON path: ")
|
|
182
|
+
catalog = load_catalog_file(catalog_path)
|
|
183
|
+
print("\nDiscovering query space from supplied schema...")
|
|
184
|
+
space = discover_query_space(catalog, provider, assessment.request_understanding,
|
|
185
|
+
resolution.context)
|
|
186
|
+
_print_query_space(space)
|
|
187
|
+
if not args.no_planning:
|
|
188
|
+
plan = create_query_plan(
|
|
189
|
+
assessment.request_understanding, resolution.context, space, provider
|
|
190
|
+
)
|
|
191
|
+
_print_plan(plan, review_query_plan(plan, resolution.context, space))
|
|
192
|
+
if not args.no_sql:
|
|
193
|
+
period_bounds = ((args.period_start, args.period_end)
|
|
194
|
+
if args.period_start else None)
|
|
195
|
+
comparison_bounds = ((args.comparison_start, args.comparison_end)
|
|
196
|
+
if args.comparison_start else None)
|
|
197
|
+
query = generate_sql_from_plan(
|
|
198
|
+
plan, period_bounds=period_bounds, comparison_bounds=comparison_bounds
|
|
199
|
+
)
|
|
200
|
+
_print_sql(query)
|
|
201
|
+
validation = validate_report_query(query, plan, resolution.context, space)
|
|
202
|
+
print("Validation:")
|
|
203
|
+
for check in validation.checks_passed:
|
|
204
|
+
print(f" PASS: {check}")
|
|
205
|
+
for issue in validation.issues:
|
|
206
|
+
print(f" BLOCK: {issue}")
|
|
207
|
+
if args.execute_sqlite:
|
|
208
|
+
if not validation.valid:
|
|
209
|
+
raise QueryValidationError("query must pass validation before execution")
|
|
210
|
+
result = execute_sqlite_report(
|
|
211
|
+
args.execute_sqlite, query, plan, resolution.context, space,
|
|
212
|
+
attached_schemas=_schema_paths(args.sqlite_schema),
|
|
213
|
+
max_rows=args.max_rows,
|
|
214
|
+
)
|
|
215
|
+
print("Read-only SQLite result:")
|
|
216
|
+
print(" Columns:", result.columns)
|
|
217
|
+
for row in result.rows:
|
|
218
|
+
print(" Row:", row)
|
|
219
|
+
print(" Truncated:", result.truncated)
|
|
220
|
+
else:
|
|
221
|
+
print("Execution: Not performed.")
|
|
222
|
+
return 0
|
|
223
|
+
except (EOFError, KeyboardInterrupt):
|
|
224
|
+
print("\nChat ended.")
|
|
225
|
+
return 0
|
|
226
|
+
except (ValueError, ChatError, DiscoveryError, PlanningError, SQLGenerationError,
|
|
227
|
+
QueryValidationError, QueryExecutionError) as exc:
|
|
228
|
+
print(f"sageql: {exc}", file=sys.stderr)
|
|
229
|
+
return 1
|