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.
Files changed (43) hide show
  1. app/agents/InterpreterAgent.py +473 -0
  2. app/agents/__init__.py +0 -0
  3. app/agents/insights_generator.py +151 -0
  4. app/agents/intent_corrector.py +59 -0
  5. app/agents/llm_intepreter.py +132 -0
  6. app/agents/narrator.py +27 -0
  7. app/agents/planner.py +77 -0
  8. app/cli/__init__.py +0 -0
  9. app/cli/main.py +346 -0
  10. app/cli/tui_app.py +98 -0
  11. app/cli/ui.py +21 -0
  12. app/core/__init__.py +0 -0
  13. app/core/context.py +10 -0
  14. app/core/logger.py +2 -0
  15. app/core/pipeline.py +379 -0
  16. app/data/__init__.py +0 -0
  17. app/data/connectors/csv_connector.py +99 -0
  18. app/data/connectors/excel_connector.py +68 -0
  19. app/data/connectors/no_sql_db_connector.py +0 -0
  20. app/data/connectors/sql_db_connector.py +0 -0
  21. app/data/schema_engine.py +18 -0
  22. app/data/type_caster.py +128 -0
  23. app/executor/__init__.py +0 -0
  24. app/executor/db_executor.py +0 -0
  25. app/executor/sheet_selector.py +120 -0
  26. app/llm/ollama_client.py +47 -0
  27. app/prompts/interpreter_prompt.txt +28 -0
  28. app/security/__init__.py +0 -0
  29. app/security/input_guard.py +133 -0
  30. app/security/schema_filter.py +20 -0
  31. app/tests/__init__.py +0 -0
  32. app/tests/llm_test.py +18 -0
  33. app/tools/__init__.py +0 -0
  34. app/tools/analyzer.py +157 -0
  35. app/tools/join_resolver.py +159 -0
  36. app/tools/sql_writer.py +37 -0
  37. app/tools/validator.py +0 -0
  38. querymind_cli-0.1.0.dist-info/METADATA +139 -0
  39. querymind_cli-0.1.0.dist-info/RECORD +43 -0
  40. querymind_cli-0.1.0.dist-info/WHEEL +5 -0
  41. querymind_cli-0.1.0.dist-info/entry_points.txt +2 -0
  42. querymind_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  43. 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 = ""