querymind-cli 0.1.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.
- app/agents/InterpreterAgent.py +473 -0
- app/agents/__init__.py +0 -0
- app/agents/insights_generator.py +151 -0
- app/agents/intent_corrector.py +59 -0
- app/agents/llm_intepreter.py +132 -0
- app/agents/narrator.py +27 -0
- app/agents/planner.py +77 -0
- app/cli/__init__.py +0 -0
- app/cli/main.py +346 -0
- app/cli/tui_app.py +98 -0
- app/cli/ui.py +21 -0
- app/core/__init__.py +0 -0
- app/core/context.py +10 -0
- app/core/logger.py +2 -0
- app/core/pipeline.py +379 -0
- app/data/__init__.py +0 -0
- app/data/connectors/csv_connector.py +99 -0
- app/data/connectors/excel_connector.py +68 -0
- app/data/connectors/no_sql_db_connector.py +0 -0
- app/data/connectors/sql_db_connector.py +0 -0
- app/data/schema_engine.py +18 -0
- app/data/type_caster.py +128 -0
- app/executor/__init__.py +0 -0
- app/executor/db_executor.py +0 -0
- app/executor/sheet_selector.py +120 -0
- app/llm/ollama_client.py +47 -0
- app/prompts/interpreter_prompt.txt +28 -0
- app/security/__init__.py +0 -0
- app/security/input_guard.py +133 -0
- app/security/schema_filter.py +20 -0
- app/tests/__init__.py +0 -0
- app/tests/llm_test.py +18 -0
- app/tools/__init__.py +0 -0
- app/tools/analyzer.py +157 -0
- app/tools/join_resolver.py +159 -0
- app/tools/sql_writer.py +37 -0
- app/tools/validator.py +0 -0
- querymind_cli-0.1.0.dist-info/METADATA +139 -0
- querymind_cli-0.1.0.dist-info/RECORD +43 -0
- querymind_cli-0.1.0.dist-info/WHEEL +5 -0
- querymind_cli-0.1.0.dist-info/entry_points.txt +2 -0
- querymind_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- querymind_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from app.llm.ollama_client import OllamaClient
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LLMInterpreter:
|
|
7
|
+
"""
|
|
8
|
+
Falls back to an LLM when the rule-based interpreter has low confidence.
|
|
9
|
+
|
|
10
|
+
Converts a natural-language query into the same structured intent dict
|
|
11
|
+
that InterpreterAgent produces, so the rest of the pipeline is unaware
|
|
12
|
+
of which interpreter was used.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.client = OllamaClient()
|
|
17
|
+
|
|
18
|
+
# ------------------------------------------------------------------
|
|
19
|
+
# Prompt
|
|
20
|
+
# ------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
def build_prompt(self, query: str, columns: list, semantic_map: dict) -> str:
|
|
23
|
+
metric = semantic_map.get("metric", "")
|
|
24
|
+
dimension = semantic_map.get("dimension", "")
|
|
25
|
+
time_col = semantic_map.get("time", "")
|
|
26
|
+
|
|
27
|
+
numeric_cols = [
|
|
28
|
+
c
|
|
29
|
+
for c in columns
|
|
30
|
+
if any(
|
|
31
|
+
hint in c
|
|
32
|
+
for hint in (
|
|
33
|
+
"amount",
|
|
34
|
+
"price",
|
|
35
|
+
"spent",
|
|
36
|
+
"revenue",
|
|
37
|
+
"sales",
|
|
38
|
+
"total",
|
|
39
|
+
"count",
|
|
40
|
+
"qty",
|
|
41
|
+
"quantity",
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
]
|
|
45
|
+
categorical_cols = [c for c in columns if c not in numeric_cols]
|
|
46
|
+
|
|
47
|
+
return f"""You are a data analyst assistant.
|
|
48
|
+
|
|
49
|
+
Convert the user query into a JSON intent object. Use ONLY column names from the list below.
|
|
50
|
+
|
|
51
|
+
Dataset columns : {columns}
|
|
52
|
+
Numeric columns : {numeric_cols or "unknown β pick best match"}
|
|
53
|
+
Categorical cols : {categorical_cols or "unknown β pick best match"}
|
|
54
|
+
Default metric : {metric}
|
|
55
|
+
Default dimension: {dimension}
|
|
56
|
+
Time column : {time_col or "none"}
|
|
57
|
+
|
|
58
|
+
User query: "{query}"
|
|
59
|
+
|
|
60
|
+
Return ONLY valid JSON β no explanation, no markdown fences.
|
|
61
|
+
|
|
62
|
+
{{
|
|
63
|
+
"metric": "<column_name>",
|
|
64
|
+
"dimension": "<column_name>",
|
|
65
|
+
"operation": "sum | mean | count",
|
|
66
|
+
"query_type": "comparison | aggregation | trend | top_n",
|
|
67
|
+
"limit": <number or null>
|
|
68
|
+
}}
|
|
69
|
+
|
|
70
|
+
Rules
|
|
71
|
+
- metric must be a numeric column
|
|
72
|
+
- dimension must be a categorical or time column
|
|
73
|
+
- "highest" / "most" / "compare" β query_type = "comparison"
|
|
74
|
+
- "top N" / "bottom N" β query_type = "top_n", limit = N (default 5)
|
|
75
|
+
- "average" / "avg" / "mean" β query_type = "aggregation", operation = "mean"
|
|
76
|
+
- "trend" / "over time" / "monthly" β query_type = "trend", dimension = time column
|
|
77
|
+
- anything else β query_type = "aggregation", operation = "sum"
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
# Helpers
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def _parse(self, text: str) -> dict | None:
|
|
85
|
+
try:
|
|
86
|
+
start = text.find("{")
|
|
87
|
+
end = text.rfind("}") + 1
|
|
88
|
+
if start == -1 or end == 0:
|
|
89
|
+
return None
|
|
90
|
+
return json.loads(text[start:end])
|
|
91
|
+
except Exception:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def _valid(self, intent: dict, columns: list) -> bool:
|
|
95
|
+
if not intent:
|
|
96
|
+
return False
|
|
97
|
+
if intent.get("metric") not in columns:
|
|
98
|
+
return False
|
|
99
|
+
if intent.get("dimension") not in columns:
|
|
100
|
+
return False
|
|
101
|
+
if intent.get("query_type") not in (
|
|
102
|
+
"comparison",
|
|
103
|
+
"aggregation",
|
|
104
|
+
"trend",
|
|
105
|
+
"top_n",
|
|
106
|
+
):
|
|
107
|
+
return False
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# Main
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def run(self, context: dict) -> dict:
|
|
115
|
+
query = context["user_query"]
|
|
116
|
+
schema = context["schema"]["columns"]
|
|
117
|
+
semantic_map = context.get("semantic_map", {})
|
|
118
|
+
columns = [col["name"] for col in schema]
|
|
119
|
+
|
|
120
|
+
prompt = self.build_prompt(query, columns, semantic_map)
|
|
121
|
+
response = self.client.generate(prompt)
|
|
122
|
+
intent = self._parse(response)
|
|
123
|
+
|
|
124
|
+
if not self._valid(intent, columns):
|
|
125
|
+
context["error"] = (
|
|
126
|
+
f"LLM returned an invalid intent. Raw response: {response[:200]}"
|
|
127
|
+
)
|
|
128
|
+
return context
|
|
129
|
+
|
|
130
|
+
context["intent"] = intent
|
|
131
|
+
context["llm_used"] = True
|
|
132
|
+
return context
|
app/agents/narrator.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
class Narrator:
|
|
2
|
+
"""
|
|
3
|
+
Final layer: formats and cleans the output before sending to UI
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
def run(self, context):
|
|
7
|
+
answer = context.get("answer")
|
|
8
|
+
|
|
9
|
+
if not answer:
|
|
10
|
+
return context
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
# Clean duplicate emojis
|
|
14
|
+
answer = answer.replace("π‘ π‘", "π‘")
|
|
15
|
+
|
|
16
|
+
# Ensure spacing
|
|
17
|
+
answer = answer.strip()
|
|
18
|
+
|
|
19
|
+
# Add fallback formatting if plain text
|
|
20
|
+
if not any(x in answer for x in ["π‘", "π", "π"]):
|
|
21
|
+
answer = f"π‘ {answer}"
|
|
22
|
+
|
|
23
|
+
context["answer"] = answer
|
|
24
|
+
return context
|
|
25
|
+
|
|
26
|
+
except Exception:
|
|
27
|
+
return context
|
app/agents/planner.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PlannerAgent β lightweight pre-flight check.
|
|
3
|
+
|
|
4
|
+
NOTE: This agent is currently unused by the pipeline (InterpreterAgent
|
|
5
|
+
handles intent extraction). Keep it here for future multi-step query
|
|
6
|
+
planning (e.g. chaining two analyses together, or deciding whether a
|
|
7
|
+
query needs a trend + comparison combined answer).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PlannerAgent:
|
|
12
|
+
"""
|
|
13
|
+
Validates that the semantic_map is properly configured and that the
|
|
14
|
+
query contains at least one actionable keyword before the pipeline runs.
|
|
15
|
+
|
|
16
|
+
Returns an error only for genuinely unactionable input (empty, numeric-only).
|
|
17
|
+
For low-confidence / keyword-free natural-language queries the pipeline
|
|
18
|
+
should route to the LLMInterpreter instead of hard-failing here.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
VALID_KEYWORDS = {
|
|
22
|
+
"highest",
|
|
23
|
+
"lowest",
|
|
24
|
+
"top",
|
|
25
|
+
"bottom",
|
|
26
|
+
"average",
|
|
27
|
+
"avg",
|
|
28
|
+
"mean",
|
|
29
|
+
"trend",
|
|
30
|
+
"over time",
|
|
31
|
+
"monthly",
|
|
32
|
+
"daily",
|
|
33
|
+
"total",
|
|
34
|
+
"sum",
|
|
35
|
+
"most",
|
|
36
|
+
"least",
|
|
37
|
+
"distribution",
|
|
38
|
+
"breakdown",
|
|
39
|
+
"compare",
|
|
40
|
+
"how much",
|
|
41
|
+
"how many",
|
|
42
|
+
"which",
|
|
43
|
+
"where",
|
|
44
|
+
"what",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def run(self, context: dict) -> dict:
|
|
48
|
+
query = context.get("user_query", "").lower().strip()
|
|
49
|
+
semantic_map = context.get("semantic_map")
|
|
50
|
+
|
|
51
|
+
# --- Validate semantic map ---
|
|
52
|
+
if not semantic_map:
|
|
53
|
+
context["error"] = (
|
|
54
|
+
"Semantic map missing. Please restart and configure your dataset."
|
|
55
|
+
)
|
|
56
|
+
return context
|
|
57
|
+
|
|
58
|
+
metric = semantic_map.get("metric")
|
|
59
|
+
dimension = semantic_map.get("dimension")
|
|
60
|
+
|
|
61
|
+
if not metric or not dimension:
|
|
62
|
+
context["error"] = (
|
|
63
|
+
"Metric or dimension not configured. "
|
|
64
|
+
"Please restart and select valid columns."
|
|
65
|
+
)
|
|
66
|
+
return context
|
|
67
|
+
|
|
68
|
+
# --- Guard obviously bad input ---
|
|
69
|
+
if not query or query.isdigit():
|
|
70
|
+
context["error"] = "Please enter a meaningful question."
|
|
71
|
+
return context
|
|
72
|
+
|
|
73
|
+
# --- Route decision (informational, does NOT block) ---
|
|
74
|
+
has_keyword = any(kw in query for kw in self.VALID_KEYWORDS)
|
|
75
|
+
context["planner_has_keyword"] = has_keyword
|
|
76
|
+
|
|
77
|
+
return context
|
app/cli/__init__.py
ADDED
|
File without changes
|
app/cli/main.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.prompt import Prompt
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from app.core.pipeline import QueryMindPipeline
|
|
10
|
+
from app.cli.tui_app import QueryMindApp
|
|
11
|
+
from app.data.connectors.csv_connector import CSVConnector
|
|
12
|
+
from app.data.connectors.excel_connector import ExcelConnector
|
|
13
|
+
from app.executor.sheet_selector import prompt_sheet_selection
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
EXCEL_EXTS = {".xlsx", ".xls", ".xlsm", ".xlsb"}
|
|
18
|
+
CSV_EXTS = {".csv", ".tsv"}
|
|
19
|
+
|
|
20
|
+
# Words that mean "I want to quit" at any prompt
|
|
21
|
+
EXIT_WORDS = {"exit", "quit", "/exit", "/quit", "bye", "q", ":q", "bye", "/bye"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
25
|
+
# CLEAN EXIT SIGNAL
|
|
26
|
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UserExitError(Exception):
|
|
30
|
+
"""Raised when the user types an exit command at any prompt."""
|
|
31
|
+
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def ask(message: str, default: str = None) -> str:
|
|
36
|
+
"""
|
|
37
|
+
Wrapper around Prompt.ask that:
|
|
38
|
+
- Checks for exit words before returning
|
|
39
|
+
- Raises UserExitError so the caller doesn't need any special logic
|
|
40
|
+
- Handles KeyboardInterrupt (Ctrl+C) as a clean exit too
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
value = (
|
|
44
|
+
Prompt.ask(message, default=default)
|
|
45
|
+
if default is not None
|
|
46
|
+
else Prompt.ask(message)
|
|
47
|
+
)
|
|
48
|
+
except (KeyboardInterrupt, EOFError):
|
|
49
|
+
raise UserExitError()
|
|
50
|
+
|
|
51
|
+
if value.strip().lower() in EXIT_WORDS:
|
|
52
|
+
raise UserExitError()
|
|
53
|
+
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
58
|
+
# HELPERS
|
|
59
|
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def normalize_column(col: str) -> str:
|
|
63
|
+
return col.lower().strip().replace(" ", "_")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_column(input_col: str, columns: list) -> str | None:
|
|
67
|
+
col = normalize_column(input_col)
|
|
68
|
+
if col not in columns:
|
|
69
|
+
console.print(f"[red]β '{col}' not found. Choose from the list above.[/red]")
|
|
70
|
+
return None
|
|
71
|
+
return col
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def prompt_column(message: str, columns: list, optional: bool = False) -> str | None:
|
|
75
|
+
"""Prompt for a column name, with exit-word detection on every attempt."""
|
|
76
|
+
while True:
|
|
77
|
+
value = (
|
|
78
|
+
ask(f"[cyan]{message}[/cyan]", default="")
|
|
79
|
+
if optional
|
|
80
|
+
else ask(f"[cyan]{message}[/cyan]")
|
|
81
|
+
)
|
|
82
|
+
if optional and value.strip() == "":
|
|
83
|
+
return None
|
|
84
|
+
validated = validate_column(value, columns)
|
|
85
|
+
if validated:
|
|
86
|
+
return validated
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def detect_column_types(df: pd.DataFrame) -> tuple:
|
|
90
|
+
"""Returns (numeric_cols, categorical_cols, datetime_cols)."""
|
|
91
|
+
numeric = df.select_dtypes(include="number").columns.tolist()
|
|
92
|
+
obj_cols = df.select_dtypes(exclude="number").columns.tolist()
|
|
93
|
+
datetime_cols, categorical = [], []
|
|
94
|
+
for col in obj_cols:
|
|
95
|
+
if col == "_sheet":
|
|
96
|
+
continue
|
|
97
|
+
col_data = df[col]
|
|
98
|
+
if isinstance(col_data, pd.DataFrame):
|
|
99
|
+
col_data = col_data.iloc[:, 0] # duplicate col name β take first
|
|
100
|
+
sample = col_data.dropna().head(20)
|
|
101
|
+
try:
|
|
102
|
+
pd.to_datetime(sample, infer_datetime_format=True)
|
|
103
|
+
datetime_cols.append(col)
|
|
104
|
+
except Exception:
|
|
105
|
+
categorical.append(col)
|
|
106
|
+
return numeric, categorical, datetime_cols
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def show_columns(df: pd.DataFrame, numeric: list, categorical: list, datetime: list):
|
|
110
|
+
table = Table(title="Detected Columns", border_style="blue", show_lines=False)
|
|
111
|
+
table.add_column("Column", style="bold white")
|
|
112
|
+
table.add_column("Type", style="dim")
|
|
113
|
+
table.add_column("Sample values", style="dim")
|
|
114
|
+
|
|
115
|
+
for col in df.columns:
|
|
116
|
+
if col == "_sheet":
|
|
117
|
+
continue
|
|
118
|
+
if col in numeric:
|
|
119
|
+
col_type = "[green]numeric[/green]"
|
|
120
|
+
elif col in datetime:
|
|
121
|
+
col_type = "[magenta]datetime[/magenta]"
|
|
122
|
+
else:
|
|
123
|
+
col_type = "[yellow]categorical[/yellow]"
|
|
124
|
+
try:
|
|
125
|
+
col_data = df[col]
|
|
126
|
+
# Duplicate column names β df[col] returns DataFrame not Series
|
|
127
|
+
if isinstance(col_data, pd.DataFrame):
|
|
128
|
+
col_data = col_data.iloc[:, 0]
|
|
129
|
+
sample = col_data.dropna().head(3).tolist()
|
|
130
|
+
sample_str = ", ".join(str(v) for v in sample)
|
|
131
|
+
except Exception:
|
|
132
|
+
sample_str = "(error reading samples)"
|
|
133
|
+
table.add_row(col, col_type, sample_str)
|
|
134
|
+
|
|
135
|
+
console.print(table)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
139
|
+
# FILE LOADING
|
|
140
|
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def load_file(file_path: str) -> tuple:
|
|
144
|
+
"""
|
|
145
|
+
Returns (connector, preview_df).
|
|
146
|
+
Raises RuntimeError for bad files, UserExitError if user quits mid-flow.
|
|
147
|
+
"""
|
|
148
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
149
|
+
|
|
150
|
+
if ext in EXCEL_EXTS:
|
|
151
|
+
selected_sheets = prompt_sheet_selection(
|
|
152
|
+
file_path
|
|
153
|
+
) # exit-aware (see sheet_selector.py)
|
|
154
|
+
if not selected_sheets:
|
|
155
|
+
raise RuntimeError("No sheets selected.")
|
|
156
|
+
|
|
157
|
+
connector = ExcelConnector(file_path, selected_sheets)
|
|
158
|
+
|
|
159
|
+
frames = []
|
|
160
|
+
xl = pd.ExcelFile(file_path)
|
|
161
|
+
for s in selected_sheets:
|
|
162
|
+
df = xl.parse(s, nrows=100)
|
|
163
|
+
df.columns = [normalize_column(c) for c in df.columns]
|
|
164
|
+
df["_sheet"] = s
|
|
165
|
+
frames.append(df)
|
|
166
|
+
preview_df = pd.concat(frames, ignore_index=True, sort=False)
|
|
167
|
+
|
|
168
|
+
real_cols = [c for c in preview_df.columns if c != "_sheet"]
|
|
169
|
+
if len(real_cols) < 2:
|
|
170
|
+
col_name = real_cols[0] if real_cols else "none"
|
|
171
|
+
raise RuntimeError(
|
|
172
|
+
f"The selected sheet(s) only have 1 column ('{col_name}'). "
|
|
173
|
+
f"QueryMind needs at least one metric column and one dimension column. "
|
|
174
|
+
f"Please select sheets with 2 or more columns."
|
|
175
|
+
)
|
|
176
|
+
return connector, preview_df
|
|
177
|
+
|
|
178
|
+
elif ext in CSV_EXTS:
|
|
179
|
+
from app.data.connectors.csv_connector import (
|
|
180
|
+
_detect_encoding,
|
|
181
|
+
_detect_delimiter,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
connector = CSVConnector(file_path)
|
|
185
|
+
encoding = _detect_encoding(file_path)
|
|
186
|
+
delimiter = _detect_delimiter(file_path, encoding)
|
|
187
|
+
try:
|
|
188
|
+
preview_df = pd.read_csv(
|
|
189
|
+
file_path,
|
|
190
|
+
encoding=encoding,
|
|
191
|
+
sep=delimiter,
|
|
192
|
+
nrows=100,
|
|
193
|
+
on_bad_lines="warn",
|
|
194
|
+
)
|
|
195
|
+
except pd.errors.EmptyDataError:
|
|
196
|
+
raise RuntimeError(
|
|
197
|
+
f"'{file_path}' is completely empty. "
|
|
198
|
+
f"Please provide a file with headers and at least one row of data."
|
|
199
|
+
)
|
|
200
|
+
if preview_df.empty:
|
|
201
|
+
raise RuntimeError(
|
|
202
|
+
f"'{file_path}' contains only headers and no data rows. "
|
|
203
|
+
f"Please provide a file with at least one row of data."
|
|
204
|
+
)
|
|
205
|
+
preview_df.columns = [normalize_column(c) for c in preview_df.columns]
|
|
206
|
+
# Warn about duplicate column names after normalization
|
|
207
|
+
dupes = [
|
|
208
|
+
c for c in preview_df.columns if preview_df.columns.tolist().count(c) > 1
|
|
209
|
+
]
|
|
210
|
+
if dupes:
|
|
211
|
+
unique_dupes = list(dict.fromkeys(dupes)) # preserve order, deduplicate
|
|
212
|
+
console.print(
|
|
213
|
+
f"[yellow]β οΈ Duplicate column names detected after normalization: "
|
|
214
|
+
f"{unique_dupes}. Only the first occurrence of each will be used.[/yellow]"
|
|
215
|
+
)
|
|
216
|
+
preview_df = preview_df.loc[:, ~preview_df.columns.duplicated()]
|
|
217
|
+
if len(preview_df.columns) < 2:
|
|
218
|
+
raise RuntimeError(
|
|
219
|
+
f"'{file_path}' only has 1 column ('{preview_df.columns[0]}'). "
|
|
220
|
+
f"QueryMind needs at least one metric column and one dimension column. "
|
|
221
|
+
f"Please provide a file with 2 or more columns."
|
|
222
|
+
)
|
|
223
|
+
return connector, preview_df
|
|
224
|
+
|
|
225
|
+
else:
|
|
226
|
+
raise RuntimeError(
|
|
227
|
+
f"Unsupported file type: '{ext}'. "
|
|
228
|
+
f"Supported: {sorted(EXCEL_EXTS | CSV_EXTS)}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
233
|
+
# MAIN
|
|
234
|
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def main():
|
|
238
|
+
console.print(
|
|
239
|
+
Panel.fit(
|
|
240
|
+
"[bold cyan]π§ QueryMind[/bold cyan]\n[green]CLI AI Data Analyst[/green]",
|
|
241
|
+
border_style="blue",
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
console.print(
|
|
245
|
+
"[dim] Type [bold]exit[/bold] or [bold]quit[/bold] at any prompt to leave.[/dim]\n"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
# ββ File input ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
250
|
+
while True:
|
|
251
|
+
file_path = ask(
|
|
252
|
+
"\n[cyan]π Enter file path[/cyan] [dim](.csv, .xlsx, .xls)[/dim]"
|
|
253
|
+
)
|
|
254
|
+
try:
|
|
255
|
+
connector, preview_df = load_file(file_path)
|
|
256
|
+
rows = len(preview_df)
|
|
257
|
+
console.print(
|
|
258
|
+
f"[green]β
Loaded {rows:,} preview rows Γ "
|
|
259
|
+
f"{len([c for c in preview_df.columns if c != '_sheet'])} columns[/green]"
|
|
260
|
+
)
|
|
261
|
+
break
|
|
262
|
+
except UserExitError:
|
|
263
|
+
raise # bubble up β sheet selector raised it mid-flow
|
|
264
|
+
except RuntimeError as e:
|
|
265
|
+
console.print(f"[red]β {e}[/red]")
|
|
266
|
+
console.print(
|
|
267
|
+
"[yellow]Please try again or type 'exit' to quit.[/yellow]"
|
|
268
|
+
)
|
|
269
|
+
except Exception as e:
|
|
270
|
+
console.print(f"[red]β Failed to load: {e}[/red]")
|
|
271
|
+
console.print(
|
|
272
|
+
"[yellow]Please try again or type 'exit' to quit.[/yellow]"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
columns_all = [c for c in preview_df.columns if c != "_sheet"]
|
|
276
|
+
numeric_cols, categorical_cols, datetime_cols = detect_column_types(preview_df)
|
|
277
|
+
|
|
278
|
+
# ββ Show column overview ββββββββββββββββββββββββββββββββββββββββββ
|
|
279
|
+
console.print()
|
|
280
|
+
show_columns(preview_df, numeric_cols, categorical_cols, datetime_cols)
|
|
281
|
+
|
|
282
|
+
# ββ Semantic mapping ββββββββββββββββββββββββββββββββββββββββββββββ
|
|
283
|
+
console.print("\n[bold cyan]π§ Help me understand your data[/bold cyan]")
|
|
284
|
+
console.print("[dim]Use column names exactly as shown above.[/dim]\n")
|
|
285
|
+
|
|
286
|
+
default_metric = numeric_cols[0] if numeric_cols else ""
|
|
287
|
+
default_dimension = categorical_cols[0] if categorical_cols else ""
|
|
288
|
+
default_time = datetime_cols[0] if datetime_cols else ""
|
|
289
|
+
|
|
290
|
+
if default_metric:
|
|
291
|
+
console.print(f"[dim] Suggested metric β {default_metric}[/dim]")
|
|
292
|
+
if default_dimension:
|
|
293
|
+
console.print(f"[dim] Suggested dimension β {default_dimension}[/dim]")
|
|
294
|
+
if default_time:
|
|
295
|
+
console.print(f"[dim] Suggested time col β {default_time}[/dim]")
|
|
296
|
+
console.print()
|
|
297
|
+
|
|
298
|
+
metric = prompt_column(
|
|
299
|
+
"π Which column is the main VALUE to measure? (metric)", columns_all
|
|
300
|
+
)
|
|
301
|
+
dimension = prompt_column(
|
|
302
|
+
"π Which column to GROUP BY by default? (dimension)", columns_all
|
|
303
|
+
)
|
|
304
|
+
time_col = prompt_column(
|
|
305
|
+
"π Time column for trend queries? (optional β press Enter to skip)",
|
|
306
|
+
columns_all,
|
|
307
|
+
optional=True,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
semantic_map = {"metric": metric, "dimension": dimension, "time": time_col}
|
|
311
|
+
|
|
312
|
+
console.print(
|
|
313
|
+
f"\n[green]β
Semantic map:[/green] "
|
|
314
|
+
f"metric=[bold]{metric}[/bold] "
|
|
315
|
+
f"dimension=[bold]{dimension}[/bold] "
|
|
316
|
+
f"time=[bold]{time_col or 'none'}[/bold]"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# ββ Build pipeline ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
320
|
+
console.print("\n[dim]Loading data and building pipelineβ¦[/dim]")
|
|
321
|
+
try:
|
|
322
|
+
pipeline = QueryMindPipeline(connector, semantic_map)
|
|
323
|
+
except RuntimeError as e:
|
|
324
|
+
console.print(f"[red]β Pipeline failed to start: {e}[/red]")
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
console.print("[green]β
Launching QueryMind UIβ¦[/green]\n")
|
|
328
|
+
|
|
329
|
+
app = QueryMindApp(pipeline)
|
|
330
|
+
app.run()
|
|
331
|
+
|
|
332
|
+
except UserExitError:
|
|
333
|
+
pass # fall through to goodbye message
|
|
334
|
+
|
|
335
|
+
os.system("cls" if os.name == "nt" else "clear")
|
|
336
|
+
console.print(
|
|
337
|
+
Panel.fit(
|
|
338
|
+
"[bold cyan]π Goodbye![/bold cyan]\n"
|
|
339
|
+
"[dim]Thanks for using QueryMind.[/dim]",
|
|
340
|
+
border_style="blue",
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
if __name__ == "__main__":
|
|
346
|
+
main()
|
app/cli/tui_app.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from textual.app import App, ComposeResult
|
|
2
|
+
from textual.widgets import Header, Footer, Input, Static
|
|
3
|
+
from textual.containers import Horizontal, Vertical
|
|
4
|
+
from textual.reactive import reactive
|
|
5
|
+
|
|
6
|
+
from app.core.pipeline import QueryMindPipeline
|
|
7
|
+
from app.core.context import Context
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class QueryMindApp(App):
|
|
11
|
+
CSS = """
|
|
12
|
+
#top {
|
|
13
|
+
height: 10;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
#chat {
|
|
17
|
+
border: round green;
|
|
18
|
+
padding: 1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#input {
|
|
22
|
+
dock: bottom;
|
|
23
|
+
}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
BINDINGS = [
|
|
27
|
+
("q", "quit", "Quit"),
|
|
28
|
+
("ctrl+c", "quit", "Quit"),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
def __init__(self, pipeline: QueryMindPipeline):
|
|
32
|
+
super().__init__()
|
|
33
|
+
self.pipeline = pipeline
|
|
34
|
+
self.chat_history = "π§ QueryMind Ready\n"
|
|
35
|
+
|
|
36
|
+
# Show active sheet in system info if available
|
|
37
|
+
active = getattr(pipeline, "_base_context", {}).get("active_sheet", "")
|
|
38
|
+
self._active_sheet = active
|
|
39
|
+
|
|
40
|
+
def compose(self) -> ComposeResult:
|
|
41
|
+
yield Header()
|
|
42
|
+
|
|
43
|
+
with Horizontal(id="top"):
|
|
44
|
+
yield Static(self._get_banner(), id="banner")
|
|
45
|
+
yield Static(self._get_system_info(), id="system")
|
|
46
|
+
|
|
47
|
+
self.chat = Static(self.chat_history, id="chat")
|
|
48
|
+
yield self.chat
|
|
49
|
+
|
|
50
|
+
self.input = Input(placeholder="Ask a question about your data...", id="input")
|
|
51
|
+
yield self.input
|
|
52
|
+
|
|
53
|
+
yield Footer()
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------ #
|
|
56
|
+
|
|
57
|
+
def _get_banner(self) -> str:
|
|
58
|
+
return " π§ QueryMind\n AI Data Analyst\n"
|
|
59
|
+
|
|
60
|
+
def _get_system_info(self) -> str:
|
|
61
|
+
sheet_line = f"Sheet : {self._active_sheet}\n" if self._active_sheet else ""
|
|
62
|
+
llm_status = (
|
|
63
|
+
"LLM : β
Ollama (phi)"
|
|
64
|
+
if getattr(self.pipeline, "llm_available", False)
|
|
65
|
+
else "LLM : β οΈ Offline (rule-based only)"
|
|
66
|
+
)
|
|
67
|
+
return f"Agent : QueryMind\nMode : Local Analysis\n{llm_status}\n{sheet_line}"
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------ #
|
|
70
|
+
|
|
71
|
+
async def on_input_submitted(self, event: Input.Submitted):
|
|
72
|
+
query = event.value.strip()
|
|
73
|
+
|
|
74
|
+
if not query:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
if query.lower() in ("exit", "quit", "/bye", "bye", "/c"):
|
|
78
|
+
self.exit()
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
self.chat_history += f"\n>> {query}"
|
|
82
|
+
|
|
83
|
+
context = Context(query)
|
|
84
|
+
result = self.pipeline.run(context)
|
|
85
|
+
|
|
86
|
+
if result.get("error"):
|
|
87
|
+
response = f"β {result['error']}"
|
|
88
|
+
else:
|
|
89
|
+
response = result.get("answer", "No answer generated.")
|
|
90
|
+
|
|
91
|
+
# Show which sheet the answer came from (useful in multi-sheet mode)
|
|
92
|
+
active = result.get("active_sheet", "")
|
|
93
|
+
if active and "+" in active:
|
|
94
|
+
response = f"[{active}]\n{response}"
|
|
95
|
+
|
|
96
|
+
self.chat_history += f"\nπ‘ {response}\n"
|
|
97
|
+
self.chat.update(self.chat_history)
|
|
98
|
+
self.input.value = ""
|